Files
orchestrator/docs/history/LESSONS_2026-06-08_phantom-merge.md

7.8 KiB
Raw Blame History

Lessons Learned — 2026-06-08: «Фантомный merge» — прод деплоится, но код не сливается в main

Severity: CRITICAL (потеря целостности main, накопительная потеря кода между задачами)

Резюме

Self-deploy (Phase B) собирал прод-образ из ВЕТКИ задачи и рапортовал finalize SUCCESS + post-deploy HEALTHY, но git-merge ветки в main НЕ происходил. PR оставался open. Следующая задача срезала свою ветку от устаревшего main → теряла код незалитых предшественников. Накопительно потеряны в main: ORCH-022, ORCH-059, ORCH-066, ORCH-068 (PR#67/68/69/70 — все open, merged=False). Последний реально слитый — ORCH-065 (PR#66).

Как обнаружено

Симптом: ORCH-067 переведён в To Analyse, но конвейер не стартовал (no pipeline action). Причина — прод слушал старый триггер in_progress, а не to_analyse (ORCH-066). При разборе выяснилось: код ORCH-066 не в проде, хотя он «деплоился».

Решающее наблюдение оператора (Слава): «спам ET-002 начался СРАЗУ после деплоя 66 → значит код деплоился». Это вскрыло механизм: код 66 БЫЛ в проде 22:1705:32, потом стёрт деплоем 068 (срезан от старого main без 66).

Доказательная база (как подтверждали — воспроизводимый метод)

  1. PR-статус (Gitea API): PR#67(022)/68(059)/69(066)/70(068) = open, merged=False. PR#66(065) = merged=True (последний честный).
  2. md5-сверка файлов прод vs origin/main vs ветка:
    • src/reconciler.py, src/plane_sync.py: prod md5 == ветка ORCH-068 != main → прод = снимок ветки 068, НЕ main.
    • src/webhooks/plane.py: prod == main == ветка-068 (ветка 068 этот файл не трогала → видна старая база без to_analyse).
  3. git merge-base: ветка ORCH-068 срезана от bb03350 (ORCH-065), не от кода 066. История ветки-068 по 066 содержит только docs staging, кода (to_analyse) нет.
  4. Таймлайн логов: деплой 22:17 (ветка-066, сломанный reconciler) → спам ET-002 начался; деплой 05:32 (ветка-068, база 065 без 66) → спам прекратился (0 после 05:33). Подтверждает: прод-образ = снимок ВЕТКИ, меняется при каждом деплое, теряет незалитое.

Root cause (гипотеза → нужен код-аудит self_deploy/merge_gate)

Self-deploy Phase B инициирует прод-деплой из worktree ветки (BUILD-ONCE из validated commit). Шаг git-merge ветки в main:

  • ЛИБО не вызывается на self-hosting пути (Phase B уходит в detached host-процесс, finalizer пишет SUCCESS-маркеры, но merge отдельно и молча скипается/падает),
  • ЛИБО регресс фикса ORCH-065 (idempotent merge / merge-lease reclaim): guard pr_already_merged или lease-reclaim ошибочно считает PR уже слитым / не докатывает merge после рестарта контейнера (а Phase B ИМЕННО рестартит контейнер → процесс, державший merge-lease, умирает до завершения merge). Симптоматически ORCH-065 был последним успешным merge — деградация началась СРАЗУ после него или из-за взаимодействия его механики с self-deploy-рестартом.

Почему конвейер не заметил

  • finalize SUCCESS и post-deploy HEALTHY маркеры пишутся НЕЗАВИСИМО от факта merge. Пайплайн считает задачу done по этим маркерам, git-состояние main не верифицируется.
  • Прод здоров (образ из ветки рабочий) → health-check зелёный → нет сигнала о проблеме.
  • Дыра видна только при сравнении main с прод ИЛИ когда следующая задача теряет код предыдущей (что и случилось с 67).

Уроки

  1. Деплой ОБЯЗАН верифицировать, что код реально в main ПОСЛЕ деплоя. finalize SUCCESS без проверки git merge-base origin/main == deployed_commit (или PR.merged==true) — фальшивый зелёный. Добавить post-merge верификацию: deployed SHA должен быть предком origin/main.
  2. Маркер «deployed» != «merged». Нельзя считать задачу завершённой по staging/post-deploy-маркерам, если PR не закрыт merge. Гейт: задача → done ТОЛЬКО при PR.merged==true.
  3. Self-deploy рестартит контейнер → любой держатель merge-lease/незавершённый git-шаг умирает. Merge ДОЛЖЕН завершиться и быть подтверждён ДО рестарта прод-контейнера, либо merge выносится в шаг, переживающий рестарт (как requeue_running_jobs, но для merge-в-main).
  4. Срез ветки от main делает целостность main критичной. Если main отстаёт — каждая новая задача наследует дыру. main = единственный источник для новых веток, его рассинхрон с прод = накопительная потеря.
  5. Метод диагностики (сохранить как runbook): при подозрении на рассинхрон — (a) Gitea API PR list merged-флаги, (b) md5 prod-файлов vs git show origin/main:<file>, (c) merge-base ветки vs main, (d) таймлайн деплой-логов. Эти 4 проверки однозначно локализуют фантом.

Действия

  • Восстановление main: интеграционная ветка integ/restore-main-2026-06-08 — последовательный merge 022→059→066→068 (docs union-resolved, reconciler-конфликт 066⊕068 разрешён: каркас 068 livelock-fix + триггер to_analyse 066), полный pytest, затем merge в main + передеплой.
  • Заведён критбаг ORCH-071: «фантомный merge — self-deploy без верификации merge в main» (root-fix: post-deploy verify + done-гейт по PR.merged + merge до рестарта).
  • ORCH-070 (Confirm Deploy trigger) частично ДУБЛИРУЕТ ORCH-059 (handle_confirm_deploy уже написан в 059) — после долива 059 пересмотреть scope 070 (остаётся только display-слой статусов Monitoring after Deploy).

Связанные

  • ORCH-065 (последний честный merge; подозрение на регресс его merge-механики)
  • ORCH-066/068 (потерянный код), ORCH-059 (Confirm Deploy trigger, тоже потерян)
  • Урок 2026-06-08 confirm-deploy-deadtrigger (симптом того же корня)