Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/cost-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ on:
required: false
default: ""
llm_tokens_used_override:
description: "LLM token 用量覆盖(pending 数据源的注入测试通道)"
description: "LLM token 用量覆盖(优先于计量账本的注入测试通道)"
required: false
default: ""
llm_tokens_quota_override:
Expand Down Expand Up @@ -49,6 +49,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# 归账引擎 sparse checkout(W2-C3 .github#216,ADR-0062):CI-Workflows 是公开仓且
# 自身有 CI+org-gate(C1),跟 main 消费;升级若破坏 aggregate 会让本检查显式
# INFRA 红(fail-closed 可见),不会被静默吞掉
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Missing card: metadata line 📘 Rule violation § Compliance

The PR description body does not include the required single Card: <owner>/<repo>#<n> line, so
downstream tooling cannot reliably associate this change with its tracking item.
Agent Prompt
## Issue description
PR description is missing the required card metadata line in the exact format `Card: <owner>/<repo>#<n>`.

## Issue Context
This PR already references `.github#216` / `W2-C3 (.github#216)` in text, but it must be provided as a dedicated `Card:` line (e.g., `Card: Cloudbird-Software/.github#216`).

## Fix Focus Areas
- .github/workflows/cost-check.yml[52-55]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

with:
repository: Cloudbird-Software/CI-Workflows
sparse-checkout: pipeline/metering
path: ciw-metering
persist-credentials: false
Comment on lines +56 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. 外部仓checkout未固定ref 🐞 Bug ⛨ Security

workflow 直接 checkout Cloudbird-Software/CI-Workflows 默认分支来获取 metering.py,未固定到特定
commit/tag,导致成本熔断行为随外部仓 HEAD 变化而漂移,存在供应链与可重复性风险。若外部仓被误推/破坏,预算检查可能被绕过或频繁误报。
Agent Prompt
### Issue description
`actions/checkout` 拉取外部仓 `Cloudbird-Software/CI-Workflows` 时未指定 `ref`,会默认跟随外部仓默认分支最新提交。预算归账/验链的执行逻辑因此不可复现且易受外部变更影响。

### Issue Context
该 PR 将 metering.py 作为“预算通道”的关键执行组件,且 cost-check 拥有治理 token(GH_TOKEN)。即使外部仓有 CI,也仍建议对关键治理逻辑固定版本并通过显式升级流程更新。

### Fix Focus Areas
- .github/workflows/cost-check.yml[55-60]

### Suggested change
- 在 checkout CI-Workflows 步骤增加 `ref: <pinned sha or tag>`(推荐 sha),并在需要升级时通过 PR 明确更新该 ref。
- 若确实要跟随 main,也建议至少在 policy/脚本中记录期望版本并加 drift-check 校验(否则治理行为不可追溯)。

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

