步骤从 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>
259 lines
8.0 KiB
Go
259 lines
8.0 KiB
Go
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
|
||
}
|