fix(coordinator,intent): nil AdvisorBrief panic guard + interrogative gate for ADVANCE_STAGE overmatch

This commit is contained in:
weichen
2026-09-25 04:04:30 +00:00
parent 725181de4a
commit bc94080332
5 changed files with 231 additions and 2 deletions
@@ -0,0 +1,44 @@
---
title: "阶段推进 AdvisorBrief 空指针 panic 修复"
task_id: fix-stage-brief-nil-panic-2026-09-25-r2
status: in_progress
created: 2026-09-25
owner: Cursor
task_type: fix
policy_revision: "AGENTS.md @ 725181de"
branch: fix/fix-stage-brief-nil-panic-2026-09-25(实施并入 dialogue-context-injection-2026-09-25 scope 持有者 worktree)
worktree: .worktrees/fix-stage-brief-nil-panic-2026-09-25(注册用)/ 实际代码在 .worktrees/dialogue-context-injection-2026-09-25
base_dev_sha: "725181de4aa4480f77c52e40eeaef040c6563c3f"
write_scope:
- internal/coordinator/engine_stage.go
- internal/coordinator/engine_test.go
- internal/intent/recognizer_matchers_task.go
- .doc/test-reports/2026-09-25-stage-brief-nil-panic-fix-test-report.md
mutex: coordinator-nil-panic-fix
review_state: accepted_with_evidence
integration_state: pending
deployment: not_required
cleanup: pending
remote_sync: not_requested
---
# 阶段推进 AdvisorBrief 空指针 panic 修复
## 用户目标与授权
- 总控 `/goal`(2026-09-25)批次 P0:消除 dev 基线存量 panic。
- scope 冲突处置:`internal/coordinator` 被 `dialogue-context-injection-2026-09-25-r2` 声明(hard same_scope),按 catalog 规则「合并到持有者任务」——修复作为独立提交挂入持有者 worktree 分支。
## 修复内容
1. **panic**:`engine_stage.go` `stageRes.AdvisorBrief` nil 保护 + 降级文案。
2. **意图误判**:`matchAdvanceStage` 前置 `isInterrogative` 疑问句排除(问句不构成推进指令)。
3. **回归测试**:`TestCoordinatorEngine_AdvanceStageNilBriefNoPanic`。
## 验证
见 [测试报告](../../test-reports/2026-09-25-stage-brief-nil-panic-fix-test-report.md):PASS。
## 残差
- `internal/chat` 的 `TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord` 存量失败(dev 基线同款,非本批引入),登记后续批次。
@@ -0,0 +1,95 @@
# 阶段推进 AdvisorBrief 空指针 panic 修复测试报告
- task: `fix-stage-brief-nil-panic-2026-09-25-r2`(合入 `dialogue-context-injection-2026-09-25-r2` scope;独立分支 `fix/fix-stage-brief-nil-panic-2026-09-25` 因 scope 硬重叠并入持有者任务执行)
- date: 2026-09-25
- tested_worktree: `.worktrees/dialogue-context-injection-2026-09-25`
- base: `725181de`(dev tip;本 worktree 分支 tip `dc29b84c` 之上)
- verdict: PASS
## 一、 测试背景
总控批次规划(`planned-tasks-closeout-plan-2026-09-25`)P0 项:dev 基线 `TestBizDev_CaseRecommendation_FullLink_E2E` 稳定 panic,阻塞所有后续批次的测试可信度。
## 二、 测试目标
1. 确认 panic 根因并修复;
2. 意图误判层根因(问句被 `做.*报价` 误判为 ADVANCE_STAGE)一并修复;
3. 回归无新增失败。
## 三、 测试方案与环境
- Linux 7.0.0-31-generic;Go 1.27.1(linuxbrew)
- 层级:工程工具/单元(非产品 PI;验证契约工程修复口径)
- 用例:新增回归 `TestCoordinatorEngine_AdvanceStageNilBriefNoPanic` + 既有 E2E 复跑 + 关联包全量
## 四、 预期结果
- nil brief 推进返回 accepted + 降级文案,不 panic;
- 问句「我们之前做过类似的…吗?型检和报价大概怎么定的?」路由到 QUERY_SIMILAR_CASES;
- E2E `TestBizDev_CaseRecommendation_FullLink_E2E` 全绿。
## 五、 实际测试结果与判定
### 5.1 panic 复现(修复前,dev 基线 725181de 同样复现)
**输入**:`go test -run 'TestBizDev_CaseRecommendation_FullLink_E2E' ./internal/bizdev/`
**实际输出**(节选):
```
panic: runtime error: invalid memory address or nil pointer dereference
internal/coordinator/engine_stage.go:306 +0x240c
internal/coordinator/engine.go:281 +0x1fb4
internal/chat/session.go:467 +0x21b0
internal/bizdev/case_recommend_e2e_test.go:133 +0x2425
```
**评价判定**:FAIL(存量缺陷,非本批引入)
### 5.2 根因
1. **panic 层**:`engine_stage.go:306` 裸解引用 `stageRes.AdvisorBrief.MaturityHeadline`;空 `executedArtifacts` 时 `primaryArtifact=nil → stageBrief=nil`。
2. **意图层**(修复 1 后暴露):问句「…报价大概怎么定的?」被 `matchAdvanceStage` 的 `做.*报价` 命中(「定」∈`做.*`)误判 ADVANCE_STAGE,进入无产物阶段推进路径。
### 5.3 修复
1. `engine_stage.go`:`stageRes.AdvisorBrief != nil` 分支保护 + 降级文案「未生成阶段级参谋简报」。
2. `intent/recognizer_matchers_task.go`:新增 `isInterrogative`(句尾 ?/吗/呢 + 疑问词表),`matchAdvanceStage` 疑问句直接不匹配。
### 5.4 回归测试(新增)
**输入**:`go test -run 'TestCoordinatorEngine_AdvanceStageNilBriefNoPanic' -v ./internal/coordinator/`
**实际输出**:
```
=== RUN TestCoordinatorEngine_AdvanceStageNilBriefNoPanic
2026/09/25 01:50:24 INFO coordinator_route intent=ADVANCE_STAGE ... channel=SQUAD_FIXED
--- PASS: TestCoordinatorEngine_AdvanceStageNilBriefNoPanic (0.00s)
PASS
```
**评价判定**:PASS
### 5.5 原 E2E 复跑
**输入**:`go test -run 'TestBizDev_CaseRecommendation_FullLink_E2E' ./internal/bizdev/`
**实际输出**:`ok chat-agent-system/internal/bizdev 0.063s`
**评价判定**:PASS(案例推荐 + AdvisorBrief 注入断言全过)
## 六、 自动化测试套件汇总
| 套件 | 结果 |
|---|---|
| `go test ./internal/intent/` | PASS |
| `go test ./internal/coordinator/` | PASS |
| `go test ./internal/bizdev/` | PASS(含原 panic E2E) |
| `go test ./internal/squad/ ./internal/dialoguectx/ ./internal/conflictresolve/ ./internal/ctxmgr/` | PASS |
| `go test ./internal/chat/` | FAIL:`TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord` —— **dev 基线 725181de 同款失败(存量,与本修复无关,已对照确认)** |
## 七、 测试结论与后续
- P0 达到授权交付终点:panic 消除 + 意图误判修正 + 回归绿。
- 存量遗留:chat 语义记忆异步轮记录用例失败(独立问题,登记为后续批次候选,不冒充本批 PASS)。
+7 -1
View File
@@ -303,7 +303,13 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
sb.WriteString(fmt.Sprintf("已为您成功推进并完成了【%s】!\n", stageRes.StageTitle))
}
sb.WriteString("相关论证与文档已沉淀完成。\n\n")
sb.WriteString(fmt.Sprintf("💡 最新进展:%s\n", stageRes.AdvisorBrief.MaturityHeadline))
// AdvisorBrief 可为 nil:阶段全部子任务 halt/失败时 executedArtifacts 为空,stageBrief 未赋值。
// 裸解引用会在无产物推进场景 panic(见 2026-09-25 nil-panic 修复)。
if stageRes.AdvisorBrief != nil {
sb.WriteString(fmt.Sprintf("💡 最新进展:%s\n", stageRes.AdvisorBrief.MaturityHeadline))
} else {
sb.WriteString("💡 最新进展:阶段推进完成,但未生成阶段级参谋简报。\n")
}
sb.WriteString(fmt.Sprintf("建议您审阅交付文档(%s),或按下方建议推进后续洽谈。\n\n", artifactPath))
sb.WriteString("可选下一步:\n")
for idx, opt := range nextExp.Options {
+57 -1
View File
@@ -2,15 +2,71 @@ package coordinator_test
import (
"context"
"strings"
"testing"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/maturity"
"chat-agent-system/internal/squad"
"strings"
)
// nilBriefStageRunner 模拟阶段任务全部 halt / 无已执行产物:AdvisorBrief 为 nil。
type nilBriefStageRunner struct{}
func (nilBriefStageRunner) ExecuteTask(ctx context.Context, req stageflow.TaskRunRequest) (*stageflow.TaskRunResult, error) {
return &stageflow.TaskRunResult{}, nil
}
func (nilBriefStageRunner) ExecuteStage(ctx context.Context, req stageflow.StageRunRequest) (*stageflow.StageRunResult, error) {
return &stageflow.StageRunResult{
Stage: req.Stage,
StageTitle: "可行性论证",
Success: true,
// PrimaryArtifact / AdvisorBrief 均为 nil —— 复现 2026-09-25 nil-deref panic
}, nil
}
// 回归:空 executedArtifacts 时 stageBrief=nil,旧代码在 NaturalReply 组装处
// 裸解引用 stageRes.AdvisorBrief 导致 panic(TestBizDev_CaseRecommendation_FullLink_E2E 亦触发)。
func TestCoordinatorEngine_AdvanceStageNilBriefNoPanic(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
engine.SetStageTaskRunner(nilBriefStageRunner{})
userCtx := &coordinator.UserProjectContext{
Project: coordinator.ProjectInfo{
Name: "超声测试项目",
CurrentStage: maturity.StageV01_Triage,
},
User: coordinator.UserInfo{ProjectRole: "composer"},
BlackboardFacts: make(map[string]string),
}
recRes := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentAdvanceStage,
FilledSlots: map[string]string{
intent.SlotTargetStage: string(maturity.StageV05_Feasibility),
},
},
}
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
if err != nil {
t.Fatalf("process intent failed: %v", err)
}
if res.Status != coordinator.StatusAccepted {
t.Fatalf("expected status ACCEPTED, got %s", res.Status)
}
if !strings.Contains(res.NaturalReply, "未生成阶段级参谋简报") {
t.Errorf("expected graceful nil-brief fallback in reply, got: %s", res.NaturalReply)
}
if userCtx.LatestAdvisorBrief != nil {
t.Errorf("expected LatestAdvisorBrief to stay nil, got %+v", userCtx.LatestAdvisorBrief)
}
}
func TestCoordinatorEngine_QueryProgress(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
@@ -11,7 +11,35 @@ import (
"chat-agent-system/internal/squad"
)
// isInterrogative 判断输入是否疑问句:句尾问号/「吗」「呢」,或句中出现
// 「怎么/如何/什么/多少/哪/是否/大概/多久/为什么」等疑问词。
// 用于把「查询/咨询」类输入从祈使句「指令」类意图(阶段推进等)中排除。
func isInterrogative(input string) bool {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return false
}
lastRune, _ := utf8.DecodeLastRuneInString(trimmed)
if lastRune == '?' || lastRune == '?' || lastRune == '吗' || lastRune == '呢' {
return true
}
questionWords := []string{"怎么", "如何", "什么", "多少", "哪些", "哪一", "是否",
"大概", "多久", "为什么", "是不是", "有没有", "何时", "什么时候", "什么样的"}
for _, w := range questionWords {
if strings.Contains(input, w) {
return true
}
}
return false
}
func matchAdvanceStage(input string, currentStage maturity.MaturityStage) *CandidateIntent {
// 疑问句不构成推进指令:「…做过类似的…吗?报价大概怎么定的?」属于案例/知识查询,
// 曾被 "做.*报价" 过宽匹配误判为 ADVANCE_STAGE(见 2026-09-25 意图误判修复)。
// 疑问标记:句尾问号/语气词「吗」「呢」,或「怎么/什么/多少/哪/是否/大概」等疑问词。
if isInterrogative(input) {
return nil
}
patterns := []string{
"进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证",
"方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定",