Files
Cyrene_v2/internal/runtime/orchestration/scheduler.go
T
AskaEth de1fa83b79 feat(orchestration,loop,flow): 接线子代理编排 — 派发入口 + 分支归父会话 + 节点日志(含脱敏)
修的是「文档说 ✅、生产不可达」:此前 Scheduler.Spawn 只有测试调用方、无 bus 入口、无派发工具;Span.Record 无生产调用方 → 泳道画不出、节点日志恒空。
- 入口:subagent_spawn 工具(internal/runtime/orchestration/spawntool.go,同包便于用真 Scheduler/loop/Tracer/Flow 做集成测试;注册在 cmd/cyrene/subagent_tool.go 照 terminal_tool 范式)。默认 owner-only,不设 AllowInGroup/AllowInStranger;安全仍由 restrictSchemas + applyToolSecurityGate 双层保证,未绕代审
- 父会话归属:AgentSpec.ParentSession(请求语义)→ Scheduler 转运行期 ctx 标记 loop.WithFlowBranch → 分支 span 以父 session 发布;**allow 谓词一行未改**;单图语义靠「分支 Run 不调 StartRound/EndRound」+ FlowPublisher 不新建图;未归属 spawn 一张图都不发(有测试锁)
- 前台最多等 2s(对齐 task.autoBackgroundThreshold)→ 合并结果作工具结果;转后台则广播 bus subagent.result(design/05 §9 回填 hook);新增 Scheduler.Wait(ids) 按批等待(Join 会串并发批次,故不重用)
- 子代理跑在 WithoutCancel(ctx):保留 span 归属、不被工具超时/回合结束顺手杀掉;生命周期由 PerAgentTimeout + CancelAll 约束;内部 WithMutedBroadcast 静音(不进前台 stream)
- 节点日志:loop/spanlog.go 新增 llm.done / tool.done / subagent.done;工具参数**只记键名不记值**;仅步骤真正结束才追加(跳过/被拒路径不发,不伪造)
- 脱敏(红线):新增 trace/redact.go(形状规则 sk-/Bearer/JWT/key:value + 键名规则 api_key/token/authorization/password…,递归嵌套、不改调用方事件),graph.go 发布前必经;**顺带修掉一个既有泄漏**:result_summary prop 原先未脱敏(工具结果里的密钥会进图),由图级变异测试抓出
- 测试:新增 22 个(工具级 / 图级 / 日志级),含 4 次变异验证(去掉 WithFlowBranch、去掉 record*、还原未脱敏、去掉静音 各自 FAIL);-race 过 orchestration+trace+loop
- 已知限制:真实模型端到端未验(无凭据/不起服务);工具白名单是 design gap 未做;subagent.result 暂无消费者(design/15 未实现);后台子代理跨轮次边界迟到 span 会丢泳道根;PerAgentTimeout=10min 待确认
2026-10-03 19:39:38 +08:00

491 lines
16 KiB
Go

