惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
量子位
博客园 - Franky
罗磊的独立博客
月光博客
月光博客
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
Hugging Face - Blog
Hugging Face - Blog
宝玉的分享
宝玉的分享
腾讯CDC
D
Docker
N
Netflix TechBlog - Medium
Y
Y Combinator Blog
V
V2EX
Microsoft Azure Blog
Microsoft Azure Blog
Latest news
Latest news
C
CERT Recently Published Vulnerability Notes
G
GRAHAM CLULEY
C
Cisco Blogs
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
T
Threatpost
Simon Willison's Weblog
Simon Willison's Weblog
GbyAI
GbyAI
S
SegmentFault 最新的问题
Blog — PlanetScale
Blog — PlanetScale
L
Lohrmann on Cybersecurity
I
Intezer
博客园 - 叶小钗
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Last Week in AI
Last Week in AI
Cisco Talos Blog
Cisco Talos Blog
Hacker News: Ask HN
Hacker News: Ask HN
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
B
Blog
Microsoft Security Blog
Microsoft Security Blog
AI
AI
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
S
Schneier on Security
V
Visual Studio Blog
The Register - Security
The Register - Security
AWS News Blog
AWS News Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
F
Fortinet All Blogs
博客园 - 司徒正美
WordPress大学
WordPress大学
Jina AI
Jina AI
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Spec-driven development в микросервисах, часть 3: archspec investigate — исследование фичи до кода
Стас Королев · 2026-06-15 · via Все публикации подряд на Хабре

Третья, заключительная статья из цикла.

Часть 1 — где LLM теряет межсервисный контекст и почему локальных спек недостаточно.

Часть 2 — archspec как контракт вместо свободного Markdown.

Часть 3 — archspec: исследование фичи, обновление контрактов и реализация.

1. Напоминание: где ломался spec-driven и зачем контракт

В части 1 я показал, что spec-driven development с LLM начинает ошибаться, когда фича проходит через несколько микросервисов: по отдельности каждый сервис выглядит аккуратно, а вместе система работает не так, как нужно. Причина в том, что модель теряет межсервисный контекст — правила, которые живут на границах между сервисами, не записаны в одном месте, поэтому LLM их пропускает. В части 2 я собрал archspec: на каждый сервис генерируется машиночитаемый контракт SERVICE_MAP.yaml, который делает эти правила явными. В этой части я возвращаюсь к той же фиче и прогоняю её через /archspec:investigate поверх контрактов — поймает ли план межсервисные ошибки ещё до кода.

Напомню стенд. Это Go-проект из 12 микросервисов для поиска фрилансеров: gRPC для синхронных вызовов, брокер NATS для асинхронных событий, единая Clean Architecture в каждом сервисе. У task-service и matching-service есть Transactional Outbox, чтобы изменение состояния и событие записывались вместе. Схема та же, что я использую на протяжении всего цикла:

Фича для эксперимента — Smart Task Reassignment. Если выбранный фрилансер отказался от оффера, платформа сама находит следующего кандидата, отправляет ему новый оффер и уведомляет заказчика, а не отправляет задачу на ручную обработку. Правила переназначения такие:

  • отказ фрилансера запускает новый подбор;

  • кандидаты ранжируются по рейтингу, а при равном рейтинге — по расстоянию до города задачи;

  • заказчик получает уведомление о переназначении;

  • после трёх неудачных переназначений задача переходит в failed.

В первый раз (task_1) я прогонял эту фичу без archspec: Claude читал локальные CLAUDE.md по сервисам, планировал на Sonnet 4.6, выдал план примерно на 180 строк и реализовал его. Затем я прогнал два независимых ревью — Claude в отдельной сессии и Codex со сверкой по эталонному решению и чек-листу. Оба нашли одну и ту же группу межсервисных ошибок, и полный сценарий отказа и переназначения не сошёлся. Итог — 6/10, чеклист ~64%.

Классы ошибок task_1 коротко: прямой вызов закрытых сервисов в обход worker-facade; придуманные методы, которых нет в proto; город передавался как имя вместо city_id; цикл вызовов review → worker-facade → review; N+1 вместо batch-методов; публикация события мимо Outbox. Был и критический баг: один match_id на все переназначения, из-за которого notification-service отбрасывал новые офферы как дубликаты, и фича не проходила сквозной сценарий. Все эти правила объединяет одно: они живут между сервисами и нигде не были записаны как единое ограничение, поэтому LLM их не видел.

В части 2 эти правила стали явными в контракте — каждое попало в SERVICE_MAP.yaml соответствующего сервиса. Теперь проверим, изменит ли это результат, если дать LLM не локальные Markdown-файлы, а контракты и /archspec:investigate.

