feat(gateway): 并行提示接线(第二路 + 串行续跑路;OtherMessageID/Queued 取自另一在途路,无其它在途路则不挂)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 12:21:24 +08:00
1 parent 20160c66d8
commit ba1974038a
3 files changed
+271 -5

No files matched your search

+60 -5
View File
@@ -13,18 +13,21 @@ import (
// 2026-10-05 裁决 B)。它从 session_orchestrator.go 拆出,使编排器本体继续只留
// 状态机,并保持在「单文件 ≤500 行 / 函数 ≤80 行」的硬约束内。
//
// 它做五件事:
// 它做六件事:
// - 解析上行的并行声明(ctx 通道,与 deviceID / mode / 幂等键同一种机制);
// - 判定并发额度(默认 2,硬上限 3,<2 = 关闭真并行 ⇒ 完全回落到 steer-only 现状);
// - 在状态锁下登记一条新路(自己的世代号 / turnId / 取消链 / checkpoint 归属键);
// - 忙态帧的在途路数(activeTurns)与排队原因(reason);
// - 下发新帧 `turn_begin`(`publishLegStart` / `emitTurnBegin`):单路
// kind:"turn"、并行路 kind:"parallel",两条路径都发。`steer_ack` 的帧壳
// turnId 由 session_steer.go 的转向游标带出,与这里**取值同源**(leg.turnID)。
// turnId 由 session_steer.go 的转向游标带出,与这里**取值同源**(leg.turnID);
// - 把「本会话其它在途路」的**进行态摘要**(design/15 §2.5.6,见
// parallelNoticeLocked)挂到新路的 runCtx 上,使循环每一步都能把一条独立的
// system 提示注入本轮视图 —— 这正是「一题多答 / 跨端串话」的治法。摘要只含
// 元数据,结构里**没有**任何能承载对方正文的字段(红线)。
//
// 它**不做**(留给后续切片):第二路的进行态 system 摘要。写类工具的文件级互斥
// 在**两个地方**落地:互斥**身份**由 beginLegLocked 对每一条在途路盖
// (loop.WithToolLegID,取值 = 本路 turnID),登记 / 判忙 / 释放由工具侧
// 写类工具的文件级互斥在**两个地方**落地:互斥**身份**由 beginLegLocked 对每一条
// 在途路盖(loop.WithToolLegID,取值 = 本路 turnID),登记 / 判忙 / 释放由工具侧
// (internal/runtime/tool/fileclaims.go)在执行前后来做。
// 并发额度的取值口径(design/15 §2.5.7)。
@@ -144,6 +147,52 @@ func (o *Orchestrator) canStartParallelLocked(st *sessionState) bool {
return len(st.legs) >= 1 && len(st.legs) < o.maxParallelTurns
}
// parallelNoticeLocked 把该会话**其它在途路**的进行态收成一份结构化的
// loop.ParallelNotice(design/15 §2.5.6),供新起的这一路挂到自己的 runCtx 上。
// 第二个返回值报告「是否有其它在途路」:false 时调用方**不挂**提示,使单路 /
// 收尾只剩一路 / 未接线的路径零成本、行为逐字不变。
//
// 摘要的每一项都取**结构化数据**,绝无例外:
// - OtherMessageID = 其它某一路已落库用户消息(leg.docs.msgs)**末条**的权威 id;
// 该路还没落库(落库在锁外、比登记稍晚)时留空,对应段不渲染,绝不凭空编 id;
// - Queued = st.pending 的条数 —— 仍在排队、尚未被任何一路接手的消息。
//
// 它**没有**「对方正文」这个概念:ParallelNotice 结构里根本没有能承载正文的字段
// (结构性红线,见 loop/steer.go)。半句话会诱导本路去补全或抢答,也无法诚实地
// 标注 who said what(§2.5.6「不做的事」①)。
//
// 多路同时在场(上限 3)时取**世代号最大**的那一条其它路,即最近起跑的那一路:
// 契约里只有单个 OtherMessageID,按 map 遍历序取值会让提示内容随调度抖动,因此
// 这里刻意取一个确定的口径。
//
// **Step / ToolNames 恒为 0 / nil**:当前**没有数据源** —— turnLeg 不带步数、也不带
// 已调用工具名,loop 也不回传任何中途进度。这两个字段对应的事实行因此不渲染;进度源
// 待另开切片,**不在这里猜或伪造数据**。
//
// 必须在 o.mu 下调用(它读 st.legs / st.pending,与 activeTurns 同一临界区)。
func parallelNoticeLocked(st *sessionState, selfGen uint64) (loop.ParallelNotice, bool) {
if st == nil {
return loop.ParallelNotice{}, false
}
var other *turnLeg
for gen, leg := range st.legs {
if gen == selfGen || leg == nil {
continue
}
if other == nil || leg.gen > other.gen {
other = leg
}
}
if other == nil {
return loop.ParallelNotice{}, false
}
notice := loop.ParallelNotice{Queued: len(st.pending)}
if msgs := other.docs.msgs; len(msgs) > 0 {
notice.OtherMessageID = msgs[len(msgs)-1].ID
}
return notice, true
}
// startParallelLocked 在状态锁下把一条忙时到达、且显式声明并行的用户消息升为
// **独立的一路**(design/15 §2.5.1 / §2.5.3),并在锁外起 goroutine、返回本次提交
// 的判定。它必须在 o.mu 下调用,且由调用方保证 canStartParallelLocked 已经成立
@@ -168,6 +217,12 @@ func (o *Orchestrator) startParallelLocked(ctx context.Context, st *sessionState
// 这与续跑路用 Background 派生属于同一条口径:后台路的生死由编排器说了算。
leg, runCtx, cancel := o.beginLegLocked(context.WithoutCancel(ctx), st, newTurnID(), true, newTurnFence(nil))
activeTurns := len(st.legs)
// 进行态摘要(design/15 §2.5.6)在**解锁之前**取快照:它读 st.legs / st.pending,
// 与 activeTurns 同一条纪律 —— 解锁后再读数会随另一路的收尾漂移。没有其它在途路
// 时 ok=false ⇒ 不挂,runCtx 与加入这条通道之前逐字相同(零成本)。
if notice, ok := parallelNoticeLocked(st, leg.gen); ok {
runCtx = loop.WithParallelNotice(runCtx, notice)
}
o.mu.Unlock()
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, leg.gen, leg.turnID))