- name: 用量检查与熔断(exit 1=触发告警/熔断 2=基础设施故障)
env:
GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }}
Expand All @@ -57,5 +66,6 @@ jobs:
COST_QUOTA_MINUTES_OVERRIDE: ${{ inputs.quota_minutes_override }}
COST_LLM_TOKENS_USED_OVERRIDE: ${{ inputs.llm_tokens_used_override }}
COST_LLM_TOKENS_QUOTA_OVERRIDE: ${{ inputs.llm_tokens_quota_override }}
COST_LLM_METERING_PY: ciw-metering/pipeline/metering/metering.py # 归账引擎(ADR-0062 通道)
COST_DRY_RUN: ${{ inputs.dry_run }}
run: bash governance/cost-check.sh
107 changes: 97 additions & 10 deletions governance/cost-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# ≥ warn_pct(80) → 告警 issue(.github 仓,label cost-budget-warning,同日去重,不硬停)
# ≥ hardstop_pct(100)→ org Actions 变量 AUTO_MERGE_DISABLED=true + 撤全部 open PR 的
# auto-merge + P0 issue(label cost-circuit-breaker)
# LLM token 通道(W2-C3 .github#216,ADR-0062):data_source=ciw-metering 时按角色档归账
# (CI-Workflows metering-ledger 分支 → metering.py aggregate,先验链后归账);
# 链断/拉取失败 = INFRA fail-closed(exit 2),不静默归零不盲熔断。
# 熔断消费点:agent 派发/automerge 前置检查(AGENTS.md 行为契约)+ auto-fix-limit.sh
# 每轮机器执法撤 auto-merge。复位仅人工(owner PATCH/DELETE 变量 + P0 issue 留评论);
# 本脚本观察到"变量已复位且用量 <100%"后自动关闭 P0 issue(复位留痕=issue 评论历史)。
Expand Down Expand Up @@ -67,6 +70,10 @@ try:
rows = [("AM_QUOTA", am["quota_per_month"]), ("AM_WARN", am["warn_pct"]), ("AM_STOP", am["hardstop_pct"]),
("LT_QUOTA", lt["quota_per_month"]), ("LT_WARN", lt["warn_pct"]), ("LT_STOP", lt["hardstop_pct"]),
("LT_SOURCE", lt["data_source"]), ("CB_VARIABLE", cb["variable"]), ("CB_RESET_BY", cb["reset_by"])]
# W2-C3(ADR-0062):ciw-metering 数据源定位(data_source 非 ciw-metering 时可为空)
m = lt.get("metering") or {}
rows += [("LT_M_REPO", m.get("repo", "")), ("LT_M_BRANCH", m.get("branch", "")),
("LT_M_CODE", m.get("code_path", ""))]
for kk, vv in rows:
vv = str(vv)
assert "=" not in vv and "\n" not in vv, f"policy 值含非法字符: {kk}"
Expand All @@ -79,6 +86,12 @@ while IFS='=' read -r key val; do declare "$key=$val"; done <<< "$POLICY_ENV"
for v in AM_QUOTA AM_WARN AM_STOP LT_QUOTA LT_WARN LT_STOP LT_SOURCE CB_VARIABLE CB_RESET_BY; do
[[ -n "${!v:-}" ]] || { echo "FATAL: policy 缺 $v" >&2; exit 2; }
done
# ciw-metering 数据源须完整声明定位三件套(W2-C3,ADR-0062)——缺一 = 配置面残缺,fail-closed
if [[ "$LT_SOURCE" == "ciw-metering" ]]; then
for v in LT_M_REPO LT_M_BRANCH LT_M_CODE; do
[[ -n "${!v:-}" ]] || { echo "FATAL: data_source=ciw-metering 但 policy llm_tokens.metering 缺 $v" >&2; exit 2; }
done
fi
# 环境注入优先(T2 注入式测试通道)
AM_QUOTA="${COST_QUOTA_MINUTES_OVERRIDE:-$AM_QUOTA}"
LT_QUOTA="${COST_LLM_TOKENS_QUOTA_OVERRIDE:-$LT_QUOTA}"
Expand Down Expand Up @@ -127,21 +140,93 @@ PCT_MIN=$(pct "$USED_MIN" "$AM_QUOTA")
check_num PCT_MIN "Actions 用量百分比"
ok "Actions 分钟($YEAR-$MONTH): $USED_MIN / $AM_QUOTA = ${PCT_MIN}%($SRC_MIN)"

