From 89367986f2bfcc0be0983063ea572bff3be51bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 17 Jun 2026 17:16:18 +0300 Subject: [PATCH] =?UTF-8?q?fix(track-b):=20=D0=BD=D0=B0=D0=B4=D1=91=D0=B6?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=20=E2=80=94=20=D1=84=D0=BB=D0=B0=D0=BF?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D0=B0=D0=B2=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20+=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D0=B9=20degraded=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D1=80=D1=8B=D0=B2=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=85=D0=BE=D0=B4=D0=B0=20=D1=81=D1=83=D0=B4=D1=8C=D0=B8?= =?UTF-8?q?/gate3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) validateMentorVerdict: recommendation обязателен только на NO-GO (положительный GO с пустым слотом больше не заворачивается). 2) runJudgeTurn: срыв runJudgeGate -> видимый degraded вместо слепого возврата. 3) produceGate3Verdict: срыв захода/построения -> видимый degraded вместо немого fail-OPEN. TDD-тесты добавлены; vitest в worktree сломан средой, логика проверена через node. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/enforce-gate3-loop.mjs | 22 +++++++++++++++++----- tools/enforce-gate3-loop.test.mjs | 27 +++++++++++++++++++++++++++ tools/enforce-judge-gate.mjs | 6 +++++- tools/enforce-judge-gate.test.mjs | 13 +++++++++++++ tools/mentor-verdict.mjs | 9 ++++++--- tools/mentor-verdict.test.mjs | 21 +++++++++++++++++++++ 6 files changed, 89 insertions(+), 9 deletions(-) diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs index 4a74a1f..62500d4 100644 --- a/tools/enforce-gate3-loop.mjs +++ b/tools/enforce-gate3-loop.mjs @@ -78,6 +78,21 @@ export function gate3SurfaceRecord({ verdict, hash } = {}) { return { stage: 'judge:gate3', hash: hash || null, status, reason: (verdict && verdict.reason) || '' }; } +/** + * Производитель gate3-вердикта (зеркало cd831b8 / runMentorVerdict): нет ключа/захода → degraded; + * исключение в построении продукта (buildProduct) ИЛИ в заходе судьи (callJudge) → ВИДИМЫЙ degraded + * (wired:false, unavailable, cause), а НЕ проброс наверх — там немой fail-OPEN catch main() тихо + * разблокировал бы конец хода без записи стадии и без причины. Чистая (без IO): buildProduct инъектируется. + */ +export async function produceGate3Verdict({ judgeKey, callJudge, buildProduct }) { + if (!(judgeKey && callJudge)) return { wired: false, decision: 'GO', unavailable: true }; + try { + return await callJudge(buildProduct()); + } catch (e) { + return { wired: false, decision: 'GO', unavailable: true, cause: `судья gate-3 сорвался: ${String((e && e.message) || e).slice(0, 200)}` }; + } +} + /** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */ export async function runGate3Stop(event, deps) { const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps; @@ -93,11 +108,8 @@ export async function runGate3Stop(event, deps) { let verdict = cache.verdict; let noGoCount = cache.noGoCount || 0; if (cache.fingerprint !== fingerprint) { - if (judgeKey && callJudge) { - verdict = await callJudge(buildGate3ProductFromMarker({ marker, frozenArtifact, greens })); - } else { - verdict = { wired: false, decision: 'GO', unavailable: true }; - } + // Срыв построения продукта/захода → видимый degraded (не немой fail-open в main()). + verdict = await produceGate3Verdict({ judgeKey, callJudge, buildProduct: () => buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) }); const isContentNoGo = !!verdict && verdict.wired === true && verdict.decision !== 'GO'; const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO'; noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount); diff --git a/tools/enforce-gate3-loop.test.mjs b/tools/enforce-gate3-loop.test.mjs index bfb0bc7..05480c9 100644 --- a/tools/enforce-gate3-loop.test.mjs +++ b/tools/enforce-gate3-loop.test.mjs @@ -122,3 +122,30 @@ describe('loop marker delivery', () => { expect(verifyLoopMarker({ ...m, delivery: 'internal' }, KEY)).toBe(false); }); }); + +import { produceGate3Verdict } from './enforce-gate3-loop.mjs'; + +describe('produceGate3Verdict (видимость срыва gate3 — фикс silent-swallow)', () => { + it('нет ключа судьи → degraded (wired:false, unavailable), без cause', async () => { + const r = await produceGate3Verdict({ judgeKey: null, callJudge: async () => ({ wired: true, decision: 'GO' }), buildProduct: () => ({}) }); + expect(r.wired).toBe(false); + expect(r.unavailable).toBe(true); + }); + it('callJudge бросил → ВИДИМЫЙ degraded с непустым cause (не молчит)', async () => { + const r = await produceGate3Verdict({ judgeKey: 'K', callJudge: async () => { throw new Error('boom'); }, buildProduct: () => ({}) }); + expect(r.wired).toBe(false); + expect(r.unavailable).toBe(true); + expect(typeof r.cause).toBe('string'); + expect(r.cause.length).toBeGreaterThan(0); + }); + it('buildProduct бросил → degraded (срыв построения продукта тоже виден)', async () => { + const r = await produceGate3Verdict({ judgeKey: 'K', callJudge: async () => ({ wired: true, decision: 'GO' }), buildProduct: () => { throw new Error('bad marker'); } }); + expect(r.wired).toBe(false); + expect(r.unavailable).toBe(true); + }); + it('заход вернул вердикт → проброс без искажения', async () => { + const v = { wired: true, decision: 'NO-GO', reason: 'не достигнуто' }; + const r = await produceGate3Verdict({ judgeKey: 'K', callJudge: async () => v, buildProduct: () => ({ goal: 'g' }) }); + expect(r).toEqual(v); + }); +}); diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index c2a32d8..560659c 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -298,8 +298,12 @@ export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warn const seal = (fields) => { if (judged) { try { sealLogImpl(buildSealEntry({ ...fields, nowMs })); } catch { /* best-effort */ } } }; if (mode === 'inert') { seal({ judgeActive: false }); return { block: false }; } let verdict; + // Фикс silent-swallow (зеркало cd831b8): throw в производстве вердикта (runJudgeGate) раньше + // молча возвращал { block } без записи стадии и без причины — в снимке «упало» неотличимо от + // «ещё считает». Теперь throw → ВИДИМЫЙ degraded (wired:false, unavailable, cause): идёт общим + // degraded-путём ниже (warnImpl + снимок judge=degraded + degraded-блок с причиной в live-block). try { verdict = await runJudgeGate(event, deps); } - catch { seal({ wired: false, decision: null }); return { block: mode === 'live-block' }; } + catch (e) { verdict = { decision: 'GO', wired: false, unavailable: true, cause: `судья сорвался: ${String((e && e.message) || e).slice(0, 200)}` }; } let sealResult = null; if (verdict && verdict.wired) { try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ } diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs index 165a258..5dd5320 100644 --- a/tools/enforce-judge-gate.test.mjs +++ b/tools/enforce-judge-gate.test.mjs @@ -350,6 +350,19 @@ describe('runJudgeTurn — режим-aware (Δ-D inert/shadow/live-block, бе expect(r.block).toBe(false); expect(warned).toBe(1); }); + it('live-block + заход судьи бросил → ВИДИМЫЙ degraded (block + degraded), предупреждение вызвано (фикс silent-swallow)', async () => { + let warned = 0; + const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => { throw new Error('boom'); }, logImpl: () => {}, warnImpl: () => { warned++; } }); + expect(r.block).toBe(true); + expect(r.degraded).toBe(true); + expect(warned).toBe(1); + }); + it('shadow + заход судьи бросил → allow (D28), но предупреждение вызвано (срыв виден, не нем)', async () => { + let warned = 0; + const r = await runJudgeTurn(planEv(), { mode: 'shadow', judgeActiveImpl: () => { throw new Error('boom'); }, warnImpl: () => { warned++; } }); + expect(r.block).toBe(false); + expect(warned).toBe(1); + }); }); describe('sealed-plan production Task 5 — seal on wired GO (SPEC_PATH_RE + sealOnWiredGo)', () => { diff --git a/tools/mentor-verdict.mjs b/tools/mentor-verdict.mjs index 7e34733..f89da00 100644 --- a/tools/mentor-verdict.mjs +++ b/tools/mentor-verdict.mjs @@ -20,9 +20,12 @@ export function validateMentorVerdict(verdict) { // формально «непустой массив», но субстанции нет (R2-VA-meta presence ≠ substance). if (!Array.isArray(verdict.plan_points_addressed) || verdict.plan_points_addressed.length === 0 || !verdict.plan_points_addressed.every((p) => typeof p === 'string' && p.trim())) missingSlots.push('plan_points_addressed'); - for (const slot of ['reasoning', 'recommendation']) { - if (typeof verdict[slot] !== 'string' || !verdict[slot].trim()) missingSlots.push(slot); - } + // reasoning — обязателен ВСЕГДА (разбор по существу нужен и на GO, и на NO-GO). + if (typeof verdict.reasoning !== 'string' || !verdict.reasoning.trim()) missingSlots.push('reasoning'); + // recommendation = «что править» — обязателен ТОЛЬКО на NO-GO; при decision='GO' (положительный + // разбор) чинить нечего → слот пуст по смыслу, валидатор НЕ заворачивает (фикс ложного флапа + // «несодержательный вердикт: пустые слоты [recommendation]» при содержательном GO). + if (verdict.decision === 'NO-GO' && (typeof verdict.recommendation !== 'string' || !verdict.recommendation.trim())) missingSlots.push('recommendation'); if (typeof verdict.confidence !== 'number' || !Number.isFinite(verdict.confidence) || verdict.confidence < 0 || verdict.confidence > 1) missingSlots.push('confidence'); // decision (Р7/мерж): явная кнопка GO/NO-GO — только {GO, NO-GO}; иначе вердикт несодержателен. if (verdict.decision !== 'GO' && verdict.decision !== 'NO-GO') missingSlots.push('decision'); diff --git a/tools/mentor-verdict.test.mjs b/tools/mentor-verdict.test.mjs index 49bf75f..b290937 100644 --- a/tools/mentor-verdict.test.mjs +++ b/tools/mentor-verdict.test.mjs @@ -78,6 +78,27 @@ describe('validateMentorVerdict — decision (Р7/мерж)', () => { }); }); +describe('флап-фикс: recommendation обязателен только на NO-GO (положительный GO не заворачивать)', () => { + const base = { plan_points_addressed: ['п1 ок'], reasoning: 'r', confidence: 0.9 }; + it('GO + пустой recommendation → ok (на положительном разборе чинить нечего)', () => { + expect(validateMentorVerdict({ ...base, recommendation: '', decision: 'GO' }).ok).toBe(true); + }); + it('GO + отсутствует recommendation → ok', () => { + expect(validateMentorVerdict({ ...base, decision: 'GO' }).ok).toBe(true); + }); + it('NO-GO + пустой recommendation → не ok, missingSlots несёт recommendation', () => { + const r = validateMentorVerdict({ ...base, recommendation: '', decision: 'NO-GO' }); + expect(r.ok).toBe(false); + expect(r.missingSlots).toContain('recommendation'); + }); + it('NO-GO + непустой recommendation → ok (регресс не сломан)', () => { + expect(validateMentorVerdict({ ...base, recommendation: 'править шаг 2', decision: 'NO-GO' }).ok).toBe(true); + }); + it('substance: GO + пустой recommendation + wired:true → содержателен', () => { + expect(isMentorVerdictSubstantive({ ...base, recommendation: '', decision: 'GO' }, { wired: true })).toBe(true); + }); +}); + describe('промпты просят decision (Р7/мерж)', () => { it('промпт плана требует decision GO/NO-GO', () => { const p = buildMentorVerdictPrompt({ plan: { steps: [] } });