Files
AskaEth 89434eadef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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) 提交里(同一个工作时段内)。
2026-10-06 08:16:03 +08:00

239 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}