From 43e03e4969917b364d8e461c55910bbb05931232 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 14:37:18 +0000 Subject: [PATCH 1/7] fix(docgov): idempotent lookup before busy; SSE write deadline heartbeat Return existing task run 202 for the same turn before scope/preflight so SSE disconnect retries do not hit DOC_BUSY or duplicate chat. Extend task-run events write deadlines with heartbeats without dropping global HTTP WriteTimeout. Reconnect subscribeTaskRunEvents on EOF via after_seq. Co-authored-by: Cursor --- ...09-13-document-revision-sse-idempotency.md | 46 ++++ .doc/ops/CURRENT.md | 12 +- ...09-13-document-revision-sse-idempotency.md | 46 ++++ .doc/remediation/gap-matrix.md | 1 + internal/server/sse.go | 20 ++ internal/server/task_run_handlers.go | 67 +++++- .../server/task_run_idempotent_sse_test.go | 197 ++++++++++++++++++ web/src/api/chatStream.test.ts | 7 + web/src/api/chatStream.ts | 24 ++- web/src/api/taskRuns.test.ts | 73 ++++++- web/src/api/taskRuns.ts | 75 ++++++- 11 files changed, 541 insertions(+), 27 deletions(-) create mode 100644 .doc/audit/2026-09-13-document-revision-sse-idempotency.md create mode 100644 .doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md create mode 100644 internal/server/task_run_idempotent_sse_test.go diff --git a/.doc/audit/2026-09-13-document-revision-sse-idempotency.md b/.doc/audit/2026-09-13-document-revision-sse-idempotency.md new file mode 100644 index 00000000..df1a1468 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-sse-idempotency.md @@ -0,0 +1,46 @@ +--- +title: "任务 run SSE 超时断线后同 turn 误击 DOC_BUSY" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: critical +--- + +# 任务 run SSE 超时断线后同 turn 误击 DOC_BUSY + +## 现象 + +真实 Antigravity UI-03(dev schema 30): + +1. 初始 `POST /api/v1/tasks/runs` → **202** `run_1789308825655387640` +2. `GET .../events` 约 **157s** 后以 `ERR_INCOMPLETE_CHUNKED_ENCODING` 断开(对齐服务端默认 `WriteTimeout=120s`) +3. 浏览器用**完全相同** `session=sess-p6098-u107`、`client_turn_id=turn_1789308825646`、`doc_id=doc_proj6098_intake` 再 POST +4. `handleCreateTaskRun` 在幂等 `ClaimRun` **之前**执行 `preflightTaskDocumentBusy` → **409 DOC_BUSY**,并 `recordBusyPreflightMessages` 写入**重复**用户/失败助手消息 + +## 根因分析 + +| 层 | 问题 | +|---|---| +| HTTP | 全局 `WriteTimeout: 120s` 切断长任务 SSE 响应体,客户端见 incomplete chunked encoding | +| Handler | 同 turn 重试仍走 scope/preflight;活跃小队租约被当成「别人在编」 | +| FE | `subscribeTaskRunEvents` 遇意外 EOF 直接结束,上层可能再次 `createTaskRun` | + +## 严重度 + +`critical` + +## 关联整改 + +- Short-term: [../ops/short-term/2026-09-13-document-revision-sse-idempotency.md](../ops/short-term/2026-09-13-document-revision-sse-idempotency.md) +- 分支: `fix/document-revision-sse-idempotency` + +## 证据 + +- 运行产物目录(勿删):[../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../test-reports/assets/2026-09-13-document-revision-runtime-ui/) +- 前序 UI-03 客户端同步审计:[./2026-09-13-controlled-doc-client-sync-ui03.md](./2026-09-13-controlled-doc-client-sync-ui03.md) +- 现场键:`run_1789308825655387640` / `sess-p6098-u107` / `turn_1789308825646` / `doc_proj6098_intake` + +## 开放问题 + +- 合入前占槽真实 UI 重跑 UI-03(本轮不 rebuild Docker) +- chat `/stream` 与 `tasks/run/stream` 是否需同样 SSE write-deadline 扩展(本轮优先 `/tasks/runs/{id}/events`) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 39a7943b..f5b6bcd4 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV 集成槽 e2e;UI-00~02 PASS;UI-03 工程修复在 `fix/document-revision-runtime-ui03`:post-task sync + goose 00030 + Claim 幂等;schema30 卷迁移与真实 UI-03 待合入前验证) +**production_ready**: no(DOCREV:schema **30** 已合入;真实 UI-03 暴露 SSE WriteTimeout 断线后同 turn 误 409 DOC_BUSY;整改在 `fix/document-revision-sse-idempotency`) ## 焦点 -1. **DOCREV-RT**: [运行时整改收口](short-term/2026-09-13-document-revision-runtime.md)(`fix/document-revision-runtime-ui03`:UI-03 客户端同步/幂等/chk;占槽真实 UI 重跑;不 push) +1. **DOCREV-RT**: [SSE 幂等与写超时](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等先于 busy、SSE heartbeat、FE after_seq 重连;不 push) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term @@ -16,7 +16,8 @@ | Cursor | chat-history-refresh-remediation | fix/chat-history-refresh | - | chat | in_progress | | Codex | conversation-intent-runtime-state | fix/conversation-intent-state | .worktrees/fix-conversation-intent-state | intent | in_progress | | Cursor | document-revision-wave1-6-implementation | feature/document-revision-management | - | docgov | done | -| Cursor-Auto | document-revision-runtime-ui03 | fix/document-revision-runtime-ui03 | - | docgov | in_progress | +| Cursor-Auto | document-revision-runtime-ui03 | fix/document-revision-runtime-ui03 | - | docgov | done | +| Cursor-Auto | document-revision-sse-idempotency | fix/document-revision-sse-idempotency | - | docgov | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -24,7 +25,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Codex/Antigravity | e2e | 2026-09-13 11:16 UTC | DOCREV-RT real browser UI + real PI E2E on schema 29 | - | +| Codex/Antigravity | e2e | 2026-09-13 | DOCREV-RT real UI on **schema 30**;SSE 断线/同 turn 409 现场保留 | Cursor-Auto (sse-idempotency 编码,待占槽冒烟) | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -34,7 +35,7 @@ | 环境 | 端口 | schema | 状态 | |------|------|--------|------| -| dev | 8080 | 28 | healthy | +| dev | 8080 | **30** | healthy(UI-03 现场) | | test | 8081 | 25→27* | healthy | | main | 8000 | 25→27* | healthy(app + runner + DB) | @@ -49,3 +50,4 @@ | Agent 手册 | [AGENTS.md](../../AGENTS.md) | | DOCREV ADR | [../adr/2026-09-13-document-version-boundary.md](../adr/2026-09-13-document-version-boundary.md) | | DOCREV 测试 | [../test-reports/2026-09-13-document-revision-management-test-report.md](../test-reports/2026-09-13-document-revision-management-test-report.md) | +| SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md new file mode 100644 index 00000000..cefe97e0 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -0,0 +1,46 @@ +--- +title: "任务 SSE 写超时与同 turn 幂等先于 busy 预检" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-sse-idempotency +worktree: "-" +mutex: docgov +--- + +# 任务 SSE 写超时与同 turn 幂等先于 busy 预检 + +## 目标 + +同 turn 在 SSE 断线重连时返回既有 run 202、不重复聊天、不误 409 DOC_BUSY;长任务 events SSE 不被 120s WriteTimeout 切断;前端 EOF 按 after_seq 重订阅同一 run。 + +## 范围 + +- `internal/server/task_run_handlers.go`:授权后幂等查找先于 scope/preflight +- `internal/server`:SSE 专用 `ResponseController` 写期限延长 + heartbeat +- `web/src/api/taskRuns.ts` + `chatStream` seq:EOF 重连 +- 单测与文档 + +## 非目标 + +- 不改全局 HTTP 超时为「无超时」;不 rebuild Docker;不 push/merge;不删证据 assets + +## 步骤 + +1. [x] 从 `dev` 开 `fix/document-revision-sse-idempotency`;CURRENT 认领 +2. [x] 幂等先查 + 属主校验 +3. [x] SSE write deadline + heartbeat +4. [x] FE subscribe 重连 +5. [x] 测试与原子提交 + +## 验收标准 + +- [x] 同 turn + 活跃租约 → 同 run 202、仅一对消息;不同 turn → 仍 409 +- [x] SSE 心跳/写期限可测;FE EOF/after_seq 单测 +- [x] `go test -short ./...`、`go vet`、`npm test`/`build`、`git diff --check` + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-sse-idempotency.md](../../audit/2026-09-13-document-revision-sse-idempotency.md) +- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/) diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index 9ed71c8b..70a5f984 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -62,6 +62,7 @@ | ENV-RESTART-SOURCE | `docker compose restart` 只重启旧镜像,健康检查无法证明合入代码已部署 | **PASS** | `env-service.sh rebuild` + 真实摘要重测 | 集成冒烟统一使用 `dev rebuild` | | DOCREV-RUNTIME-404 | revision-state 404;账本内存丢失;块保存/overlay 无效;agent 假报告 | **FAIL** | [audit](../audit/2026-09-13-document-revision-runtime-404.md);`internal/docrevision/service.go` 仅 map | [remediation](./2026-09-13-document-revision-runtime-remediation.md) | | DOCREV-UI03-CLIENT-SYNC | 小队完成后右栏仍模板;UI-03 选择器误命中 Banner;`pending_author_confirmation` 撞 chk;重试重复建 run | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-controlled-doc-client-sync-ui03.md);分支 `fix/document-revision-runtime-ui03`:post-task sync、templateMatch intake 优先、goose 00030、Claim+idempotency | 合入前:schema30 **真实现卷**迁移独立验证;占槽真实 UI-03 重跑;勿把 stub 当验收 | +| DOCREV-SSE-IDEM-BUSY | SSE WriteTimeout 断线后同 turn 再 POST 误 409 DOC_BUSY + 重复聊天 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-sse-idempotency.md);证据 [assets/2026-09-13-document-revision-runtime-ui](../test-reports/assets/2026-09-13-document-revision-runtime-ui/);`fix/document-revision-sse-idempotency` | 占槽真实 UI-03 重跑;不删证据 | --- diff --git a/internal/server/sse.go b/internal/server/sse.go index 3324df79..d19cbc8d 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -42,6 +42,26 @@ type StreamChatRequest struct { ClientTurnID string `json:"client_turn_id,omitempty"` } +// 长任务 SSE:不取消全局 WriteTimeout,仅在流式响应上周期性延长写期限并发心跳。 +const ( + sseWriteDeadlineExtend = 45 * time.Second + sseHeartbeatInterval = 15 * time.Second +) + +func extendSSEWriteDeadline(w http.ResponseWriter) { + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Now().Add(sseWriteDeadlineExtend)) +} + +func writeSSEHeartbeat(w http.ResponseWriter, flusher http.Flusher) error { + extendSSEWriteDeadline(w) + if _, err := fmt.Fprintf(w, ": heartbeat %d\n\n", time.Now().Unix()); err != nil { + return err + } + flusher.Flush() + return nil +} + func writeSSE(w http.ResponseWriter, flusher http.Flusher, ev stream.StreamEvent) { data, err := json.Marshal(ev) if err != nil { diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 8b7fcbd2..3c53b0fa 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -76,6 +76,29 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } req.WorkDir = "" + // 授权后、scope/preflight 前:稳定幂等键命中则直接 202,避免 SSE 断线重试误击 DOC_BUSY / 重复写聊天。 + idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID) + if idemKey != "" && s.taskRuns != nil { + if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { + http.Error(w, findErr.Error(), http.StatusInternalServerError) + return + } else if existing != nil { + if !taskRunMatchesIdempotentOwner(existing, req) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "幂等键已存在但不属于当前用户/会话/项目", + }) + return + } + writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ + RunID: existing.RunID, + WorkspaceID: existing.WorkspaceID, + Status: existing.Status, + }) + return + } + } + ctxMgr := s.svc.ContextManager() if ctxMgr != nil { _, _ = ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName) @@ -104,7 +127,6 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } // 幂等两阶段:先 Claim 占位,仅 owner 写聊天并 Start;loser 不得写消息。 - idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID) runID := fmt.Sprintf("run_%d", time.Now().UnixNano()) record := &taskrun.RunRecord{ RunID: runID, @@ -184,6 +206,16 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { }) } +func taskRunMatchesIdempotentOwner(existing *taskrun.RunRecord, req createTaskRunRequest) bool { + if existing == nil { + return false + } + return existing.UserID == req.UserID && + existing.SessionID == req.SessionID && + existing.ProjectID == req.ProjectID && + existing.ClientTurnID == req.ClientTurnID +} + func (s *Server) recordBusyPreflightMessages( ctx context.Context, ctxMgr *ctxmgr.ContextManager, @@ -310,6 +342,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { s.writeCORS(w, r) w.Header().Set("X-Accel-Buffering", "no") flusher.Flush() + extendSSEWriteDeadline(w) ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq) if err != nil { @@ -318,26 +351,48 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { } defer s.taskRuns.Unsubscribe(runID, ch) - writeStoredEvents := func(events []taskrun.StoredEvent) { + writeStoredEvents := func(events []taskrun.StoredEvent) error { for _, stored := range events { data, _ := json.Marshal(stored.Event) - fmt.Fprintf(w, "id: %d\n", stored.Seq) - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", stored.Event.Type, string(data)) + if _, err := fmt.Fprintf(w, "id: %d\n", stored.Seq); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", stored.Event.Type, string(data)); err != nil { + return err + } } flusher.Flush() + extendSSEWriteDeadline(w) + return nil } - writeStoredEvents(replay) + if err := writeStoredEvents(replay); err != nil { + return + } + for _, stored := range replay { + if stored.Event.Type == stream.EventDone || stored.Event.Type == stream.EventError { + return + } + } + + heartbeat := time.NewTicker(sseHeartbeatInterval) + defer heartbeat.Stop() for { select { case <-r.Context().Done(): return + case <-heartbeat.C: + if err := writeSSEHeartbeat(w, flusher); err != nil { + return + } case stored, ok := <-ch: if !ok { return } - writeStoredEvents([]taskrun.StoredEvent{stored}) + if err := writeStoredEvents([]taskrun.StoredEvent{stored}); err != nil { + return + } if stored.Event.Type == stream.EventDone || stored.Event.Type == stream.EventError { return } diff --git a/internal/server/task_run_idempotent_sse_test.go b/internal/server/task_run_idempotent_sse_test.go new file mode 100644 index 00000000..838c0e28 --- /dev/null +++ b/internal/server/task_run_idempotent_sse_test.go @@ -0,0 +1,197 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/stream" +) + +func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *testing.T) { + srv := newTestServer(t) + ctx := t.Context() + token := loginTestAdmin(t, srv) + userID := int64(1) + + // 慢执行器:保持 run 在内存中可被 FindByIdempotencyKey 命中 + block := make(chan struct{}) + t.Cleanup(func() { close(block) }) + srv.taskRuns.SetExecutor( + func(ctx context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + select { + case <-ctx.Done(): + return &bizdev.TaskRunResult{Success: false}, ctx.Err() + case <-block: + return &bizdev.TaskRunResult{Success: true}, nil + } + }, + nil, + func(ctx context.Context, _ string, _ bizdev.StageRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{Success: true}, nil + }, + ) + + docID := "doc_proj143_intake" + if _, err := srv.svc.DocGov().CreateOrGetDocument( + ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1_triage", "", + userID, "编制人", userID, "审核人", userID, "批准人", + ); err != nil { + t.Fatalf("create document: %v", err) + } + + body1, _ := json.Marshal(createTaskRunRequest{ + TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "幂等项目", DocID: docID}, + SessionID: "sess-idem-busy", + ClientTurnID: "turn-same-1", + UserMessage: "请编制首轮摸底", + }) + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(body1)) + withBearerAuth(req1, token) + w1 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w1, req1) + if w1.Code != http.StatusAccepted { + t.Fatalf("first create: %d %s", w1.Code, w1.Body.String()) + } + var first createTaskRunResponse + if err := json.Unmarshal(w1.Body.Bytes(), &first); err != nil { + t.Fatalf("decode first: %v", err) + } + if first.RunID == "" { + t.Fatal("missing run_id") + } + + hist1, err := srv.svc.ContextManager().GetChatHistory(ctx, "sess-idem-busy", 50, 0) + if err != nil { + t.Fatalf("history1: %v", err) + } + if len(hist1) != 2 { + t.Fatalf("after first create history=%d want 2", len(hist1)) + } + + // 活跃小队租约:模拟长任务仍占文档(旧路径会 409) + if _, err := srv.svc.DocGov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, HolderType: docgov.LeaseHolderSquad, HolderID: first.RunID, TTL: time.Minute, + }); err != nil { + t.Fatalf("acquire squad lease: %v", err) + } + t.Cleanup(func() { + _, _ = srv.svc.DocGov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), first.RunID) + }) + + body2, _ := json.Marshal(createTaskRunRequest{ + TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "幂等项目", DocID: docID}, + SessionID: "sess-idem-busy", + ClientTurnID: "turn-same-1", + UserMessage: "请编制首轮摸底", + }) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(body2)) + withBearerAuth(req2, token) + w2 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w2, req2) + if w2.Code != http.StatusAccepted { + t.Fatalf("same-turn retry want 202, got %d %s", w2.Code, w2.Body.String()) + } + var second createTaskRunResponse + _ = json.Unmarshal(w2.Body.Bytes(), &second) + if second.RunID != first.RunID { + t.Fatalf("want same run %s, got %s", first.RunID, second.RunID) + } + + hist2, err := srv.svc.ContextManager().GetChatHistory(ctx, "sess-idem-busy", 50, 0) + if err != nil { + t.Fatalf("history2: %v", err) + } + if len(hist2) != 2 { + t.Fatalf("idempotent retry must not append chat; history=%d", len(hist2)) + } + + // 不同 turn:仍应 DOC_BUSY 409 + body3, _ := json.Marshal(createTaskRunRequest{ + TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "幂等项目", DocID: docID}, + SessionID: "sess-idem-busy", + ClientTurnID: "turn-other-2", + UserMessage: "另一轮摸底", + }) + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(body3)) + withBearerAuth(req3, token) + w3 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w3, req3) + if w3.Code != http.StatusConflict { + t.Fatalf("other turn want 409, got %d %s", w3.Code, w3.Body.String()) + } + var busy map[string]any + _ = json.Unmarshal(w3.Body.Bytes(), &busy) + if busy["error_code"] != "DOC_BUSY" { + t.Fatalf("want DOC_BUSY, got %+v", busy) + } +} + +func TestSSEHeartbeat_ExtendsPastServerWriteTimeout(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "no flush", 500) + return + } + w.Header().Set("Content-Type", "text/event-stream") + flusher.Flush() + extendSSEWriteDeadline(w) + tick := time.NewTicker(60 * time.Millisecond) + defer tick.Stop() + end := time.After(700 * time.Millisecond) + for { + select { + case <-end: + _, _ = fmt.Fprintf(w, "event: done\ndata: {\"type\":\"done\"}\n\n") + flusher.Flush() + return + case <-tick.C: + if err := writeSSEHeartbeat(w, flusher); err != nil { + return + } + case <-r.Context().Done(): + return + } + } + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := &http.Server{ + Handler: mux, + WriteTimeout: 200 * time.Millisecond, // 短于流时长;无 extend 会断 + } + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + + resp, err := http.Get("http://" + ln.Addr().String() + "/sse") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + body := string(raw) + if !strings.Contains(body, "heartbeat") { + t.Fatalf("expected heartbeats, body=%q", body) + } + if !strings.Contains(body, "event: done") { + t.Fatalf("stream should survive WriteTimeout via deadline extend; body=%q", body) + } +} diff --git a/web/src/api/chatStream.test.ts b/web/src/api/chatStream.test.ts index d6a4148a..1320dce3 100644 --- a/web/src/api/chatStream.test.ts +++ b/web/src/api/chatStream.test.ts @@ -26,3 +26,10 @@ test('parseSSEChunk: flush parses final frame without trailing delimiter', () => assert.equal(events[0].type, 'done'); assert.equal(events[0].done?.session_id, 's1'); }); + +test('parseSSEChunk: ignores heartbeat comments and keeps last id', () => { + const raw = 'id: 1\nevent: status\ndata: {"type":"status"}\n\n: heartbeat 9\n\nid: 2\nevent: delta\ndata: {"type":"delta","delta":"z"}\n\n'; + const { events, lastSeq } = parseSSEChunk(raw); + assert.equal(events.length, 2); + assert.equal(lastSeq, 2); +}); diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index fe7716e4..70b921fa 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -102,24 +102,30 @@ function parseActions(raw?: string[]): ChatErrorInfo['actions'] { return raw.filter((a): a is ErrorAction => (VALID_ACTIONS as readonly string[]).includes(a)); } -function parseFrame(frame: string): StreamEvent | null { - if (!frame.trim()) return null; +function parseFrame(frame: string): { event: StreamEvent | null; seq?: number } { + if (!frame.trim()) return { event: null }; let dataLine: string | undefined; + let seq: number | undefined; for (const line of frame.split('\n')) { + if (line.startsWith('id:')) { + const n = Number(line.slice(3).trim()); + if (Number.isFinite(n)) seq = n; + } if (line.startsWith('data:')) { dataLine = line.slice(5).trim(); } } - if (!dataLine) return null; + // SSE comment / heartbeat frames (": ...") have no data — ignore. + if (!dataLine) return { event: null, seq }; - return JSON.parse(dataLine) as StreamEvent; + return { event: JSON.parse(dataLine) as StreamEvent, seq }; } export function parseSSEChunk( buffer: string, options?: { flush?: boolean }, -): { events: StreamEvent[]; rest: string } { +): { events: StreamEvent[]; rest: string; lastSeq?: number } { const events: StreamEvent[] = []; if (!buffer) return { events, rest: '' }; @@ -131,13 +137,15 @@ export function parseSSEChunk( processCount = frames.length; } + let lastSeq: number | undefined; for (let i = 0; i < processCount; i++) { - const ev = parseFrame(frames[i]); - if (ev) events.push(ev); + const parsed = parseFrame(frames[i]); + if (parsed.seq !== undefined) lastSeq = parsed.seq; + if (parsed.event) events.push(parsed.event); } const rest = hasTrailingDelimiter || options?.flush ? '' : (frames[frames.length - 1] ?? ''); - return { events, rest }; + return { events, rest, lastSeq }; } export async function streamChat( diff --git a/web/src/api/taskRuns.test.ts b/web/src/api/taskRuns.test.ts index 602fba6e..a640e244 100644 --- a/web/src/api/taskRuns.test.ts +++ b/web/src/api/taskRuns.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createTaskRun } from './taskRuns'; +import { createTaskRun, readTaskRunSSEOnce, subscribeTaskRunEvents } from './taskRuns'; +import { parseSSEChunk } from './chatStream'; test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () => { const originalFetch = globalThis.fetch; @@ -29,3 +30,73 @@ test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () = globalThis.fetch = originalFetch; } }); + +test('parseSSEChunk tracks id seq for after_seq reconnect', () => { + const raw = + 'id: 7\nevent: phase\ndata: {"type":"phase","phase":{"phase_id":"x","message":"m"}}\n\n' + + ': heartbeat 1\n\n' + + 'id: 8\nevent: delta\ndata: {"type":"delta","delta":"a"}\n\n'; + const { events, lastSeq } = parseSSEChunk(raw); + assert.equal(events.length, 2); + assert.equal(lastSeq, 8); +}); + +test('subscribeTaskRunEvents reconnects on EOF with advancing after_seq', async () => { + const originalFetch = globalThis.fetch; + const calls: string[] = []; + let round = 0; + globalThis.fetch = async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + round += 1; + if (round === 1) { + // Unexpected EOF after seq 3 (no done/error) + const body = 'id: 3\nevent: phase\ndata: {"type":"phase","phase":{"phase_id":"a","message":"m"}}\n\n'; + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + assert.match(url, /after_seq=3/); + const body = + 'id: 4\nevent: done\ndata: {"type":"done","done":{"session_id":"s","workspace_id":"w"}}\n\n'; + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }; + try { + const seen: string[] = []; + await subscribeTaskRunEvents( + 'run_reconnect', + { + onEvent: (ev) => seen.push(String(ev.type)), + maxReconnects: 3, + reconnectDelayMs: 1, + sleep: async () => undefined, + }, + ); + assert.deepEqual(seen, ['phase', 'done']); + assert.equal(calls.length, 2); + assert.match(calls[0], /after_seq=0/); + assert.match(calls[1], /after_seq=3/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('readTaskRunSSEOnce marks non-terminal EOF for reconnect', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response('id: 2\nevent: status\ndata: {"type":"status","status":{"message":"x"}}\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + try { + const { terminal, lastSeq } = await readTaskRunSSEOnce('run_x', 0, () => undefined); + assert.equal(terminal, false); + assert.equal(lastSeq, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 5d3bad56..eb7a55d0 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -46,13 +46,22 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** 单次 SSE 读取;意外 EOF 时返回 { terminal:false, lastSeq } 供上层重连。 */ +export async function readTaskRunSSEOnce( runId: string, - handlers: { onEvent: (ev: StreamEvent) => void; afterSeq?: number }, + afterSeq: number, + onEvent: (ev: StreamEvent) => void, signal?: AbortSignal, -): Promise { - const after = handlers.afterSeq ?? 0; - const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${after}`; +): Promise<{ terminal: boolean; lastSeq: number }> { + const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${afterSeq}`; const resp = await fetch(url, { method: 'GET', headers: mergeAuthHeaders({ Accept: 'text/event-stream' }), @@ -64,17 +73,69 @@ export async function subscribeTaskRunEvents( const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buf = ''; + let lastSeq = afterSeq; + let terminal = false; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const parsed = parseSSEChunk(buf); buf = parsed.rest; - for (const ev of parsed.events) handlers.onEvent(ev); + if (parsed.lastSeq !== undefined) { + lastSeq = Math.max(lastSeq, parsed.lastSeq); + } + for (const ev of parsed.events) { + onEvent(ev); + if (isTerminalTaskEvent(ev)) terminal = true; + } } if (buf) { const flushed = parseSSEChunk(buf, { flush: true }); - for (const ev of flushed.events) handlers.onEvent(ev); + if (flushed.lastSeq !== undefined) { + lastSeq = Math.max(lastSeq, flushed.lastSeq); + } + for (const ev of flushed.events) { + onEvent(ev); + if (isTerminalTaskEvent(ev)) terminal = true; + } + } + return { terminal, lastSeq }; +} + +/** + * 订阅任务 SSE:意外 EOF / 不完整 chunked 时按最后 seq 重订阅同一 run,禁止调用方据此 POST 新 run。 + */ +export async function subscribeTaskRunEvents( + runId: string, + handlers: { + onEvent: (ev: StreamEvent) => void; + afterSeq?: number; + maxReconnects?: number; + reconnectDelayMs?: number; + sleep?: (ms: number) => Promise; + }, + signal?: AbortSignal, +): Promise { + let afterSeq = handlers.afterSeq ?? 0; + const maxReconnects = handlers.maxReconnects ?? 40; + const baseDelay = handlers.reconnectDelayMs ?? 500; + const sleep = handlers.sleep ?? sleepMs; + + for (let attempt = 0; attempt <= maxReconnects; attempt++) { + if (signal?.aborted) return; + try { + const { terminal, lastSeq } = await readTaskRunSSEOnce(runId, afterSeq, handlers.onEvent, signal); + afterSeq = Math.max(afterSeq, lastSeq); + if (terminal) return; + // 意外 EOF:未见到 done/error,按 after_seq 重连 + } catch (err) { + if (signal?.aborted) return; + if (attempt >= maxReconnects) throw err; + } + if (attempt >= maxReconnects) { + throw new Error(`task run SSE disconnected after ${maxReconnects} reconnects (after_seq=${afterSeq})`); + } + await sleep(Math.min(baseDelay * (attempt + 1), 5000)); } } From 900af79e142540692cb98fa24dd6641891d919b8 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:00:50 +0000 Subject: [PATCH 2/7] fix(docgov): quality gate self-pollution, server repair budget, quality_failed UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 受控小队任务服务端启用修复预算;否定性自检引用不再误报;quality_failed 保持失败且不激活未过门禁稿。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 46 +++++++++++ .doc/ops/CURRENT.md | 5 +- ...09-13-document-revision-sse-idempotency.md | 24 ++++-- .doc/remediation/gap-matrix.md | 1 + configs/agents/writer.md | 2 +- internal/bizdev/service.go | 3 +- internal/bizdev/task_result_status.go | 12 ++- internal/bizdev/task_result_status_test.go | 31 +++++++ internal/server/task_run_handlers.go | 4 +- internal/squad/quality_gate.go | 82 +++++++++++++++++-- internal/squad/quality_gate_test.go | 67 +++++++++++++++ internal/squad/repair_budget_test.go | 25 ++++++ internal/squad/roles.go | 2 +- internal/squad/service.go | 24 ++++-- internal/squad/workspace.go | 14 ++-- internal/squad/workspace_test.go | 2 +- templates/docs/feasibility_dev.md | 2 +- templates/docs/feasibility_reg.md | 2 +- templates/docs/intake.md | 4 +- templates/docs/research.md | 4 +- web/src/App.tsx | 24 +++++- web/src/api/chatWindow.ts | 4 + .../chat/chatWindowArtifact.test.ts | 6 +- web/src/types/workbench.ts | 1 + 24 files changed, 341 insertions(+), 50 deletions(-) create mode 100644 .doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md create mode 100644 internal/squad/repair_budget_test.go diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md new file mode 100644 index 00000000..8b4d3982 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -0,0 +1,46 @@ +--- +title: "质量门禁自污染与 auto_repair 未启用导致 quality_failed" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# 质量门禁自污染与 auto_repair 未启用导致 quality_failed + +## 现象 + +同一整改范围真实 run(与 SSE/幂等现场同源): + +- workspace=`data/dev/workspaces/bizdev/intake_1789308825666278807` +- run 于 **14:17:51** 结束为 **`quality_failed`**(仅 1 个 `task_runs`) +- 质量门禁事件:**score=40**,命中 `不做展开` / `篇幅有限` / `从略` +- `output.md` **第204行**终稿自检表:`无"不做展开""从略""篇幅有限"等敷衍套话` → 门禁 `strings.Contains` **自污染误报** +- 请求体未带 `auto_repair`;虽存在 `EnsureQualityOrRepair`,实际 **maxRetries=0** +- chat_messages 同 `client_turn` 两对(1131/1132 正常;1133/1134 重连误写)——与 SSE 幂等缺陷同源,另案处理 + +## 根因 + +| 项 | 说明 | +|---|---| +| A | 修复预算依赖客户端 `auto_repair`(浏览器可省略,默认 false) | +| B | 生成指令与自检表原样引用禁词;检测无否定/元语境豁免 | +| C | FE 将 `quality_failed` 映射为 `completed`,并在断线 catch 路径同步受控文档 | +| D | 缺真实失败形态单测 | + +## 整改(同分支 `fix/document-revision-sse-idempotency`) + +- A:有 `TaskKey` 的受控小队任务服务端强制 `DefaultMaxRepairRounds`;handlers 产品契约 `AutoRepair: true` +- B:prompt 去原词;`containsProhibitedUsage` + 否定/元语境;违规文案与修复 TASK 不回写禁词 +- C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 +- D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 + +## 证据 + +- workspace path 如上;events seq10;output.md:204 +- assets:[../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../test-reports/assets/2026-09-13-document-revision-runtime-ui/)(勿删) + +## 关联 + +- Short-term 扩写:[../ops/short-term/2026-09-13-document-revision-sse-idempotency.md](../ops/short-term/2026-09-13-document-revision-sse-idempotency.md) +- SSE 幂等审计:[./2026-09-13-document-revision-sse-idempotency.md](./2026-09-13-document-revision-sse-idempotency.md) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index f5b6bcd4..f1ae21ee 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30** 已合入;真实 UI-03 暴露 SSE WriteTimeout 断线后同 turn 误 409 DOC_BUSY;整改在 `fix/document-revision-sse-idempotency`) +**production_ready**: no(DOCREV:schema **30**;SSE 幂等 + 质量门禁自污染/auto_repair 整改在 `fix/document-revision-sse-idempotency`) ## 焦点 -1. **DOCREV-RT**: [SSE 幂等与写超时](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等先于 busy、SSE heartbeat、FE after_seq 重连;不 push) +1. **DOCREV-RT**: [SSE 幂等 + 质量门禁](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等/SSE + A–D 质量契约;不 push) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term @@ -51,3 +51,4 @@ | DOCREV ADR | [../adr/2026-09-13-document-version-boundary.md](../adr/2026-09-13-document-version-boundary.md) | | DOCREV 测试 | [../test-reports/2026-09-13-document-revision-management-test-report.md](../test-reports/2026-09-13-document-revision-management-test-report.md) | | SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) | +| 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index cefe97e0..430acdb9 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -1,5 +1,5 @@ --- -title: "任务 SSE 写超时与同 turn 幂等先于 busy 预检" +title: "任务 SSE 幂等 + 质量门禁自污染与 auto_repair 产品契约" status: in_progress created: 2026-09-13 updated: 2026-09-13 @@ -9,18 +9,18 @@ worktree: "-" mutex: docgov --- -# 任务 SSE 写超时与同 turn 幂等先于 busy 预检 +# 任务 SSE 幂等 + 质量门禁自污染与 auto_repair 产品契约 ## 目标 -同 turn 在 SSE 断线重连时返回既有 run 202、不重复聊天、不误 409 DOC_BUSY;长任务 events SSE 不被 120s WriteTimeout 切断;前端 EOF 按 after_seq 重订阅同一 run。 +1. 同 turn 在 SSE 断线重连时返回既有 run 202、不重复聊天、不误 409 DOC_BUSY;长任务 events SSE 不被 120s WriteTimeout 切断;前端 EOF 按 after_seq 重订阅同一 run。 +2. 受控文档小队任务质量门禁:**服务端启用修复预算**;否定性自检引用不误报;`quality_failed` UI 保持失败且不激活未过门禁稿。 ## 范围 -- `internal/server/task_run_handlers.go`:授权后幂等查找先于 scope/preflight -- `internal/server`:SSE 专用 `ResponseController` 写期限延长 + heartbeat -- `web/src/api/taskRuns.ts` + `chatStream` seq:EOF 重连 -- 单测与文档 +- SSE/幂等:`task_run_handlers`、SSE write deadline、FE subscribe 重连 +- 质量 A–D:`repairBudgetForSquadTask`、`quality_gate` 语境检测、prompt 去原词、Materialize/`TerminalStatus`、FE `quality_failed`、单测 +- 证据:`data/dev/workspaces/bizdev/intake_1789308825666278807`(output.md:204;events seq10) ## 非目标 @@ -32,15 +32,21 @@ mutex: docgov 2. [x] 幂等先查 + 属主校验 3. [x] SSE write deadline + heartbeat 4. [x] FE subscribe 重连 -5. [x] 测试与原子提交 +5. [x] A 服务端修复预算产品契约 +6. [x] B 门禁否定/元语境 + prompt 去原词 +7. [x] C quality_failed UI/Materialize 契约 +8. [x] D 真实问题单测 + 门禁回归 ## 验收标准 - [x] 同 turn + 活跃租约 → 同 run 202、仅一对消息;不同 turn → 仍 409 - [x] SSE 心跳/写期限可测;FE EOF/after_seq 单测 +- [x] TaskKey 任务 AutoRepair=false 仍有 DefaultMaxRepairRounds;L204 自检引用不误报;真敷衍仍 FAIL +- [x] quality_failed ≠ completed;Materialize 跳过未过门禁稿 - [x] `go test -short ./...`、`go vet`、`npm test`/`build`、`git diff --check` ## 相关链接 -- Audit: [../../audit/2026-09-13-document-revision-sse-idempotency.md](../../audit/2026-09-13-document-revision-sse-idempotency.md) +- Audit SSE: [../../audit/2026-09-13-document-revision-sse-idempotency.md](../../audit/2026-09-13-document-revision-sse-idempotency.md) +- Audit 质量: [../../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) - Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/) diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index 70a5f984..fb6c47bc 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -63,6 +63,7 @@ | DOCREV-RUNTIME-404 | revision-state 404;账本内存丢失;块保存/overlay 无效;agent 假报告 | **FAIL** | [audit](../audit/2026-09-13-document-revision-runtime-404.md);`internal/docrevision/service.go` 仅 map | [remediation](./2026-09-13-document-revision-runtime-remediation.md) | | DOCREV-UI03-CLIENT-SYNC | 小队完成后右栏仍模板;UI-03 选择器误命中 Banner;`pending_author_confirmation` 撞 chk;重试重复建 run | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-controlled-doc-client-sync-ui03.md);分支 `fix/document-revision-runtime-ui03`:post-task sync、templateMatch intake 优先、goose 00030、Claim+idempotency | 合入前:schema30 **真实现卷**迁移独立验证;占槽真实 UI-03 重跑;勿把 stub 当验收 | | DOCREV-SSE-IDEM-BUSY | SSE WriteTimeout 断线后同 turn 再 POST 误 409 DOC_BUSY + 重复聊天 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-sse-idempotency.md);证据 [assets/2026-09-13-document-revision-runtime-ui](../test-reports/assets/2026-09-13-document-revision-runtime-ui/);`fix/document-revision-sse-idempotency` | 占槽真实 UI-03 重跑;不删证据 | +| DOCREV-QUALITY-SELF-POLLUTE | 自检表引用禁词致 score=40 quality_failed;auto_repair 未启用;UI 映射 completed | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md);workspace `intake_1789308825666278807` output.md:204;同分支 | 占槽真实重跑;不删证据 | --- diff --git a/configs/agents/writer.md b/configs/agents/writer.md index 80d100fd..5fe9f4ab 100644 --- a/configs/agents/writer.md +++ b/configs/agents/writer.md @@ -15,7 +15,7 @@ - 先立框架:读者是谁、要他们做什么决定。 - 按本阶段模板撰写,把专员输入填进去;模板是下限,按共用写作质量把每一节写完整。 - **正文优先**:文档主题章节写在前面且详尽;`阻塞信息`/`输入摘要`/`本阶段材料` 等字段快照放文末,不得用字段表顶替正文。 -- **禁止省略**:不得写「不做展开」「从略」「不赘述」等;缺信息标「待确认」并说明缺什么。 +- **禁止省略**:各节须实质展开,不得以篇幅借口或省略写法跳过应写细节;缺信息标「待确认」并说明缺什么(自检勿复述禁用原词)。 - 数字标注来源;材料没有的数字不许填。涉及法规条款用〔标准号 §条款 p.页〕。 - 缺口显式化,文末汇总「待确认」。没有出处的判断可保留,写「(分析判断,无直接条文依据)」。 - 按 TASK.md 指定文件落盘:普通/执行步写 `output.md`;计划步(plan)只写 `output_plan.md`,**不要**写 `output.md`。 diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 33a9fbd2..dd510754 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -728,7 +728,8 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun // leave needs_confirmation. Activity is recorded only after a committed projection // (or a pending-awaiting note that is explicitly not completion). - if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil { + if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil && + (res.QualityReport == nil || res.QualityReport.Passed) { _ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir) dataRoot := "." if s.cfg != nil && s.cfg.DataDir != "" { diff --git a/internal/bizdev/task_result_status.go b/internal/bizdev/task_result_status.go index 48cad723..f98b7527 100644 --- a/internal/bizdev/task_result_status.go +++ b/internal/bizdev/task_result_status.go @@ -28,16 +28,20 @@ func (r *TaskRunResult) IsProposalIntercept() bool { // TerminalStatus maps TaskRunResult to a conversation/workflow terminal status. // Pending agent confirmation is never "completed" until commit+projection. +// Quality failure stays quality_failed even when Success was cleared to block Materialize. func (r *TaskRunResult) TerminalStatus() string { - if r == nil || !r.Success { + if r == nil { + return TaskStatusFailed + } + if r.QualityReport != nil && !r.QualityReport.Passed { + return TaskStatusQualityFailed + } + if !r.Success { return TaskStatusFailed } if r.AwaitsAgentConfirmation() { return TaskStatusPendingAuthorConfirmation } - if r.QualityReport != nil && !r.QualityReport.Passed { - return TaskStatusQualityFailed - } return TaskStatusCompleted } diff --git a/internal/bizdev/task_result_status_test.go b/internal/bizdev/task_result_status_test.go index db73f750..e9094544 100644 --- a/internal/bizdev/task_result_status_test.go +++ b/internal/bizdev/task_result_status_test.go @@ -6,6 +6,7 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/squad" ) func TestTaskRunResult_PendingConfirmation_NotCompleted(t *testing.T) { @@ -79,3 +80,33 @@ func TestTaskRunResult_CompletedOnlyWhenCommitted(t *testing.T) { t.Fatalf("completed summary: %s", sum) } } + +// 现场:score=40 / quality_failed 不得映射 completed;Success 已清仍保持 quality_failed。 +func TestTaskRunResult_QualityFailed_NotCompleted(t *testing.T) { + res := &bizdev.TaskRunResult{ + Success: false, + ArtifactTitle: "用户初始需求收集表", + TaskKey: "intake", + QualityReport: &squad.QualityReport{ + Passed: false, + Score: 40, + ProhibitedPhrases: []string{"不做展开", "从略", "篇幅有限"}, + Violations: []string{"检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)"}, + }, + DocRecord: &docgov.DocumentRecord{DocID: "doc_proj6098_intake"}, + } + if res.TerminalStatus() != bizdev.TaskStatusQualityFailed { + t.Fatalf("status=%q want quality_failed", res.TerminalStatus()) + } + sum := res.CompletionSummary() + if strings.Contains(sum, "已完成") { + t.Fatalf("quality_failed summary must not claim completed: %s", sum) + } + if !strings.Contains(sum, "质量检查未通过") { + t.Fatalf("summary missing quality fail wording: %s", sum) + } + art := res.ArtifactMetadata() + if art["quality_passed"] != false { + t.Fatalf("quality_passed=%v", art["quality_passed"]) + } +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 3c53b0fa..8afe0ddd 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -578,7 +578,7 @@ func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, client Fields: dto.Fields, Materials: dto.Materials, Backend: dto.Backend, - AutoRepair: dto.AutoRepair, + AutoRepair: true, // 产品契约:受控小队任务服务端启用修复,不依赖浏览器字段 SessionID: sessionID, UserID: dto.UserID, ProjectID: dto.ProjectID, @@ -607,7 +607,7 @@ func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clien Fields: dto.Fields, Materials: dto.Materials, Backend: dto.Backend, - AutoRepair: dto.AutoRepair, + AutoRepair: true, // 产品契约:阶段任务同样启用修复预算 SessionID: sessionID, UserID: dto.UserID, ProjectID: dto.ProjectID, diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index 44dd98e2..9bb2b00c 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -77,11 +77,12 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q } } - // 2. 检查敷衍违规套话 + // 2. 检查敷衍违规套话(否定性自检/禁令复述不计入,避免门禁自污染) for _, phrase := range ProhibitedPhrases { - if strings.Contains(content, phrase) { + if containsProhibitedUsage(content, phrase) { report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase) - report.Violations = append(report.Violations, fmt.Sprintf("检测到敷衍违规词: '%s'", phrase)) + // 违规说明勿回写禁用原词,避免修复指令/自检再次自污染 + report.Violations = append(report.Violations, "检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)") report.Score -= 20.0 report.Passed = false } @@ -155,6 +156,77 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q return report, nil } +// sanitizeViolationForAgent 修复指令中不得原样回写禁用套话,避免二次自污染。 +func sanitizeViolationForAgent(v string) string { + out := v + for _, phrase := range ProhibitedPhrases { + if phrase != "" && strings.Contains(out, phrase) { + out = strings.ReplaceAll(out, phrase, "「省略/敷衍写法」") + } + } + return out +} + +// containsProhibitedUsage 仅当禁词被用作正文省略/敷衍时命中。 +// 否定性自检、写作禁令复述(如:无"…"等敷衍套话)不得误报。 +func containsProhibitedUsage(content, phrase string) bool { + if phrase == "" || content == "" { + return false + } + searchFrom := 0 + for { + rel := strings.Index(content[searchFrom:], phrase) + if rel < 0 { + return false + } + abs := searchFrom + rel + if !isNegatedOrMetaProhibitedCitation(content, abs, phrase) { + return true + } + searchFrom = abs + len(phrase) + } +} + +func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) bool { + lineStart := strings.LastIndex(content[:idx], "\n") + 1 + lineEnd := strings.Index(content[idx:], "\n") + if lineEnd < 0 { + lineEnd = len(content) + } else { + lineEnd = idx + lineEnd + } + line := content[lineStart:lineEnd] + + metaMarkers := []string{ + "写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写", + "负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准", + } + for _, m := range metaMarkers { + if strings.Contains(line, m) { + return true + } + } + + prefixStart := idx - 48 + if prefixStart < 0 { + prefixStart = 0 + } + prefix := content[prefixStart:idx] + negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"} + for _, n := range negMarkers { + if strings.Contains(prefix, n) { + return true + } + } + // 现场形态:无"不做展开""从略""篇幅有限"等敷衍套话 + if strings.Contains(prefix, "无\"") || strings.Contains(prefix, "无「") || strings.Contains(prefix, "无'") || + strings.Contains(prefix, "无“") { + return true + } + _ = phrase + return false +} + // GenerateManifest 扫描工作区核心文件生成 SHA-256 指纹与防篡改审计清单 func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report QualityReport) (*Manifest, error) { now := time.Now() @@ -276,7 +348,7 @@ func (q *QualityGate) EnsureQualityOrRepair( acceptCriteria = append(acceptCriteria, "补齐缺失章节: "+strings.Join(report.MissingSections, ", ")) } if len(report.ProhibitedPhrases) > 0 { - acceptCriteria = append(acceptCriteria, "清除敷衍违规词: "+strings.Join(report.ProhibitedPhrases, ", ")) + acceptCriteria = append(acceptCriteria, "清除正文中实际使用的省略/敷衍写法;自检表勿复述禁用原词") } for _, v := range report.Violations { acceptCriteria = append(acceptCriteria, "解决违规点: "+v) @@ -302,7 +374,7 @@ func (q *QualityGate) EnsureQualityOrRepair( b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt)) b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n") for _, v := range report.Violations { - b.WriteString(fmt.Sprintf("- ❌ %s\n", v)) + b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v))) } // 治理:停滞升级——上轮修复产物与再上轮几乎相同,注入反重复指令换策略 if stallHinted { diff --git a/internal/squad/quality_gate_test.go b/internal/squad/quality_gate_test.go index 0fc71fbe..7fe224b2 100644 --- a/internal/squad/quality_gate_test.go +++ b/internal/squad/quality_gate_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -201,3 +202,69 @@ func TestQualityGate_SystemReqsAndTestManager(t *testing.T) { t.Errorf("expected compliant test_manager to pass, got violations: %v", rep4.Violations) } } + +// 现场证据 output.md:204 — 终稿自检表否定性引用禁词,不得误报为敷衍违规。 +func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) { + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: tmpDir, + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + ProjectName: "医疗设备", + TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", + }) + if err != nil { + t.Fatalf("PrepareWorkspace: %v", err) + } + gate := squad.NewQualityGate() + + // 与 data/dev/workspaces/bizdev/intake_1789308825666278807/output.md 第204行同构 + realChecklistLine := `| 写作禁令遵守情况 | ✅ 通过 | 无"不做展开""从略""篇幅有限"等敷衍套话 |` + content := `# 用户初始需求收集表 + +## 1. 客户与项目画像 +本项目客户为专业医疗机构〔材料: 需求表 §1〕。四段式展开详细论述客户背景与项目边界。 + +## 2. 预期用途 +用于临床辅助诊断与生理信号监测,覆盖病房与门诊场景。 + +## 3. 待确认清单 +- 供电模块认证周期待窗口确认。 + +### 附录C:审核记录(Review Record) + +| 审核项 | 审核结论 | 说明 | +|-------|---------|------| +| 全文逻辑一致性 | ✅ 通过 | 结论→正文→解读完整 | +` + realChecklistLine + "\n" + + report, err := gate.Validate(ws.WorkDir, "intake", content) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if !report.Passed { + t.Fatalf("negated self-check must not fail gate; violations=%v phrases=%v score=%.0f", + report.Violations, report.ProhibitedPhrases, report.Score) + } + if len(report.ProhibitedPhrases) != 0 { + t.Fatalf("expected no prohibited hits on meta citation, got %v", report.ProhibitedPhrases) + } + + // 真敷衍仍须命中 + hedging := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处篇幅有限,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" + bad, err := gate.Validate(ws.WorkDir, "intake", hedging) + if err != nil { + t.Fatalf("Validate hedging: %v", err) + } + if bad.Passed || len(bad.ProhibitedPhrases) == 0 { + t.Fatalf("real hedging must fail; passed=%v phrases=%v", bad.Passed, bad.ProhibitedPhrases) + } + for _, v := range bad.Violations { + for _, p := range squad.ProhibitedPhrases { + if strings.Contains(v, p) { + t.Fatalf("violation text must not echo banned phrase %q: %s", p, v) + } + } + } +} diff --git a/internal/squad/repair_budget_test.go b/internal/squad/repair_budget_test.go new file mode 100644 index 00000000..a72f32aa --- /dev/null +++ b/internal/squad/repair_budget_test.go @@ -0,0 +1,25 @@ +package squad + +import "testing" + +func TestRepairBudgetForSquadTask_ProductContract(t *testing.T) { + // 受控文档小队任务:即使客户端省略 auto_repair=false,也必须给修复预算 + got := repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: false}) + if got != DefaultMaxRepairRounds { + t.Fatalf("intake AutoRepair=false budget=%d want %d", got, DefaultMaxRepairRounds) + } + if DefaultMaxRepairRounds < 1 { + t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds) + } + + // 无 TaskKey 且未开 AutoRepair:保持 0(非受控路径) + got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: false}) + if got != 0 { + t.Fatalf("empty TaskKey without AutoRepair budget=%d want 0", got) + } + + got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: true}) + if got != DefaultMaxRepairRounds { + t.Fatalf("AutoRepair=true budget=%d want %d", got, DefaultMaxRepairRounds) + } +} diff --git a/internal/squad/roles.go b/internal/squad/roles.go index 1bbefada..0c0f28fb 100644 --- a/internal/squad/roles.go +++ b/internal/squad/roles.go @@ -107,7 +107,7 @@ func (r *RoleRegistry) registerDefaults() { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满", "撰写正式交付文档正文终稿"}, - ForbiddenActions: []string{"严禁写不做展开或从略", "严禁随意篡改前序事实"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"}, InstructionsFile: "configs/agents/writer.md", }, { diff --git a/internal/squad/service.go b/internal/squad/service.go index 9437c2f3..aa4b56ee 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -267,10 +267,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR if streamer != nil { streamer.EmitPhase("quality_gate", "审核主管正在核验交付物规范并执行质量门禁把关...", "leader", 0, 0, 85) } - maxRetries := 0 - if req.AutoRepair { - maxRetries = 2 - } + maxRetries := repairBudgetForSquadTask(req) report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts) if err != nil { // 记录门禁错误但容错返回当前正文 @@ -298,6 +295,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR if backendName == "stub" { finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md")) _ = os.WriteFile(finalOutPath, finalBytes, 0o644) + // stub 终稿以 template 为准:门禁须对齐最终交付物,避免对中间草稿误伤 Success/Materialize + if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil { + report = reReport + manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report) + } } bb, _ := s.driver.LoadBlackboard(workDir) @@ -354,13 +356,25 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR WorkDir: workDir, BackendUsed: backendName, Duration: time.Since(start), - Success: execRes.Success, + Success: execRes.Success && (report == nil || report.Passed), QualityReport: report, Manifest: manifest, AdvisorBrief: advisorBrief, }, nil } +// repairBudgetForSquadTask 产品契约:有 TaskKey 的受控文档小队任务默认启用修复轮预算, +// 不依赖客户端 auto_repair(浏览器可省略且默认为 false)。 +func repairBudgetForSquadTask(req SquadRunRequest) int { + if strings.TrimSpace(req.TaskKey) != "" { + return DefaultMaxRepairRounds + } + if req.AutoRepair { + return DefaultMaxRepairRounds + } + return 0 +} + // ExecuteWorkflow 执行既有工作区工作流 func (s *Service) ExecuteWorkflow(ctx context.Context, wsDir string, backendName string, opts ExecuteOptions) (*SquadRunResult, error) { start := time.Now() diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index d72e4ea7..396d7e17 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -299,7 +299,7 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar // 3. agent.md agentContent := req.AgentContext if strings.TrimSpace(agentContent) == "" { - agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作、禁止'不做展开'、'从略'\n" + agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n" } _ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644) contextItems = append(contextItems, ContextItem{ @@ -538,7 +538,7 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq taskBuilder.WriteString("## 交付契约与写作纪律\n") taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName)) taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n") - taskBuilder.WriteString("2. 禁止使用'不做展开'、'从略'、'篇幅有限'等省略敷衍套话。\n") + taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n") taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n") if step.Role == "organizer" { taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n") @@ -585,7 +585,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"}, - ForbiddenActions: []string{"严禁写不做展开或从略", "严禁随意篡改前序事实"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"}, }, { Role: "leader", @@ -633,7 +633,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"}, - ForbiddenActions: []string{"严禁写不做展开或从略"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节"}, }, { Role: "leader", @@ -681,7 +681,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格按 system_reqs.md 模板成文", "正文结构完整", "填满追溯表"}, - ForbiddenActions: []string{"严禁残留占位符", "严禁写不做展开"}, + ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, }, { Role: "leader", @@ -729,7 +729,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格按 test_manager.md 模板成文", "用例主表翔实完整"}, - ForbiddenActions: []string{"严禁残留占位符", "严禁写不做展开"}, + ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, }, { Role: "leader", @@ -797,7 +797,7 @@ func DefaultWorkflowSteps(taskKey string) []StepSpec { Needs: []string{"tasks/01_organize/output.md"}, Accept: []string{ "按 template.md 各章节严格四段式展开", - "正文翔实,禁止写'不做展开'", + "正文翔实,各节须实质展开", "文末集中附录字段快照与待确认清单", }, }, diff --git a/internal/squad/workspace_test.go b/internal/squad/workspace_test.go index 2964bc31..4bd8ab06 100644 --- a/internal/squad/workspace_test.go +++ b/internal/squad/workspace_test.go @@ -58,7 +58,7 @@ func TestWorkspaceEngine_PrepareWorkspace(t *testing.T) { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"撰写正文"}, - ForbiddenActions: []string{"严禁写不做展开"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节"}, }, { Role: "leader", diff --git a/templates/docs/feasibility_dev.md b/templates/docs/feasibility_dev.md index 58076cde..ee66d70a 100644 --- a/templates/docs/feasibility_dev.md +++ b/templates/docs/feasibility_dev.md @@ -6,7 +6,7 @@ ## 结论 -(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止「不做展开」。) +(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止以省略写法跳过应写内容。) ## 技术说明 diff --git a/templates/docs/feasibility_reg.md b/templates/docs/feasibility_reg.md index 1caaf460..57d0e4c1 100644 --- a/templates/docs/feasibility_reg.md +++ b/templates/docs/feasibility_reg.md @@ -6,7 +6,7 @@ ## 结论 -(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止「不做展开」。) +(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止以省略写法跳过应写内容。) ## 分类判定 diff --git a/templates/docs/intake.md b/templates/docs/intake.md index 67e5938c..14bc0f51 100644 --- a/templates/docs/intake.md +++ b/templates/docs/intake.md @@ -6,11 +6,11 @@ ## 结论 -(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或写「不做展开」。) +(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或以省略写法跳过应写内容。) ## 产品与使用场景 -(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止「本节不做展开」「略」等省略套话。) +(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止用省略套话跳过本节。) ## 客户诉求与约束 diff --git a/templates/docs/research.md b/templates/docs/research.md index 2a400934..5500bfd5 100644 --- a/templates/docs/research.md +++ b/templates/docs/research.md @@ -6,7 +6,7 @@ ## 结论 -(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止「不做展开」「略」。) +(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止以省略写法跳过应写内容。) ## 市场 @@ -18,7 +18,7 @@ ## 竞品对比 -(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止「竞品不做展开」。) +(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止对竞品对比一笔带过。) | 产品/厂商 | 技术路线 | 关键性能(厂商宣称) | 获批状态 | 备注 | | --- | --- | --- | --- | --- | diff --git a/web/src/App.tsx b/web/src/App.tsx index 4e29cd22..ee4381b9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2662,7 +2662,10 @@ export default function App() { taskWorkspaceId: ev.done?.workspace_id, ...(finishedRunId ? { taskRunId: finishedRunId } : {}), }); - await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); + // quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换 + if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') { + await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); + } await chatWindow.refresh().catch(() => undefined); })(); break; @@ -2695,7 +2698,7 @@ export default function App() { } if (remote && isRemoteTaskTerminal(remote.status)) { settled = true; - const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'completed'; + const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed'; patchAsst({ status: mapped, livePhaseLabel: undefined, @@ -2703,7 +2706,9 @@ export default function App() { taskRunId: existingRunId, }); taskRunStream.setThinking(sessionId, false); - await syncControlledDocsAfterTask(task.taskKey || 'intake'); + if (mapped === 'completed' || mapped === 'pending_author_confirmation') { + await syncControlledDocsAfterTask(task.taskKey || 'intake'); + } return; } } @@ -2737,7 +2742,18 @@ export default function App() { if (runId) { try { const remote = await getTaskRun(runId); - if (remote.status === 'completed' || remote.status === 'quality_failed') { + if (remote.status === 'quality_failed') { + settled = true; + patchAsst({ + status: 'quality_failed', + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + return; + } + if (remote.status === 'completed') { settled = true; patchAsst({ status: 'completed', diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 9049f9cc..aae11add 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -26,6 +26,8 @@ export function mapTaskStatusToMessageStatus( case 'pending_author_confirmation': return 'pending_author_confirmation'; case 'quality_failed': + // 门禁未过必须保持失败态,不得映射 completed + return 'quality_failed'; case 'completed': case '': return 'completed'; @@ -43,6 +45,8 @@ export function assistantTaskStatusLabel(status: AssistantMessageStatus | undefi switch (status) { case 'pending_author_confirmation': return '待编制人确认'; + case 'quality_failed': + return '质量未通过'; case 'failed': return '执行失败'; case 'cancelled': diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts index 9d06ef80..2357e0ef 100644 --- a/web/src/components/chat/chatWindowArtifact.test.ts +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -30,7 +30,9 @@ describe('mapRecordToMessage artifact projection', () => { expect(msg.content).not.toBe('任务执行中…'); expect(msg.taskArtifact?.doc_id).toBe('doc_proj56_intake'); - expect(msg.status).toBe('completed'); + expect(msg.status).toBe('quality_failed'); + expect(msg.status).not.toBe('completed'); + expect(assistantTaskStatusLabel(msg.status)).toBe('质量未通过'); }); it('maps pending_author_confirmation after history refresh (not completed)', () => { @@ -101,7 +103,7 @@ describe('mapRecordToMessage artifact projection', () => { 'pending_author_confirmation', ); expect(mapTaskStatusToMessageStatus('completed', 'assistant')).toBe('completed'); - expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('completed'); + expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('quality_failed'); expect(mapTaskStatusToMessageStatus('failed', 'assistant')).toBe('failed'); expect(mapTaskStatusToMessageStatus('running', 'assistant')).toBe('streaming_status'); expect(mapTaskStatusToMessageStatus('pending_author_confirmation', 'user')).toBeUndefined(); diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 2a096d29..a351c542 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -116,6 +116,7 @@ export type AssistantMessageStatus = | 'completed' | 'pending_author_confirmation' | 'failed' + | 'quality_failed' | 'cancelled'; export interface ChatErrorInfo { From 92488b327a6692219301421e962a1f6fc1d0a70f Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:14:55 +0000 Subject: [PATCH 3/7] fix(docgov): short-circuit quality_failed before controlled doc projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 质量门禁未过时在 SetDocumentWorkDir/Materialize/哈希更新/完成活动之前返回,保留隔离工作区与失败摘要,不改正式 head。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 1 + ...09-13-document-revision-sse-idempotency.md | 1 + internal/bizdev/quality_projection_test.go | 141 ++++++++++++++++++ internal/bizdev/service.go | 57 ++++++- 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 internal/bizdev/quality_projection_test.go diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md index 8b4d3982..3b28f545 100644 --- a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -34,6 +34,7 @@ severity: high - B:prompt 去原词;`containsProhibitedUsage` + 否定/元语境;违规文案与修复 TASK 不回写禁词 - C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 - D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 +- **E(投影边界)**:`finalizeSquadDeliverable` 在任何 `SetDocumentWorkDir` / `Materialize` / `MarkDocumentContentUpdated` / 完成活动之前按 `QualityReport.Passed=false` 短路;保留隔离工作区与失败摘要;正式 `work_dir`/head/hash/候选不变。单测:`TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects`(敌意 `Success=true`) ## 证据 diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index 430acdb9..9f7b797f 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -36,6 +36,7 @@ mutex: docgov 6. [x] B 门禁否定/元语境 + prompt 去原词 7. [x] C quality_failed UI/Materialize 契约 8. [x] D 真实问题单测 + 门禁回归 +9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用) ## 验收标准 diff --git a/internal/bizdev/quality_projection_test.go b/internal/bizdev/quality_projection_test.go new file mode 100644 index 00000000..b0784d7b --- /dev/null +++ b/internal/bizdev/quality_projection_test.go @@ -0,0 +1,141 @@ +package bizdev + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/squad" +) + +func TestQualityBlocksControlledProjection(t *testing.T) { + if qualityBlocksControlledProjection(nil) { + t.Fatal("nil report must not block") + } + if qualityBlocksControlledProjection(&squad.QualityReport{Passed: true}) { + t.Fatal("passed report must not block") + } + if !qualityBlocksControlledProjection(&squad.QualityReport{Passed: false, Score: 40}) { + t.Fatal("failed report must block") + } +} + +// 敌意输入:Success=true 但 QualityReport.Passed=false —— 仍不得投影到正式受控事实源。 +func TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects(t *testing.T) { + dataDir := t.TempDir() + svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"}) + ctx := context.Background() + projectID := int64(6098) + docID := "doc_proj6098_intake" + + officialWD := filepath.Join(dataDir, "official-head") + if err := os.MkdirAll(officialWD, 0o755); err != nil { + t.Fatal(err) + } + base := "# 正式受控 head\n\n本正文为账本事实源,质量失败后不得被未过门禁稿覆盖。\n" + if _, err := svc.DocRevision().OpenTemplateVersion(ctx, docID, base, docrevision.ActorSnapshot{DisplayName: "baseline"}, projectID, officialWD); err != nil { + t.Fatal(err) + } + docRec, err := svc.DocGov().CreateOrGetDocument(ctx, docID, projectID, "intake", "用户初始需求收集表", "V0.5", officialWD, 1, "a", 2, "r", 3, "p") + if err != nil { + t.Fatal(err) + } + stBefore, err := svc.DocRevision().GetRevisionState(ctx, docID) + if err != nil { + t.Fatal(err) + } + versBefore, err := svc.DocRevision().ListVersions(ctx, docID) + if err != nil { + t.Fatal(err) + } + beforeSHA := docRec.ContentSHA256 + beforeWD := docRec.WorkDir + beforeHead := stBefore.HeadSHA256 + beforeRev := stBefore.HeadRevisionNo + beforeVer := stBefore.CurrentVersionNo + if stBefore.PendingCandidateID != nil { + t.Fatalf("baseline must not already have pending candidate: %v", *stBefore.PendingCandidateID) + } + isolatedWD := filepath.Join(dataDir, "isolated-quality-fail") + if err := os.MkdirAll(isolatedWD, 0o755); err != nil { + t.Fatal(err) + } + failBody := "# 未过门禁草稿\n\n此处篇幅有限,不做展开。\n" + + out, err := svc.finalizeSquadDeliverable(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", UserID: 1, DocID: docID, + }, &squad.SquadRunResult{ + TaskKey: "intake", + ArtifactTitle: "用户初始需求收集表", + MarkdownBody: failBody, + WorkDir: isolatedWD, + BackendUsed: "stub", + Duration: time.Second, + // 敌意:Success 误为 true,旧路径若只看 Success 会 Materialize + Success: true, + QualityReport: &squad.QualityReport{ + Passed: false, + Score: 40, + ProhibitedPhrases: []string{"不做展开", "篇幅有限"}, + Violations: []string{"检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)"}, + }, + }, docID, docRec, "", nil) + if err != nil { + t.Fatalf("quality fail must return nil error, got %v", err) + } + if out == nil || out.Success { + t.Fatalf("want Success=false, got %+v", out) + } + if out.TerminalStatus() != TaskStatusQualityFailed { + t.Fatalf("status=%q want quality_failed", out.TerminalStatus()) + } + if out.WorkDir != isolatedWD { + t.Fatalf("must keep isolated workspace, got %q", out.WorkDir) + } + if !strings.Contains(out.ErrorReason, "质量门禁未通过") { + t.Fatalf("ErrorReason=%q", out.ErrorReason) + } + if out.MarkdownBody != failBody { + t.Fatal("failure summary body must remain visible") + } + + afterDoc, err := svc.DocGov().GetDocument(ctx, docID) + if err != nil { + t.Fatal(err) + } + if afterDoc.WorkDir != beforeWD { + t.Fatalf("formal work_dir mutated: before=%q after=%q", beforeWD, afterDoc.WorkDir) + } + if afterDoc.ContentSHA256 != beforeSHA { + t.Fatalf("formal content hash mutated: before=%q after=%q", beforeSHA, afterDoc.ContentSHA256) + } + stAfter, err := svc.DocRevision().GetRevisionState(ctx, docID) + if err != nil { + t.Fatal(err) + } + if stAfter.HeadSHA256 != beforeHead || stAfter.HeadRevisionNo != beforeRev || stAfter.CurrentVersionNo != beforeVer { + t.Fatalf("ledger head mutated: before=%s rev=%d ver=%d after=%s rev=%d ver=%d", + beforeHead, beforeRev, beforeVer, stAfter.HeadSHA256, stAfter.HeadRevisionNo, stAfter.CurrentVersionNo) + } + if stAfter.PendingCandidateID != nil { + t.Fatalf("quality_failed must not create pending candidate: %v", *stAfter.PendingCandidateID) + } + versAfter, err := svc.DocRevision().ListVersions(ctx, docID) + if err != nil { + t.Fatal(err) + } + if len(versAfter) != len(versBefore) { + t.Fatalf("version count changed: before=%d after=%d", len(versBefore), len(versAfter)) + } + for _, title := range activityTitles(t, svc, projectID) { + if strings.Contains(title, "完成交付物") || strings.Contains(title, "待编制人确认") { + t.Fatalf("quality_failed must not record delivery activity: %q", title) + } + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index dd510754..eb335e82 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -724,12 +724,65 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun return nil, err } + return s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc) +} + +// qualityBlocksControlledProjection 质量门禁未过时禁止任何正式受控投影。 +// 独立于 Success,避免 Success 误为 true 时仍 Materialize。 +func qualityBlocksControlledProjection(report *squad.QualityReport) bool { + return report != nil && !report.Passed +} + +func qualityFailedErrorReason(report *squad.QualityReport) string { + if report == nil { + return "质量门禁未通过" + } + if len(report.Violations) > 0 { + return "质量门禁未通过:" + strings.Join(report.Violations, ";") + } + return fmt.Sprintf("质量门禁未通过(评分 %.0f/100)", report.Score) +} + +// finalizeSquadDeliverable 处理小队返回后的受控文档投影边界。 +// 质量失败必须在 SetDocumentWorkDir / Materialize / MarkDocumentContentUpdated / 完成活动之前短路。 +func (s *Service) finalizeSquadDeliverable( + ctx context.Context, + req TaskRunRequest, + res *squad.SquadRunResult, + docID string, + docRec *docgov.DocumentRecord, + appliedProposalID string, + upstreamSRDoc *docgov.DocumentRecord, +) (*TaskRunResult, error) { + if res == nil { + return &TaskRunResult{TaskKey: req.TaskKey, Success: false, ErrorReason: "小队无返回结果"}, nil + } + + // 质量门禁短路:保留隔离工作区与可见失败摘要,不改正式 doc work_dir/head/hash,不激活账本版本。 + if qualityBlocksControlledProjection(res.QualityReport) { + return &TaskRunResult{ + TaskKey: res.TaskKey, + ArtifactTitle: res.ArtifactTitle, + MarkdownBody: res.MarkdownBody, + ExtractedData: res.ExtractedData, + Attachments: res.Attachments, + WorkDir: res.WorkDir, + BackendUsed: res.BackendUsed, + Duration: res.Duration, + Success: false, + ErrorReason: qualityFailedErrorReason(res.QualityReport), + QualityReport: res.QualityReport, + Manifest: res.Manifest, + AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + }, nil + } + // RecordActivity for "完成交付物" must NOT run here: Materialize may still fail or // leave needs_confirmation. Activity is recorded only after a committed projection // (or a pending-awaiting note that is explicitly not completion). - if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil && - (res.QualityReport == nil || res.QualityReport.Passed) { + if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil { _ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir) dataRoot := "." if s.cfg != nil && s.cfg.DataDir != "" { From 107f898362088ade1316ad4ff1201627ad36499b Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:16:12 +0000 Subject: [PATCH 4/7] fix(docgov): tighten prohibited-phrase exemption; lock stage request fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自检豁免改为同行且须 meta+否定紧邻或引号引用,避免正文「无」误放过;buildBizdevStageRequest 单测锁住身份/职责字段与 AutoRepair 契约。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 2 +- ...09-13-document-revision-sse-idempotency.md | 1 + .../server/task_run_request_build_test.go | 47 +++++++++++++++++ internal/squad/quality_gate.go | 52 ++++++++++++++----- internal/squad/quality_gate_test.go | 28 ++++++++-- 5 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 internal/server/task_run_request_build_test.go diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md index 3b28f545..da927bc2 100644 --- a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -31,7 +31,7 @@ severity: high ## 整改(同分支 `fix/document-revision-sse-idempotency`) - A:有 `TaskKey` 的受控小队任务服务端强制 `DefaultMaxRepairRounds`;handlers 产品契约 `AutoRepair: true` -- B:prompt 去原词;`containsProhibitedUsage` + 否定/元语境;违规文案与修复 TASK 不回写禁词 +- B:prompt 去原词;豁免仅限同一自检/规则行且同时具备 meta marker + 否定紧邻或引号引用(禁止 48 字窗口遇「无」即放过) - C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 - D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 - **E(投影边界)**:`finalizeSquadDeliverable` 在任何 `SetDocumentWorkDir` / `Materialize` / `MarkDocumentContentUpdated` / 完成活动之前按 `QualityReport.Passed=false` 短路;保留隔离工作区与失败摘要;正式 `work_dir`/head/hash/候选不变。单测:`TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects`(敌意 `Success=true`) diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index 9f7b797f..dd39f0d5 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -37,6 +37,7 @@ mutex: docgov 7. [x] C quality_failed UI/Materialize 契约 8. [x] D 真实问题单测 + 门禁回归 9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用) +10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定 ## 验收标准 diff --git a/internal/server/task_run_request_build_test.go b/internal/server/task_run_request_build_test.go new file mode 100644 index 00000000..1caeb2d0 --- /dev/null +++ b/internal/server/task_run_request_build_test.go @@ -0,0 +1,47 @@ +package server + +import ( + "testing" + + "chat-agent-system/internal/bizdev" +) + +func TestBuildBizdevStageRequest_PreservesIdentityAndRoles(t *testing.T) { + s := &Server{} + dto := TaskRunRequestDTO{ + Stage: "v0.5_feasibility", + ProjectName: "P", + CustomerName: "C", + DeviceType: "D", + IntendedUse: "U", + TargetMarket: "M", + Backend: "stub", + AutoRepair: false, + UserID: 107, + ProjectID: 6098, + DocID: "doc_proj6098_intake", + AuthorID: 1, + AuthorName: "编制人", + ReviewerID: 2, + ReviewerName: "审核人", + ApproverID: 3, + ApproverName: "批准人", + ForceDirectEdit: true, + } + got := s.buildBizdevStageRequest(dto, "sess-p6098-u107", "turn_x", "run_y") + if !got.AutoRepair { + t.Fatal("product contract: AutoRepair must be forced true") + } + if got.SessionID != "sess-p6098-u107" || got.ClientTurnID != "turn_x" || got.RunID != "run_y" { + t.Fatalf("session/turn/run dropped: %+v", got) + } + if got.UserID != 107 || got.ProjectID != 6098 || got.DocID != "doc_proj6098_intake" { + t.Fatalf("identity fields dropped: %+v", got) + } + if got.AuthorID != 1 || got.ReviewerID != 2 || got.ApproverID != 3 || !got.ForceDirectEdit { + t.Fatalf("role fields dropped: %+v", got) + } + if got.Stage != bizdev.MaturityStage("v0.5_feasibility") { + t.Fatalf("stage=%q", got.Stage) + } +} diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index 9bb2b00c..a944ba22 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -196,7 +196,20 @@ func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) b lineEnd = idx + lineEnd } line := content[lineStart:lineEnd] + localIdx := idx - lineStart + if localIdx < 0 || localIdx > len(line) { + return false + } + // 仅同一自检/规则行,且须同时具备元标注 + 否定词紧邻或引号引用结构。 + // 禁止用「48 字窗口出现 无」豁免:正文「此处无更多数据,不做展开」必须仍命中。 + if !lineHasProhibitedMetaMarker(line) { + return false + } + return phraseQuotedOnLine(line, phrase) || negationAdjacentToPhrase(line, localIdx) +} + +func lineHasProhibitedMetaMarker(line string) bool { metaMarkers := []string{ "写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写", "负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准", @@ -206,24 +219,39 @@ func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) b return true } } + return false +} - prefixStart := idx - 48 - if prefixStart < 0 { - prefixStart = 0 +func phraseQuotedOnLine(line, phrase string) bool { + if phrase == "" { + return false } - prefix := content[prefixStart:idx] - negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"} - for _, n := range negMarkers { - if strings.Contains(prefix, n) { + wrappers := [][2]string{ + {`"`, `"`}, + {"「", "」"}, + {`'`, `'`}, + {"“", "”"}, + {"‘", "’"}, + } + for _, w := range wrappers { + if strings.Contains(line, w[0]+phrase+w[1]) { return true } } - // 现场形态:无"不做展开""从略""篇幅有限"等敷衍套话 - if strings.Contains(prefix, "无\"") || strings.Contains(prefix, "无「") || strings.Contains(prefix, "无'") || - strings.Contains(prefix, "无“") { - return true + return false +} + +func negationAdjacentToPhrase(line string, phraseIdx int) bool { + if phraseIdx <= 0 || phraseIdx > len(line) { + return false + } + prefix := strings.TrimRight(line[:phraseIdx], " \t\"'「“‘") + negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"} + for _, n := range negMarkers { + if strings.HasSuffix(prefix, n) { + return true + } } - _ = phrase return false } diff --git a/internal/squad/quality_gate_test.go b/internal/squad/quality_gate_test.go index 7fe224b2..530d4c5a 100644 --- a/internal/squad/quality_gate_test.go +++ b/internal/squad/quality_gate_test.go @@ -208,10 +208,10 @@ func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine() ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ - WorkDir: tmpDir, - TaskKey: "intake", - StageTitle: "用户初始需求收集表", - ProjectName: "医疗设备", + WorkDir: tmpDir, + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + ProjectName: "医疗设备", TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", }) if err != nil { @@ -267,4 +267,24 @@ func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) { } } } + + // 正文出现「无」但非自检引用:不得因 48 字窗口豁免 + falseNeg := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处无更多数据,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" + fn, err := gate.Validate(ws.WorkDir, "intake", falseNeg) + if err != nil { + t.Fatalf("Validate false-neg: %v", err) + } + if fn.Passed || len(fn.ProhibitedPhrases) == 0 { + t.Fatalf("body hedging with 无 must still fail; passed=%v phrases=%v", fn.Passed, fn.ProhibitedPhrases) + } + + // 仅有审核栏标题、禁词作正文省略:不得豁免 + metaOnly := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n审核结论:本节不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" + mo, err := gate.Validate(ws.WorkDir, "intake", metaOnly) + if err != nil { + t.Fatalf("Validate meta-only: %v", err) + } + if mo.Passed || len(mo.ProhibitedPhrases) == 0 { + t.Fatalf("meta heading plus real hedging must fail; passed=%v phrases=%v", mo.Passed, mo.ProhibitedPhrases) + } } From 9e50bc0543a8305eae750f94f52a7c0af49bf1ff Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:17:40 +0000 Subject: [PATCH 5/7] fix(docgov): stop ExecuteStage on quality_failed without absorbing facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段链在 ExecuteTask err=nil 后同时检查质量门禁与 Success;失败立即停链并保留 artifact,不注入 ExtractedData/PriorDoc,不宣称阶段成功。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 1 + ...09-13-document-revision-sse-idempotency.md | 1 + internal/bizdev/service.go | 58 +++---- internal/bizdev/stage_halt.go | 70 ++++++++ internal/bizdev/stage_quality_stop_test.go | 152 ++++++++++++++++++ 5 files changed, 244 insertions(+), 38 deletions(-) create mode 100644 internal/bizdev/stage_halt.go create mode 100644 internal/bizdev/stage_quality_stop_test.go diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md index da927bc2..1049bffd 100644 --- a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -35,6 +35,7 @@ severity: high - C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 - D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 - **E(投影边界)**:`finalizeSquadDeliverable` 在任何 `SetDocumentWorkDir` / `Materialize` / `MarkDocumentContentUpdated` / 完成活动之前按 `QualityReport.Passed=false` 短路;保留隔离工作区与失败摘要;正式 `work_dir`/head/hash/候选不变。单测:`TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects`(敌意 `Success=true`) +- **F(阶段链)**:`ExecuteStage` 在 `err=nil` 后经 `classifyStageTaskHalt` 检查质量/`Success`;失败立即停链,保留失败 artifact,不吸收 ExtractedData、不追加 PriorDoc、不写 completed workspace / 阶段跃迁。单测:`TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc` ## 证据 diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index dd39f0d5..8c7e2ca1 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -38,6 +38,7 @@ mutex: docgov 8. [x] D 真实问题单测 + 门禁回归 9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用) 10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定 +11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc ## 验收标准 diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index eb335e82..66fdd254 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -43,6 +43,8 @@ type Service struct { pool *pgxpool.Pool schemaVersion int64 stageGate StageAccessGate + // stageTaskHook 仅测试注入:覆盖 ExecuteStage 子任务执行,验证链中止边界。 + stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error) } // NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。 @@ -1131,7 +1133,7 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) ForceDirectEdit: req.ForceDirectEdit, } - res, err := s.ExecuteTask(ctx, tReq) + res, err := s.executeStageTask(ctx, tReq) if err != nil { return &StageRunResult{ Stage: req.Stage, @@ -1144,48 +1146,28 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) }, err } - // DocGov claimed-doc proposal intercept — stop stage; do not absorb facts. - if res != nil && res.IsProposalIntercept() { - return &StageRunResult{ - Stage: req.Stage, - StageTitle: targetMeta.Title, - Success: false, - WorkDir: workDir, - Duration: time.Since(start), - ErrorReason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey), - SubArtifacts: append(executedArtifacts, res), - }, nil - } - - // High-risk revision awaiting author confirmation — stop stage chain. - // Do NOT absorb ExtractedData or MarkdownBody as PriorDoc (would indirectly apply unconfirmed content). - if res != nil && res.AwaitsAgentConfirmation() { - risk := res.PendingRiskLevel - if risk == "" { - risk = "high" + if halt := classifyStageTaskHalt(res, tKey); halt != nil { + out := &StageRunResult{ + Stage: req.Stage, + StageTitle: targetMeta.Title, + Success: false, + WorkDir: workDir, + Duration: time.Since(start), + ErrorReason: halt.reason, + PrimaryArtifact: res, + SubArtifacts: append(executedArtifacts, res), } - reason := fmt.Sprintf( - "任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。", - tKey, risk, res.PendingAgentCandidateID, - ) - return &StageRunResult{ - Stage: req.Stage, - StageTitle: targetMeta.Title, - Success: false, - WorkDir: workDir, - Duration: time.Since(start), - ErrorReason: reason, - PrimaryArtifact: res, - SubArtifacts: append(executedArtifacts, res), - PendingAgentCandidateID: res.PendingAgentCandidateID, - PendingChangeReportID: res.PendingChangeReportID, - PendingRiskLevel: res.PendingRiskLevel, - }, nil + if halt.pending { + out.PendingAgentCandidateID = res.PendingAgentCandidateID + out.PendingChangeReportID = res.PendingChangeReportID + out.PendingRiskLevel = res.PendingRiskLevel + } + return out, nil } executedArtifacts = append(executedArtifacts, res) - // 吸收回填事实(仅已提交 / 非 pending 产物) + // 吸收回填事实(仅已提交且过门禁的产物) if res.ExtractedData != nil { for k, v := range res.ExtractedData { if v != "" { diff --git a/internal/bizdev/stage_halt.go b/internal/bizdev/stage_halt.go new file mode 100644 index 00000000..27f99bda --- /dev/null +++ b/internal/bizdev/stage_halt.go @@ -0,0 +1,70 @@ +package bizdev + +import ( + "context" + "fmt" + "strings" +) + +type stageTaskHalt struct { + reason string + pending bool + quality bool +} + +func (s *Service) executeStageTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) { + if s != nil && s.stageTaskHook != nil { + return s.stageTaskHook(ctx, req) + } + return s.ExecuteTask(ctx, req) +} + +// classifyStageTaskHalt 阶段链在 ExecuteTask err=nil 后的中止判定。 +// 质量失败(含仅 Success=false + QualityReport.Passed=false)必须停链: +// 不吸收 ExtractedData、不追加 PriorDoc、不宣称阶段成功。 +func classifyStageTaskHalt(res *TaskRunResult, tKey string) *stageTaskHalt { + if res == nil { + return &stageTaskHalt{reason: fmt.Sprintf("任务【%s】无返回结果", tKey)} + } + if res.IsProposalIntercept() { + return &stageTaskHalt{ + reason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey), + } + } + if res.AwaitsAgentConfirmation() { + risk := res.PendingRiskLevel + if risk == "" { + risk = "high" + } + return &stageTaskHalt{ + pending: true, + reason: fmt.Sprintf( + "任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。", + tKey, risk, res.PendingAgentCandidateID, + ), + } + } + if qualityBlocksControlledProjection(res.QualityReport) || res.TerminalStatus() == TaskStatusQualityFailed { + detail := qualityFailedErrorReason(res.QualityReport) + if res.ErrorReason != "" && !strings.Contains(detail, res.ErrorReason) { + detail = res.ErrorReason + } + return &stageTaskHalt{ + quality: true, + reason: fmt.Sprintf( + "任务【%s】质量门禁未通过。%s。本阶段已停止,未吸收提取事实、未将未过门禁正文作为后续 PriorDoc。", + tKey, strings.TrimPrefix(detail, "质量门禁未通过:"), + ), + } + } + if !res.Success { + reason := res.ErrorReason + if strings.TrimSpace(reason) == "" { + reason = "任务未成功" + } + return &stageTaskHalt{ + reason: fmt.Sprintf("任务【%s】未成功:%s。本阶段已停止,未吸收提取事实、未追加 PriorDoc。", tKey, reason), + } + } + return nil +} diff --git a/internal/bizdev/stage_quality_stop_test.go b/internal/bizdev/stage_quality_stop_test.go new file mode 100644 index 00000000..80bce120 --- /dev/null +++ b/internal/bizdev/stage_quality_stop_test.go @@ -0,0 +1,152 @@ +package bizdev + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/squad" +) + +func TestClassifyStageTaskHalt_QualityFailedStopsWithoutAbsorbing(t *testing.T) { + res := &TaskRunResult{ + Success: false, + TaskKey: "research", + ArtifactTitle: "调研报告", + MarkdownBody: "此处篇幅有限,不做展开。", + ExtractedData: map[string]string{"device_type": "poison_from_failed_draft"}, + QualityReport: &squad.QualityReport{Passed: false, Score: 40, Violations: []string{"省略/敷衍写法"}}, + ErrorReason: "质量门禁未通过:省略/敷衍写法", + } + halt := classifyStageTaskHalt(res, "research") + if halt == nil || !halt.quality { + t.Fatalf("quality fail must halt stage, got %+v", halt) + } + if !strings.Contains(halt.reason, "质量门禁未通过") { + t.Fatalf("reason=%q", halt.reason) + } + if !strings.Contains(halt.reason, "未吸收") || !strings.Contains(halt.reason, "PriorDoc") { + t.Fatalf("reason must state no absorb / no PriorDoc: %q", halt.reason) + } +} + +func TestClassifyStageTaskHalt_SuccessFalseWithoutQualityAlsoStops(t *testing.T) { + halt := classifyStageTaskHalt(&TaskRunResult{Success: false, ErrorReason: "落盘失败"}, "intake") + if halt == nil || halt.quality || halt.pending { + t.Fatalf("generic failure must halt (not as quality/pending): %+v", halt) + } +} + +func TestClassifyStageTaskHalt_PassedSuccessContinues(t *testing.T) { + if halt := classifyStageTaskHalt(&TaskRunResult{ + Success: true, + QualityReport: &squad.QualityReport{Passed: true, Score: 90}, + }, "intake"); halt != nil { + t.Fatalf("passed task must continue: %+v", halt) + } +} + +func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t *testing.T) { + dataDir := t.TempDir() + svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"}) + ctx := context.Background() + projectID := int64(6099) + stageWork := filepath.Join(dataDir, "ws-stage-quality") + if err := os.MkdirAll(stageWork, 0o755); err != nil { + t.Fatal(err) + } + + sentinelKey := "quality_must_not_overwrite" + sentinelVal := "pre_stage_sentinel" + var seenKeys []string + svc.stageTaskHook = func(_ context.Context, req TaskRunRequest) (*TaskRunResult, error) { + seenKeys = append(seenKeys, req.TaskKey) + if req.TaskKey != "research" { + t.Fatalf("quality fail must stop before subsequent task %q; seen=%v priors=%d", + req.TaskKey, seenKeys, len(req.PriorDocs)) + } + for _, p := range req.PriorDocs { + if p.DocKey == "research" || strings.Contains(p.Content, "未过门禁") { + t.Fatalf("must not feed failed markdown as PriorDoc: %+v", p) + } + } + return &TaskRunResult{ + TaskKey: "research", + ArtifactTitle: "调研报告", + MarkdownBody: "# 未过门禁草稿\n此处篇幅有限,不做展开。", + ExtractedData: map[string]string{ + sentinelKey: "poison_from_quality_fail", + "device_type": "should_not_land_on_blackboard", + }, + WorkDir: filepath.Join(stageWork, "research"), + Success: false, + ErrorReason: "质量门禁未通过:省略/敷衍写法", + QualityReport: &squad.QualityReport{ + Passed: false, + Score: 40, + Violations: []string{"省略/敷衍写法"}, + }, + }, nil + } + + stageRes, err := svc.ExecuteStage(ctx, StageRunRequest{ + Stage: StageV05_Feasibility, + ProjectName: "阶段链质量失败测试", + CustomerName: "C", + DeviceType: "医疗器械", + IntendedUse: "诊断", + TargetMarket: "国内", + Fields: map[string]string{sentinelKey: sentinelVal}, + Backend: "stub", + Timeout: 15 * time.Second, + WorkDir: stageWork, + ProjectID: projectID, + UserID: 1, + }) + if err != nil { + t.Fatalf("ExecuteStage unexpected hard error: %v", err) + } + if stageRes == nil || stageRes.Success { + t.Fatalf("stage must fail on quality gate, got %+v", stageRes) + } + if !strings.Contains(stageRes.ErrorReason, "质量门禁未通过") { + t.Fatalf("ErrorReason=%q", stageRes.ErrorReason) + } + if stageRes.PrimaryArtifact == nil || stageRes.PrimaryArtifact.MarkdownBody == "" { + t.Fatal("must retain failed artifact body") + } + if stageRes.PrimaryArtifact.TerminalStatus() != TaskStatusQualityFailed { + t.Fatalf("primary status=%q", stageRes.PrimaryArtifact.TerminalStatus()) + } + if len(stageRes.SubArtifacts) != 1 || seenKeys[0] != "research" || len(seenKeys) != 1 { + t.Fatalf("must stop after first task; sub=%d seen=%v", len(stageRes.SubArtifacts), seenKeys) + } + for _, next := range []string{"feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"} { + if _, err := os.Stat(filepath.Join(stageWork, next, "output.md")); err == nil { + t.Fatalf("subsequent task %s must not run", next) + } + } + if _, err := os.Stat(filepath.Join(stageWork, "workspace.json")); err == nil { + t.Fatal("quality stop must not write completed stage workspace.json") + } + + pCtx, err := svc.ContextManager().GetProjectContext(ctx, projectID) + if err != nil { + t.Fatalf("GetProjectContext: %v", err) + } + if got := pCtx.BlackboardFacts[sentinelKey]; got != "" && got != sentinelVal { + t.Fatalf("must not absorb failed ExtractedData onto blackboard: %q", got) + } + if pCtx.BlackboardFacts["device_type"] == "should_not_land_on_blackboard" { + t.Fatal("poison extracted field landed on project blackboard") + } + for _, act := range pCtx.AllActivities { + if strings.Contains(act.Title, "阶段跃迁") { + t.Fatalf("must not record stage_advance: %q", act.Title) + } + } +} From 2d86d330e5016e2c666e4093b81e20bd57780061 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:19:20 +0000 Subject: [PATCH 6/7] fix(docgov): keep squad AutoRepair/Success semantics; force repair only on controlled docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小队修复预算只认 AutoRepair,Success 只表示执行完成;bizdev 在解析出 docRec 后强制开启修复,投影与阶段停链仍以 QualityReport 为准。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 2 +- ...09-13-document-revision-sse-idempotency.md | 1 + internal/bizdev/quality_projection_test.go | 13 ++++++ internal/bizdev/service.go | 10 ++++- internal/bizdev/stage_halt.go | 2 +- internal/bizdev/stage_quality_stop_test.go | 5 ++- internal/squad/repair_budget_test.go | 18 +++----- internal/squad/service.go | 43 +++++++++---------- 8 files changed, 55 insertions(+), 39 deletions(-) diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md index 1049bffd..2e20aff8 100644 --- a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -30,7 +30,7 @@ severity: high ## 整改(同分支 `fix/document-revision-sse-idempotency`) -- A:有 `TaskKey` 的受控小队任务服务端强制 `DefaultMaxRepairRounds`;handlers 产品契约 `AutoRepair: true` +- A:小队 `repairBudget` 只认 `AutoRepair`;bizdev 在解析出 `docID`+`docRec` 后强制开启;受控 HTTP `task_run_handlers` 仍设 `true`。小队 `Success` 仅表示执行完成,投影/阶段停链看 `QualityReport` - B:prompt 去原词;豁免仅限同一自检/规则行且同时具备 meta marker + 否定紧邻或引号引用(禁止 48 字窗口遇「无」即放过) - C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 - D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index 8c7e2ca1..43faba04 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -39,6 +39,7 @@ mutex: docgov 9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用) 10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定 11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc +12. [x] 小队 AutoRepair/Success 语义恢复;受控修复仅在 bizdev docRec 与 HTTP 边界强制 ## 验收标准 diff --git a/internal/bizdev/quality_projection_test.go b/internal/bizdev/quality_projection_test.go index b0784d7b..13732b16 100644 --- a/internal/bizdev/quality_projection_test.go +++ b/internal/bizdev/quality_projection_test.go @@ -9,10 +9,23 @@ import ( "time" "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/squad" ) +func TestAutoRepairForControlledSquad(t *testing.T) { + if autoRepairForControlledSquad(false, "", nil) { + t.Fatal("uncontrolled must respect AutoRepair=false") + } + if !autoRepairForControlledSquad(true, "", nil) { + t.Fatal("uncontrolled AutoRepair=true must stay true") + } + if !autoRepairForControlledSquad(false, "doc_proj1_intake", &docgov.DocumentRecord{DocID: "doc_proj1_intake"}) { + t.Fatal("resolved controlled doc must force AutoRepair") + } +} + func TestQualityBlocksControlledProjection(t *testing.T) { if qualityBlocksControlledProjection(nil) { t.Fatal("nil report must not block") diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 66fdd254..b2e5c60c 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -698,7 +698,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun Timeout: req.Timeout, WorkDir: req.WorkDir, CustomSteps: req.CustomSteps, - AutoRepair: req.AutoRepair, + AutoRepair: autoRepairForControlledSquad(req.AutoRepair, docID, docRec), KnowledgeHits: hits, PriorDocs: priorDocs, UserContext: userCtxStr, @@ -735,6 +735,14 @@ func qualityBlocksControlledProjection(report *squad.QualityReport) bool { return report != nil && !report.Passed } +// autoRepairForControlledSquad 已解析出正式受控文档后强制开启修复;非受控调用仍尊重请求值。 +func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docgov.DocumentRecord) bool { + if strings.TrimSpace(docID) != "" && docRec != nil { + return true + } + return reqAutoRepair +} + func qualityFailedErrorReason(report *squad.QualityReport) string { if report == nil { return "质量门禁未通过" diff --git a/internal/bizdev/stage_halt.go b/internal/bizdev/stage_halt.go index 27f99bda..e3fed066 100644 --- a/internal/bizdev/stage_halt.go +++ b/internal/bizdev/stage_halt.go @@ -20,7 +20,7 @@ func (s *Service) executeStageTask(ctx context.Context, req TaskRunRequest) (*Ta } // classifyStageTaskHalt 阶段链在 ExecuteTask err=nil 后的中止判定。 -// 质量失败(含仅 Success=false + QualityReport.Passed=false)必须停链: +// 质量失败以 QualityReport 为准(小队 Success 仍可表示执行完成),必须停链: // 不吸收 ExtractedData、不追加 PriorDoc、不宣称阶段成功。 func classifyStageTaskHalt(res *TaskRunResult, tKey string) *stageTaskHalt { if res == nil { diff --git a/internal/bizdev/stage_quality_stop_test.go b/internal/bizdev/stage_quality_stop_test.go index 80bce120..5237e979 100644 --- a/internal/bizdev/stage_quality_stop_test.go +++ b/internal/bizdev/stage_quality_stop_test.go @@ -82,8 +82,9 @@ func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t sentinelKey: "poison_from_quality_fail", "device_type": "should_not_land_on_blackboard", }, - WorkDir: filepath.Join(stageWork, "research"), - Success: false, + WorkDir: filepath.Join(stageWork, "research"), + // 小队执行完成可为 true;阶段停链看 QualityReport,不依赖 Success=false + Success: true, ErrorReason: "质量门禁未通过:省略/敷衍写法", QualityReport: &squad.QualityReport{ Passed: false, diff --git a/internal/squad/repair_budget_test.go b/internal/squad/repair_budget_test.go index a72f32aa..53b8da22 100644 --- a/internal/squad/repair_budget_test.go +++ b/internal/squad/repair_budget_test.go @@ -2,24 +2,20 @@ package squad import "testing" -func TestRepairBudgetForSquadTask_ProductContract(t *testing.T) { - // 受控文档小队任务:即使客户端省略 auto_repair=false,也必须给修复预算 +func TestRepairBudgetForSquadTask_RespectsAutoRepairOnly(t *testing.T) { got := repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: false}) - if got != DefaultMaxRepairRounds { - t.Fatalf("intake AutoRepair=false budget=%d want %d", got, DefaultMaxRepairRounds) + if got != 0 { + t.Fatalf("TaskKey alone must not enable repair: budget=%d", got) } - if DefaultMaxRepairRounds < 1 { - t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds) - } - - // 无 TaskKey 且未开 AutoRepair:保持 0(非受控路径) got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: false}) if got != 0 { t.Fatalf("empty TaskKey without AutoRepair budget=%d want 0", got) } - - got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: true}) + got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: true}) if got != DefaultMaxRepairRounds { t.Fatalf("AutoRepair=true budget=%d want %d", got, DefaultMaxRepairRounds) } + if DefaultMaxRepairRounds < 1 { + t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds) + } } diff --git a/internal/squad/service.go b/internal/squad/service.go index aa4b56ee..1a250c05 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -32,22 +32,22 @@ type Attachment struct { // SquadRunRequest 运行请求 type SquadRunRequest struct { - TaskKey string `json:"task_key"` - Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle) - ProjectName string `json:"project_name"` - CustomerName string `json:"customer_name"` - DeviceType string `json:"device_type"` - IntendedUse string `json:"intended_use"` - TargetMarket string `json:"target_market"` - Fields map[string]string `json:"fields"` - Materials []MaterialItem `json:"materials"` - Backend string `json:"backend"` - Timeout time.Duration `json:"timeout"` - WorkDir string `json:"work_dir"` - CustomSteps []StepSpec `json:"custom_steps"` - AutoRepair bool `json:"auto_repair"` - KnowledgeHits []KnowledgeHit `json:"knowledge_hits"` - PriorDocs []PriorDocItem `json:"prior_docs"` + TaskKey string `json:"task_key"` + Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle) + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + Fields map[string]string `json:"fields"` + Materials []MaterialItem `json:"materials"` + Backend string `json:"backend"` + Timeout time.Duration `json:"timeout"` + WorkDir string `json:"work_dir"` + CustomSteps []StepSpec `json:"custom_steps"` + AutoRepair bool `json:"auto_repair"` + KnowledgeHits []KnowledgeHit `json:"knowledge_hits"` + PriorDocs []PriorDocItem `json:"prior_docs"` UserContext string `json:"user_context,omitempty"` MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"` SessionID string `json:"session_id,omitempty"` @@ -295,7 +295,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR if backendName == "stub" { finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md")) _ = os.WriteFile(finalOutPath, finalBytes, 0o644) - // stub 终稿以 template 为准:门禁须对齐最终交付物,避免对中间草稿误伤 Success/Materialize + // stub 终稿以 template 为准:QualityReport 对齐最终交付物(Success 仍只表示执行完成) if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil { report = reReport manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report) @@ -356,19 +356,16 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR WorkDir: workDir, BackendUsed: backendName, Duration: time.Since(start), - Success: execRes.Success && (report == nil || report.Passed), + Success: execRes.Success, QualityReport: report, Manifest: manifest, AdvisorBrief: advisorBrief, }, nil } -// repairBudgetForSquadTask 产品契约:有 TaskKey 的受控文档小队任务默认启用修复轮预算, -// 不依赖客户端 auto_repair(浏览器可省略且默认为 false)。 +// repairBudgetForSquadTask 仅尊重请求方 AutoRepair,不因 TaskKey 非空而改写 CLI/桩/非受控语义。 +// 受控文档任务的修复预算由 bizdev/HTTP 在调用前强制 AutoRepair=true。 func repairBudgetForSquadTask(req SquadRunRequest) int { - if strings.TrimSpace(req.TaskKey) != "" { - return DefaultMaxRepairRounds - } if req.AutoRepair { return DefaultMaxRepairRounds } From f4aa0da14fb3f226eccc5725adb55a1d29d4c9a3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:20:42 +0000 Subject: [PATCH 7/7] test(docgov): lock quality projection, stage halt, and uncontrolled auto-repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 QualityReport 拦截投影与阶段停链断言,并证明 CLI 非受控路径 AutoRepair=false 不会追加 repair 步。 Co-authored-by: Cursor --- ...09-13-document-revision-sse-idempotency.md | 1 + internal/bizdev/quality_projection_test.go | 43 +++++++++++++++++++ internal/bizdev/stage_quality_stop_test.go | 12 ++++++ 3 files changed, 56 insertions(+) diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index 43faba04..f8bcb5b1 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -40,6 +40,7 @@ mutex: docgov 10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定 11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc 12. [x] 小队 AutoRepair/Success 语义恢复;受控修复仅在 bizdev docRec 与 HTTP 边界强制 +13. [x] 补强:quality projection / 阶段停链 / 非受控不强制 repair 单测;全量 Go+Web 门禁 PASS ## 验收标准 diff --git a/internal/bizdev/quality_projection_test.go b/internal/bizdev/quality_projection_test.go index 13732b16..fada1da2 100644 --- a/internal/bizdev/quality_projection_test.go +++ b/internal/bizdev/quality_projection_test.go @@ -2,6 +2,7 @@ package bizdev import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -24,6 +25,9 @@ func TestAutoRepairForControlledSquad(t *testing.T) { if !autoRepairForControlledSquad(false, "doc_proj1_intake", &docgov.DocumentRecord{DocID: "doc_proj1_intake"}) { t.Fatal("resolved controlled doc must force AutoRepair") } + if autoRepairForControlledSquad(false, "doc_proj1_intake", nil) { + t.Fatal("docID without docRec must not force AutoRepair") + } } func TestQualityBlocksControlledProjection(t *testing.T) { @@ -152,3 +156,42 @@ func TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects(t *te } } } + +func TestExecuteTask_Uncontrolled_DoesNotForceAutoRepair(t *testing.T) { + dataDir := t.TempDir() + svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"}) + res, err := svc.ExecuteTask(context.Background(), TaskRunRequest{ + TaskKey: "intake", + ProjectID: 0, + DocID: "", + AutoRepair: false, + ProjectName: "CLI非受控", + CustomerName: "C", + DeviceType: "医疗器械", + Backend: "stub", + Timeout: 10 * time.Second, + }) + if err != nil || res == nil || !res.Success { + t.Fatalf("uncontrolled ExecuteTask should succeed, err=%v res=%+v", err, res) + } + if res.DocRecord != nil { + t.Fatal("uncontrolled path must not create DocRecord") + } + raw, readErr := os.ReadFile(filepath.Join(res.WorkDir, "workflow.json")) + if readErr != nil { + t.Fatalf("read workflow.json: %v", readErr) + } + var wf struct { + Steps []struct { + ID string `json:"id"` + } `json:"steps"` + } + if err := json.Unmarshal(raw, &wf); err != nil { + t.Fatalf("workflow.json: %v", err) + } + for _, s := range wf.Steps { + if strings.HasPrefix(s.ID, "step_repair_") { + t.Fatalf("AutoRepair=false uncontrolled must not append repair step %q", s.ID) + } + } +} diff --git a/internal/bizdev/stage_quality_stop_test.go b/internal/bizdev/stage_quality_stop_test.go index 5237e979..8f20cd2e 100644 --- a/internal/bizdev/stage_quality_stop_test.go +++ b/internal/bizdev/stage_quality_stop_test.go @@ -34,6 +34,18 @@ func TestClassifyStageTaskHalt_QualityFailedStopsWithoutAbsorbing(t *testing.T) } } +func TestClassifyStageTaskHalt_QualityFailedEvenWhenSquadSuccess(t *testing.T) { + halt := classifyStageTaskHalt(&TaskRunResult{ + Success: true, + MarkdownBody: "未过门禁草稿", + ExtractedData: map[string]string{"device_type": "poison"}, + QualityReport: &squad.QualityReport{Passed: false, Score: 40}, + }, "research") + if halt == nil || !halt.quality { + t.Fatalf("QualityReport.Passed=false must halt even if Success=true, got %+v", halt) + } +} + func TestClassifyStageTaskHalt_SuccessFalseWithoutQualityAlsoStops(t *testing.T) { halt := classifyStageTaskHalt(&TaskRunResult{Success: false, ErrorReason: "落盘失败"}, "intake") if halt == nil || halt.quality || halt.pending {