23 Commits
Author SHA1 Message Date
AskaEth 656c35f1e8 Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
# Conflicts:
#	internal/runtime/trace/errortoken.go
2026-10-26 21:25:48 +08:00
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00
AskaEth 1b2576ecaf fix(weixin): 扫码上游字段过令牌守卫,errortoken 契约改写
复审 p2 两条:

① 第五个出口:get_qrcode_status 的 status 与 scaned_but_redirect 的
redirect_host 此前原样进 INFO;同一 status 经 setPhase 还从复用二维码那条
INFO(phase)二次出镜。三处一律过 trace.ErrorToken;**控制流仍用原值**
(状态机读 status.Status、路由读 status.RedirectHost、confirmingPhase 读
phase),用例同时钉住「日志变空串」与「路由/判定不变」两个方向。

② 顺带补扫出一处 URL 类:get_bot_qrcode 的 qrcode_img_content(上游给的
图片地址,可能是带签名的 URL 或 data: 内联图)原样进 INFO ⇒ 改走
trace.MaskQuery(凭据类 query 打码、保留路径、解析不出 host 回空串)。

③ errortoken.go 的契约改写:原「本包之外不应出现 ErrorToken 调用」已被
M50 的正当调用点证伪。新口径按**字段类别**判定:协议枚举字段(status/
code/type/request_id)可在渲染点调用;自由文本字段(message/description/
msg/wording)不得靠它出镜 —— 那类默认不带,要带必须 opt-in + RedactText。
dashscope 的 request_id 例外一并写入。

每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
2026-10-26 19:41:28 +08:00
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00
AskaEth 92b0f89492 fix(trace): URLCause 用层数上限终止,去掉不可比较的 error 比较
手写终止条件只认「长度 1 的自引用」,于是:
- 两节点环(a.Err=b / b.Err=a)⇒ 死循环,打挂当前请求的 goroutine;
- uerr.Err == err 在两侧动态类型相同且不可比较时 ⇒ panic
  (comparing uncomparable type),同样打挂当前请求。

改为唯一终止条件 maxURLCauseDepth=8:超限返回只含 Op 的替身,不返回当前层
(当前层是 *url.Error,Error() 里就是整条地址,不能为防死循环把地址漏回去)。
正常单层/双层路径逐字不变。用例:两节点环 1s 内返回、不可比较类型不 panic、
自引用仍退化成替身、超限路径无 URL 片段、上限内(8 层)仍剥到内层原因。
2026-10-26 19:01:15 +08:00
AskaEth 3001fcc716 fix(model): providerUpstreamError 的 error.code 过令牌守卫
error.code 与 error.type 一样是**自由文本面**:主流上游里是枚举,但网关、代理与
兼容层可以往里塞任何东西(包括回显的用户原文)。M41 已给 type 加了守卫,
providerUpstreamError 的 code 一直原样带出,是这一族里唯一还漏的字段。

守卫本色从 runtime/model(safeErrorToken)与 runtime/multimodal/voice
(dashscopeErrorToken)两份逐字同形的实现上提到 trace.ErrorToken,model 侧改为
调用它(voice 侧仍是自己那份,不在本切片文件面内,见随附 finding)。

唯一的行为变化就是「不像令牌的 code 变空串」:status / type / body 体积与 sha256、
错误文案模板、双 %w 哨兵链(ErrUpstream、trace.ErrUpstream)都不变。

验收:go build ./... + go vet ./... + trace/model 包 test 全绿;新增用例覆盖
「带空格标点的 code 变空串且 status/type 仍在」与「令牌形状的 code 保留」。
2026-10-26 18:45:38 +08:00
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00
AskaEth 3157abcf3c fix(gateway): 日志页脱敏改用「可带上限」变体(4096),摘要侧仍是 80
塔 2026-10-26 复审裁定:复用 trace.RedactText 让面板日志页每条 msg 都被切到
80 rune,是实质性 UX 回归(日志页就是排障面)。修法不是网关侧另写脱敏,而是
在 trace 包给上限加一个参数。

- trace/redact.go:新增导出 `RedactTextLimited(s, limit)`,语义 = 同一套凭据形状
  清洗 + 按调用方给的上限截断;`RedactText` 改为它的 `maxSummaryRunes`(80)
  调用形态,既有调用方零改动,清洗逻辑仍只有一份(redactSecrets)。
  limit <= 0 = 不截断,留给体积已自己界定的调用方。
- observability_logs.go:新增常量 `maxLogMsgRunes = 4096`(含注释说明为什么不是
  80、也不是无上限:单条日志可能是 %v 的 MiB 级对象,1000 条会撑爆 JSON),
  adminLogOf 改用 `RedactTextLimited(e.Msg, maxLogMsgRunes)`。
