【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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) 提交里(同一个工作时段内)。
252 lines
8.1 KiB
Go
252 lines
8.1 KiB
Go
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
|
||
}
|