# LLM token:data_source=pending 时仅注入通道可测,不触发真实告警(ADR-0040 决策 6)
# ---------- LLM token 用量(W2-C3 .github#216,ADR-0062:ciw-metering 数据源按角色档归账) ----------
# @w2c3-llm-channel-begin(governance/tests/test-cost-llm-channel.sh 按标记对提取本函数体
# 离线单测——标记对缺失=测试红,防"测试测影子")
llm_channel_account() {
# → stdout 单行 "DATA<TAB>当月token<TAB>角色档json" | "ZERO<TAB>说明" | "INFRA<TAB>说明"。
# 本函数经命令替换调用(子 shell),不直接调 infra/ok(计数会丢)——标签由调用方
# 在父 shell 落账。env 注入通道(T2):COST_LLM_METERING_DIR=本地账本目录、
# COST_LLM_METERING_PY=归账引擎(metering.py)路径;缺省走真实数据源
# (metering-ledger 分支 tarball → metering.py aggregate,先验链后归账)。
local led="${COST_LLM_METERING_DIR:-}" mpy="${COST_LLM_METERING_PY:-}" out rc=0
if [[ -z "$led" ]]; then
led=$(mktemp -d) || { printf 'INFRA\tmetering 账本临时目录创建失败\n'; return 0; }
if ! "$GH" api "repos/$LT_M_REPO/tarball/$LT_M_BRANCH" >"$led.tar.gz" 2>/dev/null; then
if "$GH" api "repos/$LT_M_REPO/branches/$LT_M_BRANCH" >/dev/null 2>&1; then
printf 'INFRA\tmetering 账本分支存在但 tarball 拉取失败(%s@%s)\n' "$LT_M_REPO" "$LT_M_BRANCH"
else
Comment on lines +155 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Api失败被判zero 🐞 Bug ☼ Reliability

llm_channel_account() 在 tarball 拉取失败后,用 gh api .../branches/... 的成功与否来判断分支是否存在,但对 branches 查询的非
404 失败(限流/网络/权限)会被当作“分支未建”并输出 ZERO,导致用量被静默归零。该路径违背本 PR 声明的 fail-closed(不可信数据不入账),会造成预算检查漏报/假绿。
Agent Prompt
### Issue description
`llm_channel_account()` 在 tarball 拉取失败后,用 `gh api repos/.../branches/...` 作为“分支是否存在”的判据,但当前写法把 branches 查询的任何失败都当成“分支不存在”,从而输出 `ZERO`。

这会在 GitHub API 限流/网络抖动/权限异常等场景下把未知状态 fail-open 成 0 用量,导致 LLM 通道预算检查漏报。

### Issue Context
- 该通道在 PR 描述中强调:tarball 失败/链断/引擎缺失应为 INFRA(exit 2 fail-closed)。
- 但当前分支存在性探测没有区分 404 vs 其他错误。

### Fix Focus Areas
- governance/cost-check.sh[155-160]

### Suggested change
- 捕获 branches 查询的 stdout/stderr 与退出码:
  - rc==0 => 分支存在(tarball 失败应为 INFRA)
  - rc!=0 且输出包含 Not Found/404 => 分支不存在(ZERO)
  - 其他任何失败 => INFRA(“无法确认分支是否存在/不可判定”,fail-closed)
- 同时建议不要吞掉 tarball 拉取失败的错误信息(至少截断输出用于诊断)。

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

printf 'ZERO\t计量账本分支 %s@%s 未建(尚无经 wrapper 的 LLM 调用落账)——当月用量记 0\n' "$LT_M_REPO" "$LT_M_BRANCH"
fi
return 0
fi
Comment on lines +155 to +162
if ! tar -xzf "$led.tar.gz" -C "$led" --strip-components=1 --wildcards \
"*/$LT_M_CODE/records-*.jsonl" 2>/dev/null; then
printf 'INFRA\tmetering 账本 tar 解包失败(strip-components=1 + %s/records-*.jsonl)\n' "$LT_M_CODE"
return 0
Comment on lines +163 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. 空账本被判infra 🐞 Bug ≡ Correctness

llm_channel_account() 使用 tar 的 wildcard 解包 records-*.jsonl,当分支已建但尚无任何 records 文件时,tar 会因“无匹配成员/Not
found in archive”失败并被当作 INFRA。该场景在 PR 设计中应归类为 ZERO(零用量声明),否则会导致通道上线初期或月初窗口持续误报基础设施故障。
Agent Prompt
### Issue description
当前 tar 解包逻辑要求归档中必须存在 `*/$LT_M_CODE/records-*.jsonl`,否则直接返回 INFRA。若 metering-ledger 分支已创建但尚未产生任何 records 文件(空账本),这会把“零用量”误判为 INFRA。

### Issue Context
脚本后续已把 `metering.py aggregate` 的 rc=2 视为 ZERO(无周片/零用量)。但在真实数据源路径下,tar 阶段就可能因无 records 文件而提前退出,导致永远到不了 aggregate 的 rc=2 分支。

### Fix Focus Areas
- governance/cost-check.sh[163-167]

### Suggested change
- 在 tar 前先 `tar -tzf` 列出并检查是否存在匹配 records:
  - 若无匹配:不要报 INFRA,直接让 ledger 目录保持为空并继续执行 aggregate,让其以 rc=2 输出 ZERO;或直接输出 ZERO(带说明“分支已建但无 records 文件”)。
  - 若有匹配但解包失败:仍按 INFRA 处理。
- 或者捕获 tar 的错误输出,仅当错误为“Not found in archive/无匹配”时转 ZERO,其余错误转 INFRA。

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

fi
fi
if [[ -z "$mpy" || ! -f "$mpy" ]]; then
printf 'INFRA\t归账引擎不可用(COST_LLM_METERING_PY=%s——cost-check.yml 须 sparse checkout %s 的 %s)\n' "$mpy" "$LT_M_REPO" "$LT_M_CODE"
return 0
fi
out=$(python3 "$mpy" aggregate --dir "$led" --since "$(date -u +%Y-%m-01)" --json 2>&1) || rc=$?
if [[ $rc -eq 2 ]]; then
printf 'ZERO\t账本无周片(aggregate: %.200s)——当月用量记 0\n' "$out"
return 0
fi
if [[ $rc -ne 0 ]]; then
printf 'INFRA\tmetering 归账失败(账本验链不过——不可信数据不入账):%.300s\n' "$out"
return 0
fi
printf 'DATA\t%s\t%s\n' \
"$(python3 -c 'import json,sys;print(json.load(sys.stdin)["totals"]["total_tokens"])' <<<"$out")" \
"$(python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["roles"],ensure_ascii=False,sort_keys=True))' <<<"$out")"
}
# @w2c3-llm-channel-end

