【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。
【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
单路/未给 lanes 时渲染路径与改前逐字相同
【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)
【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)
⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
239 lines
9.8 KiB
Go
239 lines
9.8 KiB
Go
package orchestration
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// Status 是已派发子代理的生命周期状态。
|
||
//
|
||
// 生命周期为 pending -> running -> 终结态之一(done、failed、aborted)。
|
||
// 当且仅当 Terminal 报告 true 时,子代理处于终结态。
|
||
type Status string
|
||
|
||
const (
|
||
// StatusPending 表示子代理已创建但尚未开始。
|
||
StatusPending Status = "pending"
|
||
// StatusRunning 表示子代理的循环正在执行。
|
||
StatusRunning Status = "running"
|
||
// StatusDone 表示循环已完成并产出结果。
|
||
StatusDone Status = "done"
|
||
// StatusFailed 表示循环返回了错误。
|
||
StatusFailed Status = "failed"
|
||
// StatusAborted 表示子代理被取消或超时。
|
||
StatusAborted Status = "aborted"
|
||
)
|
||
|
||
// Terminal 报告 s 是否为终态。pending 与 running 不是终态;每个子代理都以三种终结态
|
||
// 之一收尾。
|
||
func (s Status) Terminal() bool {
|
||
return s == StatusDone || s == StatusFailed || s == StatusAborted
|
||
}
|
||
|
||
// AgentSpec 描述一个待派发的子代理。Goal 必填;其余字段可选,绑定到子代理上供上层
|
||
// 解读。
|
||
type AgentSpec struct {
|
||
// Goal 是子代理要达成的目标。必填。
|
||
Goal string
|
||
// RoleID 选择子代理绑定的角色/persona。
|
||
RoleID string
|
||
// PersonaID 是子代理实际充当的角色(design/22 §8-2):它以该角色的指令作答,
|
||
// 读写该角色的记忆作用域 ——「她以该角色身份临时替班」。Spawn 把它盖到子代理
|
||
// 运行的会话 env 上,而那是循环解析提示词、记忆命名空间、情绪与思考旋钮的唯一
|
||
// 输入,因此四者不可能互相矛盾。留空则逐字节保持既有行为:子代理继承派发回合的
|
||
// env,即父会话的角色。该 id 必须是本进程能以自身 agent 运行的角色
|
||
// ((*Scheduler).PersonaKnown);Spawn 会拒绝未知 id 并返回 ErrUnknownPersona,
|
||
// 而不是静默回落到默认角色。
|
||
PersonaID string
|
||
// Mode 选择该角色的行为模式(例如 "engineer")。
|
||
Mode string
|
||
// Context 是初始的、隔离的会话上下文。Spawn 会深拷贝它,因此调用方此后的修改
|
||
// 不会影响子代理。
|
||
Context []model.Message
|
||
// AttachInfo 是派发时提供的自由形式附加/注入文本。
|
||
AttachInfo string
|
||
// ParentSession 是拥有该分支的会话,其实时流程图将承载子代理的 span 及其内部
|
||
// 步骤,使它们渲染成派发会话图的一条泳道,而不是自成一张图(design/19 §2)。
|
||
// 派发工具从 loop.FlowSessionFromContext 取得它(**不含路**:后台批次的
|
||
// `subagent_result` 帧按它归属到父会话,因此它必须是真会话 id)。
|
||
// 留空则关闭流图归属;它绝不影响子代理在哪个会话里运行,因此其 checkpoint、
|
||
// 记忆与流隔离都不变。
|
||
ParentSession string
|
||
// FlowOwner 是子代理 span 落进**哪一张** flow 图的归属桶
|
||
// (`{会话}[#{路}]`,见 loop.FlowOwnerFromContext)。真并行时同一会话有多条路,
|
||
// 派发它的那一路有自己的一张图,子代理的泳道必须落进那一路 —— 否则两路的
|
||
// 子代理节点会互相长在对方的图上(design/15 §2.5.10-1)。派发工具填它;
|
||
// 留空时回落到 ParentSession(会话主干那张,即单路与旧调用方的既有行为)。
|
||
FlowOwner string
|
||
// Data 承载供调用方使用的可选结构化载荷。
|
||
Data any
|
||
// ToolWhitelist 限定该子代理可执行的工具集合(design/05 §7 裁决 02.4.1):
|
||
// 缺省(nil/空)=继承主 agent 的全量工具集;显式列出=只放行所列工具,
|
||
// 名单外的调用在工具循环层被否决并以软结果回灌。它只收缩能力面,绝不扩展。
|
||
ToolWhitelist []string
|
||
// Timeout 覆盖该子代理的运行时限(design/05 §7 裁决 02.4.4 的按调用覆盖)。
|
||
// 非正时回退到调度器的 Options.PerAgentTimeout。
|
||
Timeout time.Duration
|
||
}
|
||
|
||
// 经 comm 探针发布到 agent 间总线上的进度事件名。外部订阅方可据此匹配,驱动一个
|
||
// 监控面板(DevDocs/design/05-子代理.md §9)。
|
||
const (
|
||
// EventDispatched 在子代理被创建时发布。
|
||
EventDispatched = "subagent.dispatched"
|
||
// EventStarted 在子代理开始运行时发布。
|
||
EventStarted = "subagent.started"
|
||
// EventDone 在子代理成功结束时发布。
|
||
EventDone = "subagent.done"
|
||
// EventFailed 在子代理失败时发布。
|
||
EventFailed = "subagent.failed"
|
||
// EventAborted 在子代理被取消或超时时发布。
|
||
EventAborted = "subagent.aborted"
|
||
)
|
||
|
||
// Progress 是进度事件 Data 载荷所承载的状态快照。
|
||
type Progress struct {
|
||
// AgentID 是已派发的子代理 id。
|
||
AgentID string
|
||
// Goal 是子代理的目标。
|
||
Goal string
|
||
// RoleID 是绑定的角色/persona。
|
||
RoleID string
|
||
// Mode 是绑定的行为模式。
|
||
Mode string
|
||
// Status 是子代理在发布时刻的状态。
|
||
Status Status
|
||
}
|
||
|
||
// Scheduler 返回的哨兵错误。调用点以 %w 包装它们,因此调用方可用 errors.Is 判断。
|
||
var (
|
||
// ErrMissingGoal 在 spec 的 Goal 为空时由 Spawn 返回。
|
||
ErrMissingGoal = errors.New("orchestration: missing goal")
|
||
|
||
// ErrNoRunner 在子代理运行时未配置循环 runner(Options.Loop 为 nil)时返回。
|
||
ErrNoRunner = errors.New("orchestration: no loop runner configured")
|
||
|
||
// ErrNoApprover 在未注入 *audit.Approver 时由 Approve 返回。
|
||
ErrNoApprover = errors.New("orchestration: no approver configured")
|
||
|
||
// ErrNoMemory 在未注入 *memory.Store 时由 SinkMemory 返回。
|
||
ErrNoMemory = errors.New("orchestration: no memory store configured")
|
||
|
||
// ErrAgentNotFound 在子代理 id 未知时由 Abort 返回。
|
||
ErrAgentNotFound = errors.New("orchestration: sub-agent not found")
|
||
|
||
// ErrUnknownPersona 在 spec 指名了一个本进程没有对应按角色 agent 的角色时由
|
||
// Spawn 返回(design/22 §8-2)。它是拒绝,不是回退:调用方要的是另一个角色,
|
||
// 却用默认角色的提示词运行子代理,那会静默地背离请求本意。
|
||
ErrUnknownPersona = errors.New("orchestration: unknown persona")
|
||
|
||
// ErrNoScheduler 在派发工具被构建时未带调度器(接线错误,如实报告而不 panic)
|
||
// 时由该工具返回。
|
||
ErrNoScheduler = errors.New("orchestration: no scheduler configured")
|
||
)
|
||
|
||
// cloneMessages 返回 msgs 的深拷贝:消息切片与每条消息的内容分段都被复制,因此结果
|
||
// 与入参不共享内存。它在 Spawn 处用于给每个子代理一个隔离上下文。nil 入参产出 nil;
|
||
// 非 nil 的空入参产出非 nil 的空切片。
|
||
func cloneMessages(msgs []model.Message) []model.Message {
|
||
if msgs == nil {
|
||
return nil
|
||
}
|
||
out := make([]model.Message, len(msgs))
|
||
for i, m := range msgs {
|
||
if m.Content != nil {
|
||
parts := make([]model.Content, len(m.Content))
|
||
copy(parts, m.Content)
|
||
m.Content = parts
|
||
}
|
||
out[i] = m
|
||
}
|
||
return out
|
||
}
|
||
|
||
// compactText 把 s 截短到至多 n 个字符以用于摘要,被截断时追加省略号。它绝不切开
|
||
// 多字节字符。
|
||
func compactText(s string, n int) string {
|
||
s = strings.TrimSpace(s)
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
// taskContext 返回子代理的隔离上下文:调用方显式给出的消息在前(深拷贝),
|
||
// 任务本身作为最后一条 user 消息注入。裁决 2026-10-04:goal 正文 +
|
||
// attach_info/data 作为同一条 user 消息内的独立段落;system 只承载角色与
|
||
// 安全约束(循环前置的 agent 指令),绝不把任务塞进 system——否则子代理
|
||
// 的模型上下文里没有任何一轮用户消息,任务一个字都到不了模型。
|
||
func taskContext(spec AgentSpec) []model.Message {
|
||
return append(cloneMessages(spec.Context), taskUserMessage(spec))
|
||
}
|
||
|
||
// taskUserMessage 把任务渲染为一条 user 消息(裁决 2026-10-04):
|
||
// [任务目标] 段固定存在(Spawn 已保证 goal 非空),[附加信息] 与
|
||
// [任务数据] 段仅在对应字段非空时出现;Data 以 JSON 编码,编码失败时
|
||
// 整段跳过——宁可少一段噪声,不注入一段坏文本。
|
||
func taskUserMessage(spec AgentSpec) model.Message {
|
||
var b strings.Builder
|
||
b.WriteString("[任务目标]\n")
|
||
b.WriteString(spec.Goal)
|
||
if spec.AttachInfo != "" {
|
||
b.WriteString("\n\n[附加信息]\n")
|
||
b.WriteString(spec.AttachInfo)
|
||
}
|
||
if spec.Data != nil {
|
||
if data, err := json.Marshal(spec.Data); err == nil {
|
||
b.WriteString("\n\n[任务数据]\n")
|
||
b.Write(data)
|
||
}
|
||
}
|
||
return model.TextMessage(model.RoleUser, b.String())
|
||
}
|
||
|
||
// cloneStrings 返回 names 的拷贝,使子代理持有的白名单与调用方的切片
|
||
// 不共享内存。nil 进 nil 出。
|
||
func cloneStrings(names []string) []string {
|
||
if names == nil {
|
||
return nil
|
||
}
|
||
return append([]string(nil), names...)
|
||
}
|
||
|
||
// stringListArg 读取字符串数组参数:容忍缺失/非数组/非字符串元素,
|
||
// 逐项去空白并丢弃空项,使名单恒为「显式列出的工具名」。缺失返回 nil(全开)。
|
||
func stringListArg(m map[string]any, key string) []string {
|
||
raw, ok := m[key].([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(raw))
|
||
for _, v := range raw {
|
||
s, _ := v.(string)
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// timeoutArg 读取 timeout_seconds 按调用覆盖(JSON 解码为 float64):
|
||
// 缺失/非数/越界(<1s 或 >24h)视为未提供并沿用 dft;越界值不静默夹取——
|
||
// 回退到默认而不是替模型猜一个邻近值。
|
||
func timeoutArg(m map[string]any, key string, dft time.Duration) time.Duration {
|
||
f, ok := m[key].(float64)
|
||
if !ok {
|
||
return dft
|
||
}
|
||
sec := int64(f)
|
||
if sec < minSpawnTimeoutSeconds || sec > maxSpawnTimeoutSeconds {
|
||
return dft
|
||
}
|
||
return time.Duration(sec) * time.Second
|
||
}
|