fix: §3.4 десинк — печать снимается ЛЕНИВО (criterion-gate видит план на пуше)

Баг: на ПОСЛЕДНЕМ шаге плана supreme-gate (PreToolUse) снимал печать синхронно
ДО того, как со-хук criterion-gate (PreToolUse, в settings.json ПОЗЖЕ) успевал
проверить пуш → criterion видел «нет плана» и ложно блокировал код-пуш.

Фикс (правка только enforce-supreme-gate.mjs runGate):
- на planComplete печать БОЛЬШЕ НЕ снимается синхронно (указатель за конец +
  метка петли E-S1 остаются) → план жив для co-хуков в этом же tool-call;
- печать снимается ЛЕНИВО на СЛЕДУЮЩЕМ действии (ветка в начале runGate: если
  committed-указатель за последним листом и steps — непустой массив → removeFrozenPlan
  + разговорный режим). Гард steps на массив: минимальные/legacy-планы без steps
  не считаются «исчерпанными».

Строго лучше прежнего: чинит ложный блок код-пуша последним шагом (зелёные критерии),
случай реального провала критерия не ухудшен. Спека §3.4.

+2 теста (ленивое снятие + репро «печать не снята синхронно»), свод 4320 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 14:49:33 +03:00
parent 6bc0f6d040
commit cdcaf610a0
2 changed files with 38 additions and 8 deletions
+18 -4
View File
@@ -438,6 +438,20 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
const toolUse = { name: event.tool_name, input: event.tool_input };
const incomingAction = actionOf(toolUse);
// §3.4 (десинк fix, ленивое завершение Фазы 5): план был доведён до конца на ПРОШЛОМ действии
// (committed-указатель за последним листом), но печать НЕ снималась синхронно на последнем шаге —
// чтобы со-хук criterion-gate (PreToolUse ПОСЛЕ supreme) увидел валидный план на код-пуше и не дал
// ложный блок. Снимаем печать ЛЕНИВО ЗДЕСЬ → текущее действие идёт в разговорном режиме (frozenPlan
// обнулён). Снятие best-effort (сбой не ломает). Указатель за концом резолвится в null-лист.
if (frozenPlan && Array.isArray(frozenPlan.steps) && frozenPlan.steps.length > 0 && (!verifyImpl || verifyImpl(frozenPlan, key))) {
let exhausted = false;
try { exhausted = treeLeafAt(frozenPlan.steps, stepPtr) == null; } catch { exhausted = false; }
if (exhausted) {
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
frozenPlan = null; // далее — разговорный режим (печать снята)
tentativeToPtr = null;
}
}
// F-J: двухтактный сдвиг. Сверить открытую ПРЕДВАРИТЕЛЬНУЮ пометку с входным действием ДО
// решения: commit (= шаг по toPtr → прошлый исполнился) / discard (= повтор шага → прошлый был
// заблокирован, не исполнился) / hold / none. Решение принимается по эффективному указателю.
@@ -477,12 +491,12 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeT
return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' };
}
if (r.planComplete) {
// Последний шаг: ранний сдвиг + снятие печати немедленно (mid-plan-клина нет — следующего
// шага не существует; блок последнего шага со-хуком отдаёт участника в разговорный режим,
// не в тупик). Best-effort: сбой снятия НЕ ломает allow.
// Последний шаг: ранний сдвиг указателя за конец + метка петли (E-S1). Печать НЕ снимаем
// здесь (§3.4): синхронное снятие на последнем шаге опережало co-хук criterion-gate (он видел
// «нет плана» → ложный блок код-пуша). Печать снимается ЛЕНИВО на СЛЕДУЮЩЕМ действии (ветка
// ленивого завершения в начале runGate, указатель за концом) → план жив для co-хуков сейчас.
saveStep(r.advanceTo, null);
if (typeof writeLoopOpen === 'function') { try { writeLoopOpen(); } catch { /* E-S1: сбой метки не ломает завершение */ } }
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
} else {
// F-J: ПРЕДВАРИТЕЛЬНАЯ пометка вместо немедленного сдвига. Committed-указатель остаётся на
// текущем шаге; toPtr подтвердится следующим действием (commit) либо сбросится (discard).
+20 -4
View File
@@ -865,16 +865,32 @@ describe('Фаза 5 — чистое завершение плана (стен
expect(r.decision).toBe('allow');
expect(r.planComplete).toBe(false);
});
it('runGate: последний шаг выполнен → removeFrozenPlan вызван (печать снята → разговорный)', () => {
let removed = 0;
// §3.4 (десинк fix): на ПОСЛЕДНЕМ шаге печать НЕ снимается синхронно в этом же вызове —
// иначе со-хук criterion-gate (PreToolUse ПОСЛЕ supreme) увидит «нет плана» и ложно заблокирует
// код-пуш. Печать снимается ЛЕНИВО на следующем действии (указатель за концом). Указатель сдвигается.
it('runGate: последний шаг выполнен → removeFrozenPlan НЕ вызван синхронно (ленивое снятие, §3.4)', () => {
let removed = 0; const saved = [];
const r = runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; },
journal: () => true, saveStep: (p) => saved.push(p), removeFrozenPlan: () => { removed++; },
});
expect(r.block).toBe(false);
expect(removed).toBe(1);
expect(removed).toBe(0); // НЕ снято синхронно — план жив для criterion-gate
expect(saved).toContain(1); // указатель всё равно сдвинут за конец (advanceTo=1)
});
it('runGate: ленивое снятие — указатель уже за концом → removeFrozenPlan + разговорный (§3.4)', () => {
let removed = 0;
const r = runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/other.mjs' } },
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 1, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; },
});
expect(removed).toBe(1); // план исчерпан на прошлом действии → снят лениво сейчас
expect(r.block).toBe(true); // действие вне плана → разговорный default-deny (мутатор)
expect(r.message).toMatch(/разговорн|нет замороженного плана/i);
});
it('runGate: НЕ последний шаг → removeFrozenPlan НЕ вызван (печать держится)', () => {
let removed = 0;