Задания, отчёты и патчи, лежавшие в C:\Users\Ochenstarik\projects и в домашней папке, перенесены в agents/. Разложено по агентам там, где имя файла позволяло определить автора; остальное — в _salvage-2026-08-18/ и разбирается вручную. Патчи в notes/salvage-2026-08-18/ — незакоммиченная работа из брошенных рабочих копий: она существовала только на диске. Тяжёлое (релизные архивы, инсталляторы, наборы данных) в репозиторий не попало: оно лежит рядом, в Agent_projects/_archive и Agent_projects/_data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 KiB
ТЗ: B-3R — репарация Block B-3 до пригодного к merge состояния
Репозиторий реализации: C:\Users\Ochenstarik\projects\server-monitor-manager-task3-b3
Ветка: hermes/task3-b3-fact-reconciliation, база b11c277, изменения не закоммичены.
Исходное ТЗ: smm-task-B3-spec-2026-08-03.md — остаётся в силе целиком, этот документ его не заменяет, а добавляет условия приёмки.
Пакет Task3-B3-2026-08-04 — промежуточный (STATUS-WIP.md + первичный review), кода в нём нет. Проверку проводил по рабочей копии напрямую.
1. Что подтверждено независимо
Обе блокирующие находки первичного review — реальны и в текущем состоянии рабочей копии не закрыты. Проверял по коду, а не по отчёту.
B1 подтверждён
src/ServerMonitorManager.Control/LinkService.cs:
:416 if (persisted)
:418 await VerifyFactualStateAsync(current, expectedConnected, cancellationToken);
:431 else if (!persisted)
:433 current = current with { ActualState = completedState, LastError = null };
DB-less orphan удаляется сырым link-disconnect, после чего успех присваивается синтетическому объекту без единой проверки факта. Доказательства, что все handles с этим comment исчезли, нет.
Для persisted-политики проверка есть, но идёт через link-status, а status_rule по-прежнему вызывает lookup_node_ip для обоих узлов. Если Node отсутствует или не активирован, helper возвращает 80 уже после успешного удаления правила, и ConvergeAsync:444-455 записывает PendingActivation / mesh.node-not-activated.
Дальше в ReconcileAllAsync:274-284 классификация такова, что PendingActivation не попадает ни в Converged, ни в Failed. Возможен результат Examined=1, Converged=0, Failed=0, при котором фоновый сервис видит Failed == 0 и потребляет marker, хотя фактический результат не подтверждён.
B2 подтверждён
deploy/ochenstarik-smm-policy-apply:37-44:
lookup_node_ip() {
record="$(awk -F '\t' -v node="$node_id" '$1 == node { print; exit }' "$STATE_FILE")"
[[ -n "$record" ]] || node_not_activated
ip="$(awk -F '\t' '{ print $2 }' <<<"$record")"
[[ "$ip" =~ $ipv4_pattern ]] || fail "node has no valid mesh address: $node_id"
Читается только второе поле. Четвёртое — статус — не читается никогда. reserve_node_address пишет строку node<TAB>10.77.0.x<TAB>-<TAB>reserved, peer-add меняет статус на active. Значит зарезервированный, но не активированный Node проходит проверку, и link-connect создаёт accept-правило. Exit 80 возникает только при полном отсутствии строки — случай, которого в реальной топологии не бывает, потому что строку создаёт сам node-code.
Новый тест моделирует именно отсутствующую строку и поэтому дефект не ловит. Воспроизведение ревьюера на production-shaped фикстуре считаю корректным.
Что уже сделано правильно и ломать не надо
disconnect_ruleбольше не вызываетlookup_node_ip— удаление правила перестало зависеть отnodes.tsv. Это верное направление, сохранить.list_rulesразбирает только managed-comment, проверяет арность 5 полей, node-паттерны, протокол и диапазон порта, отбрасывает подделки в stderr, чужие правила не выводит.reconcile-statusвозвращаетcompleteпри отсутствующем каталоге mesh; добавлена явная проверка-x /usr/sbin/nft; режимSMM_POLICY_LISTING_FILEдля контрактных тестов.Examined/Converged/Failedразведены,FailedPolicyIdsпробрасывается.
2. Обязательные исправления
R1 — верификация факта только через link-list (закрывает B1)
Корень дефекта: проход стал fact-first, а верификация осталась per-link через link-status. Отсюда и пропуск проверки для DB-less, и зависимость от nodes.tsv, и лишние привилегированные вызовы.
Сделать:
VerifyFactualStateAsyncпереписать наlink-list: считать число записей с ключом(source, target, protocol, port).- ожидается
Active→ ровно 1; - ожидается
Disabledили DB-less orphan → ровно 0; - любое иное число — отказ применения с явным кодом.
- ожидается
- Убрать условие
if (persisted)вокруг верификации. DB-less orphan проверяется теми же правилами, что и persisted. - Убрать присваивание
current with { ActualState = completedState }без предшествующей проверки. Синтетический объект имеет право получитьDisabledтолько после подтверждённого нуля записей. - Верификация удаления не должна зависеть от наличия узла в
nodes.tsv.link-listэтого не требует — после перехода зависимость исчезает сама. - Оптимизация, которую даёт этот же переход: в проходе выполнять один финальный
link-listпосле всех мутаций и сверять по нему все затронутые ключи разом, вместо вызова на каждую мутацию. Требование «проход без расхождений — один привилегированный вызов» из B3-2 сохраняется; для прохода сkмутациями допускается ровно два вызова (стартовый и верифицирующий).
R2 — исчерпывающая классификация результата (закрывает вторую половину B1)
Ввести явный третий класс — Deferred — и инвариант:
Converged + Failed + Deferred == Examined
Инвариант проверять в коде (нарушение — исключение либо LogError с идентификаторами) и отдельным тестом. Ни одно состояние не должно молча выпадать из классификации.
Решение, которое нужно принять явно, потому что иначе оно будет принято неправильно:
PendingActivation— этоDeferred, а неFailed. Это ожидаемое устойчивое состояние: Node зарезервирован,peer-addещё не выполнен, и так может продолжаться сутками.Deferredне блокирует потребление marker. Иначе один зарезервированный Node навсегда удержит маркер и вернёт ровно тот бесконечный цикл проходов каждые 30 секунд, который был закрыт в B-2 и B3-3.- Marker блокирует только
Failed, и на него уже действует ограничение в три попытки. Deferredобязан быть видимым: отдельное событие (link.pending-node-activationуже есть), отдельная подпись в Desktop, отражение в журнале прохода. Ошибкой не называется.
LinkFullReconciliationResult и LinkReconciliationResult дополнить полем Deferred и списком DeferredPolicyIds по образцу FailedPolicyIds.
R3 — читать статус узла в lookup_node_ip (закрывает B2)
Разбирать минимум два поля — адрес и статус — и различать три исхода:
Строка в nodes.tsv |
Результат |
|---|---|
статус active, валидный IPv4 |
успех |
статус reserved (или любой не-active) |
exit 80, точный маркер mesh.node-not-activated |
статус active, IP пустой или невалидный |
exit 78 — повреждённое состояние, fail-closed |
| строки нет вовсе | exit 80 |
Неизвестное значение статуса трактовать как не-active только если оно проходит валидацию формата; мусор в поле — это exit 78, а не 80. Fail-closed при сомнении.
Тесты обязаны использовать production-shape фикстуру из четырёх полей, разделённых табуляцией:
source 10.77.0.2 key-source active
target 10.77.0.3 - reserved
Три случая: reserved + валидный IP → 80; active + валидный IP → успех; active + невалидный IP → 78. Тест на полностью отсутствующую строку сохранить, но он не заменяет перечисленные.
R4 — source-generated сериализация orphan audit (H1)
Заменить анонимный тип в ControlStore.cs:1376-1398 именованным DTO, добавить его в source-generated JSON-контекст Control и сериализовать через Context.Default.<Type>. Мотив не в предупреждениях компилятора, а в том, что отказ произойдёт после мутации firewall и после публикации link.orphan-removed, дав противоречивую телеметрию.
Дополнительно к тому, что предложил review: проверить не локально, а прогоном опубликованного linux-x64 PublishTrimmed артефакта по orphan-пути и сверкой содержимого audit-записи. Разово, руками, с приложением вывода в TEST_EVIDENCE.md.
Порядок операций зафиксировать явно: запись audit — до публикации финального события успеха.
R5 — Linux integration test на новый протокол (H2)
tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs остался на протоколе link-status-first: fake helper не реализует link-list, ожидаемый журнал вызовов начинается с link-status. На Windows тест выходит по OperatingSystem.IsLinux() == false, поэтому заявленные локальные 85/85 дефект не показывают, а linux-control-agent.yml гоняет Control-тесты на Ubuntu, где пропуска не будет.
Сделать:
- реализовать
link-listв fake helper; - переписать ожидаемый журнал на fact-first протокол;
- проверить: no-drift → ровно один
link-list; отсутствующая таблица классифицируется прямо наlink-listбез промежуточной мутации; мутация сопровождается финальной верификацией черезlink-list; DB-less raw disconnect проходит верификацию; - прогнать Control suite на Linux, а не только на Windows.
Пока это не сделано, утверждение «CI зелёный» не имеет оснований: PR не создан, Linux-прогон не выполнялся.
R6 — убрать fail-open default в интерфейсе (M1)
ILinkPolicyApplier.ListRulesAsync с телом по умолчанию, возвращающим пустой список, означает, что забытая реализация молча сообщает «firewall пуст» — для источника истины о факте это худший из возможных дефолтов, и он уже позволил старым test doubles скомпилироваться без нового протокола. Сделать ListRulesAsync и raw ApplyDisconnectAsync(LinkRule, …) обязательными членами интерфейса, все test doubles обновить явно.
R7 — читаемость вложенных scope (L1)
Переформатировать LinkService.cs:181-229 и 240-293 либо вынести обработку одного tuple и одного orphan в приватные методы без дублирования логики сходимости. Порядок блокировок sorted node locks → per-Link gate должен быть виден глазами, а не восстанавливаться разбором отступов.
3. Дополнительные тесты к обязательным из исходного ТЗ
- DB-less orphan: helper сообщает успех удаления, но правило осталось → проход обязан классифицировать это как
Failed, а не как успех. - Persisted
Disabled, узел отсутствует вnodes.tsv→ удаление подтверждается черезlink-list, состояние становитсяDisabled, а неPendingActivation. - Marker не потребляется, если хоть один элемент попал в
Failed; потребляется, если остались толькоConvergedиDeferred. - Инвариант
Converged + Failed + Deferred == Examinedна смешанном наборе из шести политик. - Проход с
kмутациями выполняет ровно два привилегированных вызоваlink-list. - Production-shape
reserved→ exit 80 (см. R3). - Зарезервированный Node в течение десяти последовательных проходов:
Deferredкаждый раз, marker потребляется, число проходов не превышает расписание.
4. Definition of Done B-3R
- Верификация факта выполняется только через
link-list, счётом записей, одинаково для persisted и DB-less - Синтетический успех без проверки факта отсутствует в коде
- Верификация удаления не зависит от
nodes.tsv Converged + Failed + Deferred == Examinedвыполняется и проверяется тестомPendingActivationклассифицирован какDeferred, marker им не блокируется, состояние видно в UI и журналеlookup_node_ipчитает статус;reserved→ 80,active+ битый IP → 78; тесты на production-shape фикстуре- orphan audit сериализуется через source-generated контекст; проверено на опубликованном trimmed артефакте
- Linux integration test переведён на fact-first протокол и прогнан на Linux
ListRulesAsyncи raw disconnect — обязательные члены интерфейса- Все пункты DoD исходного ТЗ B-3 по-прежнему выполняются
- PR создан, CI зелёный — впервые для этой ветки
- Финальный независимый review получает полный текст исходного ТЗ B-3 и этот документ, а не только диф
5. Формат сдачи
Как в предыдущих блоках: REPORT.md, TEST_EVIDENCE.md, патч и/или ZIP, CI_CHECKS.txt, финальный INDEPENDENT_REVIEW.md, SHA256SUMS — считать последним действием, после всех правок отчётов, включая все файлы пакета.
Промежуточный пакет из двух файлов, как сейчас, — нормальная практика для отчёта о ходе работ, если он так и назван. Финальный обязан быть полным.
Статус по итогам merge формулировать как merged / verified, physical acceptance pending.
6. Оценка первичного review
Обе блокирующие находки верны, воспроизведение B2 корректно, разбор границ безопасности содержательный. Ревьюер честно указал, что dotnet в его окружении отсутствует и заявленные 85/85 он не воспроизводил — и именно из этого сделал правильный вывод про H2, что Linux-прогон обязателен. Это ровно тот способ работы, который нужен.
Практика передачи ревьюеру полного текста ТЗ продолжает окупаться: B2 и H2 — пропущенные требования, а не ошибки в написанном коде, при diff-only ревью они не находятся.
7. Внешний блокер, без изменений
Physical acceptance не выполнялся ни разу за четыре блока. Нужны HUB_SSH_HOST, HUB_SSH_USER, SOURCE_SSH_HOST, SOURCE_SSH_USER, HOME_WG_IP, SECOND_WG_IP, SSH_IDENTITY_FILE и прогон
SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1 tests/acceptance/three-server-mesh.sh
Harness готов и содержит и firewall-restore, и инъекцию orphan-правила. Критерий B6 и пункт «выполнить физический acceptance» Этапа 3 roadmap до этого прогона остаются открытыми при любом состоянии CI. Исполнитель закрыть их не может.
Следующий блок после B-3R — Блок C, доверенная поставка, по разделу 3 документа smm-task-4-2026-08-03.md.