Стабилизация, итерация 2: терминальный жизненный цикл ранов — единый гейт, supersede, reconcile (один PR, 4 коммита) #487

Open
opened 2026-07-11 02:01:23 +03:00 by agent_vscode · 0 comments
Collaborator

Один PR с фиче-ветки в develop, коммиты строго в указанном порядке (каждый следующий потребляет механизм предыдущего). Координаты — develop@363f20ab, перед работой перегенерировать. Зависит от #486 (коммит 4 — fail-turn политика beginRun). Блокирует итерацию клиентского FSM.

Коммит 1 — fix(ai-chat): in-app тулы — race-on-abort + safe-points + per-call cap

ai-chat-tools.service.ts:589-599: обёртки (async (args) => run(client, args)) отбрасывают второй аргумент options с abortSignal — in-app тулы единственный класс без отмены и без wall-clock cap. Контент-мутации при этом идут через collab-WS (packages/mcp/src/client.ts:22-26, mutatePageContent), НЕ через axios — «прокинуть signal в axios» главный write-путь не трогает вовсе.
Сценарии: read-тул крутит пагинацию минуты — Stop его не прерывает; Stop во время editPageText → ран финализирован, а запись доезжает в страницу ПОСЛЕ стопа.
Правка (нормативно):

  1. Переиспользовать race-паттерн wrapToolWithCallTimeout (mcp-clients.service.ts:748-805): вызов гонится против композитного сигнала (abort + per-call cap), на abort — реджект немедленно, проигравший промис отбрасывается. Реализация «signal в axios и ждать throw» НЕ удовлетворяет (латентность = сетевой teardown; collab не покрыт) — от race-семантики зависит таймаут supersede из коммита 3.
  2. Safe-point проверки сигнала МЕЖДУ последовательными вызовами многозапросного тула + пре-коммитная проверка сигнала в mutatePageContent.
  3. Per-call cap покрывает вызов целиком (env-tunable).
  4. Известное ограничение (зафиксировать комментарием + issue): abort между вызовами многовызовного write-тула оставляет частично применённую операцию; отмена не гарантирует «запись не доехала».
    Тест (честное свойство): «после Stop не стартует ни один новый HTTP/WS-вызов; уже начатый одиночный — оба исхода легальны». НЕ «запись не доехала» — таймингозависимая ложь (зелёная в CI, ложная в проде).

Коммит 2 — feat(ai-chat): зомби-механика finalizeRun + settledPromise + условные терминальные UPDATE

Сейчас: give-up-путь finalizeRun ВОССТАНАВЛИВАЕТ entry (ai-chat-run.service.ts:364-371) — зомби неотличим от живого рана; терминальный runRepo.update БЕЗУСЛОВНЫЙ (repo :121-143) — последний писатель перетирает терминальный статус.
Правка:

  1. При исчерпании ретраев терминальной записи finalizeRun НЕ восстанавливает entry, а оставляет зомби-запись { terminalWriteFailed: true, intended: { status, error } }.
  2. Per-run settledPromise в ОТДЕЛЬНОЙ map runId → deferred (не в entry — обязан переживать active.delete-claim); создаётся в beginRun; резолвится ровно один раз исходом «записано/сдался» (поздний подписчик получает резолвленный); отсутствие записи в map → трактовать как «сдался» с чтением статуса из строки.
  3. Зомби-запись удаляется после успешного условного UPDATE либо при обнаружении строки уже терминальной; обе map — bounded (cleanup при удалении/рестарте).
  4. Все терминальные UPDATE ранов — условные (WHERE status IN ('pending','running'), зеркально onlyIfStreaming у сообщений): двойной settle любых писателей схлопывается в benign no-op.
  5. Документированная потеря (комментарий у механизма): рестарт до пере-драйва → boot sweep пишет 'aborted' поверх фактического intended — неустранимо при single-process; допущение single-process повторить в комментарии.
    Тесты: юниты give-up-пути (зомби вместо restore), двойного settle, позднего подписчика settledPromise.

