diff --git a/.doc/ops/short-term/2026-09-25-stage-brief-nil-panic-fix.md b/.doc/ops/short-term/2026-09-25-stage-brief-nil-panic-fix.md new file mode 100644 index 00000000..c2889aec --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-stage-brief-nil-panic-fix.md @@ -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 基线同款,非本批引入),登记后续批次。 diff --git a/.doc/test-reports/2026-09-25-stage-brief-nil-panic-fix-test-report.md b/.doc/test-reports/2026-09-25-stage-brief-nil-panic-fix-test-report.md new file mode 100644 index 00000000..db2e2f4e --- /dev/null +++ b/.doc/test-reports/2026-09-25-stage-brief-nil-panic-fix-test-report.md @@ -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)。 diff --git a/internal/coordinator/engine_stage.go b/internal/coordinator/engine_stage.go index 281c760e..15f28cb8 100644 --- a/internal/coordinator/engine_stage.go +++ b/internal/coordinator/engine_stage.go @@ -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 { diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 4077eba3..72cd7bf4 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -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) diff --git a/internal/intent/recognizer_matchers_task.go b/internal/intent/recognizer_matchers_task.go index 2197fc57..04a97274 100644 --- a/internal/intent/recognizer_matchers_task.go +++ b/internal/intent/recognizer_matchers_task.go @@ -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{ "进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证", "方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定",