feat(serial-gate): per-repo serial gate + deferred branch cut + rollback-freeze (ORCH-088)

Этап 1 (serial e2e) пакетного автономного режима. Новая задача репо не входит
в analysis (analyst-job не выбирается, ветка не режется), пока в репо есть более
ранняя незавершённая задача (FIFO, t2.id < jobs.task_id) ИЛИ репо заморожен.

- src/serial_gate.py — новый leaf (never-raise): build_claim_clause (fail-OPEN),
  is_repo_frozen (fail-CLOSED), set/clear_repo_freeze, serial_gate_applies, snapshot.
- src/db.py — идемпотентная миграция repo_freeze + serial_gate-фрагмент в claim_next_job.
- src/webhooks/plane.py + src/agents/launcher.py — отложенный срез ветки: start_pipeline
  не создаёт Gitea-ветку/docs для применимого репо; релокация в _materialize_deferred_branch
  на момент claim analyst-job (база = свежий origin/main с кодом предшественника, AC-6).
- src/stage_engine.py — post-deploy DEGRADED → durable per-repo freeze + Telegram-алерт.
- src/main.py — блок serial_gate в GET /queue + POST /serial-gate/unfreeze.
- src/config.py — serial_gate_enabled / serial_gate_repos / serial_gate_freeze_enabled.

FIFO-уточнение реализации (FR-2): ADR-001 D1 фиксировал t2.id != jobs.task_id; при !=
пакет одновременно созданных свежих задач взаимно блокировался бы (дедлок). t2.id <
jobs.task_id допускает самую раннюю задачу и сериализует остальные, сохраняя AC-1/R-7.

STAGE_TRANSITIONS / QG_CHECKS / check_* — без изменений. Аддитивно, под kill-switch,
never-raise, restart-safe; при выключенном флаге — нулевая регрессия (enduro не затронут).

Тесты: TC-01..TC-22 (test_serial_gate*.py + test_queue_endpoint.py); полный прогон 1114 зелёных.
Docs: README (serial gate / /queue / API / БД), CLAUDE.md, CHANGELOG.md, .env.example.

Refs: ORCH-088
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:14:48 +03:00
committed by stream
parent 4597a8471d
commit ee4773f5b0
19 changed files with 1315 additions and 22 deletions

View File

@@ -573,20 +573,36 @@ async def start_pipeline(data: dict, project_id: str = ""):
return
task_id = task_row["id"]
# Create branch in Gitea
try:
await _create_gitea_branch(repo, branch)
except Exception as e:
logger.error(f"Failed to create branch '{branch}': {e}")
# Task is created, branch creation failed — log but don't crash
notify_error(0, f"Branch creation failed: {e}")
return
# ORCH-088 (FR-1/AC-6, ADR-001 D1): DEFER the branch cut for an applicable repo.
# Creating the Gitea branch here (T0, issue -> analysis) would cut it from `main`
# BEFORE the predecessor is merged -> stale base. When the serial gate applies we
# do NOT create the branch / initial docs now; the analyst-job sits in the queue
# (status='queued', no branch) and the gate keeps it there until the predecessor
# reaches stage='done'. The branch + docs are then materialised at claim time in
# launcher._spawn from a fresh origin/main (anti-stale-base). The task row already
# stores `branch` as a NAME (R-5) — only the git ref is deferred.
from .. import serial_gate
defer_branch = serial_gate.serial_gate_applies(repo)
if not defer_branch:
# Create branch in Gitea
try:
await _create_gitea_branch(repo, branch)
except Exception as e:
logger.error(f"Failed to create branch '{branch}': {e}")
# Task is created, branch creation failed — log but don't crash
notify_error(0, f"Branch creation failed: {e}")
return
# Create initial docs structure via Gitea API (create file)
try:
await _create_initial_docs(repo, branch, work_item_id, name)
except Exception as e:
logger.error(f"Failed to create initial docs: {e}")
# Create initial docs structure via Gitea API (create file)
try:
await _create_initial_docs(repo, branch, work_item_id, name)
except Exception as e:
logger.error(f"Failed to create initial docs: {e}")
else:
logger.info(
f"Task {work_item_id}: serial gate applies for {repo} -> deferring branch "
f"cut to analyst-job claim (anti-stale-base, ORCH-088)"
)
logger.info(f"Task created: {work_item_id} ({name}), branch={branch}, stage=analysis")