fix(track-b): надёжность вердиктов — флап наставника + видимый degraded при срыве захода судьи/gate3

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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 17:16:18 +03:00
parent ba584a8335
commit 89367986f2
6 changed files with 89 additions and 9 deletions
+17 -5
View File
@@ -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);
+27
View File
@@ -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);
});
});
+5 -1
View File
@@ -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 */ }
+13
View File
@@ -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)', () => {
+6 -3
View File
@@ -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');
+21
View File
@@ -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: [] } });