Написано 26 августа 2026 года. Описанный здесь инцидент — с моего собственного публикующего сервера, разбор от 25 июля 2026 года. Все цифры — из этого разбора и ничьи больше; там, где цифры у меня нет, я так и пишу, а не беру чужую.

Пометку о неудаче ставит тот же код, который ошибся насчёт происходящего. В этом вся статья, а ниже — двенадцать статей, которых мне это стоило.

25 июля 2026 года я прошёл по всем записям своего календаря публикаций, у которых стоял адрес публикации. Их было двадцать пять. Двенадцать живых постов на Medium оказались обрезаны: от отправленного текста осталось от 41 до 72%, у части пропали блоки кода. Ещё один адрес в том же аудите отдавал HTTP 410. И каждый из них неделями назад был отчитан как успех, с валидным URL.

Оглавление

Что показал аудит

Аудит 25 июля 2026 года
Записей календаря с адресом публикации25
Живых постов на Medium оказалось обрезано12
Сколько исходного текста в них осталосьот 41 до 72%
Из обрезанных потеряли блоки кодачасть, не все
Адресов, отдающих HTTP 4101
Пострадало коротких анонсов примерно на 1500 знаков0
Пострадало публикаций на dev.to, Stack Overflow и Hacker News0

Две строки в этой таблице делают больше работы, чем все остальные. Короткие анонсы уцелели, и три другие площадки не пострадали вовсе. Держите обе в голове: именно они превращают неловкий баг в то, что стоит записать.

Как черновик теряет половину текста

Мой публикующий сервер кладёт черновики на Medium, управляя залогиненным Chrome по отладочному протоколу, — API для записи у Medium брошен гнить. Функция, создававшая черновик, ждала ровно одного: смены адреса в браузере на адрес редактирования новой истории, /p/<id>/edit. Дождавшись, она считала работу сделанной и закрывала вкладку в блоке finally.

По сути всё определение «готово» выглядело так:

js
1// форма старого пути: дождаться id и вернуть адрес
2await waitForUrl(page, /\/p\/[a-f0-9]+\/edit/)
3return { url: page.url(), ok: true }
4// ...а finally по дороге наружу закрывает вкладку

Вставка в редактор мгновенна. Сохранение — нет. Medium записывает содержимое редактора на свой сервер инкрементально, несколькими фоновыми запросами, и вкладка закрывалась, пока эти запросы были в пути. Всё, что не успело дойти до сервера, пропадало. Ни ошибки, ни баннера, ни упавшего запроса, который можно было бы поймать, — потому что запрос и не делался: страницы, которая должна была его сделать, уже не существовало.

Адаптер при этом возвращал валидный URL и ok.

Почему «браузер плох, API хорош» — неверный вывод

Неделями я читал это так: браузерная автоматизация ненадёжна, API надёжны. Вывод приятный. И он не соответствует данным — а заметить это было единственным по-настоящему полезным, что я сделал за весь эпизод.

dev.to доехал целиком; это API, теория сходится. Но целиком доехали ещё Stack Overflow и Hacker News, а их я публикую через тот же самый браузер, по тому же самому отладочному протоколу, тем же Puppeteer, в том же прогоне. Если бы дело было в браузере, обрезало бы и их.

Граница проходила не между браузером и API. Она проходила между одной отправкой и многими. Stack Overflow и Hacker News отправляют форму: весь текст уходит одним POST, и к моменту, когда пришёл ответ, досылать нечего. API dev.to устроен так же — один запрос, в нём всё. А Medium принимал текст частями, и мой адаптер переставал смотреть после первой.

Отсюда же и уцелевшие короткие анонсы. На полутора тысячах знаков они успевали сохраниться до закрытия вкладки. Баг был не «Medium ломается», баг был гонкой, а короткие тексты её выигрывали.

Почему мониторинг говорил, что всё хорошо

По самой обыкновенной причине. Мониторинг был. Он спрашивал, открываются ли опубликованные адреса и набирают ли они просмотры. Адреса открывались. Просмотры набирались.