PCT_TOK=""
USED_TOK=""
LLM_ROLES=""
LLM_SUMMARY=""
if [[ -n "${COST_LLM_TOKENS_USED_OVERRIDE:-}" ]]; then
PCT_TOK=$(pct "$COST_LLM_TOKENS_USED_OVERRIDE" "$LT_QUOTA")
check_num PCT_TOK "LLM 用量百分比"
ok "LLM token: $COST_LLM_TOKENS_USED_OVERRIDE / $LT_QUOTA = ${PCT_TOK}%(注入——pending 数据源的注入测试通道)"
USED_TOK="$COST_LLM_TOKENS_USED_OVERRIDE"; LLM_ROLES="注入通道"
LLM_SUMMARY="LLM token: $USED_TOK / $LT_QUOTA = ${PCT_TOK}%(注入通道——优先于计量账本)"
ok "$LLM_SUMMARY"
elif [[ "$LT_SOURCE" == "pending" ]]; then
ok "LLM token: 数据源 pending(llm-gateway usage 端点未就绪)——仅声明,不参与告警"
LLM_SUMMARY="LLM token: 数据源 pending(回滚形态,ADR-0040 决策 6)——仅声明,不参与告警"
ok "$LLM_SUMMARY"
elif [[ "$LT_SOURCE" == "ciw-metering" ]]; then
LLINE=$(llm_channel_account) || true
IFS=$'\t' read -r LTAG LVAL LREST <<<"$LLINE"
case "$LTAG" in
DATA)
USED_TOK="$LVAL"; LLM_ROLES="$LREST"
check_num USED_TOK "LLM 当月 token(归账)"
PCT_TOK=$(pct "$USED_TOK" "$LT_QUOTA")
check_num PCT_TOK "LLM 用量百分比"
LLM_SUMMARY="LLM token(当月归账): $USED_TOK / $LT_QUOTA = ${PCT_TOK}%(角色档 ${LLM_ROLES:-∅})"
ok "$LLM_SUMMARY"
;;
ZERO) LLM_SUMMARY="LLM token: $LVAL"; ok "$LLM_SUMMARY" ;;
INFRA) infra "$LVAL" ;;
*) infra "LLM 通道输出不可解析(期望 DATA/ZERO/INFRA 标签):$LLINE" ;;
esac
else
infra "LLM token 数据源未知:$LT_SOURCE(policy cost.llm_tokens.data_source 无此形态)"
fi

