docs(secretary): спека подпроекта B — фоновый воркер (всё в фоне, очередь-один-писатель)
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user