执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。
- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
`Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
(顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
`scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
`ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。
验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。
⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
488 lines
17 KiB
Go
488 lines
17 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
|
|
// personaExists answers "can this process run that role with its own
|
|
// instructions" (Options.PersonaExists); nil means no role can be named.
|
|
personaExists func(personaID string) bool
|
|
|
|
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,
|
|
personaExists: opts.PersonaExists,
|
|
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, and a wrapped ErrUnknownPersona when spec.PersonaID
|
|
// names a role this process has no agent for (design/22 §8-2).
|
|
//
|
|
// The sub-agent's Context is a deep copy of spec.Context plus the task itself
|
|
// as one trailing user message (裁决 2026-10-04), 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 err := s.checkPersona(spec.PersonaID); err != nil {
|
|
return nil, err
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
sub := &SubAgent{
|
|
ID: s.nextID(),
|
|
Goal: spec.Goal,
|
|
RoleID: effectiveRole(spec),
|
|
PersonaID: spec.PersonaID,
|
|
Mode: spec.Mode,
|
|
AttachInfo: spec.AttachInfo,
|
|
ParentSession: spec.ParentSession,
|
|
Data: spec.Data,
|
|
ToolWhitelist: cloneStrings(spec.ToolWhitelist),
|
|
Context: taskContext(spec),
|
|
WorkingMemory: memory.NewWorkingMemory(0),
|
|
timeout: effectiveTimeout(spec.Timeout, s.perAgentTimeout),
|
|
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)
|
|
}
|
|
|
|
// 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, sub)
|
|
// 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)
|
|
// A dispatched target role decides the prompt and the memory scope for every step
|
|
// of this run (design/22 §8-2). Blank keeps the inherited env: the pre-existing
|
|
// behavior, where a sub-agent runs as the spawning turn's role.
|
|
runCtx = withPersonaEnv(runCtx, sub.PersonaID)
|
|
// 工具白名单(design/05 §7 裁决 02.4.1)随运行上下文传递:名单非空时
|
|
// 只放行所列工具;空名单=全开,与挂载前的行为一致。
|
|
runCtx = loop.WithToolWhitelist(runCtx, sub.ToolWhitelist)
|
|
runCtx, span := s.subSpan(runCtx, sub)
|
|
sub.setCancel(cancel)
|
|
defer cancel()
|
|
|
|
res, err := s.scopedRunner(sub).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. The timeout notice names the deadline that actually applied to
|
|
// this sub-agent (per-call override or scheduler default), not the global one.
|
|
func (s *Scheduler) abortOrFail(sub *SubAgent, err error) {
|
|
switch {
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
s.finish(sub, StatusAborted, timeoutSummaryFor(sub.timeout), 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 sub-agent's effective timeout:
|
|
// a per-call AgentSpec.Timeout (design/05 §7 裁决 02.4.4 按调用覆盖) wins over
|
|
// the scheduler's PerAgentTimeout; neither configured means no deadline beyond
|
|
// cancellation.
|
|
func (s *Scheduler) subCtx(parent context.Context, sub *SubAgent) (context.Context, context.CancelFunc) {
|
|
if sub.timeout > 0 {
|
|
return context.WithTimeout(parent, sub.timeout)
|
|
}
|
|
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)
|
|
}
|
|
// The target role rides the branch node so the flow panel shows which role the
|
|
// sub-agent answered as (design/22 §8-2 可观测性), not just the sub-agent id.
|
|
if sub.PersonaID != "" {
|
|
_ = span.SetProp("persona", sub.PersonaID)
|
|
}
|
|
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
|
|
}
|
|
}
|