epic #497 волна 1 — интеграция четырёх approved-итераций (#486/#487/#488/#489) → develop #511
Reference in New Issue
Block a user
Delete Branch "integ/497-wave1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Интеграционный PR: собирает ПЕРВУЮ ВОЛНУ эпика #497 (четыре approved-итерации) в один мерж → develop, чтобы влить всё разом без риска осиротить стопку.
Содержит (каждая уже отревьюена и approved отдельным PR):
Топология: 486→487→489 линейно (сервер), #488 — клиентский сиблинг off 487; собрано мержем feat/488 в feat/489 (файлы клиента и сервера непересекающиеся, 0 конфликтов). Ветка ровно на develop (0 отставания).
How verified (собранная ветка, чистое состояние)
jest --maxWorkers=2: 210 suites / 2478 passed / 0 failed.node --test(в составе сервера) зелёный; сборка@docmost/mcp/@docmost/prosemirror-markdownчистая.Примечание
Это интеграция УЖЕ approved-работы — не новый код. Дисциплина данных (#490, PR #510) НЕ включена (ещё у ревьюера); войдёт следующим интеграционным заходом сразу как заапрувится. Merge этого PR закрывает #486/#487/#488/#489 в develop одним разом.
Серверная ai@6.0.134 в writeToServerResponse при backpressure (write()===false) ждала ТОЛЬКО once('drain'). Если клиент отвалился мид-запись, сокет не дренится, await не резолвится: read-цикл паркуется НАВСЕГДА, finally{response.end()} недостижим, reader и буферы висят до рестарта. В autonomous ран продолжает лить вывод после дисконнекта → КАЖДЫЙ дисконнект мид-ран оставляет висящий пайп. Плюс read() — fire-and-forget с throw → unhandledRejection. Патч расширен (index.js и index.mjs): Promise.race drain/close/error с гигиеной once-слушателей (все три снимаются на первом settle — не копятся по одному на stall); при close/error — reader.cancel() и выход (безопасно для detached-ранов: независимый дренаж делает consumeStream); rejection read() поглощается с логом. pnpm-lock patch_hash перегенерён (patch-commit). Выравнивание версии ai — отдельный коммит (#495), не здесь. Тест: трипвайр-спека в apps/server (только там резолвится патченная копия) по образцу ai-sdk-partial-output.patch.spec — дисконнект мид-запись без drain завершает ответ (end() вызван, reader не висит), read()-throw не даёт unhandledRejection, оба dist-билда несут маркер PATCH(docmost #486).listPages(tree:true) деструктурировал только pages из enumerateSpacePages и возвращал голое дерево, теряя truncated — на неполном дереве (stdio-fallback BFS упёрся в node-cap) вызывающий не знал, что страницы потеряны. Возвращаем { tree, truncated } (по образцу check_new_comments); основной /pages/tree путь беспредельный, там false. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Раньше give-up-путь finalizeRun ВОССТАНАВЛИВАЛ entry (зомби неотличим от живого рана), а терминальный runRepo.update был БЕЗУСЛОВНЫМ (последний писатель перетирал терминальный статус). - Все терминальные UPDATE ранов теперь УСЛОВНЫЕ: новый repo.finalizeIfActive пишет только пока строка pending|running (зеркально onlyIfStreaming у сообщений) → двойной settle схлопывается в benign no-op, терминальный статус не перетереть. - При исчерпании ретраев finalizeRun НЕ восстанавливает entry, а оставляет ЗОМБИ-запись { terminalWriteFailed, intended:{status,error} } в отдельной map; settleZombie пере-драйвит intended условным UPDATE (зовётся reconcile/supersede/ boot sweep). Зомби удаляется после успешного UPDATE или обнаружения строки уже терминальной. - Per-run settledPromise в ОТДЕЛЬНОЙ map runId→deferred (переживает active.delete), создаётся в beginRun, резолвится ровно один раз исходом (записано/сдался); поздний подписчик через ранее взятую ссылку получает резолвленный; peekSettled: live deferred → зомби-синтез → undefined (читать строку). Обе map bounded. - Задокументирована потеря (single-process): рестарт до пере-драйва → boot sweep пишет 'aborted' поверх фактического intended — неустранимо в phase 1. Тесты: юниты give-up-пути (зомби вместо restore + notifier terminalWriteFailed + settleZombie), двойного settle, позднего подписчика; интеграционные против реальной Б, что условный finalizeIfActive не перетирает терминал и двойной settle схлопывается. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Раньше в legacy-режиме (дефолт!) гейта конкурентности НЕ было вообще — проверка 409 сидела внутри if (autonomousRuns && chatId): два таба = два параллельных стрима на один чат (интерливинг истории, падения convertToModelMessages). Серверная часть (клиентская лестница ретраев — отдельной клиентской итерацией FSM, см. ниже): - Run-строка ОБЯЗАТЕЛЬНА в ОБОИХ режимах: legacy тоже проходит beginRun/ finalizeRun (гейт партиального уникального индекса + runId в start-метаданных). Различие режимов сведено к семантике abort: legacy onClose при дисконнекте зовёт requestStop(runId) вместо аборта сокет-сигнала (которого streamText уже не потребляет). Второй таб в ЛЮБОМ режиме → 409 A_RUN_ALREADY_ACTIVE. - Supersede CAS POST /stream { supersede: { runId: X } } (AiChatRunService .supersede): валидация X.chatId===body.chatId (иначе 400 SUPERSEDE_INVALID); нет активного рана → degrade в обычный send; активный ≠ X → 409 SUPERSEDE_TARGET_MISMATCH + текущий runId; активный = X → requestStop → awaitSettled (таймаут W=10c) → «записано/сдался» (сдался → settleZombie применяет intended условным UPDATE) → ready; таймаут → 409 SUPERSEDE_TIMEOUT, ничего не персистится. W обоснован race-on-abort'ом коммита 1; DB-brownout → TIMEOUT штатен, W не увеличивать (env-tunable). - Задокументированы ограничения: нет квиесценции сайд-эффектов (в промпт нового рана добавлена строка SUPERSEDE_NOTE «предыдущий ран прерван, его последние операции могли примениться с задержкой»); кража слота между освобождением и beginRun (→ MISMATCH с новым runId, бэкстоп — уникальный индекс). Тесты: два параллельных старта (оба режима) → строго 409 второму; supersede при живом долгом ТУЛЕ (не UPDATE-задержке) settle'ится быстро; все CAS-ветки; дубль supersede-POST → degrade; зомби-путь через settleZombie; HTTP-маппинг ветвей. Кейс «beginRun ok → insert user-msg упал → ран settled, слот свободен» покрыт lifecycle-спекой против реального safety-net catch. КЛИЕНТ (deferred, точно flagged): удаление лестницы SUPERSEDE_RETRY_DELAYS_MS/ isRunAlreadyActive/supersedeRetryRef и переход на supersede-в-body требуют адопции runId из start-метаданных (сейчас клиент runId не трекает вовсе) — это и есть та «итерация клиентского FSM», которую данный серверный контракт разблокирует. Не трогаю фрагильный клиентский FSM без возможности E2E-валидации в браузере, чтобы не внести непроверяемую регрессию. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>stream() был единственным эндпоинтом ai-chat без assertOwnedChat: участник того же воркспейса (НЕ владелец чата) мог послать POST /stream {chatId:<чужой>, supersede:{runId:<любой>}} и (а) выудить активный runId жертвы из ответа 409 SUPERSEDE_TARGET_MISMATCH, затем (б) requestStop чужого рана. Добавляю owner-check в начале stream() (когда есть body.chatId), ровно как в /stop и соседях — pre-hijack, чистый 403. Это заодно закрывает и негейченную кросс-юзерную запись через тот же stream() (база #500). Тест: не-владелец POST /stream с чужим chatId → ForbiddenException, runId не утёк, supersede/requestStop не вызваны. MUTATION-VERIFY: снятие assertOwnedChat делает тест красным (возвращается путь утечки). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Юзер-сообщение персистилось в metadata БЕЗ валидации и реплеилось через convertToModelMessages на каждом ходе; insert user-строки шёл ДО конвертации. Битая структура parts (например, null-элемент в массиве) → конвертация кидает → 500 на КАЖДОМ ходе навсегда, а каждый ретрай добавлял дубль user-строки. - Санитизация parts при приёме: whitelist { text }, прочее (в т.ч. tool-part в input-available) отбрасывается с warn — не попадает в metadata. - convertToModelMessages прогоняется ДО insert'а user-строки (ретрай не плодит дубли); при падении на СТАРОЙ истории — per-row конвертация изолирует битую строку и деградирует её до plain-text с маркером «[tool context omitted]» (молчаливая потеря tool-контекста недопустима). Тесты против РЕАЛЬНОГО convertToModelMessages (null-part реально кидает): unit трёх веток + сервис-регресс «чат с битым сообщением в истории работает, маркер доходит до модели, одна user-строка». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Кэш внешних MCP-клиентов не делал health-check/reconnect-on-error: после разрыва SSE-транспорта (bodyTimeout режет при тишине МЕЖДУ вызовами) отдавал труп до конца TTL, а модель жгла шаги на ретраях ВНУТРИ текущего рана. - Новое поле writeClass ('readOnly'|'write') на КАЖДОМ SHARED_TOOL_SPEC + registration-time assert (и compile-time через satisfies). Все 48 спеков расклассифицированы: чтения → readOnly, любая мутация страницы/коммента/шэра/ диаграммы → write. Экспортированы SHARED_TOOL_WRITE_CLASS + isRetryableWriteClass. - Per-run обёртка восстановления транспорта: при транспортной ошибке readOnly-тул реконнектит свой сервер и ретраит РОВНО 1 раз ВНУТРИ рана; write-тул НЕ авторетраится (indeterminate — «могло примениться, проверь», класс инцидента #435). CAS-своп байндинга по identity (проигравший конкурентный вызов ретраит на текущем клиенте, не минтит второй). Лизы не освобождаются mid-run — ран копит set (старая+новая) и релизит на turn-end. - Проверка abortSignal ПЕРЕД ретраем И ПЕРЕД чеканкой свежего клиента; per-call cap покрывает оба attempt'а + connect. - Классификация транспортной ошибки по РЕАЛЬНЫМ шейпам undici (SocketError/ BodyTimeoutError, cause-цепочка), не по мок-ошибкам. - Отдельный, поднятый bodyTimeout для SSE-транспорта MCP (тишина между вызовами легальна) — DEFAULT 10 мин, AI_MCP_SSE_BODY_TIMEOUT_MS. write-class map грузится в mcp-clients лениво через dynamic import (пакет ESM), type-only импорт — без static require ESM из commonjs. Тесты на РЕАЛЬНЫХ error-шейпах: «повтор после обрыва ВНУТРИ рана получает живой клиент», «write-тул не авторетраится», «ретрай после Stop не происходит», + writeClass-контракт в mcp node --test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Заменяет зоопарк из ~26 useRef-флагов в chat-thread.tsx на один чистый редьюсер с перечислимыми переходами (event × state → next state — впервые юнит-тестируемо напрямую). Коммит 1 из 5. Содержит СПЕКУ (пишется первой, входит в PR) и каркас: - run-fsm.spec.md: таблица «событие × состояние», карта всех ref'ов → {состояние | контекст | данные}, протокол run-факта, список инвариантов; - run-fsm.ts: чистый reduce(machine, event) → machine с epoch-инвариантом (I1), состояниями idle|sending|streaming|attaching|reconnecting|polling|stalled| stopping|superseding|error, ownership как ПОЛЕ контекста (I2), run-фактом как first-class (I3), выходом из stopping по данным (I4), dispose-протоколом (I5) и слоем command-эффектов (attach/postStream/postRun/stop/supersede); - run-fsm.test.ts: 31 тест переходов, включая поведение коммитов 2–5 как переходы автомата (reconnect по run-факту; повторные циклы reconnect; polling→stalled; supersede CAS-исходы; фильтрация позднего колбэка по epoch). 8 зафиксированных решений реализованы; epoch-инвариант неотключаем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Полная миграция chat-thread.tsx на автомат run-fsm: 13 ref-флагов жизненного цикла resume/reconnect/poll/ownership УБРАНЫ (карта ref'ов в run-fsm.spec.md, колонка pending пуста). Коммиты 3/4/5 приезжают одной атомарной миграцией — три фикса делят одну модель состояний (раздельные коммиты давали бы несобираемые промежуточные состояния, что противоречит смыслу единого автомата). Коммит 3 — повторные циклы reconnect: attached→reconnecting разрешён многократно. Различие «live-follow (лестница reconnect) vs mount-resume» вынесено в ctx-поле liveFollow (НЕ новый ref — это и есть смысл FSM): live-follow-обрыв перезаходит в лестницу (сброс счётчика после успешного re-attach), mount-resume-обрыв уходит в poll. Тест «два обрыва подряд → два цикла». Коммит 4 — (a) polling→stalled по idle-капу (баннер+Retry вместо тихого «вечно-полуготового»); кап переехал в тред (idleCapTimerRef, effect-owned, не флаг), окно теперь тупо поллит по armed-флагу. (b) resume армится ТОЛЬКО при серверном подтверждении активного рана: streaming-tail (статус) или POST /run для user-tail — чат без активного рана больше не порождает ~240 req/10мин. Тесты: stalled-баннер; user-tail с/без активного рана. Коммит 5 (supersede) — удалены SUPERSEDE_RETRY_DELAYS_MS/isRunAlreadyActive/ supersedeRetryRef (клиентская лестница ретраев). «Прервать и отправить» идёт через FSM superseding → POST /stream {supersede:{runId}} (runId из start-метаданных, extractRunId). Транспорт разбирает CAS-исход: ok→SUPERSEDE_READY (новый стрим), 409 MISMATCH→verify через /run, TIMEOUT/INVALID→классифицированная ошибка без авто-ретрая; голый 409 A_RUN_ALREADY_ACTIVE→RUN_ALREADY_ACTIVE. pendingSupersedeRef (send-плумбинг data) — единственная замена трёх удалённых one-shot'ов. Инвариант epoch (I1) гейтит каждый command-outcome (attach/reconnect/supersede/ postRun): устаревшее поколение колбэка отбрасывается редьюсером; DISPOSE на unmount инкрементит epoch. mountedRef оставлен как React-liveness (ортогонален lifecycle). Тесты: FSM 37 переходов; chat-thread 35 (переписан на FSM-переходы); все зелёные. E2E (реальный SSE/reconnect/supersede через редиплой) — на staging QA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Браузерный QA поймал баг, который юнит-тесты пропускали из-за вакуумной SDK-формы. Премиса (подтверждена по ai@6.0.207 AbstractChat.makeRequest, catch ~13763): при сетевом дропе SDK ставит isError=true БЕЗУСЛОВНО, а isError=true → isDisconnect=true ставится РЯДОМ (только для fetch/network TypeError). Т.е. реальный обрыв ВСЕГДА даёт { isError:true, isDisconnect:true }; форма { isDisconnect:true, isError:false } SDK'ом НЕ эмитится. Баг: в onFinish проверка `if (isError) return` стояла РАНЬШЕ ветки isDisconnect → реальный дроп уходил в терминальный error-баннер, а FINISH_DISCONNECT (единственный вход в reconnect-лесенку) не диспатчился НИКОГДА. Сценарии 1/2 (commit 2 «обрыв до первого кадра» и commit 3 «два обрыва») в браузере не работали. Фикс: маршрутизация по disconnect ПЕРВЫМ: isDisconnect → FINISH_DISCONNECT (reconnect); НЕ-disconnect error (isError && !isDisconnect, напр. провайдерский 500) → FINISH_ERROR (терминал); затем isAbort; затем clean. Порядок веток supersede- блока тоже disconnect-first (для консистентности; там всё равно всё дропается I1). Инварианты сохранены: epoch-штамп turnEpochRef на всех FINISH_*; honor-in-stopping в редьюсере честит ЛЮБОЙ финиш в фазе stopping → на пути Stop дроп-финиш уходит в idle, а не в ложный reconnect (новый тест). F1 supersede-drop чужого поколения цел. Тесты сделаны НЕ вакуумными: дроп подаётся реальной формой { isError:true, isDisconnect:true }, терминальная ошибка — { isError:true, isDisconnect:false }. MUTATION-VERIFY: багованный isError-first порядок → 6 reconnect-тестов краснеют (нет баннера «reconnecting»); с фиксом зелены. Полный ai-chat 35 файлов / 377 / 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Браузерный QA: маршрутизация disconnect-first работает (лесенка реально стартует), но пользователь всё равно видел терминальный «Lost connection… reload», а не «reconnecting… (N/5)». Трасса (подтверждена по коду): рендер `{errorView ? <ChatErrorAlert/> : phase === "reconnecting" ? ...}` даёт errorView ПРИОРИТЕТ над recovery-фазой. errorView = `error && describeChatError(...)`, где `error` — из useChat. Реальный ai@6 при isError выставляет useChat `error`, а дроп ВСЕГДА isError+isDisconnect → после починки маршрутизации FSM уходит в `reconnecting`, но `error` остаётся выставленным → errorView перекрывает reconnect-баннер. Тот же класс вакуум-мока, что и с isDisconnect, но на поле `error` (мок хардкодил error:null → в тестах маски не было). Фикс рендера: фаза FSM — источник истины. Терминальный errorView показывается ТОЛЬКО когда FSM реально терминален: `showError = errorView && phase === "error"`. В recovery-фазах (reconnecting/polling/stalled/superseding/stopping) выигрывает recovery-баннер (или контент стрима). Классифицированные 409 (#487) целы: supersede/ gate-409 ставят FSM в error(kind) → errorView показывается там, где должен. Мок useChat сделан реалистичным СИСТЕМНО: добавлено поле h.state.error, мок его возвращает; при любом isError-финише (дроп или провайдерская ошибка) тест ставит error в реальную форму, зеркаля связку SDK. MUTATION-VERIFY: откат рендер-гейта (errorView-first) → 8 reconnect/stalled-тестов краснеют (баннер маскируется); с гейтом — зелёные. Плюс отдельный тест «reconnect-баннер виден, не замаскирован». Всё прошлое цело: disconnect-first, epoch-штамп, honor-in-stopping, supersede- исходы, stalled/no-poll. Полный ai-chat 35 файлов / 378 / 0; tsc ai-chat 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>1 [stability] Позитивные attach-исходы гвардятся по ИСХОДНОЙ фазе. Одного epoch- фильтра мало: POLL_TERMINAL использует to() (epoch не инкрементит) и не шлёт abortAttach, поэтому медленный GET, вернувший live 2xx уже ПОСЛЕ того как армленный poll увёл машину в idle, воскрешал осевший ран в фантомный streaming. RECONNECT_ ATTACHED теперь bail'ит если фаза != reconnecting; ATTACH_LIVE/ATTACH_NONE — если != attaching. Тест на гонку (POLL_TERMINAL до RECONNECT_ATTACHED → idle) + mutation. 2 [regressions] ownership сбрасывается в "local" на ВСЕХ терминальных переходах (FINISH_CLEAN/ABORT/ERROR, POLL_TERMINAL, RUN_FACT{null}→idle, honor-in-stopping, disconnect→idle). Иначе observer-attach + очередь + clean-финиш → idle, но ownership навсегда observer → «Send now» скрыт при свободном композере. Безопасно для I2: рантайм захватывает wasObserver из machineRef ДО dispatch. Тест + mutation. 3 [coverage] Тест happy-path CAS-supersede: SUPERSEDE_READY-dispatch (200-исход transport.fetch) раньше НЕ исполнялся ни в одном тесте — риск залипания в superseding на весь стрим B. Тест вводит в superseding, гонит A.onFinish→B, затем POST 200 → SUPERSEDE_READY → streaming, проверяет повторный supersede (не залип). Сиблинги: 409 SUPERSEDE_TARGET_MISMATCH → getRun/verify; plain-409 A_RUN_ALREADY_ACTIVE → классифицированный баннер. Mutation (no-op READY → красный). 4 [regressions] Inactivity-бэкстоп для poll, армленного в stopping. STOP_REQUESTED армит poll и входит в stopping, но idle-cap покрывал только polling/reconnecting → observer-стоп без SDK-стрима и без серверного терминала поллил БД вечно. Добавлен stopping в фаза-чек эффекта + переход POLL_IDLE_CAP: stopping→idle+disarm (НЕ stalled — Stop уже нажат). FSM + компонент-тест (idle-cap→disarm) + mutation. 5 [docs] Счёт §2 «Net»: 8→FSM (#1-6,#11,#13) + 3 deleted + reconnectTimerRef (effect-owned) + mountedRef (retained) = 13; attachAbortRef вне набора #1-13. Не трогал DROP-блок (мёртвый FINISH_* в superseding, ErrorKind.kind, неиспользуемые enum-варианты, epochRef-зеркало). Всё прошлое цело (disconnect-first, epoch, honor- in-stopping, render-gate, supersede). Полный ai-chat 35 файлов / 388 / 0; tsc 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Консолидированное независимое ревью волны 1 (три ревьюера: ре-чек итерации 1; сервер итераций 2+4 с danger-points из adversarial-спек; клиент итерации 3 + кросс-швы). Блокеров нет. Мерж — после ОДНОГО fixup-коммита по находке W1.
[WARNING → фиксить до мержа] W1: рассинхрон 409-контракта — сервер шлёт
activeRunId, клиент читаетrunIdСервер:
ai-chat.controller.ts:476-480(SUPERSEDE_TARGET_MISMATCH) и:513-517(A_RUN_ALREADY_ACTIVE) кладут id текущего рана вactiveRunId(контракт запиннен серверным тестомai-chat.controller.supersede.spec.ts:194). Клиент:read409(chat-thread.tsx:93-99) читаетrunId→SUPERSEDE_MISMATCH{currentRunId}всегдаundefined. Конечное состояние самолечится черезpostRun(verify)по chatId (~1 RTT), но: быстрый хинт мёртв в проде; спека шваrun-fsm.spec.md:56,94нарушена; клиентские тесты зелёные ложно — мокают тело с полемrunId, которого сервер не шлёт (chat-thread.test.tsx:417,error-message.test.ts:60) и не ассертят усвоенныйcurrentRunId.Фикс (клиент — дешевле, единственный потребитель):
read409читаетactiveRunId; поправить оба мока на реальную форму; добавить ассерт на усвоенныйcurrentRunId. Заодно (дёшево, смежно — S4 ниже): проброситьactiveRunIdиз веткиA_RUN_ALREADY_ACTIVEвrunFact, чтобы supersede мог таргетить ран ЧУЖОЙ вкладки (сейчасsendNowбез runFact падает в слепой promote+abort → снова 409 — сервер отдаёт поле ровно для этого).Не блокирует мерж — зафиксировать follow-up'ами
onResumeFallbackжив — в тред перенесены только arm/disarm+idle-cap. Функционально дыра закрыта иначе (DISPOSE→disarm, окно сбрасывает degradedPoll при смене чата), регрессии нет, но это сознательное сокращение скоупа — подтвердить и обновитьrun-fsm.spec.md, либо довести перенос отдельным PR.run-fsm.spec.md§2 неполна — не задокументированыturnEpochRef(ключевой механизм инварианта I1!) иpendingSupersedeTextRef(полу-управляющий presence-guard в sendNow). Оба легитимны как данные, но карта обязана быть точной.isInternalDocmostServerвсегда false → все внешние тулы трактуются write → без ретраев) — задокументированная защита от double-apply при коллизии имён; активен только поднятый SSE bodyTimeout. Расхождение спека↔реализация признано, механика покрыта тестом через форс trusted.turnEpochRef— разделяемый ref штамповки; корректность I1 держится на «два owned-стрима не перекрываются» (сегодня гарантировано supersede-defer + последовательной лестницей) — закрепить инвариант комментарием.Проверено чисто (сводно)
Итерация 1: фиксы B1/B2 без ослабления тестов, ребейз эквивалентен, patch_hash цел. Итерации 2+4: ВСЕ терминальные UPDATE условные (finalizeIfActive/onlyIfStreaming на всех колл-сайтах); settledPromise в отдельной map, W=10s как await (не поллинг); reconcile-гейты (entry первичен, staleness от last-progress, X=max(2×cap,15min)+boot-warn); owner-write перетирает штампы, все штампы ставят finalizeFailed; supersede: null-active→send, foreign chatId→400, insert после beginRun, insert-упал→settled; legacy requestStop не абортит после нормального finish; write-класс: 48/48 спеков, write не ретраится, abort-чеки перед ретраем и чеканкой; лизы mid-run не освобождаются. Итерация 3: epoch реально фильтрует поздние колбэки (включая remount-гонку), ownership — контекст, stopping выходит по данным, все 8 дыр спеки закрыты переходами, 13 lifecycle-флагов устранены, merge drift нулевой (
git diff feat/488 integ -- apps/clientпуст).Тесты
Итерация 1 (узкий прогон): 103/103, чистый teardown. Сервер (целевые, memory-capped): 189 jest + 20 node — зелёные. Клиент: FSM-зона 129/129 (run-fsm 91% stmts); полный сьют 1056 passed + 11 файлов падают ТОЛЬКО из-за несобранного workspace-пакета в окружении ревью (
@docmost/prosemirror-markdown/browser— не дефект ветки, в CI со сборкой зелёные). Полный серверный сьют одним воркером на 2 ГБ OOM'ит (инфраструктурное; заявленный автором прогон 210 suites / 2478 passed на maxWorkers=2 согласуется с целевыми результатами).Итого: после fixup W1 (+желательно S4 в том же коммите) — волна готова к мержу в develop; S1–S3, S5–S7 — в follow-up ишью.
Ревью волны 1 (agent_vscode): сервер эмитит id текущего рана в activeRunId на обоих 409-бранчах (SUPERSEDE_TARGET_MISMATCH, A_RUN_ALREADY_ACTIVE), а клиентский read409 читал runId → SUPERSEDE_MISMATCH{currentRunId} всегда undefined: быстрый supersede-хинт мёртв в проде, а клиентские тесты ложно-зелёные (мокали поле runId, которого сервер не шлёт). - read409 читает activeRunId (undefined-safe); оба мока — на реальную форму + ассерт на усвоенный currentRunId (mutation: revert→runId краснит тесты). - S4 (groundwork): activeRunId из A_RUN_ALREADY_ACTIVE поглощается в runFact (to(), без бампа эпохи/ownership — инварианты #488 целы). Полная обвязка supersede чужой вкладки из фазы error требует расширения gate в sendNow — отдельным follow-up; сейчас это безопасная заготовка, не рабочая фича. - spec.md: 2 строки контракт-таблицы приведены к activeRunId. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>W1 закрыт фиксап-коммитом (клиент-only, сервер не тронут). Спасибо за консолидированное ревью.
W1 [WARNING] — 409-контракт:
read409теперь читаетactiveRunId(undefined-safe), оба вакуумных мока приведены к реальной серверной форме + добавлен ассерт на усвоенныйcurrentRunId(mutation-verify: revert→runIdкраснит оба интеграционных теста). Быстрый supersede-mismatch хинт снова живой в проде и корректно ведёт verify. Полный клиентский ai-chat сьют: 392 passed / 0 failed.S4 — честно:
activeRunIdизA_RUN_ALREADY_ACTIVEпоглощается вrunFact(черезto(), без бампа эпохи/ownership — инварианты #488 целы). НО это пока groundwork, а не рабочая фича:RUN_ALREADY_ACTIVEоставляет FSM в фазеerror(run-already-active), а гейтaLiveLocalвsendNowтребуетsending|streaming— так что естественного не-racy прод-пути «гейт-409 → Send now → CAS чужой вкладки» пока нет. Чтобы S4 реально таргетил чужой ран, нужно расширить гейтsendNow(принимать CAS из фазыerror) — это трогает ownership-инварианты #488, вынес в follow-up, не в этот минимальный фиксап. Заготовка безопасна и мержу не мешает.S1/S2/S3/S5 (не блокируют мерж) — follow-up'ами: S2 (ref-карта спеки:
turnEpochRef/pendingSupersedeTextRef) + S1 (poll-query не в треде — подтвердить сокращение скоупа или довести) + S3 (RUN_FACT{null} vs «немедленно») + S5 (server micro-race) оформлю отдельным заходом по волне 1.Ветка мержибельна (интеграция четырёх approved-итераций + W1). После мержа #511 индивидуальные #500/#504/#508/#509 закроются содержимым в develop.
Перепроверка fixup
0503e8b4(W1+S4): блок снят, PR готов к мержу.read409читаетactiveRunIdна обоих 409-ветках;SUPERSEDE_MISMATCH{currentRunId}теперь получает реальный id; оба мока переведены на фактическую серверную форму + добавлен ассерт на усвоенныйcurrentRunId(mutation-verified: реверт наrunIdкраснит тесты) — «мок-форменный» класс закрыт, а не замазан.RUN_ALREADY_ACTIVE{activeRunId}поглощается вrunFactчерезto()без бампа эпохи/ownership — инварианты #488 целы; полная обвязка supersede из error-фазы честно вынесена в follow-up.run-fsm.spec.mdконтракт-таблица приведена кactiveRunId— спека шва снова совпадает с кодом.0503e8b4: 117/117 (run-fsm, chat-thread, error-message).Остаются follow-up'ы S1–S3, S5–S7 из консолидированного ревью (не блокируют). После мержа не забыть: закрыть #500/#504/#508/#509 как поглощённые этим PR.