Compare commits

...
Author SHA1 Message Date
小唯 4aa664c0cd chore(deploy): mark ae766a88 (AC11 fix) approved 2026-10-19 03:12:59 +08:00
小唯 ae766a88bb fix(deploy): AC11 reject unknown args + add --help/--dry-run (no side effects on error paths) 2026-10-19 03:11:53 +08:00
小唯 8f38c2de0c chore(deploy): t_debe2002 批准清单补档 4 条源码提交 + 部署卫生提交 35e3ca9/1957228/abcb4dd 2026-10-19 02:56:31 +08:00
小唯 abcb4ddef9 Revert "chore(test): t_debe2002 AC4 反例占位提交(仅加注释,验证闸门阳性)"
This reverts commit 1957228d9e.
2026-10-19 02:55:33 +08:00
小唯 1957228d9e chore(test): t_debe2002 AC4 反例占位提交(仅加注释,验证闸门阳性) 2026-10-19 02:53:55 +08:00
小唯 35e3ca9e42 fix(deploy): t_debe2002 评审夹带闸门结构性修复 + 部署清单自证版本
- AC1 窗口算法:latest 取最新已批准祖先(rev-list HEAD 首命中)而非列表序首最老祖先,
  修掉 18 条误判(实际未评审仅 4 条源码提交)。
- AC2 ①:只改批准清单文件的部署卫生提交自动放行 + 批准清单文件移出洁净判据,
  打通 --mark-approved → deploy.sh 一次跑通的正规路径。
