Files
Agent-System/internal/taskrun/phase_snapshot.go
T
weichenandCursor 561709178a fix(taskrun): 收口 completed 步骤时归一化冻结的进度中 label
步骤从 running 收口为 completed 时,此前只翻转 Status,Label 永久冻结为
上游下发的"正在…(NN%)"进度中叙述,导致卡片即便步骤已完成仍读起来像还在跑
(缺陷见消息 #4424)。

新增导出函数 NormalizeSettledStep:completed 步骤改写为短标签 + "已完成",
保留"(第 x/y 步)"结构后缀以区分多步任务,原始叙述留在 Detail 供审计;
failed/pending/running 有意不改(失败细节由错误气泡承载)。
接入 applyPhasePayload 三处收口分支、existing-step 刷新分支,
以及 BuildPhaseSnapshot 终态收口 + 存量兜底全量归一化。

导出供回填脚本复用,避免逻辑漂移。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 10:32:38 +00:00

259 lines
8.0 KiB
Go
Raw 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 taskrun
import (
"fmt"
"regexp"
"strings"
"chat-agent-system/internal/stream"
)
// stepIndexSuffixRe 匹配 decoratePhaseLabel 产生的"(第 x/y 步)"结构后缀。
var stepIndexSuffixRe = regexp.MustCompile(`(第 \d+/\d+ 步)`)
// PhaseStepSnapshot 折叠后的单步进度(与前端 PhaseEvent 字段对齐)。
type PhaseStepSnapshot struct {
Step string `json:"step"`
Icon string `json:"icon"`
Label string `json:"label"`
Status string `json:"status"` // pending | running | completed | failed
Detail string `json:"detail,omitempty"`
}
// PhaseSnapshot 写入 chat_messages.metadata.task_phase_snapshot,供刷新后零请求重建卡片。
type PhaseSnapshot struct {
Steps []PhaseStepSnapshot `json:"steps"`
Label string `json:"label,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Status string `json:"status,omitempty"`
Title string `json:"title,omitempty"`
}
type phaseMeta struct {
Icon string
Label string
}
var phaseMetaTable = map[string]phaseMeta{
"squad_assembly": {Icon: "👥", Label: "组建小队"},
"knowledge_retrieval": {Icon: "📚", Label: "知识检索"},
"knowledge_retrieved": {Icon: "📚", Label: "知识命中"},
"step_executing": {Icon: "⚙️", Label: "专员执行"},
"step_completed": {Icon: "✅", Label: "步骤完成"},
"quality_gate": {Icon: "🛡️", Label: "质量门禁"},
"advisor_briefing": {Icon: "💡", Label: "参谋简报"},
"stage_transition": {Icon: "🚀", Label: "阶段跃迁"},
"stage_started": {Icon: "🚀", Label: "阶段启动"},
"stage_completed": {Icon: "🎉", Label: "阶段完成"},
"step_failed": {Icon: "⚠️", Label: "步骤异常"},
"task_failed": {Icon: "⚠️", Label: "任务异常"},
}
var closePhases = map[string]struct{}{
"step_completed": {},
"stage_completed": {},
}
// BuildPhaseSnapshot 把 run 的 StoredEvents 折叠为可持久化快照(与前端 applyPhaseEvent 语义对齐)。
func BuildPhaseSnapshot(events []StoredEvent, durationMs int64, status, title string) *PhaseSnapshot {
steps, label := FoldPhaseEvents(events)
if len(steps) == 0 && strings.TrimSpace(label) == "" && durationMs <= 0 && strings.TrimSpace(title) == "" {
if strings.TrimSpace(status) == "" {
return nil
}
}
// 终态时把仍 running 的步骤收口为 completed / failed,避免刷新后卡片卡在旋转态。
if isTerminalTaskStatus(status) {
fail := status == StatusFailed || status == StatusFinalizingFailed || status == StatusQualityFailed || status == StatusInterrupted
for i := range steps {
if steps[i].Status == "running" {
if fail {
steps[i].Status = "failed"
} else {
steps[i].Status = "completed"
}
steps[i] = NormalizeSettledStep(steps[i])
}
}
}
// 写出快照前再统一归一化 completed 步骤 label:applyPhasePayload 各收口分支已归一化,
// 此处作为防御性兜底,确保任何路径落库的 completed 步骤文案都与终态一致(幂等)。
for i := range steps {
steps[i] = NormalizeSettledStep(steps[i])
}
return &PhaseSnapshot{
Steps: steps,
Label: label,
DurationMs: durationMs,
Status: status,
Title: strings.TrimSpace(title),
}
}
func isTerminalTaskStatus(status string) bool {
_, ok := terminalStatuses[status]
return ok
}
// FoldPhaseEvents 纯折叠:phase 事件 → 步骤序列 + 最近阶段文案。
func FoldPhaseEvents(events []StoredEvent) ([]PhaseStepSnapshot, string) {
strip := make([]PhaseStepSnapshot, 0, 8)
label := ""
for _, se := range events {
ev := se.Event
if ev.Type != stream.EventPhase || ev.Phase == nil || ev.Phase.PhaseID == "" {
continue
}
phase := ev.Phase
if msg := strings.TrimSpace(phase.Message); msg != "" {
label = msg
}
strip = applyPhasePayload(strip, phase)
}
return strip, label
}
func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) []PhaseStepSnapshot {
meta, ok := phaseMetaTable[phase.PhaseID]
if !ok {
meta = phaseMeta{Icon: "⏳", Label: "任务推进"}
}
detail := strings.TrimSpace(phase.Message)
base := detail
if base == "" {
base = meta.Label
}
label := decoratePhaseLabel(base, phase)
key := phaseStepKey(phase)
if idx := indexOfStep(strip, key); idx >= 0 {
cur := strip[idx]
cur.Label = label
if detail != "" {
cur.Detail = detail
}
if cur.Status == "failed" && phase.PhaseID != "step_failed" {
cur.Status = "running"
}
// 已收口步骤被重复事件刷新文案时,保持 label 与 status 语义一致。
strip[idx] = NormalizeSettledStep(cur)
return strip
}
if phase.PhaseID == "step_failed" {
if hasRunning(strip) {
out := make([]PhaseStepSnapshot, len(strip))
copy(out, strip)
for i := range out {
if out[i].Status == "running" {
out[i].Status = "failed"
if detail != "" {
out[i].Detail = detail
}
// 失败节点有意保留原步骤文案(红色图标 + 错误气泡承载细节),不归一化 label。
}
}
return out
}
return append(strip, PhaseStepSnapshot{
Step: key, Icon: meta.Icon, Label: label, Status: "failed", Detail: detail,
})
}
if _, close := closePhases[phase.PhaseID]; close {
out := make([]PhaseStepSnapshot, len(strip))
copy(out, strip)
for i := range out {
if out[i].Status == "running" {
out[i].Status = "completed"
out[i] = NormalizeSettledStep(out[i])
}
}
return out
}
settled := make([]PhaseStepSnapshot, len(strip))
copy(settled, strip)
for i := range settled {
if settled[i].Status == "running" {
settled[i].Status = "completed"
settled[i] = NormalizeSettledStep(settled[i])
}
}
return append(settled, PhaseStepSnapshot{
Step: key, Icon: meta.Icon, Label: label, Status: "running", Detail: detail,
})
}
// phaseIDOfStep 从 step key 还原出 phase_id(去掉 "#<idx>" 后缀)。
func phaseIDOfStep(step string) string {
if i := strings.IndexByte(step, '#'); i >= 0 {
return step[:i]
}
return step
}
// NormalizeSettledStep 把处于 completed 终态的步骤标签归一化为静态措辞。
//
// 收口时若只翻转 Status 而不动 Label,label 会永久冻结成"正在…(NN%)""(第 x/y 步)"
// 这类进度中叙述,导致卡片即便步骤已完成仍读起来像"还在跑"(缺陷见消息 #4424)。
// 这里统一改写成 phaseMetaTable 短标签 + "已完成",Detail 保留原始叙述供审计。
//
// 仅处理 completed:failed 步骤有意保留原步骤文案(红色图标 + 错误气泡承载失败细节);
// pending / running 不改动。导出供回填脚本复用,避免逻辑漂移。
func NormalizeSettledStep(step PhaseStepSnapshot) PhaseStepSnapshot {
if step.Status != "completed" {
return step
}
short := phaseMetaTable[phaseIDOfStep(step.Step)].Label
if short == "" {
short = "任务推进"
}
const suffix = "已完成"
// 保留"(第 x/y 步)"结构后缀(多步任务需区分进步骤),丢弃百分比与进度中叙述。
label := short + suffix
if loc := stepIndexSuffixRe.FindStringIndex(step.Label); loc != nil {
label = short + step.Label[loc[0]:loc[1]] + suffix
}
if step.Label == label {
return step // 幂等
}
step.Label = label
return step
}
func phaseStepKey(phase *stream.PhasePayload) string {
if phase.StepIndex > 0 {
return fmt.Sprintf("%s#%d", phase.PhaseID, phase.StepIndex)
}
return phase.PhaseID
}
func decoratePhaseLabel(base string, phase *stream.PhasePayload) string {
if phase.StepIndex > 0 && phase.TotalSteps > 0 {
return fmt.Sprintf("%s(第 %d/%d 步)", base, phase.StepIndex, phase.TotalSteps)
}
if phase.Progress > 0 {
return fmt.Sprintf("%s(%d%%)", base, phase.Progress)
}
return base
}
func indexOfStep(strip []PhaseStepSnapshot, key string) int {
for i := range strip {
if strip[i].Step == key {
return i
}
}
return -1
}
func hasRunning(strip []PhaseStepSnapshot) bool {
for i := range strip {
if strip[i].Status == "running" {
return true
}
}
return false
}