2. Планирование заново: investigate поверх контрактов

Во втором прогоне (task_3) я меняю ровно две вещи относительно task_1. Промпт фичи тот же — то же описание Smart Task Reassignment, что в первой части. Модель та же — Claude Sonnet 4.6 на medium reasoning. Разница в окружении: теперь у каждого из 12 сервисов есть контракт SERVICE_MAP.yaml из второй части, и вместо свободного брейншторма я запускаю /archspec:investigate. Это read-only этап: он не трогает код и не трогает контракты. Единственный файл, который он пишет, — план.

2.1. Clarify-gate: investigate сначала спрашивает

investigate начинает с того, что читает срез контрактов затронутых сервисов. Дальше он не переходит сразу к планированию. Сначала он проходит по измерениям неоднозначности и задаёт уточняющие вопросы: откуда приходит триггер и через какой публичный вход, кто владелец состояния, откуда берётся идентификатор исполнителя, что именно считает лимит, по какому ключу связываются сущности, что происходит в терминальных ветках. Уточнение тоже остаётся read-only действием и кода не касается.

Что он спросил на этом проходе:

  • нужен ли reference/golden-спек для сверки именований — я ответил skip;

  • откуда приходит триггер отказа — решили, что это новый HTTP-endpoint в api-gateway;

  • что значит «максимум 3 переназначения» — 3 после первого оффера;

  • как резолвить гео-тай-брейк — по city_id воркера и city_id задачи, с fail loudly, если city_id нет.

Это ровно те места, где в task_1 модель приняла решение без вопроса и ошиблась: worker_id она взяла из тела запроса, город передала как имя вместо id, а на лимите получила ошибку на единицу. Здесь каждое из этих мест вынесено в явный вопрос до начала планирования.

2.2. Результат — план-артефакт

investigate сохраняет план в отдельный файл, а не оставляет его в чате. Это рабочий артефакт, который потом читает implement.

Само устройство плана я разбираю в секции 4, а здесь просто перечислю, что внутри:

  • на какие правила опирается фича — план цитирует конкретные строки из контрактов затронутых сервисов, так что каждое его утверждение можно перепроверить по контракту;

  • открытые вопросы — места, где требования допускают разные трактовки. Например, откуда брать worker_id отказавшегося фрилансера. План собирает их в отдельный список и решает до кода;

  • какие правки нужны в API до кода — например, добавить метод DeclineOffer и поле city_id в proto. Интерфейсы сервисов меняются раньше реализации;

  • диаграмма нового сценария — как переназначение пройдёт по сервисам (показана ниже);

  • diff правок в контрактах — что именно поменяется в SERVICE_MAP.yaml каждого затронутого сервиса;

  • проверка по всем 12 сервисам — для каждого нового события план находит, кто его публикует и кто слушает, чтобы не пропустить ни одного получателя;

  • кто чем владеет — для каждого куска данных указано, какой сервис за него отвечает и кто имеет право его менять;

  • список крайних случаев — каждый риск записан отдельным пунктом и сразу привязан к тесту, который его проверит;

  • отметки о самопроверке и независимом ревью плана.

Вот отрендеренная диаграмма последовательности из плана:

схема по частям

Это весь путь переназначения, который план нарисовал сам — от действия клиента до финала. Удобнее читать его по трём веткам.

Обычный отказ. Фрилансер нажимает «отказаться» — запрос приходит в api-gateway, тот достаёт worker_id из токена и зовёт task-service. task-service сам, через свой outbox, публикует событие offer.declined. Его слушает matching-service: он берёт следующего кандидата из уже посчитанного списка и отправляет ему новый оффер событием match.found. Параллельно заказчику уходит уведомление, что задачу переназначили.

Лимит исчерпан. Если отказ уже третий, task-service не запускает новый подбор, а публикует task.failed, и заказчик получает уведомление, что подобрать никого не удалось.

Кандидаты кончились раньше лимита. Если в списке не осталось кандидатов, matching-service шлёт match.exhausted, task-service переводит задачу в failed и тоже уведомляет заказчика.

На что смотреть на этой схеме. Во-первых, синхронные вызовы (gRPC) и асинхронные события (через NATS) нарисованы по-разному — сразу видно, где сервис дёргает другой напрямую, а где общается через брокер. Во-вторых, у каждой ветки есть конец: ни один путь не обрывается на полпути, у любого исхода задача меняет статус и заказчик получает уведомление. Это как раз те места, на которых спотыкался task_1.

2.3. Что план поймал заранее