Коммит 3 — feat(ai-chat): единый гейт конкурентности + серверный supersede (CAS)

Сейчас: в legacy-режиме (дефолт!) гейта НЕТ вообще — проверка 409 внутри if (autonomousRuns && body.chatId) (ai-chat.controller.ts:440-451): два таба = два параллельных стрима на один чат, интерливинг истории, падения convertToModelMessages. «Interrupt and send now» — клиентская лестница ретраев с бюджетом 1.05 с (chat-thread.tsx:76-101, 434-471) против неограниченного settle.
Правка:

  1. Run-строка обязательна в ОБОИХ режимах: legacy-ход тоже проходит beginRun/finalizeRun (гейт партиального уникального индекса + runId в stream-метаданных start — нужен клиентскому FSM); различие режимов сводится к семантике abort: в legacy onClose при дисконнекте вызывает requestStop(runId) вместо controller.abort(). Второй таб в любом режиме → 409 A_RUN_ALREADY_ACTIVE. Release notes: для legacy это наблюдаемое изменение (двухтабный параллелизм был багом, но работал).
  2. Supersede — CAS-протокол POST /stream { supersede: { runId: X } }:
    • валидация X.chatId === body.chatId, иначе 400;
    • активного рана нет → деградация в обычный send (ран мог кончиться между кликом и POST — не MISMATCH);
    • активный ≠ X → 409 SUPERSEDE_TARGET_MISMATCH + текущий runId в теле; клиент НЕ авто-ретраит;
    • активный = X → requestStop(X)await settledPromise(X), таймаут W=10 c → «записано» или «сдался» (тогда применить intended условным UPDATE) → beginRun → insert user msg;
    • таймаут → 409 SUPERSEDE_TIMEOUT, ничего не персистится, композер сохраняет текст. SUPERSEDE_TIMEOUT ≠ rollback: stop уже выдан, старый ран обречён, ручной resend найдёт слот свободным.
  3. W=10 c обоснован race-on-abort'ом коммита 1 (abort→settle — мс/сотни мс) при здоровой БД; под DB-brownout деградация в TIMEOUT штатна, W не увеличивать.
  4. Документированные ограничения: (а) квиесценции сайд-эффектов нет — in-flight write старого рана может докоммититься ПОСЛЕ старта нового; в промпт нового рана при supersede добавляется строка «предыдущий ран прерван, его последние операции могли примениться с задержкой»; (б) кража слота: между освобождением и beginRun обычный POST соседнего таба может выиграть → MISMATCH с новым runId.
  5. Удалить клиентскую лестницу SUPERSEDE_RETRY_DELAYS_MS/isRunAlreadyActive/supersedeRetryRef.
    Тесты: два параллельных POST /stream (оба режима) → строго 409 второму; supersede при живом долгом туле (не UPDATE-задержке!); «beginRun ok → insert user-msg упал → ран settled, слот свободен» (safety-net catch уже существует, service :1589-1617 — тест против реального механизма); все CAS-ветки; дубль supersede-POST.

Коммит 4 — feat(ai-chat): ретраи finalizeAssistant + owner-write приоритет + двусторонний reconcile

