merge: candidate intent-plan-i2-shadow-allowlist into local_dev

This commit is contained in:
weichen
2026-09-23 15:10:49 +00:00
17 changed files with 477 additions and 35 deletions
+1
View File
@@ -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 | module-batches-ag-status-fix-2026-09-23 | feature/module-batches-ag-status-fix-2026-09-23 | .worktrees/module-batches-ag-status-fix-2026-09-23 | module-decoupling-spec | done |
| Cursor | fav-constraint-current-2026-09-23 | feature/fav-constraint-current-2026-09-23 | (removed) | git:dev+docs | done |
@@ -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)
+1
View File
@@ -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}
+1
View File
@@ -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}
+1
View File
@@ -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}
+15
View File
@@ -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 {
+63
View File
@@ -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
}
+10 -5
View File
@@ -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),
}
}
+42
View File
@@ -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))
+86
View File
@@ -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)
}
}
-10
View File
@@ -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
+84 -12
View File
@@ -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])
+98 -4
View File
@@ -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")
}
}
+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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()
+4 -1
View File
@@ -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
}
}
}
+6 -1
View File
@@ -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 动作适配器"},