Стабилизация, итерация 2: терминальный жизненный цикл ранов — единый гейт, supersede, reconcile (один PR, 4 коммита) #487
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Один 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 → ран финализирован, а запись доезжает в страницу ПОСЛЕ стопа.
Правка (нормативно):
wrapToolWithCallTimeout(mcp-clients.service.ts:748-805): вызов гонится против композитного сигнала (abort + per-call cap), на abort — реджект немедленно, проигравший промис отбрасывается. Реализация «signal в axios и ждать throw» НЕ удовлетворяет (латентность = сетевой teardown; collab не покрыт) — от race-семантики зависит таймаут supersede из коммита 3.mutatePageContent.Тест (честное свойство): «после 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) — последний писатель перетирает терминальный статус.Правка:
{ terminalWriteFailed: true, intended: { status, error } }.settledPromiseв ОТДЕЛЬНОЙ maprunId → deferred(не в entry — обязан переживатьactive.delete-claim); создаётся вbeginRun; резолвится ровно один раз исходом «записано/сдался» (поздний подписчик получает резолвленный); отсутствие записи в map → трактовать как «сдался» с чтением статуса из строки.WHERE status IN ('pending','running'), зеркальноonlyIfStreamingу сообщений): двойной settle любых писателей схлопывается в benign no-op.Тесты: юниты 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.Правка:
start— нужен клиентскому FSM); различие режимов сводится к семантике abort: в legacyonCloseпри дисконнекте вызываетrequestStop(runId)вместоcontroller.abort(). Второй таб в любом режиме → 409A_RUN_ALREADY_ACTIVE. Release notes: для legacy это наблюдаемое изменение (двухтабный параллелизм был багом, но работал).POST /stream { supersede: { runId: X } }:X.chatId === body.chatId, иначе 400;409 SUPERSEDE_TARGET_MISMATCH+ текущий runId в теле; клиент НЕ авто-ретраит;requestStop(X)→ await settledPromise(X), таймаут W=10 c → «записано» или «сдался» (тогда применить intended условным UPDATE) → beginRun → insert user msg;409 SUPERSEDE_TIMEOUT, ничего не персистится, композер сохраняет текст. SUPERSEDE_TIMEOUT ≠ rollback: stop уже выдан, старый ран обречён, ручной resend найдёт слот свободным.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 навсегда».Правка:
status='streaming' OR metadata.finalizeFailed=true(перетирает status-only-штамп reconcile); ВСЕ status-only-штампы reconcile пишут строгоonlyIfStreamingИ ставятmetadata.finalizeFailed: true(все, не только succeeded-маппинг — иначе поздний owner-write не перетрёт aborted-штамп).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 (страховка для исторических строк).
sweepStreaming— из boot-only в периодический.Тесты: интеграционный «убить БД на finish → после восстановления ни строка, ни ран не застряли»; клаузы юнитами («живой entry не трогать», «staleness от last-progress»); «finalizeFailed исключает interrupt-resume»; «reconcile-штамп 'aborted' → поздний owner-finalize с реальным контентом ПЕРЕТИРАЕТ».