feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде, отсутствие ретраев у Plane/Gitea, неразрезолвленный sha→branch) оставляет задачу молча застрявшей (класс инцидента ORCH-044). Новый фоновый daemon-поток src/reconciler.py (паттерн queue_worker) доигрывает пропущенный переход через те же штатные гейты/обработчики, что и webhook: - F-1 gate-side: для задач stage≠done, без активного job и age(updated_at) ≥ grace_for_stage(stage) — read-only пред-оценка канонического QG; зелёный → stage_engine.advance_stage(..., finished_agent=None); красный → тишина (спам нотификаций структурно невозможен). analysis F-1 не трогает (человеческий гейт). - F-2 plane-side: опрос Plane API per-project (plane_sync.list_issues_by_state, курсорная пагинация, never-raise) → реплей In Progress/Approved/Rejected через существующие handle_status_start/handle_verdict (async из sync-потока, asyncio.run). - F-3: усиление sha→branch в handle_ci_status — БД-fallback по единственной development-задаче repo (неоднозначность → не резолвим), debug→info. - Анти-дубль на создании (db.create_task_atomic под process-wide Lock): гонка reconcile↔webhook не плодит второй task/branch/worktree/analyst-job (AC-4). - F-4 observability: лог-строка разблокировки + Telegram + блок reconcile в /queue. Старт/стоп в main.lifespan (после worker.start() / перед worker.stop()), restart-safe, never-raise на единицу работы. Kill-switches ORCH_RECONCILE_ENABLED / ORCH_RECONCILE_PLANE_ENABLED + grace-настройки. Схема БД и реестры STAGE_TRANSITIONS/QG_CHECKS не менялись. Тесты: test_reconciler.py, test_reconciler_plane.py, test_gitea_sha_resolve.py, test_config.py (33 новых, 563 всего зелёные). Документация обновлена (golden source): architecture/README.md, INFRA.md, README.md, CHANGELOG.md, adr-0007 → accepted. Refs: ORCH-053 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -356,6 +356,62 @@ def fetch_issue_fields(issue_id: str, project_id: str) -> tuple[str, str]:
|
||||
return "", ""
|
||||
|
||||
|
||||
def list_issues_by_state(project_id: str, state_uuids: list[str]) -> list[dict]:
|
||||
"""ORCH-053 (F-2): list a project's issues whose state is in ``state_uuids``.
|
||||
|
||||
GETs ``/workspaces/{ws}/projects/{pid}/issues/`` and walks ALL pages
|
||||
(Plane's cursor pagination: ``results`` + ``next_cursor`` /
|
||||
``next_page_results``), keeping only issues whose state uuid is one of the
|
||||
requested ones. The filter is applied client-side on ``issue.state`` (a dict
|
||||
``{id,...}`` or a bare uuid string) so it works regardless of whether Plane's
|
||||
query-param state filter is honoured.
|
||||
|
||||
Never raises: on any network / API / shape error it logs a warning and
|
||||
returns ``[]`` so a Plane outage degrades the F-2 tick softly instead of
|
||||
crashing it.
|
||||
"""
|
||||
if not project_id or not state_uuids:
|
||||
return []
|
||||
wanted = set(state_uuids)
|
||||
out: list[dict] = []
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/"
|
||||
try:
|
||||
cursor = None
|
||||
pages = 0
|
||||
while True:
|
||||
params: dict = {"per_page": 100}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
if isinstance(body, dict):
|
||||
items = body.get("results", [])
|
||||
else:
|
||||
items = body if isinstance(body, list) else []
|
||||
for issue in items:
|
||||
state = issue.get("state")
|
||||
sid = state.get("id") if isinstance(state, dict) else state
|
||||
if sid in wanted:
|
||||
out.append(issue)
|
||||
# Pagination: continue only while Plane reports more pages.
|
||||
pages += 1
|
||||
if not isinstance(body, dict):
|
||||
break
|
||||
has_more = bool(body.get("next_page_results"))
|
||||
next_cursor = body.get("next_cursor")
|
||||
if not has_more or not next_cursor or pages >= 100:
|
||||
break
|
||||
cursor = next_cursor
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"list_issues_by_state: API failed for project {project_id[:8]}..., "
|
||||
f"returning []. Error: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
|
||||
"""Find Plane issue UUID by work_item_id (e.g. 'ET-002')."""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
|
||||
Reference in New Issue
Block a user