На этапе плана закрыто то, на чём task_1 не проходил сквозной сценарий. Главным провалом task_1 была коллизия match_id: все переназначения получали один и тот же match_id, и notification-service отбрасывал их как дубликаты. Здесь:

  • новый match_id на каждую попытку, плюс убран TaskID-fallback дедупа в notification-service;

  • city_id сделан обязательным и протянут в payload task.created;

  • offer.declined и task.failed публикуются из outbox task-service — топология полностью event-driven, без синхронного matching-service → task-service;

  • добавлено уведомление заказчика на переназначении и на task.failed;

  • снят off-by-one лимита: reassignment_count < 3 проверяется до инкремента, что даёт 4 оффера и 3 переназначения.

То, в чём план был не уверен, он не спрятал, а вынес в открытые вопросы. Среди них — доверенный источник worker_id: план не стал додумывать его в коде, а вынес отдельным вопросом — OQ-1.

2.4. improve plan

С первого захода investigate не выдал законченный план. Часть решений он не стал принимать за меня и собрал в список открытых вопросов — это видно прямо в файле плана.

Открытые вопросы в плане; я прошу доработать его командой improve plan

Открытые вопросы в плане; я прошу доработать его командой improve plan

Я разобрал эти вопросы и попросил доработать план — командой improve plan. investigate прошёл по каждому и внёс решение прямо в план. Вот вопросы и что с ними стало:

Вопрос

В чём неоднозначность

Как решили в плане

OQ-1: откуда брать worker_id отказавшегося

если читать из тела запроса, любой клиент сможет отказаться за чужого фрилансера

брать из токена; полноценную авторизацию в рамках этой фичи не делаем, решение записано как осознанный риск в ADR-001 (как это отыграло в коде — в секции 3)

OQ-3: city_id обязателен или нет

без city_id нечем считать расстояние, и тай-брейк по близости не работает

сделать обязательным: CreateTask без city_id отклоняется с INVALID_ARGUMENT

OQ-4: что делать, если кандидатов нет уже на первой попытке

задача может навсегда зависнуть в незавершённом статусе

тот же путь, что и при исчерпании списка: match.exhaustedtask.failed → уведомление заказчику

Всего план прошёл 4 прохода self-review и был принят независимым plan-review за 2 раунда.

2.5. Сравнение с task_1

Отдельное ревью оценило план в 9/10 против 6/10 у task_1. По чеклисту — рост с ~64% до ~98%. Критический баг с match_id закрыт ещё на этапе плана. И главное: в task_1 план не менялся вообще, а здесь он реально дорабатывался — сам себя проверил и прошёл независимое ревью.

Но один пробел план всё же пропустил. Поле city_id он протянул через все сервисы правильно. А вот то, что значения этого id у сервисов записаны по-разному (task-service хранит city-msk, а geo и worker-profilemoscow), план не заметил. Позже именно это сломало гео-расчёт в коде (подробнее — в секции 3).

Это не значит, что план плохой. Скорее это повод доработать инструмент: ему не хватает проверки, что код и правда соответствует плану. Зелёная сборка такой пробел не ловит — она проходит, даже когда id в сервисах не совпадают.

3. Реализация: implement по плану

План одобрен, теперь его нужно превратить в код. Я запускаю вторую команду на той же модели — Sonnet 4.6, medium reasoning:

Запуск /archspec:implement по сохранённому плану

Запуск /archspec:implement по сохранённому плану

Архплан обязателен: без него implement не запускается и отправляет обратно в investigate. Сначала план — потом код.

Как implement превращает план в код

Механику я разбираю в секции 4, здесь — только контур, чтобы было понятно, откуда берётся итог. implement сначала применяет YAML-патч к SERVICE_MAP.yaml затронутых сервисов и синхронизирует документацию — контракты меняются до кода. Затем строится план реализации, в котором каждое требование привязано к конкретной задаче и тесту. Дальше идёт реализация по TDD через записи edge_cases, затем несколько проверок, что код соответствует плану, и прогон /archspec:validate и /archspec:check-architecture. В конце готовые изменения проверяет отдельный агент, который их не писал, — и только после этого коммит.

Главное в этом шаге: implement не просто пишет код и останавливается. У него есть собственное ревью, которое гоняет реализацию по кругу, пока не закончатся замечания. На этой задаче оно за два круга само нашло и починило несколько реальных ошибок — лимит переназначений срабатывал не на той попытке, заказчику не уходило уведомление, не отрабатывал один из крайних случаев. Одну проблему implement чинить не стал, но и не спрятал: worker_id по-прежнему приходит от клиента, а не из токена. Её записали отдельным решением (ADR-001) как осознанный риск прототипа. В итоге все 15 крайних случаев закрыты тестами, сборка и тесты зелёные.

