From 778861b981b33d950723c2b148d0aca657e2710d Mon Sep 17 00:00:00 2001 From: zichun Date: Tue, 2 Jun 2026 14:35:08 +0800 Subject: [PATCH] coding.mjs: harden per-FE behavior gate per multi-agent review (build-failed guard, coverage reconciliation, testdb halt) --- docs/design/2026-06-02-frontend-behavior-in-review-loop.md | 30 ++++++++++++++++++++++++------ workflows/coding.mjs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/design/2026-06-02-frontend-behavior-in-review-loop.md b/docs/design/2026-06-02-frontend-behavior-in-review-loop.md index 87d8d98..427205c 100644 --- a/docs/design/2026-06-02-frontend-behavior-in-review-loop.md +++ b/docs/design/2026-06-02-frontend-behavior-in-review-loop.md @@ -84,7 +84,7 @@ reviewWithFixLoop(FE): **v2 方案**: 1. `BEHAVIOR_GATE_SCHEMA.envError.kind` 枚举**新增 `build-failed`**(确定性失败语义;`route-not-buildable` 不单列,统一用 `build-failed` + detail 区分)。 -2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` **既不 retry 也不 halt**——记 `coverageGap`(reason 新增枚举 `build-failed-sibling-unimpl`)+ recordDecisions,**本轮行为门视为「本 FE 行为维度无法判定但非本 FE 缺陷」直接放行 approve**(因为它是「后续 FE 未实现」的预期中途态,不是 FE-N 的 bug;§2 骨架占位让这种情况罕见,一旦发生说明占位未覆盖,留证据供人工)。 +2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` 经**确定性前置校验**后才 green-by-skip 放行(既不 retry 也不 halt);不满足前置 = 「脏」build-failed → 过 `adjudicate(allowContinue:false)` retry/halt,**绝不静默放行**。前置(评审加固,见下「评审加固」):(a) 必须有 `rootCausePath`;(b) 不得同时携带交互/`sentinel` 硬问题。干净放行时记 `coverageGap`(reason `build-failed-sibling-unimpl`)+ recordDecisions(「后续 FE 未实现」的预期中途态,非 FE-N 的 bug;§2 骨架占位让这种情况罕见)。 3. `behaviorGatePrompt`(per-FE 版)step0/step2 **明确归因指令**:先 `build` / 起 dev server;若失败,先用 `git` / `Grep` 判断报错根因文件路径—— - 落在**非本 FE 的 frontend/ 路径**(兄弟 FE / 占位未覆盖)→ 判 `envError.kind="build-failed"`(预期中途态)。 - 落在**本 FE 路径** → 才可能是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]` 或带 locator must-fix。 @@ -132,7 +132,7 @@ if (!ALLOW && !/(^|_)(test|dev|local)$|(^|_)test_|^test_/.test(DB_SCHEMA) && !/t (具体正则以实现为准,语义=库名须含 `test`/`_test`/`_dev`/`_local` 之一,否则 fail-closed。) - 这样不论被行为门调用多少次都安全。 -- coding.mjs 行为门控制流里,对「测试库护栏触发的红」**保持不重试不仲裁直接 throw** 的硬边界语义(与现 v1 一致,见 behaviorGatePrompt step2 第 1 条)。 +- coding.mjs 行为门控制流里,对「测试库护栏触发的红」**不重试不仲裁直接 throw** 的硬边界语义现已落地为确定性机制(评审加固):门子代理在 `envError.detail` 以固定标记 `TESTDB_GUARD_MARK`(`[TESTDB-GUARD]`)开头,`runBehaviorGateOnce` 拿到首个结果即 `behaviorTestDbGuardTripped` 命中 → 立即 throw HALT,绝不进入 attempt 重试 / adjudicate(此前仅 step2 prompt 承诺、JS 无兑现,护栏红会误入 stack-not-ready 通用重试路径空转约 5 次)。 - 这是 skeleton-gen 模板的一次性改动,**不属于 coding.mjs 改造**,但列为本设计前置(否则反复起栈的安全暴露面不可接受)。 --- @@ -154,7 +154,8 @@ approve 出口(现 1386 `if (r.verdict==='approve')` 分支)**改为合取** ``` reviewer.verdict==='approve' ∧ behaviorSubGate(FE) 返回 green - 其中 green ≡ behaviorHard.length===0 ∧ envError∈{none, build-failed} ∧ 本FE覆盖非空(或 build-failed 短路) + 其中 green ≡ behaviorHard.length===0 ∧ envError∈{none, build-failed(经前置校验)} ∧ 本FE覆盖非空 + ∧ 无未解释漏达路由(routesReached + 路由级 coverageGap ≥ routesPlanned) (或干净 build-failed 短路) ``` 只有合取成立才 `flipDocs08Checkbox` + `return {approved:true}`。这保证(采纳「删阶段门」维度 blocker 的钉死): @@ -168,13 +169,18 @@ reviewer.verdict==='approve' async function behaviorSubGate(id, specPath, feScope): // feScope = {routes:[...], controlWhitelist:[...]}(来自 §4 spec 结构化小节) for behaviorRound in 1..BEHAVIOR_FE_MAX(=3): - bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 envError attempt 重试 - // 1) build-failed 短路(依赖 B):兄弟未实现 → 记 coverageGap + decisions,子门视为 green-by-skip,return passed - if (bg.envError.kind === 'build-failed' && 根因在非本FE路径) { recordDecisions; return {green:true, skipped:true} } + bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 testdb-guard 直接 halt + envError attempt 重试 + // 1) build-failed:经前置校验后短路(依赖 B)。脏(无 rootCausePath / 携带交互|sentinel 硬问题) → adjudicate(allowContinue:false),绝不静默放行 + if (bg.envError.kind === 'build-failed') { + if (dirty(bg)) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 } + else { recordDecisions; return {green:true, skipped:true} } // 干净:兄弟未实现,green-by-skip + } // 2) envError(其它) / 空覆盖:runBehaviorGateOnce 内部已 attempt 重试;到这里仍 blocked → adjudicate(allowContinue:false) retry/halt if (envBlocked(bg)) { adjudicate; 仍 blocked → throw HALT } // 3) 软文字:for-of 走 adjudicate;continue→recordDecisions + 加入跨轮 softPassed;sentinel→并入 behaviorHard;retry/halt 同现 processTextIssues(bg, softPassed) // softPassed 提升到 reviewWithFixLoop 顶层作用域,跨 behaviorRound 持久 + // 3.6) 覆盖率对账(确定性兜底):未被路由级 coverageGap 解释的漏达路由(routesReached0 && planned-reached-routeGapCount > 0) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 } // 4) behaviorHard = interactionFailures + sentinel textIssues if (behaviorHard.length === 0) return {green:true} // 5) 分流 @@ -286,3 +292,15 @@ async function behaviorSubGate(id, specPath, feScope): 7. README / coding-start SKILL 文案。 每步后端分支必须逐字不变(diff 校验);运行时红线(time/random builtin / 顶层 return / 注入全局)每步复核。 + +--- + +## 12. 评审加固(实现后多代理评审产出,已落地) + +实现后用多代理评审(6 维 + 逐发现对抗复核)核验本设计的逻辑与目标达成。控制流(approve 合取不变量、fix 轮边界、无 stale-green、残留清除)与运行时红线(确定性 builtin / 顶层 return / 16 schema 合法)全部通过。目标维度(「每控件可用 / 每文字正确」)判为 mostly-achieves,确认了若干 escapability 缺口,其中确定性、低风险的三项已加固: + +1. **`build-failed` 短路加确定性前置(头号 must-fix)**:`behaviorSubGate` 在 green-by-skip 前校验 (a) `rootCausePath` 非空、(b) 无交互/`sentinel` 硬问题搭车;任一不满足 → `adjudicate(allowContinue:false)` retry/halt,绝不凭未校验的 LLM 归因静默放行。骨架(lazy router + FeStub)令合法的兄弟未实现 build-failed 极罕见,故一个 build-failed 更可能是本 FE 真共享代码回归——这是此前 comment §107-108 声称 load-bearing 却无 JS 兜底的边界。 +2. **覆盖率对账兜底(§3.6)**:空覆盖此前只兜 `==0`;新增 `routesReached < routesPlanned` 且缺口未被「路由级 coverageGap」解释时 → `adjudicate(allowContinue:false)`,堵住「部分覆盖假绿」(只数路由级 reason,过计只抑制本门、绝不误 halt)。 +3. **测试库护栏直接 halt 落地**:用 `TESTDB_GUARD_MARK` 标记 + `behaviorTestDbGuardTripped` 兑现 step2「不重试不仲裁直接 halt」承诺(详见 §5)。 + +未改(确认为可接受的设计取舍,留作后续):白名单/路由作用域自证(非从 live router/DOM 独立枚举)、非数据文字按 source 软分流、disabled 控件仅提交类有 should-work 复核——均为有意取舍,记于此供后续权衡。 diff --git a/workflows/coding.mjs b/workflows/coding.mjs index 2a27bad..9742bc9 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -614,7 +614,7 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) { '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。', '', '## step2 安全护栏 + 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)', - `1) **测试库安全护栏**:测试库命名护栏现已下沉到 \`${ROOT}/scripts/setup-test-db.mjs\` 模板自身(确定性 JS 边界,库名须含 test/_test/_dev/_local,否则 fail-closed,\`ALLOW_NONTEST_DROP=1\` 显式放行)。runner 可复述但模板是唯一防线;若模板因测试库护栏非零退出 → 返回 \`status:red\` + 在 detail 写明「测试库护栏触发」(上层对此**不重试不仲裁直接 halt**,留人工确认)。`, + `1) **测试库安全护栏**:测试库命名护栏现已下沉到 \`${ROOT}/scripts/setup-test-db.mjs\` 模板自身(确定性 JS 边界,库名须含 test/_test/_dev/_local,否则 fail-closed,\`ALLOW_NONTEST_DROP=1\` 显式放行)。runner 可复述但模板是唯一防线;若模板因测试库护栏非零退出 → 返回 \`status:red\` + \`envError.kind="stack-not-ready"\` + \`envError.detail\` **以固定标记 \`${TESTDB_GUARD_MARK}\` 开头**并写明「测试库护栏触发」+ 库名。上层据此标记**不重试不仲裁直接 halt**(留人工确认)——务必只在确为护栏触发时打此标记,其它起栈失败照常用普通 detail。`, `2) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程。`, '3) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', '4) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。', @@ -757,6 +757,8 @@ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限 // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。 const BEHAVIOR_FE_MAX = 3 const BEHAVIOR_ATTEMPT_MAX = 2 +// 测试库护栏触发的确定性标记:门子代理在 envError.detail 以此开头,JS 据此「不重试不仲裁直接 halt」(兑现 step2 第 1 条承诺)。 +const TESTDB_GUARD_MARK = '[TESTDB-GUARD]' const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。 @@ -1646,6 +1648,11 @@ function behaviorEnvBlocked(r) { return { ev, emptyCov, blocked: !!ev || emptyCov } } function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] } +// 测试库护栏触发判定:门子代理按 step2 第 1 条在 envError.detail 打 TESTDB_GUARD_MARK;命中即确定性 halt(不重试不仲裁)。 +function behaviorTestDbGuardTripped(r) { + const d = (r.envError && r.envError.detail) || '' + return typeof d === 'string' && d.includes(TESTDB_GUARD_MARK) +} // runBehaviorGateOnce:跑一次本 FE 行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。 // 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 behaviorSubGate 推进)。 @@ -1657,6 +1664,10 @@ async function runBehaviorGateOnce(id, specPath, grp, behaviorRound) { {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA}) recordDecisions(`behavior:${id}`, bg.decisions) + // 测试库护栏触发 → 不重试不仲裁直接 halt(兑现 step2 第 1 条承诺;首个结果即拦截,绝不进重试/仲裁烧预算)。 + if (behaviorTestDbGuardTripped(bg)) + throw new Error(`HALT behavior-testdb-guard ${id}: 测试库护栏触发(库名非测试库),不重试不仲裁直接 halt 待人工确认 — ${(bg.envError || {}).detail || ''}`) + // build-failed 短路:根因落非本 FE 路径(兄弟未实现)→ 直接返回(外层据此放行 approve),不重试不仲裁。 const isBuildFailedShortCircuit = (r) => r.envError && r.envError.kind === 'build-failed' if (isBuildFailedShortCircuit(bg)) return bg @@ -1696,13 +1707,34 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_FE_MAX; behaviorRound++) { const bg = await runBehaviorGateOnce(id, specPath, grp, behaviorRound) - // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → 记 coverageGap + decisions,子门 green-by-skip 放行。 + // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → green-by-skip 放行。但骨架(lazy router + FeStub) + // 令「合法的兄弟未实现 build-failed」极罕见,故一个 build-failed 更可能是本 FE 引入的真共享代码回归; + // 绝不凭未校验的 LLM 归因静默放行——先过确定性前置校验(comment §107-108 声称 load-bearing 的边界,此前无 JS 兜底): + // a) 必须有 rootCausePath(否则无从判定根因落点); + // b) 不得同时携带交互硬问题(interactionFailures / source=sentinel 文字)——那是真缺陷搭车。 + // 任一不满足 = 「脏」build-failed → 不短路,过 adjudicate(allowContinue:false) retry/halt,绝不 green-by-skip。 if (bg.envError && bg.envError.kind === 'build-failed') { + const rootCausePath = (bg.envError.rootCausePath || '').trim() + const hardRiders = behaviorIfails(bg).length + + (Array.isArray(bg.textIssues) ? bg.textIssues : []).filter(t => t && t.source === 'sentinel').length + const dirty = !rootCausePath + ? 'build-failed 未给 rootCausePath(无法判定根因是否落在本 FE 之外)' + : hardRiders + ? `build-failed 同时携带 ${hardRiders} 项交互/sentinel 硬问题(疑似本 FE 真构建 bug 搭车)` + : null + if (dirty) { + const verdict = await adjudicate(`behavior-buildfailed-dirty:${id}`, + { problem:`build-failed 归因不可信,绝不短路放行:${dirty}(rootCausePath=${rootCausePath || '∅'})`, + envError: bg.envError, allowContinue:false }, grp, behaviorRound) + if (verdict.action !== 'retry') throw new Error(`HALT behavior-buildfailed ${id}: ${verdict.rationale || dirty}`) + continue // retry → 下一 behaviorRound 重跑整门 + } + // 干净的 build-failed(有 rootCausePath 且无硬问题搭车)→ green-by-skip 放行,记低置信证据。 recordDecisions(`behavior-build-failed:${id}`, [{ - question:`本 FE ${id} 行为验收遇 build-failed(根因 ${bg.envError.rootCausePath || '?'})`, + question:`本 FE ${id} 行为验收遇 build-failed(根因 ${rootCausePath})`, choice:'green-by-skip(兄弟 FE 未实现属预期中途态,本 FE 非缺陷,放行 approve)', rationale: bg.envError.detail || '', confidence:'low' }]) - log(`behavior ${id}: build-failed 短路放行(根因非本 FE:${bg.envError.rootCausePath || '?'}),记证据不阻断`) + log(`behavior ${id}: build-failed 短路放行(根因非本 FE:${rootCausePath}),记证据不阻断`) return } @@ -1746,13 +1778,29 @@ async function behaviorSubGate(id, specPath, grp, softPassed) { continue // retry → 重跑本 FE 行为验收(下一 behaviorRound) } + // 3.6) 覆盖率对账(确定性兜底):空覆盖只兜 ==0;这里兜 0 cg && ROUTE_GAP.has(cg.reason)).length + const unaccounted = planned - reached - routeGapCount + if (planned > 0 && unaccounted > 0) { + const verdict = await adjudicate(`behavior-undercoverage:${id}`, + { problem:`本 FE 路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`, + coverageGaps: bg.coverageGaps || [], allowContinue: false }, grp, behaviorRound) + if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage ${id}: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`) + continue // retry → 下一 behaviorRound 重跑整门 + } + // 4) behaviorHard = interactionFailures(含 binding-garbage)+ source=='sentinel' textIssues。 const sentinelHard = (Array.isArray(bg.textIssues) ? bg.textIssues : []) .filter(t => t && t.source === 'sentinel') .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator })) const behaviorHard = [...behaviorIfails(bg), ...sentinelHard] - // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)→ 子门 green 放行。 + // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 子门 green 放行。 if (behaviorHard.length === 0) { log(`behavior ${id} green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`) return -- libgit2 0.22.2