daeeb1f3f2
Реестр ран-стримов больше не буферизует до 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>
84 lines
3.4 KiB
TypeScript
84 lines
3.4 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readUIMessageStream, type UIMessage } from "ai";
|
|
import pkg from "../../../../package.json";
|
|
|
|
/**
|
|
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
|
|
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
|
|
* test, an `ai` bump could silently break attach (the client would append the
|
|
* live tail to the wrong message, or duplicate a step):
|
|
*
|
|
* 1. `readUIMessageStream({ message })` CONTINUES the passed message — it does
|
|
* not start a fresh one — so the tail streamed after a re-seed is appended to
|
|
* the seeded assistant row (the same DB id).
|
|
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
|
|
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
|
|
* carries the run-fact metadata).
|
|
* 3. Text parts do NOT cross a `finish-step` boundary — a new `text-start` after
|
|
* `finish-step` is a NEW part — so the reconstructed steps stay separated and
|
|
* the step frontier stays meaningful.
|
|
*
|
|
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
|
|
* resume path silently corrupting.
|
|
*/
|
|
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
|
|
it("is pinned to the exact ai version the continuation was verified against", () => {
|
|
// A caret/range bump is exactly what would silently break attach — require an
|
|
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
|
|
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
|
|
"6.0.207",
|
|
);
|
|
});
|
|
|
|
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
|
|
// A seeded assistant row with ONE finished step already reconstructed.
|
|
const seeded: UIMessage = {
|
|
id: "assistant-1",
|
|
role: "assistant",
|
|
parts: [
|
|
{ type: "step-start" },
|
|
{ type: "text", text: "STEP0", state: "done" },
|
|
],
|
|
} as UIMessage;
|
|
|
|
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
|
|
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
|
|
// decode to).
|
|
const chunks = [
|
|
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
|
|
{ type: "start-step" },
|
|
{ type: "text-start", id: "t1" },
|
|
{ type: "text-delta", id: "t1", delta: "STEP1" },
|
|
{ type: "text-end", id: "t1" },
|
|
{ type: "finish-step" },
|
|
{ type: "finish" },
|
|
];
|
|
const stream = new ReadableStream({
|
|
start(c) {
|
|
for (const ch of chunks) c.enqueue(ch);
|
|
c.close();
|
|
},
|
|
});
|
|
|
|
let last: UIMessage | undefined;
|
|
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
|
|
last = msg;
|
|
}
|
|
|
|
expect(last).toBeDefined();
|
|
// Same message id (continuation, not a fresh message).
|
|
expect(last!.id).toBe("assistant-1");
|
|
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
|
|
// as SEPARATE parts (text did not cross the finish-step boundary).
|
|
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
|
|
expect(shape).toEqual([
|
|
"step-start:",
|
|
"text:STEP0",
|
|
"step-start:",
|
|
"text:STEP1",
|
|
]);
|
|
// The run-fact metadata from the synthetic start frame is applied.
|
|
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
|
|
});
|
|
});
|