Files
Cyrene_v2/internal/runtime/orchestration/scheduler_spans.go
T
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

69 lines
2.5 KiB
Go

package orchestration
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件是调度层的「编排 span」:把每次子代理派发与收束记成一个
// orchestration 节点(design/19 §1 旁路节点,裁决 11.4.2)。它从
// scheduler.go 拆出,因为该文件已接近 AGENTS §10 的 500 行上限,
// 而 span 的开启/收尾是一块独立职责。
// orchestration.dispatch 的 span 名与收尾事件类型:此前调度只发
// SpanKindSubagent,因此全仓没有任何 orchestration 节点。
const (
spanNameOrchestrationSpawn = "orchestration.spawn"
orchestrationEventDone = "orchestration.done"
)
// orchSpan 为一次子代理派发开一个 orchestration span。它是挂在主干上的
// 「派发标记」:与泳道根并列而不是它的父级 —— 若把它当作子代理 span 的父级,
// 泳道链会多出一节(图构建器把非 subagent 祖先一路穿透解析泳道),既有泳道
// 契约就被改写了。span 只带非敏感属性(被派发的子代理 id、目标角色 id),
// 声明了归属时同时发布一个「运行中」占位节点(真并行时落进派发它的那一路的图)。
// 未配置 tracer 时返回 nil。
func (s *Scheduler) orchSpan(ctx context.Context, sub *SubAgent) *trace.Span {
if s.tracer == nil || sub == nil {
return nil
}
_, span := s.tracer.StartSpan(ctx, spanNameOrchestrationSpawn, trace.SpanKindOrchestration)
if sub.ID != "" {
_ = span.SetProp("subagent", sub.ID)
}
if sub.PersonaID != "" {
_ = span.SetProp("persona", sub.PersonaID)
}
if owner := sub.flowOwner(); owner != "" && s.flow != nil {
_ = span.SetProp("session", owner)
s.flow.SpanStarted(owner, span)
}
return span
}
// finishOrchSpan 收尾一次派发:记录耗时(失败另记原因)与一条脱敏事件,
// 失败时终态为 error,然后把已关闭的 span 折回所属那一路的图。
func (s *Scheduler) finishOrchSpan(sub *SubAgent, span *trace.Span, err error) {
if span == nil {
return
}
props := map[string]any{"elapsed_ms": trace.ElapsedMS(span)}
if err != nil {
props["error"] = err.Error()
_ = span.SetProp("error", err.Error())
}
_ = span.Record(trace.Event{Type: orchestrationEventDone, Props: props})
status := trace.SpanStatusOK
if err != nil {
status = trace.SpanStatusError
}
span.Finish(status)
if s.flow == nil || sub == nil {
return
}
if owner := sub.flowOwner(); owner != "" {
s.flow.SpanFinished(owner, span)
}
}