developer(ET): auto-commit from developer run_id=363

This commit is contained in:
2026-06-08 10:19:42 +00:00
committed by stream
parent 9f176036f1
commit 096c452230
16 changed files with 1207 additions and 65 deletions

View File

@@ -682,8 +682,8 @@ class AgentLauncher:
"\u274c Deploy FAILED (smoke/healthcheck). Rolled back. Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
author="deployer",
)
from ..notifications import send_telegram
send_telegram(f"\U0001f6a8 {_wid}: Deploy failed! Rolled back. Needs fix.")
from ..notifications import send_telegram, link_for
send_telegram(f"\U0001f6a8 {link_for(_wid)}: Deploy failed! Rolled back. Needs fix.")
# Notify on startup timeout (exit_code from kill = -9 or 137)
if exit_code != 0 and exit_code not in (None,):
@@ -695,8 +695,8 @@ class AgentLauncher:
conn.close()
if task_row and agent != "deployer": # deployer handled above
_tid, _wid = task_row
from ..notifications import send_telegram
send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
from ..notifications import send_telegram, link_for
send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
# Feature 4 + ORCH-016: post the unified per-agent status comment under
# that agent's bot, threading the wall-clock duration we just measured

View File

@@ -400,12 +400,27 @@ class Settings(BaseSettings):
telegram_chat_id: str = ""
# ORCH-042: режим live-трекера задачи.
# edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было).
# bump -> при обновлении старое сообщение удаляется и карточка отправляется
# заново вниз чата (deleteMessage + sendMessage + repoint message_id),
# тихо (disable_notification). Одна карточка на задачу в обоих режимах.
# Неизвестное/пустое значение трактуется как edit (см. notifications).
tracker_mode: str = "edit"
# bump (ДЕФОЛТ с ORCH-067) -> при обновлении старое сообщение удаляется и
# карточка отправляется заново вниз чата (deleteMessage + sendMessage
# + repoint message_id), тихо (disable_notification).
# edit -> карточка редактируется на месте (editMessageText); доступен через
# ORCH_TRACKER_MODE=edit.
# Одна карточка на задачу в обоих режимах. Неизвестное/пустое значение
# трактуется как edit (см. notifications).
tracker_mode: str = "bump"
# ORCH-067 (ADR Р-2/Р-3/Р-4): best-effort live-overlay для статус-строки
# карточки. Дорисовывает ветки Plane-статуса, неотличимые offline по
# tasks.stage (Needs Input / Blocked / Rejected / Cancelled / Deploying /
# Monitoring after Deploy) — читая ЖИВОЙ Plane-статус с коротким таймаутом и
# TTL-кэшем. Offline-ядро (stage -> статус, In Review из brd-clock) работает
# всегда без сети; overlay лишь дополняет его и НИКОГДА не блокирует конвейер.
# tracker_live_status -> kill-switch (False -> только offline-ядро).
# tracker_live_status_ttl_s -> TTL per-issue кэша live-uuid (защита hot-path).
# tracker_live_status_timeout_s -> таймаут одного live-GET в пути рендера.
tracker_live_status: bool = True
tracker_live_status_ttl_s: int = 60
tracker_live_status_timeout_s: int = 3
class Config:
env_prefix = "ORCH_"

View File