- span props / 摘要侧不动,仍是 80(一个要摘要,一个要全文,两边不该被统一)。

测试(均验过变异打红):
- TestAdminObservabilityLogs_LongLineKeepsFullText:547 rune 的日志行(行首凭据 +
  行尾哨兵)⇒ 凭据打码、末尾哨兵仍在、rune 数只差「凭据→[redacted]」那一处;
  变异(改回 RedactText 80)⇒ 红:拿到 81 rune、末尾哨兵消失。
- TestAdminObservabilityTrace_PropsStayAtSummaryBound:200 rune 的 args_summary
  ⇒ 81 rune(80 + 省略号);变异(把 maxSummaryRunes 提到 4096)⇒ 红。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... ./internal/runtime/trace/... 全绿。未 push。
2026-10-26 13:58:02 +08:00
AskaEth 542bac677d fix(trace): UpstreamError 默认不带 error.message,要带必须显式 opt-in
复审 P2 收口。上一条留的漏法(不是理论,是构造得出的):
`{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
—— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了
input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的
记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当
模板,默认带 message 等于把洞复制到十几处。

改法(默认安全,放宽必须写出来):

- `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`:
  最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/
  BodyLen/BodySHA256/Code/Type;
- 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText
  (凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明
  「只有在你确认该上游不会回显用户内容时才可用」;
- 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠
  status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是
  model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时
  报文在调用点上下文里还在。

测试(可变异打红):
- 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下
  error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256
  仍可辨;Attrs() 也不旁路带出;
- 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune
  且凭据被打码(超长 + 带 token 的 message);
- 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」;
- 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒
  opt-in 那条变红。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条
2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用
trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/
orchestration 未动。
2026-10-26 13:31:11 +08:00
AskaEth 6d0df6a825 fix(trace): 白名单放行 session —— 归属标识可读,正文边界不动
主人 2026-10-26 裁定:session 是**业务标识**,不是正文。它是排障时把一条 stderr
日志对回某个会话的唯一线索;而 /admin/flow/graph 本来就按会话返回、连 sessionId
都给了,图里多一个 session 不构成新的暴露。因此把 session 加进共享白名单
(safePropKeys,走 redactValue 的普通清洗),不再被写侧折成 Digest。

「不放宽白名单」仍是原则:本次只放行这一个键,正文的边界一个字没动 ——

- trace 侧新增 TestJSONRecorder_KeepsSessionReadableDropsBody,**一条用例同时钉
  两件相反的事**:JSONL 里 `"session":"web#lane-2"` 原文可读;同一 span 里的
  result / text 哨兵正文仍然搜不到,且只在 omitted 里留下体积与指纹。
- loop 侧真循环用例补一句断言:落盘里 `"session":"sess"` 可读,而工具结果哨兵
  同时仍不可见。
- 变异验证:把 session 从白名单摘掉,上述两处都会变红(session 落到 omitted)。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:20:45 +08:00
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00
AskaEth 4a937bd269 feat(flow): toolCalls 覆盖率追到全部编排回合(L3)—— 图带权威回合标识
- 新增 StartRoundWithTurnID(ctx, owner, text):从 ctx 取权威回合标识(与 chat_messages.turn_id
  同值);Run 的唯一生产建图入口切到它;原 StartRound 语义逐字不变(1 行包装)
- 归属桶(并行按路分图)保持不变;落盘查询键 lane_turn_id 对单路也有值 ⇒ 消息面按等值查得到图
- 无权威标识时**保持缺席**(不拿桶里的路标识顶上:不许臆造)
- 不需要迁移 0024(0023 的列+索引已在位;老单路行标识根本没存过,回填=编造)

形状变化(已登记契约):graph.id 对单路也追加 #{回合标识}(web-7 → web-7#turn-…),
顺带修掉「重启后单路轮次 upsert 掉旧行」的隐患。3 个变异自检(退回老入口/无视 ctx/臆造回退)。
2026-10-06 14:12:25 +08:00
AskaEth 89434eadef fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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
AskaEth 1bf85a955b feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
  铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
  无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
2026-10-05 15:38:53 +08:00
AskaEth b99268d019 feat(trace,loop,orchestration): memory/guardrail/orchestration 真 span + 参数摘要脱敏 + Guardrail 输出侧异步(裁决 11.4.2/11.4.3/13.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 11.4.2:三类节点首次真进图 —— memory.recall/memory.write(loop/memory.go 召回与写入点)、
  guardrail.input/output(流式主路径两侧 + agent.RunStep 静音路径)、orchestration.spawn
  (scheduler 每次派发/收束);新增 trace.ChildSpan 让不持有 Tracer 的叶子包也能开子 span
- 11.4.3(设计先行,规格先写进 design/19 §1.1):tool 节点参数摘要 —— 放行表 argValueKeys +
  trace.IsSecretKey 统一判据(密钥类键不给值)+ 单值 80 rune / ≤24 条 + 出图前再清洗;
  前端 NodeDetail 新增「参数摘要」区,未放行键显示「值未展示」
- 13.4.1:流式主路径输出侧护栏改后台 goroutine(脱离 context、30s 兜底、不阻塞回合),
  输入侧仍在模型调用前同步拦下;拦截闭环到 guardrail 节点 + 审计事件
- 拆文件:scheduler.go 加 span 后 546 行 → 编排 span 助手拆出 scheduler_spans.go(回到 492)
- 契约:无帧名/事件形状变化;graph_update 节点 props 新增**可选**键(tool args_keys/args_summary、
  guardrail action/allow/elapsed_ms、memory action/scope/hits/…、orchestration subagent/persona),
  文档同步由主会话统一落
2026-10-04 12:31:20 +08:00
AskaEth 6e7ec56f9a feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00
AskaEth ec292081b2 refactor(trace): 拆 graph.go 471 行 → graph.go / graph_build.go / graph_props.go(纯搬移)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-B:该文件距 500 行红线仅 29 行,而后续
11.4.2(memory/guardrail/orchestration 三类 span 生产者)等改动必然推进它 ⇒ 先拆。

- graph.go(159):对外类型与快照 API(FlowStatus* / FlowNode / FlowEdge / FlowGraph /
  NodeSpec / RunGraph;NewRunGraph / Start / SeedInput / SeedOutput / Snapshot)
- graph_build.go(245):构图与折叠(Upsert / rememberParent / laneOf / merge / addEdge /
  AddSpan / AddSpanStart / kindForSpan / labelForSpan / statusForFinished)
- graph_props.go(77):prop 白名单与脱敏(safeSpanProps / safePropKeys / summarize / truncate)

纯搬移机械证明:拆前与拆后(取非空、非 package、非 import 块行)排序后 **md5 相同、
diff 为空**;声明集合 28 条守恒;唯一新增文本 = 两个新文件的 package 行与 import 块。
`safePropKeys`(含 4bf1f7c 刚加入的 "persona")原样落在 graph_props.go,逐字节未动。

验证:go build/vet 绿;internal/runtime/trace + internal/services/gateway 测试绿(含
graph_persona_test 与 flow_update_ws_test 的 graph_update 线上帧成对契约回归);
linux/amd64 + darwin/arm64 交叉编译绿;三文件 159/245/77 行均 ≤500;gofmt -l 无输出。
2026-10-04 10:46:13 +08:00
AskaEth 4bf1f7cac6 fix(trace): 让 graph_update 的分支根节点 props.persona 真的前送(白名单漏登记)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
审计(DevDocs/audit/11-trace-observability.md 发现 #1):scheduler 用
span.SetProp("persona", sub.PersonaID) 设了这个 prop,api-client v0.47 也已把它写进
对外承诺,但 trace/graph.go 的 safePropKeys 白名单不含 "persona",而 safeSpanProps
只遍历白名单 ⇒ 该键在到达客户端前被静默丢弃(实测:整个 props 键都消失)。

修法:把 "persona" 加进 safePropKeys,并在同处写清它为什么可以前送 ——
① 它必然是注册表校验过的角色 id(scheduler.checkPersona 未知 id 一律 ErrUnknownPersona
拒绝整批派发);② 它本来就随 GET /personas 与 message_persisted.personaId 对外可见;
③ 值仍过 redactValue → RedactText(凭据形状清洗 + 80 rune 截断)。

测试:新增 trace/graph_persona_test.go(真 Tracer → 真 SetProp → 真 Snapshot,两条断言:
有 persona / 不指定角色时键不出现 = 钉住 v0.47 的"纯增量")+ 网关 flow_update_ws_test.go
追加 TestFlowGraphUpdateRelaysPersonaProp(真 bus Publish → 真 WS 帧 → 断言帧内节点
props.persona=="yuki")。

验证:go build/vet 绿;trace + gateway 包测试绿;全树 go test ./... = 58 包 ok / 0 FAIL;
linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净;三文件 471/57/370 行、最长函数 64 行。
变异验证:把 persona 从白名单摘掉 → 两条断言分别 FAIL(trace 层 props 为空;
WS 帧里 span-sub 节点的 props 键整个消失),还原后复绿。

遗留(登记):前端尚未消费该键(flow 节点未显示"以谁的身份替班")。
2026-10-04 10:37:22 +08:00
AskaEth de1fa83b79 feat(orchestration,loop,flow): 接线子代理编排 — 派发入口 + 分支归父会话 + 节点日志(含脱敏)
修的是「文档说 ✅、生产不可达」:此前 Scheduler.Spawn 只有测试调用方、无 bus 入口、无派发工具;Span.Record 无生产调用方 → 泳道画不出、节点日志恒空。
- 入口:subagent_spawn 工具(internal/runtime/orchestration/spawntool.go,同包便于用真 Scheduler/loop/Tracer/Flow 做集成测试;注册在 cmd/cyrene/subagent_tool.go 照 terminal_tool 范式)。默认 owner-only,不设 AllowInGroup/AllowInStranger;安全仍由 restrictSchemas + applyToolSecurityGate 双层保证,未绕代审
- 父会话归属:AgentSpec.ParentSession(请求语义)→ Scheduler 转运行期 ctx 标记 loop.WithFlowBranch → 分支 span 以父 session 发布;**allow 谓词一行未改**;单图语义靠「分支 Run 不调 StartRound/EndRound」+ FlowPublisher 不新建图;未归属 spawn 一张图都不发(有测试锁)
- 前台最多等 2s(对齐 task.autoBackgroundThreshold)→ 合并结果作工具结果;转后台则广播 bus subagent.result(design/05 §9 回填 hook);新增 Scheduler.Wait(ids) 按批等待(Join 会串并发批次,故不重用)
- 子代理跑在 WithoutCancel(ctx):保留 span 归属、不被工具超时/回合结束顺手杀掉;生命周期由 PerAgentTimeout + CancelAll 约束;内部 WithMutedBroadcast 静音(不进前台 stream)
- 节点日志:loop/spanlog.go 新增 llm.done / tool.done / subagent.done;工具参数**只记键名不记值**;仅步骤真正结束才追加(跳过/被拒路径不发,不伪造)
- 脱敏(红线):新增 trace/redact.go(形状规则 sk-/Bearer/JWT/key:value + 键名规则 api_key/token/authorization/password…,递归嵌套、不改调用方事件),graph.go 发布前必经;**顺带修掉一个既有泄漏**:result_summary prop 原先未脱敏(工具结果里的密钥会进图),由图级变异测试抓出
- 测试:新增 22 个(工具级 / 图级 / 日志级),含 4 次变异验证(去掉 WithFlowBranch、去掉 record*、还原未脱敏、去掉静音 各自 FAIL);-race 过 orchestration+trace+loop
- 已知限制:真实模型端到端未验(无凭据/不起服务);工具白名单是 design gap 未做;subagent.result 暂无消费者(design/15 未实现);后台子代理跨轮次边界迟到 span 会丢泳道根;PerAgentTimeout=10min 待确认
2026-10-03 19:39:38 +08:00
AskaEth 2c100fe3d9 feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
2026-10-03 19:21:10 +08:00
AskaEth 29aa60d0eb test(platform): 修 Windows 上平台脆弱的断言(不削弱测试意图)
- trace/task: 亚 tick 耗时合法读到 0(本机时钟步长约 0.44ms),改为断言非负且 Duration == EndedAt-StartedAt
- user/runtimeconfig: Windows 无法表示普通文件的 POSIX 权限位(Stat 恒 0666),0600 断言仅在非 Windows 生效,注释写明实现确实请求 0600
- exec: MSYS pwd 把同一目录渲染成 /tmp/...;改用「标记文件相对名解析」+「与显式 root 的 pwd 输出逐字相等」两条跨平台断言 + 变异验证
- 只读排查全仓同类脆弱断言,结果记入 DevDocs/工作小结
2026-10-03 16:08:52 +08:00
Aska 44e7a5c2c5 feat(flow): 实时全链路流程可视化 P1——trace→graph_update→前端全链路流程页(design/19)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端: trace/graph.go(RunGraph 图构建器,kind/status 映射,父子优先连线,脱敏白名单+截断);
       loop/flow.go(FlowPublisher 按会话增量发布 ws.graph_update,仅主人web会话); loop span
       start/finish 折入图; run.go StartRound/EndRound; wire.go 组装+注入.
前端: Flow.tsx(全链路流程页:FlowGraphView+会话/节点/开始/耗时卡片+状态计数+节点点开日志);
       FlowGraph 组件加 onNodeClick; types/wsParse 扩展; router/sections/AdminApp 注册 flow;
       api-client.md §3.5 graph_update 契约.
验证: 后端 build/vet/test 全绿+flow_test.go 集成(span→graph→ws.graph_update主干); 前端 build 绿.
红线: 单二进制零新依赖; 工具参数/结果脱敏; 仅主人web会话; 身份靠actor元数据; 只读不虚构.
P2(并行子代理分支/泳道/节点实时日志)后置.
2026-09-07 19:10:57 +08:00
Aska e33b005612 feat(trace): 深追踪(Span 生命周期/父子链/JSONL recorder,支持 llm/tool/state 等类别) 2026-09-06 05:06:41 +08:00