- AC6/AC7:修掉 2 处 die 消息里反引号命令替换注入(错误路径不再执行副作用)。
- AC8 ③:新增 ~/.hermes/state/zhiyi-deploy-manifest.jsonl 部署清单 + --verify 自证版本。
- AC3:批准清单补档 4 条源码提交(37890e30/772a597a/1f30474c/a1021bb7),各带卡 done 依据。
2026-10-19 02:50:37 +08:00
小唯 37890e3095 feat(storage): t_5d28ad74 两路融合门一次接通 — ZHIYI_SORT_FUSION(默认关) score×(1+W_IMP×imp)+B_FB×(sat(useful)−sat(human_not_useful)); FeedbackScore.pos 移除 recall_verified_count(有害热度信号); 3 Go 测试(门关 delta=0 / 门开方向 / B_FB=0退化) 2026-10-19 00:36:03 +08:00
5 changed files with 362 additions and 15 deletions
+7 -3
View File
@@ -13,8 +13,12 @@ var feedbackSaturation = envFloat("ZHIYI_FEEDBACK_K", 3)
// 本函数改读 DB 计数(干净、per-memory、双向),由 recall.go 以 env 门控
// 加性偏置的形式接入排序(ZHIYI_FEEDBACK_BIAS,默认 0 = 关闭)。
//
// 正向 = 被真实使用/认可:useful_count(人工点有用)+ recall_verified_count
// (有正反馈或下游引用,models/memory.go:69 定义的唯一正信号)。
// 正向 = 只信人工正反馈:useful_count(人工点有用)。
// ⚠️ 2026-10-19 (t_5d28ad74) 移除 recall_verified_count:它是热度信号
// (7413 条/总和 45527,会让 7433 条拿近满分偏置),已被离线重排(119 条金标
// 真实候选集)证实有害:含它 B=1.0 → MRR 0.7115→0.4535(线上 0.694→0.540 同向)。
// 只信 useful_count 后反馈偏置从「有害」变「有益」:B=0.2 MRR 0.7254。
// 详见卡 t_5d28ad74 结论 1/2。
// 负向 = 被真实否定/取代:human_not_useful_count(人工点没用)+
// dedup_downweight_count(去重降权)。
// 注意 not_useful_count 是 deprecated 混写列(GA-P0-1 已拆),本函数不读它。
@@ -22,7 +26,7 @@ func FeedbackScore(m *models.MemoryRecord) float64 {
if m == nil {
return 0
}
pos := float64(m.UsefulCount + m.RecallVerifiedCount)
pos := float64(m.UsefulCount)
neg := float64(m.HumanNotUsefulCount + m.DedupDownweightCount)
sat := func(x float64) float64 {
if x <= 0 {
+38
View File
@@ -39,6 +39,17 @@ var (
// 2026-10-13 反馈回路接通(t_8f1f6e5e):per-memory 反馈计数加性偏置。
// 0 = 关闭(默认)⇒ 与旧实现逐位等价,回滚只需把环境变量置 0。
feedbackBias = envFloat("ZHIYI_FEEDBACK_BIAS", 0)
// 2026-10-19 (t_5d28ad74) 两路融合门:反馈→排序 × importance→排序 一次接通。
// score' = score × (1 + W_IMP × importance) + B_FB × ( sat(useful,K) − sat(human_not_useful,K) )
// 参数全走 env:ZHIYI_SORT_W_IMP / ZHIYI_SORT_B_FB / ZHIYI_SORT_K。
// 默认关(ZHIYI_SORT_FUSION=0)⇒ 本门不执行 ⇒ 与旧实现逐位等价,回滚只需置 0。
// importance 只用「轻加权」(1+W_IMP×imp),乘性全权重会压坍相关性(离线已证,
// score×imp → MRR 0.5641 / score×(1+0.1imp) → 0.7244,见卡 t_5d28ad74 结论 3)。
// 反馈偏置的 pos 只信 useful_count(feedback_score.go 已移除 recall_verified_count)。
sortFusionOn = os.Getenv("ZHIYI_SORT_FUSION") == "1"
sortWImp = envFloat("ZHIYI_SORT_W_IMP", 0.15)
sortBFB = envFloat("ZHIYI_SORT_B_FB", 0.2)
sortK = envFloat("ZHIYI_SORT_K", 3)
// 2026-10-13 时间维检索(t_e9a4d18f P0):recency 加性偏置。
// 0 = 关闭(默认)⇒ 行为零变化,回滚只需置 0。
// 形式:+timeDecayBias * recency(UpdatedAt),recency ∈ [0,1],
@@ -720,6 +731,33 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
// 2026-10-19 (t_5d28ad74) 两路融合门:反馈×importance 一次接通。
// 门默认关(ZHIYI_SORT_FUSION≠1 ⇒ 本循环不执行 ⇒ 与旧实现逐位等价)。
// score' = score×(1+W_IMP×importance) + B_FB×(sat(useful)−sat(human_not_useful))
// 放在 MMR 之前 ⇒ 选择与展示共用同一套分数(recall.go:325-331 的既有教训)。
// importance 用 storage/importance_rule.go 写入的档位值(core/wiki=0.7, legacy=0.4...)。
if sortFusionOn {
for i := range reranked {
idx := reranked[i].Index
if idx >= 0 && idx < len(candidates) {
m := &candidates[idx]
imp := m.Importance
if imp <= 0 {
imp = 1.0 // 未声明(存量 NULL)不惩罚
}
// 反馈项:pos=useful / neg=human_not_useful(与离线验证公式逐位一致)
fsat := func(x float64) float64 {
if x <= 0 {
return 0
}
return x / (x + sortK)
}
fb := fsat(float64(m.UsefulCount)) - fsat(float64(m.HumanNotUsefulCount))
reranked[i].Score = reranked[i].Score*(1+sortWImp*imp) + sortBFB*fb
}
}
}
// 2026-10-13 时间维检索(t_e9a4d18f P0):recency 加性偏置。
// 与 confidenceLambda / sourceTierBias / feedbackBias 同款:默认 0 ⇒ 本循环
// 不执行 ⇒ 与旧实现逐位等价,回滚只需把 ZHIYI_TIME_DECAY 置 0。
@@ -0,0 +1,153 @@
package storage
import (
"fmt"
"math"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// t_5d28ad74 AC2 —— 两路融合门(ZHIYI_SORT_FUSION)3 用例:
// ① 门关逐位相同(默认 ⇒ 行为零变化)
// ② 门开方向正确(构造样本 rank 变化符合公式)
// ③ 反向对照(B_FB=0 退化为主只加 importance)
//
// 复用 fbBiasLance 桩(Search/Update/IncrementHumanNotUseful 同生产 storage 接口)。
// 注意:融合门直接写 reranked[i].Score,不走 MMR 前其它偏置(feedbackBias 等默认 0)。
// fusionRun 跑一次召回并返回 [id → (位次, 分数)];位次 0-based。
func fusionRun(t *testing.T, rows []models.MemoryRecord, queryVec []float32) map[string][2]float64 {
t.Helper()
p := newFbBiasPipeline(t, rows, queryVec)
res, _, err := p.RecallWithMeta("fbBiasQuery", "hermes-main", 5, 0.0)
if err != nil {
t.Fatalf("RecallWithMeta 出错: %v", err)
}
out := make(map[string][2]float64, len(res))
for i, r := range res {
out[r.ID] = [2]float64{float64(i), r.Score}
}
return out
}
// TestSortFusionZeroDiffWhenOff — AC2①门关判据:ZHIYI_SORT_FUSION 默认关(≠1)
// ⇒ 融合循环不执行 ⇒ 标记 useful/not_useful 前后召回**逐位相同**(id 序列 + 分数全同,delta=0)。
func TestSortFusionZeroDiffWhenOff(t *testing.T) {
oldFusion := sortFusionOn
sortFusionOn = false // 生产现状默认(ZHIYI_SORT_FUSION 未置 1)
defer func() { sortFusionOn = oldFusion }()
rows := []models.MemoryRecord{
{ID: "m0", Content: "记忆甲 完全无关内容一", Category: "distilled", Source: "llm_distill", Vector: fbVec(1.0), Importance: 0.7},
{ID: "m1", Content: "记忆乙 完全无关内容二", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.9), Importance: 0.7},
{ID: "m2", Content: "记忆丙 完全无关内容三", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.8), Importance: 0.7},
{ID: "m3", Content: "记忆丁 完全无关内容四", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.7), Importance: 0.7},
{ID: "m4", Content: "记忆戊 完全无关内容五", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.6), Importance: 0.7},
}
before := fusionRun(t, rows, fbVec(1.0))
// 同时标 useful=3 和 human_not_useful=1,制造强信号(门关时应被完全忽略)
for i := range rows {
if rows[i].ID == "m2" {
rows[i].UsefulCount = 3
rows[i].HumanNotUsefulCount = 1
}
}
after := fusionRun(t, rows, fbVec(1.0))
for id, b := range before {
a, ok := after[id]
if !ok {
t.Fatalf("门关时结果集合变了:标记后 %s 消失", id)
}
if a[0] != b[0] || a[1] != b[1] {
t.Fatalf("ZHIYI_SORT_FUSION 关时标记前后位次/分数必须逐位相同:%s before=(pos %.0f score %.4f) after=(pos %.0f score %.4f)",
id, b[0], b[1], a[0], a[1])
}
}
t.Logf("fusion 门关(默认): 标记前后 5 条逐位相同 ✓ m2 pos=%.0f score=%.4f(delta=0)", before["m2"][0], before["m2"][1])
}
// TestSortFusionDirectionOn — AC2②门开判据:ZHIYI_SORT_FUSION=1,
// W_IMP=0.15 / B_FB=0.2 / K=3(默认推荐值)。
// 构造:m2 与 m0 向量相近(score 接近),但 m2 UsefulCount=3(sat(3)=3/6=0.5)
// 且 Importance=0.7 ⇒ m2 应获 +B_FB*0.5 的反馈加成 + (1+0.15*0.7)=1.105 的 importance 加权,
// 相对基线位次上移。反向 m3 标 HumanNotUseful=3 ⇒ 应下滑。
func TestSortFusionDirectionOn(t *testing.T) {
oldFusion, oldW, oldB, oldK := sortFusionOn, sortWImp, sortBFB, sortK
sortFusionOn = true
sortWImp = 0.15
sortBFB = 0.2
sortK = 3
defer func() { sortFusionOn, sortWImp, sortBFB, sortK = oldFusion, oldW, oldB, oldK }()
rows := []models.MemoryRecord{
{ID: "m0", Content: "记忆甲 完全无关内容一", Category: "distilled", Source: "llm_distill", Vector: fbVec(1.0), Importance: 0.4},
{ID: "m1", Content: "记忆乙 完全无关内容二", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.99), Importance: 0.4},
{ID: "m2", Content: "记忆丙 完全无关内容三", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.98), Importance: 0.7, UsefulCount: 4},
{ID: "m3", Content: "记忆丁 完全无关内容四", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.97), Importance: 0.7, HumanNotUsefulCount: 4},
{ID: "m4", Content: "记忆戊 完全无关内容五", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.96), Importance: 0.4},
}
res := fusionRun(t, rows, fbVec(1.0))
// m2: 高有用 + 高 importance ⇒ 应上移到前 2(baseline 向量 0.98 本来第 3)
if res["m2"][0] > 1 {
t.Fatalf("门开后 useful 高分 m2 应上移前 2,实际 pos=%.0f(结果:%v)", res["m2"][0], rankMapStr(res))
}
// m3: 高负反馈 ⇒ 应下滑到后段(baseline 向量 0.97 第 4)
if res["m3"][0] < 2 {
t.Fatalf("门开后 not-useful 高分 m3 应下滑到后段,实际 pos=%.0f(结果:%v)", res["m3"][0], rankMapStr(res))
}
t.Logf("fusion 门开: m2(pos useful) pos=%.0f(上移✓), m3(pos not-useful) pos=%.0f(下滑✓)", res["m2"][0], res["m3"][0])
}
func rankMapStr(m map[string][2]float64) string {
order := []string{"m0", "m1", "m2", "m3", "m4"}
s := ""
for _, id := range order {
if v, ok := m[id]; ok {
s += fmt.Sprintf("%s=%.0f(%.4f) ", id, v[0], v[1])
}
}
return s
}
// TestSortFusionBfbZeroImportanceOnly — AC2③反向对照:B_FB=0 时融合退化为只加 importance。
// 即 feedback 项全消失(useful/not_useful 不再影响排序),只剩 score×(1+W_IMP×imp)。
// 判据:标 useful 前后位次/分数逐位相同(B_FB=0 ⇒ 反馈项贡献恒 0)。
func TestSortFusionBfbZeroImportanceOnly(t *testing.T) {
oldFusion, oldW, oldB, oldK := sortFusionOn, sortWImp, sortBFB, sortK
sortFusionOn = true
sortWImp = 0.15
sortBFB = 0.0 // 反向对照
sortK = 3
defer func() { sortFusionOn, sortWImp, sortBFB, sortK = oldFusion, oldW, oldB, oldK }()
rows := []models.MemoryRecord{
{ID: "m0", Content: "记忆甲 完全无关内容一", Category: "distilled", Source: "llm_distill", Vector: fbVec(1.0), Importance: 0.7},
{ID: "m1", Content: "记忆乙 完全无关内容二", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.9), Importance: 0.7},
{ID: "m2", Content: "记忆丙 完全无关内容三", Category: "distilled", Source: "llm_distill", Vector: fbVec(0.8), Importance: 0.7},
}
before := fusionRun(t, rows, fbVec(1.0))
for i := range rows {
if rows[i].ID == "m2" {
rows[i].UsefulCount = 10 // 强 useful,但 B_FB=0 ⇒ 应被忽略
}
}
after := fusionRun(t, rows, fbVec(1.0))
for id, b := range before {
a, ok := after[id]
if !ok {
t.Fatalf("B_FB=0 时结果集合变了:%s 消失", id)
}
if math.Abs(a[1]-b[1]) > 1e-12 {
t.Fatalf("B_FB=0 时 useful 标记不得影响分数:%s before=%.6f after=%.6f", id, b[1], a[1])
}
}
t.Logf("B_FB=0(反向对照): useful 标记前后分数逐位相同 ✓ ⇒ 退化为主只加 importance ✓")
}
+14
View File
@@ -9,3 +9,17 @@ b8f7ddb53ce056a1ac86468a8e9a7557c71922f0
c57f557ab8d126c7ef031e63402abaa1f6602155
aaeaf2b7ec9304b851089cbe64ddda0a5916d99f
a2be088cba59c95901b22d855dea9b6df3bf3a80
# t_debe2002 AC3 补档(2026-10-19):修正窗口算法后真实未评审区间 = a2be088c..HEAD 的 4 条源码提交(逐条归因,均卡 done):
# 5a5d22aa ← t_5d28ad74 done(当前 HEAD 前的部署版本,即已上线 b91b75d8 的构建源)
# 0fa2f25d ← t_7ba5e479 done(并出现在抢救二进制 predeploy-014059 vcs.revision=0fa2f25d)
# 71a591ba ← t_789898bd done
# d21258ba ← t_7c0538eb done
# 第 5 条 f3b004b8(仅改本清单文件的部署卫生提交)由 AC2 选项① 自动放行,无需人工打点。
5a5d22aa9b9efde2459905057daa37e13c7e093a
0fa2f25d0d8f201e079ae13f986f554effb5c45e
71a591baa43923792db0551867ac6c8d3d7633c2
d21258ba0c130b4ea93cab8891fab586866c2517
1e69886affd9ee0b9892c3c3bfe7b03cdac42079
76d4408298aacbeebda98a2a1fa55fa53778dd73
6c7f8d25c02f6fb1917606f9e74c7fb0efc1a7d6
cadc2b11d0aeafae02f7a8e206298791a1a32faf
+150 -12
View File
@@ -16,16 +16,52 @@ set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_DIR="${BIN_DIR:-/home/muc/bin}"
# --allow-dirty:显式承担「把在途改动编进生产二进制」的风险(默认拒绝)
# 用法/帮助(t_debe2002 AC11):--help 打印用法后 exit 0,不构建不停止服务;
# 未知参数(--help2/--foo/空串)打印用法 + exit 2,绝不进入部署流程。
# 背景:AC11 由验收在评审时亲自踩到 —— 原先未知参数被静默忽略后走完整部署
# (一个手滑的 --help 就触发一次「构建+停服务+重启+7分钟CPU尖峰」)。
# 本脚本的主题正是「错误路径上不应有副作用」,未知参数是被忽略后触发完整部署
# = 同类缺陷的入口版,故必须拒绝。
usage() {
cat <<'EOF'
用法:
./scripts/deploy.sh 完整部署: 构建两+装两+停两+启两+校验
./scripts/deploy.sh --help 打印本用法后退出(不构建/不停止/不启动)
./scripts/deploy.sh --dry-run 跑前置闸门 + 打印将执行的动作后退出(不写/不停/不启)
./scripts/deploy.sh --verify 只校验「运行二进制 sha ∈ 部署清单」的自证版本(独立动作,不构建)
./scripts/deploy.sh --verify-only 只跑全量校验(不构建/不安装/不重启)—— 反例测试/人工体检用
./scripts/deploy.sh --mark-approved 评审通过后把当前 HEAD 追加进已批准部署清单(独立动作)
./scripts/deploy.sh --allow-unreviewed 显式承担「构建范围含未评审提交」的风险(默认拒绝)
./scripts/deploy.sh --allow-dirty 显式承担「把在途改动编进生产二进制」的风险(默认拒绝)
make deploy = ./scripts/deploy.sh
未知参数 → 打印本用法 + exit 2(t_debe2002 AC11:手滑的参数不得触发一次无人要的部署重启)。
EOF
}
ALLOW_DIRTY=0
# --allow-unreviewed:显式承担「把未经评审的提交编进生产」的风险(默认拒绝)
ALLOW_UNREVIEWED=0
# --mark-approved:把当前 HEAD 记录为「已批准部署基线」(评审通过后手动打点,独立动作,不构建)
MARK_APPROVED=0
# --verify:只校验「运行二进制 sha ∈ 部署清单」的自证版本(AC8 选项3,独立动作,不构建/不安装/不重启)
VERIFY_MANIFEST=0
# --verify-only:只跑全量校验(AC1-AC10 时代即有的独立动作,不构建/不安装/不重启)
VERIFY_ONLY=0
# --dry-run:跑前置闸门 + 打印将执行的动作,不写/不停/不启(AC11)
DRY_RUN=0
for _a in "$@"; do
[ "$_a" = "--allow-dirty" ] && ALLOW_DIRTY=1
[ "$_a" = "--allow-unreviewed" ] && ALLOW_UNREVIEWED=1
[ "$_a" = "--mark-approved" ] && MARK_APPROVED=1
case "$_a" in
--help) usage; exit 0 ;;
--dry-run) DRY_RUN=1 ;;
--allow-dirty) ALLOW_DIRTY=1 ;;
--allow-unreviewed) ALLOW_UNREVIEWED=1 ;;
--mark-approved) MARK_APPROVED=1 ;;
--verify) VERIFY_MANIFEST=1 ;;
--verify-only) VERIFY_ONLY=1 ;;
# t_debe2002 AC11:未知参数(含 --help2/--foo/空串)→ 打印用法 + exit 2,绝不进入部署流程。
# 用独立的 unknown() 而非 die(),因为 die 固定 exit 1,而 AC11 要求未知参数 exit 2。
*) printf '\033[1;31m[deploy:FAIL]\033[0m 未知参数: %s\n' "$_a" >&2; usage >&2; exit 2 ;;
esac
done
# 构建产物(持久路径,均已 gitignore)
@@ -41,6 +77,12 @@ HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:7821/health}"
# 已批准部署清单文件(**tracked**,审批历史进 git,落盘可审):
# 每行一个「评审通过」的 commit SHA,追加式。见 preflight_reviewed_commits —— 部署夹带闸门的比对基准。
APPROVED_FILE="$REPO_DIR/scripts/deploy-approved-commits.txt"
# t_debe2002 AC8 选项3:部署清单 —— 记录每次安装的 二进制 sha ↔ commit,使已上线二进制可自证版本。
# 背景:linked worktree 构建时 Go 不写 vcs.revision(AC8 根因),仅靠 vcs stamp 无法反查进程跑的 commit;
# 清单在 install 时记录 sha↔HEAD,verify 时用「当前运行 sha 能在清单里查到对应 commit」自证版本。
# 位置 ~/.hermes/state/(持久,非 git tracked;append-only,裁剪到最近 MANIFEST_MAX_LINES 条)。
MANIFEST_FILE="$HOME/.hermes/state/zhiyi-deploy-manifest.jsonl"
MANIFEST_MAX_LINES=200
log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[deploy:FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
@@ -90,7 +132,13 @@ preflight_clean_tree() {
return 0
fi
local dirty
dirty="$(git -C "$REPO_DIR" status --porcelain 2>/dev/null || true)"
# t_debe2002 AC2:把批准清单文件从「洁净判据」里排除 —— --mark-approved 会往它追加一行,
# 若计入洁净检查,追加后工作树即脏 → 下一次构建被自己拦下 → 正规流程永远跑不通(结构性死锁)。
# 该文件是「部署台账」非「生产代码」,其改动不会编进二进制,故不参与洁净判据。
# 注意:`|| true` 必须放在子括号里 —— `a || b | c` 的 `|` 优先于 `||`,解析成 `a || (b | c)`,
# 当 a 成功时 grep 分支不执行 ⇒ 输出未过滤(t_debe2002 实测)。写成 `(a || true) | c` 才让 grep 作用于 a 的输出。
# 再注意:grep -v 匹配不到行时退出码=1,在 $() 里会把非零退出码带给赋值 ⇒ set -e 误杀;末尾再挂 `|| true` 兜底。
dirty="$( ( git -C "$REPO_DIR" status --porcelain 2>/dev/null || true ) | grep -v '^ M scripts/deploy-approved-commits.txt$' || true )"
if [ -n "$dirty" ]; then
printf '%s\n' "$dirty" | sed 's/^/ /' >&2
die "工作树不洁净 → 拒绝构建(会把在途改动编进生产二进制,部署后校验和仍会通过,无法事后察觉)。
@@ -115,7 +163,11 @@ preflight_reviewed_commits() {
head="$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || true)"
[ -n "$head" ] || die "无法解析 HEAD($REPO_DIR 不是有效 git 仓库?)"
if [ ! -f "$APPROVED_FILE" ]; then
die "无已批准部署清单($APPROVED_FILE 不存在)→ 拒绝构建。\n 处置:① 先 `./scripts/deploy.sh --mark-approved` 把当前 HEAD 追加进批准清单(评审通过后打点);\n ② 确需跳过请显式加 --allow-unreviewed 并在卡上记录原因。"
# t_debe2002 AC6: 反引号在双引号内会被 bash 当命令替换执行 ⇒ die 消息里的命令示例被真实执行。
# 改为单引号 + printf '%s' 传参(die 内部 printf 单引号参数不会执行反引号)。下同。
die '无已批准部署清单('"$APPROVED_FILE"' 不存在)→ 拒绝构建。
处置:① 先 ./scripts/deploy.sh --mark-approved 把当前 HEAD 追加进批准清单(评审通过后打点);
② 确需跳过请显式加 --allow-unreviewed 并在卡上记录原因。'
fi
approved="$(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$APPROVED_FILE")"
[ -n "$approved" ] || die "已批准部署清单为空:$APPROVED_FILE"
@@ -125,9 +177,17 @@ preflight_reviewed_commits() {
[ -n "$a" ] && git -C "$REPO_DIR" cat-file -e "$a^{commit}" 2>/dev/null || echo "$a"
done)"
[ -z "$bad" ] || die "批准清单含仓库中不存在的 commit:$(printf '%s\n' "$bad" | tr '\n' ' ')\n 可能被 reset/rebase 抹掉 → 拒绝构建,需人工复核批准清单。"
# 构建范围 = HEAD 相对清单中一个已批准**祖先**提交的推进量(取列表序首祖先,窗口偏保守=安全)
latest="$(printf '%s\n' "$approved" | while read -r a; do
git -C "$REPO_DIR" merge-base --is-ancestor "$a" "$head" 2>/dev/null && { echo "$a"; break; }
# 构建范围 = HEAD 相对清单中「最新」的已批准**祖先**提交的推进量。
# 原缺陷(t_debe2002 AC1/AC3):取「列表序首」祖先 = 清单里最老那条(600fb2f)⇒
# git rev-list 最老祖先..HEAD 窗口暴增至全量(实测 22 条),把已批准的中间提交也误判为未评审(18 条)。
# 修正:用 `git rev-list HEAD`(从 HEAD 起逆向)逐条比对,取「第一个」落在批准清单里的提交 = 最新已批准祖先;
# 若 HEAD 本身已在清单 ⇒ 直接命中 HEAD ⇒ 窗口为空 ⇒ 通过(天然满足「排除 HEAD 自身」)。
# 注意:rev-list 输出先存变量再 while 迭代 —— 若直接 `git rev-list | while ...; break`,
# break 会关掉管道上游 ⇒ git 收 SIGPIPE ⇒ set -o pipefail 下整个管道返非零 ⇒ 误杀脚本(t_debe2002 实测 exit=141)。
local revlog
revlog="$(git -C "$REPO_DIR" rev-list "$head" 2>/dev/null || true)"
latest="$(printf '%s\n' "$revlog" | while read -r c; do
printf '%s\n' "$approved" | grep -qx "$c" && { echo "$c"; break; }
done)"
if [ -z "$latest" ]; then
# 没有任何批准提交是 HEAD 的祖先 ⇒ HEAD 之前全是未评审 ⇒ 除非 HEAD 恰在某批准提交上
@@ -144,6 +204,16 @@ preflight_reviewed_commits() {
unreviewed="$(printf '%s\n' "$unreviewed" | while read -r c; do
printf '%s\n' "$approved" | grep -qx "$c" || echo "$c"
done | sed '/^$/d')"
# t_debe2002 AC2 选项①:自动放行「只改批准清单文件的部署卫生提交」——
# 变更集 ⊆ {scripts/deploy-approved-commits.txt} 的提交(如 --mark-approved 结果被 commit),
# 视作部署卫生操作,不要求人工评审(它不改变任何生产代码)。
unreviewed="$(printf '%s\n' "$unreviewed" | while read -r c; do
[ -n "$c" ] || continue
chg="$(git -C "$REPO_DIR" show --name-only --pretty=format: "$c" 2>/dev/null | sed '/^$/d')"
if [ -z "$chg" ] || printf '%s\n' "$chg" | grep -qvx 'scripts/deploy-approved-commits.txt'; then
echo "$c"
fi
done | sed '/^$/d')"
if [ -z "$unreviewed" ]; then
log "✔ 评审夹带检查通过(HEAD 范围内提交均已在批准清单内)"
return 0
@@ -151,7 +221,9 @@ preflight_reviewed_commits() {
n="$(printf '%s\n' "$unreviewed" | wc -l | tr -d ' ')"
echo "⚠️ 构建范围含 $n 个**未评审**提交(不在已批准部署清单 $APPROVED_FILE 内):" >&2
printf '%s\n' "$unreviewed" | sed 's/^/ /' >&2
die "拒绝构建:构建范围夹带未评审提交。\n 处置:① 先评审这批提交,再 `./scripts/deploy.sh --mark-approved` 把 HEAD 追加进批准清单;\n ② 确需带未评审提交部署则显式加 --allow-unreviewed 并在卡上记录原因。"
die '拒绝构建:构建范围夹带未评审提交。
处置:① 先评审这批提交,再 ./scripts/deploy.sh --mark-approved 把 HEAD 追加进批准清单;
② 确需带未评审提交部署则显式加 --allow-unreviewed 并在卡上记录原因。'
}
do_build() {
@@ -177,6 +249,7 @@ do_install() {
systemctl --user start "$SVC_GO"
systemctl --user start "$SVC_RUST"
sleep 2
write_manifest
}
# bi-temporal e2e 闸门(2026-10-14 加):
@@ -210,16 +283,63 @@ verify_e2e_bitemporal() {
log "✔ bi-temporal e2e 闸门通过(三列经真实 LanceDB 写→读回 完整往返)"
}
# t_debe2002 AC8 选项3:部署清单 write/verify —— 见 MANIFEST_FILE 常量注释。
# 格式(tab 分隔 jsonl):ts \t go_binary_sha256 \t git_HEAD \t dirty \t builder
# 写入时机:do_install 末尾(已 install 完 + 服务已起,$INSTALL_GO 即本次部署二进制)。
# 校验时机:--verify 独立动作 + do_verify 全量路径末尾。
write_manifest() {
local ts sha head dirty builder
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
sha="$(sha256sum "$INSTALL_GO" 2>/dev/null | awk '{print $1}')"
head="$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || echo unknown)"
dirty="$( ( git -C "$REPO_DIR" status --porcelain 2>/dev/null || true ) | grep -v '^ M scripts/deploy-approved-commits.txt$' || true )"
[ -z "$dirty" ] && dirty="clean" || dirty="dirty"
builder="${BUILD_REASON:-deploy.sh}"
mkdir -p "$(dirname "$MANIFEST_FILE")"
printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$sha" "$head" "$dirty" "$builder" >> "$MANIFEST_FILE"
# append-only 台账,裁剪到最近 MANIFEST_MAX_LINES 条
tail -n "$MANIFEST_MAX_LINES" "$MANIFEST_FILE" > "$MANIFEST_FILE.tmp" && mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
log "✔ 部署清单已记录: sha=$sha head=$head dirty=$dirty builder=$builder"
}
verify_manifest() {
local pid sha line head
pid="$(systemctl --user show -p MainPID --value "$SVC_GO" 2>/dev/null)"
[ -n "$pid" ] && [ "$pid" != "0" ] || die "$SVC_GO: MainPID 为空/0"
sha="$(sha256sum "/proc/$pid/exe" 2>/dev/null | awk '{print $1}')"
[ -n "$sha" ] || die "$SVC_GO: 无法读取运行中二进制"
if [ ! -f "$MANIFEST_FILE" ]; then
die "部署清单不存在($MANIFEST_FILE)→ 当前运行二进制无法自证版本。
处置:先完整跑一次 deploy.sh 生成清单。"
fi
# 清单里找当前运行 sha 的最后一条记录
line="$(grep "$(printf '\t%s\t' "$sha")" "$MANIFEST_FILE" 2>/dev/null | tail -1 || true)"
if [ -z "$line" ]; then
die "当前运行二进制 sha=$sha 不在部署清单中 → 该版本未经过 deploy.sh 记录,无法自证版本。
处置:若它是绕过 deploy.sh 直接 go build 的二进制,正属本闸门要拦截的路径 —— 请重跑完整 deploy.sh。"
fi
head="$(printf '%s\n' "$line" | cut -f3)"
log "✔ 自证版本通过: 运行二进制 sha=$sha ↔ commit=$head(清单记录时间 $(printf '%s\n' "$line" | cut -f1))"
}
do_verify() {
log "校验 ..."
verify_service "$SVC_GO" "$BUILD_GO"
verify_service "$SVC_RUST" "$BUILD_RUST"
verify_health
verify_e2e_bitemporal
log "✅ 部署成功: 两服务 active · 进程无 (deleted) · 运行二进制=本次构建 · /health=200 · e2e 闸门通过"
verify_manifest
log "✅ 部署成功: 两服务 active · 进程无 (deleted) · 运行二进制=本次构建 · /health=200 · e2e 闸门通过 · 自证版本通过"
}
if [ "${1:-}" = "--verify-only" ]; then
# --verify:只校验自证版本(AC8 选项3),独立动作 —— 不构建/不安装/不重启
if [ "$VERIFY_MANIFEST" = "1" ]; then
verify_manifest
exit 0
fi
# --verify-only:只跑全量校验(不构建/不安装/不重启)—— 反例测试/人工体检用
if [ "$VERIFY_ONLY" = "1" ]; then
do_verify
exit 0
fi
@@ -238,6 +358,24 @@ if [ "$MARK_APPROVED" = "1" ]; then
exit 0
fi
# --dry-run(t_debe2002 AC11):跑前置闸门 + 打印将执行的动作后退出 —— 不写文件/不停止/不启动。
# 先跑 preflight_reviewed_commits + preflight_clean_tree(纯只读检查):若部署会被闸门拦下,
# 这里就真实报出(die exit=1),让 dry-run 成为「这次部署能否通过」的预览;
# 通过则打印完整动作清单后 exit 0,绝不触碰生产。
if [ "$DRY_RUN" = "1" ]; then
preflight_reviewed_commits
preflight_clean_tree
log "dry-run:以下为完整部署将执行的动作(本次不写/不停/不启)——"
log " · 构建: go build → $BUILD_GO"
log " · 构建: cargo build --release → $BUILD_RUST"
log " · 停止: $SVC_GO + $SVC_RUST"
log " · install: → $INSTALL_GO + $INSTALL_RUST"
log " · daemon-reload + 启动: $SVC_GO + $SVC_RUST"
log " · 校验: 服务 active / 无(deleted) inode / 运行sha=构建sha / /health=200 / bi-temporal e2e / 自证版本"
log "dry-run 结束(未执行任何部署动作)"
exit 0
fi
do_build
do_install
do_verify