Зелёная сборка — необходимое, но не достаточное условие. Поэтому я прогнал по диффу два независимых ревью: одно Claude, второе — Codex. Они смотрели один и тот же код против эталона.

Что получилось

Оба ревью сходятся в том, что архитектурно решение task_3 заметно сильнее task_1. Важный контраст с первым прогоном:

  • Атомарный outbox для offer.declined и task.failed. В task_1 событие об отказе публиковал напрямую api-gateway — это и было главное архитектурное нарушение. Здесь api-gateway лишь проксирует gRPC, а task-service атомарно пишет состояние и событие через DeclineAndPublish / UpdateWithEvent. Это главное исправление относительно task_1.

  • Уникальный match_id на каждую попытку. В task_1 все переназначения получали один match_id, и notification-service отбрасывал их как дубликаты — фича ломалась на сквозном сценарии. В task_3 CreateAttemptPendingIfAbsent выдаёт новый match-N на каждую пару (task_id, attempt), дедуп в notification-service идёт по match_id без fallback на TaskID. Критический баг task_1 устранён, сценарий проходит целиком.

  • Корректные ключи идемпотентности: (task_id, attempt) для offer.declined, match_id для match.found, task_id для task.failed.

  • city_id добавлен в домен и proto и проброшен в matching и geo — структурно того, чего в task_1 не было вовсе.

  • Полностью событийная топология: task-service подписан на match.found и match.exhausted, без синхронных обратных gRPC.

  • Снапшот кандидатов: на переназначении конвейер подбора не перезапускается, берётся следующий из уже найденного списка.

  • Уведомление клиента доходит, обработан путь «кандидаты исчерпаны».

По чеклисту эталона это рост с ~64% (task_1) до ~93% (19.5/21 у Claude). Оценки: Claude 8/10, Codex 6/10 как eval-решение (7/10 как прототип).

Где сместилось от плана

Главное расхождение оба ревьюера нашли в одном месте — в самом ядре фичи, в логике ранжирования.

Инвертированный тай-брейкер. При доступном geo код сортирует кандидатов только по расстоянию и теряет рейтинг:

// matching-service/usecase/matching.go — SortCandidatesByGeo
sort.SliceStable(entries, func(i, j int) bool {
    if entries[i].distance != entries[j].distance {
        return entries[i].distance < entries[j].distance // первичный ключ — расстояние
    }
    return entries[i].w.ID < entries[j].w.ID             // тай-брейк — worker_id, не рейтинг
})
out := make([]domain.MatchCandidate, len(entries))
for i, e := range entries {
    out[i] = domain.MatchCandidate{WorkerID: e.w.ID, Name: e.w.Name} // рейтинг (Score) сюда не попадает
}

Из-за этого «следующий лучший фрилансер» превращается в «ближайшего»: кандидат с рейтингом 3.0, но поближе к задаче, обходит кандидата с 5.0. А этот список потом переиспользуется на каждом отказе — значит, и переназначения идут по расстоянию, а не по рейтингу.

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

Рассинхрон city_id. Сервисы в проекте хранят данные в памяти и на старте заполняются демо-записями (задачи, воркеры, города). И вот в этих демо-данных task-service записывает город задачи как city-msk, а geo-service и worker-profile — как moscow. Когда matching-service просит geo-service посчитать расстояния, тот не находит city-msk среди своих городов и возвращает ошибку на весь запрос. matching-service эту ошибку проглатывает и просто работает дальше без расстояний.

Здесь свой пробел есть и у плана: он протянул поле city_id, но не зафиксировал единый набор значений id между сервисами (см. конец секции 2). В итоге ошибка в данных прячет баг тай-брейкера: до расчёта расстояний дело вообще не доходит. Поэтому тай-брейк не работает в любом случае — на текущих данных geo-service сразу отвечает ошибкой, а если выровнять id, включится уже сломанная сортировка из прошлого пункта. Это видно прямо в коде — task-service отдаёт один id, а geo-service знает другой:

// task-service: задача публикует city_id = "city-msk"
{"task-1", ..., "Moscow", "city-msk"}

// geo-service: знает только "moscow", "spb", … — пары "city-msk" у него нет
"moscow": {ID: "moscow", Name: "Moscow", RegionID: "moscow_region"}

С city_id связан и ещё один промах, уже серьёзнее. Поле сделали обязательным в task-service, но api-gateway его не принимает и не передаёт дальше — поэтому создание задач через публичный API теперь стабильно падает, и теста на этот путь нет. Классический случай: новое поле добавили в один сервис, но не протянули до публичного входа.

