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:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user