fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1
Reconciler F-1 could not tell "stuck by a lost webhook" from "escalated:
max developer retries reached, waiting for a human". With CI green and a
reviewer that kept sending REQUEST_CHANGES up to the cap, every tick
re-unblocked development -> review -> rollback -> re-unblock (incident
ET-013, infinite bounce: wasted agent runs, Telegram spam, parasitic load
on the shared self-hosting instance).
Add two pre-gate guards in Reconciler._reconcile_gate_task (after the
existing analysis/no-gate/active-job/grace guards, before the gate
pre-evaluation), each an early silent return (no advance, no unblocked_total
increment, no notifications):
- Guard 1 (escalated, deterministic, no network, checked first):
developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES. Promote
stage_engine._developer_retry_count to public developer_retry_count
(single source of truth; private alias kept). Limit from the constant,
not a literal 3.
- Guard 2 (explicit human Plane gate, Variant A, no DB migration): new
never-raise plane_sync.fetch_issue_state + Reconciler._is_blocked_or_needs_input;
any error/None/unresolved project -> conservative skip. New sub-flag
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED mutes only the networked Guard 2.
F-2 unchanged: Blocked/Needs Input are outside {in_progress, approved,
rejected} so they are never replayed (regression test added). DB schema,
STAGE_TRANSITIONS, QG_CHECKS, never-raise, analysis carve-out and
kill-switches untouched.
Refs: ORCH-060
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -278,6 +278,33 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
|
||||
"""ORCH-060 (F-1 Guard 2): GET the Plane issue and return its current state uuid.
|
||||
|
||||
Used by the reconciler to honour an explicit human gate: an issue a person
|
||||
moved to **Blocked** / **Needs Input** must not be auto-unblocked by the
|
||||
sweeper. Reuses the exact GET issue-detail endpoint / shared token already
|
||||
used by ``fetch_issue_sequence_id`` / ``fetch_issue_fields``.
|
||||
|
||||
Plane returns ``state`` as a bare uuid string; older shapes may nest it as a
|
||||
``{"id": ...}`` dict — both are handled.
|
||||
|
||||
Returns None on network error, non-2xx, or a missing field — never raises, so
|
||||
the caller can apply its conservative fallback (treat as "possibly blocked").
|
||||
"""
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
state = resp.json().get("state")
|
||||
if isinstance(state, dict):
|
||||
state = state.get("id")
|
||||
return str(state) if state else None
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_issue_state failed for {issue_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user