62482827c9b4e7d4965304597b2a701ca5dbfe8f
2175 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
62482827c9 |
fix(ai-chat): ре-ревью #518 — CI-фиделити delta, attach-дубль, чистки (#491)
Ребейз на обновлённый feat/490 (PR #510, Option-A): стек #491 перенесён с
|
||
|
|
2a58b63e15 |
fix(ai-chat): ре-ревью #491 — дубль на getRun-fail + epoch RUN_FACT + doc (#491)
Ре-ревью нашло регрессию класса #137/#161 на пути отказа getRun при tail-only
re-seed:
- НАХОДКА 1 (MEDIUM/HIGH): в onFinish-disconnect ветка .catch (отказ getRun) на
локальном дропе входила в reconnect-ladder БЕЗ ре-сида и БЕЗ фильтра «живой»
частичной строки. anchorRef оставался устаревшим (mount-инициализатор), живая
частичная строка со шагами N..M-1 — последней; через ~1с реконнект строил
?anchor=&n=N_mount, и при живом ране с покрытием от N_mount (flaky-сеть: SSE и
getRun упали, сеть поднялась за 1с) сервер отдавал кадры ≥N_mount → SDK дописывал
их к строке, где они УЖЕ есть → дублирование (клиентского дедупа реплея против
parts нет). Фикс: восстановлена структурная гарантия удалённого resumeStream-
фильтра — на отказе getRun (и на no-persisted-row, и на no-cid) живая частичная
строка удаляется из стора по id + anchorRef=null → реконнект реплеит со start в
ЧИСТЫЙ стор (полная пересборка) либо 204→poll. Нет пути, где attach tail-applies
на строку с уже присутствующими шагами. Тест: getRun-reject на локальном
дисконнекте → живая строка отфильтрована + URL без параметров (mutation-verify:
без фикса тест краснеет — фильтр не срабатывает).
- НАХОДКА 2 (LOW): RUN_FACT в enterReconnect теперь epoch-штампуется (epoch:
stampEpoch), как везде (postRun): getRun-rtt расширяет окно onFinish→dispatch,
конкурентный SEND_LOCAL во время rtt теперь дропает устаревший RUN_FACT по I1,
а не перетирает runFact.runId нового хода.
- НАХОДКА 3 (LOW, doc): run-fsm.spec.md обновлён — stripRef/strippedRowRef →
anchorRef {id, stepsPersisted}, tail-only + re-seed-from-persist.
FSM run-fsm.ts не тронут; инварианты #488 (epoch/honor-in-stopping/ownership-reset/
disconnect-first/render-gate) сохранены. Клиент ai-chat vitest 399 зелёный, tsc 0
ai-chat-ошибок; сервер delta(6, реально исполняется)/registry/step-marker/attach +
integration attach — зелёное.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
2dfc5c5144 |
feat(ai-chat): клиентская активация tail-only resume + delta-поллинг (#491)
Переводит клиент на серверный tail-only контракт resume (задел commit 3),
не трогая FSM run-fsm.ts — меняется только рантайм-обвязка в chat-thread.tsx.
A. Убран STRIP-механизм. Seed теперь содержит ВСЕ персистнутые строки без
изъятия хвоста (стриминговый хвост — это шаги 0..N-1, к которым SDK-
продолжение дописывает tail). stripRef/strippedRowRef заменены на anchorRef
{ id, stepsPersisted } — персистнутая assistant-строка, питающая
?anchor=<id>&n=<stepsPersisted>. Восстановления stripped-строки на
204/NONE/starved удалены (строку никто не изымал — нечего восстанавливать);
invalidateQueries + диспатчи FSM сохранены. Блок anchor-mismatch в reconcile
сверяется по id из свежей персист-истории, а не по «живой» строке.
B. Вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста; «живой»
стор НИКОГДА не база для tail-apply. На локальном FINISH_DISCONNECT (и на
live-follow повторном дропе observer-а) сначала getRun(chatId) → замена
«живой» частичной строки персистнутой по id (mergeById) + установка anchor,
и лишь ПОСЛЕ этого диспатч RUN_FACT + FINISH_DISCONNECT (который планирует
реконнект). Так attach не может продублировать частичный шаг N. Фильтр
«живой» строки в resumeStream-эффекте убран (его заменяет re-seed). Инварианты
FSM (I1 epoch-штамп, I4 honor-in-stopping, DISCONNECT-FIRST, сброс ownership на
терминалах, render-gate) сохранены.
C. URL attach: ?anchor=<id>&n=<stepsPersisted> при наличии якоря, без expect.
D. Degraded-поллинг переведён с полного рефетча всех страниц на дельту:
useAiChatMessagesQuery больше не поллит (seed один раз), а окно при
degradedPoll раз в 2.5с зовёт getAiChatMessagesDelta(chatId, cursor) и
идемпотентно по id мёржит строки в тот же infinite-query кэш через новый
чистый хелпер mergeDeltaRowsIntoPages. Арминг/разарм (onResumeFallback) и
idle-cap не тронуты.
Хелперы: seedRows удалён; добавлены stepsPersistedOf и mergeDeltaRowsIntoPages
(+ юнит-тесты на идемпотентность). Тесты chat-thread обновлены под новый URL,
seed-без-стрипа, re-seed-из-персиста на дисконнекте (mutation-verify: падают
без re-seed и при n мимо персиста) и 204→poll-без-restore. Весь ai-chat vitest
зелёный (398), tsc без новых ошибок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
878ede409b |
fix(ai-chat): целостность delta-spec (молчаливый скип) + 2 hardening (#491)
Внутреннее ре-ревью: DB-backed delta-spec молча СКИПАЛСЯ — нулевое покрытие
инварианта DB-clock курсора.
- Импорт `import postgres from 'postgres'` (default) при tsconfig
module:commonjs без esModuleInterop компилился в `postgres_1.default(...)`,
а CJS-`postgres` не имеет `.default` → TypeError в beforeAll → пустой catch →
reachable=false → все 6 тестов уходили в console.warn('SKIP') и return БЕЗ
ассертов (suite оставался бы зелёным даже при регрессии на new Date()).
Фикс: `import * as postgres from 'postgres'` (как в рабочем int-харнессе).
- Хардненг харнесса: реальная ошибка программирования в beforeAll больше НЕ
маскируется под «DB unreachable» — скип легитимен только для сетевого отказа
(ECONNREFUSED и т.п.), иначе rethrow → suite падает громко.
- Два DB-clock теста использовали jest.useFakeTimers() целиком, что замораживало
внутренние таймеры postgres.js → awaited DB round-trip зависал на 5s-капе (и
вешал afterAll). Фейкаем ТОЛЬКО Date (doNotFake всех таймеров) — запрос
резолвится, а инвариант «стамп от часов БД, не app-clock» по-прежнему доказан
(скос процесс-часов в 2099 → стамп остаётся на времени БД). Теперь все 6
тестов РЕАЛЬНО исполняются и зелёные против живого Postgres.
Два дешёвых hardening из ревью:
- registry coverageFloor: пустая ветка возвращает max(currentStamp,
persistedFloor) — инвариант «клиент с n=persistedFloor всегда покрыт»
структурный, а не тайминг-зависимый.
- GetChatDeltaDto.cursor: @IsString → @IsISO8601 — битый курсор отсекается 400
на уровне DTO, а не 500 на `::timestamptz`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b8185a9f0e |
refactor(ai-chat): registry — step-aligned retention + tail-only attach (#491)
Реестр ран-стримов больше не буферизует до 32МБ сырых SSE-кадров на активный
ран и не выливает весь буфер в сокет синхронно при attach — это давало OOM на
1ГБ-контейнере при нескольких марафонских ранах. Теперь кольцо ограничено
(env-настраиваемо, по умолчанию 4МБ) и держится в границах за счёт ротации по
шагам.
Серверная часть (суть коммита):
- Штамповка кадров по шагам в ingestFrame. Штамп кадра = число `finish-step`
кадров ДО него (с 0); сам finish-step несёт текущее значение, затем счётчик
инкрементится. Так штамп совпадает с `metadata.stepsPersisted`: клиент с N
персистнутыми шагами имеет 0..N-1 в сиде и просит хвост `stamp >= N`. Границу
ловим дешёвым startsWith по `data: {"type":"finish-step"` — форма кадра
проверена эмпирически против ai@6.0.207 (одна часть на кадр, type всегда
первый ключ; кавычки в text-delta экранированы, ложных срабатываний нет).
- Кольцо ротируется ТОЛЬКО на подтверждённом персисте шага N
(`confirmPersistedStep`), сбрасывая кадры `stamp < N` (эти шаги уже на диске и
придут в свежем сиде). `updateStreaming` теперь СИГНАЛИЗИРУЕТ исход (число
персистнутых шагов или null), и ротация вызывается лишь при не-null возврате —
провал персиста ничего не ротирует, кольцо покрывает БОЛЬШЕ (анти-инверсия:
наивная ротация в .then() после НЕзаписанного шага дырявила бы гарантию).
- Переполнение кольца сверх байтового капа вытесняет старейшие кадры; вытеснение
ещё-не-персистнутого кадра открывает GAP. Гэп НЕ липкий: floor покрытия
считается из кольца, поздний персист, проротировав дырявые шаги, его чистит.
- attach(chatId, anchor, n): маркер шага N приходит ТОЛЬКО от клиента (сервер не
читает строку — N из устаревшего сида дал бы тихую дыру в один шаг). Покрытие
ОК ⟺ coverageFloor <= n; иначе 204 → клиент рефетчит (больший N) и
переподключается. Хвост = синтетический `start`-кадр (ран-факт runId/chatId) +
кадры `stamp >= n`. Инвариант 6 (нет кросс-ран реплея) сохранён через anchor;
инвариант 4 (снапшот+регистрация в один синхронный тик) сохранён. N-срез
применяется во ВСЕХ ветках, включая finished-retained: finished + N=N_final →
пустой хвост + finish-кадр, клиент закрывает стрим.
- Контроллер пишет хвост чанками с учётом drain (writeTailRespectingDrain), а не
синхронным залпом (вторая половина OOM). Кап подписчика — производное 2× кап
кольца, обе величины env-резолвятся на инстансе.
Клиент: в тип строки добавлен `metadata.stepsPersisted` (источник N). PIN-SPEC
трип-вайр на ai@6.0.207: `readUIMessageStream({ message })` продолжает последнее
сообщение, `start`-кадр не сбрасывает parts, текст не пересекает finish-step —
на этом держится продолжение при attach; апгрейд ai теперь падает громко.
Тесты (observable-property против РЕАЛЬНОГО реестра/БД): детектор границы на
реальной форме кадра, N-срез (в т.ч. посреди шага), ротация только на
подтверждённом персисте, «персист провалился но кольцо влезло → attach успешен /
провал + переполнение → 204», «устаревший N → 204 → после рефетча успех», очистка
гэпа поздним персистом, finished-retained + N_final, memory-bound (5 параллельных
марафонов сверх 32МБ, каждое кольцо ≤ кап). Обновлены registry/controller specs и
DB-backed интеграционный attach-spec под новую сигнатуру/семантику.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
108d2c6bef |
feat(ai-chat): персист step-маркера (#491)
Персист отставал от live-стрима на целый текущий шаг, а run.stepCount как
источник границы шага НЕгоден: recordStep — fire-and-forget, не атомарен с
записью parts (рассинхрон сид↔маркер). Пишем маркер ТЕМ ЖЕ flush'ем, что и
parts:
- flushAssistant стампует metadata.stepsPersisted = число ЗАВЕРШЁННЫХ шагов,
чьи parts лежат в ЭТОЙ строке (оба выводятся из finished → маркер не может
разойтись с persisted parts). In-progress хвост (частичный шаг при
error/abort или mid-stream flush) НЕ считается. Это step-alignment якорь, на
котором строится resume-стек (ротация кольца по подтверждённому шагу N —
коммит 3; attach режет хвост по «шаг > N»).
- Контракт AiChatRunService.reconstructRunParts(runId) → { parts,
stepsPersisted } — единственный интерфейс чтения ЖИВОГО рана (run →
assistantMessageId → строка → чистый reconstructPartsFromRow). null при
отсутствии рана / связанной строки / удалённой строки; маркер 0 у pre-#491
строки — безопасный пол. Потребители: attach (коммит 3), дельта (rows уже
несут маркер в metadata), экспорт. messageRepo — опциональный 3-й параметр
конструктора (2-арг тест-конструкции компилируются без изменений).
- /messages и дельта отдают маркер в row автоматически (он внутри metadata).
Тесты: property «stepsPersisted == число завершённых шагов для любого N + parts
согласованы»; частичный хвост не инкрементит маркер; reconstructPartsFromRow
(маркер, дефолт 0, фолбэк на content); reconstructRunParts (резолв + null-кейсы).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
1e28151f7a |
perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе (#491)
Degraded-poll рефетчил ВСЕ страницы infinite-query каждые 2.5 c с полными
parts. Вводим дельта-эндпоинт «строки, изменённые после курсора»:
- POST /ai-chat/messages/delta → { rows, cursor, run: {id,status}|null }.
Курсор — таймстамп часов БД (now()), клиент эхом возвращает его каждый
поллинг. Окно перекрытия now()−5s ловит строку, закоммиченную с updatedAt
чуть раньше момента снятия предыдущего курсора на другом autocommit-
соединении (одиночные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ
повторы → контракт: клиентский merge идемпотентен по id (mergeById).
- Run-факт едет В дельте (отдельный /run-поллинг удвоил бы QPS — отвергнуто).
- Все дельта-релевантные записи (message update/finalizeOwner/reconcile/sweep,
run update/finalizeIfActive/markStopRequested/sweepRunning) стампуют
updatedAt через SQL now(), а не app-clock new Date(): единая монотонная ось
курсора, смешанные источники часов были независимым источником пропусков.
- Клиент: сервис-функция getAiChatMessagesDelta + фиксация контракта
идемпотентности mergeById тестом (свап degraded-поллинга на дельту — в
коммите 3, вместе с re-seed-путём attach).
Тесты (observable-property на живом Postgres, не моки):
- дельта-семантика + монотонность курсора;
- RACE «коммит позже с updatedAt раньше курсора» — ловится окном перекрытия
(наивный updatedAt > cursor пропустил бы);
- окно гарантирует повторы → идемпотентный merge;
- updatedAt стампуется часами БД, а не app-clock (фейк системных часов в 2099
— стамп остаётся на времени БД);
- контроллер: owner-gate, форма ответа, run-факт только {id,status}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
6e42752a9c |
fix(ai-chat): ревью #510 — Опция A бюджета + 4 DO ревьюера
Эскалация (владелец) — Опция A: 100k только фолбэк для НЕсконфигурированных инсталляций; при заданном chatContextWindow бюджет = floor(0.7×window) БЕЗ капа (бюджетер — защита от брика об контекст-окно, не эконом-лимитер). Спек репиннут resolveReplayBudget(1_000_000)→700_000. DO1: агрессивный next-turn recovery ×0.5 вынесен в чистую resolveEffectiveReplayThreshold + тест линковки replayOverflow→0.5×бюджет (mutation-verified). DO2: checkNewComments partial-failure — per-page reject скипается (→null), скан резолвится, порядок выживших сохранён; тест #7 (mutation-verified). DO3: ai-chat.write-volume.spec.ts → .int-spec.ts (WAL-гард не бежал НИ в одном CI-lane) + маппер @docmost/token-estimate в jest-integration.json; реальный WAL на pg:5432 зелёный (трейс v1 140MB→v2 0.04MB). DO4: CHANGELOG [Unreleased] по #490. Follow-up: issue #520 (эскалация агрессивной доли при незаданном окне + малом реальном контексте). Ребейзнут на develop (волна 1 смержена): только 6 коммитов #490 над develop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8430eea700 |
perf(mcp): checkNewComments — параллелизм с капом (#490)
checkNewComments делал O(N) последовательных REST-вызовов listComments по страницам working set — большой space линеен по round-trip'ам. Теперь per-page фетчи идут с ограниченным параллелизмом (cap 6, середина полосы 5–8): независимые чтения не ждут друг друга, но и не заваливают сервер/сокеты. mapWithConcurrency — крошечный пул без зависимости от p-limit: N воркеров тянут следующий индекс с общего курсора. Порядок результатов сохраняется (по входному порядку страниц), поэтому вывод детерминирован независимо от того, какой фетч завершился первым. Серверный batch-эндпоинт «comments updated since T по space» — опционально, отдельным заходом. Тест (mock-HTTP): 13 страниц, задержанный /api/comments — maxInFlight > 1 и <= 6 (последовательная реализация дала бы 1), порядок результатов = порядок обхода. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5563811aa8 |
perf(ai-chat): snapshotOpenPage fast-path (#490)
snapshotOpenPage делал полный экспорт Markdown + upsert каждый ход. Fast-path: если снапшот уже существует на ТЕКУЩЕЙ версии страницы (тот же instant updated_at), его контент уже актуален — пропускаем экспорт+upsert целиком. Ход, не тронувший открытую страницу (частый случай), больше не делает работы по снапшоту. Зеркалит read-side fast-path в detectPageChange (sameInstant): оба доверяют, что правка страницы двигает updated_at. Когда агент/человек ПРАВИЛ страницу этим ходом, updated_at продвинулся → не совпадает → экспортируем как раньше (правки агента запекаются в снапшот, инвариант #274 сохранён). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
55a1c279fc |
perf(ai-chat): deferred-активация тулов в metadata чата (#490)
Активированный set сбрасывался каждый ход → модель заново гоняла loadTools, чтобы
переактивировать те же тулы (лишний round-trip на каждом ходу). Теперь набор
персистится в metadata чата и сидируется на следующем ходу.
- Миграция: jsonb-колонка metadata на ai_chats (default '{}'); db.d.ts дополнен
вручную (AiChats.metadata: Generated<Json>).
- seedActivatedTools(metadata, validDeferredNames): читает сохранённый набор,
ПЕРЕСЕКАЯ с актуальными validDeferredNames — смена allowlist/ролей не воскресит
несуществующий тул (иначе prepareAgentStep получил бы фантомное активное имя).
Сид только при deferredEnabled.
- Персист на завершении хода (once-guard, во всех терминальных ветках рядом со
snapshotTurnEnd): детерминированно отсортированный набор, merge в существующий
bag (другие ключи сохраняются), запись пропускается если ничего нового не
активировано (обычный ход не даёт лишней записи).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d4b21957dc |
feat(ai-chat): токен-бюджет реплея истории + реактивная ветка (#490)
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат рано или поздно упирается в контекстное окно и получает провайдерский 400 на каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable (обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache). Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»). history-budget.ts (чистый, покрыт тестами): - resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 = явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает 0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw). - trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг tool-call/result сохраняется (схлопывание убирает ОБЕ части). - isContextOverflowError: классификация провайдерского 400 (статус + паттерны). Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход не имеет usage). onError классифицирует context-overflow → пишет различимую причину и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно (0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens. ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт тот же инвариант (чат не кирпичится) без рискованного рефактора стрима. Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на реальном 400-шейпе; parity клиент/сервер оценщика. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e71212cdc6 |
perf(ai-chat): кэш compactToolOutput по identity шага (#490)
compactToolOutput делает JSON.stringify каждого output на КАЖДОМ flush. Т.к. onStepFinish на шаге N перестраивает всю assistant-строку по всем N накопленным шагам, а каждый output — 50–200 KB, это O(N²) stringify за ход. Мемоизация по identity шага: finished-шаг в capturedSteps неизменен и держит стабильную ссылку между flush'ами, поэтому его parts (и дорогой stringify output) строятся ровно раз за ход. buildStepParts вынесен в чистую функцию; assistantParts принимает опциональный StepPartsCache (WeakMap<step, parts>), flushAssistant пробрасывает его, stream() заводит один WeakMap на ход и передаёт во все flush'и. Промах кэша (или его отсутствие в тестах/легаси-вызовах) просто пересобирает — байтового расхождения нет. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
763a1bf7bd |
perf(ai-chat): формат трейса tool_calls v2 — outputs только в parts (#490)
Каждый tool-output хранился ДВАЖДЫ: в metadata.parts (assistantParts) И в
tool_calls (serializeSteps). При 50-шаговом ране с outputs по 50–200 KB это
127–510 МБ записи в Postgres за ход (+WAL/TOAST/dead tuples), т.к. onStepFinish
переписывает всю строку. Копия в parts — та, что реально реплеится модели и
рендерится UI/markdown-экспортом, так что копия в трейсе была чистым дублем.
Новый формат элементов tool_calls (v2), парно на каждый вызов:
{toolName, input} — вызов
{toolName, ok: true} — успех (БЕЗ output)
{toolName, error, kind: 'thrown'} — брошенный tool-error
{toolName, error, kind: 'interrupted'} — прерван mid-step (abort/restart)
kind обязателен: синтетический «Tool call did not complete.» при прерывании иначе
неотличим от реального hard-fail и загрязняет error-rate. Различие структурное
(errorsById-хит против синтетической ветки), НЕ per-tool классификатор — soft-
маркеры в трейс не выносятся (остаются в metadata.parts).
metadata.toolTraceVersion: 2 — маркер эры; старые строки НЕ мигрируются
(перезапись гигантских jsonb — тот самый WAL-чарн). serializeSteps пейрит
результаты/ошибки по toolCallId (как assistantParts); общая константа
TOOL_CALL_INCOMPLETE_TEXT держит текст реплея и трейса в синхроне.
docs/reading-ai-logs.md переписан dual-shape: ветвление по toolTraceVersion,
soft-анализ v2 через metadata.parts, правило «не сравнивать агрегаты через границу
эр». UI action-log и markdown-экспорт читают только parts — не затронуты.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
bfb6a52eea |
Merge pull request 'epic #497 волна 1 — интеграция четырёх approved-итераций (#486/#487/#488/#489) → develop' (#511) from integ/497-wave1 into develop
Reviewed-on: #511 |
||
|
|
0503e8b4b1 |
fix(ai-chat): W1 — клиент читает 409-поле activeRunId (было runId=undefined)
Ревью волны 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>
|
||
|
|
b97cad0ebe | Merge remote-tracking branch 'gitea/feat/488-client-fsm' into integ/497-wave1 | ||
|
|
080dd82023 |
fix(client): ре-ревью #509 — 5 пунктов Do-листа (стабильность/регрессии) (#488)
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>
|
||
|
|
803bbd40b4 |
fix(client): фаза FSM решает баннер — reconnect не маскируется остаточным error (#488)
Браузерный 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>
|
||
|
|
c7a38e274e |
fix(client): реальный обрыв SSE → reconnect-лесенка, не терминальный error (#488)
Браузерный 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>
|
||
|
|
791a707eb6 |
docs(ai-chat): задокументировать поверхность #489 — env-var, устаревшая заметка, CHANGELOG
F1 (ревью #508): - .env.example: новая AI_MCP_SSE_BODY_TIMEOUT_MS (дефолт 600000, 10 мин) — bodyTimeout SSE-транспорта external-MCP; idle между тул-вызовами легитимен. - .env.example: поправлена ставшая ложной заметка про AI_MCP_STREAM_TIMEOUT_MS (SSE-idle-между-вызовами больше не режется им — с #489 это отдельная var; 1-мин silence остаётся только для HTTP/headers и одиночного залипшего вызова). - CHANGELOG [Unreleased]/Fixed: битый part больше не 500-ит каждый ход + не плодит дубль user-строки; MCP-транспорт-дропы восстанавливаются in-run (readOnly ретраится раз, write никогда) + новая env-var. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
60205398bb |
test(ai-chat): застабить findAllByChat в lifecycle-моке под convert-before-insert (#489)
Коммит 1 (#489) прогоняет загрузку истории + convertToModelMessages ДО insert user-строки (convert-before-insert, чтобы ретрай не плодил дубли), поэтому stream() теперь зовёт реальный метод репо findAllByChat перед insert. Хэнд-роллед мок в тесте «exception after beginRun → settled to error» стабил только insert, и тест ловил «findAllByChat is not a function» вместо «insert boom». Порядок инварианта НЕ изменился: и findAllByChat, и insert идут ПОСЛЕ beginRun (reconcileChat между ними — best-effort, глотает ошибку), так что бросок по- прежнему происходит ПОСЛЕ начала рана и обязан сеттлить ран в error. Застабил findAllByChat → [] (реальный репо-метод, см. ai-chat.controller.ts), тест снова доходит до insert boom и проверяет settle-to-error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1685b32333 |
fix(client): ре-ревью F1 — правка задетых Stop и supersede-таймингов (#488)
MEDIUM — эпоха-штамп из F1 сломал выход из `stopping` для локального Stop. STOP_REQUESTED бампит epoch (E1→E2), а onFinish аборчиваемого стрима стампится ПРЕД-стоп-эпохой E1 (start-эпоха стрима), поэтому фильтр I1 дропал FINISH_ABORT и машина зависала в `stopping` навсегда (idle-cap покрывает только polling/ reconnecting). Фикс в редьюсере: ЛЮБОЙ finish (`FINISH_*`/`STREAM_INCOMPLETE`) в фазе `stopping` честится и выводит `stopping→idle` МИНУЯ epoch-фильтр — у обычного Stop нет преемника, финиш аборта и есть ожидаемое завершение (I4). Для `superseding` фильтр сохранён (это и есть F1-drop). Тест переписан на ПРЕД-стоп-эпоху E1 (реальная проводка); MUTATION-VERIFY: снятие honor-in-stopping → зависание → красный. LOW-1 — supersede терял B, если A уже settled, а statusRef ещё «streaming». Ливнесс в sendNow теперь берётся из ФАЗЫ FSM (machineRef, обновляется onFinish'ем СИНХРОННО), а не из отрендеренного statusRef: settled-A (фаза idle) → B шлётся немедленно, без аборта мёртвого стрима и залипания в `superseding`. statusRef удалён (больше не нужен). Тест на под-кадровое окно. LOW-2 — двойной «Send now» в окне аборта A перезаписывал pendingSupersedeTextRef. Второй клик при уже летящем supersede (pendingText взведён / фаза `superseding`) — NO-OP: сообщение остаётся в очереди, ничего не теряется/не перетирается. Тест. Тесты: FSM 36 + chat-thread 39 (+error 26 +adopt 16) = 117 зелёных; tsc ai-chat 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4b78e336a2 |
fix(client): внутреннее ревью миграции FSM — F1..F4 (#488)
F1 [correctness] — supersede: перекрытие стримов рушило свежий run. ai@6 AbstractChat.makeRequest в finally читает и обнуляет общий activeResponse, поэтому параллельные стрим-A и стрим-B корёжат друг друга, а незастемпленный onFinish мёртвого A уводил ЖИВОЙ новый run в ложный reconnect / сбрасывал runFact. Фикс: (1) FINISH_*/STREAM_INCOMPLETE штампуются per-stream generation (turnEpochRef, взводится в момент старта стрима) → фильтр I1 отбрасывает финиш чужого поколения; (2) sendNow-supersede АБОРТИТ A и стартует B только из onFinish A (микротаск, после того как finally A обнулил activeResponse) — гарантия отсутствия перекрытия. Тест на поздний isDisconnect A после SUPERSEDE_REQUESTED: машина НЕ уходит в reconnect, B отправлен. MUTATION-VERIFY: снятие epoch-штампа у FINISH_DISCONNECT → тест краснеет («Connection lost — reconnecting»). F2 [correctness] — гонка mount getRun→ATTACH_START с локальным send. Редьюсер ATTACH_START теперь игнорирует любую не-idle фазу, поэтому поздний резолв getRun не перехватывает начавшийся локальный турн в observer-attach. Тест на гонку. F3 [ghost feature] — RUN_SUPERSEDED объявлен+покрыт тестом, но НИКОГДА не диспатчился. Удалён (событие+обработчик+тест+postRun-reason observer-follow) как сознательный scope-cut: наблюдатель убитого supersede-рана и так следует за новым через деградированный поллинг (свежие строки истории, независимо от runId). F4 [hygiene] — мёртвые события. STREAM_START подключён (первый ассистент-фрейм локального турна: sending→streaming, спека↔код совпали). RECONNECT_BEGIN и POLL_ACTIVITY удалены (не диспатчились). Множество событий редьюсера = множеству диспатчащихся; редьюсер тотален. Тесты: FSM 35 + chat-thread 37 (+error 26 +adopt 16) = 114 зелёных; tsc ai-chat 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
77e20ecb41 |
fix(client): миграция chat-thread на FSM — reconnect×N, stalled, supersede (#488)
Полная миграция 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>
|
||
|
|
d533daa45f |
fix(client): обрыв SSE до первого кадра ассистента → ран подхватывается (#488)
Коммит 2. Раньше вход в reconnect требовал `message?.role === "assistant"`, и обрыв в setup-фазе (до первого кадра ассистента, включая сборку MCP-тулсета с дедлайном до 60 c) не давал НИ реконнекта, НИ поллинга — а detached-ран продолжал писать в страницы (тихая дыра целостности). Правка: вход в reconnect по РАН-ФАКТУ (активный detached-ран), а не по наличию assistant-сообщения. В autonomous-режиме ран активен весь ход, поэтому здесь сигнал run-факта — сам autonomousRunsEnabled; более богатый серверный run-факт (POST /run / runId из start-метаданных) смоделирован и покрыт тестами в FSM (run-fsm.ts FINISH_DISCONNECT по ctx.runFact) и приезжает с полной миграцией компонента. При отсутствии assistant-строки reconnect идёт БЕЗ strip/anchor (простой live-attach — на экране нечего перестраивать). Тест: «обрыв до первого кадра → баннер reconnect + resumeStream + attach без anchor», плюс FSM-переход (уже зелёный в коммите 1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ba58493909 |
fix(client): классификация 409-кодов + run-факт плумбинг (#488)
Потребляет реальный контракт #487 на клиенте (без выдумывания кодов): - error-message.ts: ветки для 409 A_RUN_ALREADY_ACTIVE / SUPERSEDE_TARGET_MISMATCH / SUPERSEDE_TIMEOUT / SUPERSEDE_INVALID — человеческие тексты СТРОГО ДО generic- веток по статусу (иначе юзер видит сырой JSON {"code":"A_RUN_ALREADY_ACTIVE"}); - extractRunId(message): чтение runId из start-метаданных (зеркало extractServerChatId) — live-обновление run-факта для FSM; - getRun(chatId): POST /ai-chat/run — first-class run-факт с сервера (init на маунте + verify после supersede-mismatch). Плумбинг под FSM-обвязку коммитов 2–5. Тесты: классификатор (все 4 кода + order- guard), extractRunId. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
947796adef |
refactor(client): FSM skeleton + spec для run-lifecycle (#488)
Заменяет зоопарк из ~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>
|
||
|
|
38a09c5ca1 |
fix(ai-chat): write-class-ретрай только для доверенного внутреннего Docmost-сервера (#489 ревью)
MEDIUM (реальная брешь): SHARED_TOOL_WRITE_CLASS — имена ТУЛОВ Docmost, но mergeNamespaced применял мапу к тулам ЛЮБОГО стороннего MCP-сервера по совпадению rawName. Сторонний WRITE-тул, названный как Docmost-read (getPage/listPages/…), наследовал readOnly → авто-ретрай при транспортной ошибке → double-apply (класс #435). Гарантия «unknown third-party → write → не ретраится» была ЛОЖНОЙ при коллизии имён. Фикс: write-class мапа применяется ТОЛЬКО к серверу, про который известно, что это внутренний Docmost-MCP (isInternalDocmostServer). Сейчас это ВСЕГДА false — в этом пути нет встроенного/доверенного Docmost-сервера: все ai_mcp_servers суть сторонние admin-конфиги, а собственные тулы Docmost идут отдельным in-app путём (docmostTools), не через mcp-clients. Значит НИ ОДИН сторонний тул не получает readOnly по коллизии и не авто-ретраится (undefined → трактуется как write). Мапа грузится лениво только если есть доверенный сервер (иначе ESM-импорт пропускается). isInternalDocmostServer — метод-сим, флипается при появлении доверенного сервера (kind/isBuiltin-колонка или сконфигурённый self-MCP URL). LOW: reconnect не наблюдает composed abort во время 5-сек handshake — задокумен- тировано (окно поздней отмены ≤5s, сокет закрывается на turn-end); проброс composed в общий CAS-дедуплицированный reconnect намеренно НЕ сделан (отменил бы реконнект, нужный конкурентному живому вызову). Тест: сторонний WRITE-тул с именем getPage при транспортной ошибке НЕ ретраится; mutation-verify — форсирование trusted делает тест красным (чужой getPage ретраится). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1b05224b27 |
fix(ai-chat): MCP-кэш — in-run восстановление, ретраи только для readOnly (#489)
Кэш внешних 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>
|
||
|
|
b50b32bf64 |
fix(ai-chat): валидация клиентских parts + insert после конвертации (#489)
Юзер-сообщение персистилось в 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>
|
||
|
|
1d704d4ec5 |
test(mcp): покрыть write-safe-point «после Stop новая запись не стартует» (#487, F4)
paginate-abort-safepoint покрывал только READ-safe-point; WRITE-шов (collaboration.mutatePageContent / context.mutateLiveContentUnlocked — signal?.throwIfAborted() перед session.mutate) был без теста. Добавляю мок-collab юнит через тест-сим __setCollabProviderFactory (фейковый провайдер с мгновенным onSynced): предварительно abort-нутый сигнал → оба шва реджектят ДО session.mutate, трансформ не вызывается; контрольные кейсы (живой сигнал) подтверждают, что session.mutate достижим. MUTATION-VERIFY: снятие throwIfAborted в обоих швах делает красными ровно два abort-кейса, контрольные остаются зелёными. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2cb07ef7fb |
test(ai-chat): покрыть оркестраторы reconcile()/reconcileChat() (#487, F3)
Клаузы (a-d) тестировались по отдельности, но сам оркестратор reconcile() (ПОРЯДОК клауз + пер-клаузная try/catch-изоляция «одна упавшая не блокирует остальные») не проверялся, а reconcileChat() (старт каждого хода) не был покрыт вовсе. Добавляю: тест порядка a→b→c→d; тест изоляции (клауза b кидает — c и d всё равно отрабатывают, reconcile не реджектит); тест reconcileChat (скоуп по чату, bound 50, failed→error / прочее→aborted). MUTATION-VERIFY: снятие try/catch у клаузы (b) делает тест изоляции красным (исключение пробрасывается, c+d пропускаются). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9ba97b6d2b |
docs(ai-chat): задокументировать наблюдаемую поверхность #487 (F2)
- CHANGELOG [Unreleased]: серверный supersede + три кода (SUPERSEDE_INVALID / SUPERSEDE_TARGET_MISMATCH / SUPERSEDE_TIMEOUT) в Added; смена поведения (легаси вторая вкладка теперь → 409 A_RUN_ALREADY_ACTIVE вместо второго параллельного стрима; каждый ход — ран в обоих режимах) в Changed. - .env.example: три новых AI_CHAT_* (SUPERSEDE_TIMEOUT_MS / RECONCILE_INTERVAL_MS / INAPP_TOOL_CALL_CAP_MS, дефолты 10000/120000/120000, связь cap↔staleness). - AGENTS.md:458: ран теперь универсален (оба режима), флаг autonomousRuns = только disconnect-семантика; плюс упоминание supersede. - ai-chat.service.ts:1136: устаревший коммент про «legacy → socket signal» → оба режима на run-signal, guard runId защищает лишь no-handle fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0b9de2c25e |
fix(ai-chat): owner-gate stream() до supersede — закрыть утечку чужого runId (#487, F1)
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>
|
||
|
|
123cba7de1 |
fix(mcp): убрать залипший маркер конфликта в context.ts (артефакт ребейза)
После ребейза в src/client/context.ts:209 остался одиночный маркер `>>>>>>> 917c4064` без парного открытия — он попал в коммит и ломал сборку всего пакета mcp (TS1185 Merge conflict marker), из-за чего не запускались никакие node --test. Код выше маркера цел; удаляю только строку маркера. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
001eb1c923 |
docs(mcp): точный коммент toolAbortSignal — set-and-leave, не restore (#487, ревью nit)
Внутреннее ревью: docstring поля/метода toolAbortSignal утверждал «restores the prior value on unwind», но wrapInAppToolWithCap намеренно НЕ восстанавливает сигнал (set-and-leave) — именно это заставляет брошенного проигравшего race бросить на следующем safe-point; корректность держится на свежем клиенте на ход + перезаписи следующим вызовом. Комментарии приведены в соответствие с механизмом. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
002c931c6b |
feat(ai-chat): ретраи finalizeAssistant + owner-write приоритет + двусторонний reconcile (#487)
Раньше finalizeAssistant ставил finalized=true ДО записи и не ретраил → один неудачный UPDATE = строка вечно 'streaming'; свип был boot-only; run-свип безусловный — асимметрия «run succeeded / message streaming навсегда». - finalizeAssistant: bounded-ретраи; once-гейт закрывается ТОЛЬКО после успешной записи; возвращает ok. Правило owner-write: терминальная запись owner'а условна на status='streaming' OR metadata.finalizeFailed (repo.finalizeOwner) — перетирает reconcile-штамп, но не проставленный терминал. ВСЕ status-only штампы reconcile (stampTerminalIfStreaming, sweepStreaming) пишут строго onlyIfStreaming И мёржат metadata.finalizeFailed:true (иначе поздний owner-write не перетрёт). - Порядок: попытка message-finalize → ран финализируется ВСЕГДА; при провале message onFinish помечает ран 'error' (не 'completed'). Ран не гейтится на message. - Периодический reconcile-джоб (setInterval, env-tunable) клаузами по порядку: (a) пере-драйв зомби; (b) message streaming + ран терминален → штамп по статусу рана (succeeded-ран + зависшая строка → 'aborted'+finalizeFailed, НЕ 'completed'-empty); (c) run running + НЕТ entry И НЕТ zombie + staleness → aborted (гейт «нет entry» первичный, staleness от last-progress updatedAt, X=max(2×per-call cap,15мин)+boot-warn); (d) message streaming + возраст>X + нет активной run-строки → aborted (двойной гейт). isInterruptResume исключает finalizeFailed-строки. Оппортунистический одно-чатовый reconcile при старте хода (best-effort, не фейлит ход). sweepStreaming boot-only → периодический. Тесты (реальная БД): owner finalizeOwner чистит finalizeFailed; штамп не перетирает терминал; поздний owner-write перетирает aborted-штамп; клаузы b/c/d (+живой entry не трогать, двойной гейт d); «убить БД на finish → после восстановления ни строка, ни ран не застряли». Юниты: finalizeFailed исключает interrupt-resume; reconcileStaleRuns «нет entry». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
307ad49324 |
feat(ai-chat): единый гейт конкурентности + серверный supersede (CAS) (#487)
Раньше в 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>
|
||
|
|
1031ed1ae8 |
feat(ai-chat): зомби-механика finalizeRun + settledPromise + условные терминальные UPDATE (#487)
Раньше 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>
|
||
|
|
3b6634c6be |
fix(ai-chat): in-app тулы — race-on-abort + safe-points + per-call cap (#487)
In-app тул-обёртки отбрасывали второй аргумент options с abortSignal — это был единственный класс тулов без отмены и без wall-clock cap. Контент-мутации идут через collab-WS (mutatePageContent), не через axios, поэтому «прокинуть signal в axios» главный write-путь не покрывал. - Переиспользован race-паттерн wrapToolWithCallTimeout: каждый in-app тул гонится против композитного сигнала (Stop + per-call cap); на abort — реджект немедленно, проигравший промис отбрасывается (латентность мс, не сетевой teardown — от этого зависит таймаут supersede в коммите 3). - Safe-point проверки сигнала между последовательными вызовами paginateAll и пре-коммитная проверка в mutatePageContent (+ реентрантный близнец) через DocmostClient.setToolAbortSignal, который обёртка публикует перед каждым вызовом. - Per-call cap покрывает весь вызов, env-tunable (AI_CHAT_INAPP_TOOL_CALL_CAP_MS, дефолт 2 мин). - Задокументировано ограничение (#487): abort между вызовами многовызовного write-тула оставляет частично применённую операцию — отмена гарантирует «новый вызов не стартует», не «запись не доехала». Тесты (честное свойство «после Stop не стартует новый HTTP/WS-вызов»): юнит wrapInAppToolWithCap (реджект-на-abort, отсутствие новых вызовов, per-call cap, публикация сигнала) + mock-HTTP тест реального paginateAll safe-point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fdc37de3e8 |
fix(ai-chat): ревью-раунд #500 — красный серверный сьют + регрессия Redis-health
B1: переименование строки ошибки (#394) не обновило 4 предсуществующих share-ассерта → полный серверный сьют был красный. Обновлены ассерты в public-share-chat.spec.ts и public-share-chat-tools.service.spec.ts под новую классифицированную строку. B2: /health первая проба после старта врала DOWN при живом Redis (lazyConnect + enableOfflineQueue:false + maxRetriesPerRequest:1 → первый ping до открытия сокета). Добавлен ensureConnected() с bounded-таймаутом перед первым ping; покрывает и путь пересоздания после onModuleDestroy. Тест UP-с-первой-пробы против реального ioredis (mutation-verified). Устранён open-handle leak в redis.health.spec (drain ioredis force-destroy-таймера в teardown; без forceExit) и окно ложного DOWN при конкурентных пробах (мемоизированный connectingPromise). B3: комментарий про orphan-чат при провале beginRun (insert до begin). B4: описание listPages упоминает поле truncated в tree-режиме. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4a750c1e7f |
fix(#486): ревью — CHANGELOG + AGENTS.md + два теста
Ре-ревью PR #500 (changes-requested, 4 мелких, все doc/test): F1 [CHANGELOG] CHANGELOG.md [Unreleased]: - Breaking Changes: metrics-листенер 0.0.0.0→127.0.0.1 — кросс-контейнерный скрейп (docmost:9464) молча умрёт без METRICS_BIND=0.0.0.0 + METRICS_TOKEN (миграция в .env.example). - Security: утечка errorText тулов/провайдера анониму (closes #394); /metrics под Bearer (METRICS_TOKEN). - Fixed: ioredis-утечка в /health; ELK вешал event loop; beginRun-призрак → честный 503 A_RUN_BEGIN_FAILED; ai drain-hang. F2 [AGENTS.md] строка про ai-патч: теперь он несёт ДВА фикса (#184 O(n²) partialOutput И #486 drain-hang), оба тривайра названы. F3 [test] metrics.server.spec: добавлен кейс токена ТОЙ ЖЕ длины (Bearer topsecreX) → 401 — пиннит constant-time сравнение (прежние кейсы коротили на length-guard, до timingSafeEqual не доходили). F4 [test] output-degeneration.spec: behavior-тест, гоняющий РЕАЛЬНЫЕ onChunk/onStepFinish из stream() — длинный чистый шаг → граница → свежий дегенеративный бёрст → ассерт abortSignal.aborted (было хардкодом resetWatermark=0, ревёрт правки не краснил). Мутационные пруфы (non-vacuous): F3 — форс compare→true роняет same-length кейс (401→200); F4 — ревёрт lastDegenerationCheckLen=0 роняет behavior-тест (aborted true→false). Оба восстановлены, специи зелёные (34/34). |
||
|
|
ea7c4d7cd2 |
fix(#486): ревью — run-race контракт под новую политику + timing-safe metrics-токен
Ревью полной ветки #486 нашло два пункта в моих коммитах (3 и 4): - BLOCKER (коммит 4): ai-chat.service.run-race.spec.ts (#184 F14) пинил СТАРУЮ политику «plain begin() failure → swallow + стрим UNTRACKED» (resolves toBeUndefined). Коммит 4 её развернул → тест падал (1 failed/6). Кейс ИНВЕРТИРОВАН под новую политику: plain begin-failure теперь REJECTS с 503 A_RUN_BEGIN_FAILED, до первого байта, user-строка не вставлена, streamText не вызван — путь остаётся явно запинён, а не удалён. - NIT (коммит 3): metrics-токен сравнивался наивным !== (единственный слой auth эндпоинта) → тайминг-утечка токена. Заменено на crypto.timingSafeEqual с length-guard (разная длина → reject), семантика 401/200 без изменений. Внесено отдельным fixup-коммитом (rebase -i недоступен в окружении; ветка не запушена). Тесты: run-race 7/7 + metrics.server 7/7 зелёные. |
||
|
|
255024fbe2 |
chore(mcp): McpService.onModuleDestroy → destroyAllSessions + удаление мёртвого кода (#486)
onModuleDestroy чистил только sweep-таймер; loopback-collab-сессии держали доки открытыми на collab-сервере до идла — рестарт мог гонять с доком, запиненным умирающим воркером. Теперь дергает destroyAllSessions() через переопределяемый шов (для юнит-теста без ESM-пакета), best-effort. Плюс удалён мёртвый код: неиспользуемый импорт parseNodeArg в mcp/index.ts и мёртвые enum-члены SEARCH_REMOVE_* в queue.constants (подтверждено grep'ом — ни диспетчера, ни процессора). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b934fb2292 |
fix(mcp): пробросить truncated в tree-mode listPages (#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>
|
||
|
|
14da295185 |
fix(auth): провенанс для API-key пути (#486)
validateApiKey возвращал результат до резолва провенанса — REST-записи по is_agent API-ключу не получали маркер 'agent'. Перенести «выше» нельзя: payload API-ключа не несёт подписанный actor-клейм, а user (с isAgent) неизвестен до валидации ключа. Резолвим провенанс от возвращённого user: isAgent -> 'agent', иначе 'user'; aiChatId у API-ключа всегда null (нет ai_chats-строки). Загрузка EE ApiKeyService вынесена в переопределяемый шов resolveApiKeyService для юнит- теста без EE-бандла. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
de8f9c804c |
fix(client): flushNext в onFinish гейтится mountedRef (#486)
Финальный onFinish->flushNext() не проверял live-mount флаг. Чистый onFinish может прийти ПОСЛЕ анмаунта треда (New-chat / переключение чата мид-стрим — асинхронные attach/resume оседают поздно): flush дергал очередь и re-POST'ил сообщение из брошенного треда — «призрачные» отправки/чаты-призраки. Остальные обращения к очереди уже гейтятся mountedRef; закрываем последнюю дыру. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8ebdfe156f |
fix(ai-chat): сброс lastDegenerationCheckLen в onStepFinish (#486)
onStepFinish обнулял inProgressText, но НЕ lastDegenerationCheckLen — а это смещение В аккумулятор. После первого длинного шага протухшая (большая) отметка делала throttle-условие отрицательным, и детектор токен-лупов молчал весь следующий шаг, пока текст не перерастал старую отметку. Обнуляем отметку в onStepFinish. Throttle-предикат вынесен в output-degeneration (shouldCheckDegeneration + DEGENERATION_CHECK_STEP), чтобы юнит гонял ту же логику, что и стрим. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
31176d5f93 |
fix(mcp): сброс collab-токена на WS-auth-failure с ретраем (#486)
Кэш collab-токена (#435) инвалидировался только на HTTP-401/403 (REST- интерцептор и login()); отклонённый Hocuspocus-handshake оставлял протухший токен в кэше — каждая последующая мутация переотправляла тот же битый токен до истечения TTL (минуты) без self-heal. collab-session помечает ошибку onAuthenticationFailed маркером; клиентские write-швы (mutatePage/replacePage/ mutateLiveContentUnlocked) обёрнуты в writeWithCollabAuthRetry: на помеченной ошибке кэш сбрасывается и запись ретраится ровно один раз со свежим токеном — симметрично HTTP-пути. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |