feat(ai-chat): терминальный жизненный цикл ранов — единый гейт, supersede CAS, reconcile (#487) #504
Open
agent_coder
wants to merge 10 commits from
feat/487-terminal-lifecycle into fix/486-iter1-prod-fires
pull from: feat/487-terminal-lifecycle
merge into: vvzvlad:fix/486-iter1-prod-fires
vvzvlad:main
vvzvlad:feat/489-tool-resilience
vvzvlad:fix/486-iter1-prod-fires
vvzvlad:develop
vvzvlad:feat/481-version-coherence
vvzvlad:refactor/347-client-md-paste
vvzvlad:perf/479-getpage-cache
vvzvlad:refactor/449-write-invariants
vvzvlad:refactor/450-split-client
vvzvlad:ci/476-publish-gates
vvzvlad:feat/443-get-page-context
vvzvlad:feat/443-get-tree
vvzvlad:feat/443-search
vvzvlad:docs/415-lossy-descriptions
vvzvlad:feat/413-markdown-default
vvzvlad:refactor/412-camelcase-tool-names
vvzvlad:feat/425-drawio-graph
vvzvlad:fix/464-diffdocs-cpu-guard
vvzvlad:refactor/411-update-page-markdown
vvzvlad:feat/424-drawio-rules
vvzvlad:fix/409-invalid-node-validation
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/444-agent-loop-guards
vvzvlad:fix/396-sendnow-detached-run
vvzvlad:fix/452-immutable-cache-control
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#504
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/487-terminal-lifecycle"
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
Итерация 2 эпика #497 — терминальный жизненный цикл ранов. Один PR, 4 строго упорядоченных коммита (+ nit-фикс коммента по внутреннему ревью). Зависит от #486 (честный 503 beginRun). closes #487
Стоит стопкой на #486 (
fix/486-iter1-prod-fires, PR #500) — база указывает на неё, в диффе только #487. Мержить после #486 (при мерже базу перенацелю на develop).fix(ai-chat)paginateAll+ пре-коммит вmutatePageContent, env-cap.feat(ai-chat){terminalWriteFailed,intended});settledPromiseв отдельной map (переживает active.delete, резолв ровно раз, поздний подписчик); ВСЕ терминальные UPDATE условны (WHERE status IN pending/running) — двойной settle = benign no-op.feat(ai-chat)start-метаданных. SupersedePOST /stream {supersede:{runId:X}}: валидация→400, degrade, mismatch/timeout→409, ready;awaitSettledW=10s. Реальный гейт — партиальный уникальный индекс (два рана не сосуществуют).feat(ai-chat)streaming OR finalizeFailed; периодический reconcile (клаузы a→d, «нет entry» первичный гейт, staleness от last-progress); оппортунистический per-chat reconcile на старте хода.How verified
src/core/ai-chat(--runInBand) 910/910; mcptsc+node --test811/811; real-DB интеграция (ai-chat-run.int-spec,ai-chat-reconcile.int-spec) 17/17.⚠️ Осознанно отложено в #488 (клиентский FSM, который эта итерация разблокирует)
Удаление клиентской лестницы supersede (
SUPERSEDE_RETRY_DELAYS_MS/isRunAlreadyActive/supersedeRetryRef) + пробросsupersede:{runId}в тело POST НЕ сделаны здесь: требуют, чтобы клиент подхватывалrunIdизstart-метаданных (сейчас клиент runId вообще не трекает) — это ровно работа итерации 3 (#488). Диф не трогаетapps/client(0 файлов): старая лестница цела и деградирует штатно (второй таб → 409 A_RUN_ALREADY_ACTIVE → старый ретрай его обрабатывает), полу-заведённого состояния нет. Серверный контракт под #488 полон (runId в метаданных, тело supersede принимается, все SUPERSEDE_*-коды).Checklist
Готово к ревью. Итерация 2 эпика — терминальный жизненный цикл ранов, 4 строго упорядоченных коммита. Стоит стопкой на #486 (PR #500); мержить после него, базу перенацелю на develop при мерже.
Что: race-on-abort для in-app тулов (Stop реально прерывает + per-call cap), зомби finalizeRun + settledPromise + УСЛОВНЫЕ терминальные UPDATE (двойной settle = no-op), единый гейт обоих режимов (второй таб → 409 и в legacy — было два параллельных стрима на чат) + supersede по CAS, ретраи finalizeAssistant + reconcile зависших строк. Детали в теле PR.
Проверка: server ai-chat 910/910, mcp 811/811, real-DB интеграция 17/17. Тесты — наблюдаемые свойства против реального механизма (условный UPDATE против БД, CAS-ветки, reconcile-клаузы на реальных зависших строках), не таймингозависимые прокси.
Внутренний цикл: 1 проход (адверсарно, исчерпывающе по трём concurrency-рискам). Все чисто: два рана на чате не сосуществуют — реальный гейт это партиальный уникальный индекс ai_chat_runs_one_active_per_chat, а не in-memory map; ни одного остаточного безусловного терминального UPDATE; reconcile не может тронуть живой ран (первичный гейт «нет entry» + staleness от last-progress, подтверждено тестами). Поправил 1 nit (устаревший коммент про set/restore toolAbortSignal — на деле set-and-leave).
Важно: клиентская часть supersede (удаление ретрай-лестницы + проброс runId в тело) ОСОЗНАННО отложена в #488 — требует, чтобы клиент трекал runId из start-метаданных, а это ровно клиентский FSM. Диф не трогает apps/client (0 файлов), старая лестница деградирует штатно (409 → старый ретрай), клиент не сломан. Серверный контракт под #488 полон.
Ревью #504 — терминальный жизненный цикл ранов (#487). Вердикт: changes-requested
Дизайн конкурентности сильный и подтверждён: стейт-машина тотальна (coherence), гонок/утечек/false-terminal нет (stability проследил конкретные интерливинги), DB-индекс как реальный гейт + app-мост — корректная слоистость (architecture, без форка). CAS/индекс проверил на реальном PG — 17/17. Блокирует один security-DO (пропущен owner-гейт на supersede) + доки + два теста. Мержить после #486 (стоит стопкой на #500, который сейчас красный — 4 share-теста; #504 своих падений НЕ вносит).
Открыто: F1 (BLOCKER — supersede без проверки владельца чата), F2 (доки #487), F3/F4 (два непокрытых пути).
🔧 Сделать, потом ре-ревью
/streamне проверяет владельца чата — утечка чужого runId + остановка чужого рана.ai-chat.controller.ts:392stream()— единственный эндпоинт, который НЕ зовётassertOwnedChat(все 6 соседних —getRun/stop/history/rename/delete/attachRunStream— зовут, гейт поcreatorId). #487 добавляет supersede-путь (стр. ~449), эксплуатируемый членом того же воркспейса, НЕ владельцем чата:/stream {chatId:<чужой чат>, supersede:{runId:<рандом UUID>}}→getRun(рандом)=undefined (гардinvalidпропущен) →getActiveForChat(чужой чат)вернёт живой ран → mismatch → ответ 409 SUPERSEDE_TARGET_MISMATCH {activeRunId: <чужой runId>} — атакующий получил чужой активный runId.supersede:{runId:<утёкший>}→requestStop(<чужой runId>)— прерывает чужой in-flight ран (то, что сосед/stopгейтитassertOwnedChat).Проверил сам:
stream()нигде до supersede-блока не зовётassertOwnedChat. Фикс: в началеstream()(или перед supersede-CAS)await this.assertOwnedChat(body.chatId, user, workspace)при наличииbody.chatId— зеркально/stop. Бонусом закрывает и предсуществующую (база #500, вне scope) неогороженную кросс-юзер запись через тот жеstream()..env.example: три новыхAI_CHAT_SUPERSEDE_TIMEOUT_MS/AI_CHAT_RECONCILE_INTERVAL_MS/AI_CHAT_INAPP_TOOL_CALL_CAP_MS(дефолты 10000/120000/120000; связь cap↔staleness=max(2×cap,15мин)) — все прочие AI_CHAT_* там документированы.AGENTS.md:458: секция рана устарела (описывает как flag-gated; теперь каждый ход — run, гейт универсален; флаг = только disconnect-семантика); + устаревший комментai-chat.service.ts:1129(«на легаси-пути effectiveSignal — socket signal» — теперь оба режима на run-signal).reconcile()иreconcileChat()не покрыты. Клаузы (a-d) протестированы поштучно в int-spec, но саму продовуюreconcile()(порядок клауз + per-clause try/catch изоляция «падение одной не блокирует другие») никто не гоняет — мутация «убрать try/catch у клаузы (b)» оставит (c)+(d) молча пропущенными, ни один тест не упадёт.reconcileChat()(юзер-facing, на старте каждого хода) не покрыт вовсе. Фикс: тест, что все 4 клаузы срабатывают по порядку И брошенная клауза не срывает остальные; + тестreconcileChat().paginate-abort-safepoint.test.mjsпокрывает только READ-safe-point; write-seam (collaboration.ts:279/context.ts:603signal?.throwIfAborted()передsession.mutate) — нет. Мутация «убрать throwIfAborted» ни один тест не уронит — интегрити-критичная половина без гарда. Фикс: мок-collab юнит: pre-aborted signal →mutatePageContent/mutateLiveContentUnlockedреджектят ДОsession.mutate(без вызова transform).✅ Что проверено (9 аспектов + гейт, изолированная дельта #487)
node --test812/812 (paginate-abort-safepoint и др.); серверный tsc — файлы #504 чисто; ai-chat спеки (blast radius) — падают ТОЛЬКО 2 унаследованных #500 share-сьюта, #504 своих падений НЕ вносит; Docker int-gate на реальном PG (pgvector:pg18): ai-chat-reconcile + ai-chat-run 17/17 — DB-CAS / partial unique index «один активный ран на чат» + условные терминальные UPDATE + reconcile проверены на настоящей БД. (Полный серверный сьют в чистой комнате OOM-ит — но whole-repo grep по переименованным символам (regressions) + прогон всей ai-chat-директории покрывают кросс-спек-класс.)active.has, не staleness); zombie ограничен + гарантированно передавливается; листенеры once. Одна астрономически-маловероятная ms-гонка (>15-мин молчаливый шаг) — no-data-loss, DROP.finalizeIfActive/finalizeOwner— НОВЫЕ методы (не переименования), старыйupdateжив, все достижимые вызыватели/ассерты обновлены (урок #500 применён grep'ом); коды SUPERSEDE_* аддитивны; легаси-409 уже обрабатывается клиентом. #504 своих спеков не ломает.⛔ DROP — кодеру НЕ делать · калибровка
[below-threshold]medium[conventions] Три новыхAI_CHAT_*читаются инлайнprocess.env(один — на загрузке модуля, нетестируемо), минуя EnvironmentService-аксессоры. Конвенция мягкая (инфра-значения тоже так читают); можно принять.[below-threshold]low[simplification] Дублированный stuck-message settle-loop (reconcile clause b + reconcileChat) + повторный env-ms parse в 3 местах — механический дедуп.[speculative]low/low[stability] reconcileStaleRuns vs in-flight finalize — ms-окно, требует >15-мин молчаливого шага + попадание 2-мин тика; no-data-loss (только статус рана). Опц. харднинг: reconcile скипать иsettled-сет.[info][regressions] Легаси второй таб → 409 (намеренно, уже обрабатывается клиентом, заменяет ранее падавший параллельный стрим).9 аспектов на изолированной дельте #487 (база = #500 head); security-DO пойман фан-аутом (мой беглый прочит смотрел не тот эндпоинт — /stop, а дыра в /stream). Дизайн конкурентности силён и проверен на реальном PG; DO — периметр (authz-гейт + доки + два теста), не ядро. Стоит на #500 — мержить после его фикса.
Раньше 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>3d8bc655ebto1d704d4ec5Готово, все 4 находки закрыты; ветка ребейзнута на актуальный
fix/486-iter1-prod-fires(тот уже зелёный на develop), полный серверный сьют зелёный из чистого состояния: 208 suites / 2461 passed.F1 [security, BLOCKER] — утечка чужого runId через
stream()Добавил
await this.assertOwnedChat(body.chatId, user, workspace)в началеstream()(при наличииbody.chatId) — до supersede-CAS,getActiveForChatи разрешения роли, зеркально/stop. Проверено адверсарно: ни одна строка до гейта не трогает чужой чат/ран, так что409 SUPERSEDE_TARGET_MISMATCH(несущийactiveRunId) до гейта недостижим. Не-владелец → голыйForbiddenExceptionбез тела (нет оракула существования). Путь создания нового чата (безchatId) и агентский in-app-путь (отдельный контроллер) не задеты. Бонусом закрыта и предсуществующая неогороженная кросс-юзер запись через тот жеstream().Тест
ai-chat.controller.supersede.spec.ts: не-владелец → 403,run-victimНЕ в ответе,supersede/requestStop/getActiveForChat/streamне вызваны. Mutation-verified: снятие гейта → тест краснеет (проваливается в supersede, возвращает mismatch с чужим runId).F2 [docs]
CHANGELOG (supersede-эндпоинт + 3 кода + изменение поведения «легаси второй таб → 409 A_RUN_ALREADY_ACTIVE»);
.env.example(триAI_CHAT_SUPERSEDE_TIMEOUT_MS/RECONCILE_INTERVAL_MS/INAPP_TOOL_CALL_CAP_MS, дефолты 10000/120000/120000, связь cap↔staleness=max(2×cap,15мин));AGENTS.md:458(универсальный run в обоих режимах, флаг = только disconnect-семантика); поправлен устаревший комментai-chat.service.tsпро effectiveSignal.F3 [test-coverage]
Оркестраторы
reconcile()(порядок клауз a→b→c→d + пер-клаузная try/catch-изоляция) иreconcileChat()покрыты. Mutation-verified: снятие try/catch у клаузы (b) → c+d молча пропускаются → тест изоляции краснеет.F4 [test-coverage]
Write-safe-point «после Stop новая запись не стартует» покрыт (
write-abort-safepoint.test.mjs, оба шва —mutatePageContentиmutateLiveContentUnlocked): pre-aborted signal → реджект доsession.mutate,transformне вызван; контрольные кейсы с живым сигналом достигаютsession.mutate. Mutation-verified: снятиеthrowIfAborted→ ровно 2 abort-теста краснеют.Внутренний цикл (калибровка)
1 проход ре-ревью. Поймал залипший маркер конфликта
>>>>>>> 917c4064вcontext.ts— артефакт ребейза (моя недорезолвленная правка), ломал сборку всего mcp-пакета (TS1185), из-за чего F4 не запускался; удалён отдельным коммитом. И адверсарно подтвердил, что owner-гейт стоит раньше любого места утечки runId (а не после, как сосед/stop, который я поначалу смотрел).Коммиты разбиты на 5 (маркер / F1 / F2 / F3 / F4) для читаемости ревью. Стоит стопкой на #500 (
fix/486, тоже зелёный, у ревьюера) — при мерже базу перенацелю на develop в порядке 486→487.Ревью #504 — раунд 2. Вердикт: approved ✅
Все 4 находки закрыты; ключевые (security + integrity) проверил мутацией лично. Ветка ребейзнута на уже-зелёный #500. Мержить после #500.
assertOwnedChat(body.chatId, user, workspace)добавлен в началоstream()(после A7-гейта, ДО supersede-CAS/getChatModel), зеркально/stop. Mutation-verified лично: снял гейт (controller.ts:431) →ai-chat.controller.supersede.specкраснеет (1 failed) — тест реально ловит утечку чужогоrun-victimrunId. Не-владелец → голый 403 без тела (нет оракула существования). Бонусом закрыта предсуществующая кросс-юзер запись черезstream()..env.example(три AI_CHAT_* с дефолтами + связь cap↔staleness);AGENTS.md:458(универсальный run); поправлен коммент про effectiveSignal. Сверено.reconcile()(порядок a→b→c→d + пер-клаузная try/catch-изоляция) иreconcileChat()покрыты (в зелёномai-chat.service.spec); кодер mutation-verified снятием try/catch.write-abort-safepoint.test.mjs. Mutation-verified лично: снялthrowIfAbortedв write-seam (collaboration.ts:281) → тест краснеет — «после Stop новая запись не начинается» реально загарджено.✅ Объективка (что прогнал сам)
node --test830/830 (вкл. write-abort-safepoint + paginate-abort-safepoint).Дизайн конкурентности (тотальная стейт-машина, reconcile не убивает живой ран, CAS backstop индексом) подтверждён в р1 (coherence+stability+architecture LGTM).
→ approved. Мержить после #500 (стоит на нём стопкой).
Раунд 2: спотлайт на закрытии F1-F4 + мутационная проверка криталов (F1 security, F4 integrity) + прогон всей ai-chat-директории чанками (урок #500 про полный сьют — применён, blast-radius весь зелёный).
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.