@@ -307,7 +307,7 @@ def render_task_tracker(task_id: int) -> str:
conn = get_db()
task = conn.execute(
"SELECT id, work_item_id, title, stage, created_at, updated_at, "
"brd_review_started_at, brd_review_ended_at "
"brd_review_started_at, brd_review_ended_at, repo, plane_issue_id "
"FROM tasks WHERE id=?",
(task_id,),
).fetchone()
@@ -358,13 +358,27 @@ def render_task_tracker(task_id: int) -> str:
agent_seconds += d
esc_title = html.escape(title)
# ORCH-067 (req 3): the issue number in the header is now a clickable link to
# the Plane issue (degrades to the escaped number when no web URL \u2014 fail-safe).
task_repo = _row_get(task, "repo")
task_issue_id = _row_get(task, "plane_issue_id")
num_html = plane_issue_link(work_item_id, plane_issue_id=task_issue_id, repo=task_repo)
header = (
f"\U0001f389 {html.escape(work_item_id)} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e"
f"\U0001f389 {num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e"
if done
else f"\U0001f6e0\ufe0f {html.escape(work_item_id)} \u00b7 {esc_title}"
else f"\U0001f6e0\ufe0f {num_html} \u00b7 {esc_title}"
)
bar = "\u2501" * 22
lines = [header, bar]
# ORCH-067 (req 2): a Plane-status line (model ORCH-066) under the header.
# Built fail-safe: any error degrades to a stage default, never breaks render.
try:
status_label = _card_status_label(
task, repo=task_repo, plane_issue_id=task_issue_id
)
except Exception:
status_label = _DEFAULT_STATUS_LABEL
status_line = f"\U0001f4cd {status_label}"
lines = [header, status_line, bar]
def _stage_line(label, run):
usage = {
@@ -704,38 +718,276 @@ def _build_brd_link(repo, branch, work_item_id) -> str | None:
)
def _plane_issue_url(repo, plane_issue_id, project_id=None) -> str | None:
"""ORCH-067 (Р-5): build the Plane issue browser URL, or None if unbuildable.
Single source of the URL + guards, shared by ``plane_issue_link`` (link text =
issue number) and ``_build_plane_issue_link`` (link text = '✅ Задача в Plane'),
so the project resolution and loopback-guard live in ONE place (ORCH-017 Р-2).
Full path: ``{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/``.
web_base = plane_web_url or plane_api_url; a loopback base counts as "no web
URL" -> None. ``project_id`` is taken explicitly when given, else resolved from
``repo``. Never raises.
"""
try:
s = _get_settings()
web_base = (
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
).rstrip("/")
workspace = getattr(s, "plane_workspace_slug", "")
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
return None
if not project_id:
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
project_id = getattr(project, "plane_project_id", "") if project else ""
if not project_id:
return None
return (
f"{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/"
)
except Exception:
return None
def _build_plane_issue_link(repo, plane_issue_id) -> str | None:
"""ORCH-017: '<a>' to the Plane issue browser page, or None if unusable.
Full path per ADR-001 Р-2:
``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``.
web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated
as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6).
Link text = '✅ Задача в Plane'. URL built by the shared ``_plane_issue_url``
(loopback / workspace / project guards, ADR-001 Р-2 / ORCH-067 Р-5).
"""
s = _get_settings()
web_base = (
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
).rstrip("/")
workspace = getattr(s, "plane_workspace_slug", "")
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
url = _plane_issue_url(repo, plane_issue_id)
if not url:
return None
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
if not project or not getattr(project, "plane_project_id", ""):
return None
url = (
f"{web_base}/{workspace}/projects/{project.plane_project_id}"
f"/issues/{plane_issue_id}/"
)
return (
f'<a href="{html.escape(url, quote=True)}">'
f"✅ Задача в Plane</a>"
)
def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str:
"""ORCH-067 (Р-5): clickable issue number for cards / alerts.
Returns ``<a href=...>ORCH-NNN</a>`` when a Plane web URL can be built, else
``html.escape(work_item_id)`` (number without a link). Never raises.
Link text is always ``html.escape(work_item_id)``; the href is built by the
shared ``_plane_issue_url`` (same loopback / workspace / project guards as the
'✅ Задача в Plane' link). On any missing piece -> the escaped number.
"""
label = html.escape(str(work_item_id)) if work_item_id is not None else ""
try:
url = _plane_issue_url(repo, plane_issue_id, project_id)
if not url:
return label
return f'<a href="{html.escape(url, quote=True)}">{label}</a>'
except Exception:
return label
def link_for(work_item_id, task_id=None) -> str:
"""ORCH-067 (Р-6): clickable issue number for alert points that hold only a
``work_item_id`` (or ``task_id``).
Resolves ``(repo, plane_issue_id)`` from the DB (by ``task_id`` when given,
else the latest task row for ``work_item_id``) and delegates to
``plane_issue_link``. On any missing data -> ``html.escape(work_item_id)``.
Never raises.
"""
if not work_item_id:
return html.escape(str(work_item_id)) if work_item_id is not None else ""
repo = None
plane_issue_id = None
try:
from .db import get_db
conn = get_db()
if task_id is not None:
row = conn.execute(
"SELECT repo, plane_issue_id FROM tasks WHERE id=?", (task_id,)
).fetchone()
else:
row = conn.execute(
"SELECT repo, plane_issue_id FROM tasks WHERE work_item_id=? "
"ORDER BY id DESC LIMIT 1",
(work_item_id,),
).fetchone()
conn.close()
if row:
repo = row["repo"]
plane_issue_id = row["plane_issue_id"]
except Exception as e:
logger.debug(f"link_for({work_item_id}) DB lookup failed: {e}")
return plane_issue_link(work_item_id, plane_issue_id=plane_issue_id, repo=repo)
# --------------------------------------------------------------------------- #
# ORCH-067: Plane status label for the live card (layer B indication, ADR Р-1)
# --------------------------------------------------------------------------- #
# Offline stage -> Plane status label. Names are the final ORCH-066 status names
# (_PLANE_NAME_TO_KEY). Pure / deterministic — derived entirely from tasks.stage
# (+ the brd-clock for In Review), NEVER from the network.
_STAGE_STATUS_LABEL = {
"created": "To Analyse",
"analysis": "Analysis",
"architecture": "Architecture",
"development": "Development",
"review": "Code-Review",
"testing": "Testing",
"deploy": "⏸️ Awaiting Deploy — ожидание Confirm Deploy",
"done": "Done",
}
_DEFAULT_STATUS_LABEL = "To Analyse"
_IN_REVIEW_LABEL = (
"⏸️ In Review — ожидание "
"согласования BRD"
)
# Live-overlay branch labels (keys not derivable offline from tasks.stage).
_LIVE_BRANCH_LABELS = {
"needs_input": "❓ Needs Input — нужны уточнения",
"blocked": "Blocked",
"rejected": "Rejected",
"cancelled": "Cancelled",
"deploying": "Deploying",
"monitoring": "Monitoring after Deploy",
}
# ORCH-066 (Р-1 anti-false-positive): deploying/monitoring alias their BASE key's
# UUID on a project without dedicated statuses (enduro). Override is applied ONLY
# when the project really defined a SEPARATE UUID for the branch key.
_LIVE_BRANCH_BASE = {
"deploying": "in_progress",
"monitoring": "done",
}
def _row_get(row, key, default=None):
"""Safe sqlite3.Row / dict / object getter. Never raises."""
try:
return row[key]
except Exception:
try:
return getattr(row, key, default)
except Exception:
return default
def plane_status_label(task_row) -> str:
"""ORCH-067 (Р-1, layer 1): current Plane status label for the card header.
Pure / deterministic from the task row, NEVER hits the network, NEVER raises.
On unknown / broken input -> a safe stage default. ``⏸️ In Review`` and
``⏸️ Awaiting Deploy`` are produced here (offline), so both work without a
network connection (AC-7, AC-8). Branch statuses that are indistinguishable
offline (Needs Input / Blocked / …) are drawn by ``_live_plane_branch_override``.
"""
try:
stage = _row_get(task_row, "stage") or "created"
except Exception:
return _DEFAULT_STATUS_LABEL
try:
if stage == "analysis":
started = _row_get(task_row, "brd_review_started_at")
ended = _row_get(task_row, "brd_review_ended_at")
if started and not ended:
return _IN_REVIEW_LABEL
return _STAGE_STATUS_LABEL.get(stage, _DEFAULT_STATUS_LABEL)
except Exception:
return _DEFAULT_STATUS_LABEL
# ORCH-067 (Р-3): per-issue TTL cache of the live state uuid -> {issue_id: (ts, uuid)}.
_LIVE_STATE_CACHE: dict[str, tuple] = {}
def _live_state_uuid_cached(plane_issue_id, project_id):
"""ORCH-067 (Р-3/Р-4): TTL-cached single live-state read for the render path.
At most one ``fetch_issue_state`` per issue per ``tracker_live_status_ttl_s``
with a SHORT timeout. Never raises -> None on any failure.
"""
try:
import time
s = _get_settings()
ttl = getattr(s, "tracker_live_status_ttl_s", 60)
now = time.monotonic()
hit = _LIVE_STATE_CACHE.get(plane_issue_id)
if hit is not None and (now - hit[0]) <= ttl:
return hit[1]
from .plane_sync import fetch_issue_state
timeout = getattr(s, "tracker_live_status_timeout_s", 3)
uuid = fetch_issue_state(plane_issue_id, project_id, timeout=timeout)
_LIVE_STATE_CACHE[plane_issue_id] = (now, uuid)
return uuid
except Exception as e:
logger.debug(f"_live_state_uuid_cached({plane_issue_id}) failed: {e}")
return None
def _live_plane_branch_override(repo, plane_issue_id, base_label) -> str:
"""ORCH-067 (Р-1 layer 2 / Р-2): best-effort live-status overlay.
Draws the branch statuses that are indistinguishable from ``tasks.stage``
offline (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring
after Deploy) by reading the LIVE Plane status (short timeout, TTL cache). Any
failure / disabled kill-switch / missing data -> ``base_label`` (offline). The
pipeline is NEVER blocked. Never raises.
"""
try:
s = _get_settings()
if not getattr(s, "tracker_live_status", True):
return base_label
if not plane_issue_id:
return base_label
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
project_id = getattr(project, "plane_project_id", "") if project else ""
if not project_id:
return base_label
live_uuid = _live_state_uuid_cached(plane_issue_id, project_id)
if not live_uuid:
return base_label
from .plane_sync import get_project_states
states = get_project_states(project_id)
for key, label in _LIVE_BRANCH_LABELS.items():
uuid = states.get(key)
if not uuid or uuid != live_uuid:
continue
base_key = _LIVE_BRANCH_BASE.get(key)
if base_key and states.get(base_key) == uuid:
# deploying/monitoring just alias their base key on this project
# (enduro / no dedicated status) -> not a real branch, don't override.
continue
return label
return base_label
except Exception as e:
logger.debug(f"_live_plane_branch_override failed: {e}")
return base_label
def _card_status_label(task_row, repo=None, plane_issue_id=None) -> str:
"""ORCH-067: full status label for the card = offline core + live overlay.
Precedence (Р-1): if the offline core resolved ``⏸️ In Review`` (brd-clock,
authoritative) the overlay is NOT consulted; otherwise the overlay may draw a
branch status. Never raises (AC-9).
"""
try:
base = plane_status_label(task_row)
if base == _IN_REVIEW_LABEL:
return base
return _live_plane_branch_override(repo, plane_issue_id, base)
except Exception:
return _DEFAULT_STATUS_LABEL
def notify_approve_requested(task_id: int):
"""ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved.
@@ -749,7 +1001,7 @@ def notify_approve_requested(task_id: int):
except Exception as e:
logger.warning(f"notify_approve_requested: brd clock start failed: {e}")
msg = (
f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\U0001f4cb {link_for(work_item_id, task_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved "
f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f."
)
@@ -783,8 +1035,14 @@ def notify_done(task_id: int):
def notify_error(task_id: int, error: str):
"""ALERT (separate, notifying): task error."""
"""ALERT (separate, notifying): task error.
ORCH-067 (req 4): the issue number is a clickable Plane link (fail-safe ->
raw number) and the error text is html-escaped so it cannot break the <a>
markup under parse_mode=HTML (AC-14).
"""
work_item_id = _get_work_item_id(task_id) if task_id else "system"
msg = f"\U0001f534 {work_item_id}: ERROR \u2014 {error}"
num = link_for(work_item_id, task_id) if task_id else html.escape(work_item_id)
msg = f"\U0001f534 {num}: ERROR \u2014 {html.escape(str(error))}"
logger.error(msg)
send_telegram(msg) # separate, notifying

View File

@@ -402,7 +402,7 @@ 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:
def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> 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
@@ -413,12 +413,16 @@ def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
Plane returns ``state`` as a bare uuid string; older shapes may nest it as a
``{"id": ...}`` dict — both are handled.
ORCH-067 (Р-4): ``timeout`` is optional (default 10s — unchanged for the
reconciler) so the tracker live-overlay can read with a SHORT timeout
(settings.tracker_live_status_timeout_s) on the synchronous render path.
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 = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout)
resp.raise_for_status()
state = resp.json().get("state")
if isinstance(state, dict):

View File

@@ -67,7 +67,7 @@ from .plane_sync import (
list_issues_by_state,
)
from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram
from .notifications import send_telegram, link_for
from . import projects
logger = logging.getLogger("orchestrator.reconciler")
@@ -447,7 +447,7 @@ class Reconciler:
if settings.reconcile_notify_unblock:
try:
send_telegram(
f"\U0001f527 reconciler: {work_item_id} {stage} "
f"\U0001f527 reconciler: {link_for(work_item_id)} {stage} "
f"разблокирована (потерян webhook)"
)
except Exception as e: # noqa: BLE001 - never break the tick

View File

@@ -670,9 +670,9 @@ def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool
dep_result.detail,
)
try:
from .notifications import send_telegram
from .notifications import send_telegram, link_for
send_telegram(
f"⚠️ {work_item_id}: dep-audit недоступен фид CVE "
f"⚠️ {link_for(work_item_id)}: dep-audit недоступен фид CVE "
f"({dep_result.detail}). "
+ ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed
else "Гейт fail-open → warning (секреты проверены оффлайн).")

View File

@@ -44,6 +44,7 @@ from .notifications import (
notify_qg_failure,
notify_approve_requested,
send_telegram,
link_for,
)
from .plane_sync import (
notify_stage_change as plane_notify_stage,
@@ -611,7 +612,7 @@ def _handle_analysis_approved_flow(
author="analyst",
)
send_telegram(
f"\u2753 {work_item_id}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane."
f"\u2753 {link_for(work_item_id)}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane."
)
result.note = "analysis-needs-input"
return
@@ -670,7 +671,7 @@ def _handle_qg_failure_rollbacks(
)
else:
send_telegram(
f"\u26a0\ufe0f {work_item_id}: Max developer retries (3) reached. "
f"\u26a0\ufe0f {link_for(work_item_id)}: Max developer retries (3) reached. "
f"Manual intervention needed."
)
result.alerted = True
@@ -717,7 +718,7 @@ def _handle_qg_failure_rollbacks(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Tests still failing after 3 developer "
f"\U0001f6a8 {link_for(work_item_id)}: Tests still failing after 3 developer "
f"retries. Manual intervention needed."
)
result.alerted = True
@@ -774,7 +775,7 @@ def _handle_qg_failure_rollbacks(
author="deployer",
)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging FAILED ({reason}). "
f"\U0001f6a8 {link_for(work_item_id)}: Staging FAILED ({reason}). "
f"Rolled back to development. Needs fix."
)
result.alerted = True
@@ -818,7 +819,7 @@ def _handle_qg_failure_rollbacks(
author="deployer",
)
send_telegram(
f"\U0001f6a8 {work_item_id}: Deploy FAILED ({reason}). "
f"\U0001f6a8 {link_for(work_item_id)}: Deploy FAILED ({reason}). "
f"Rolled back to development. Needs fix."
)
result.alerted = True
@@ -914,7 +915,7 @@ def _handle_merge_gate_defer(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: merge-gate defer limit "
f"\U0001f6a8 {link_for(work_item_id)}: merge-gate defer limit "
f"({settings.merge_defer_max_attempts}) reached (merge-lock busy). "
f"Manual intervention needed."
)
@@ -969,7 +970,7 @@ def _handle_merge_gate_rollback(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Merge-gate still failing after "
f"\U0001f6a8 {link_for(work_item_id)}: Merge-gate still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
@@ -1055,7 +1056,7 @@ def _handle_security_gate(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Security-гейт still failing after "
f"\U0001f6a8 {link_for(work_item_id)}: Security-гейт still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
@@ -1132,7 +1133,7 @@ def _handle_image_freshness(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after "
f"\U0001f6a8 {link_for(work_item_id)}: Staging image freshness still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
@@ -1190,7 +1191,7 @@ def _handle_self_deploy_phase_a(
author="deployer",
)
send_telegram(
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя "
f"\U0001f7e1 {link_for(work_item_id)}: staging OK. Ждёт подтверждения ПРОД-деплоя "
f"(смените статус на «Confirm Deploy»)."
)
logger.info(
@@ -1225,7 +1226,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
"Повторите approve после устранения причины.",
author="deployer",
)
send_telegram(f"⚠️ {work_item_id}: прод-деплой не запустился: {msg}")
send_telegram(f"⚠️ {link_for(work_item_id)}: прод-деплой не запустился: {msg}")
logger.error(f"Task {task_id}: self-deploy initiate failed: {msg}")
return
@@ -1254,7 +1255,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
"Вердикт будет зафиксирован после health-check.",
author="deployer",
)
send_telegram(f"\U0001f680 {work_item_id}: прод-деплой стартовал. Жду результат.")
send_telegram(f"\U0001f680 {link_for(work_item_id)}: прод-деплой стартовал. Жду результат.")
logger.info(
f"Task {task_id}: self-deploy Phase B — detached deploy initiated, "
f"finalizer enqueued (job_id={new_job})"
@@ -1365,7 +1366,7 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
try:
merge_gate.note_not_merged_alert(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: ошибка merge-verify ({e}). "
f"\U0001f6a8 {link_for(work_item_id)}: ошибка merge-verify ({e}). "
f"Задача удержана на `deploy` (НЕ done)."
)
except Exception: # noqa: BLE001 - best-effort alert
@@ -1423,7 +1424,7 @@ def run_deploy_finalizer(job: dict):
if work_item_id:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: deploy result не появился после "
f"\U0001f6a8 {link_for(work_item_id)}: deploy result не появился после "
f"{settings.deploy_finalize_max_attempts} попыток. Нужно ручное вмешательство."
)
logger.error(
@@ -1444,7 +1445,7 @@ def run_deploy_finalizer(job: dict):
f"✅ Прод-деплой успешен (health-check OK, exit {code}).",
author="deployer",
)
send_telegram(f"{work_item_id}: прод-деплой успешен (exit {code}).")
send_telegram(f"{link_for(work_item_id)}: прод-деплой успешен (exit {code}).")
# Drive the EXISTING deploy contracts via the gate verdict we just wrote.
advance_stage(