Сейчас: finalizeAssistant ставит finalized=true ДО записи и не ретраит (catch→log, ai-chat.service.ts:1169-1192) → один неудачный UPDATE = строка вечно 'streaming'; свип — boot-only со staleness 10 мин (sweepStreaming); run-свип безусловный — асимметрия даёт «run succeeded / message streaming навсегда».
Правка:

  1. finalizeAssistant: bounded-ретраи; once-гейт закрывается только ПОСЛЕ успешной записи. Правило приоритета owner-write: терминальная запись owner'а условна на status='streaming' OR metadata.finalizeFailed=true (перетирает status-only-штамп reconcile); ВСЕ status-only-штампы reconcile пишут строго onlyIfStreaming И ставят metadata.finalizeFailed: true (все, не только succeeded-маппинг — иначе поздний owner-write не перетрёт aborted-штамп).
  2. Порядок: попытка message-finalize → ран финализируется ВСЕГДА (условным UPDATE; с error-пометкой при провале message). Ран никогда не гейтится на message.
  3. Периодический reconcile-джоб (раз в N минут), клаузы по порядку:
    a. зомби-записи → пере-драйв терминальной записи (intended, условный UPDATE);
    b. «message streaming + run терминален» → settle по статусу рана; succeeded-ран + зависшая строка → 'aborted' + finalizeFailed (НЕ 'completed' с пустыми parts — финальный текст существовал только в памяти, документированная потеря); isInterruptResume обязан исключать строки с finalizeFailed (иначе ложная interrupt-нота);
    c. «run running + НЕТ entry И НЕТ zombie + staleness» → aborted. Гейты: «нет entry» первичный (живой entry не трогать никогда); staleness от last-progress (updatedAt бампается recordStep), НЕ от startedAt (марафоны 11–25 мин легитимны); порог X — производная max(2 × per-call cap, 15 мин) + boot-warn при нарушении;
    d. «message streaming + возраст>X + нет активной run-строки» → aborted (страховка для исторических строк).
  4. Фолбэк-матчинг chatId+staleness (когда linkAssistantMessage не записался) — двойной гейт: «нет активной run-строки на чате» И staleness > cap + маржа.
  5. Оппортунистический одно-чатовый reconcile при beginRun/supersede (синхронно settle'ить streaming-строки чата с терминальными ранами — убирает reconcile-латентность из пути, который видит пользователь); best-effort, ошибка не фейлит ход.
  6. sweepStreaming — из boot-only в периодический.
    Тесты: интеграционный «убить БД на finish → после восстановления ни строка, ни ран не застряли»; клаузы юнитами («живой entry не трогать», «staleness от last-progress»); «finalizeFailed исключает interrupt-resume»; «reconcile-штамп 'aborted' → поздний owner-finalize с реальным контентом ПЕРЕТИРАЕТ».
Один PR с фиче-ветки в develop, коммиты строго в указанном порядке (каждый следующий потребляет механизм предыдущего). Координаты — develop@363f20ab, перед работой перегенерировать. **Зависит от #486** (коммит 4 — fail-turn политика beginRun). **Блокирует** итерацию клиентского FSM. ### Коммит 1 — fix(ai-chat): in-app тулы — race-on-abort + safe-points + per-call cap `ai-chat-tools.service.ts:589-599`: обёртки `(async (args) => run(client, args))` отбрасывают второй аргумент `options` с `abortSignal` — in-app тулы единственный класс без отмены и без wall-clock cap. Контент-мутации при этом идут через collab-WS (`packages/mcp/src/client.ts:22-26`, `mutatePageContent`), НЕ через axios — «прокинуть signal в axios» главный write-путь не трогает вовсе. Сценарии: read-тул крутит пагинацию минуты — Stop его не прерывает; Stop во время editPageText → ран финализирован, а запись доезжает в страницу ПОСЛЕ стопа. Правка (нормативно): 1. Переиспользовать race-паттерн `wrapToolWithCallTimeout` (`mcp-clients.service.ts:748-805`): вызов гонится против композитного сигнала (abort + per-call cap), на abort — **реджект немедленно**, проигравший промис отбрасывается. Реализация «signal в axios и ждать throw» НЕ удовлетворяет (латентность = сетевой teardown; collab не покрыт) — от race-семантики зависит таймаут supersede из коммита 3. 2. Safe-point проверки сигнала МЕЖДУ последовательными вызовами многозапросного тула + пре-коммитная проверка сигнала в `mutatePageContent`. 3. Per-call cap покрывает вызов целиком (env-tunable). 4. Известное ограничение (зафиксировать комментарием + issue): abort между вызовами многовызовного write-тула оставляет частично применённую операцию; отмена не гарантирует «запись не доехала». Тест (честное свойство): «после Stop не **стартует** ни один новый HTTP/WS-вызов; уже начатый одиночный — оба исхода легальны». НЕ «запись не доехала» — таймингозависимая ложь (зелёная в CI, ложная в проде). ### Коммит 2 — feat(ai-chat): зомби-механика finalizeRun + settledPromise + условные терминальные UPDATE Сейчас: give-up-путь finalizeRun ВОССТАНАВЛИВАЕТ entry (`ai-chat-run.service.ts:364-371`) — зомби неотличим от живого рана; терминальный `runRepo.update` БЕЗУСЛОВНЫЙ (repo :121-143) — последний писатель перетирает терминальный статус. Правка: 1. При исчерпании ретраев терминальной записи finalizeRun НЕ восстанавливает entry, а оставляет зомби-запись `{ terminalWriteFailed: true, intended: { status, error } }`. 2. Per-run `settledPromise` в ОТДЕЛЬНОЙ map `runId → deferred` (не в entry — обязан переживать `active.delete`-claim); создаётся в `beginRun`; резолвится ровно один раз исходом «записано/сдался» (поздний подписчик получает резолвленный); отсутствие записи в map → трактовать как «сдался» с чтением статуса из строки. 3. Зомби-запись удаляется после успешного условного UPDATE либо при обнаружении строки уже терминальной; обе map — bounded (cleanup при удалении/рестарте). 4. **Все терминальные UPDATE ранов — условные** (`WHERE status IN ('pending','running')`, зеркально `onlyIfStreaming` у сообщений): двойной settle любых писателей схлопывается в benign no-op. 5. Документированная потеря (комментарий у механизма): рестарт до пере-драйва → boot sweep пишет 'aborted' поверх фактического intended — неустранимо при single-process; допущение single-process повторить в комментарии. Тесты: юниты give-up-пути (зомби вместо restore), двойного settle, позднего подписчика settledPromise. ### Коммит 3 — feat(ai-chat): единый гейт конкурентности + серверный supersede (CAS) Сейчас: в legacy-режиме (дефолт!) гейта НЕТ вообще — проверка 409 внутри `if (autonomousRuns && body.chatId)` (`ai-chat.controller.ts:440-451`): два таба = два параллельных стрима на один чат, интерливинг истории, падения convertToModelMessages. «Interrupt and send now» — клиентская лестница ретраев с бюджетом 1.05 с (`chat-thread.tsx:76-101, 434-471`) против неограниченного settle. Правка: 1. **Run-строка обязательна в ОБОИХ режимах**: legacy-ход тоже проходит beginRun/finalizeRun (гейт партиального уникального индекса + runId в stream-метаданных `start` — нужен клиентскому FSM); различие режимов сводится к семантике abort: в legacy `onClose` при дисконнекте вызывает `requestStop(runId)` вместо `controller.abort()`. Второй таб в любом режиме → 409 `A_RUN_ALREADY_ACTIVE`. **Release notes:** для legacy это наблюдаемое изменение (двухтабный параллелизм был багом, но работал). 2. **Supersede — CAS-протокол** `POST /stream { supersede: { runId: X } }`: - валидация `X.chatId === body.chatId`, иначе 400; - активного рана нет → деградация в обычный send (ран мог кончиться между кликом и POST — не MISMATCH); - активный ≠ X → `409 SUPERSEDE_TARGET_MISMATCH` + текущий runId в теле; клиент НЕ авто-ретраит; - активный = X → `requestStop(X)` → **await settledPromise(X), таймаут W=10 c** → «записано» или «сдался» (тогда применить intended условным UPDATE) → beginRun → insert user msg; - таймаут → `409 SUPERSEDE_TIMEOUT`, ничего не персистится, композер сохраняет текст. SUPERSEDE_TIMEOUT ≠ rollback: stop уже выдан, старый ран обречён, ручной resend найдёт слот свободным. 3. W=10 c обоснован race-on-abort'ом коммита 1 (abort→settle — мс/сотни мс) **при здоровой БД**; под DB-brownout деградация в TIMEOUT штатна, W не увеличивать. 4. Документированные ограничения: (а) квиесценции сайд-эффектов нет — in-flight write старого рана может докоммититься ПОСЛЕ старта нового; в промпт нового рана при supersede добавляется строка «предыдущий ран прерван, его последние операции могли примениться с задержкой»; (б) кража слота: между освобождением и beginRun обычный POST соседнего таба может выиграть → MISMATCH с новым runId. 5. Удалить клиентскую лестницу `SUPERSEDE_RETRY_DELAYS_MS`/`isRunAlreadyActive`/`supersedeRetryRef`. Тесты: два параллельных POST /stream (оба режима) → строго 409 второму; supersede при живом долгом **туле** (не UPDATE-задержке!); «beginRun ok → insert user-msg упал → ран settled, слот свободен» (safety-net catch уже существует, service :1589-1617 — тест против реального механизма); все CAS-ветки; дубль supersede-POST. ### Коммит 4 — feat(ai-chat): ретраи finalizeAssistant + owner-write приоритет + двусторонний reconcile Сейчас: `finalizeAssistant` ставит `finalized=true` ДО записи и не ретраит (catch→log, `ai-chat.service.ts:1169-1192`) → один неудачный UPDATE = строка вечно 'streaming'; свип — boot-only со staleness 10 мин (`sweepStreaming`); run-свип безусловный — асимметрия даёт «run succeeded / message streaming навсегда». Правка: 1. finalizeAssistant: bounded-ретраи; once-гейт закрывается только ПОСЛЕ успешной записи. **Правило приоритета owner-write:** терминальная запись owner'а условна на `status='streaming' OR metadata.finalizeFailed=true` (перетирает status-only-штамп reconcile); ВСЕ status-only-штампы reconcile пишут строго `onlyIfStreaming` И ставят `metadata.finalizeFailed: true` (все, не только succeeded-маппинг — иначе поздний owner-write не перетрёт aborted-штамп). 2. Порядок: попытка message-finalize → ран финализируется ВСЕГДА (условным UPDATE; с error-пометкой при провале message). Ран никогда не гейтится на message. 3. Периодический reconcile-джоб (раз в N минут), клаузы по порядку: a. зомби-записи → пере-драйв терминальной записи (intended, условный UPDATE); b. «message streaming + run терминален» → settle по статусу рана; succeeded-ран + зависшая строка → **'aborted' + finalizeFailed** (НЕ 'completed' с пустыми parts — финальный текст существовал только в памяти, документированная потеря); `isInterruptResume` обязан исключать строки с finalizeFailed (иначе ложная interrupt-нота); c. «run running + НЕТ entry И НЕТ zombie + staleness» → aborted. Гейты: «нет entry» первичный (живой entry не трогать никогда); staleness от **last-progress** (updatedAt бампается recordStep), НЕ от startedAt (марафоны 11–25 мин легитимны); порог X — производная `max(2 × per-call cap, 15 мин)` + boot-warn при нарушении; d. «message streaming + возраст>X + нет активной run-строки» → aborted (страховка для исторических строк). 4. Фолбэк-матчинг chatId+staleness (когда linkAssistantMessage не записался) — двойной гейт: «нет активной run-строки на чате» И staleness > cap + маржа. 5. Оппортунистический одно-чатовый reconcile при beginRun/supersede (синхронно settle'ить streaming-строки чата с терминальными ранами — убирает reconcile-латентность из пути, который видит пользователь); best-effort, ошибка не фейлит ход. 6. `sweepStreaming` — из boot-only в периодический. Тесты: интеграционный «убить БД на finish → после восстановления ни строка, ни ран не застряли»; клаузы юнитами («живой entry не трогать», «staleness от last-progress»); «finalizeFailed исключает interrupt-resume»; «reconcile-штамп 'aborted' → поздний owner-finalize с реальным контентом ПЕРЕТИРАЕТ».
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#487