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