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

252 lines
8.1 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 (
"context"
"sync"
"time"
"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"
)
// SubAgent 是单个已派发的子代理实例。身份字段(ID、Goal、RoleID、PersonaID、Mode、
// AttachInfo、ParentSession、FlowOwner、Data、ToolWhitelist、Context、WorkingMemory)
// 在 Spawn 时一次设定,此后永不改动。生命周期字段(Status、Summary、Result、Err)由
// 子代理的后台 goroutine 写入,并经下方同步访问器读取,因此在子代理结束前读取它们
// 是安全的。当且仅当 Done 报告 true 时子代理处于终结态;Join 在全部子代理终结后
// 返回,Wait 在指名的 id 全部终结后返回。
//
// SubAgent 由 (*Scheduler).Spawn 创建;不应直接构造。
type SubAgent struct {
// ID 在所属调度器内唯一标识该子代理。
ID string
// Goal 是子代理追求的目标。
Goal string
// RoleID 是绑定的角色/persona。
RoleID string
// PersonaID 是子代理运行时充当的目标角色:其指令与记忆作用域(design/22 §8-2)。
// 留空表示「按派发回合的角色运行」,即既有行为。派发工具填充它(已对照按角色
// agent 池校验),Spawn 把它盖到运行的会话 env 上。
PersonaID string
// Mode 是绑定的行为模式。
Mode string
// AttachInfo 是派发时提供的自由形式附加文本。
AttachInfo string
// ParentSession 是拥有本子代理分支的流程图所属会话(该分支不被可视化时为空)。
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 全量工具集;非空=只放行所列工具。Spawn 时深拷贝。
ToolWhitelist []string
// Context 是隔离的会话上下文。它是 AgentSpec.Context 的深拷贝加上任务 user
// 消息,绝不与前台会话共享。
Context []model.Message
// WorkingMemory 是该子代理自己的工作记忆窗口,在 Spawn 时全新创建,因此绝不与
// 另一个子代理或前台会话重叠。
WorkingMemory *memory.WorkingMemory
// timeout 是该子代理生效的运行时限(按调用覆盖优先于调度器默认,
// design/05 §7 裁决 02.4.4);非正表示除取消外无期限。Spawn 时落定,只读。
timeout time.Duration
mu sync.RWMutex
status Status
summary string
result *loop.Result
runErr error
started time.Time
finished time.Time
done chan struct{}
cancel context.CancelFunc
abortReq bool
doneClosed bool
// finishedCh 在子代理进入终结态时恰好关闭一次,因此 Wait 能按 id 阻塞而无需
// 轮询。
finishedCh chan struct{}
launchOnce sync.Once
finishOnce sync.Once
}
// Status 返回子代理当前的生命周期状态。
func (s *SubAgent) Status() Status {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}
// Summary 返回子代理收尾时写入的执行摘要:成功时是完成摘要,否则是失败/中止原因。
// 子代理进入终结态之前它为空。
func (s *SubAgent) Summary() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.summary
}
// Result 返回运行成功时的循环结果;运行失败或被中止时返回 nil。
func (s *SubAgent) Result() *loop.Result {
s.mu.RLock()
defer s.mu.RUnlock()
return s.result
}
// Err 返回结束失败或中止运行的那个错误(成功时为 nil)。
func (s *SubAgent) Err() error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.runErr
}
// StartedAt 返回子代理转入 running 的时间(从未启动时为零值)。
func (s *SubAgent) StartedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.started
}
// FinishedAt 返回子代理进入终结态的时间(尚未终结时为零值)。
func (s *SubAgent) FinishedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.finished
}
// Done 报告子代理是否已进入终结态。
func (s *SubAgent) Done() bool { return s.Status().Terminal() }
// flowOwner 返回本子代理 span 该落进哪一张 flow 图的归属桶
// (`{会话}[#{路}]`):显式声明了 FlowOwner 时用它(真并行路派发的子代理因此落进
// **那一路**的图),否则回落到 ParentSession(会话主干那张图,即单路与旧调用方
// 的既有行为)。两者都为空时返回空串 —— 调用方据此抑制发布(不被可视化的分支)。
func (s *SubAgent) flowOwner() string {
if s == nil {
return ""
}
if s.FlowOwner != "" {
return s.FlowOwner
}
return s.ParentSession
}
// waitDone 返回子代理进入终结态时被关闭的 channel。它是 (*Scheduler).Wait 按 id 阻塞
// 时等待的对象,无需轮询。
func (s *SubAgent) waitDone() <-chan struct{} { return s.finishedCh }
// markFinished 恰好关闭一次完成 channel。它在 finish 里于 finishOnce 之下调用,因此
// 每个子代理恰好唤醒它的每个 Wait 调用方一次。
func (s *SubAgent) markFinished() { close(s.finishedCh) }
// Snapshot 返回子代理状态的某一时刻拷贝,在子代理仍在运行时读取也安全。
func (s *SubAgent) Snapshot() SubAgentResult {
s.mu.RLock()
defer s.mu.RUnlock()
snap := SubAgentResult{
ID: s.ID,
Goal: s.Goal,
RoleID: s.RoleID,
PersonaID: s.PersonaID,
Mode: s.Mode,
AttachInfo: s.AttachInfo,
Status: s.status,
Summary: s.summary,
Err: s.runErr,
StartedAt: s.started,
FinishedAt: s.finished,
}
if s.result != nil {
snap.Text = s.result.Text
snap.Steps = s.result.Steps
}
return snap
}
// transitionRunning 把 pending 的子代理转入 running。子代理已被中止(因此不得运行)
// 或当前不是 pending 时返回 false。
func (s *SubAgent) transitionRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.abortReq || s.status != StatusPending {
return false
}
s.status = StatusRunning
s.started = time.Now()
return true
}
// progress 返回用于进度事件的当前状态快照。
func (s *SubAgent) progress() Progress {
s.mu.RLock()
defer s.mu.RUnlock()
return Progress{
AgentID: s.ID,
Goal: s.Goal,
RoleID: s.RoleID,
Mode: s.Mode,
Status: s.status,
}
}
// setOutcome 在运行结束后记录终结状态、摘要与错误。每个子代理至多调用一次,由
// finishOnce 把守。
func (s *SubAgent) setOutcome(status Status, summary string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.status = status
s.summary = summary
s.runErr = err
s.finished = time.Now()
}
// setResult 记录成功时的循环结果。
func (s *SubAgent) setResult(res *loop.Result) {
s.mu.Lock()
defer s.mu.Unlock()
s.result = res
}
// setCancel 保存运行上下文的取消函数,使 Abort 能中断循环。若子代理已被中止
// (cancelRun 先行),则立即调用保存的 cancel。
func (s *SubAgent) setCancel(c context.CancelFunc) {
s.mu.Lock()
s.cancel = c
aborted := s.abortReq
s.mu.Unlock()
if aborted {
c()
}
}
// cancelRun 请求取消该子代理。它幂等且可并发使用:done channel 只关闭一次,已保存的
// cancel(若已设置)只调用一次。排队中的子代理经 done 解除对并行槽位的阻塞;运行中的
// 子代理经 cancel 函数被中断。
func (s *SubAgent) cancelRun() {
s.mu.Lock()
s.abortReq = true
c := s.cancel
first := !s.doneClosed
s.doneClosed = true
s.mu.Unlock()
if first {
close(s.done)
}
if c != nil {
c()
}
}
// aborted 报告该子代理是否已被请求取消。
func (s *SubAgent) aborted() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.abortReq
}