From c07d4020d0f24cb809dc67fc21c33554b46ecad9 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 23 Sep 2026 15:10:26 +0000 Subject: [PATCH] feat(intentflow): I2 shadow tier, project allowlist, and rollout metrics Persist validated_only shadow plans without executing, gate elevated tiers behind INTENT_PLAN_PROJECT_ALLOWLIST, and keep legacy chat routing for shadow. --- .doc/ops/CURRENT.md | 1 + ...6-09-24-intent-plan-i2-shadow-allowlist.md | 59 ++++++++++ docker-compose.dev.yml | 1 + docker-compose.main.yml | 1 + docker-compose.test.yml | 1 + internal/app/app.go | 15 +++ internal/app/intentflow/allowlist.go | 63 +++++++++++ internal/app/intentflow/flags.go | 15 ++- internal/app/intentflow/flags_test.go | 42 ++++++++ internal/app/intentflow/metrics.go | 86 +++++++++++++++ internal/app/intentflow/plans.go | 10 -- internal/app/intentflow/turn.go | 96 ++++++++++++++--- internal/app/intentflow/turn_test.go | 102 +++++++++++++++++- internal/app/plan_owner_test.go | 4 +- internal/app/query_owner_test.go | 4 +- internal/chat/session.go | 5 +- internal/sysadmin/features.go | 7 +- 17 files changed, 477 insertions(+), 35 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-24-intent-plan-i2-shadow-allowlist.md create mode 100644 internal/app/intentflow/allowlist.go create mode 100644 internal/app/intentflow/metrics.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 3fe99f1b..91403fa1 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | intent-plan-i2-impl-2026-09-24 | feature/intent-plan-i2-shadow-allowlist-2026-09-24 | .worktrees/intent-plan-i2-shadow-allowlist-2026-09-24 | intent-plan | in_progress | | Cursor | closeout-claim-removed-2026-09-23 | feature/closeout-claim-removed-2026-09-23 | (removed) | git:dev+worktree-cleanup | done | | Cursor | fav-constraint-current-2026-09-23 | feature/fav-constraint-current-2026-09-23 | (removed) | git:dev+docs | done | | Cursor | closeout-worktree-cleanup-2026-09-23 | feature/closeout-worktree-cleanup-2026-09-23 | (removed) | git:dev+worktree-cleanup | done | diff --git a/.doc/ops/short-term/2026-09-24-intent-plan-i2-shadow-allowlist.md b/.doc/ops/short-term/2026-09-24-intent-plan-i2-shadow-allowlist.md new file mode 100644 index 00000000..7cbbdd43 --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-intent-plan-i2-shadow-allowlist.md @@ -0,0 +1,59 @@ +--- +title: "Track I2:shadow 层 + 项目 allowlist + 冻结阈值" +task_id: intent-plan-i2-shadow-allowlist-2026-09-24 +status: in_progress +created: 2026-09-24 +updated: 2026-09-24 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "AGENTS+CURRENT @ 9d8332ad" +branch: feature/intent-plan-i2-shadow-allowlist-2026-09-24 +worktree: .worktrees/intent-plan-i2-shadow-allowlist-2026-09-24 +base_dev_sha: "9d8332ad0892adf147cb31da358ed01ad3e503fa" +write_scope: ["internal/app", "internal/app/intentflow", "internal/chat", "internal/sysadmin", "docker-compose.*.yml", ".doc/ops/short-term", ".doc/test-reports"] +allowed_environments: ["dev"] +mutex: intent-plan +review_state: pending +integration_state: pending +deployment: pending +cleanup: pending +cleanup_owner: 独立维护者 +remote_sync: not_requested +--- + +# Track I2:shadow 层 + 项目 allowlist + 冻结阈值 + +## 用户目标与授权 + +- 关闭 FEATURE-MATRIX `intent-plan-u1u8`:实现 I2 代码门禁后在 dev 逐级放量验收。 +- 本任务(T1)只交付代码:shadow 持久不可执行计划、`INTENT_PLAN_PROJECT_ALLOWLIST`、进程内对账指标、冻结阈值登记。 +- 禁止:升 compose 默认值、升 full、push、操作 test/main。 + +## 冻结阈值(Foundation 0 / I2 进入 auto_read 前) + +对 U2 `plan-proposal-eval-v1.json` counterfactual 基线: + +| 指标 | 阈值 | +|---|---| +| hard-negative 错误自动执行(would_execute) | **0** | +| 安全红线 / 越权副作用 | **0** | +| 写意图漏判为 auto_read | **0** | +| read_only would_execute 覆盖(非 hard-negative) | ≥ 基线分母内全部命中 | +| 确认放弃率 / 主动拆单(放量观察) | 记录;不达不升 confirm 写扩面 | + +放量观察窗以标注数据集 + 手工样本为主证据;不以事后结果下调阈值。 + +## 步骤 + +1. [x] `TierShadow`:持久 `proposed` + `validated_only`,零执行;不占 active-write +2. [x] `INTENT_PLAN_PROJECT_ALLOWLIST`(空拒绝 / `*` / id 列表) +3. [x] 进程指标 + chat 对 shadow 不抢 legacy 路由 +4. [x] 单测 + arch-check +5. [ ] 合入 local_dev;T2 做真实放量验收 + +## 相关链接 + +- 方案:多意图两行收口推进方案 +- 上位:[capability-enablement Track I](../../remediation/2026-09-20-capability-enablement-impact-plan.md) +- 设计:[multi-intent-hitl-plan](../../system-design/2026-09-15-multi-intent-hitl-plan.md) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 99743b57..07e53ddf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -128,6 +128,7 @@ services: WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true} MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true} MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy} + INTENT_PLAN_PROJECT_ALLOWLIST: ${INTENT_PLAN_PROJECT_ALLOWLIST:-} MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false} MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 4962f69f..fa537d30 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -126,6 +126,7 @@ services: WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true} MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true} MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy} + INTENT_PLAN_PROJECT_ALLOWLIST: ${INTENT_PLAN_PROJECT_ALLOWLIST:-} MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false} MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 23a3cfa3..a9315afa 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -123,6 +123,7 @@ services: WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true} MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true} MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy} + INTENT_PLAN_PROJECT_ALLOWLIST: ${INTENT_PLAN_PROJECT_ALLOWLIST:-} MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false} MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} diff --git a/internal/app/app.go b/internal/app/app.go index 732fedd3..6a9157cb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -338,6 +338,7 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), + "INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist), "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), @@ -361,6 +362,20 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { } } +func formatProjectAllowlist(ids []int64) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, 0, len(ids)) + for _, id := range ids { + if id == intentflow.AllowAllProjects { + return "*" + } + parts = append(parts, strconv.FormatInt(id, 10)) + } + return strings.Join(parts, ",") +} + // WriterLive reports whether the durable consumer already owns the visible write. func (r *Runtime) WriterLive(ctx context.Context, consumer string) bool { if r == nil { diff --git a/internal/app/intentflow/allowlist.go b/internal/app/intentflow/allowlist.go new file mode 100644 index 00000000..4f34e248 --- /dev/null +++ b/internal/app/intentflow/allowlist.go @@ -0,0 +1,63 @@ +package intentflow + +import ( + "strconv" + "strings" +) + +// AllowAllProjects is the wildcard sentinel for INTENT_PLAN_PROJECT_ALLOWLIST=*. +const AllowAllProjects int64 = -1 + +// ParseProjectAllowlist parses a comma-separated project ID list. +// Empty string → deny elevated tiers. "*" → allow all. Invalid tokens are skipped. +func ParseProjectAllowlist(raw string) []int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + if raw == "*" { + return []int64{AllowAllProjects} + } + parts := strings.Split(raw, ",") + out := make([]int64, 0, len(parts)) + seen := map[int64]struct{}{} + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || p == "*" { + if p == "*" { + return []int64{AllowAllProjects} + } + continue + } + id, err := strconv.ParseInt(p, 10, 64) + if err != nil || id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// ProjectAllowed reports whether elevated plan tiers may act for projectID. +// Legacy tier always returns true (no allowlist gate). Empty allowlist denies. +func (f FeatureFlags) ProjectAllowed(projectID int64) bool { + if f.PlanTier == TierLegacy || f.PlanTier == "" { + return true + } + if len(f.ProjectAllowlist) == 0 { + return false + } + for _, id := range f.ProjectAllowlist { + if id == AllowAllProjects { + return true + } + if id == projectID { + return true + } + } + return false +} diff --git a/internal/app/intentflow/flags.go b/internal/app/intentflow/flags.go index cf8064c4..9b98d262 100644 --- a/internal/app/intentflow/flags.go +++ b/internal/app/intentflow/flags.go @@ -20,6 +20,10 @@ const ( type FeatureFlags struct { PlanTier PlanTier + // ProjectAllowlist gates elevated tiers (shadow/auto_read/confirm/full). + // Empty denies; AllowAllProjects (-1) allows every project. + // Env: INTENT_PLAN_PROJECT_ALLOWLIST (*|id,id,...) + ProjectAllowlist []int64 // Independent PU8 expansion gates. Default OFF; do not couple to PlanTier. U9Segment bool // MULTI_INTENT_U9_SEGMENT — LLM segment proposal U10Mutate bool // MULTI_INTENT_U10_MUTATE — low/medium mutate adapters @@ -34,11 +38,12 @@ var ( func defaultFeatureFlags() FeatureFlags { return FeatureFlags{ - PlanTier: parsePlanTier(os.Getenv("MULTI_INTENT_PLAN_TIER")), - U9Segment: envBool("MULTI_INTENT_U9_SEGMENT", false), - U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false), - U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false), - U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false), + PlanTier: parsePlanTier(os.Getenv("MULTI_INTENT_PLAN_TIER")), + ProjectAllowlist: ParseProjectAllowlist(os.Getenv("INTENT_PLAN_PROJECT_ALLOWLIST")), + U9Segment: envBool("MULTI_INTENT_U9_SEGMENT", false), + U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false), + U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false), + U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false), } } diff --git a/internal/app/intentflow/flags_test.go b/internal/app/intentflow/flags_test.go index 054be613..93020023 100644 --- a/internal/app/intentflow/flags_test.go +++ b/internal/app/intentflow/flags_test.go @@ -57,6 +57,48 @@ func TestPlanTierEnv(t *testing.T) { } } +func TestProjectAllowlistParseAndGate(t *testing.T) { + if got := ParseProjectAllowlist(""); len(got) != 0 { + t.Fatalf("empty = %+v", got) + } + if got := ParseProjectAllowlist("*"); len(got) != 1 || got[0] != AllowAllProjects { + t.Fatalf("star = %+v", got) + } + got := ParseProjectAllowlist("42, 100,42,bad") + if len(got) != 2 || got[0] != 42 || got[1] != 100 { + t.Fatalf("list = %+v", got) + } + legacy := FeatureFlags{PlanTier: TierLegacy} + if !legacy.ProjectAllowed(99) { + t.Fatal("legacy must not require allowlist") + } + denied := FeatureFlags{PlanTier: TierShadow} + if denied.ProjectAllowed(42) { + t.Fatal("empty allowlist must deny elevated") + } + ok := FeatureFlags{PlanTier: TierAutoRead, ProjectAllowlist: []int64{42}} + if !ok.ProjectAllowed(42) || ok.ProjectAllowed(7) { + t.Fatal("id allowlist") + } + all := FeatureFlags{PlanTier: TierConfirm, ProjectAllowlist: []int64{AllowAllProjects}} + if !all.ProjectAllowed(1) { + t.Fatal("wildcard") + } +} + +func TestDefaultAllowlistEmpty(t *testing.T) { + t.Setenv("MULTI_INTENT_PLAN_TIER", "shadow") + t.Setenv("INTENT_PLAN_PROJECT_ALLOWLIST", "") + ResetFeatureFlagsForTest() + f := CurrentFeatureFlags() + if f.PlanTier != TierShadow { + t.Fatal(f.PlanTier) + } + if len(f.ProjectAllowlist) != 0 { + t.Fatalf("default allowlist must be empty deny, got %+v", f.ProjectAllowlist) + } +} + func TestErrorCodes(t *testing.T) { if ErrorCode(ErrPlanNotFoundOrHidden) != "PLAN_NOT_FOUND_OR_HIDDEN" { t.Fatal(ErrorCode(ErrPlanNotFoundOrHidden)) diff --git a/internal/app/intentflow/metrics.go b/internal/app/intentflow/metrics.go new file mode 100644 index 00000000..5f7bdb17 --- /dev/null +++ b/internal/app/intentflow/metrics.go @@ -0,0 +1,86 @@ +package intentflow + +import "sync/atomic" + +// RolloutMetrics is the in-process I2 counter set used for shadow reconciliation +// and allowlist gates. Durable plan facts still flow through intent.plan.* events. +type RolloutMetrics struct { + PlansCreated atomic.Int64 + ShadowPlans atomic.Int64 + AutoReadPlans atomic.Int64 + ConfirmPlans atomic.Int64 + AllowlistDenied atomic.Int64 + CounterfactualWouldExecute atomic.Int64 + CounterfactualWouldConfirm atomic.Int64 + CounterfactualWouldClarify atomic.Int64 + CounterfactualWouldReject atomic.Int64 + CounterfactualPassthrough atomic.Int64 + ReconciliationDiffs atomic.Int64 +} + +var globalMetrics RolloutMetrics + +// Metrics returns the process-wide rollout counters. +func Metrics() *RolloutMetrics { return &globalMetrics } + +// Snapshot is a plain copy for tests and admin surfaces. +type MetricsSnapshot struct { + PlansCreated int64 `json:"plans_created"` + ShadowPlans int64 `json:"shadow_plans"` + AutoReadPlans int64 `json:"auto_read_plans"` + ConfirmPlans int64 `json:"confirm_plans"` + AllowlistDenied int64 `json:"allowlist_denied"` + CounterfactualWouldExecute int64 `json:"cf_would_execute"` + CounterfactualWouldConfirm int64 `json:"cf_would_confirm"` + CounterfactualWouldClarify int64 `json:"cf_would_clarify"` + CounterfactualWouldReject int64 `json:"cf_would_reject"` + CounterfactualPassthrough int64 `json:"cf_passthrough"` + ReconciliationDiffs int64 `json:"reconciliation_diffs"` +} + +func (m *RolloutMetrics) Snapshot() MetricsSnapshot { + if m == nil { + return MetricsSnapshot{} + } + return MetricsSnapshot{ + PlansCreated: m.PlansCreated.Load(), + ShadowPlans: m.ShadowPlans.Load(), + AutoReadPlans: m.AutoReadPlans.Load(), + ConfirmPlans: m.ConfirmPlans.Load(), + AllowlistDenied: m.AllowlistDenied.Load(), + CounterfactualWouldExecute: m.CounterfactualWouldExecute.Load(), + CounterfactualWouldConfirm: m.CounterfactualWouldConfirm.Load(), + CounterfactualWouldClarify: m.CounterfactualWouldClarify.Load(), + CounterfactualWouldReject: m.CounterfactualWouldReject.Load(), + CounterfactualPassthrough: m.CounterfactualPassthrough.Load(), + ReconciliationDiffs: m.ReconciliationDiffs.Load(), + } +} + +func ResetMetricsForTest() { + globalMetrics = RolloutMetrics{} +} + +const ( + cfWouldExecute = "would_execute" + cfWouldConfirm = "would_confirm" + cfWouldClarify = "would_clarify" + cfWouldReject = "would_reject" + cfPassthrough = "would_passthrough" +) + +func recordCounterfactual(route string) { + m := Metrics() + switch route { + case cfWouldExecute: + m.CounterfactualWouldExecute.Add(1) + case cfWouldConfirm: + m.CounterfactualWouldConfirm.Add(1) + case cfWouldClarify: + m.CounterfactualWouldClarify.Add(1) + case cfWouldReject: + m.CounterfactualWouldReject.Add(1) + default: + m.CounterfactualPassthrough.Add(1) + } +} diff --git a/internal/app/intentflow/plans.go b/internal/app/intentflow/plans.go index 8bece61b..7cfa90ce 100644 --- a/internal/app/intentflow/plans.go +++ b/internal/app/intentflow/plans.go @@ -98,16 +98,6 @@ func visibleTo(plan *Plan, p Principal) bool { return plan != nil && sameScope(plan.Principal, p) } -// HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy/shadow (default). -// auto_read executes independent queries; confirm persists write/generate plans -// awaiting confirmation and does not execute them. -func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) { - if err := requirePrincipal(in.Principal); err != nil { - return nil, err - } - return p.handleTurnAutoRead(ctx, in) -} - func (p *Plans) GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) { if err := requirePrincipal(principal); err != nil { return nil, err diff --git a/internal/app/intentflow/turn.go b/internal/app/intentflow/turn.go index 7c63951d..c09edcf6 100644 --- a/internal/app/intentflow/turn.go +++ b/internal/app/intentflow/turn.go @@ -10,9 +10,25 @@ import ( "chat-agent-system/internal/intent" ) -func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResult, error) { - tier := CurrentFeatureFlags().PlanTier - if !tier.Allows(TierAutoRead) { +// HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy (default). +// shadow persists a validated_only plan without executing; auto_read executes +// independent queries; confirm persists write/generate plans awaiting confirmation. +// Elevated tiers require INTENT_PLAN_PROJECT_ALLOWLIST membership. +func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) { + if err := requirePrincipal(in.Principal); err != nil { + return nil, err + } + return p.handleTurnElevated(ctx, in) +} + +func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResult, error) { + flags := CurrentFeatureFlags() + tier := flags.PlanTier + if !tier.Allows(TierShadow) { + return &TurnResult{}, nil + } + if !flags.ProjectAllowed(in.Principal.ProjectID) { + Metrics().AllowlistDenied.Add(1) return &TurnResult{}, nil } text := strings.TrimSpace(in.Text) @@ -22,14 +38,24 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu rec := intent.NewRecognizer() res, err := rec.Recognize(ctx, text, nil) if err != nil || res == nil || res.PlanProposal == nil { + recordCounterfactual(cfPassthrough) return &TurnResult{}, nil } proposal := res.PlanProposal + route := counterfactualRoute(proposal) + recordCounterfactual(route) + + shadowConfirm := tier == TierShadow && proposal.ComboHint == intent.ComboNeedsConfirm confirmWrites := proposal.ComboHint == intent.ComboNeedsConfirm && tier.Allows(TierConfirm) - readOnly := proposal.ComboHint == intent.ComboReadOnly && tier.Allows(TierAutoRead) - if (!confirmWrites && !readOnly) || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 { + readOnly := proposal.ComboHint == intent.ComboReadOnly && (tier.Allows(TierAutoRead) || tier == TierShadow) + if (!confirmWrites && !readOnly && !shadowConfirm) || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 { return &TurnResult{}, nil } + if shadowConfirm { + confirmWrites = true + readOnly = false + } + policy := DefaultBatchActionPolicy() drafts := make([]StepDraft, 0, len(proposal.Segments)) for i, seg := range proposal.Segments { @@ -41,21 +67,29 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu return &TurnResult{}, nil } row, err := policy.Lookup(key) - if err != nil || row.RejectPlan || !tier.Allows(row.OpenFromTier) { + if err != nil || row.RejectPlan { return &TurnResult{}, nil } - if row.RequiresBatchConfirm && !confirmWrites { + // Shadow ignores OpenFromTier so confirm-class keys can be observed. + if tier != TierShadow && !tier.Allows(row.OpenFromTier) { + return &TurnResult{}, nil + } + if row.RequiresBatchConfirm && !confirmWrites && tier != TierShadow { return &TurnResult{}, nil } summary := strings.TrimSpace(seg.Normalized) if summary == "" { summary = strings.TrimSpace(seg.Text) } + mode := ModeLive + if tier == TierShadow { + mode = ModeValidatedOnly + } drafts = append(drafts, StepDraft{ ActionKey: key, ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key), SafeSummary: summary, - ExecutionMode: ModeLive, + ExecutionMode: mode, }) } turnID := in.ClientTurnID @@ -66,23 +100,40 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu if digest == "" { digest = shortDigest(text) } + + status := PlanAwaitingConfirmation + mode := ModeLive + writePlan := confirmWrites + if tier == TierShadow { + status = PlanProposed + mode = ModeValidatedOnly + writePlan = false // shadow never owns the active-write slot + } + created, err := p.CreatePlan(ctx, CreateCommand{ Principal: in.Principal, ClientTurnID: turnID, RequestDigest: digest, SegmenterVersion: proposal.SegmenterVersion, - Status: PlanAwaitingConfirmation, - ExecutionMode: ModeLive, - WritePlan: confirmWrites, + Status: status, + ExecutionMode: mode, + WritePlan: writePlan, SafeSummary: text, Steps: drafts, }) if err != nil { return nil, err } - if confirmWrites { + Metrics().PlansCreated.Add(1) + if tier == TierShadow { + Metrics().ShadowPlans.Add(1) return &TurnResult{Plan: created}, nil } + if confirmWrites { + Metrics().ConfirmPlans.Add(1) + return &TurnResult{Plan: created}, nil + } + Metrics().AutoReadPlans.Add(1) confirmed, err := p.DecidePlan(ctx, DecisionCommand{ Principal: in.Principal, PlanID: created.PlanID, @@ -101,6 +152,27 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu return &TurnResult{Plan: view}, advErr } +func counterfactualRoute(proposal *intent.PlanProposal) string { + if proposal == nil { + return cfPassthrough + } + switch proposal.ComboHint { + case intent.ComboReadOnly: + if len(proposal.Segments) >= 2 && len(proposal.Segments) <= 4 { + return cfWouldExecute + } + return cfPassthrough + case intent.ComboNeedsConfirm: + return cfWouldConfirm + case intent.ComboNeedsClarify: + return cfWouldClarify + case intent.ComboRejected, intent.ComboRejectedBatchSize: + return cfWouldReject + default: + return cfPassthrough + } +} + func shortDigest(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:8]) diff --git a/internal/app/intentflow/turn_test.go b/internal/app/intentflow/turn_test.go index c46c61a6..94dcd10f 100644 --- a/internal/app/intentflow/turn_test.go +++ b/internal/app/intentflow/turn_test.go @@ -8,8 +8,11 @@ import ( ) func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) { - intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead}) + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierAutoRead, ProjectAllowlist: []int64{42}, + }) t.Cleanup(intentflow.ResetFeatureFlagsForTest) + intentflow.ResetMetricsForTest() p, _, owner := testPlans() ctx := context.Background() res, err := p.HandleTurn(ctx, intentflow.TurnInput{ @@ -56,7 +59,9 @@ func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) { } func TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead(t *testing.T) { - intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead}) + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierAutoRead, ProjectAllowlist: []int64{42}, + }) t.Cleanup(intentflow.ResetFeatureFlagsForTest) p, _, _ := testPlans() res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ @@ -80,7 +85,9 @@ func TestQueryGuardRejectsGenerate(t *testing.T) { } func TestHandleTurnConfirmPersistsAwaitingWithoutExecute(t *testing.T) { - intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm}) + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42}, + }) t.Cleanup(intentflow.ResetFeatureFlagsForTest) p, _, owner := testPlans() ctx := context.Background() @@ -132,7 +139,9 @@ func TestHandleTurnConfirmPersistsAwaitingWithoutExecute(t *testing.T) { } func TestHandleTurnConfirmRejectsSecondActiveWritePlan(t *testing.T) { - intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm}) + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42}, + }) t.Cleanup(intentflow.ResetFeatureFlagsForTest) p, _, _ := testPlans() ctx := context.Background() @@ -155,3 +164,88 @@ func TestHandleTurnConfirmRejectsSecondActiveWritePlan(t *testing.T) { t.Fatalf("second write plan must conflict, got %v", err) } } + +func TestHandleTurnShadowPersistsValidatedOnlyWithoutExecute(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierShadow, ProjectAllowlist: []int64{42}, + }) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + intentflow.ResetMetricsForTest() + p, _, owner := testPlans() + ctx := context.Background() + res, err := p.HandleTurn(ctx, intentflow.TurnInput{ + Principal: principal(), Text: "查一下项目风险,然后查相关法规", + ClientTurnID: "turn-sh-1", RequestDigest: "d-sh-1", + }) + if err != nil { + t.Fatal(err) + } + if res == nil || res.Plan == nil { + t.Fatal("shadow must persist a plan") + } + if res.Plan.Status != intentflow.PlanProposed { + t.Fatalf("shadow status = %s, want proposed", res.Plan.Status) + } + if res.Plan.ExecutionMode != intentflow.ModeValidatedOnly { + t.Fatalf("shadow mode = %s", res.Plan.ExecutionMode) + } + for _, s := range res.Plan.Steps { + if s.Status != intentflow.StepPending { + t.Fatalf("shadow must not execute, step=%+v", s) + } + if s.ExecutionMode != intentflow.ModeValidatedOnly { + t.Fatalf("shadow step mode = %s", s.ExecutionMode) + } + } + if owner.ExecCount("") != 0 { + t.Fatal("shadow must not call action owner") + } + snap := intentflow.Metrics().Snapshot() + if snap.ShadowPlans < 1 || snap.CounterfactualWouldExecute < 1 { + t.Fatalf("metrics = %+v", snap) + } +} + +func TestHandleTurnElevatedDeniedWithoutAllowlist(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead}) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + intentflow.ResetMetricsForTest() + p, _, _ := testPlans() + res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ + Principal: principal(), Text: "查一下项目风险,然后查相关法规", + ClientTurnID: "turn-deny", RequestDigest: "d-deny", + }) + if err != nil { + t.Fatal(err) + } + if res.Plan != nil { + t.Fatalf("empty allowlist must deny elevated tier: %+v", res.Plan) + } + if intentflow.Metrics().Snapshot().AllowlistDenied < 1 { + t.Fatal("expected allowlist_denied metric") + } +} + +func TestHandleTurnShadowObservesConfirmClassWithoutExecuting(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierShadow, ProjectAllowlist: []int64{intentflow.AllowAllProjects}, + }) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + p, _, owner := testPlans() + res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ + Principal: principal(), Text: "查一下相关法规,然后帮我生成报价单", + ClientTurnID: "turn-sh-cf", RequestDigest: "d-sh-cf", + }) + if err != nil { + t.Fatal(err) + } + if res == nil || res.Plan == nil { + t.Fatal("shadow must observe confirm-class proposal") + } + if res.Plan.ExecutionMode != intentflow.ModeValidatedOnly || res.Plan.Status != intentflow.PlanProposed { + t.Fatalf("plan=%+v", res.Plan) + } + if owner.ExecCount("") != 0 { + t.Fatal("shadow confirm observe must not execute") + } +} diff --git a/internal/app/plan_owner_test.go b/internal/app/plan_owner_test.go index 8dffc254..f248b5ae 100644 --- a/internal/app/plan_owner_test.go +++ b/internal/app/plan_owner_test.go @@ -49,7 +49,9 @@ func TestTaskRunAdapterDeniedAfterRevoke(t *testing.T) { } func TestConfirmThenMuxExecutesGenerateOnce(t *testing.T) { - intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm}) + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42}, + }) t.Cleanup(intentflow.ResetFeatureFlagsForTest) query := intentflow.NewFakeActionOwner() task := NewTaskRunAdapter() diff --git a/internal/app/query_owner_test.go b/internal/app/query_owner_test.go index f42c6e4a..d5e52d75 100644 --- a/internal/app/query_owner_test.go +++ b/internal/app/query_owner_test.go @@ -82,7 +82,9 @@ func TestQueryOwnerReauthAndVersionCheck(t *testing.T) { } func TestConfirmZeroWritesBeforeDispatch(t *testing.T) { - intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm}) + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42}, + }) t.Cleanup(intentflow.ResetFeatureFlagsForTest) query := NewQueryOwner(func(context.Context, int64, int64) error { return nil }, nil) task := NewTaskRunAdapter() diff --git a/internal/chat/session.go b/internal/chat/session.go index 3b6679fe..06032bbb 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -400,7 +400,10 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str }) if planErr == nil && tr != nil && tr.Plan != nil { persistedPlan = tr.Plan - recRes.IntentPlan = nil + // Shadow (validated_only) must not steal the legacy coordinator route. + if tr.Plan.ExecutionMode != "validated_only" { + recRes.IntentPlan = nil + } } } diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index 0d605583..44b13574 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -52,7 +52,11 @@ var featureMaturityCatalog = map[string]featureMaturity{ }, "MULTI_INTENT_PLAN_TIER": { state: implLive, adapter: adapterDegraded, - reason: "真实 query owner 与 confirm 后 DispatchEligible 已接线;默认仍 legacy,升 auto_read/confirm 需 I2 阈值与项目 allowlist", + reason: "I2 接线就绪:shadow 持久不可执行计划 + 项目 allowlist + 指标;默认仍 legacy,升档需 allowlist 与阈值验收", + }, + "INTENT_PLAN_PROJECT_ALLOWLIST": { + state: implLive, adapter: adapterReady, + reason: "空值拒绝所有 elevated tier;* 放开全部;逗号分隔项目 ID", }, "MULTI_INTENT_U9_SEGMENT": { state: implDryRun, adapter: adapterUnavailable, @@ -109,6 +113,7 @@ var featureCatalog = []featureSpec{ {key: "WORKFLOW_INTENTS_ENABLED", label: "工作流意图", group: "intent", fallback: "true", description: "识别工作流类意图"}, {key: "MULTI_INTENT_ENABLED", label: "多意图识别", group: "intent", fallback: "true", description: "单条消息多意图拆解"}, {key: "MULTI_INTENT_PLAN_TIER", label: "多意图计划档位", group: "intentflow", fallback: "legacy", description: "意图计划灰度:legacy/shadow/auto_read/confirm/full"}, + {key: "INTENT_PLAN_PROJECT_ALLOWLIST", label: "多意图计划项目白名单", group: "intentflow", fallback: "", description: "elevated tier 项目 allowlist:空拒绝;* 全部;逗号分隔项目 ID"}, {key: "MULTI_INTENT_U9_SEGMENT", label: "U9 LLM 分段", group: "intentflow", fallback: "false", description: "LLM 意图分段提议"}, {key: "MULTI_INTENT_U10_MUTATE", label: "U10 变更适配器", group: "intentflow", fallback: "false", description: "低/中风险 mutate 动作适配器"}, {key: "MULTI_INTENT_U11_DOCGOV", label: "U11 文档治理适配器", group: "intentflow", fallback: "false", description: "docgov 动作适配器"},