Остальное:

  • в режиме без geo (когда подключиться к сервису не удалось) спрятана ловушка: проверка geo != nil в Go проходит, даже когда клиент на самом деле пустой, поэтому следующий вызов geo всё равно роняет matching-service;

  • поздний или повторный match.found может вернуть уже проваленную задачу обратно в статус «назначена»: обработчик HandleMatchFound не проверяет, что задача уже в финальном статусе.

Тесты зелёные, логика не покрыта

15 edge-case-тестов зелёные, сборка зелёная — но центральная логика ранжирования «рейтинг первичен, расстояние тай-брейк» не покрыта ни одним из них. Один тест проверяет деградацию воркера без city_id, другой — TestSortCandidatesByGeo_TieBreakByWorkerID — закрепляет в изоляции уже ошибочное поведение сортировки, и рейтинг в нём вообще не участвует. Нет теста вида «воркер A: рейтинг 5.0, далеко; воркер B: рейтинг 4.0, близко — первым должен остаться A».

Это тот же паттерн, что и в task_1: код проходит CI, а дефект остаётся ровно в той логике, которую тесты обходят стороной. Разница в масштабе. В task_1 ломалась вся фича; здесь — одно из пяти требований задачи.

Вывод

Фича работает на сквозном сценарии — в отличие от task_1. Отказ, переназначение, новый оффер, уведомление клиента, лимит 3, task.failed — поток замкнут и протестирован. Но одно из пяти требований — тай-брейкер по расстоянию при равных рейтингах — функционально не выполнено. Код разошёлся с планом.

Хороший план заметно поднял качество и закрыл критический баг task_1 ещё до кода. Но он не гарантировал соответствия кода: зелёная сборка и зелёные юнит-тесты прошли при сломанном гео-пути. Значит, плагину нужны ещё итерации и более строгая проверка того, что код действительно соответствует плану, а не только зелёная сборка и тесты. Идея при этом рабочая.

Код решения — ветка task_3: https://github.com/krus210/freelance-marketplace/tree/task_3

4. Как это работает: принципы investigate

Предыдущие секции показали результат: план task_3 получил 9/10 против 6/10 у task_1, но один баг дрейфа всё равно доехал до кода. Чтобы понять, откуда взялась эта разница и почему она не дотянула до десяти, нужно посмотреть, как устроен investigate внутри. Разберу его по стадиям.

Общая идея одна: investigate старается поймать ошибку как можно раньше — на этапе плана, а не уже в готовом коде. В task_1 фича сначала писалась целиком, и только потом её ловили сквозные тесты и ревью — то есть в момент, когда коллизия match_id уже была закодирована в нескольких сервисах. investigate переносит проверку на момент, когда LLM ещё только собирает требования и рисует план: дешевле поймать ошибку в одной строке плана, чем в коде, разнесённом по нескольким сервисам. Механизм при этом не «ещё один свободный Markdown», а машиночитаемый контракт SERVICE_MAP.yaml на входе плюс дисциплина из нескольких стадий, где каждая закрывает свой конкретный класс ошибки из части 1. И investigate остаётся read-only: единственный файл, который он пишет, — план-артефакт; код и контракты он не трогает.

Дальше — стадии по порядку, и для каждой я отмечаю, какую ошибку она ловит.

Контракт как вход и устранение неоднозначности

investigate начинает не с кода и не с разрозненных Markdown, а с нужных частей контракта SERVICE_MAP.yaml затронутых сервисов: какие у сервиса есть методы (api.endpoints), какие события он публикует и какие слушает (events.published / events.consumed), как он записывает своё состояние (consistency.write_path — например, через outbox). Это закрывает первый класс ошибок из части 1 — неполную картину межсервисных правил. Восстанавливать архитектуру из кода LLM умеет плохо: часть правил в реализации не видна, часть живёт только в голове команды. Контракт даёт их явным списком.

Дальше — clarify-gate. Это запрет планировать, пока не закрыты девять измерений неоднозначности. Каждое измерение — про конкретный класс багов:

Измерение

Что фиксируется

Какую ошибку ловит

Точка входа

через какой сервис входит запрос, есть ли публичный вход (api-gateway)

запрос привязан не к тому сервису или нет публичного входа

Кто владеет состоянием

какой сервис отвечает за каждый кусок данных

один сервис меняет данные, за которые отвечает другой

Доверенная личность

откуда берётся worker_id — из токена, а не из тела

клиент выдаёт себя за другого

Порядок событий

гонки с тем, что пишет другой поток событий

поздний или повторный match.found затирает свежее состояние

Доставка и дубли

