【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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) 提交里(同一个工作时段内)。
238 lines
13 KiB
Go
238 lines
13 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件实现「回合内转向注入」(design/15 §2.0/§2.4,裁决 13.4.5 目标态)。
|
||
//
|
||
// 不变量按 design/15 §2.0 实现,语义不在循环里自创:
|
||
//
|
||
// - **I1(同会话在途回合数恒为 1)**:转向只在**同一回合的边界**注入,绝不取消
|
||
// 当前回合、也绝不新开第二个回合。循环对这一点的保证是结构性的——它没有任何
|
||
// 「起新回合」的分支,注入只是把消息并进当前回合下一次模型调用的视图。
|
||
// - **I2(消息恰好被处理一次)**:注入=已消费。循环看不到消息 id
|
||
// (model.Message 没有 id 字段),因此「按 created_at + id 去重」与「从待处理
|
||
// 队列移除」都归 SteerSource 的实现方(网关会话编排器,它同时持有转录与
|
||
// pending 队列)。循环只保证同一个边界只问一次、按返回顺序追加。
|
||
// - **I3(转向不取消已发起的工具)**:注入点只在**回合内边界**——一次模型步结束
|
||
// 与它那一批工具调用结束**之后**、下一次模型调用**之前**。此刻没有任何工具在
|
||
// 飞,因此注入不可能取消任何已发起的工具;正在执行的工具永远跑完(与 Cline
|
||
// PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。
|
||
//
|
||
// 为什么注入只进**本轮视图**而不写回运行窗口:写回会让注入内容进入 checkpoint
|
||
// 快照与后台记忆提取的输入面(两者都读该回合的 msgs),而它们本应只看到该回合真正
|
||
// 的输入与工具结果。注入在每一步重新并入视图,因此对模型而言它仍是**同一个窗口**
|
||
// (一致性=同一窗口,design/15 §2.4),只是不落盘、不出现在记忆面。
|
||
|
||
// SteerRequest 是循环在回合内边界向 SteerSource 发起的一次「重读转录」请求。
|
||
type SteerRequest struct {
|
||
// SessionID 是本次 Run 驱动的会话。
|
||
SessionID string
|
||
// Step 是**即将开始**的模型步序号(从 1 开始):被返回的消息会出现在该步的
|
||
// 模型可见视图里,因此它就是网关 `steer_ack` 帧上报的 `atStep`。
|
||
Step int
|
||
}
|
||
|
||
// SteerSource 由调用方(网关会话编排器)提供,在指定的回合内边界重读该会话转录,
|
||
// 返回「本回合开始后新到、且尚未被本回合吃进」的用户消息,供循环追加进本轮视图。
|
||
// 返回空切片表示本次边界没有可注入的消息,是正常情形而非错误。
|
||
//
|
||
// 实现方负责去重与「已消费」口径(见文件头 I2):循环按返回顺序原样追加,不做也
|
||
// 无法做 id 去重。它必须是非阻塞的——它在 Run 路径上被调用;网络/存储读取有界,
|
||
// 读失败时返回空(该消息留在待处理队列,照旧作为自己的回合被回答,绝不静默丢失)。
|
||
type SteerSource interface {
|
||
Steer(ctx context.Context, req SteerRequest) []model.Message
|
||
}
|
||
|
||
// steerSourceKey 是回合内转向源的 context key。
|
||
type steerSourceKey struct{}
|
||
|
||
// WithSteerSource 把转向源挂到 ctx 上,使本次 Run 的每个回合内边界都能重读转录并
|
||
// 注入新到的用户消息(design/15 §2.4)。调用方(网关编排器)在**每个回合启动之前**
|
||
// 挂一份新的源:转向游标是按回合的状态,跨回合复用会让上一个回合已吃进的消息被
|
||
// 过滤掉或重复注入。
|
||
//
|
||
// nil 源是空操作,ctx 原样返回——未接线的调用方(子代理、后台思考、未接网关的循环)
|
||
// 行为逐字不变。
|
||
func WithSteerSource(ctx context.Context, src SteerSource) context.Context {
|
||
if ctx == nil || src == nil {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, steerSourceKey{}, src)
|
||
}
|
||
|
||
// SteerSourceFrom 返回 ctx 上挂的转向源,未挂载时为 nil。它是 WithSteerSource 的
|
||
// 读侧,供拿到 context 的调用方(网关测试、断言注入边界的实现)观察本次 Run 的
|
||
// 注入能力;nil 表示本次运行不做任何转向注入。
|
||
func SteerSourceFrom(ctx context.Context) SteerSource {
|
||
if ctx == nil {
|
||
return nil
|
||
}
|
||
src, _ := ctx.Value(steerSourceKey{}).(SteerSource)
|
||
return src
|
||
}
|
||
|
||
// steerAtBoundary 在一个回合内边界重读该会话转录,并把新到的用户消息合并进本回合
|
||
// 已注入列表。未接线转向源、ctx 已取消、或本次边界无新消息时,它原样返回 steered
|
||
// (无新消息是常态:工具批次之间通常没有用户插话)。
|
||
//
|
||
// 它绝不返回错误:转向是增益,任何一个边界的读取失败都不该让用户已经在跑的回合
|
||
// 失败——失败时返回空(见 SteerSource 契约),该消息留在待处理队列里等下一个回合。
|
||
func (l *Loop) steerAtBoundary(ctx context.Context, sessionID string, step int, steered []model.Message) []model.Message {
|
||
src := SteerSourceFrom(ctx)
|
||
if src == nil || ctx.Err() != nil {
|
||
return steered
|
||
}
|
||
msgs := src.Steer(ctx, SteerRequest{SessionID: sessionID, Step: step})
|
||
if len(msgs) == 0 {
|
||
return steered
|
||
}
|
||
// 注入的图片消息走与回合开始处同一条视觉改写(design/06 §0):否则一条中途
|
||
// 注入的图片会绕过 resolver,直接落到不支持视觉的主模型面前。
|
||
msgs = l.resolveImages(ctx, msgs)
|
||
// 链路图如实标记「本轮输入被中途追加过」(design/15 §5)。子代理 Run 不拥有
|
||
// 自己的图(它的 span 并入派生它的会话),因此分支 Run 不打这个标记。
|
||
if _, isBranch := flowBranch(ctx); !isBranch {
|
||
l.flow.MarkInputSteered(flowOwner(ctx, sessionID))
|
||
}
|
||
l.log.Info("steer injected", "session", sessionID, "step", step, "messages", len(msgs))
|
||
return append(steered, msgs...)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 并行路进行态摘要(design/15 §2.5.6,主人 2026-10-05 裁决 B 的落地)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// 背景(真实事故):主人连发三条消息(两条来自安卓语音助手设备 `voiceassist:cyrene:v1`、
|
||
// 一条微信图片),后端真并发跑了三路;三路因为**互相看不见**,各自把同一句
|
||
// 「写 2000 字关于海洋」当成自己的题作答(三篇作文),其中一路还把别路的作文答进了
|
||
// 另一个客户端(跨端串话)。§2.5.6 的「进行态摘要」就是为了让每一路**结构化地**
|
||
// 知道「本会话此刻另有一路在跑」,从而各答各的。
|
||
//
|
||
// 它只含**元数据**(对方消息 id / 步数 / 已调用工具名 / 排队条数),结构里没有任何承载
|
||
// 对方正文的字段 —— 半句话会诱导本路去「补全」或「抢答」,也无法诚实地标注
|
||
// who said what(§2.5.6「不做的事」①)。它作为**独立的 system 消息**注入本轮视图,
|
||
// **绝不拼进任何 user 正文**(项目红线:用户内容只进 user 角色消息);这段文本也
|
||
// **不**承载任何身份 / 权限信息(身份识别靠 channel 层 actor 元数据 + 策略引擎)。
|
||
//
|
||
// 注入点与口径与 steer 注入**完全相同**(§2.5.6 明文要求):在 driveTurn 每步的
|
||
// assembleContext 之后追加进**本轮视图**,不写回 msgs ⇒ 不进 checkpoint 快照、
|
||
// 不进后台记忆提取的输入面。
|
||
|
||
// design/15 §2.5.6 定死的措辞(逐字,含标点)。头尾两句在任何字段为空时也照发 ——
|
||
// 「另有一路在跑、你独立作答」本身就是这条提示要传递的核心事实,元数据只是增益。
|
||
const (
|
||
parallelNoticeHeader = "[并行路提示] 该会话当前另有一路正在进行中(不打断它)。"
|
||
parallelNoticeTail = "你这一路独立作答;不要复述或改写对方可能给出的内容。"
|
||
)
|
||
|
||
// ParallelNotice 是本会话「另有一路正在进行」的**结构化**进行态摘要(design/15 §2.5.6)。
|
||
//
|
||
// 它由**网关**(会话编排器)在起第二路时按该路上游的实时状态构造,经
|
||
// WithParallelNotice 挂到该路的 runCtx 上;循环在每一步把它渲染成一条独立的
|
||
// system 消息(appendParallelNotice)。**空字段对应的那一段不渲染**,因此哪怕一条
|
||
// 元数据都没有,挂载这个值本身仍然是有意义的信号(头尾两句照发)。
|
||
//
|
||
// 结构里**没有**任何能承载对方半成品正文的字段 —— 这条红线是**结构性**的:
|
||
// 想拼也拼不进来。
|
||
type ParallelNotice struct {
|
||
// OtherMessageID 是另一路(或上一条)用户消息的服务端 id,作为「上一条消息 id」
|
||
// 渲染。为空时该段不渲染。
|
||
OtherMessageID string
|
||
// Step 是另一路**已进行到**的步序号(1 基)。<= 0 时该段不渲染。
|
||
Step int
|
||
// ToolNames 是另一路已经调用过的工具名,按调用顺序、已去重。为空时该段不渲染。
|
||
ToolNames []string
|
||
// Queued 是该会话仍在 pending、尚未被任何一路接手的消息条数。<= 0 时该段不渲染。
|
||
Queued int
|
||
}
|
||
|
||
// parallelNoticeKey 是并行路进行态摘要的 context key。
|
||
type parallelNoticeKey struct{}
|
||
|
||
// WithParallelNotice 把进行态摘要挂到 ctx 上,使本次 Run 每一步的模型可见视图都带上
|
||
// 一条独立的 system 提示(design/15 §2.5.6)。调用方(网关会话编排器)在**起第二路
|
||
// 之前**挂一份:摘要是该路起点的一份**快照**,中途不再改写(I4:两路之间不做运行中
|
||
// 的实时可见)。nil ctx 是空操作,ctx 原样返回 —— 未挂该键的运行(子代理 / 后台思考 /
|
||
// 未接网关的循环 / 单路)**行为逐字不变**。
|
||
func WithParallelNotice(ctx context.Context, notice ParallelNotice) context.Context {
|
||
if ctx == nil {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, parallelNoticeKey{}, notice)
|
||
}
|
||
|
||
// ParallelNoticeFrom 返回 ctx 上挂的进行态摘要,ok 报告是否挂载。它是
|
||
// WithParallelNotice 的读侧,供注入点与测试观察本路的提示能力。
|
||
//
|
||
// 它刻意返回 ok 而不只返回值:零值摘要也是「另有一路在跑」的有效信号(头尾两句本身
|
||
// 就是核心事实),因此「未挂载」不能与「挂了但没元数据」混为一谈。
|
||
func ParallelNoticeFrom(ctx context.Context) (ParallelNotice, bool) {
|
||
if ctx == nil {
|
||
return ParallelNotice{}, false
|
||
}
|
||
notice, ok := ctx.Value(parallelNoticeKey{}).(ParallelNotice)
|
||
return notice, ok
|
||
}
|
||
|
||
// parallelNoticeText 把进行态摘要渲染成 design/15 §2.5.6 定死的那段文本。措辞(含
|
||
// 标点与全角分隔)逐字取自设计;只有**有数据的字段**才出现在事实行里,使空字段不
|
||
// 产生空段(如「已进行到第 0 步」这种无信息量的噪声)。
|
||
func parallelNoticeText(notice ParallelNotice) string {
|
||
var b strings.Builder
|
||
b.WriteString(parallelNoticeHeader)
|
||
if facts := parallelNoticeFacts(notice); facts != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(facts)
|
||
}
|
||
if notice.Queued > 0 {
|
||
fmt.Fprintf(&b, "\n另有 %d 条消息在排队(未开始)。", notice.Queued)
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(parallelNoticeTail)
|
||
return b.String()
|
||
}
|
||
|
||
// parallelNoticeFacts 渲染事实行(设计示例里的
|
||
// 「上一条消息 id:msg-…;已进行到第 3 步;已调用工具:search_documents、document-read。」):
|
||
// 只列出有值的元数据段,以全角「;」分隔、以「。」收尾。一段都没有时返回空串,
|
||
// 该行整体不出现。
|
||
func parallelNoticeFacts(notice ParallelNotice) string {
|
||
parts := make([]string, 0, 3)
|
||
if notice.OtherMessageID != "" {
|
||
parts = append(parts, "上一条消息 id:"+notice.OtherMessageID)
|
||
}
|
||
if notice.Step > 0 {
|
||
parts = append(parts, fmt.Sprintf("已进行到第 %d 步", notice.Step))
|
||
}
|
||
if len(notice.ToolNames) > 0 {
|
||
parts = append(parts, "已调用工具:"+strings.Join(notice.ToolNames, "、"))
|
||
}
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return strings.Join(parts, ";") + "。"
|
||
}
|
||
|
||
// appendParallelNotice 在**本步视图**末尾追加 §2.5.6 的进行态摘要,作为一条
|
||
// **独立的 system 消息**。它必须在 assembleContext **之后**调用(§2.5.6 的落点)。
|
||
//
|
||
// 它只改本轮视图、**不写回 msgs**:checkpoint 快照与后台记忆提取的输入面因此完全
|
||
// 看不到它(与 steer 注入同一口径,见本文件头)。ctx 上未挂摘要时它原样返回 view,
|
||
// 不做任何分配 ⇒ 单路 / 子代理 / 后台思考的路径**零成本、逐字不变**。
|
||
func (l *Loop) appendParallelNotice(ctx context.Context, sessionID string, step int, view []model.Message) []model.Message {
|
||
notice, ok := ParallelNoticeFrom(ctx)
|
||
if !ok {
|
||
return view
|
||
}
|
||
l.log.Debug("parallel notice injected", "session", sessionID, "step", step,
|
||
"other_message", notice.OtherMessageID, "other_step", notice.Step,
|
||
"tools", len(notice.ToolNames), "queued", notice.Queued)
|
||
return append(view, model.TextMessage(model.RoleSystem, parallelNoticeText(notice)))
|
||
}
|