Задания, отчёты и патчи, лежавшие в 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>
18 KiB
Приёмка Task 3 / B-2 и Задача 4
Базовая ревизия: main @ b11c277 (PR #12, squash merge, 21 файл, 1113/172).
Предшествующие документы: smm-improvement-plan-2026-07-30.md, smm-task-2-2026-07-31.md, smm-task2-blockB-review-2026-08-03.md, smm-task-3-2026-08-03.md.
0. Приёмка B-2: принято
Проверял по origin/main, а не по отчёту. Все семь контрольных сумм сходятся, CI 14/14 на PR #12, PATCH_VERIFICATION.txt подтверждает применимость патча к чистой базе.
| Требование задачи 3 | Проверка в main @ b11c277 |
|---|---|
| B2-1 фоновый сервис, старт + интервал | Program.cs:63 — AddHostedService<LinkReconciliationBackgroundService>; do { RunOnceAsync } while (timer.WaitForNextTickAsync) — первый проход немедленный ✅ |
Control__LinkReconciliationSeconds |
ControlOptions.cs:27 (=300), валидация Program.cs:49 (30…3600), appsettings.json:13, control.env в bootstrap ✅ |
| Проход по всем действующим политикам | ReconcileAllAsync через ListEffectiveLinksAsync, не по одному Node ✅ |
| Порядок блокировок сохранён | sorted node locks → per-Link gate, новых классов не введено ✅ |
link.reapplied / link.orphan-removed |
LinkService.cs:309 ✅ |
| Единая логика сходимости | ConvergeAsync — используется create/disable/reconnect/full/TTL/retry. Три копии сведены к одной ✅ |
| B2-2 «таблицы нет» ≠ «правил ноль» | helper: exit 79 + точный маркер mesh.firewall-unavailable в stderr, LC_ALL=C; типизированное исключение; проход прерывается; агрегированное событие; bounded backoff; событие восстановления mesh.firewall-available ✅ |
| B2-3 маркер внеочередной реконсиляции | firewall-restore и mesh-enable публикуют маркер атомарно под общим root-flock с UUID-поколением; helper потребляет только точное поколение ✅ |
| B2-4 тесты | LinkReconciliationTests.cs (338 строк), контрактные проверки в bootstrap-тесте, интеграционный тест последовательности вызовов helper ✅ |
| Acceptance: firewall-restore без перезапуска Node | новый шаг [8/12] с retry-циклом по фактическому статусу и реальной связности ✅ |
M1 GetLinkAsync, L1 CR/LF |
оба исправлены ✅ (в REPORT.md метки M1 и L1 перепутаны местами — сами правки на месте) |
| M2, M4, M5 | явно перенесены в B-3 с обоснованием в docs/roadmap.md — это и требовалось по DoD ✅ |
docs/linux-bootstrap.md |
приведён в соответствие с реализацией ✅ |
Отдельно отмечу два места, где сделано лучше, чем было в задании:
- Я просил просто маркер. Реализовано поколение-UUID под общим
flockс потреблением ровно прочитанного поколения — то есть закрыта гонка «пока шёл проход, пришёл новый запрос», о которой в задании не было ни слова. - Маркер обходит только регулярный интервал, но не обходит backoff недоступного firewall. Это правильное разделение, и оно покрыто тестом.
Независимый review в этот раз проводился против полного текста задачи, а не только дифа — рекомендация из прошлого документа принята и сработала: его находки (маркер против throttle, слепое удаление чужого поколения, выход acceptance по первому несовпадению, баннер при нуле SSH-профилей, локализованный stderr nft) — это ровно тот класс дефектов, который в прошлый раз никто не искал.
Статус блока: merged / verified, physical acceptance pending. Формулировка в отчёте корректна, B6 не объявлен закрытым. Это правильно и так и должно остаться до прогона на реальной тройке.
1. Находки по B-2 (не блокирующие merge, в работу)
N1 — MEDIUM. Незавершаемый маркер даёт полный проход каждые 30 секунд бесконечно
LinkReconciliationBackgroundService.RunOnceAsync:
if (requestGeneration is null && _nextRegularAt is not null && now < _nextRegularAt) return null;
...
else { _nextRegularAt = now + interval;
if (requestGeneration is not null && result.Failed == 0)
await applier.CompleteReconciliationAsync(requestGeneration, ct); }
Маркер потребляется только при Failed == 0, а наличие маркера полностью снимает регулярный throttle. _backoffUntil взводится только для FirewallUnavailable, обычные отказы под него не попадают.
Следствие: одна политика, которая падает стабильно и не связана с firewall — например Node удалён из nodes.tsv, и lookup_node_ip возвращает 78, — навсегда удерживает маркер. Полный проход начинает выполняться на каждом poll-тике (min(interval, 30) = 30 с) без ограничения, каждый проход — до двух sudo+nft на каждую подходящую политику. При полусотне Link это сотня привилегированных вызовов каждые полминуты на неопределённый срок.
Сделать: применить ограниченный backoff и к marker-пути. Например: счётчик неуспешных prompt-проходов, после 3–4 попыток маркер перестаёт снимать регулярный throttle, в журнал пишется одно предупреждение с идентификаторами упавших политик. Тест: политика, падающая всегда, при висящем маркере не должна вызывать более N проходов за интервал.
N2 — MEDIUM. Orphan-правило для полностью сошедшейся Disabled-политики не обнаруживается
private static bool IsEligibleForFullReconciliation(LinkPolicy link)
=> link.DesiredState == "Active"
|| (link.DesiredState == "Disabled" && link.ActualState != "Disabled");
Политика в состоянии Disabled/Disabled из полного прохода исключена — то есть если accept-правило с её комментарием окажется в цепочке, никто этого не заметит. А это ровно инвариант kill switch.
Это моя недоработка в постановке, а не отклонение исполнителя — фильтр в задаче 3 сформулирован мной дословно так же. Отмечаю, чтобы не потерялось.
Реалистичные пути появления такого правила: восстановление сохранённого ruleset nft -f поверх, частично применённый backup, обрыв между успешным link-connect и записью в БД, отказ на середине link.orphan-removed.
Правильное решение — развернуть направление прохода. Сейчас идём БД → факт, по одной политике, двумя вызовами helper на каждую. Нужно наоборот: один вызов link-list, возвращающий все правила цепочки с комментарием smm:*, и сверка множеств:
- правило есть, политики в БД нет либо
DesiredState=Disabled→ удалить,link.orphan-removed; - политика
DesiredState=Active, правила нет → применить,link.reapplied; - совпало → ничего.
Это одновременно закрывает N2, снимает зависимость от IsEligibleForFullReconciliation, и превращает стоимость прохода из 2N привилегированных вызовов в один. link-list был в исходной постановке Block B (пункт B1) и до сих пор не реализован — сейчас есть только пер-линковый link-status.
Связано с M4: пока нет retention для завершённых Disabled-политик, обход БД→факт растёт неограниченно. Обход факт→БД от этого не зависит.
N3 — LOW. На Hub без mesh-init предупреждение каждые 30 секунд
install_control создаёт $STATE_DIR и $STATE_DIR/backups, но не $STATE_DIR/mesh; каталог появляется только в mesh_init / reserve_node_address. Helper в reconcile_status делает exec 9>"$RECONCILE_LOCK" в несуществующем каталоге — под set -e это отказ. В RunOnceAsync он ловится и пишется LogWarning, то есть в journal попадает предупреждение каждые 30 секунд до первого mesh-init.
Сделать: helper возвращает complete, если каталог mesh отсутствует (Mesh ещё не инициализирован — запросов быть не может), либо создаёт каталог при первом обращении.
N4 — LOW. Отсутствующий бинарь nft неотличим от отсутствующей таблицы
inspect_firewall берёт объединённый вывод 2>&1 и классифицирует по подстроке No such file or directory. Если /usr/sbin/nft не установлен, эту же строку выдаёт сам bash — и ситуация классифицируется как mesh.firewall-unavailable (79). Поведение fail-closed и мутаций не будет, так что дефект не опасный, но оператор увидит «Mesh firewall не загружен» вместо «nftables не установлен».
Сделать: явная проверка [[ -x /usr/sbin/nft ]] с отдельным сообщением до вызова.
N5 — LOW. Форматирование в three-server-mesh.sh
В блоке probe_factual_status / expect_factual_status перемешаны отступы 4 и 2 пробела, закрывающая } вынесена внутрь тела, определение expect_factual_status() { смещено на два пробела. bash -n проходит, поведение верное, но остальной файл написан с отступом в 2 пробела. Похоже на тот же артефакт инструмента, что в прошлый раз дал (char)13. Переформатировать.
N6 — nit. REPORT.md меняет местами метки M1 и L1
Написано «M1 (CR/LF compaction) и L1 (direct Link lookup)»; в задаче M1 — это GetLinkAsync, L1 — CR/LF. Обе правки сделаны, перепутаны только подписи.
2. Задача 4
Блок B-3 (первым)
Переносится из roadmap плюс находки выше.
| ID | Что | Приоритет |
|---|---|---|
| N2 + B1 | link-list в policy-helper и разворот прохода реконсиляции на факт → БД; удаление orphan-правил независимо от состояния в БД; один привилегированный вызов на проход вместо 2N |
высокий |
| N1 | Ограниченный backoff для marker-пути; тест на политику, падающую всегда | высокий |
| M2 | Развести LinkReconciliationResult на Examined / Converged / Failed; сейчас «1 реконсилирован» при нуле мутаций |
средний |
| M4 | Фильтр по умолчанию на Links-странице («действующие + расхождения», с переключателем) и/или retention завершённых Disabled-политик в ControlMaintenance |
средний |
| M5 | Типизировать «Node зарезервирован, но не активирован в mesh» — сейчас lookup_node_ip даёт отказ применения и Link уходит в Failed на каждом проходе |
средний |
| N3 | Helper возвращает complete при отсутствующем каталоге mesh |
низкий |
| N4 | Явная проверка наличия nft с отдельным сообщением |
низкий |
| N5 | Переформатировать three-server-mesh.sh |
низкий |
Критерий выхода B-3: проход реконсиляции обнаруживает и снимает accept-правило, которому не соответствует ни одна действующая политика, включая случай Disabled/Disabled; стабильно падающая политика не вызывает более одного прохода за интервал; Links-страница остаётся читаемой при сотнях исторических политик.
Блок C — доверенная поставка
Без изменений относительно smm-task-2-2026-07-31.md, раздел 3, и smm-task-3-2026-08-03.md, раздел 3:
- C1 manifest v2: хэши всех артефактов, версии Control/Agent/helper/Desktop, минимальные совместимые версии,
helper_protocol. - C2 подпись (
cosign sign-blobkeyless либоminisign); публичный ключ/issuer вшит константой в bootstrap и Desktop, не скачивается вместе с релизом; отсутствие инструмента проверки — отказ, не предупреждение. - C3
verify_archiveсверяет хэш с manifest, а не с соседним.sha256; действиеverify-manifest; отказupdate-*при несовместимых версиях;PROGRAM_VERSIONиз тега вместо0.2.0-dev. - C4 пиннинг Actions по commit SHA,
dependabot.yml(github-actions + nuget), сужениеpermissions: contents: writeдо job'а публикации, SBOM в артефакты. - C5 негативные тесты в CI: изменённый байт архива, подменённый хэш в manifest, manifest без подписи — все три отвергаются.
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
Пока он не выполнен, B6 и вся Этап 3 roadmap остаются открытыми, независимо от того, сколько раз CI будет зелёным. Сейчас harness готов и включает шаг firewall-restore без перезапуска Node — то есть сценарий, ради которого делались Block B и B-2, впервые проверяем по-настоящему. Это единственный пункт списка, который не может закрыть исполнитель.
3. Порядок
- B-3, приоритет по таблице: N2+B1 и N1 в одном PR (они оба про сервис реконсиляции), M2/M4/M5 вторым, N3/N4/N5 попутно.
- Physical acceptance — как только выданы topology inputs. По результату закрыть B6 и соответствующие пункты Этапа 3 и 5 roadmap.
- Блок C.
4. Процесс — работает, оставить
Практика «независимый review получает полный текст задачи, а не только диф» подтвердилась: пять находок этого цикла относятся к классу, который в прошлый раз не искали вовсе. Сохранить и для B-3, и для Блока C — для Блока C особенно, там корректность дифа и корректность модели угроз расходятся ещё сильнее.
Формулировка статуса merged / verified, physical acceptance pending вместо «выполнено» — тоже оставить.