событие может прийти дважды — кто и чем отсекает дубли

дубликат считается новой попыткой

Числовые лимиты

«3 переназначения» — до или после первой попытки

ошибка на единицу в лимите

Ключи связи

по какому полю связывают: city_id, а не имя города

связали по неверному полю, а ошибки нет

Терминальные ветки

какой переход и какое уведомление на каждом тупике

тупик без действия — задача зависает

Запись состояния

публикация идёт через outbox, ошибка не теряется

публикация в брокер в обход outbox

Важное правило: на каждый вопрос внутри измерения нужно ответить отдельно — ответ на один не закрывает соседний. Именно здесь task_1 действовал наугад: не задавал вопросов и принимал догадки за решения.

Диаграмма изменения и правки контракта

Любой поток, который пересекает границу между сервисами, investigate рисует диаграммой последовательности. Только она показывает сразу всё, что важно ревьюеру: кто кого вызывает, в каком порядке, напрямую или через событие, и чем заканчивается каждая ветка. Для отказа от оффера это выглядит так:

Польза в том, что на такой схеме сразу заметны две типичные межсервисные ошибки из части 1. Первая — где-то стоит прямой вызов между сервисами, хотя по смыслу там должно быть событие через брокер. Вторая — какая-то ветка обрывается на полпути: задача не переходит в финальный статус и заказчик не получает уведомление. На обычной блок-схеме такое разглядеть труднее.

Сами правки контракта investigate только предлагает, но не применяет — этап остаётся read-only. И если правка задевает что-то общее — меняет, какой сервис владеет данными, добавляет публикацию в обход outbox, ослабляет существующее правило, — investigate не вписывает её сам, а отдельно проговаривает и просит подтверждения. Иначе сгенерированная строка контракта незаметно узаконит дизайн, который человек не одобрял.

Все участники события и владельцы данных

Срез контракта на входе намеренно узкий. Но для каждого нового или изменённого события investigate делает исключение и просматривает все контракты в репозитории: находит всех, кто это событие шлёт, и всех, кто его слушает. Заодно проверяет, что новый ключ, по которому отсекают повторы, появился у каждого получателя, а не только у одного, и что одно событие не используют сразу для двух разных задач. Так ловится ошибка вида «дедуп починили в одном сервисе, а в соседнем забыли» — её не видно, если смотреть только на один сервис. Это и есть критический баг match_id из task_1: переиспользованный идентификатор notification-service отбрасывал как дубликат, и поломку нельзя было заметить, глядя только на matching-service.

Параллельно investigate строит карту владения данными: для каждого куска состояния — какой сервис им владеет и имеет право его менять. Так ловится случай, когда один сервис меняет данные другого напрямую, минуя их владельца. Например, увеличивает счётчик переназначений задачи сам, хотя владелец счётчика — task-service, и менять его можно только через его outbox. Любой такой обход помечается отдельно и требует подтверждения.

Каждый риск превращается в тест

Это самая важная стадия. Каждый риск, пробел, неоднозначность и тупик, всплывшие на предыдущих шагах, превращаются в запись edge_cases[] с путём к тесту. Так выглядит запись в документации archspec (иллюстративный пример из доки скила, не из разбираемого плана):

edge_cases:
  - id: EC-014
    description: "worker city joins to geo by city_id, not free-text city_name; an unresolved city_id must fail loudly, never silently collapse the distance tie-breaker to a default"
    test: "services/matching-service/usecase/matching_geo_test.go::TestEC014"

Почему это важно. Следующий агент, который пишет код, не перечитывает чат — он читает контракт. Замечание, оставленное только в переписке, теряется. А запись edge_cases[] остаётся в контракте, и её держат две проверки: коммит не пройдёт, пока нет файла теста (DET-003), а удалить запись без отдельного решения нельзя (DET-007). Так найденный риск доходит до кода в виде теста. В task_1 этого как раз не было — риски проговорили, но нигде не записали, и они потерялись.

Self-review loop

