19 KiB
Эмиттер прогонных событий (шов движок→платформа) — сборка-норматив (строки 103+165, докс-пак 167)
⚠ ЭМИТТЕР ПОСТРОЕН И ПРИНЯТ С ОБЕИХ СТОРОН. Движковая половина — D39.131 (
9cfe080, 14.08), платформенная — D39.132 (P6, 15.08); PD-113 закрыт. Фактический код первичен, этот док — справочник ФОРМЫ:backend/internal/runevents/(журнал и словарь) ·backend/internal/pipeline/events.go·backend/internal/store/outbox.go·platform/internal/ingest/.Статус: СБОРКА-НОРМАТИВ. Док ничего не решает сам — он собирает уже ратифицированные решения об
events.jsonlв одно место. Источники первичны; при конфликте с05-decisions-log.mdпобеждает D-лог. Каждое утверждение несёт указатель; дословные цитаты — в кавычках-ёлочках.Для кого: бэкенд- или платформенная сессия, которой нужно ПОНЯТЬ форму шва — что несёт кадр, почему журнал файлом, что запрещено строить на событиях денег. Как заказ на постройку док отработан.
1. Что это и зачем
Что. Машинный канал живого статуса движок→платформа: NDJSON-журнал events.jsonl в каталоге книги — словарь-enum со стабильными типами, version-хендшейк первой строкой (правило terraform), плотная нумерация seq, событие потолка и событие денег. Заказ жил строками бэклога 103 (эмиттер) и 165 (различимые exit-коды); обе ЗАКРЫТЫ D39.131 и из живого бэклога сняты — дословные формулировки заказа, если понадобятся, лежат в архивных слайсах ../archive/PROGRESS-*.md.
Зачем две ноги. (а) Свежесть статуса — слово владельца 08.08: «эмиттер нужен СВЕЖЕСТИ статуса и деньгам шва, не первым экранам» (D39.119 п.1); резерв на время без него — периодический status --json-резюнк. (б) Деньги шва: без события потолка «экран покажет „ошибка“ там, где верно „остановлено: лимиты“» (PD-113), а после поправки формулы потолка (D39.123, PD-158) стоп по потолку = ровно исчерпание холда, поэтому цена неразличимости выросла (⚠ неразличимость вскрыта приёмкой раннера на РЕАЛЬНОМ tmctl — фейк пробы умирал от сигнала; дословный заказ — docs/archive/prompts/BACKEND_EMITTER_SESSION_PROMPT_2026-08-10.md:96=Новые exit-коды tmctl).
Что эмиттер НЕ решает (и не решал): реконсилятор и свип — платформа (строки 138·139 закрыты, D39.123 п.3) · write-ahead intent / uncertain-эскроу / closing — строка 136 бэклога · пиннинг версии бинаря на прогон — исполняется резюмом (PD-143/134 закрыты) · движковая оценка $/глава — строка 166 · тейлер/декодер платформы — platform/internal/ingest/, эмиттер пишет ПОД их контракт.
2. Ратифицированная форма (D39.106 · research/25)
Канон — D39.106 п.2, дословно:
«2. Форма (заменяет ТРАНСПОРТ D39.85 §1; остальное D39.85 в силе): движок = транзиентный systemd-юнит на прогон (
Restart=no, SIGTERM); события =events.jsonlкаталога книги как проекция коммитов SQLite движка (outbox, та же транзакция, что чекпойнт); платформа тейлит, курсор(engine_run_id, seq)коммитится с эффектом в одной Postgres-транзакции;status --json— ремонт по остановленному прогону. Формат NDJSON/hello/seq, деньги (холд+потолок = защита, события = свежесть, D39.84/D39.100) и запрет на живой SQLite — без изменений.»
Разворачивание по research/25 (docs/research/25-seam-cold-review.md):
- Журнал = outbox, не вторая запись: «События —
events.jsonlв каталоге книги, append-only, как проекция уже закоммиченных строк SQLite движка (та же транзакция, что чекпойнт chunk×stage; outbox, не вторая запись)» (25:21-22). Следствие для эмиттера: строка события рождается в той же SQLite-транзакции, что чекпойнт, — не «после», не «параллельно». - Тейл с курсором: «Платформа тейлит журнал; запись курсора —
(engine_run_id, seq), байтовое смещение — хинт; курсор коммитится в той же Postgres-транзакции, что эффект события» (25:23-24). Смещение — хинт и может быть отброшено; решает курсор (platform/internal/ingest/tail.go:31-35). - events ≠ status: «
events(fsync, seq, деньги) ≠status(atomic rename, косметика, не для биллинга)» (25:73).status --json— ремонт-канал по остановленному прогону, «берёт тот же flock — проверка „остановлен“ не есть взаимоисключение (GPT)» (25:79). - Файл — пер-КНИГА, поток — пер-процесс: имя журнала ратифицировано кодом платформы:
const JournalFile = "events.jsonl"— «the engine's event journal, in the BOOK's directory (D39.106 §2)» (tail.go:16-17).Seq— «per PROCESS and starts at 1»; ключ идемпотентности —(Hello.EngineRunID, seq), НЕ прогон платформы: «a resumed run is a new process whose seq restarts» (events.go:62-70). Резюм дописывает в тот же файл новый hello; тейлер пропускает строки чужогоengine_run_id(tail.go:58-60). - Сброс буфера на всех путях выхода: «сброс буфера на всех путях выхода (
os.Exitпропускает defer — теряются финальные события)» (25:82); то же PD-61(а) — см. §4. - Живость: «живость = растущий
seq, не удерживаемый лок; политика обратного давления выбирается явно» (25:83).
Отвергнутые альтернативы (25:33-44; повторены в доккомменте зоны, «so that none of them comes back as a fresh idea» — events.go:21-23): платформа-родитель + пайп stdout/fd — 0 из 15 («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон», 25:38) · выделенный fd 3 — 0 (25:43) · stdout/journald как источник событий — 0 («journald молча роняет под всплеском и ротирует сам; людям — да, контракту — нет», 25:44) · gRPC/HTTP поверх движка — 0 (25:39) · push в платформу — 2→0 (25:40) · брокер — 0 (25:41) · монолит — 0 из ~18 (25:37). Пайп-путь supervisor.go — ДЕВ-режим и только (D39.106 п.3; events.go:23).
Словарь событий — В КОДЕ, не здесь, и двигается минорами. Действующий носитель — backend/internal/runevents/runevents.go (греп const StreamVersion): конверт {seq, type, time, data} · типы hello/progress/unit_done/bank_stop/ceiling/spend/finished · Hello{stream_version, engine_run_id, book_id, chunker_version} · пофазные счётчики progress (семантика done = «разрешено волной» — D39.122 п.2б) · Ceiling{halted, scope} · Spend{committed_micro_usd} · Finished{outcome} с ледджером объёма. Зеркало-читатель — platform/internal/ingest/events.go. ⚠ Версии двух сторон расходятся по построению (минор = ignore-unknown): сверять по коду, а не по этому доку — числа в теле дока не поддерживаются (носитель расхождения — бэклог-строка 243, находка arch-10).
3. Контракт декодера и дублей (PD-105 · D39.123 п.2(в))
Ратификация — D39.119 п.3, дословно:
«доставка событий шва = at-least-once; ДУБЛЬ
seq— норма повторного чтения, декодер обязан принимать идемпотентно без фатала; тот жеseqс ДРУГИМ payload = карантин прогона (research/25)».
⚠ Расхождение, разрешённое позже: «карантин прогона» — тот же, что процитирован выше (и research/25:77-78) — уточнён D39.123 п.2(в): «PD-105-реализация: карантин ПРОЕКЦИИ (попытки), не прогона — уточнение D39.119; деньги прогона сеттлятся ремонт-каналом». Побеждает D39.123 как более свежая нота.
Как это реализовано читателем (тело — platform/docs/DEFECT_REGISTER.md, греп PD-105): ingest.Tail пропускает seq <= last_seq идемпотентно и без ошибки, pgstore.RunSink.Apply пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта, сверка дублей — по sha256 сырой строки (run_attempts.last_line_sha256). Пропасть (seq > last+1) осталась ошибкой: строки потеряны, читать дальше нечего.
- hello — всегда первая строка потока, seq=1 (
decoder.go:64: «hello carries seq 1, want 1»); версия — правило terraform, ратифицировано D39.85: «a MINOR bump adds fields and event types — unknown ones are ignored; a MAJOR bump is refused» (events.go:34-37); мажор-несовпадение =ErrUnsupportedVersion(events.go:150-163). - Дубль строки безопасен, значит строка обязана быть повторимой байт-в-байт — сверка дублей идёт по sha256 сырой строки (
tail.go:44-49); тот же seq с другим payload =ErrPayloadConflict(tail.go:20-24, 181). Эмиттер не должен пере-генерировать уже записанную строку иначе (например, с другим timestamp при реплее). - Пропуск seq фатален для чтения (
tail.go:190,ErrStreamGap) — нумерация плотная, без дыр. - Недописанная последняя строка — не ошибка: тейлер «deliberately left alone» строку без
\n— это строка в процессе записи (tail.go:51-55). Это смягчает, но не снимает слепое пятно JSONL — битую запись С валидным\n(research/25:74-75); сама развилка формата решена в пользу одиночного JSONL (⚠-строка в конце §5). - Ключ — engine_run_id из hello, не прогон платформы; чужие run id в том же файле тейлер пропускает (
events.go:63-65,tail.go:58-60).
4. Деньги и потолок
Роль событий денег — свежесть, НЕ защита. Канон D39.106 п.2: «деньги (холд+потолок = защита, события = свежесть, D39.84/D39.100)». Доккоммент Spend прямо запрещает обратное: «Building enforcement on this event is forbidden — the stream is at-least-once and a crash truncates it» (events.go:129-132). Форма Spend: «CUMULATIVE, not a delta: a redelivered or duplicated line is then harmless … Integer micro-USD: money never travels as a float, and the engine's ledger is a lower bound, so the conversion at the seam rounds up» (events.go:134-137); это внутренний канал в приватную таблицу — D39.84 правит провод/экран/INFO ПОЛЬЗОВАТЕЛЯ, их это не касается (events.go:137-138).
Событие потолка — факт без цифр. Ceiling{halted}: «It carries the FACT and nothing else: money never reaches the platform's wire or its INFO logs (D39.84), and the stop is resumable, so it is not a failure» (events.go:123-127).
Различимость стопа — ПОСТРОЕНА (D39.131), и это был весь смысл заказа. До пака потолочный стоп был неотличим от инфраструктурного отказа (оба = exit 1), из-за чего платформа ставила failed там, где контракт требует paused — открытый major PD-113, закрыт с обеих сторон (движок D39.131, платформа D39.132). Действующий словарь кодов выхода — в коде, а не здесь: backend/cmd/tmctl/main.go (греп exitProjectLocked; 0 clean · 1 infra · 2 completed-with-flags · 3 signature-stop банка · 4 потолок · 5 graceful stop · полоса отказов 10–19 с классами config_invalid/source_unreadable/project_locked/schema_mismatch/decisions_rejected/write_incomplete/book_incomplete) и его зеркало-читатель platform/internal/ingest/exit.go.
Потолочная окрестность — не здесь. Семантика --ceiling-usd (КНИЖНЫЙ потолок в силе, не бюджет прогона; D39.122 п.2в) и формула аргумента потолка committed + прирост×ставка БЕЗ reserved (D39.123 п.2б, PD-158) живут одним маршрутом в 15-money-path.md §2–§3. Для эмиттера важно ОДНО следствие: платформа выставляет потолок впритык к холду, значит стоп по потолку = ровно исчерпание холда — потому событие потолка и различимый exit-код и перестали быть косметикой (D39.123 п.2ж).
Дефекты-входы, из-за которых форма именно такая (тела и текущий статус — platform/docs/DEFECT_REGISTER.md, греп PD-79 · PD-60 · PD-61 · PD-99 · PD-107): деньги шва едут ЦЕЛЫМИ micro-USD без строковых форм и float (урок PD-79: строковый "null" читался как ноль денег) · политика «журнал не пишется» выбирается ЯВНО, молчаливой третьей нет (PD-60) · буфера в эмиттере НЕТ вовсе — это и есть ответ на PD-61(а), потому что os.Exit/log.Fatal/паника пропускают defer и потеряли бы ровно finished и ceiling (backend/internal/runevents/journal.go, греп There is no user-space buffer; ⚠ имя <jobdir>/events.ndjson в ТЕЛЕ PD-61 — до-ратификационное и его закрывающий баннер имени не правит: канон — events.jsonl каталога книги, D39.106 п.2) · деньги и id книги не текут в INFO и на провод (PD-99, D39.84) · удаление аккаунта против живого холда — гейт перед появлением такой операции (PD-107).
5. Оговорки, которые переживают стройку
- Знаменатели голосов панелей
research/25различаются между вариантами (0/15 · 0/~18) — это РАЗНЫЕ плечи панелей, не усреднять. Оговорка нужна ровно там, где §2 цитирует счёт голосов отвергнутых альтернатив. - Тела D39.84/D39.85/D39.100 вошли сюда через цитаты D39.106 и доккомменты кода — при сомнении открывать первоисточники (живой D-лог/слайсы), а не эту сборку.
⚠ Снято как отработанный хендофф (D39.131, 9cfe080) — речь о ПРЕЖНИХ разделах этого файла, нумерация с тех пор сдвинулась и живой §5 выше — другой (⚠ испр. 02.09: надгробие называло их «§5·§6·§7» как действующие). Прежний раздел развилок — «пять открытых развилок дизайна», все пять решены (формат = одиночный JSONL · политика PD-60 = громкая деградация · fsync каталога один раз при создании · значение exit-кодов · словарь, отвеченный диффом), ответы читать в backend/internal/runevents/journal.go и runevents.go. Прежний чек-лист входного чтения эмиттер-сессии (ратифицирован D39.123 п.4) — исполнен, ни одной внешней ссылки на него не было. Из прежних «оговорок сборки» две первые сняты стройкой (сверка словаря с research/23 сделана диффом; «строка события в той же SQLite-транзакции, что чекпойнт» — теперь факт кода backend/internal/store/outbox.go, а не формула).