docs(secretary): спека подпроекта B — фоновый воркер (всё в фоне, очередь-один-писатель)

This commit is contained in:
Дмитрий
2026-06-25 13:16:40 +03:00
parent ac76e8096d
commit 8bdcb77444
@@ -0,0 +1,196 @@
# Спека: секретарь — фоновый воркер (подпроект B)
**Дата:** 2026-06-25 · **Статус:** дизайн утверждён владельцем, готов к writing-plans
**Источник дизайна:** `docs/secretary/протокол-наставника/прогон/НАХОДКИ.md` (раздел «ДИЗАЙН-РЕШЕНИЯ
25.06») + брейншторм-сессия 25.06 (этот документ её фиксирует и **правит решение №1**).
Опирается на подпроекты A (`2026-06-25-secretary-pipeline-A-design.md`) и C
(`2026-06-25-secretary-render-C-design.md`) — оба уже влиты в main.
## 0. Поправка к НАХОДКАМ (решение №1 переписано)
НАХОДКИ, решение №1, формулировали **гибрид**: «ствол на критическом пути (синхронно), ветки в фон».
В брейншторме 25.06 владелец потребовал **15-минутные таймауты для ВСЕХ ролей** (не только брейншторма)
и **снятие всех лимитов ввода/вывода**. Синхронный ствол этого не выдержит: stop-хук имеет харнесс-стену
**900с (= 15 мин)** (`.claude/settings.json`), и тяжёлая модель на стволе упёрлась бы в неё ровно так, как
владелец уже трижды налетал. Поэтому решение №1 заменено на:
> **Всё в фоне.** Ни одна роль не бежит под 900с-стеной хука. Stop-хук не зовёт LLM и не пишет протокол.
> Весь разбор (ствол + ветки + садовник) делает фон-воркер; он же — единственный писатель протокола.
Остальные 7 решений НАХОДОК сохраняются. Это **упрощает** архитектуру: «почтовый ящик» (был нужен, чтобы
развести двух писателей в гибриде) **больше не нужен** — писатель один (воркер).
## 1. Контекст и цель
Подпроект A собрал боевой конвейер «пушистого дерева» (редактор → ствол → ветки 1.1∥1.2→1.3 → садовник →
применение), C — рендер. Оба за флагом `SECRETARY_FLUFFY` (выключен). Но конвейер тяжёлый: брейншторм на
флэше 67–97с, а владелец будет ставить сильные модели (5 per-role слотов) и кормить полный research из
Перплексити. Синхронно в stop-хуке это упирается в 900с-стену и теряет протокол.
**Цель B:** вынести **весь** разбор в фоновый процесс с очередью-одним-работником, чтобы (а) ход владельца
возвращался мгновенно, (б) тяжёлые модели и полный вход работали без таймаут-стены, (в) параллельные темы
не мешали друг другу, (г) включить флаг и оживить конвейер A+C на боевом секретаре.
## 2. Объём
**Входит в B:**
- Очередь/замок над `docs/secretary/<тема>/_worker/` (очередь спанов, файл-замок с пульсом).
- Фон-процесс `secretary-worker.mjs` — единственный писатель протокола; разбирает спаны по очереди.
- «Выстрел» воркера из stop-хука (detached spawn, Windows-safe).
- Переразводка stop-хука: **никакого LLM в хуке**; только сырьё Слой 1 + постановка спанов в очередь +
выстрел воркера. Догон мёртвых сессий — тоже через очередь.
- Снятие лимитов ввода/вывода (обрезка 4000 знаков → ручка, дефолт «полный»; выход — max_tokens модели).
- Таймауты 900с/роль + повторы; мягкая деградация при превышении окна модели.
- Безопасная к параллели запись оглавления `содержание.md`.
- Включение флага `SECRETARY_FLUFFY=1` (финальный шаг, с согласия владельца).
- Тесты на всё перечисленное (TDD-гейт), включая ранее непроверенную механику (решение №8).
**НЕ входит (YAGNI):**
- «Режим разбора» — разговорный, через готовые функции A (`applyTend`/`applyResults`); нового кода нет.
- Демон/служба ОС. Воркер — короткоживущий процесс, запускается выстрелом из хука, умирает по опустошении
очереди. Никаких постоянно висящих процессов.
- Версии протокола. Один файл, один писатель (см. решение №2).
## 3. Архитектура
### 3.1 Поток данных
**Stop-хук (синхронно, мгновенно, БЕЗ единого LLM-вызова):**
1. Пишет сырьё Слой 1 из всего транскрипта (как сегодня — переживает обрывы, PII вырезается).
2. Если тема включена/закрывается: вычисляет границы закрытых спанов (структурно из сырья, как сегодня),
ставит новые закрытые спаны (+ их `note` об обрыве) в очередь `_worker/queue.json`.
3. Если есть `catchUp` (хвосты мёртвых сессий) — ставит их спаны в ту же очередь.
4. «Выстреливает» воркера (`secretary-spawn.mjs`) и завершается. **Ход владельца вернулся за миллисекунды.**
Хук **никогда не пишет** `protocol.json` и **не зовёт модель**.
**Фон-воркер `secretary-worker.mjs <папка-темы>` (вне харнесс-таймаута):**
1. `acquireLock(workDir)` — замок темы занят живым воркером? → немедленный выход (тот добьёт очередь).
2. Цикл, пока очередь не пуста:
- `dequeue` — следующий спан по порядку;
- редактор: `reconcileTurn` (ствол) → merge → `preserveRegistry`;
- ветки: сбор 1.1∥1.2 → 1.3 брейншторм → садовник (последовательно, решение №3);
- `applyResults``collapseProtocol`;
- **записывает** `protocol.json` (атомарно) + `renderDoc``protocol.md` + обновляет `содержание.md`;
- обновляет курсор темы и пульс замка.
3. `releaseLock`, выход.
Воркер = редактор = **единственный писатель** (решение №2 цело: писатель один, потому что воркер один).
### 3.2 Ритм обновления
На каждый спан — **одно обновление протокола**, где ствол и ветки этого спана приезжают вместе (воркер
пишет файл один раз в конце разбора спана). Протокол **догоняющий**: показывает разобранное на текущий
момент, последние спаны досчитываются в фоне. Лаг принят владельцем; протокол смотрится на разборе
(середина/конец диалога), не ежеходно.
### 3.3 Очередь, замок, пульс (модуль `secretary-queue.mjs`)
Над `docs/secretary/<тема>/_worker/`:
- `queue.json` — массив заданий `{ span:{start,end,index,note}, kind:'span'|'catchup', enqueuedAt }`.
`enqueue` дозаписывает; `dequeue` атомарно снимает первое (read-modify-write через `writeFileAtomic`).
Дедуп по `index` — повторная постановка того же спана не плодит дублей.
- `lock``{ pid, startedAt, beat }`. `acquireLock` создаёт, если файла нет ИЛИ пульс протух
(`now - beat > STALE_MS`, по умолчанию 30с). Живой замок (свежий пульс) → отказ.
- Пульс: воркер раз в `BEAT_MS` (~10с) обновляет `beat`. Так **медленная модель ≠ мёртвый воркер**
долгая честная работа держит пульс, замок не перехватывают. `STALE_MS` заведомо > `BEAT_MS`×3.
- `releaseLock` — удаляет `lock`.
`STALE_MS`/`BEAT_MS` — константы модуля, перебиваются env (для тестов и тюнинга).
### 3.4 Выстрел воркера (модуль `secretary-spawn.mjs`)
`spawnWorker(workDir, { spawnImpl = spawn } = {})`
`spawnImpl(process.execPath, [workerPath, workDir], { detached: true, stdio: 'ignore' }).unref()`.
Detached + `unref` → процесс переживает выход хука, хук его не ждёт. `spawnImpl` инъектируется для теста.
Идемпотентность по факту: если воркер уже крутится, новый процесс упрётся в замок и выйдет (см. 3.3) —
лишний выстрел безвреден.
### 3.5 Разрез distill (правка `secretary-distill.mjs`)
`distillSpan` сегодня делает ствол **и** ветки в одной функции. Для B логика остаётся та же (воркер зовёт
её целиком — ему таймаут-стена не грозит), но выносится **детерминированная запись наружу**: воркер сам
пишет файл после `distillSpan`. Никакого деления на «trunk sync / branches async» (отменено вместе с
гибридом). `distillSpan` остаётся единой точкой разбора одного спана — общий код для воркера и (теоретич.)
пересборки. Меняется только то, **кто** и **где** её зовёт: раньше — хук синхронно, теперь — воркер в фоне.
### 3.6 Лимиты и таймауты
- **Вход без обрезки:** `renderExchangeText` (`secretary-harvest.mjs:92`) — `slice(0,4000)` заменить на
ручку `SECRETARY_INPUT_CAP` (по умолчанию `Infinity` = полный обмен, включая весь research). Текст
ассистента — тоже под ту же ручку (сейчас не клампится — формально цел, но ручка распространяется на всё).
- **Выход максимум:** `max_tokens` вызова = максимум модели; ручка `SECRETARY_OUTPUT_MAX` (env).
- **Таймаут вызова:** `perAttemptTimeoutMs` по умолчанию **900_000** (15 мин) всем ролям; `maxRetries` 2
(ловит Windows-TLS-handshake). Ручки env: `SECRETARY_LLM_TIMEOUT_MS`, `SECRETARY_LLM_RETRIES`.
- **Жёсткий потолок только один — окно контекста модели.** Полный research его превысил → отказ API →
мягкая деградация: роль помечается «не досчитана: вход больше окна, кусок N» (в `diag` + видимо в
протоколе как пустая ветка с пометкой), спан закрывается, протокол цел, остальные роли отрабатывают.
### 3.7 Параллельные темы и оглавление
- Две темы — два независимых `_worker/`, два замка, два воркера → честная параллель, не пересекаются.
- `содержание.md` пишет воркер (раньше — хук). Общий файл → запись под `содержание.lock` (тот же
пульс-замок, отдельный файл рядом с оглавлением) вокруг read-modify-write `upsertIndexEntry`. Upsert
по ключу-теме идемпотентен → строчки тем не затирают друг друга. Модуль `secretary-index.mjs` получает
блокирующую обёртку записи (или это делает воркер через `secretary-queue`-замок — решить в плане).
### 3.8 Включение флага
`SECRETARY_FLUFFY=1` оживляет конвейер A+C. Финальный шаг B, **с явного согласия владельца**. Способ
(env-блок `.claude/settings.json` против рантайм-файла) — определить в плане; рекомендация: рантайм-файл
`~/.claude/runtime/secretary-fluffy` для тогглинга без правки enforcement-чувствительного settings.json
(тогда `fluffyPipelineOn` читает и env, и файл). До включения живой секретарь не тронут.
## 4. Компоненты (карта файлов)
**Создать:**
- `tools/secretary-queue.mjs``enqueue`/`dequeue`/`acquireLock`/`releaseLock`/`beat`/`isLocked`.
- `tools/secretary-worker.mjs` — тонкая CLI-обёртка над `runWorker(workDir, {callModel, now, spawnImpl})`.
- `tools/secretary-spawn.mjs``spawnWorker(workDir, {spawnImpl})`.
- Тест-файлы `*.test.mjs` к каждому.
**Править:**
- `tools/secretary-stop-hook.mjs` — выкинуть синхронный разбор/запись протокола; оставить сырьё +
постановку спанов (вкл. catchUp) в очередь + выстрел воркера. Курсор темы двигает **воркер**, не хук
(или хук ставит, воркер подтверждает — решить в плане так, чтобы не было гонки курсора).
- `tools/secretary-harvest.mjs``renderExchangeText` cap → ручка `SECRETARY_INPUT_CAP` (дефолт полный).
- `tools/secretary-index.mjs` — запись оглавления под замок (безопасность параллельных тем).
- `tools/secretary-flag.mjs``fluffyPipelineOn` читает и рантайм-файл (для тогглинга), если выбран он.
- `.gitignore``docs/secretary/*/_worker/` (очередь/замок — рабочий мусор).
## 5. Тестирование (TDD)
- **Очередь:** enqueue→dequeue по порядку; дедуп по index; пустая очередь.
- **Замок:** второй `acquireLock` при живом пульсе — отказ; протухший пульс — перехват; `beat` держит
живого; `releaseLock` чистит.
- **Воркер (`runWorker` как чистая функция):** фейковый `callModel` + фейковые часы → разбирает спаны по
порядку, пишет протокол по одному обновлению на спан, держит пульс, по опустошении выходит и снимает
замок. Деградация: `callModel`, кидающий «context window» → роль помечена «не досчитана», протокол цел.
- **Выстрел:** фейковый `spawnImpl` → detached:true, unref вызван, аргументы `[worker, workDir]`.
- **Оглавление:** два «параллельных» upsert под замком не теряют строк.
- **Stop-хук:** на закрытом спане — ставит в очередь + стреляет (фейковый spawn), LLM не зовёт, протокол
не пишет; catchUp-спаны мёртвой сессии попадают в очередь.
- **Лимиты:** `SECRETARY_INPUT_CAP=Infinity` → полный обмен в промпте; конечная ручка → обрезка с пометкой.
- **Непроверенная механика (решение №8), каждое — кейс на воркере:**
- **Гравитация** — кандидат поднимается в ствол, когда владелец заговорил об идее (вход содержит её).
- **Reopen / обратный каскад** — смена несущего решения переоткрывает авто-закрытое.
- **Сжатие длинной истории** — на длинном протоколе старое сворачивается/сжимается без потери корня.
## 6. Коэкзистенция и риски
- Флаг ВЫКЛ (до §3.8) → секретарь работает по-старому (синхронный аудит в хуке), B-инфраструктура лежит
«тёмной». Включение — отдельный осознанный шаг.
- **Риск гонки курсора темы** между хуком (ставит спан) и воркером (разобрал спан) — развести явно в плане
(источник истины курсора — один; кандидат: курсор двигает только воркер после успешной записи, хук
ставит «сырые» границы в очередь).
- **Риск двух писателей** снят построением: один воркер на тему (замок), хук не пишет протокол.
- **Риск зависшего воркера** на 15-мин модели — снят пульсом (живой ≠ мёртвый).
- **Windows:** detached spawn + `unref`, файловые замки атомарны (`writeFileAtomic` + rename), пути с
кириллицей — как везде в проекте.
## 7. Связь с подпроектами
- A даёт конвейер (`distillSpan`, `applyResults`, harvest/gardener) — воркер его исполняет.
- C даёт `renderDoc` — воркер им рисует `protocol.md`.
- B — последний: оживляет флаг, выносит исполнение в фон, снимает таймаут-стену. После B эпик «пушистое
дерево» закрыт; песочница `.scratch/sec/*` прибирается (CLAUDE.md п.11).