Если clarify-gate проверял требования, то здесь investigate проверяет уже сам нарисованный план: прогоняет свой черновик по списку из 18 типичных ошибок и повторяет проверку, пока очередной проход не перестанет находить новое (на первом проходе обычно что-то да находится). В списке, среди прочего:

  • при каждом переназначении весь пайплайн подбора (skill-analyzer, worker-facade, рейтинги, гео) запускается заново, хотя кандидатов достаточно посчитать один раз на первой попытке и сохранить снапшот для остальных;

  • консьюмер берёт номер попытки (attempt) из своего состояния в памяти, а не из payload события: после рестарта сервиса или повторной доставки (replay) это состояние пустое — и старое событие повторно запускает весь флоу;

  • хэндлер шлёт два события, которые должны попасть в один outbox-коммит (либо оба, либо ни одного), но добавляет их по отдельности — если между записями случится сбой, уйдёт только первое;

  • сервис обращается к другому по одному запросу на каждый элемент (классический N+1), хотя у того есть batch-метод (GetWorkersBatch, GetDistancesBatch), который вернёт всё за один вызов;

  • сервис меняет данные, которыми владеет другой сервис, синхронным RPC напрямую — вместо того чтобы владелец менял их сам, по событию через свой outbox;

  • команда объявлена идемпотентной, но это не прослежено под replay: если то же событие придёт дважды, нет dedup-ключа или CAS, который не дал бы выполнить эффект второй раз;

  • терминальную ветку (тупик) обработали только для последней попытки, хотя кандидатов может не оказаться уже на первой — и тогда задача зависнет без перехода в финальный статус.

Результат записывается отдельной строкой вида Self-review: <N> pass(es), так что видно, сколько проходов прошло. В task_3 их было 4.

План сохраняется в файл и проходит независимое ревью

План пишется в отдельный датированный файл с расширением .archplan.md. Главный урок task_1: план, который жил только в чате, при переходе к реализации терял топологию и инварианты. Файл переживает чат — implement читает файл, а не историю диалога.

Дальше план проходит независимое ревью — и это проверка, а не формальность. Самопроверка слаба тем, что перечитывает собственные допущения, поэтому план отдают отдельному сабагенту со свежим контекстом, без истории чата. Ему дают только файл плана, все SERVICE_MAP.yaml и proto и просят придирчиво искать причины отклонить план — по той же рубрике, что investigate проверял сам: нет ли придуманных методов; снапшотится ли дорогой подбор вместо пересчёта; используется ли batch-метод там, где он есть; идут ли события через outbox владельца, без синхронной записи в чужой агрегат; протянуто ли новое поле от публичного входа до всех потребителей и демо-данных; стоят ли dedup-ключи у всех консьюмеров; закрыт ли каждый тупик (включая первую попытку) переходом и уведомлением; совпадает ли диаграмма с правками контракта.

Если ревьюер возвращает REVISE, план правят и отдают новому ревьюеру со свежим контекстом — не больше 3 раундов. В task_3 это дало Plan-review: APPROVED после 2 раундов. А если запустить отдельных сабагентов нельзя, ревью честно помечается как Plan-review: SELF-ONLY (план перечитал сам себя), а не выдаётся за APPROVED — чтобы было видно, что независимой проверки не было.

Завершает investigate чеклист готовности (Definition of done) — явный список, где зелёный go build/go test не закрывает ни одного пункта. Закрыто только тогда, когда у каждого edge_cases[] есть реально работающий тест и когда /archspec:validate (а для кросс-сервисных изменений и /archspec:check-architecture) зелёный. Это прямой ответ на ловушку task_1, где зелёная сборка выдавалась за готовность.

Кратко про implement

implement устроен по тому же принципу: проверка перед фактом, а не после. Архплан для него обязателен — без .archplan.md он не запускается. Сначала меняются контракты: YAML-патч применяется и прогоняется через /archspec:sync раньше, чем пишется код. Затем строится план реализации, где каждое требование привязано к задаче и тесту, и отдельно проверяется, что каждый вызываемый метод реально есть в proto или контракте (грепом) — придуманных методов быть не должно. Дальше — реализация по TDD, задача за задачей. И главное: по всему диффу прогоняются 5 проверок, что код соответствует плану, каждая против своего класса бага — тех самых, что мы видели в секции 3:

  • Wiring — в точке сборки каждого сервиса (main.go) нигде не передан nil вместо зависимости, и каждый клиент ходит на правильный порт. Ловит панику на первом же событии из-за незаведённой зависимости.

  • Emission — каждое событие из контракта публикуется на каждом пути, где оно нужно, включая переназначение, а не только при первичном подборе. Ловит «match.found шлётся только на первом матче».

  • Threading — новое поле протянуто от публичного API до всех потребителей и демо-данных, а задекларированный роут совпадает с роутером посимвольно. Это ровно тот класс, что в task_3 сломал создание задач: city_id добавили во внутренний proto, но не протянули через api-gateway.

  • Dedup — dedup-марка ставится атомарно с side-effect (или после него, но не до), и отдельно прослеживается повторная доставка события второй попытки через дедуп каждого консьюмера. Ловит коллизию match_id из task_1.

  • Evidence — таблица «требование → конкретное место в коде (file:line) → тест». Ловит требование, которое просто не написали, — как «рейтинг первичен, расстояние тай-брейк» в task_3.

