From 08a4d78bc7a1145462b44c53c4f86bb4609101de Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 29 Aug 2026 17:28:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20R3=E2=86=92R1=20=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E8=BE=B9=E9=AA=A8=E6=9E=B6=E2=80=94=E2=80=94=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E8=B6=8A=E9=98=88=E8=87=AA=E5=8A=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=80=99=E9=80=89=20spec=20=E5=85=A5=20backlog?= =?UTF-8?q?=EF=BC=88IR-0006=20W6-M2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feedback-edge.py:dashboard 机器可读 JSON 信号求值(三值判定 crossed/ok/pending ——pending 诚实跳过不造数,ADR-0073 决策 7);越阈→候选 spec(type:intent+ state:ir-draft)入 backlog;labels 结构性硬编码无 ir-signed 旁路(AC-8h/INV-01) - policy/feedback.yaml:信号规则真源(三分类 error/usage/slo 六信号;阈值唯一 来源纪律——停摆线走 metrics.yaml 声明路径,不在本表复写) - feedback-edge.yml:每日定时面+并发串行化(防重复开卡)+审计影子 relink feedback-ledger 分支(INV-12) - test-feedback-edge.sh:23 断言(越阈/阈内/pending 三形态/去重/policy 三非法 形态 exit 2/dry-run 离线缺源红/AC-8h 红线) 真实预演:needs-human p90 30.39>24 停摆线+逃逸护栏 red 双信号越阈(首跑将 产出 2 张真候选 spec——R1 门待 owner 裁决)。 --- .github/workflows/feedback-edge.yml | 74 ++++++ .gitignore | 1 + governance/feedback-edge.py | 337 +++++++++++++++++++++++++ governance/policy/feedback.yaml | 75 ++++++ governance/tests/test-feedback-edge.sh | 178 +++++++++++++ 5 files changed, 665 insertions(+) create mode 100644 .github/workflows/feedback-edge.yml create mode 100644 governance/feedback-edge.py create mode 100644 governance/policy/feedback.yaml create mode 100644 governance/tests/test-feedback-edge.sh diff --git a/.github/workflows/feedback-edge.yml b/.github/workflows/feedback-edge.yml new file mode 100644 index 0000000..afffa8f --- /dev/null +++ b/.github/workflows/feedback-edge.yml @@ -0,0 +1,74 @@ +name: feedback-edge +# R3→R1 反馈边(宪法 §11 回边 / IR-0006 W6-M2 / 卡 #424 / AC-8g+8h) +# +# 运行信号(错误/用量/SLO——管家账本 dashboard 机器可读 JSON)越反馈阈值 → +# 自动生成候选 spec(type:intent+state:ir-draft)入 backlog。生成侧定位: +# 签署门禁不豁免——owner 签署→spec→红队照走,本工作流结构性不可置 +# state:ir-signed(INV-01/BEH-01)。去重=open issue 带 feedback: 即跳过 +# (RB-B2);信号 pending=诚实跳过不造数(ADR-0073 决策 7)。 +# 节奏=每日(信号面日频足够;dashboard 每 15min 刷新,阈值告警有 dashboard +# 互锁实时面——反馈边是 backlog 生成面非报警面)。审计经 butler-audit.sh +# (AUDIT 行+影子),影子 relink 持久化 feedback-ledger 分支。 +on: + schedule: + - cron: "47 7 * * *" # 每日 07:47 UTC(错峰:避开 :33 env-drift/:17 butler-reconcile/:23 cost-check) + workflow_dispatch: {} + +permissions: {} + +# 串行化(同 env-drift 设计):查去重→开卡非原子,并发会重复开候选 +concurrency: + group: feedback-edge + cancel-in-progress: false + +jobs: + edge: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # 读 governance/ 生成器与 policy + issues: write # 候选 spec 开卡+label 建(GITHUB_TOKEN 本仓写,最小权限) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 反馈边求值(信号越阈→候选 spec;exit 2=infra fail-closed) + env: + GH_TOKEN: ${{ github.token }} + BUTLER_TRIGGER: ${{ github.event_name }} + BUTLER_CARD: Cloudbird-Software/.github#424 + BUTLER_TENANT: cloudbird-internal + run: | + set -o pipefail # ADR-0032 同款:tee 不得吞 exit 码 + python3 governance/feedback-edge.py | tee feedback-report.txt + - name: 影子账本落盘(feedback-ledger 分支,INV-12 审计持久化) + # 本地 shadow relink 续接 feedback-ledger 基链(双侧验链)后写回。 + # always():候选生成成功与 infra 故障(exit 2)时审计事件同样必须落账 + # (append-only 纪律优先于本轮结论)。 + if: ${{ always() }} + env: + FEEDBACK_LEDGER_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + run: | + set -euo pipefail + SHADOW="governance/feedback/shadow-evidence.jsonl" + if [[ ! -s "$SHADOW" ]]; then + echo "OK 本轮无影子记录(audit_emit 未触发)——跳过(幂等)" + exit 0 + fi + git config --global user.name feedback-edge-bot && git config --global user.email feedback-bot@users.noreply.github.com + if ! git clone --depth 1 "https://x-access-token:${FEEDBACK_LEDGER_TOKEN}@github.com/Cloudbird-Software/.github.git" ledger -b feedback-ledger 2>/dev/null; then + git clone --depth 1 "https://x-access-token:${FEEDBACK_LEDGER_TOKEN}@github.com/Cloudbird-Software/.github.git" ledger + git -C ledger checkout -b feedback-ledger + fi + BASE="ledger/$SHADOW" + mkdir -p "$(dirname "$BASE")" + [[ -f "$BASE" ]] || : > "$BASE" + python3 governance/evidence_shadow.py relink --base "$BASE" --local "$SHADOW" --out merged.jsonl + python3 governance/evidence_shadow.py verify --file merged.jsonl + cp merged.jsonl "$BASE" + # -f 必须(同 feishu/env 教训):路径在 .gitignore(工作树 ephemeral 影子) + git -C ledger add -f "$SHADOW" + git -C ledger diff --cached --quiet && { echo "OK 影子无新增——不提交(幂等)"; exit 0; } + git -C ledger commit -m "feedback: 影子账本追加(IR-0006 W6-M2 反馈边,链验通过)" + for i in 1 2 3; do git -C ledger push "https://x-access-token:${FEEDBACK_LEDGER_TOKEN}@github.com/Cloudbird-Software/.github.git" HEAD:refs/heads/feedback-ledger && break + git -C ledger pull --rebase "https://x-access-token:${FEEDBACK_LEDGER_TOKEN}@github.com/Cloudbird-Software/.github.git" feedback-ledger || true; sleep 5; done diff --git a/.gitignore b/.gitignore index e1a5cd0..d0f05ca 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ env-defs/ governance/butler/shadow-evidence.jsonl governance/feishu/shadow-evidence.jsonl governance/env/shadow-evidence.jsonl +governance/feedback/shadow-evidence.jsonl diff --git a/governance/feedback-edge.py b/governance/feedback-edge.py new file mode 100644 index 0000000..fe4f1e4 --- /dev/null +++ b/governance/feedback-edge.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""feedback-edge.py —— R3→R1 反馈边:运行信号→候选 spec backlog(IR-0006 W6-M2 / 卡 #424) + +宪法 §11 三面分离的回边:R3 运行信号(错误/用量/SLO)越反馈阈值 → 自动生成 +候选 spec(type:intent + state:ir-draft issue)入 backlog。**生成侧**(AC-8g): +只产候选不开工;签署门禁不豁免(AC-8h)——owner 签署→spec→红队照走,本工具 +永不置 state:ir-signed(INV-01 判定锚点机械 / BEH-01 生成≠判定)。 + +- 信号源:管家账本 dashboard issue(§12 投影二)机器可读 JSON(ADR-0073; + --dashboard-file 注入离线 fixture——本地/测试同口径) +- 规则真源:governance/policy/feedback.yaml(feedback-edge/v1;阈值/路径全 + 声明侧,本工具零内嵌阈值——宪法 §4A 同源纪律) +- 值判定(三值,同 metrics.py 口径):crossed / ok / pending(值缺失/None/ + pending 字串=诚实 SKIP,不造数——ADR-0073 决策 7) +- 去重(RB-B2 同款):open issue 带 label feedback: 即 DUPLICATE 跳过 + (反馈边不得对同一信号重复刷卡;owner 关掉候选后信号仍越阈=复现证据,允许 + 重开——去重只看 open 面) +- 审计(INV-12):每轮经 butler-audit.sh 发射 AUDIT 行+schema v1 影子 + (BUTLER_SHADOW_FILE 可注入;持久化 feedback-ledger 分支=workflow 面) + +fail-closed:policy 非法 / dashboard 拉取失败 / 参数矛盾 = exit 2(无默认绿)。 +信号越阈=生成成功非故障(exit 0——上屏绿;候选卡是产出不是报警)。 + +用法: + python3 governance/feedback-edge.py # 在线:拉 dashboard+开卡 + python3 governance/feedback-edge.py --dry-run # 只打印候选(零 gh 调用) + python3 governance/feedback-edge.py --dashboard-file F # 信号 fixture(测试注入) + python3 governance/feedback-edge.py --existing-file F # 去重面 fixture(open issues JSON) +env: + GH_TOKEN 在线模式必填(拉 dashboard issue / 开候选卡) + BUTLER_TRIGGER / BUTLER_CARD / BUTLER_TENANT 审计 subject +退出码: 0=轮次完成(含候选产出)| 2=infra/policy fail-closed +""" +import argparse +import datetime as _dt +import json +import os +import re +import subprocess +import sys + +try: + import yaml +except ImportError: # pragma: no cover + print("FATAL 缺少 PyYAML(CI 预装)", file=sys.stderr) + raise SystemExit(2) + +DIR = os.path.dirname(os.path.abspath(__file__)) +POLICY = os.path.join(DIR, "policy", "feedback.yaml") +TRIGGER = os.environ.get("BUTLER_TRIGGER") or "manual" +OPS = {"gt", "lt", "ge", "le", "eq", "ne"} +CLASSES = {"error", "usage", "slo"} +DASH_MARK = "" +FENCE_RE = re.compile(r"^(`{3,})(json)?\s*$") + +sys.path.insert(0, DIR) + + +def die2(msg): + print(f"FATAL {msg}", file=sys.stderr) + raise SystemExit(2) + + +# ---------- dashboard 信号源 ---------- + +def parse_dashboard(text): + """issue body / 纯 JSON → dashboard dict(机器可读区提取,ADR-0073 形态)。 + + 兼容两形态:带 ``dashboard-json`` 标记+围栏(dashboard-update.py 写入形态, + 围栏可为 3+ 任意长反引号);纯 JSON 文本(fixture 直给)。两者皆失败=红。 + """ + if DASH_MARK in text: + lines = text.splitlines() + i = lines.index(next(l for l in lines if DASH_MARK in l)) + fence_len = None + buf = [] + for ln in lines[i + 1:]: + if fence_len is None: + m = FENCE_RE.match(ln) + if m: # 开栏(含可选 json 标注) + fence_len = len(m.group(1)) + continue + if re.match(r"^`{%d}\s*$" % fence_len, ln): + break + buf.append(ln) + text = "\n".join(buf) + try: + return json.loads(text) + except json.JSONDecodeError as e: + die2(f"dashboard JSON 不可解析: {e}") + + +def fetch_dashboard(repo, label): + """在线拉管家账本 dashboard issue body(缺失=infra 红——信号源断了无绿可言)。""" + r = subprocess.run( + ["gh", "issue", "list", "--repo", repo, "--state", "open", + "--label", label, "--json", "number,body", "--jq", ".[0]"], + capture_output=True, text=True, env={**os.environ}) + if r.returncode != 0: + die2(f"dashboard issue 拉取失败: {r.stderr.strip()[:200]}") + try: + item = json.loads(r.stdout) + except json.JSONDecodeError: + die2("dashboard issue 查询输出非法") + if not item or not item.get("body"): + die2(f"open dashboard issue(label={label})不存在或 body 缺失——信号源断") + return item["number"], parse_dashboard(item["body"]) + + +def resolve_path(doc, path): + """点路径取值(缺任一级=None——调用方按 pending 处理,不造数)。""" + cur = doc + for part in path.split("."): + if not isinstance(cur, dict) or part not in cur: + return None + cur = cur[part] + return cur + + +def is_pending(value): + """pending 判定:None / 'pending…' 标注字串(ADR-0073 决策 7 盲区口径)。""" + if value is None: + return True + return isinstance(value, str) and value.strip().startswith("pending") + + +def crossed(value, op, threshold): + """阈值判定(三值:True/False/None——None=不可比=待判诚实跳过)。""" + numeric = op in ("gt", "lt", "ge", "le") + if numeric: + if not isinstance(value, (int, float)) or isinstance(value, bool): + return None + if not isinstance(threshold, (int, float)) or isinstance(threshold, bool): + return None + elif not (isinstance(value, str) and isinstance(threshold, str)): + return None + return {"gt": value > threshold, "lt": value < threshold, + "ge": value >= threshold, "le": value <= threshold, + "eq": value == threshold, "ne": value != threshold}[op] + + +# ---------- 规则装载(机械校验 fail-closed) ---------- + +def load_policy(path): + with open(path, encoding="utf-8") as f: + p = yaml.safe_load(f) or {} + if p.get("schema") != "feedback-edge/v1": + die2("feedback.yaml schema 头须为 feedback-edge/v1") + repo = str(p.get("backlog_repo") or "").strip() + if "/" not in repo: + die2("backlog_repo 须为 owner/repo 形态") + di = p.get("dashboard_issue") or {} + if not (di.get("repo") and di.get("label")): + die2("dashboard_issue 须声明 repo+label(信号源真源)") + signals = p.get("signals") + if not isinstance(signals, list) or not signals: + die2("signals 缺失或为空") + seen = set() + for s in signals: + for k in ("key", "class", "description", "metric_path", "op", "spec_title"): + if not str(s.get(k) or "").strip(): + die2(f"信号 {s.get('key') or '?'} 缺 {k}") + if s["key"] in seen: + die2(f"信号 key 重复: {s['key']}") + seen.add(s["key"]) + if s["class"] not in CLASSES: + die2(f"信号 {s['key']}: class 须 error|usage|slo") + if s["op"] not in OPS: + die2(f"信号 {s['key']}: op 须 {'/'.join(sorted(OPS))}") + has_t = "threshold" in s + has_tp = bool(str(s.get("threshold_path") or "").strip()) + if has_t == has_tp: # 须且仅须其一(双写=口径漂移面,全拒) + die2(f"信号 {s['key']}: threshold/threshold_path 须且仅须其一") + return p + + +# ---------- 候选 spec 生成(R1 门形态——type:intent / state:ir-draft) ---------- + +def spec_body(key, cls, desc, value, op, threshold, dash_num, generated_at): + """候选 spec body(AC-8g 证据面 + AC-8h 门声明——无自动签署旁路)。""" + return f"""## 意图(R3→R1 反馈边自动生成) + +运行信号越反馈阈值,自动生成候选 spec 入 backlog(宪法 §11 回边 / AC-8g)。 + +- **信号**:`{key}`({cls}) +- **语义**:{desc} +- **判定**:观测值 `{value}` {op} 反馈阈值 `{threshold}` → 越阈 +- **数据源**:管家账本 dashboard issue #{dash_num}(generated_at {generated_at};机器可读 JSON,ADR-0073 口径) +- **规则真源**:governance/policy/feedback.yaml(feedback-edge/v1) + +## 门(AC-8h——签署门禁不豁免) + +本 issue 仅是**候选**:反馈边是生成侧,仍须走 R1 完整门——owner 签署 +(state:ir-draft → state:ir-signed)→ spec PR(测试设计+红队审计)→ 才可开卡。 +反馈边永不自动签署、永不置 state:ir-signed(INV-01 / BEH-01)。 + +处置:owner 按意图裁决——签署进 R1 门 / 关闭(wontfix 须给理由,信号复现即重开证据)。 + + +""" + + +def create_candidate(repo, key, title, body): + """开候选卡(labels: type:intent + state:ir-draft + feedback:)。 + + state:ir-draft 是唯一合法初始态(ADR-0095 角色路由)——ir-signed 属 owner + 签署动作,本函数结构性不可产生(labels 硬编码,无参数面)。 + """ + for lb in (f"feedback:{key}",): + subprocess.run(["gh", "label", "create", lb, "--repo", repo, + "--description", f"反馈边信号 {key}(自动)", + "--color", "0e8a16"], + capture_output=True, text=True, env={**os.environ}) + r = subprocess.run( + ["gh", "issue", "create", "--repo", repo, "--title", title, + "--body", body, "--label", "type:intent", "--label", "state:ir-draft", + "--label", f"feedback:{key}"], + capture_output=True, text=True, env={**os.environ}) + if r.returncode != 0: + die2(f"候选 spec 创建失败({key}): {r.stderr.strip()[:200]}") + m = re.search(r"/issues/(\d+)", r.stdout) + return int(m.group(1)) if m else None + + +def existing_open_issues(repo, key, existing_file): + """去重面:open issues 带 feedback:(--existing-file 注入=离线测试)。""" + if existing_file: + with open(existing_file, encoding="utf-8") as f: + items = json.load(f) + return [it["number"] for it in items + if f"feedback:{key}" in it.get("labels", [])] + r = subprocess.run( + ["gh", "issue", "list", "--repo", repo, "--state", "open", + "--label", f"feedback:{key}", "--json", "number"], + capture_output=True, text=True, env={**os.environ}) + if r.returncode != 0: + die2(f"去重查询失败({key}): {r.stderr.strip()[:200]}") + return [it["number"] for it in json.loads(r.stdout)] + + +def _audit(outcome, actions): + """审计代发(butler-audit.sh CLI:AUDIT 行+影子;同 env-drift 模式)。""" + shadow = os.environ.get("BUTLER_SHADOW_FILE") \ + or os.path.join(DIR, "feedback", "shadow-evidence.jsonl") + os.makedirs(os.path.dirname(shadow), exist_ok=True) + payload = json.dumps(actions, ensure_ascii=False) + if len(payload.encode("utf-8")) > 4096: # INV-06:超限拒写——降级只记计数 + payload = json.dumps({"crossed": actions.get("crossed"), + "created": actions.get("created"), + "duplicates": actions.get("duplicates")}, + ensure_ascii=False) + subprocess.run(["bash", os.path.join(DIR, "butler-audit.sh"), + "feedback-edge", TRIGGER, outcome, + json.dumps(actions, ensure_ascii=False)], + env={**os.environ, "BUTLER_SHADOW_FILE": shadow, + "BUTLER_SHADOW_PAYLOAD": payload}, check=False) + + +def main(): + ap = argparse.ArgumentParser(description="R3→R1 反馈边:运行信号→候选 spec") + ap.add_argument("--policy", default=POLICY) + ap.add_argument("--dashboard-file", help="dashboard 信号 fixture(离线注入)") + ap.add_argument("--existing-file", help="open issues 去重面 fixture(离线注入)") + ap.add_argument("--dry-run", action="store_true", + help="只打印候选(零 gh 写调用)") + args = ap.parse_args() + + policy = load_policy(args.policy) + repo = policy["backlog_repo"] + di = policy["dashboard_issue"] + + if args.dashboard_file: + with open(args.dashboard_file, encoding="utf-8") as f: + dash_num, doc = 0, parse_dashboard(f.read()) + else: + if args.dry_run and not os.environ.get("GH_TOKEN"): + die2("--dry-run 离线模式须配 --dashboard-file(信号源不可缺——无默认绿)") + dash_num, doc = fetch_dashboard(di["repo"], di["label"]) + generated_at = doc.get("generated_at") or "unknown" + + crossed_list, pending_list, created, duplicates = [], [], [], [] + for s in policy["signals"]: + key, op = s["key"], s["op"] + value = resolve_path(doc, s["metric_path"]) + threshold = (resolve_path(doc, s["threshold_path"]) + if str(s.get("threshold_path") or "").strip() + else s.get("threshold")) + if is_pending(value) or is_pending(threshold): + reason = "观测值 pending(数据源盲区)" if is_pending(value) else "阈值源 pending" + pending_list.append(key) + print(f"PENDING {key} {reason}——诚实跳过(不造数)") + continue + res = crossed(value, op, threshold) + if res is None: + pending_list.append(key) + print(f"PENDING {key} 值/阈值类型不可比(value={value!r} {op} {threshold!r})") + continue + if not res: + print(f"OK {key} value={value} {op} {threshold}(阈内)") + continue + crossed_list.append(key) + print(f"SIGNAL {key} value={value} {op} {threshold} → 越阈({s['class']})") + # 去重(dry-run 无 gh 面:existing-file 注入或直接报候选) + existing = (existing_open_issues(repo, key, args.existing_file) + if (not args.dry_run or args.existing_file) + and (os.environ.get("GH_TOKEN") or args.existing_file) + else []) + if existing: + duplicates.append(key) + print(f"DUPLICATE {key} open 候选已存在 #{existing[0]}——跳过(RB-B2)") + continue + body = spec_body(key, s["class"], s["description"], value, op, + threshold, dash_num, generated_at) + if args.dry_run: + # labels 行=AC-8h 可断言锚(结构性硬编码:仅 ir-draft,无签署旁路; + # body 文本合法提及"owner 签署→ir-signed"门描述,不作红线判定面) + print(f"CANDIDATE {key} labels=type:intent+state:ir-draft →(dry-run 不开卡)") + print(body) + continue + num = create_candidate(repo, key, s["spec_title"], body) + created.append(key) + print(f"CANDIDATE {key} → #{num}(type:intent+state:ir-draft——入 backlog)") + + summary = {"dashboard_issue": dash_num, "generated_at": generated_at, + "signals_total": len(policy["signals"]), + "crossed": crossed_list, "created": created, + "duplicates": duplicates, "pending": pending_list, + "dry_run": args.dry_run} + _audit("ok", summary) + print(f"SUMMARY crossed={len(crossed_list)} created={len(created)} " + f"duplicates={len(duplicates)} pending={len(pending_list)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/governance/policy/feedback.yaml b/governance/policy/feedback.yaml new file mode 100644 index 0000000..86fe85b --- /dev/null +++ b/governance/policy/feedback.yaml @@ -0,0 +1,75 @@ +schema: feedback-edge/v1 +# feedback.yaml —— R3→R1 反馈边信号规则(IR-0006 W6-M2 / 卡 #424 / AC-8g+8h) +# +# 宪法 §11(三面分离:R1 意图面 / R3 运行面)+ ADR-0095(角色路由:一切 +# spec 从 type:intent 起步)。反馈边=R3 运行信号反哺 R1 backlog 的**生成侧**: +# 信号越阈值 → 候选 spec(type:intent + state:ir-draft)自动生成入 backlog; +# 但签署门禁不豁免(AC-8h)——owner 签署→spec→红队全流程照走,反馈边永不 +# 置 state:ir-signed(INV-01:判定语义锚点机械;BEH-01:生成≠判定)。 +# +# 信号源=管家账本 dashboard issue(宪法 §12 投影二,ADR-0073 machine-readable +# JSON)——指标口径与阈值真源(policy/metrics.yaml)一致,本表只声明**何时 +# 值得开候选 spec**(反馈阈值≠护栏阈值:护栏 red 上屏,反馈越阈值开卡—— +# 两套阈值独立声明,防互绕)。 +# +# 信号三分类(卡面:错误/用量/SLO): +# error=质量逃逸面 · usage=用量成本面 · slo=服务停摆面 +# 规则字段(feedback-edge.py 机械校验,非法=exit 2 fail-closed): +# key 唯一键(去重 label feedback:) +# class error|usage|slo +# description 语义描述(进候选 spec body) +# metric_path dashboard JSON 点路径(缺/None/pending 字串=诚实 SKIP) +# op gt|lt|ge|le|eq|ne(gt/lt/ge/le 须数值) +# threshold 内联阈值;或 threshold_path(点路径取阈值——如停摆线随 +# metrics.yaml 声明走,不在本表复写) +# spec_title 候选 spec 标题(描述意图,不含承诺方案) +backlog_repo: Cloudbird-Software/.github +dashboard_issue: + repo: Cloudbird-Software/.github + label: dashboard # 管家账本 dashboard issue(机器可读 JSON 真源) +signals: + # ---- SLO(停摆面)---- + - key: needs-human-p90-stop + class: slo + description: needs-human 卡 p90 停留超停摆线(宪法 §7:单独显示不洗白;持续超线=反馈开卡找根因) + metric_path: metrics.attention.needs_human_p90_hours + op: gt + threshold_path: metrics.attention.needs_human_stop_hours # 停摆线随 metrics.yaml 声明(24h) + spec_title: "IR 候选(反馈边 needs-human-p90-stop):needs-human 停滞根因治理——p90 停留超停摆线" + # ---- 错误(质量逃逸面)---- + - key: escape-sustained + class: error + description: 逃逸率双窗>0 持续([auto-revert]+post-merge P0——北极星护栏 red) + metric_path: north_star.guardrails.escape_rate_sustained.status + op: eq + threshold: red + spec_title: "IR 候选(反馈边 escape-sustained):逃逸持续双窗——质量逃逸根因与拦截面治理" + - key: revert-rate + class: error + description: 回滚率超阈值(北极星护栏 red——合并质量退化信号) + metric_path: north_star.guardrails.revert_rate.status + op: eq + threshold: red + spec_title: "IR 候选(反馈边 revert-rate):回滚率超阈——合并质量退化根因治理" + - key: drill-red-rate + class: error + description: 演习红率低于目标(关卡漏检=检测面质量劣化,ADR-0069) + metric_path: north_star.guardrails.drill_red_rate.status + op: eq + threshold: red + spec_title: "IR 候选(反馈边 drill-red-rate):演习红率不足——关卡漏检面排查" + - key: false-allow + class: error + description: 仲裁误放行窗口>0(ADR-0054 §7——判定正确性劣化信号) + metric_path: north_star.guardrails.false_allow.status + op: eq + threshold: red + spec_title: "IR 候选(反馈边 false-allow):仲裁误放行——判定通道正确性治理" + # ---- 用量(成本面)---- + - key: per-ir-cost + class: usage + description: 单 IR 虚拟成本超反馈阈值(声明价口径失控速率——宪法 §8 成本面) + metric_path: metrics.cost.per_ir_usd + op: gt + threshold: 60.0 # 反馈阈值(护栏无此项——用量是趋势面非停摆面;与 metrics.yaml 独立声明) + spec_title: "IR 候选(反馈边 per-ir-cost):单 IR 成本超阈——用量归账与归因治理" diff --git a/governance/tests/test-feedback-edge.sh b/governance/tests/test-feedback-edge.sh new file mode 100644 index 0000000..ddd2b56 --- /dev/null +++ b/governance/tests/test-feedback-edge.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# test-feedback-edge.sh —— W6-M2(#424)R3→R1 反馈边自测 +# +# 离线自足:dashboard 信号 fixture(机器可读 JSON 形态同 dashboard-update.py +# 写入面)+ 去重面 fixture;全链零 gh 调用(--dry-run+注入文件)。断言: +# 越阈→候选(R1 门形态)/阈内→OK/pending→诚实跳过/去重→DUPLICATE/ +# policy 非法→exit 2 fail-closed/候选无 ir-signed 旁路(AC-8h 红线)。 +set -uo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo "PASS $1"; } +bad() { FAIL=$((FAIL+1)); echo "FAIL $1"; } + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +EDGE="$DIR/governance/feedback-edge.py" +# 审计影子重定向进 TMP(离线测试不污染工作树) +export BUTLER_SHADOW_FILE="$TMP/feedback-shadow.jsonl" + +# ---- fixture:dashboard 机器可读 JSON(真实形态:marker+8 反引号围栏) ---- +mkdash() { # $1=p90值 $2=p90停摆线 $3=escape状态 + cat > "$TMP/dash.txt" < +\`\`\`\`\`\`\`\`json +{"generated_at": "2026-08-29T12:00:00Z", + "north_star": {"guardrails": {"escape_rate_sustained": {"status": "$3"}, + "revert_rate": {"status": "green"}, + "drill_red_rate": {"status": "green"}, + "false_allow": {"status": "green"}}}, + "metrics": {"attention": {"needs_human_p90_hours": $1, + "needs_human_stop_hours": $2}, + "cost": {"per_ir_usd": 25.8}}} +\`\`\`\`\`\`\`\` +EOF +} + +# ---- 正向:SLO 越阈 → 候选生成(AC-8g) ---- +mkdash 30.39 24 green +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" > "$TMP/out1.txt" 2>&1 +[[ $? -eq 0 ]] && ok "反馈边轮次绿(越阈=产出非故障)" || bad "越阈轮次 exit 码红" +grep -q "^SIGNAL needs-human-p90-stop value=30.39 gt 24" "$TMP/out1.txt" \ + && ok "SLO 信号越阈检出(p90 30.39 > 停摆线 24)" || bad "SLO 越阈漏检" +grep -q "^CANDIDATE needs-human-p90-stop labels=type:intent+state:ir-draft" "$TMP/out1.txt" \ + && ok "候选 spec 生成(labels 锚=type:intent+state:ir-draft——R1 门起点形态)" || bad "候选 labels 锚缺失" + +# AC-8h 红线:CANDIDATE 行(labels 面)结构性无 ir-signed 旁路——body 门描述 +# 合法提及签署流程,不作红线判定面 +if grep "^CANDIDATE" "$TMP/out1.txt" | grep -q "ir-signed"; then + bad "候选 labels 出现 state:ir-signed——自动签署旁路(AC-8h 违约)" +else + ok "候选 labels 无 state:ir-signed(签署门禁不豁免——AC-8h)" +fi +# 候选 body 带证据锚(机器可追溯) +grep -q "feedback-signal: key=needs-human-p90-stop value=30.39" "$TMP/out1.txt" \ + && ok "候选 body 带信号证据锚(feedback-signal 标记)" || bad "证据锚缺失" + +# ---- 阈内 → OK 不开卡 ---- +mkdash 10.0 24 green +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" > "$TMP/out2.txt" 2>&1 +grep -q "^OK needs-human-p90-stop" "$TMP/out2.txt" \ + && ok "阈内信号 → OK 不开卡" || bad "阈内误报" +grep -q "^CANDIDATE" "$TMP/out2.txt" \ + && bad "阈内轮次误开候选" || ok "阈内零候选" + +# ---- 错误面:护栏 red → 候选(eq 语义) ---- +mkdash 10.0 24 red +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" > "$TMP/out3.txt" 2>&1 +grep -q "^SIGNAL escape-sustained value=red eq red" "$TMP/out3.txt" \ + && ok "错误面信号越阈(护栏 red=eq 判定)" || bad "错误面越阈漏检" + +# ---- pending → 诚实跳过(不造数,ADR-0073 决策 7) ---- +mkdash null 24 green +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" > "$TMP/out4.txt" 2>&1 +grep -q "^PENDING needs-human-p90-stop" "$TMP/out4.txt" \ + && ok "观测值 pending → 诚实跳过" || bad "pending 误判" +mkdash 30.39 null green +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" > "$TMP/out5.txt" 2>&1 +grep -q "^PENDING needs-human-p90-stop" "$TMP/out5.txt" \ + && ok "阈值源 pending → 诚实跳过(停摆线缺≠越阈)" || bad "阈值缺误判" +# pending 字串形态("pending:…" 标注) +cat > "$TMP/dash-p.txt" <<'EOF' + +```json +{"generated_at": "t", "north_star": {"guardrails": {}}, + "metrics": {"attention": {"needs_human_p90_hours": "pending:数据源未落", + "needs_human_stop_hours": 24}, + "cost": {"per_ir_usd": 25.8}}} +``` +EOF +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash-p.txt" 2>&1 | grep -q "^PENDING needs-human-p90-stop" \ + && ok "pending 标注字串 → 诚实跳过(字串口径同判)" || bad "pending 字串误判" + +# ---- 去重:open 候选已存在 → DUPLICATE 跳过(RB-B2) ---- +mkdash 30.39 24 green +cat > "$TMP/existing.json" <<'EOF' +[{"number": 901, "labels": ["type:intent", "state:ir-draft", "feedback:needs-human-p90-stop"]}] +EOF +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" \ + --existing-file "$TMP/existing.json" > "$TMP/out6.txt" 2>&1 +grep -q "^DUPLICATE needs-human-p90-stop open 候选已存在 #901" "$TMP/out6.txt" \ + && ok "去重命中 → DUPLICATE 跳过(不重复刷卡)" || bad "去重漏检" +grep -q "^CANDIDATE needs-human-p90-stop" "$TMP/out6.txt" \ + && bad "去重失败仍开候选" || ok "去重后零新候选" + +# ---- 用量面:内联阈值 gt 语义 ---- +cat > "$TMP/dash-cost.txt" <<'EOF' + +```json +{"generated_at": "t", "north_star": {"guardrails": {}}, + "metrics": {"attention": {"needs_human_p90_hours": 10, "needs_human_stop_hours": 24}, + "cost": {"per_ir_usd": 80.5}}} +``` +EOF +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash-cost.txt" 2>&1 \ + | grep -q "^SIGNAL per-ir-cost value=80.5 gt 60.0" \ + && ok "用量面信号越阈(内联阈值 gt)" || bad "用量越阈漏检" + +# ---- 负向:policy 非法 → exit 2 fail-closed(三形态) ---- +# 形态1:op 非法 +sed 's/op: gt/op: xor/; s/needs-human-p90-stop/needs-x/' "$DIR/governance/policy/feedback.yaml" > "$TMP/p1.yaml" 2>/dev/null || true +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" --policy "$TMP/p1.yaml" >/dev/null 2>&1 +# sed 可能因结构差异不生效——直接构造最小非法 policy 保证确定性 +cat > "$TMP/pbad1.yaml" <<'EOF' +schema: feedback-edge/v1 +backlog_repo: a/b +dashboard_issue: {repo: a/b, label: dashboard} +signals: + - {key: k, class: slo, description: d, metric_path: a.b, op: xor, threshold: 1, spec_title: t} +EOF +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" --policy "$TMP/pbad1.yaml" >/dev/null 2>&1 +[[ $? -eq 2 ]] && ok "非法 op → exit 2(fail-closed)" || bad "非法 op 未拒" +# 形态2:threshold/threshold_path 双缺 +cat > "$TMP/pbad2.yaml" <<'EOF' +schema: feedback-edge/v1 +backlog_repo: a/b +dashboard_issue: {repo: a/b, label: dashboard} +signals: + - {key: k, class: slo, description: d, metric_path: a.b, op: gt, spec_title: t} +EOF +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" --policy "$TMP/pbad2.yaml" >/dev/null 2>&1 +[[ $? -eq 2 ]] && ok "阈值缺声明 → exit 2" || bad "阈值缺未拒" +# 形态3:schema 头错 +cat > "$TMP/pbad3.yaml" <<'EOF' +schema: feedback-edge/v2 +EOF +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash.txt" --policy "$TMP/pbad3.yaml" >/dev/null 2>&1 +[[ $? -eq 2 ]] && ok "schema 头错 → exit 2" || bad "schema 头未拒" + +# ---- 负向:dashboard 不可解析 → exit 2(信号源断=无默认绿) ---- +echo "{broken json" > "$TMP/dash-bad.txt" +env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run --dashboard-file "$TMP/dash-bad.txt" >/dev/null 2>&1 +[[ $? -eq 2 ]] && ok "dashboard JSON 损坏 → exit 2" || bad "损坏 JSON 未拒" + +# ---- dry-run 离线无信号源 → exit 2(不可缺省造绿) ---- +GH_TOKEN= env -u GH_TOKEN -u GITHUB_TOKEN python3 "$EDGE" --dry-run >/dev/null 2>&1 +[[ $? -eq 2 ]] && ok "离线 dry-run 缺 dashboard 注入 → exit 2" || bad "缺信号源未拒" + +# ---- 真实 policy 全量:本仓 feedback.yaml 可装载(漂移面) ---- +python3 - "$DIR/governance/policy/feedback.yaml" <<'PY' +import sys, yaml +p = yaml.safe_load(open(sys.argv[1], encoding="utf-8")) +assert p["schema"] == "feedback-edge/v1" +classes = {s["class"] for s in p["signals"]} +assert classes == {"error", "usage", "slo"}, classes +assert len({s["key"] for s in p["signals"]}) == len(p["signals"]) +sys.exit(0) +PY +[[ $? -eq 0 ]] && ok "真实 feedback.yaml 声明面完整(三分类+键唯一)" || bad "真实 policy 声明坏" + +# ---- 工作流面:feedback-edge.yml 存在+定时+去重串行化声明 ---- +WF="$DIR/.github/workflows/feedback-edge.yml" +[[ -f "$WF" ]] && ok "feedback-edge.yml 在位" || bad "workflow 缺失" +grep -q "cron:" "$WF" && ok "定时面在位(每日)" || bad "无 cron" +grep -q "group: feedback-edge" "$WF" && ok "并发串行化在位(防重复开卡)" || bad "无 concurrency" + +echo "----------------------------------------" +echo "test-feedback-edge: $([[ $FAIL -eq 0 ]] && echo PASS || echo "FAIL($FAIL)")" +exit $([[ $FAIL -eq 0 ]] && echo 0 || echo 1)