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

@@ -149,6 +149,7 @@ async def queue():
from . import post_deploy
from . import merge_gate
from . import task_deps
from . import serial_gate
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
@@ -161,5 +162,37 @@ async def queue():
# ORCH-026 (G-2): declarative task-dependency observability (read-only,
# NOT a source of truth) — declared edges, blocked tasks, detected cycle.
"task_deps": task_deps.snapshot(),
# ORCH-088 (D9 / AC-10): per-repo serial-gate observability (read-only) —
# active task, queued/waiting analyst-jobs, freeze state. Additive block.
"serial_gate": serial_gate.snapshot(),
"recent": recent_jobs(10),
}
@app.post("/serial-gate/unfreeze")
async def serial_gate_unfreeze(repo: str = ""):
"""ORCH-088 (FR-5, ADR-001 D4): manually clear a per-repo rollback-freeze.
A freeze set by the post-deploy monitor (DEGRADED) keeps the serial gate CLOSED
for the repo until an operator explicitly clears it here. Idempotent: clearing
an already-clear repo reports ``cleared: 0``. The next queued analyst-job is then
claimable on the next scheduler tick (no restart needed). Alternative manual path
(documented in README): ``UPDATE repo_freeze SET cleared_at=datetime('now')
WHERE repo=? AND cleared_at IS NULL``.
"""
from . import serial_gate
if not repo or not repo.strip():
return {"ok": False, "error": "missing 'repo'", "repo": repo, "cleared": 0}
repo = repo.strip()
cleared = serial_gate.clear_repo_freeze(repo)
frozen = serial_gate.is_repo_frozen(repo)
if cleared:
try:
from .notifications import send_telegram
send_telegram(
f"🔥 {repo}: пакет РАЗМОРОЖЕН вручную ({cleared} запис(ь/и) снято). "
f"Следующая задача репо стартует на ближайшем цикле."
)
except Exception:
pass
return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen}