From e9c0281404d445c9ba05b6fabfb434b7c77f712a Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 26 Sep 2026 02:29:29 +0000 Subject: [PATCH] feat(ctxmgr): wildcard session allowlist for semantic replace rollout - SEMANTIC_SUMMARY_REPLACE_SESSIONS supports * (all sessions, present and future), mirroring INTENT_PLAN_PROJECT_ALLOWLIST; empty allowlist still replaces nothing (fail-closed defense in depth unchanged) - declare SEMANTIC_SUMMARY_REPLACE(_SESSIONS) in dev/test/main compose app env (stale Wave2 "REPLACE unwired" comment fixed); extend A-4 declaration test; .env.example key docs updated - sysadmin feature catalog: SESSIONS description + REPLACE reason note * Prepares dev degraded-flags enable (task degraded-flags-enable-2026-09-26-r4): user-authorized all-session replace, plan tier confirm, shadow summary, PI change-report narrative; env ops and runtime verification follow. --- .../2026-09-26-degraded-flags-enable.md | 53 +++++++++++++++++ .env.example | 9 ++- docker-compose.dev.yml | 5 +- docker-compose.main.yml | 2 + docker-compose.test.yml | 2 + .../config/compose_flag_declaration_test.go | 13 +++- internal/ctxmgr/semantic_replace_test.go | 59 ++++++++++++++++++- internal/ctxmgr/summary_flags.go | 38 ++++++++---- internal/sysadmin/features.go | 4 +- 9 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-26-degraded-flags-enable.md diff --git a/.doc/ops/short-term/2026-09-26-degraded-flags-enable.md b/.doc/ops/short-term/2026-09-26-degraded-flags-enable.md new file mode 100644 index 00000000..837c3f15 --- /dev/null +++ b/.doc/ops/short-term/2026-09-26-degraded-flags-enable.md @@ -0,0 +1,53 @@ +# 降级功能开关放量 dev(short-term) + +- **task_id**: degraded-flags-enable-2026-09-26-r4 +- **owner**: pi +- **status**: in_progress +- **授权来源**: 用户会话指令「请将已经实现但是降级了的功能都启动」+ 追加确认(① dev 单二进制启动、不用容器;② SEMANTIC_SUMMARY_REPLACE 面向系统全部会话含后续新建;③ MULTI_INTENT_PLAN_TIER 保持 confirm)。该指令同时构成 docgov-pi-narrative 行「dev 常驻开启另授权」与 chat-semantic-replace 行「放量逐环境另授权(dev)」的显式授权。 +- **write_scope**: internal/ctxmgr, internal/sysadmin, internal/config, docker-compose.{dev,test,main}.yml, .env.example, .doc/ops/CURRENT.md, .doc/ops/short-term, .doc/status/FEATURE-MATRIX.md, .doc/test-reports +- **mutex**: summary-flags-rollout +- **base_dev_sha**: 66ea4e3e513d5c822e23a6c887ca16d1eab80d66 +- **capabilities**: git:dev, env:dev, docs:publish +- **policy_revision**: AGENTS 2026-09 / agent-worktree-workflow r1 / validation-contract 2026-09-21 +- **review_state**: pending +- **integration_state**: pending +- **deployment**: pending +- **cleanup**: pending +- **remote_sync**: not_requested(本地 dev 领先 origin/dev 192+,未要求 push) + +## 一、范围与现状 + +用户面板(sysadmin features 目录)为 fallback 快照;dev 实际运行态 = 单二进制 `launch.sh --no-docker` 只加载根 `.env`(不读 configs/.env.dev),故 intentflow 系列与摘要/叙事开关全部落在默认 legacy/false。dev 容器组当前未运行;test/main 仍为 compose。 + +已实现但未启动的降级项及处置: + +| 开关 | 处置 | 依据 | +|---|---|---| +| MULTI_INTENT_PLAN_TIER(legacy→confirm)+ INTENT_PLAN_PROJECT_ALLOWLIST=* | 根 .env 写入 confirm + * | I2/I3 已按 allowlist 验收 shadow→auto_read→confirm(项目 6579,确认前零写);用户确认保持 confirm | +| MULTI_INTENT_U9/U10/U11/U12 | 根 .env 全 true | I3 live + P8 逐键接线;域名 flag 开启后未打开的细粒度键保持 dry-run | +| SEMANTIC_SUMMARY_SHADOW | 根 .env true | D3 收口 PASS;shadow 生成不影响 served Narrative | +| SEMANTIC_SUMMARY_REPLACE(+SESSIONS) | **代码:SESSIONS 支持 `*` 通配**(原仅枚举 ID,无法覆盖"全部会话含后续");compose 三环境补声明(原 Wave2 注释"REPLACE 未接线"已过期);根 .env true + `*` | chat-semantic-replace 2026-09-26 fail-closed 灰度接线;用户授权全会话 | +| PI_CHANGE_REPORT_NARRATIVE | 根 .env true | 2026-09-23 收口复测 PASS;matrix 注明 dev 常驻开启另授权,本任务授权来源满足 | + +不改动:WORKFLOW_SCOPE_MODE(strict 可用);U11/U12 五个逐键 live flag(P8"禁止 full 一次放量",不在本次面板范围,compose 亦未声明,保持 off 待后续运维侧逐键放量)。 + +防泄漏:根 `.env` 经 `--env-file` 同样被 test/main compose 读取,故在 `configs/.env.test` / `configs/.env.main`(git-ignored 运维配置)显式钉住上述键为默认值,行为保持不变。 + +## 二、实现(代码侧) + +1. `internal/ctxmgr/summary_flags.go`:`SummaryFeatureFlags.ReplaceAllSessions`;`parseReplaceSessions` 返回 `(set, wildcard)`,任意 `*` token → 全会话;`SemanticReplaceEnabledFor` 先过 master 再过通配/名单。空名单仍拒绝一切(纵深防御不变)。 +2. 单测:`semantic_replace_test.go` 更新 FlagParsing(通配/混合/空)+ 新增 WildcardAllowlistSwapsNarrative / WildcardMasterOffKeepsDeterministic。 +3. compose ×3 声明 `SEMANTIC_SUMMARY_REPLACE(_SESSIONS)`(默认 false/空);A-4 声明测试扩键;`.env.example` 修 Wave2 过期注释并补 SESSIONS/ALLOWLIST 注释键;sysadmin SESSIONS 描述与 REPLACE reason 注明 `*`。 + +## 三、计划 + +1. [x] 分析与授权确认 +2. [x] 代码实现(通配 + compose 声明 + 目录/示例更新) +3. [ ] 工程门禁(arch-check block / go vet / go test -short)+ dev 集成(--no-ff) +4. [ ] env 运维:根 .env + configs/.env.{test,main} 钉默认;`launch.sh --no-docker` 重启 dev(env:dev 锁内) +5. [ ] 运行时验收:/health /ready、features 快照、行为抽查 +6. [ ] 测试报告 + MATRIX 行注记 + CURRENT 收口 + +## 四、结果登记 + +(待填) diff --git a/.env.example b/.env.example index c301d9df..718bf9ca 100644 --- a/.env.example +++ b/.env.example @@ -125,16 +125,21 @@ JEV_BASE_URL=http://127.0.0.1:8767 JEV_API_KEY= WORKFLOW_INTENTS_ENABLED=true MULTI_INTENT_ENABLED=true -# MULTI_INTENT_PLAN_TIER defaults to legacy; do not raise for U9–U12 scaffolding. +# MULTI_INTENT_PLAN_TIER defaults to legacy; elevated tiers additionally need +# INTENT_PLAN_PROJECT_ALLOWLIST (empty denies; * allows every project). # MULTI_INTENT_PLAN_TIER=legacy +# INTENT_PLAN_PROJECT_ALLOWLIST= MULTI_INTENT_U9_SEGMENT=false MULTI_INTENT_U10_MUTATE=false MULTI_INTENT_U11_DOCGOV=false MULTI_INTENT_U12_RUN_CONTROL=false # 工作流作用域:strict 命中他流即拦截;weighted 按关系权重打分,验收通过后再开。 WORKFLOW_SCOPE_MODE=strict -# W2: shadow only. Do not set SEMANTIC_SUMMARY_REPLACE=true; REPLACE is unwired. +# Semantic summary: shadow generation + fail-closed REPLACE gray release. +# REPLACE needs master + session allowlist; empty allowlist replaces nothing, +# "*" covers every session (present and future). SEMANTIC_SUMMARY_SHADOW=false SEMANTIC_SUMMARY_REPLACE=false +# SEMANTIC_SUMMARY_REPLACE_SESSIONS= # W3: optional PI change-report narrative; default off. PI_CHANGE_REPORT_NARRATIVE=false diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0f33e075..d8da639a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -144,8 +144,11 @@ services: MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict} - # W2 PU6: shadow semantic summary only. REPLACE stays unwired and is not declared here. + # Semantic summary: shadow generation + fail-closed REPLACE gray release. + # REPLACE needs master + session allowlist (empty = none; * = all sessions). SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} + SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false} + SEMANTIC_SUMMARY_REPLACE_SESSIONS: ${SEMANTIC_SUMMARY_REPLACE_SESSIONS:-} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} # FAV U7: keep publisher off until separate approval (FAV_DEGRADED_READ declared above) FAV_PUBLISHER_ENABLED: ${FAV_PUBLISHER_ENABLED:-false} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index de5c3950..0d18e1e8 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -143,6 +143,8 @@ services: MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict} SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} + SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false} + SEMANTIC_SUMMARY_REPLACE_SESSIONS: ${SEMANTIC_SUMMARY_REPLACE_SESSIONS:-} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} FAV_PUBLISHER_ENABLED: "false" ports: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b9a7ea3a..c867d100 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -139,6 +139,8 @@ services: MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict} SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} + SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false} + SEMANTIC_SUMMARY_REPLACE_SESSIONS: ${SEMANTIC_SUMMARY_REPLACE_SESSIONS:-} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} FAV_PUBLISHER_ENABLED: ${FAV_PUBLISHER_ENABLED:-false} ports: diff --git a/internal/config/compose_flag_declaration_test.go b/internal/config/compose_flag_declaration_test.go index 5fbddc54..ea63ca64 100644 --- a/internal/config/compose_flag_declaration_test.go +++ b/internal/config/compose_flag_declaration_test.go @@ -64,12 +64,19 @@ func TestComposeAppDeclaresMultiIntentPlanTierAndExpansionFlags(t *testing.T) { } } -// A-4: SEMANTIC_SUMMARY_SHADOW / PI_CHANGE_REPORT_NARRATIVE must be declared in -// all three environments (test/main were missing before Wave 2). +// A-4: SEMANTIC_SUMMARY_SHADOW / REPLACE(+SESSIONS allowlist) / +// PI_CHANGE_REPORT_NARRATIVE must be declared in all three environments so +// operator-set env vars reach the container (REPLACE is fail-closed gray: +// master + non-empty session allowlist; defaults stay off/empty). func TestComposeAppDeclaresSemanticSummaryAndNarrativeShadowFlags(t *testing.T) { for _, name := range composeFilesForFlagCheck { block := serviceBlockText(t, readComposeFile(t, name), "app") - for _, key := range []string{"SEMANTIC_SUMMARY_SHADOW:", "PI_CHANGE_REPORT_NARRATIVE:"} { + for _, key := range []string{ + "SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false}", + "SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false}", + "SEMANTIC_SUMMARY_REPLACE_SESSIONS: ${SEMANTIC_SUMMARY_REPLACE_SESSIONS:-}", + "PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false}", + } { if !strings.Contains(block, key) { t.Errorf("%s: app service missing %s declaration", name, key) } diff --git a/internal/ctxmgr/semantic_replace_test.go b/internal/ctxmgr/semantic_replace_test.go index 3c80e8df..d28bc67b 100644 --- a/internal/ctxmgr/semantic_replace_test.go +++ b/internal/ctxmgr/semantic_replace_test.go @@ -74,6 +74,28 @@ func TestSemanticReplace_AllowlistedSessionSwapsNarrative(t *testing.T) { } } +// Wildcard allowlist (*) covers sessions never enumerated explicitly, +// including sessions created after the flag was set. +func TestSemanticReplace_WildcardAllowlistSwapsNarrative(t *testing.T) { + t.Cleanup(ResetSummaryFlagsForTest) + SetSummaryFlagsForTest(SummaryFeatureFlags{ + Shadow: true, Replace: true, ReplaceAllSessions: true, + }) + + m := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"}) + if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_wild_new", nil, replaceBatch()); err != nil { + t.Fatal(err) + } + sum := drainShadow(t, m, "sess_r_wild_new") + if sum.Source != "semantic_replace" || sum.Narrative != "语义影子叙事:超声刀立项" { + t.Fatalf("wildcard session must swap narrative, got source=%s narrative=%.60s", sum.Source, sum.Narrative) + } + if sum.Facts["device_type"] != "超声手术设备" { + t.Fatalf("deterministic facts lost: %+v", sum.Facts) + } +} + // Non-allowlisted session on the same config: deterministic retained. func TestSemanticReplace_NonAllowlistedKeepsDeterministic(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) @@ -183,13 +205,24 @@ func TestSemanticReplace_StaleWatermarkKeepsDeterministic(t *testing.T) { // Flag parse: allowlist parsing and fail-closed accessor. func TestSemanticReplace_FlagParsing(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) - got := parseReplaceSessions(" a , b ,, ") + got, all := parseReplaceSessions(" a , b ,, ") + if all { + t.Fatal("id list must not widen to all sessions") + } if len(got) != 2 { t.Fatalf("expected 2 sessions, got %d", len(got)) } if _, ok := got["a"]; !ok { t.Fatal("session a missing") } + if sessions, all := parseReplaceSessions(""); all || len(sessions) != 0 { + t.Fatal("empty allowlist must stay empty and non-wildcard") + } + for _, raw := range []string{"*", " a , * "} { + if sessions, all := parseReplaceSessions(raw); !all || len(sessions) != 0 { + t.Fatalf("%q must parse to the all-sessions wildcard", raw) + } + } if SemanticReplaceEnabledFor("") { t.Fatal("empty session must never be eligible") } @@ -204,6 +237,30 @@ func TestSemanticReplace_FlagParsing(t *testing.T) { if SemanticReplaceEnabledFor("s1") { t.Fatal("master on + empty allowlist must replace nothing") } + SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true, ReplaceAllSessions: true}) + if !SemanticReplaceEnabledFor("any-future-session") { + t.Fatal("wildcard allowlist must cover every nonempty session id") + } + SetSummaryFlagsForTest(SummaryFeatureFlags{ReplaceAllSessions: true}) + if SemanticReplaceEnabledFor("s1") { + t.Fatal("wildcard + master off must replace nothing") + } +} + +// Wildcard allowlist (*) + master off: fail-closed keeps deterministic. +func TestSemanticReplace_WildcardMasterOffKeepsDeterministic(t *testing.T) { + t.Cleanup(ResetSummaryFlagsForTest) + SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true, ReplaceAllSessions: true}) + + m := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"}) + if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_wild_off", nil, replaceBatch()); err != nil { + t.Fatal(err) + } + sum := drainShadow(t, m, "sess_r_wild_off") + if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") { + t.Fatalf("wildcard + master off must keep deterministic, got source=%s", sum.Source) + } } // Rollback residue: after a replace, turning flags off must regenerate a diff --git a/internal/ctxmgr/summary_flags.go b/internal/ctxmgr/summary_flags.go index 8153bbd4..934d6055 100644 --- a/internal/ctxmgr/summary_flags.go +++ b/internal/ctxmgr/summary_flags.go @@ -17,16 +17,22 @@ const ( EnvSemanticSummaryReplace = "SEMANTIC_SUMMARY_REPLACE" // EnvSemanticSummaryReplaceSessions is the fail-closed gray-release // allowlist: comma-separated chat session IDs eligible for narrative - // replacement. Empty allowlist (default) replaces no session. + // replacement. Empty allowlist (default) replaces no session. "*" widens + // eligibility to every session (present and future), mirroring + // INTENT_PLAN_PROJECT_ALLOWLIST=*; the master flag still gates globally. EnvSemanticSummaryReplaceSessions = "SEMANTIC_SUMMARY_REPLACE_SESSIONS" ) // SummaryFeatureFlags controls semantic summary rollout. REPLACE swaps the // served chat Narrative only for allowlisted sessions (fail-closed gray). type SummaryFeatureFlags struct { - Shadow bool - Replace bool - ReplaceSessions map[string]struct{} + Shadow bool + Replace bool + // ReplaceAllSessions is the "*" wildcard for ReplaceSessions: every + // session (present and future) is eligible while the master Replace flag + // stays on. An empty allowlist still replaces nothing. + ReplaceAllSessions bool + ReplaceSessions map[string]struct{} } var ( @@ -35,22 +41,29 @@ var ( ) func loadSummaryFlagsFromEnv() SummaryFeatureFlags { + sessions, all := parseReplaceSessions(os.Getenv(EnvSemanticSummaryReplaceSessions)) return SummaryFeatureFlags{ - Shadow: envBoolDefault(EnvSemanticSummaryShadow, false), - Replace: envBoolDefault(EnvSemanticSummaryReplace, false), - ReplaceSessions: parseReplaceSessions(os.Getenv(EnvSemanticSummaryReplaceSessions)), + Shadow: envBoolDefault(EnvSemanticSummaryShadow, false), + Replace: envBoolDefault(EnvSemanticSummaryReplace, false), + ReplaceAllSessions: all, + ReplaceSessions: sessions, } } -func parseReplaceSessions(raw string) map[string]struct{} { +// parseReplaceSessions splits the comma-separated allowlist. Any "*" token +// widens to all sessions; empty or ID-only values keep the explicit set. +func parseReplaceSessions(raw string) (map[string]struct{}, bool) { out := make(map[string]struct{}) for _, part := range strings.Split(raw, ",") { id := strings.TrimSpace(part) + if id == "*" { + return nil, true + } if id != "" { out[id] = struct{}{} } } - return out + return out, false } func envBoolDefault(key string, def bool) bool { @@ -81,13 +94,16 @@ func SemanticSummaryReplaceEnabled() bool { } // SemanticReplaceEnabledFor reports fail-closed gray eligibility: the master -// flag must be on AND the session must be in the allowlist (empty allowlist -// replaces no session). +// flag must be on AND the session must be in the allowlist (or the allowlist +// is the "*" wildcard). Empty allowlist replaces no session. func SemanticReplaceEnabledFor(sessionID string) bool { f := CurrentSummaryFlags() if !f.Replace || sessionID == "" { return false } + if f.ReplaceAllSessions { + return true + } _, ok := f.ReplaceSessions[sessionID] return ok } diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index 273139ab..6f1ae026 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -80,7 +80,7 @@ var featureMaturityCatalog = map[string]featureMaturity{ }, "SEMANTIC_SUMMARY_REPLACE": { state: implShadow, adapter: adapterDegraded, - reason: "fail-closed 灰度已接线(2026-09-26):验证 OK 后仅替换白名单会话 served Narrative;Facts/计数保持 deterministic;digest 仍 advisory;默认关", + reason: "fail-closed 灰度已接线(2026-09-26):验证 OK 后仅替换白名单会话 served Narrative(支持 * 全部会话,含后续新建);Facts/计数保持 deterministic;digest 仍 advisory;默认关", }, "PI_CHANGE_REPORT_NARRATIVE": { state: implShadow, adapter: adapterDegraded, @@ -122,7 +122,7 @@ var featureCatalog = []featureSpec{ {key: "WORKFLOW_SCOPE_MODE", label: "工作流作用域裁决", group: "intent", fallback: "strict", description: "文档命中裁决:strict 命中他流即拦截;weighted 按工作流关系权重打分"}, {key: "SEMANTIC_SUMMARY_SHADOW", label: "语义摘要影子", group: "ctxmgr", fallback: "false", description: "上下文语义摘要影子生成"}, {key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "验证 OK 后以语义叙事替换会话 served Narrative;fail-closed 灰度,需同时配置会话白名单"}, - {key: "SEMANTIC_SUMMARY_REPLACE_SESSIONS", label: "语义摘要替换白名单", group: "ctxmgr", fallback: "", description: "逗号分隔会话 ID;空则不替换任何会话(纵深防御)"}, + {key: "SEMANTIC_SUMMARY_REPLACE_SESSIONS", label: "语义摘要替换白名单", group: "ctxmgr", fallback: "", description: "逗号分隔会话 ID;* 全部会话(含后续新建);空则不替换任何会话(纵深防御)"}, {key: "PI_CHANGE_REPORT_NARRATIVE", label: "PI 变更报告叙事", group: "docrevision", fallback: "false", description: "变更报告 PI 叙事增强;默认仅确定性回退"}, }