package orchestration
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// runner executes one sub-agent's loop. A *loop.Loop satisfies it; tests inject a
// mock so the scheduler can be exercised without a model backend.
type runner interface {
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// Scheduler creates and runs sub-agents in the background, bounding concurrency,
// applying a per-agent timeout and publishing progress over the agent-to-agent
// bus. It is safe for concurrent use.
//
// The lifecycle is: Spawn registers a sub-agent and launches it asynchronously;
// the sub-agent's goroutine acquires a parallel slot (gated by MaxParallel),
// transitions to running, runs the injected loop template in its isolated
// context under the per-agent timeout, writes a summary and publishes a terminal
// progress event. Abort/CancelAll interrupt running or queued sub-agents. Join
// blocks until every sub-agent is terminal and returns the merged outcome.
type Scheduler struct {
maxParallel int
perAgentTimeout time.Duration
runner runner
bus bus.Bus
comm *comm.Probe
memory *memory.Store
tracer *trace.Tracer
flow FlowSpanSink
approver *audit.Approver
log *slog.Logger
agentID string
mu sync.Mutex
agents map[string]*SubAgent
active int
allDone chan struct{}
allOne sync.Once
sem chan struct{}
seq atomic.Uint64
prefix string
}
// noopRunner is the fallback runner used when no *loop.Loop template was
// configured. It makes every sub-agent fail fast with ErrNoRunner rather than
// silently doing nothing.
type noopRunner struct{}
// Run returns a wrapped ErrNoRunner.
func (noopRunner) Run(context.Context, string, []model.Message) (*loop.Result, error) {
return nil, fmt.Errorf("orchestration: run sub-agent: %w", ErrNoRunner)
}
// New builds a Scheduler from opts, applying defaults. MaxParallel non-positive
// selects DefaultMaxParallel; a nil Loop is replaced by a no-op runner; a nil
// Comm is built from Bus (when present) using AgentID (default "orchestrator"); a
// nil Logger is replaced by a discard logger tagged with source "orchestration".
func New(opts Options) *Scheduler {
maxParallel := opts.MaxParallel
if maxParallel <= 0 {
maxParallel = DefaultMaxParallel
}
agentID := opts.AgentID
if agentID == "" {
agentID = defaultAgentID
}
s := &Scheduler{
maxParallel: maxParallel,
perAgentTimeout: opts.PerAgentTimeout,
runner: wrapLoop(opts.Loop),
bus: opts.Bus,
memory: opts.Memory,
tracer: opts.Tracer,
flow: opts.Flow,
approver: opts.Approver,
log: sourcedLog(opts.Logger),
agentID: agentID,
agents: make(map[string]*SubAgent),
allDone: make(chan struct{}),
sem: make(chan struct{}, maxParallel),
prefix: randPrefix(),
}
if opts.Comm != nil {
s.comm = opts.Comm
} else if opts.Bus != nil {
s.comm = comm.New(opts.Bus, agentID)
}
return s
}
// Bus returns the injected event bus (may be nil).
func (s *Scheduler) Bus() bus.Bus { return s.bus }
// Comm returns the injected (or auto-built) comm probe (may be nil).
func (s *Scheduler) Comm() *comm.Probe { return s.comm }
// Spawn creates a sub-agent from spec, registers it, publishes a "dispatched"
// progress event and launches its execution in the background. It returns
// immediately so the front session is not blocked; the sub-agent runs
// asynchronously and reports progress over the bus. It returns ErrMissingGoal
// when spec.Goal is blank.
//
// The sub-agent's Context is a deep copy of spec.Context and its WorkingMemory is
// a fresh, isolated window, so it never shares state with the front session.
func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error) {
if strings.TrimSpace(spec.Goal) == "" {
return nil, fmt.Errorf("orchestration: spawn: %w", ErrMissingGoal)
}
if ctx == nil {
ctx = context.Background()
}
sub := &SubAgent{
ID: s.nextID(),
Goal: spec.Goal,
RoleID: spec.RoleID,
Mode: spec.Mode,
AttachInfo: spec.AttachInfo,
ParentSession: spec.ParentSession,
Data: spec.Data,
Context: cloneMessages(spec.Context),
WorkingMemory: memory.NewWorkingMemory(0),
status: StatusPending,
done: make(chan struct{}),
finishedCh: make(chan struct{}),
}
s.mu.Lock()
if s.active == 0 {
s.allDone = make(chan struct{})
s.allOne = sync.Once{}
}
s.active++
s.agents[sub.ID] = sub
s.mu.Unlock()
s.publish(EventDispatched, sub.progress())
s.launch(ctx, sub)
return sub, nil
}
// Run explicitly starts every registered sub-agent that has not started yet. It
// is idempotent and is provided for callers that prefer to defer execution; Spawn
// already auto-launches, so this is normally a no-op on freshly spawned batches.
func (s *Scheduler) Run(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
subs := make([]*SubAgent, 0, len(s.agents))
for _, sub := range s.agents {
subs = append(subs, sub)
}
s.mu.Unlock()
for _, sub := range subs {
s.launch(ctx, sub)
}
}
// Join blocks until every spawned sub-agent is terminal (or ctx is canceled) and
// returns the merged outcome. On ctx cancellation it returns the partial merged
// outcome together with the wrapped ctx error.
func (s *Scheduler) Join(ctx context.Context) (*MergedResult, error) {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
ch := s.allDone
s.mu.Unlock()
select {
case <-ch:
return s.merge(), nil
case <-ctx.Done():
return s.merge(), ctx.Err()
}
}
// Abort cancels the sub-agent with the given id. If it is running its loop is
// interrupted; if it is queued it is released without running; if it already
// finished the call is a no-op. It returns ErrAgentNotFound for an unknown id.
func (s *Scheduler) Abort(ctx context.Context, id string) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
sub, ok := s.agents[id]
s.mu.Unlock()
if !ok {
return fmt.Errorf("orchestration: abort: %w", ErrAgentNotFound)
}
sub.cancelRun()
return nil
}
// CancelAll cancels every spawned sub-agent, queued or running. It is the
// anti-runaway escape hatch: after it returns each sub-agent converges to the
// aborted state.
func (s *Scheduler) CancelAll(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
subs := make([]*SubAgent, 0, len(s.agents))
for _, sub := range s.agents {
subs = append(subs, sub)
}
s.mu.Unlock()
for _, sub := range subs {
sub.cancelRun()
}
return nil
}
// Agents returns a snapshot of every registered sub-agent in spawn order. It is
// safe to read the returned snapshots at any time.
func (s *Scheduler) Agents() []SubAgentResult {
s.mu.Lock()
subs := make([]*SubAgent, 0, len(s.agents))
for _, sub := range s.agents {
subs = append(subs, sub)
}
s.mu.Unlock()
out := make([]SubAgentResult, 0, len(subs))
for _, sub := range subs {
out = append(out, sub.Snapshot())
}
return out
}
// Approve triggers a main-agent review-and-approve (代审) of a sub-agent's
// sensitive action through the injected *audit.Approver. Whether an action is
// sensitive and whether it is granted is decided by the upper policy layer; this
// method only records the audit entry. It returns ErrNoApprover when no approver
// was injected.
func (s *Scheduler) Approve(ctx context.Context, actor, action, describe string) (*audit.Approval, error) {
if s.approver == nil {
return nil, fmt.Errorf("orchestration: approve: %w", ErrNoApprover)
}
return s.approver.ReviewAndApprove(ctx, actor, action, describe)
}
// SinkMemory persists a task memory item for a sub-agent through the injected
// *memory.Store. The sub-agent's RoleID is used as the persona/role key; content
// and the scope/kind classification (user/persona/project) are supplied by the
// caller, keeping scope policy at the call site. It returns ErrNoMemory when no
// store was injected.
func (s *Scheduler) SinkMemory(ctx context.Context, sub *SubAgent, content string, scope memory.Scope, kind memory.Kind) (memory.MemoryItem, error) {
if s.memory == nil {
return memory.MemoryItem{}, fmt.Errorf("orchestration: sink memory: %w", ErrNoMemory)
}
if sub == nil {
return memory.MemoryItem{}, fmt.Errorf("orchestration: sink memory: %w", ErrAgentNotFound)
}
item := memory.MemoryItem{
PersonaID: sub.RoleID,
RoleID: sub.RoleID,
Scope: scope,
Kind: kind,
Content: content,
}
return s.memory.Add(ctx, item)
}
// launch starts the sub-agent's background execution exactly once. It is called
// by Spawn (auto-launch) and by Run (explicit start); launchOnce guarantees a
// single run even if both happen.
func (s *Scheduler) launch(ctx context.Context, sub *SubAgent) {
sub.launchOnce.Do(func() {
go s.run(ctx, sub)
})
}
// run drives one sub-agent: acquire a parallel slot, transition to running, run
// the loop in the sub-agent's isolated context under the per-agent timeout, then
// finalize with a summary and a terminal progress event. It always reaches
// exactly one finish, so active is decremented once and Join unblocks exactly
// when every sub-agent is terminal.
func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
if ctx == nil {
ctx = context.Background()
}
select {
case s.sem <- struct{}{}:
defer func() { <-s.sem }()
case <-ctx.Done():
s.finish(sub, StatusAborted, canceledSummary(), ctx.Err())
return
case <-sub.done:
s.finish(sub, StatusAborted, canceledSummary(), context.Canceled)
return
}
if sub.aborted() || !sub.transitionRunning() {
s.finish(sub, StatusAborted, canceledSummary(), context.Canceled)
return
}
s.publish(EventStarted, sub.progress())
runCtx, cancel := s.subCtx(ctx)
// A sub-agent must not leak its inner monologue into the caller's chat stream
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台): muting suppresses the
// UI stream/capacity broadcast while progress still reaches the frontend over
// the bus and the flow lane. The flow branch attributes every span this Run
// emits to the spawning session's graph instead of opening a second graph.
runCtx = loop.WithMutedBroadcast(runCtx)
runCtx = loop.WithFlowBranch(runCtx, sub.ParentSession)
runCtx, span := s.subSpan(runCtx, sub)
sub.setCancel(cancel)
defer cancel()
res, err := s.runner.Run(runCtx, sub.ID, sub.Context)
if err != nil {
s.spanErr(sub, span, err)
s.abortOrFail(sub, err)
return
}
s.spanOK(sub, span, res)
sub.setResult(res)
s.finish(sub, StatusDone, doneSummary(res), nil)
}
// abortOrFail classifies an error after a sub-agent's run: a deadline exceeded or
// a cancellation yields the aborted status (timeout/stop), anything else is a
// real failure.
func (s *Scheduler) abortOrFail(sub *SubAgent, err error) {
switch {
case errors.Is(err, context.DeadlineExceeded):
s.finish(sub, StatusAborted, timeoutSummary(s.perAgentTimeout), err)
case errors.Is(err, context.Canceled):
s.finish(sub, StatusAborted, canceledSummary(), err)
default:
s.finish(sub, StatusFailed, failedSummary(err), err)
}
}
// finish finalizes a sub-agent exactly once: it records the terminal status and
// summary, publishes the terminal progress event and decrements the active count,
// closing the allDone channel when the last sub-agent finalizes.
func (s *Scheduler) finish(sub *SubAgent, status Status, summary string, err error) {
sub.finishOnce.Do(func() {
sub.setOutcome(status, summary, err)
s.publish(eventFor(status), sub.progress())
sub.markFinished()
s.mu.Lock()
s.active--
if s.active == 0 {
s.allOne.Do(func() { close(s.allDone) })
}
s.mu.Unlock()
})
}
// subCtx derives the run context, applying the per-agent timeout when configured.
func (s *Scheduler) subCtx(parent context.Context) (context.Context, context.CancelFunc) {
if s.perAgentTimeout > 0 {
return context.WithTimeout(parent, s.perAgentTimeout)
}
return context.WithCancel(parent)
}
// subSpan starts a sub-agent span when a tracer is configured, chaining it as a
// child of any span already in ctx. When the sub-agent is attributed to a flow
// session (AgentSpec.ParentSession) the span is also folded into that session's
// live graph as a subagent lane root, so the branch renders inside the graph of
// the session that spawned it rather than in a graph of its own (design/19 §2). It
// returns the ctx carrying the span and the span itself (nil when the tracer is
// nil).
func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context, *trace.Span) {
if s.tracer == nil {
return ctx, nil
}
sctx, span := s.tracer.StartSpan(ctx, "subagent."+sub.ID, trace.SpanKindSubagent)
if sess := sub.ParentSession; sess != "" && s.flow != nil {
_ = span.SetProp("session", sess)
s.flow.SpanStarted(sess, span)
}
return sctx, span
}
// subagentEventDone is the log event type recorded on a sub-agent span when its
// run settles, so the dashboard node carries the outcome summary (design/19 §1
// 节点实时日志).
const subagentEventDone = "subagent.done"
// spanOK finishes a sub-agent span as ok, recording the result text and a
// desensitized outcome event, then folds the closed span into the parent flow.
func (s *Scheduler) spanOK(sub *SubAgent, span *trace.Span, res *loop.Result) {
if span == nil {
return
}
if res != nil {
_ = span.SetProp("text", res.Text)
_ = span.Record(trace.Event{Type: subagentEventDone, Props: map[string]any{
"status": string(StatusDone),
"steps": res.Steps,
"summary": compactText(res.Text, summaryTextMax),
}})
}
span.Finish(trace.SpanStatusOK)
s.foldSpanFinish(sub, span)
}
// spanErr finishes a sub-agent span as an error, recording the cause, then folds
// the closed span into the parent flow.
func (s *Scheduler) spanErr(sub *SubAgent, span *trace.Span, err error) {
if span == nil {
return
}
_ = span.SetProp("error", err.Error())
_ = span.Record(trace.Event{Type: subagentEventDone, Props: map[string]any{
"status": string(StatusFailed),
"error": compactText(err.Error(), summaryTextMax),
}})
span.Finish(trace.SpanStatusError)
s.foldSpanFinish(sub, span)
}
// foldSpanFinish folds a just-closed sub-agent span into its parent session's
// flow graph, flipping the lane root to its final status. The span already
// carries the parent session as its "session" prop, so the publisher knows which
// graph owns it.
func (s *Scheduler) foldSpanFinish(sub *SubAgent, span *trace.Span) {
if span == nil || s.flow == nil || sub.ParentSession == "" {
return
}
s.flow.SpanFinished(sub.ParentSession, span)
}
// publish broadcasts a progress event over the bus. It is a no-op when no comm
// probe is configured. Publishing uses a background context so a canceled run
// still delivers its terminal event before the sub-agent is done.
func (s *Scheduler) publish(name string, data Progress) {
if s.comm == nil {
return
}
if err := s.comm.PublishEvent(context.Background(), comm.Event{Name: name, Data: data}); err != nil {
s.log.Warn("publish progress", "event", name, "agent_id", data.AgentID, "error", err)
}
}
// nextID mints a unique sub-agent id from the scheduler's random prefix and an
// atomic sequence. It is safe for concurrent use.
func (s *Scheduler) nextID() string {
return fmt.Sprintf("%s-%d", s.prefix, s.seq.Add(1))
}
// eventFor maps a terminal status to its progress event name.
func eventFor(status Status) string {
switch status {
case StatusDone:
return EventDone
case StatusFailed:
return EventFailed
case StatusAborted:
return EventAborted
default:
return EventStarted
}
}
// wrapLoop adapts a *loop.Loop into the runner interface, substituting a no-op
// runner (fail-fast) when the template is nil.
func wrapLoop(l *loop.Loop) runner {
if l == nil {
return noopRunner{}
}
return l
}