# ---------- 熔断当前状态 ----------
BREAKER_SET=0
VERR=$("$GH" api "orgs/$ORG/actions/variables/$CB_VARIABLE" --jq .value 2>&1) || true
if [[ "$VERR" == *"true"* && "$VERR" != *"Not Found"* ]]; then
BREAKER_SET=1
# 读取 rc 单独捕获(W2-C3 顺带修复的潜在缺陷):变量存在且 value=false(人工复位后
# 的常态)曾被当作"读取失败非 404"落 INFRA——复位确认路径每小时误红。现按 rc 判:
# 0=读到值(true→置位,false/其他→未置位);404=未建;其余=infra fail-closed。
VCB_RC=0
VERR=$("$GH" api "orgs/$ORG/actions/variables/$CB_VARIABLE" --jq .value 2>&1) || VCB_RC=$?
if [[ $VCB_RC -eq 0 ]]; then
[[ "$VERR" == *"true"* ]] && BREAKER_SET=1 # 读到值:true=置位;false/其他=未置位(读取成功即终态)
elif grep -q "Not Found" <<<"$VERR"; then
:
else
Expand Down Expand Up @@ -205,7 +290,7 @@ if [[ "$STOP_MIN" == "True" || "$STOP_TOK" == "True" ]]; then
P0_BODY="P0:额度/成本熔断已置位(ADR-0040,运行 $(date -u +%FT%TZ))。