Каждый проход существует потому, что соответствующий баг реально уезжал в прод с зелёными юнит-тестами. После проходов — /archspec:validate, /archspec:check-architecture и независимое ревью диффа свежим сабагентом. Если запустить отдельного ревьюера нельзя, результат, как и на этапе плана, честно помечается SELF-ONLY — код проверил сам себя, а не независимый агент.

Три слоя проверок

Проверки выстроены от быстрых к тяжёлым: мгновенный автоматический слой отсекает тривиальные ошибки до того, как дойдёт до медленных агентных ревью с вызовами LLM:

Слой

Что проверяет

Когда

Детерминированный DET-001…015

схема контракта, циклы, пути тестов, дрейф диаграмм, идемпотентность

на каждом коммите через pre-commit hook

AI-линтеры по Go AST AI-001…009

идемпотентность хэндлеров, outbox, optimistic locking, swallowed errors, N+1, незадекларированные события

на /archspec:validate

Агентные ревью-гейты

plan-review, 5 проверок кода по диффу, независимое diff-ревью

внутри investigate/implement

Весь пайплайн целиком выглядит так:

Если коротко: investigate — это не генератор красивого Markdown. Это попытка зафиксировать как контракт те межсервисные правила, которые раньше всплывали только на ревью или в проде, и довести каждую находку до кода через тест, а не оставить её прозой в чате. Каждая стадия закрывает конкретный класс ошибки из task_1: clarify-gate — догадки без вопросов, обход всех контрактов — забытый dedup в соседнем сервисе, мост edge_cases — потерю находки в чате, plan-review — слабость самопроверки. Но набор стадий хорош ровно настолько, насколько строги его проверки. И как показала секция 3, слабое звено сейчас — проверка того, что код соответствует плану: план протянул поле city_id, но ни одна стадия не заставила зафиксировать единый набор значений city_id между сервисами, и в коде это вылилось в city-msk против moscow при зелёных тестах. Об этом — в выводах.

5. Выводы

Цикл проверял одну гипотезу: меняется ли результат LLM на одной и той же фиче, если перед реализацией дать ей не локальные CLAUDE.md, а машиночитаемые архитектурные контракты. Smart Task Reassignment прошла через две модели работы: task_1 без archspec и task_3 поверх контрактов из части 2. Промпт фичи и модель планирования совпадали, отличался только вход.

Главный результат — в контрасте. В task_1 план вообще не дорабатывался, и критический баг прошёл в реализацию незамеченным — по чеклисту эталона вышло ~64%. В task_3 промпт тот же, но поверх контрактов план прошёл реальную доработку (самопроверку и независимое ревью) и закрыл этот баг ещё до кода. По чеклисту план поднялся до ~98%, код — до ~93%. Хороший план заметно поднимает качество.

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

Отсюда вывод про инструмент. Ценность investigate — в том, что он переносит проверку раньше, ещё до кода: на входе не свободный текст, а контракт; дальше уточняющие вопросы, поиск всех получателей каждого события, превращение каждого риска в тест и независимое ревью плана. Проблему ищут до реализации, а не после неё. Но task_3 показывает и следующий шаг развития: добавить проверку, что код действительно соответствует плану. Подход уже работает и приносит пользу, а этот пробел — понятная точка роста.

Что можно забрать даже без плагина:

  • машиночитаемый контракт сервиса как вход в задачу — вместо свободного Markdown, который расходится с кодом и устаревает;

  • разрешение неоднозначностей до плана: точка входа, владелец состояния (system-of-record), источник identity, числовые лимиты, ключи дедупликации;

  • проверка архитектурных инвариантов уже на этапе плана: события идут через outbox владельца, нет синхронной записи в чужой агрегат, каждая терминальная ветка закрыта переходом и уведомлением;

  • трассировка каждого нового события по всем контрактам — все продюсеры, консьюмеры и dedup-ключ у каждого консьюмера;

  • каждый риск (edge case) фиксируется тестом прямо в контракте, а не остаётся в обсуждении;

  • независимое ревью плана: отдельный ревьюер со свежим контекстом целенаправленно ищет нарушения этих правил до начала реализации.

Ценность здесь не в генерации кода, а в дисциплине стадий. Эти принципы работают и сами по себе, а archspec превращает их в автоматические проверки — чтобы они держались на инструменте, а не на памяти команды.

archspec — open source, его можно пробовать как плагин для Claude Code. Если найдёте баг, неудобный сценарий или недостающее правило — заводите issue.

Все ссылки:

На этом цикл закончен — спасибо, что дочитали.