Обрезанная статья — совершенно здоровая веб-страница. Она отдаёт 200, она отрисовывается, у неё есть заголовок, обложка, три абзаца — а потом она просто заканчивается, и ни один элемент HTTP-ответа не имеет мнения о том, там ли закончилась статья, где задумывал автор.

Вот это и обобщается за пределы публикаций, и ради этого стоило писать, а не тихо чинить. Проверка доступности отвечает на вопрос «дошли ли мы до кода проверки», а не «правильно ли то, что получилось». Если вы следите за конвейером выкатки, за ночным ETL, за бэкапом — за чем угодно, что делает за вас агент, — есть приличный шанс, что ваша проверка устроена ровно как моя.

Второй баг: не вычитывайте статус из прозы

Рядом с первым жил второй, и пересказывают обычно именно его.

Адаптеру нужно было понимать, не провалилось ли сохранение в редакторе, и он искал фразы, которые Medium показывает при ошибке. Искал он их в document.body.innerText — то есть в тексте всей страницы, а на этой странице лежит в том числе и публикуемая статья.

В одной из публикуемых статей была строчка «if something is wrong you flip back».

Прогон, который отработал безупречно, объявили провалившимся. Отработал повтор. Появился дубль черновика.

Двадцати секунд на чтение диагноза достаточно, чтобы увидеть правило, и правило шире браузеров: никогда не вычитывайте статус из прозы. Если единственный способ узнать исход — поискать слова в связном тексте, вы его угадали, а не узнали, и рано или поздно проза случайно будет содержать ваше ключевое слово. Починка была в том, чтобы перестать читать страницу как текст и смотреть только на элементы, которыми страница объявляет статус: [role=alert], [role=status], [aria-live].

Где это лежит в модели ошибок MCP

MCP определяет два способа для вызова инструмента закончиться неудачей, и пара эта по-настоящему хорошая. Ошибка протокола означает, что ничего не выполнялось: нет инструмента с таким именем, аргументы не подошли под схему, запрос кривой. Ошибка выполнения означает, что инструмент отработал и новость плохая — файла не было, API вернул 403, — и приходит она успешным ответом с пометкой, написанной словами, потому что получатель здесь — модель посреди задачи, способная что-то предпринять.

Ни одна из двух ступеней не видит того, что случилось у меня. С точки зрения протокола вызов прошёл отлично: запрос отправлен, корректный ответ получен, пометки об ошибке в нём нет, URL внутри есть.

Протокол может донести вердикт. Проверить его он не может. Эта третья ступень невидима для него по построению, и никакая редакция никакой спецификации этого не исправит: об исходе отчитывается тот же компонент, который насчёт исхода ошибся.

К этому прилагается и счёт в токенах, о котором стоит сказать, потому что он делает ошибку не только неловкой, но и дорогой. В том запросе модель заплатила за двадцать описаний инструментов, заплатила ещё раз за сам вызов, получила ok и двинулась к следующему пункту плана. Расход был настоящий. Работа сделана не была.

Что на самом деле делает починка

Две вещи в адаптере и одну вещь вне адаптера.

Первым побуждением было вставлять историю кусками — на том основании, что одна большая вставка теряет текст. Я это замерил, на той же истории в 13 953 знака, и теория не пережила замера:

ПутьПрогоновРезультат
Кусками2оба отказа, `stuck after 2 rewrites`, по ~228 с
Одной вставкой, без сверки54 целых; 1 потеряла 4 куска из 23 — и отчиталась успехом
Одной вставкой, потом сверка3все целые с первой попытки, ~50 с

Дробление не покупало ничего. На этой истории оно вообще не доходило до конца. Безопасной запись делает не способ вставки, а то, что текст вычитывается обратно с сервера. Адаптер теперь вставляет историю целиком, дёргает сохранение, перезагружает страницу и спрашивает, какие части отправленного действительно на месте:

js
1/** Какие подготовленные куски ещё НЕ на сервере — по probe-строке. */
2function missingFrom(persistedText, chunks) {
3  const norm = normalizeForMatch(persistedText)
4  return chunks.filter((c) => c.probe && !norm.includes(c.probe))
5}

Если чего-то нет, редактор очищается и вся история вставляется заново — никогда не дописывается: дописывание дублировало текст всякий раз, когда кусок считался пропавшим, но не пропал. Если повтор оставляет ровно те же дыры, что и предыдущий, адаптер останавливается и отказывает: stuck after N rewrites. А когда все probe-строки на месте, он перед выдачей URL проверяет ещё две вещи — что число отрисованных блоков кода совпадает с отправленным и что заголовок пережил круг.

Обратите внимание, что общего у всех этих проверок. Ни одна из них не доверяет вызову, который делал работу.

Вторая вещь в адаптере — детектор баннера, который больше не читает прозу.

Третья вещь не в адаптере вовсе, и она главная: проверка «работа сделана» должна жить вне инструмента, который эту работу делал, и задавать другой вопрос. Не «вернулся ли вызов», а «совпадает ли то, что сейчас лежит на сервере, с тем, что я отправлял».

Проверка, которую можно запустить

scripts/check-published-completeness.mjs вычитывает каждую опубликованную статью обратно и сверяет с исходником. Он не умный. Он открывает каждый опубликованный адрес в том залогиненном Chrome, которому этот адрес принадлежит, — Medium режет анонимные запросы, а черновики Medium видны только своему аккаунту, так что анонимный HTTP GET на этот вопрос не отвечает в принципе, — пролистывает до низа и сверяет с сохранённым телом по двум признакам: числу блоков кода и наличию текста последней ссылки. У этих статей последняя ссылка живёт в финальном блоке «что почитать», то есть это первое, что съедает обрезание. Скрипт возвращает ненулевой код, если хоть одна публикация неполная.

bash
1node scripts/check-published-completeness.mjs           # из-под авторского аккаунта каждой статьи
2node scripts/check-published-completeness.mjs --anon    # глазами разлогиненного читателя

Два режима, два разных вопроса, и понадобился аудит, чтобы понять, что вопросы разные. Режим --anon открывает все адреса в профиле браузера, не залогиненном ни во что, — потому что автор всегда видит свою статью целиком. Пейволл, заглушку «только для подписчиков», стену логина — ничего из этого не видно из-под аккаунта, который публиковал.

Единственная настоящая тонкость — само сравнение, и наивная версия этой проверки соврёт вам именно здесь. Площадки снимают markdown и подменяют типографику: у ` spec.selector ` пропадают обратные кавычки, прямой апостроф возвращается парным, дефис — длинным тире. Сравните сырые строки — и объявите обрезанными целые статьи. Поэтому сравнение идёт по видимому тексту, нормализованному с обеих сторон:

js
1// сжато из настоящего
2const bare = (v) =>
3  v.replace(/[`*_~]/g, '')             // разметка, которой на странице нет
4   .replace(/[‘’]/g, "'")    // площадки подменяют кавычки парными
5   .replace(/[‐-―]/g, '-')   // ...а дефисы длинными тире
6   .replace(/\s+/g, ' ')
7   .trim()

О двух вещах не буду делать вид, что их нет. Перезаливка двенадцати обрезанных постов — отдельная проблема, а не сноска: каждая перезаливка заново играет в ту же гонку автосейва, а скрипт правки печатает FAIL по таймауту финальной проверки, даже когда сохранение прошло, — так что верить надо сверке полноты, а не коду возврата. И есть одно расхождение, которое проверка вам покажет, но которое не является потерей: Medium разрезает блок кода с пустой строкой внутри на два блока, и восемь фенсов в исходнике становятся девятью в черновике. Косметика, ничего не потеряно, и вы один раз за этим погоняетесь, прежде чем научитесь узнавать.

Четыре вопроса к вашему собственному конвейеру

Ничего из этого не специфично для публикаций. Если что-то в вашем хозяйстве делает работу за вас — бот выкатки, ночной ETL, бэкап, агент с инструментами, — вот четыре вопроса, в которые превратился аудит:

    1. Кто-нибудь вычитывает результат обратно? Не «вернулся ли вызов», а: есть ли компонент, который достаёт результат из системы-источника правды и сверяет с тем, что отправляли?
    2. Проверяющий — это другой компонент, не исполнитель? Если инструмент сам себе ставит оценку, его слепое пятно становится вашим. Мой возвращал ok изнутри той самой функции, которая только что не доделала работу.
    3. Ваш мониторинг проверяет доступность или правильность? Код 200 и растущие просмотры прекрасно совместимы с половиной статьи. Спросите, как выглядит правильный результат, и проверяйте это.
    4. Ничего ли у вас не решает исход по совпадению слов в свободном тексте? Статус живёт в полях статуса, кодах возврата и специальных элементах. Проза рано или поздно случайно будет содержать ваше ключевое слово.

Если у вас такой конвейер и разбирать его самому не хочется: расскажите, что он делает и куда пишет, — скажу, куда посмотрел бы первым делом. Беру частичную занятость, до 20 часов в неделю, часовой пояс CET; форма связи здесь, отвечаю в течение суток.

Чего это не доказывает

Анонимную статистику про «столько-то процентов вызовов не доходят» цитируют часто, а источник называют почти никогда. Что стоит за ней, я не знаю. Знаю, что стояло за моей.

Это один сервер, одна площадка, один баг и выборка из двадцати пяти. Это не свидетельство о надёжности ИИ-агентов вообще, у этого нет денежной оценки, потому что я её не считал, и отраслевого процента я вам не предложу. Что здесь есть — так это разобранный пример отказа, которому нет места в собственной модели ошибок протокола. И повод пойти посмотреть, какой вопрос задаёт ваш мониторинг: «открывается ли» или «правильно ли».

Успешный ответ — это мнение.

Что обычно спрашивают

Это баг в MCP? Нет. Две ступени ошибок MCP хорошо сделаны для того, что они покрывают. Этот отказ невидим для любого протокола по построению: об исходе отчитывается тот компонент, который насчёт исхода ошибся, и протоколу нечего инспектировать.

Поймала бы это пометка `isError`? Нет. Эту пометку ставит сам инструмент. Инструмент считал, что справился, — у него был валидный URL как доказательство. Пометка, которую пишет ошибающийся компонент, несёт в себе ту же ошибку.

Может, это просто ненадёжная браузерная автоматизация? Такой была моя гипотеза неделями, и данные её убили. Stack Overflow и Hacker News идут через тот же Chrome, тот же протокол и тот же Puppeteer в том же прогоне — и оба целы. Граница проходит между одной отправкой и многими, а не между браузером и API.

Повтор бы это починил? Сам по себе нет, а на пути повтора жил второй баг: прогон, который на самом деле отработал, объявили провалившимся по совпадению фразы, повтор отработал, и появился дубль черновика. Повтор без сверки просто даёт вам два способа ошибиться.

Как проверить, что агент действительно сделал работу? Вычитать результат из системы-источника правды чем-то, что не является инструментом-исполнителем, и сверить с отправленным — по видимому содержимому, а не по сырой разметке. Ровно это и делает мой скрипт, и он поймал бы всё это в первый же день.

Источники

Мой собственный публикующий сервер и его рунбук docs/publishing.md, составленный 25 июля 2026 года по итогам разбора; аудит всех 25 записей календаря с адресом публикации, проведённый в тот же день; scripts/check-published-completeness.mjs и scripts/check-published-health.mjs; замер двух путей записи, проведённый 13 августа 2026 года. Две ступени ошибок — из спецификации MCP на modelcontextprotocol.io, редакции 2025-06-18 и 2026-07-28.