fix: наставник-хук silent-swallow срыв в видимый degraded plus GUIDE Уроки 7 диагностика наставник не вернулся

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 15:13:28 +03:00
parent 15e217fcb4
commit cd831b872f
5 changed files with 281 additions and 35 deletions
@@ -0,0 +1,96 @@
# Наставник-хук: фикс silent-swallow → видимый degraded (re-trigger) Implementation Plan
**Goal:** Обернуть регион производства вердикта наставника (память кругов + `onPlanWrite`/`onSpecWrite`)
в `try/catch`, чтобы throw в нём возвращал видимый `{ran:true, wired:false}` (degraded) вместо
молчаливого пробрасывания в `catch` main() — план/спека больше не зависают без сигнала.
**Architecture:** Правка только в `tools/enforce-mentor-on-plan-write.mjs` (обе ветки — план и спека).
`planHash`/`specHash` поднимаются выше региона (нужны в `catch` для binding снимка). Успешный путь
байт-в-байт прежний; degraded-контракт (`wired:false`) уже обрабатывается main() штатно (снимок
degraded + decideMentorObjection degraded-блок). Тесты — инъекцией `roundMemoryImpl`, который бросает.
**Tech Stack:** Node ESM, vitest (config `vitest.config.tools.mjs`). TDD.
## Цель
Превратить молчаливый срыв наставника в видимый degraded-вердикт (Уроки №7), чтобы зависший
наставник был ВИДЕН в снимке и перезапускался, а не выглядел вечным «считает». Обе ветки (план и
спека) симметрично.
## Структура файлов
- Изменить (тест): `tools/enforce-mentor-on-plan-write.test.mjs` — 2 теста (план/спека: throw в
регионе → ran:true, wired:false).
- Изменить: `tools/enforce-mentor-on-plan-write.mjs` — guard план-ветки и спека-ветки.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Edit","object":"tools/enforce-mentor-on-plan-write.test.mjs","ref":"m4"},
{"op":"Bash","object":"npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"},
{"op":"Edit","object":"tools/enforce-mentor-on-plan-write.mjs","ref":"m1"},
{"op":"Bash","object":"npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"},
{"op":"Edit","object":"tools/enforce-mentor-on-plan-write.mjs","ref":"m1"},
{"op":"Bash","object":"npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"},
{"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"}
]
```
```verified-context-json
[{"id":"mpc2","kind":"EXTRACTED","ref":"tools/enforce-mentor-on-plan-write.mjs","anchor":"export async function runMentorOnPlanWrite("}]
```
---
### Task 1: тесты «throw в регионе → видимый degraded» (§m4)
- [ ] **Step 1 (Edit `tools/enforce-mentor-on-plan-write.test.mjs`): 2 падающих теста**
В конец describe `runMentorOnPlanWrite` (перед его закрывающим `});`) дописать: план с
`roundMemoryImpl`, который бросает → `{ran:true, wired:false}` + непустой `planHash` + `reason`/срыв;
то же для `specEvent`.
- [ ] **Step 2 (Bash): прогон — тесты падают (RED)**
Run: `npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — сейчас throw в регионе реджектит промис. (Под Claude harness-collapse; авторитет — терминал владельца.)
### Task 2: guard обеих веток (§m1)
- [ ] **Step 3 (Edit `tools/enforce-mentor-on-plan-write.mjs`): guard план-ветки**
`planHash` поднять выше региона; обернуть `roundMemoryP` + `onPlanWrite` в `try/catch`; на throw —
вернуть `{ran:true, ok:false, wired:false, reason:'наставник-путь сорвался: <msg>', taskId:taskIdForPrompt, planHash, verdict:null, routerClassification:null}`.
- [ ] **Step 4 (Bash): прогон — план-тест зелёный, спека-тест ещё красный**
Run: `npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: план-ветка проходит, спека-ветка ещё падает. (Авторитет — терминал владельца.)
- [ ] **Step 5 (Edit `tools/enforce-mentor-on-plan-write.mjs`): guard спека-ветки**
Обернуть `roundMemoryS` + `onSpecWrite` в `try/catch`; на throw — вернуть `{ran:true, ok:false, wired:false, reason:'наставник-путь (спека) сорвался: <msg>', taskId:taskIdForPromptS, planHash:specHash, verdict:null}`.
- [ ] **Step 6 (Bash): прогон — оба теста зелёные (GREEN)**
Run: `npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — оба новых теста + прежние тесты файла без регрессий. (Авторитет — терминал владельца.)
- [ ] **Step 7 (Bash): полная регрессия tools**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база + новые тесты, 0 регрессий. (Под Claude harness-collapse; авторитетный свод — терминал владельца.)
## Self-Review (план против спеки)
- **§m1 guard** — Task 2: обе ветки обёрнуты, `planHash`/`specHash` выше региона для binding в catch,
degraded-возврат `wired:false`. ✓
- **§m2 крайние случаи** — throw в `roundMemoryImpl` и внутри `onPlanWrite`/`onSpecWrite` ловится;
транспортный сбой по-прежнему через `runMentorVerdict` (не трогаем). ✓
- **§m4 критерий** — RED (throw реджектит) → GREEN (degraded) для плана и спеки; прежние тесты целы. ✓
- **DR-1** — мутирующие шаги 1,3,5 сопровождены Bash (2,4,6); шаги 3,5 один файл, между ними Bash (4);
шаг 7 — отдельная полная регрессия. ✓
- **Конвенция §m3** — аддитивный точечный diff, успешный путь не меняется, без новых файлов. ✓
@@ -267,3 +267,58 @@ Verify-шаги под стеной сдвигают указатель, но GR
(`claude-sonnet-4-6``deepseek-v4-pro`, `HEAVY_LLM_TIMEOUT_MS` 90000→300000) ломает 3 теста (`claude-sonnet-4-6``deepseek-v4-pro`, `HEAVY_LLM_TIMEOUT_MS` 90000→300000) ломает 3 теста
(`router-config`, `enforce-mentor-on-plan-write` timeout, `enforce-judge-gate` parse `plan_soundness`) — (`router-config`, `enforce-mentor-on-plan-write` timeout, `enforce-judge-gate` parse `plan_soundness`) —
это пре-существующий дрейф, НЕ регресс твоей задачи. Не путать со своими провалами. это пре-существующий дрейф, НЕ регресс твоей задачи. Не путать со своими провалами.
## Уроки сессии №6 (2026-06-17) — escape-рецепт + verify-gate
- **Метка escape — в LABEL опции, НЕ в описании.** `enforce-askuser-answer-parser` подписывает
ВЫБРАННЫЙ ответ = текст label. Маркер `FLOOR-ESCAPE: <action>` в `description` НЕ подписывается
(проверено вживую: Edit блокнут «разговорный режим», грант не записан). Клади ТОЧНУЮ каноническую
строку прямо в `label`.
- **Один маркер на вопрос; батч = несколько ВОПРОСОВ.** Две метки в одном label (через перенос
строки) НЕ подписываются. «Разные файлы в одном AskUser» = массив `questions[]` (до 4), каждый
вопрос = один грант. Один файл несколько раз = РАЗНЫЕ раунды/вопросы.
- **Грант одноразовый — ВСЕ правки файла ОДНИМ Edit.** `enforce-floor-escape-consume` гасит грант на
первом УСПЕШНОМ Edit; вторая правка того же файла под тем же грантом блокнётся. Делай широкий
`old_string`, охватывающий все изменения, либо запрашивай отдельный грант. Упавший Edit грант НЕ
гасит (можно повторить). Read-перед-Edit обязателен и тут — Grep чтение не засчитывается.
- **`enforce-verify-gate` ≠ escape/override.** На `git commit`/`push` через Claude требует ПОДПИСАННУЮ
`~/.claude/runtime/verify-receipt.json` (fingerprint == staged-diff) от `node tools/produce-verify-receipt.mjs`.
Ни `floor_escape`, ни `ремонт инфраструктуры` его не снимают. А продюсер в claude-brain СЛОМАН (смотрит
на `app/vitest.config.tools.mjs` после сплита → always suite-not-passed, см. `bags/2026-06-17-produce-verify-receipt-app-path-bug.md`)
→ Claude-коммиты тут тупиковы, коммить в терминале владельца (verify-gate — Claude-PreToolUse-хук).
- **`node tools/verdict-wait.mjs` разрешён в стене** как read-only сторож видимости вердиктов
(`isObserveOnly` regex без цепочек/редиректов) — для по-этапного ожидания вердиктов под стеной.
## Уроки сессии №7 (2026-06-17) — диагностика «наставник не вернулся» (молчаливый срыв)
**Симптом.** Написал план, печать не встаёт, режим разговорный. В снимке вердиктов нет записи по
плану (нет 4-го `hash`), наставник/судья по плану не появляются — будто «ещё считает» бесконечно.
**Причина (корневой дефект машинерии).** `main()` в `tools/enforce-mentor-on-plan-write.mjs`
обёрнут в `catch { /* производитель никогда не блокирует */ }`. Любой throw МЕЖДУ вызовом роутера
(`classifyImpl`/`classify`) и LLM-вердиктом наставника проглатывается МОЛЧА — нет вердикта, нет
записи в снимок, нет печати, нет ошибки. Незащищённое место: `renderSkillContext`
([tools/on-plan-write.mjs](../../tools/on-plan-write.mjs) ~стр.71) НЕ в `try`; ранний throw там или
в `runMentorVerdict` до транспорта → всплывает в молчаливый catch. В снимке
**«упало-и-проглотилось» НЕОТЛИЧИМО от «ещё считает»** — в обоих случаев записи просто нет.
**Чем срыв отличается от медленной модели.** В логах вызовов владельца (внешняя панель провайдера):
роутер по плану отработал (есть строка `Agent Router` / `recommend`), а **второго вызова
`Agent mentor` после него НЕТ**. Это срыв, а не латентность. Если доступа к логам нет — считать
«вердикта нет дольше 1-2 циклов ПОСЛЕ того как роутер точно отработал» = вероятный молчаливый срыв.
**Рецепт (что делать — НЕ повторять прокол сессии №7):**
1. **НЕ опрашивать снимок по кругу и НЕ гадать «медленная модель».** «Нет 4-го hash» — это НЕ
диагноз (срыв и счёт выглядят одинаково).
2. **Сразу читать оркестратор** (`enforce-mentor-on-plan-write.mjs` + `on-plan-write.mjs`) — чтение
в разговорном режиме свободно. Помнить про молчаливый catch: он прячет срыв.
3. **Лечение — перезапуск плана НОВЫМ именем файла** (свежий `plan_id` фитилит наставника заново;
`planId` от steps, поэтому при идентичных шагах слегка варьируй один шаг, напр. `--reporter dot`).
Транзиент обычно не повторяется — r2 печатается чисто. Это быстрее, чем ждать/спрашивать владельца.
4. **Опрос снимка полезен ТОЛЬКО чтобы поймать момент печати** (Grep `verdict-snapshot` блокируется
стеной = план опечатан = режим исполнения), но НЕ как диагноз зависания.
**Мета-урок.** При «застряло» — сначала прочитать код пути (средства под рукой в разговорном
режиме), потом действовать. НЕ скатываться в «ждать или спросить владельца», когда можешь сам
диагностировать чтением. Owner видит вердикты в логах — но это не повод не открыть код самому.
@@ -0,0 +1,59 @@
# Наставник-хук: молчаливый срыв в видимый degraded (фикс silent-swallow)
**Дата:** 2026-06-17 · **Статус:** дизайн под реализацию (TDD). Багфикс машинерии переговоров.
## Цель
`main()` в `tools/enforce-mentor-on-plan-write.mjs` обёрнут в `catch {}` («производитель никогда не
блокирует»). Побочка: любой throw в регионе производства вердикта наставника — МЕЖДУ вызовом роутера
и LLM-вердиктом (например `renderSkillContext`, загрузчик памяти кругов, сборка промпта) —
проглатывается молча. Итог: ни вердикта, ни записи в снимок, ни печати, ни сигнала; план тихо не
печатается, а в снимке «упало-и-проглотилось» НЕОТЛИЧИМО от «ещё считает». Цель — превратить такой
срыв в **видимый degraded-вердикт** (`wired:false`), который проходит штатным каналом видимости
(снимок `mentor:plan`/`mentor:spec` = degraded) и блокировки (degraded-сообщение контроллеру), чтобы
зависший наставник был ВИДЕН и перезапускался, а не выглядел вечным «считает».
## Контракт guard'а {#m1}
В `runMentorOnPlanWrite` регион производства вердикта (загрузка памяти кругов + вызов
`onPlanWrite`/`onSpecWrite`) оборачивается в `try/catch`:
- успех — поведение прежнее (возврат `{ran:true, ok, wired, ...}` без изменений);
- throw в регионе — НЕ пробрасывается наружу (раньше всплывал в молчаливый `catch` main()), а
возвращается как `{ran:true, ok:false, wired:false, reason:'наставник-путь сорвался: <msg>',
taskId, planHash|specHash, verdict:null}`.
`wired:false` — это ровно тот degraded-контракт, что уже умеет `main()`: снимок пишет стадию
`degraded`, `decideMentorObjection` отдаёт degraded-блок (recordMentorGo:false, escalation не растёт).
`planHash`/`specHash` доступны в `catch` (считаются ДО региона), поэтому снимок несёт binding.
## Крайние случаи {#m2}
- throw в загрузчике памяти кругов (`roundMemoryImpl`) — degraded, не молчаливый throw.
- throw внутри `onPlanWrite`/`onSpecWrite` до транспорта (`renderSkillContext` и пр.) — degraded.
- сбой ТРАНСПОРТА LLM (сеть/таймаут) — уже ловится внутри `runMentorVerdict``wired:false` штатно;
этот путь не меняется (остаётся degraded, как был).
- оба пути (план и спека) симметричны — guard ставится в обеих ветках.
## Конвенция {#m3}
Минимальный аддитивный diff: обернуть существующий регион в `try/catch`, `planHash`/`specHash`
поднять выше региона (нужны в `catch`). Успешный путь байт-в-байт прежний. Без новых файлов и
зависимостей. Прочая логика `main()` (снимок, счётчик, decideMentorObjection) переиспользуется как
есть — она уже корректно обрабатывает `wired:false`.
## Критерий приёмки {#m4}
TDD-тесты в `tools/enforce-mentor-on-plan-write.test.mjs` (инъекция `roundMemoryImpl`, который
бросает — доступная точка throw в защищаемом регионе):
- план: `roundMemoryImpl` бросает → `runMentorOnPlanWrite` НЕ реджектит, а возвращает
`{ran:true, wired:false}` с непустым `planHash` и `reason` про срыв;
- спека: то же для spec-пути (`specEvent`) → `{ran:true, wired:false}`;
- существующие тесты (сбой транспорта, GO, NO-GO, видимость) — без регрессий.
Полная регрессия tools зелёная: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`.
```verified-context-json
[{"id":"mc1","kind":"EXTRACTED","ref":"tools/enforce-mentor-on-plan-write.mjs","anchor":"производитель никогда не блокирует"}]
```
+51 -35
View File
@@ -135,22 +135,29 @@ export async function runMentorOnPlanWrite(event, {
let graphSectionS = null; let graphSectionS = null;
try { graphSectionS = graphSectionImpl(); } catch { graphSectionS = null; } try { graphSectionS = graphSectionImpl(); } catch { graphSectionS = null; }
const verifiedContextS = parseVerifiedContext(content); const verifiedContextS = parseVerifiedContext(content);
// SP2c-2: память кругов M-side спеки (свои замечания + M-доводы + diff + замечание судьи). // Фикс silent-swallow (Уроки №7): симметрично план-ветке — throw в регионе спеки → видимый
const roundMemoryS = roundMemoryImpl ? roundMemoryImpl({ stage: 'spec', content, taskId: taskIdForPromptS }) : {}; // degraded (wired:false), не молчаливый реджект в catch main().
const rs = await onSpecWrite({ let rs;
specContent: content, try {
specHash, // SP2c-2: память кругов M-side спеки (свои замечания + M-доводы + diff + замечание судьи).
existingTaskId: loadTaskIdImpl(), const roundMemoryS = roundMemoryImpl ? roundMemoryImpl({ stage: 'spec', content, taskId: taskIdForPromptS }) : {};
persistTaskIdImpl, rs = await onSpecWrite({
llmCall, specContent: content,
journalEntries: journalS.entries, specHash,
journalKey, existingTaskId: loadTaskIdImpl(),
nowMs, persistTaskIdImpl,
verifiedContext: verifiedContextS, llmCall,
negotiationLog: negotiationLogS, journalEntries: journalS.entries,
graphSection: graphSectionS, journalKey,
roundMemory: roundMemoryS, nowMs,
}); verifiedContext: verifiedContextS,
negotiationLog: negotiationLogS,
graphSection: graphSectionS,
roundMemory: roundMemoryS,
});
} catch (e) {
return { ran: true, ok: false, wired: false, reason: `наставник-путь (спека) сорвался: ${e && e.message ? e.message : e}`, taskId: taskIdForPromptS, planHash: specHash, verdict: null };
}
try { persistVerdictImpl({ ok: rs.ok, wired: rs.wired, reason: rs.reason ?? null, planHash: specHash, verdict: rs.verdict }); } catch { /* best-effort */ } try { persistVerdictImpl({ ok: rs.ok, wired: rs.wired, reason: rs.reason ?? null, planHash: specHash, verdict: rs.verdict }); } catch { /* best-effort */ }
if (rs.journalOk && rs.journal) { try { persistJournalImpl(rs.journal); } catch { /* best-effort (SE10) */ } } if (rs.journalOk && rs.journal) { try { persistJournalImpl(rs.journal); } catch { /* best-effort (SE10) */ } }
return { ran: true, ok: rs.ok, wired: rs.wired, reason: rs.reason, taskId: rs.taskId, planHash: specHash, verdict: rs.verdict }; return { ran: true, ok: rs.ok, wired: rs.wired, reason: rs.reason, taskId: rs.taskId, planHash: specHash, verdict: rs.verdict };
@@ -177,26 +184,35 @@ export async function runMentorOnPlanWrite(event, {
const planGoal = extractPlanGoal(content); const planGoal = extractPlanGoal(content);
let registry = null; let registry = null;
try { registry = typeof registryImpl === 'function' ? registryImpl() : null; } catch { registry = null; } try { registry = typeof registryImpl === 'function' ? registryImpl() : null; } catch { registry = null; }
// SP2c-2: память кругов M-side плана (свои замечания + M-доводы + diff + замечание судьи).
const roundMemoryP = roundMemoryImpl ? roundMemoryImpl({ stage: 'plan', content, taskId: taskIdForPrompt }) : {};
const r = await onPlanWrite({
planSteps: steps,
existingTaskId: loadTaskIdImpl(),
persistTaskIdImpl,
llmCall,
journalEntries: journal0.entries,
journalKey,
nowMs,
verifiedContext,
negotiationLog,
graphSection,
classifyImpl,
registry,
declaredSkills,
planGoal,
roundMemory: roundMemoryP,
});
const planHash = planId(steps); const planHash = planId(steps);
// Фикс silent-swallow (Уроки №7): срыв В РЕГИОНЕ наставника (renderSkillContext в on-plan-write.mjs
// и пр.) раньше всплывал в молчаливый catch main() → ни вердикта, ни снимка, ни печати, ни сигнала
// (в снимке «упало» неотличимо от «считает»). Теперь любой throw → ВИДИМЫЙ degraded (wired:false):
// снимок mentor:plan=degraded + блок «наставник сорвался» → контроллер видит и перезапускает.
let r;
try {
// SP2c-2: память кругов M-side плана (свои замечания + M-доводы + diff + замечание судьи).
const roundMemoryP = roundMemoryImpl ? roundMemoryImpl({ stage: 'plan', content, taskId: taskIdForPrompt }) : {};
r = await onPlanWrite({
planSteps: steps,
existingTaskId: loadTaskIdImpl(),
persistTaskIdImpl,
llmCall,
journalEntries: journal0.entries,
journalKey,
nowMs,
verifiedContext,
negotiationLog,
graphSection,
classifyImpl,
registry,
declaredSkills,
planGoal,
roundMemory: roundMemoryP,
});
} catch (e) {
return { ran: true, ok: false, wired: false, reason: `наставник-путь сорвался: ${e && e.message ? e.message : e}`, taskId: taskIdForPrompt, planHash, verdict: null, routerClassification: null };
}
try { persistVerdictImpl({ ok: r.ok, wired: r.wired, reason: r.reason ?? null, planHash, verdict: r.verdict }); } catch { /* best-effort */ } try { persistVerdictImpl({ ok: r.ok, wired: r.wired, reason: r.reason ?? null, planHash, verdict: r.verdict }); } catch { /* best-effort */ }
if (r.journalOk && r.journal) { try { persistJournalImpl(r.journal); } catch { /* best-effort (SE10) */ } } if (r.journalOk && r.journal) { try { persistJournalImpl(r.journal); } catch { /* best-effort (SE10) */ } }
return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict, routerClassification: r.routerClassification ?? null }; return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict, routerClassification: r.routerClassification ?? null };
@@ -145,6 +145,26 @@ describe('runMentorOnPlanWrite (обёртка-производитель W7)',
const r = await runMentorOnPlanWrite(planEvent, d); const r = await runMentorOnPlanWrite(planEvent, d);
expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] }); expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] });
}); });
// Фикс silent-swallow (Уроки №7): срыв В РЕГИОНЕ наставника (между роутером и LLM-вердиктом —
// renderSkillContext, загрузчик памяти кругов и пр.) раньше реджектил промис → молчаливый catch
// main() → ни вердикта, ни снимка, ни печати. Теперь → видимый degraded (ran:true, wired:false).
it('срыв в регионе наставника (план, roundMemoryImpl бросил) → ran:true, wired:false (видимый degraded, не реджект)', async () => {
const d = deps({ roundMemoryImpl: () => { throw new Error('boom-в-регионе'); } });
const r = await runMentorOnPlanWrite(planEvent, d);
expect(r.ran).toBe(true);
expect(r.wired).toBe(false);
expect(r.reason).toMatch(/сорвал/i);
expect(typeof r.planHash).toBe('string');
expect(r.planHash.length).toBeGreaterThan(0);
});
it('срыв в регионе наставника (спека, roundMemoryImpl бросил) → ran:true, wired:false', async () => {
const d = deps({ roundMemoryImpl: () => { throw new Error('boom-спека'); } });
const r = await runMentorOnPlanWrite(specEvent, d);
expect(r.ran).toBe(true);
expect(r.wired).toBe(false);
expect(r.reason).toMatch(/сорвал/i);
});
}); });
describe('decideMentorObjection (Фаза 1 — канал замечаний наставника контроллеру)', () => { describe('decideMentorObjection (Фаза 1 — канал замечаний наставника контроллеру)', () => {