Files
orchestrator/src/frontmatter.py
claude-bot 0663da6e4c
All checks were successful
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 12s
feat(plane): unified status-comment format with duration line (ORCH-016)
Все агенты (analyst..deployer) теперь пишут финальный коммент через единый
хелпер usage.build_status_comment(...) — заголовок «{icon} {Role} — {описание}»,
опциональная строка Verdict/Status из YAML-frontmatter, строка
«Длительность: 4m 12s» (явный duration_s от launcher, fallback из agent_runs
для аналитика), HTML-блок Документы, тех-хвост <sub>tokens · cost</sub>.

- Новые публичные функции в src/usage.py: build_status_comment, fmt_duration,
  get_agent_duration. usage_comment(...) → тонкая deprecated-обёртка (legacy
  тесты в tests/test_usage.py продолжают работать). artifact_links(...)
  переписан на HTML <li><a>…</a></li> (breaking change для внутреннего API,
  но единственный внешний клиент — _post_usage_comments — мигрирован).
- Новый модуль src/frontmatter.py: defensive YAML reader, никогда не raise.
- stage_engine._build_analyst_ready_comment(...) теперь тонкая обёртка над
  build_status_comment(agent="analyst", ...); task_id пробрасывается из
  _handle_analysis_approved_flow для DB-фоллбэка длительности (AC-14).
- launcher._post_usage_comments(...) принимает duration_s, резолвит stage из
  tasks для deployer и worktree_root для AC-8 graceful skipping.

Тесты (16 файлов, 56 новых тестовых функций, покрывают TC-01..TC-25):
fmt_duration table, build_status_comment по всем агентам, DB-фоллбэк,
authorship под per-agent ботами, дедуп-инвариант, regression на
status-only verdict аналитика и финальный notify_done, snapshot
QG_CHECKS + STAGE_TRANSITIONS.

Документация: docs/architecture/README.md (раздел Plane Sync),
CHANGELOG.md (Unreleased Added/Changed).

Refs: ORCH-016

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 12:39:06 +00:00

76 lines
2.5 KiB
Python

"""Safe single-key YAML frontmatter reader (ORCH-016 / ADR-001 §5).
The status-comment builder (build_status_comment) needs to surface verdict /
deploy_status / staging_status from the per-stage artifact files (12-review.md,
13-test-report.md, 14-deploy-log.md, 15-staging-log.md). Those files share the
same leading-YAML-frontmatter convention used by the quality gates — but the
comment hot-path must NEVER raise: a missing file, malformed YAML, or absent
key should simply suppress the verdict line, not break the run.
This module is a tiny defensive helper:
- `read_frontmatter_value(path, key)` -> str | None
- swallows every exception, logs to logger.debug, returns None.
It intentionally duplicates ~10 lines of YAML-frontmatter logic that already
exist in `src/qg/checks.py` (S-5 / БАГ 8 / ET-013 fixes). ADR-001 §5 accepts
this duplication to keep the blast radius of ORCH-016 small (no QG refactor in
this PR); merging into a single parser is a follow-up task.
"""
import logging
logger = logging.getLogger("orchestrator.frontmatter")
def read_frontmatter_value(path: str, key: str) -> str | None:
"""Return the value of `key` from the leading YAML frontmatter of `path`.
Format expected (canonical, matching qg/checks.py):
---
key: value
other: ...
---
<body>
Never raises. Returns None for any of:
- missing/unreadable file,
- no leading `---` frontmatter,
- malformed/unterminated frontmatter,
- YAML parse error,
- frontmatter is not a mapping,
- key absent (or its value is None/empty).
The returned value is stringified and stripped (whitespace removed); casing
is preserved so the caller decides whether to upper/lower for matching.
"""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
logger.debug(f"read_frontmatter_value: cannot open {path}: {e}")
return None
if not content.startswith("---"):
return None
parts = content.split("---", 2)
if len(parts) < 3:
# Unterminated frontmatter.
return None
try:
import yaml
fm = yaml.safe_load(parts[1]) or {}
except Exception as e: # yaml.YAMLError + anything pyyaml may surface
logger.debug(f"read_frontmatter_value: yaml parse failed for {path}: {e}")
return None
if not isinstance(fm, dict):
return None
raw = fm.get(key)
if raw is None:
return None
value = str(raw).strip()
return value or None