fix: NO-GO наставника стирает прежнее «да» (стейл-mentor-GO)

Судья мог судить/печатать план, который наставник завернул: mentor-GO привязан к plan_hash =
planId(steps) (только шаги), пишется ТОЛЬКО на GO и НЕ стирался на NO-GO. При идентичных steps
(менялся лишь текст плана) старое «да» переживало смену содержания — судья находил устаревшее
одобрение (mentor-go-store::mentorGoValidFor по plan_hash) и проходил mentorApproved-гейт несмотря
на свежий NO-GO наставника. Вскрыто живым прогоном (план опечатался при mentor NO-GO + judge GO).
Фикс: clearMentorGo стирает запись; enforce-mentor-on-plan-write на реальном NO-GO (blocked) её
зовёт (degraded не трогаем — verdict неизвестен). Инвариант: «да» наставника живёт ⟺ последний
проход одобрил. Свод 4376 зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 22:00:24 +03:00
parent d669a6bcb5
commit b978738be6
3 changed files with 41 additions and 1 deletions
+6 -1
View File
@@ -32,7 +32,7 @@ import { parseNegotiationSection, arbitrationRequested } from './negotiation-sec
import { bumpMentorNoGo, MENTOR_ESCALATE_AFTER } from './mentor-nogo-counter.mjs';
// Способ B (Фаза 2): наставник НЕ печатает — на GO лишь записывает подписанное одобрение
// (mentor-GO, привязка к plan_hash). Печать делает судья (хук ПОСЛЕ наставника) при валидном mentor-GO.
import { buildMentorGo, persistMentorGo } from './mentor-go-store.mjs';
import { buildMentorGo, persistMentorGo, clearMentorGo } from './mentor-go-store.mjs';
import { classifyJudgeOutcome } from './verdict-outcome-line.mjs';
import { pushVerdict } from './verdict-surface-store.mjs';
// SP2c-2: загрузчик памяти кругов M-side (свои замечания + M-доводы + diff + замечание судьи
@@ -298,6 +298,11 @@ async function main() {
sessionId: sess, runtimeDir: dir,
});
} catch { /* best-effort: нет записи → судья просто не запечатает (fail-safe) */ }
} else if (blocked) {
// Стейл-mentor-GO fix: реальный NO-GO наставника СТИРАЕТ прежнее «да» — иначе судья
// находит устаревшее одобрение (тот же plan_hash при идентичных steps) и судит/печатает
// план, который наставник завернул. degraded (wired:false) не трогаем (verdict неизвестен).
try { clearMentorGo({ sessionId: sess, runtimeDir: dir }); } catch { /* best-effort */ }
}
if (decision.block) {
exitDecision(decision); // exit 2 со stderr-сообщением (замечание/degraded)
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { buildMentorGo, persistMentorGo, loadMentorGo, clearMentorGo } from './mentor-go-store.mjs';
function memFs() {
const s = new Map();
return { s,
writeFileSync: (p, d) => s.set(String(p), d),
renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); },
readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
unlinkSync: (p) => { if (!s.has(String(p))) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } s.delete(String(p)); },
};
}
const KEY = 'mgk'; const DIR = '/rt'; const SESS = 's1';
describe('clearMentorGo — реальный NO-GO стирает прежнее «да» наставника (стейл-fix)', () => {
it('после persist + clear загрузка возвращает null', () => {
const fs = memFs();
persistMentorGo({ record: buildMentorGo({ planHash: 'H', key: KEY }), sessionId: SESS, runtimeDir: DIR, fsImpl: fs });
expect(loadMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs })).not.toBe(null);
clearMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs });
expect(loadMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs })).toBe(null);
});
it('clear на отсутствующем файле не бросает (no-op)', () => {
const fs = memFs();
expect(() => clearMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs })).not.toThrow();
});
});
+8
View File
@@ -48,3 +48,11 @@ export function loadMentorGo({ sessionId, runtimeDir, fsImpl = fsDefault }) {
try { return JSON.parse(fsImpl.readFileSync(mentorGoPath(runtimeDir, sessionId), 'utf8')); }
catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
/** Стереть одобрение наставника. Реальный NO-GO наставника обязан стирать прежнее «да» —
* иначе судья находит УСТАРЕВШЕЕ одобрение (тот же plan_hash при идентичных steps, т.к.
* binding = planId(steps)) и судит/печатает план, который наставник завернул. Нет файла → no-op. */
export function clearMentorGo({ sessionId, runtimeDir, fsImpl = fsDefault }) {
try { fsImpl.unlinkSync(mentorGoPath(runtimeDir, sessionId)); }
catch (e) { if (e && e.code !== 'ENOENT') throw e; }
}