返回 DeepSeek-Reasonix
fleet.go
根目录 / internal / agent / fleet.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "strings"
11 "sync"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/evidence"
16 "reasonix/internal/jobs"
17 "reasonix/internal/tool"
18 )
19
20 const (
21 fleetMinTasks = 2
22 fleetMaxTasks = 64
23 )
24
25 // FleetTool dispatches multiple profile-aware sub-agent tasks in parallel
26 // under the session scheduler. Write tasks must predeclare non-overlapping
27 // write_paths; preflight failure starts nothing.
28 type FleetTool struct {
29 taskTool *TaskTool
30 }
31
32 // NewFleetTool creates a fleet dispatcher that reuses TaskTool infrastructure.
33 func NewFleetTool(taskTool *TaskTool) *FleetTool {
34 return &FleetTool{taskTool: taskTool}
35 }
36
37 func (*FleetTool) Name() string { return tool.HostFleet }
38
39 func (*FleetTool) Description() string {
40 return "Dispatch 2–64 sub-agent tasks as a small dependency graph and return bounded previews plus stable Subagent references for full-result retrieval from completed persisted children with read_subagent_result. Each item may select a profile, model, effort, tools, write_paths, or read_only, and may declare depends_on to run after other items (research → implement → review). Tasks with no dependency between them run in parallel and must declare non-overlapping write_paths; ordered tasks may share paths. Omitted write_paths claim the whole workspace, so two or more concurrent writers without paths fail preflight before any task starts. A failed task's dependents are skipped; independent branches keep going unless fail_fast is set. Background mode returns a fleet job id collectable with job_output."
41 }
42
43 func (*FleetTool) Schema() json.RawMessage {
44 return json.RawMessage(`{
45 "type":"object",
46 "properties":{
47 "tasks":{
48 "type":"array",
49 "description":"Array of 2–64 sub-tasks to run under the session scheduler.",
50 "minItems":2,
51 "maxItems":64,
52 "items":{
53 "type":"object",
54 "properties":{
55 "prompt":{"type":"string","description":"Task prompt for the sub-agent."},
56 "id":{"type":"string","description":"Optional stable id for this task, referenced by other tasks' depends_on. Defaults to the 1-based position."},
57 "depends_on":{"type":"array","items":{"type":"string"},"description":"Ids of tasks that must complete before this one starts. Unknown ids, self-edges, and cycles fail preflight. A task whose dependency fails or is skipped is skipped too. Ordered tasks may share write_paths; only tasks that can run at the same time need disjoint claims."},
58 "description":{"type":"string","description":"Optional short label shown in the job list."},
59 "profile":{"type":"string","description":"Optional runAs=subagent profile name."},
60 "write_paths":{"type":"array","items":{"type":"string"},"description":"Write targets for this item. Writers that can run at the same time must declare non-overlapping paths; writers ordered by depends_on may share them. Omitting write_paths claims the whole workspace; two concurrent whole-workspace claims (or any overlap between concurrent writers) fail preflight and start nothing."},
61 "read_only":{"type":"boolean","description":"Force the read-only registry even if the profile is writable."},
62 "tools":{"type":"array","items":{"type":"string"},"description":"Optional tool whitelist (intersected with profile allowed-tools)."},
63 "max_steps":{"type":"integer","description":"Optional max tool-call rounds.","minimum":1},
64 "model":{"type":"string","description":"Optional model override."},
65 "effort":{"type":"string","description":"Optional reasoning effort override."}
66 },
67 "required":["prompt"]
68 }
69 },
70 "fail_fast":{"type":"boolean","description":"Stop starting new tasks after the first failure. Tasks already running are left to finish so partial writes are not abandoned mid-flight. Omitted (the default) means independent branches keep going; a failed task's dependents are skipped either way."},
71 "run_in_background":{"type":"boolean","description":"Run the whole fleet asynchronously and return a job id collectable with job_output. Items queue for concurrency/write slots inside the job."}
72 },
73 "required":["tasks"]
74 }`)
75 }
76
77 func (*FleetTool) ReadOnly() bool { return false }
78
79 type fleetTaskItem struct {
80 Prompt string `json:"prompt"`
81 ID string `json:"id"`
82 DependsOn []string `json:"depends_on"`
83 Description string `json:"description"`
84 Profile string `json:"profile"`
85 WritePaths []string `json:"write_paths"`
86 ReadOnly bool `json:"read_only"`
87 Tools []string `json:"tools"`
88 MaxSteps int `json:"max_steps"`
89 Model string `json:"model"`
90 Effort string `json:"effort"`
91 }
92
93 type fleetItemStatus string
94
95 const (
96 fleetItemPending fleetItemStatus = "pending"
97 fleetItemCompleted fleetItemStatus = "completed"
98 fleetItemFailed fleetItemStatus = "failed"
99 fleetItemCancelled fleetItemStatus = "cancelled"
100 fleetItemSkipped fleetItemStatus = "skipped"
101 )
102
103 type fleetItemResult struct {
104 index int
105 status fleetItemStatus
106 profile string
107 output string
108 err error
109 ref string
110 }
111
112 // fleetGroupTerminalPhase classifies a fleet group's single terminal status:
113 // cancellation/deadline wins, then any failed child, then any error
114 // (including validation failures), then completed.
115 func fleetGroupTerminalPhase(ctx context.Context, err error, results []fleetItemResult) subagentProgressPhase {
116 if ctx.Err() != nil {
117 return subagentPhaseCancelled
118 }
119 for _, r := range results {
120 if r.status == fleetItemFailed {
121 return subagentPhaseFailed
122 }
123 }
124 if err != nil {
125 return subagentPhaseFailed
126 }
127 return subagentPhaseCompleted
128 }
129
130 func (f *FleetTool) Execute(ctx context.Context, args json.RawMessage) (result string, err error) {
131 if f == nil || f.taskTool == nil {
132 return "", fmt.Errorf("fleet is not configured")
133 }
134 // Group lifecycle: the group card's terminal is an explicit event from
135 // the tool (running once children start, exactly one terminal at the
136 // end) so frontends never infer group completion from the children they
137 // happen to have observed. Validation failures emit a failed terminal;
138 // once runFleet starts it owns the lifecycle (the background job runs
139 // runFleet inside the job, after this function has returned).
140 groupParentID, groupSink, _, ok := CallContext(ctx)
141 if !ok || groupSink == nil {
142 groupParentID = "fleet"
143 groupSink = event.Discard
144 }
145 // The merger emits already-namespaced group/child IDs, so it must use the
146 // raw call sink. A nested subSink would prefix the group ID a second time
147 // (group/group), leaving the frontend unable to match its lifecycle card.
148 merger := newSubagentProgressMerger(realProgressClock{}, groupSink, groupParentID)
149 lifecycleHandoff := false
150 mergerCloseHandoff := false
151 defer func() {
152 if !mergerCloseHandoff {
153 merger.Close()
154 }
155 }()
156 defer func() {
157 if lifecycleHandoff {
158 return
159 }
160 merger.directStatus(groupParentID, fleetGroupTerminalPhase(ctx, err, nil))
161 }()
162 ctx = withSubagentProgressMerger(ctx, merger)
163
164 var params struct {
165 Tasks []fleetTaskItem `json:"tasks"`
166 FailFast bool `json:"fail_fast"`
167 RunInBackground bool `json:"run_in_background"`
168 }
169 dec := json.NewDecoder(bytes.NewReader(args))
170 dec.DisallowUnknownFields()
171 if err := dec.Decode(&params); err != nil {
172 return "", fmt.Errorf("invalid args: %w", err)
173 }
174 if n := len(params.Tasks); n < fleetMinTasks || n > fleetMaxTasks {
175 return "", fmt.Errorf("fleet requires between %d and %d tasks (got %d)", fleetMinTasks, fleetMaxTasks, n)
176 }
177
178 specs := make([]ProfileExecSpec, len(params.Tasks))
179 // Keep one claim slot per original task so preflight errors report the
180 // caller-visible task numbers even when read-only items are interleaved.
181 claims := make([]WritePathSet, len(params.Tasks))
182 for i, item := range params.Tasks {
183 if strings.TrimSpace(item.Prompt) == "" {
184 return "", fmt.Errorf("task %d: prompt is required", i+1)
185 }
186 // Fleet writers without write_paths claim the whole workspace so the
187 // preflight can detect multi-writer collisions before anything starts.
188 forceBackgroundClaim := !item.ReadOnly
189 spec, err := f.taskTool.buildTaskSpec(ctx, item.Prompt, item.Description, item.Profile, item.WritePaths, item.Tools, item.MaxSteps, item.Model, item.Effort, "", "", false, item.ReadOnly)
190 if err != nil {
191 return "", fmt.Errorf("task %d: %w", i+1, err)
192 }
193 if forceBackgroundClaim && !spec.Grant.ReadOnly && spec.Grant.WritePaths.Empty() {
194 whole, werr := WholeWorkspaceWriteClaim(f.taskTool.workspaceRoot)
195 if werr != nil {
196 return "", fmt.Errorf("task %d: %w", i+1, werr)
197 }
198 spec.Grant.WritePaths = whole
199 }
200 spec.Sched.Nested = SubagentDepth(ctx) > 0
201 spec.Sched.RunInBackground = false // fleet owns backgrounding
202 if spec.Task.Description == "" {
203 spec.Task.Description = fmt.Sprintf("fleet-%d", i+1)
204 }
205 specs[i] = spec
206 if !spec.Grant.ReadOnly {
207 claims[i] = spec.Grant.WritePaths
208 }
209 }
210 plan, err := newFleetPlan(params.Tasks, params.FailFast)
211 if err != nil {
212 return "", fmt.Errorf("fleet preflight: %w", err)
213 }
214 if err := plan.validateConcurrentWriteClaims(claims); err != nil {
215 return "", fmt.Errorf("fleet preflight: %w", err)
216 }
217
218 if params.RunInBackground {
219 for i := range specs {
220 specs[i].Sched.BackgroundWriter = !specs[i].Grant.ReadOnly
221 }
222 jm, ok := jobs.FromContext(ctx)
223 if !ok {
224 return "", fmt.Errorf("background execution is not available in this context")
225 }
226 parentID := groupParentID
227 parentSession := ParentSession(ctx)
228 label := fmt.Sprintf("fleet(%d)", len(specs))
229 backgroundEvidence := evidence.NewLedger()
230 writerID := fmt.Sprintf("background-fleet:%s:%d", parentID, time.Now().UnixNano())
231 writerRegistered := false
232 observer := f.taskTool.mutationObserver
233 if observer != nil {
234 hasWriter := false
235 for i := range specs {
236 if specs[i].Sched.BackgroundWriter {
237 hasWriter = true
238 break
239 }
240 }
241 if hasWriter {
242 if err := observer.RegisterWriter(writerID, "background_fleet", observer.OwnershipTurn()); err != nil {
243 return "", err
244 }
245 writerRegistered = true
246 }
247 }
248 job := jm.StartForSession(jobs.SessionFromContext(ctx), "fleet", label, func(jobCtx context.Context, _ io.Writer) (string, error) {
249 // Execute returns as soon as the job is registered, so the job owns
250 // the handed-off merger until every child preview and terminal has
251 // flushed. Closing it in Execute would strand child cards at running.
252 defer merger.Close()
253 if writerRegistered {
254 defer observer.UnregisterWriter(writerID)
255 }
256 jobCtx = WithParentSession(jobCtx, parentSession)
257 jobCtx = evidence.WithLedger(jobCtx, backgroundEvidence)
258 defer publishBackgroundEvidence(jobCtx, backgroundEvidence, f.taskTool.workspaceRoot)
259 // The job shares the Execute-level merger so the group lifecycle
260 // events and the child previews ride the same pacing budget.
261 jobCtx = withSubagentProgressMerger(jobCtx, merger)
262 return f.runFleet(jobCtx, groupSink, specs, plan, parentID)
263 })
264 // runFleet (inside the job) owns the terminal and merger close from
265 // here on. Foreground runFleet hands off only the terminal; Execute
266 // still closes the merger after the synchronous call returns.
267 lifecycleHandoff = true
268 mergerCloseHandoff = true
269 return fmt.Sprintf("Started background fleet %q (%s). Collect results with job_output; you will be notified when it finishes.", job.ID, label), nil
270 }
271
272 lifecycleHandoff = true
273 return f.runFleet(ctx, groupSink, specs, plan, groupParentID)
274 }
275
276 func (f *FleetTool) runFleet(ctx context.Context, sink event.Sink, specs []ProfileExecSpec, plan fleetPlan, groupParentID string) (result string, err error) {
277 if sink == nil {
278 sink = event.Discard
279 }
280 // Child IDs are namespaced exactly once under the group call. Background
281 // jobs no longer carry the original call context, so groupParentID is the
282 // authoritative identity there; direct callers fall back to CallContext.
283 parentID := strings.TrimSpace(groupParentID)
284 if parentID == "" {
285 var ok bool
286 parentID, _, _, ok = CallContext(ctx)
287 if !ok || parentID == "" {
288 parentID = "fleet"
289 }
290 }
291 groupParentID = parentID
292 // The Execute-level merger (or a fallback for direct callers) paces the
293 // group; runFleet owns the lifecycle once it starts: running up front
294 // and exactly one terminal after every child settles.
295 merger := subagentProgressMergerFromContext(ctx)
296 ownsMerger := false
297 if merger == nil {
298 merger = newSubagentProgressMerger(realProgressClock{}, sink, groupParentID)
299 ownsMerger = true
300 ctx = withSubagentProgressMerger(ctx, merger)
301 }
302 if ownsMerger {
303 defer merger.Close()
304 }
305 merger.directStatus(groupParentID, subagentPhaseRunning)
306 var results []fleetItemResult
307 defer func() {
308 merger.directStatus(groupParentID, fleetGroupTerminalPhase(ctx, err, results))
309 }()
310
311 n := len(specs)
312 results = make([]fleetItemResult, n)
313 for i := range results {
314 results[i] = fleetItemResult{index: i, status: fleetItemPending, profile: specs[i].Worker.Profile}
315 }
316
317 var wg sync.WaitGroup
318 doneCh := make(chan fleetItemResult, n)
319
320 startOne := func(idx int) {
321 spec := specs[idx]
322 label := spec.Task.Description
323 subID := fmt.Sprintf("%s/fleet-%d", parentID, idx+1)
324 dispatchArgs, _ := json.Marshal(map[string]any{
325 "prompt": spec.Task.Objective,
326 "description": label,
327 "profile": spec.Worker.Profile,
328 })
329 sink.Emit(event.Event{
330 Kind: event.ToolDispatch,
331 Tool: event.Tool{
332 ID: subID, ParentID: parentID, Name: "task",
333 Args: string(dispatchArgs), ReadOnly: spec.Grant.ReadOnly,
334 },
335 })
336
337 wg.Go(func() {
338 // Each fleet item runs as its own task-shaped execution so
339 // transcripts, evidence, and scheduler claims stay independent.
340 itemCtx := withCallContext(ctx, subID, subSinkFor(subID, sink), nil, false)
341 out, err := f.taskTool.RunProfileSpec(itemCtx, spec)
342 answer, ref := splitSubagentRunResult(out)
343 res := fleetItemResult{index: idx, profile: spec.Worker.Profile, output: answer, ref: ref, err: err}
344 if err == nil {
345 res.status = fleetItemCompleted
346 sink.Emit(event.Event{
347 Kind: event.ToolResult,
348 Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Output: out},
349 })
350 } else {
351 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
352 res.status = fleetItemCancelled
353 } else {
354 res.status = fleetItemFailed
355 }
356 sink.Emit(event.Event{
357 Kind: event.ToolResult,
358 Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Err: err.Error()},
359 })
360 }
361 doneCh <- res
362 })
363 }
364
365 cancelled := driveFleet(ctx, plan, results, doneCh, wg.Wait, startOne)
366 for _, r := range results {
367 if r.status == fleetItemCancelled || r.status == fleetItemSkipped {
368 cancelled = true
369 break
370 }
371 }
372 if cancelled {
373 err := ctx.Err()
374 if err == nil {
375 err = context.Canceled
376 }
377 return formatFleetAggregate(results, true), err
378 }
379 return formatFleetAggregate(results, false), nil
380 }
381
382 func formatFleetAggregate(results []fleetItemResult, cancelled bool) string {
383 n := len(results)
384 var prefix string
385 if cancelled {
386 completed := 0
387 for _, r := range results {
388 if r.status == fleetItemCompleted {
389 completed++
390 }
391 }
392 prefix = fmt.Sprintf("Cancelled fleet after completing %d of %d tasks:\n", completed, n)
393 } else {
394 prefix = fmt.Sprintf("Completed fleet of %d tasks:\n", n)
395 }
396 items := make([]subagentAggregateItem, 0, n)
397 for i, r := range results {
398 header := fmt.Sprintf("── task-%d", i+1)
399 if r.profile != "" {
400 header += " profile=" + boundedInline(r.profile, 80)
401 }
402 header += " ──\n"
403 item := subagentAggregateItem{header: header, ref: r.ref}
404 switch r.status {
405 case fleetItemCompleted:
406 item.status = "status: completed\n"
407 item.answer = strings.TrimSpace(r.output)
408 case fleetItemFailed:
409 item.status = "status: failed\n"
410 if r.err != nil {
411 item.detail = fmt.Sprintf("[FAILED] %s\n", boundedInline(r.err.Error(), 256))
412 }
413 case fleetItemCancelled:
414 item.status = "status: cancelled\n"
415 if r.err != nil {
416 item.detail = fmt.Sprintf("[CANCELLED] %s\n", boundedInline(r.err.Error(), 256))
417 }
418 case fleetItemSkipped:
419 item.status = "status: skipped\n"
420 if r.err != nil {
421 item.detail = fmt.Sprintf("[SKIPPED] %s\n", boundedInline(r.err.Error(), 256))
422 }
423 default:
424 item.status = "status: pending\n"
425 }
426 items = append(items, item)
427 }
428 return formatBoundedSubagentAggregate(prefix, items)
429 }
430
430 lines GO