- Actions 分钟($YEAR-$MONTH): $USED_MIN / $AM_QUOTA = ${PCT_MIN}%(阈值 $AM_STOP%)${PCT_TOK:+
- LLM token: $COST_LLM_TOKENS_USED_OVERRIDE / $LT_QUOTA = ${PCT_TOK}%(阈值 $LT_STOP%,注入通道)}
- ${LLM_SUMMARY:-LLM token: $USED_TOK}(阈值 $LT_STOP%,ADR-0062 归账通道)}
- 已执行:org 变量 \`$CB_VARIABLE\`=true;全部 open PR 的 auto-merge 已撤销。
- 效果:agent 派发与 automerge 前置检查将拒绝启动(AGENTS.md);auto-fix-limit 每轮机器执法撤销新 enable。

Expand All @@ -231,9 +316,11 @@ if [[ "$STOP_MIN" == "True" || "$STOP_TOK" == "True" ]]; then
elif [[ "$WARN_MIN" == "True" || "$WARN_TOK" == "True" ]]; then
TRIPPED=1
label_ensure "$GOV_REPO" cost-budget-warning fbca04
W_TITLE="成本告警:Actions 分钟 $YEAR-$MONTH 用量 ${PCT_MIN}%(阈值 $AM_WARN%)"
W_TITLE="成本告警$YEAR-$MONTH):Actions 分钟 ${PCT_MIN}% / LLM token ${PCT_TOK:--}%(阈值 ${AM_WARN}%)"
W_EXISTING=$(gov_open_issues cost-budget-warning | grep -m1 "$YEAR-$MONTH" | cut -f1)
Comment on lines +319 to 320

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. 告警阈值字段混用 🐞 Bug ≡ Correctness

告警档标题与日志同时展示 Actions 与 LLM token,但括号内阈值仍使用 AM_WARN(Actions 阈值)而非 LT_WARN(LLM
阈值),当两者配置不一致时会误导值守与审计。该问题会导致告警信息描述与实际判定条件不一致。
Agent Prompt
### Issue description
`W_TITLE` / `act` 文案在同时展示 Actions 与 LLM 的百分比时,仍把“阈值”字段写成 `${AM_WARN}%`。如果未来 Actions 与 LLM 的 warn_pct 不同,会出现:判定用 LT_WARN,但文案展示 AM_WARN 的不一致。

### Issue Context
脚本已分别计算 `WARN_MIN` vs `WARN_TOK`(LT_WARN),因此文案应与之对齐。

### Fix Focus Areas
- governance/cost-check.sh[318-333]

### Suggested change
- 标题/日志中明确区分阈值:例如 `(阈值 Actions ${AM_WARN}% / LLM ${LT_WARN}%)`。
- 或者将“阈值”文字改成更中性的描述,并在正文里分别列出两套阈值(当前正文已部分包含)。

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

W_BODY="额度告警(ADR-0040,$(date -u +%FT%TZ)):Actions 分钟($YEAR-$MONTH)$USED_MIN / $AM_QUOTA = ${PCT_MIN}%,达 ${AM_WARN}% 告警档——未硬停;达 ${AM_STOP}% 将置 \`$CB_VARIABLE\` 熔断并撤全部 auto-merge。"
W_BODY="额度告警(ADR-0040,$(date -u +%FT%TZ)):Actions 分钟($YEAR-$MONTH)$USED_MIN / $AM_QUOTA = ${PCT_MIN}%,达 ${AM_WARN}% 告警档——未硬停;达 ${AM_STOP}% 将置 \`$CB_VARIABLE\` 熔断并撤全部 auto-merge。
${LLM_SUMMARY:+$LLM_SUMMARY
}(LLM 阈值 ${LT_WARN}%/${LT_STOP}%,ADR-0062 归账通道)"
Comment on lines +319 to +323
if [[ -n "$W_EXISTING" ]]; then
if ! issue_silent_today "$W_EXISTING"; then
mutate "$GH" issue comment "$W_EXISTING" --repo "$GOV_REPO" --body "$W_BODY" >/dev/null 2>&1 || true
Expand All @@ -242,7 +329,7 @@ elif [[ "$WARN_MIN" == "True" || "$WARN_TOK" == "True" ]]; then
mutate "$GH" issue create --repo "$GOV_REPO" --title "$W_TITLE" \
--body "$W_BODY" --label cost-budget-warning >/dev/null 2>&1 || infra "告警 issue 开立失败"
fi
act "告警档触发: Actions ${PCT_MIN}% ≥ ${AM_WARN}%(issue 已开/更新)"
act "告警档触发: Actions ${PCT_MIN}% / LLM token ${PCT_TOK:--}%(阈值 ${AM_WARN}%,issue 已开/更新)"
else
# 用量回落:关闭过期的告警 issue(月度滚动或已回落)
for row in $(gov_open_issues cost-budget-warning | cut -f1); do
Expand Down
13 changes: 10 additions & 3 deletions governance/policy/automation-limits.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,20 @@ cost:
quota_per_month: 200000000
warn_pct: 80 # ≥80% 开告警 issue(同日去重,不硬停)
hardstop_pct: 100 # ≥100% 置熔断 + 撤全部 open PR auto-merge + P0 issue
# LLM token 月预算(数据源 pending:llm-gateway usage 端点就绪后接入,ADR-0040 决策 6;
# pending 期间仅声明 + 注入通道(LLM_TOKENS_USED)可测,不触发真实告警)
# LLM token 月预算(W2-C3 .github#216,ADR-0062:数据源接入——CI-Workflows
# pipeline/metering 计量账本,按 invoke 聚合的 JSONL hash 链;cost-check 拉取
# metering-ledger 分支经 metering.py aggregate 按角色档归账。链断/归账失败=
# infra fail-closed(exit 2),不静默归零不盲熔断。pending 形态保留为回滚值
# (ADR-0040 决策 6);注入通道 COST_LLM_TOKENS_USED 仍优先于真实数据源)
llm_tokens:
quota_per_month: 200000000
warn_pct: 80
hardstop_pct: 100
data_source: pending
data_source: ciw-metering
metering: # data_source=ciw-metering 时消费(复用本段现有结构,不新建配置面)
repo: Cloudbird-Software/CI-Workflows
branch: metering-ledger
code_path: pipeline/metering # 归账引擎 metering.py 所在(cost-check.yml sparse checkout)

circuit_breaker:
# 熔断标志:org Actions 变量(visibility=all,全仓 workflow/agent 可读)
Expand Down
116 changes: 116 additions & 0 deletions governance/tests/test-cost-llm-channel.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# test-cost-llm-channel.sh —— cost-check LLM 预算通道单测(W2-C3 .github#216,ADR-0062)
#
# 从 cost-check.sh 按 @w2c3-llm-channel 标记对提取 llm_channel_account 函数体
# (不复制实现——防"测试测影子",同 test-ir0002.sh 模式;标记对缺失=fail-closed 红),
# 用桩 metering.py(行为受 STUB_MODE 控制)与桩 gh 断言通道契约:
# DATA 归账成功 → "DATA<TAB>当月token<TAB>角色档json"(调用方据此算 PCT_TOK)
# 空账本 aggregate rc=2 → ZERO(账本分支已建但无周片 = 零用量,不是 infra)
# 链断 aggregate rc=3 → INFRA(不可信数据不入账,fail-closed)
# 引擎缺失 / tarball 拉取失败(分支在)/ 分支未建 → INFRA / ZERO
# 真实归账数值(角色档聚合、验链、hash 链)由 CI-Workflows 仓
# pipeline/metering/selftest T1/T3/T5 覆盖;本测试锁 cost-check 侧消费契约与
# fail-closed 方向。用法:bash governance/tests/test-cost-llm-channel.sh(零网络零真实 gh)
set -uo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
FAILS=0
pass() { echo "PASS $1"; }
fail() { echo "FAIL $1"; FAILS=$((FAILS+1)); }

# --- 提取被测函数(标记对缺失=fail-closed:测试与实现脱钩即红) ---
SRC="$DIR/cost-check.sh"
[[ -f "$SRC" ]] || { echo "FATAL: cost-check.sh 不存在"; exit 2; }
EXTRACTED=$(awk '/@w2c3-llm-channel-begin/{f=1} f{print} /@w2c3-llm-channel-end/{exit}' "$SRC")
if ! grep -q '^llm_channel_account()' <<<"$EXTRACTED"; then
echo "FATAL: 标记对内未找到 llm_channel_account 定义(提取失效——实现与测试脱钩)"; exit 2
fi

# --- 桩环境(函数在命令替换子 shell 外定义;ok/infra 桩防实现漂移误伤主脚本计数) ---
INFRAS=0
infra() { echo "INFRA $1"; INFRAS=$((INFRAS+1)); }
ok() { echo "OK $1"; }
GH=gh
LT_M_REPO="Cloudbird-Software/CI-Workflows"
LT_M_BRANCH="metering-ledger"
LT_M_CODE="pipeline/metering"
eval "$EXTRACTED"

TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/bin" "$TMP/ledger"
# python3 本地垫片(Windows 商店 stub 环境);CI(ubuntu)python3 直用不受影响
if ! python3 -c 'import sys' >/dev/null 2>&1; then
printf '#!/usr/bin/env bash\nexec python "$@"\n' >"$TMP/bin/python3"; chmod +x "$TMP/bin/python3"
export PATH="$TMP/bin:$PATH"
fi
# 桩 metering.py:与真实 metering.py aggregate 的退出码契约一致(0=数据 2=无账本 3=链断)
STUB_MODE_PATH="$TMP/mode"
cat >"$TMP/bin/metering.py" <<'EOF'
import os, sys
mode = open(os.environ["STUB_MODE_PATH"], encoding="utf-8").read().strip()
if mode == "data":
print('{"roles": {"probe": {"invokes": 2, "total_tokens": 165}, '
'"spec-author": {"invokes": 1, "total_tokens": 42}}, '
'"totals": {"invokes": 3, "total_tokens": 207}}')
sys.exit(0)
if mode == "empty":
print("账本目录无 records-*.jsonl", file=sys.stderr); sys.exit(2)
if mode == "broken":
print("CHAIN records-2026-W34.jsonl:1: record_sha256 重算不符", file=sys.stderr); sys.exit(3)
print(f"未知桩模式 {mode}", file=sys.stderr); sys.exit(9)
EOF

run_chan() { # 捕获函数输出(真实数据源路径由 gh 桩 + 已 unset 的注入变量控制)
LLINE=$(llm_channel_account)
}
export STUB_MODE_PATH
export COST_LLM_METERING_DIR="$TMP/ledger"

# 1) DATA:归账成功 → 标签+当月 token+角色档 json
echo data >"$STUB_MODE_PATH"
COST_LLM_METERING_PY="$TMP/bin/metering.py"
run_chan
if [[ "${LLINE%%$'\t'*}" == "DATA" ]] && grep -q '207' <<<"$LLINE" && grep -q '"probe"' <<<"$LLINE"; then
pass "DATA 形态:当月 token=207 + 角色档 json 透传($LLINE)"
else fail "DATA 形态不符:$LLINE"; fi

# 2) 空账本(分支已建、尚无周片)→ ZERO(零用量不是 infra)
echo empty >"$STUB_MODE_PATH"
run_chan
if [[ "${LLINE%%$'\t'*}" == "ZERO" ]] && grep -q "用量记 0" <<<"$LLINE"; then
pass "空账本 → ZERO(零用量声明)"
else fail "空账本应 ZERO,得到:$LLINE"; fi

# 3) 链断 → INFRA(不可信数据不入账——fail-closed 方向)
echo broken >"$STUB_MODE_PATH"
run_chan
if [[ "${LLINE%%$'\t'*}" == "INFRA" ]] && grep -q "不可信数据不入账" <<<"$LLINE"; then
pass "链断 → INFRA(归账拒绝,fail-closed)"
else fail "链断应 INFRA,得到:$LLINE"; fi

# 4) 归账引擎缺失 → INFRA(配置面残缺可见)
COST_LLM_METERING_PY="$TMP/nope.py"
run_chan
if [[ "${LLINE%%$'\t'*}" == "INFRA" ]] && grep -q "归账引擎不可用" <<<"$LLINE"; then
pass "引擎缺失 → INFRA(sparse checkout 缺失可见)"
else fail "引擎缺失应 INFRA,得到:$LLINE"; fi

# 5) 分支未建(tarball 与 branches 查询都 404)→ ZERO
unset COST_LLM_METERING_DIR COST_LLM_METERING_PY
gh() { return 1; }
run_chan
unset -f gh
if [[ "${LLINE%%$'\t'*}" == "ZERO" ]] && grep -q "未建" <<<"$LLINE"; then
pass "账本分支未建 → ZERO(尚无 LLM 调用落账)"
else fail "分支未建应 ZERO,得到:$LLINE"; fi

# 6) tarball 拉取失败但分支存在 → INFRA(用量不可知,不盲熔断不静默归零)
gh() { case "$*" in *tarball*) return 1 ;; *) return 0 ;; esac; }
run_chan
unset -f gh
if [[ "${LLINE%%$'\t'*}" == "INFRA" ]] && grep -q "tarball 拉取失败" <<<"$LLINE"; then
pass "tarball 失败+分支在 → INFRA(fail-closed 出口 2 的判定输入)"
else fail "tarball 失败应 INFRA,得到:$LLINE"; fi

echo "----------------------------------------"
if [[ $FAILS -eq 0 ]]; then echo "test-cost-llm-channel PASS(6/6)"; exit 0; fi
echo "test-cost-llm-channel FAIL:$FAILS 处"; exit 1