diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09445c6..235e5f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
## [Unreleased]
### Added
+- **Единый status-коммент агентов в Plane** (ORCH-016): `usage.build_status_comment(...)` — один хелпер для ВСЕХ ролей (analyst..deployer). HTML-формат: header `{icon} {Role} — {описание}`, опциональная строка `Verdict/Status: …` из YAML-frontmatter артефакта, **строка `Длительность: 4m 12s`** (явный `duration_s` от launcher, fallback из `agent_runs` для аналитика), `Документы:
`, тех-хвост `tokens · cost`. Утилитки: `usage.fmt_duration`, `usage.get_agent_duration`, новый модуль `src/frontmatter.py` (defensive YAML reader). ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`.
- **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками).
- **ADR**: adr-0001 (multi-repo registry), adr-0002 (job queue), adr-0003 (условный staging-гейт).
- **Стадия `deploy-staging`** (ORCH-35): промежуточный гейт между `testing` и `deploy`. QG `check_staging_status` (условный, только для self-hosting repo). PR #31.
@@ -14,6 +15,7 @@
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
### Changed
+- **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `…` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись.
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 62f7ad9..3eef0ce 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -46,6 +46,21 @@ created → analysis → architecture → development → review → testing →
- Deploy / deploy-staging FAILED → откат на `development`.
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
+### Plane Sync: единый status-коммент агентов (ORCH-016)
+Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `
`:
+
+```
+{ICON} {RoleName} — {описание стадии}
+[Verdict|Status: VALUE] # reviewer/tester/deployer, из YAML-frontmatter артефакта
+[Длительность: 4m 12s] # явный duration_s от launcher, либо fallback из agent_runs
+Документы:
+[8.5M in / 45.8k out · $7.29] # тех-хвост usage; опускается при нулях
+```
+
+- **Длительность** считается launcher'ом (`_monitor_agent`) и пробрасывается в `_post_usage_comments`; для analyst (коммент строится в `stage_engine`) используется DB-фоллбэк `usage.get_agent_duration(task_id, agent)`.
+- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: `verdict:` (reviewer/tester), `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md).
+- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
+
## База данных (SQLite)
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии
diff --git a/src/agents/launcher.py b/src/agents/launcher.py
index b10d274..a7ff808 100644
--- a/src/agents/launcher.py
+++ b/src/agents/launcher.py
@@ -507,11 +507,15 @@ class AgentLauncher:
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")
- # Feature 4: post the per-agent usage comment under that agent's bot, and
- # — for the deployer finishing the task — the per-task usage summary.
+ # Feature 4 + ORCH-016: post the unified per-agent status comment under
+ # that agent's bot, threading the wall-clock duration we just measured
+ # straight through (ADR-001 §6: explicit param wins over DB fallback).
+ # The deployer finishing the task also posts the per-task usage summary.
if exit_code == 0:
try:
- self._post_usage_comments(run_id, agent, repo, branch, _usage)
+ self._post_usage_comments(
+ run_id, agent, repo, branch, _usage, duration_s=_duration_s
+ )
except Exception as e:
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
@@ -679,42 +683,67 @@ class AgentLauncher:
logger.error(f"Auto-advance failed for run_id={run_id}: {e}")
- def _post_usage_comments(self, run_id, agent, repo, branch, usage):
- """Feature 4: post the per-agent usage comment (and Deployer summary).
+ def _post_usage_comments(self, run_id, agent, repo, branch, usage, duration_s=None):
+ """Feature 4 + ORCH-016: post the unified per-agent status comment.
- Always (on success, with a work_item_id): a per-agent finish comment
- with token/cost, authored by the finishing agent's Plane bot.
+ via ``usage.build_status_comment(...)``, authored by the finishing
+ agent's Plane bot. The comment carries:
+ * single-line header (icon + role + per-stage description),
+ * machine verdict line for reviewer / tester / deployer (when the
+ relevant frontmatter is present in the worktree),
+ * the agent's wall-clock duration (``duration_s`` is the measured
+ value in _monitor_agent; DB fallback is unused on this path),
+ * an HTML of artifact links scoped per agent,
+ * a ```` token/cost tail.
- When the deployer finishes: also a per-task summary (SUM over
agent_runs GROUP BY agent), authored by the deployer.
+
+ The deployer's `stage=` is resolved from the task row so the helper can
+ pick between 14-deploy-log.md (prod) and 15-staging-log.md (staging).
"""
- from ..usage import usage_comment, task_summary_comment
+ from ..usage import build_status_comment, task_summary_comment
+ from ..git_worktree import get_worktree_path
conn = get_db()
row = conn.execute(
- "SELECT id, work_item_id FROM tasks WHERE repo=? AND branch=?",
+ "SELECT id, work_item_id, stage FROM tasks WHERE repo=? AND branch=?",
(repo, branch),
).fetchone()
conn.close()
if not row:
return
- task_id, work_item_id = row[0], row[1]
+ task_id, work_item_id, stage = row[0], row[1], row[2]
if not work_item_id:
return
# Observability: every agent's finish comment links its artifact(s)
- # (reviewer->12-review, tester->13-test-report, deployer->14-deploy-log,
+ # (reviewer->12-review, tester->13-test-report, deployer->14- or 15-,
# architect->ADR, developer->PR/branch). For the developer we resolve the
# open PR number so the link points straight at it.
pr_number = None
if agent == "developer":
pr_number = self._open_pr_number(repo, branch)
+
+ # Best-effort worktree path — drives AC-8 (skip missing artifacts) and
+ # the verdict frontmatter read. Falls back to None on lookup error so
+ # the comment still goes out without the verdict line / file probe.
+ try:
+ worktree_root = get_worktree_path(repo, branch)
+ except Exception:
+ worktree_root = None
+
plane_add_comment(
work_item_id,
- usage_comment(
+ build_status_comment(
agent,
- usage,
repo=repo,
branch=branch,
work_item_id=work_item_id,
pr_number=pr_number,
+ stage=stage,
+ usage=usage,
+ duration_s=duration_s,
+ task_id=task_id,
+ worktree_root=worktree_root,
),
author=agent,
)
diff --git a/src/frontmatter.py b/src/frontmatter.py
new file mode 100644
index 0000000..284ebe9
--- /dev/null
+++ b/src/frontmatter.py
@@ -0,0 +1,75 @@
+"""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: ...
+ ---
+
+
+ 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
diff --git a/src/stage_engine.py b/src/stage_engine.py
index adc51ca..9d9ce13 100644
--- a/src/stage_engine.py
+++ b/src/stage_engine.py
@@ -295,56 +295,41 @@ def advance_stage(
return result
-def _build_analyst_ready_comment(repo: str, work_item_id: str, branch: str) -> str:
- """BUG C: HTML comment posted when analyst artifacts are ready.
+def _build_analyst_ready_comment(
+ repo: str, work_item_id: str, branch: str, task_id: int | None = None
+) -> str:
+ """ORCH-016: analyst "artifacts ready" comment via the unified status helper.
- Status-only model (PR #12): approval is the **Approved** status, NOT a
- ``:approved:`` comment and NOT moving back to In Progress. The comment asks
- the stakeholder to flip the status and links the documents the analyst
- actually produced.
+ Historically this function hand-built the HTML for the analyst's BUG-C
+ status-only verdict comment (PR #12 / #13). After ORCH-016 / ADR-001 \u00a71 every
+ agent goes through the single ``usage.build_status_comment(...)`` hot path,
+ so this is now a thin compatibility wrapper that:
- Links point at the Gitea web view:
- {gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{wid}/
- Only files that REALLY exist in the worktree are listed (no invented docs).
+ - keeps the same 3-positional signature that ``_handle_analysis_approved_flow``
+ and the regression tests (``tests/test_analyst_comment.py``) already call,
+ - adds an optional ``task_id`` so the duration line for the analyst can be
+ resolved via the DB fallback (AC-14: analyst's ``_duration_s`` isn't in
+ scope of stage_engine, hence the fallback),
+ - locates the worktree so AC-8 graceful skipping of missing analyst
+ artifacts and ``gitea_public_url`` clickability work exactly as before.
+
+ All historical text contracts are preserved by the analyst branch inside
+ ``build_status_comment``: \u00abApproved\u00bb, \u00abRejected\u00bb, no \u00ab:approved:\u00bb, no
+ \u00abIn Progress\u00bb \u2014 the existing test_analyst_comment.py assertions still hold.
"""
- text = (
- "\u2705 BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
- "\u0414\u043b\u044f \u043f\u0440\u043e\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f "
- "\u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 "
- "\u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved. "
- "\u0414\u043b\u044f \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u2014 "
- "\u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u043f\u0440\u0438\u0447\u0438\u043d\u0443 "
- "\u043a\u043e\u043c\u043c\u0435\u043d\u0442\u043e\u043c \u0438 \u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 "
- "\u0432 Rejected."
- )
-
- # Candidate analyst artifacts (label -> filename). Only existing ones linked.
- candidates = [
- ("Business request", "00-business-request.md"),
- ("BRD", "01-brd.md"),
- ("\u0422\u0417 (TRZ)", "02-trz.md"),
- ("Acceptance Criteria", "03-acceptance-criteria.md"),
- ("Test Plan", "04-test-plan.yaml"),
- ("UI Test Cases", "04b-ui-test-cases.md"),
- ]
- rel_dir = f"docs/work-items/{work_item_id}"
+ from .usage import build_status_comment
try:
- wt_dir = os.path.join(get_worktree_path(repo, branch), rel_dir)
+ worktree_root = get_worktree_path(repo, branch)
except Exception:
- wt_dir = None
-
- owner = getattr(settings, "gitea_owner", "admin")
- base = (getattr(settings, "gitea_public_url", "") or settings.gitea_url).rstrip("/")
- links = []
- for label, fname in candidates:
- if wt_dir and not os.path.isfile(os.path.join(wt_dir, fname)):
- continue
- href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
- links.append(f'- {label}
')
-
- if links:
- text += "
\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b:"
- return text
+ worktree_root = None
+ return build_status_comment(
+ "analyst",
+ repo=repo,
+ branch=branch,
+ work_item_id=work_item_id,
+ task_id=task_id,
+ worktree_root=worktree_root,
+ )
def _handle_analysis_approved_flow(
@@ -373,7 +358,9 @@ def _handle_analysis_approved_flow(
set_issue_in_review(work_item_id)
plane_add_comment(
work_item_id,
- _build_analyst_ready_comment(repo, work_item_id, branch),
+ # task_id is threaded through so build_status_comment can resolve the
+ # analyst duration via agent_runs (ORCH-016 AC-14 DB fallback).
+ _build_analyst_ready_comment(repo, work_item_id, branch, task_id=task_id),
author="analyst",
)
notify_approve_requested(task_id)
diff --git a/src/usage.py b/src/usage.py
index 96bd25a..d64270a 100644
--- a/src/usage.py
+++ b/src/usage.py
@@ -1,4 +1,4 @@
-"""Feature 4: token / cost accounting for agent runs.
+"""Feature 4 + ORCH-016: token / cost accounting and unified status comments.
claude --output-format json emits a single result JSON object at the end of the
run log with fields:
@@ -8,11 +8,16 @@ run log with fields:
modelUsage, num_turns, duration_ms
This module parses that JSON out of a (text-or-json) run log, records the usage
-on the agent_runs row, formats a Plane comment for the finishing agent, and
-builds the per-task summary the Deployer posts on deploy/done.
+on the agent_runs row, and builds:
+ - per-agent status comments via build_status_comment(...) — the ORCH-016
+ unified format replacing the legacy usage_comment(...) and the analyst-
+ only stage_engine._build_analyst_ready_comment(...). Every agent now flows
+ through the same hot path.
+ - per-task summary the Deployer posts on deploy/done.
-Everything here is defensive: a missing/garbled JSON never raises \u2014 we record
-NULL/0 and log a warning so a broken agent run can't crash the monitor.
+Everything here is defensive: a missing/garbled JSON never raises — we record
+NULL/0 and log a warning so a broken agent run can't crash the monitor. The
+status-comment hot path likewise NEVER raises (self-hosting risk R-1).
"""
import json
@@ -247,6 +252,88 @@ def fmt_cost(c) -> str:
return f"${c:.2f}"
+def fmt_duration(seconds) -> str:
+ """Format an integer second count for the agent-finish status comment (ORCH-016).
+
+ Contract (ADR-001 §8 / AC-13):
+ 0..59 -> '{s}s' (e.g. '0s', '12s', '59s')
+ 60..3599 -> '{m}m {ss:02d}s' (e.g. '1m 00s', '4m 12s', '59m 59s')
+ >= 3600 -> '{h}h {mm:02d}m' (seconds dropped; e.g. '1h 00m', '2h 47m')
+
+ None / non-int / negative -> '' so the caller drops the 'Длительность:' line.
+ Pure function: no I/O, no DB.
+ """
+ try:
+ if seconds is None:
+ return ""
+ s = int(seconds)
+ except (TypeError, ValueError):
+ return ""
+ if s < 0:
+ return ""
+ if s < 60:
+ return f"{s}s"
+ if s < 3600:
+ m, ss = divmod(s, 60)
+ return f"{m}m {ss:02d}s"
+ h, rem = divmod(s, 3600)
+ mm = rem // 60
+ return f"{h}h {mm:02d}m"
+
+
+def get_agent_duration(task_id, agent: str) -> int | None:
+ """Last finished agent_runs duration (seconds) for (task_id, agent) — DB fallback.
+
+ ORCH-016 / ADR-001 §6: used by build_status_comment when the caller does NOT
+ pass an explicit duration_s (chiefly the analyst path, which builds its
+ comment from stage_engine where _duration_s is not in scope).
+
+ Reads the last finished row for (task_id, agent) via:
+ SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
+ FROM agent_runs WHERE task_id=? AND agent=?
+ AND finished_at IS NOT NULL
+ ORDER BY id DESC LIMIT 1
+
+ Returns None on any of:
+ - missing task_id / agent,
+ - no matching row (or finished_at IS NULL),
+ - computed value < 0 (clock skew),
+ - DB error (logged at debug, never re-raised). This is the hot comment
+ path — a locked / stale DB must never crash a finishing agent.
+ """
+ if not task_id or not agent:
+ return None
+ try:
+ conn = get_db()
+ except Exception as e:
+ logger.debug(f"get_agent_duration: cannot open DB for ({task_id},{agent}): {e}")
+ return None
+ try:
+ row = conn.execute(
+ "SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER) "
+ "FROM agent_runs WHERE task_id=? AND agent=? AND finished_at IS NOT NULL "
+ "ORDER BY id DESC LIMIT 1",
+ (task_id, agent),
+ ).fetchone()
+ except Exception as e:
+ logger.debug(f"get_agent_duration: query failed for ({task_id},{agent}): {e}")
+ return None
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+ if not row or row[0] is None:
+ return None
+ try:
+ secs = int(row[0])
+ except (TypeError, ValueError):
+ return None
+ if secs < 0:
+ return None
+ return secs
+
+
# Pretty agent names for comments (mirrors STAGE_AUTHORS roles).
AGENT_DISPLAY = {
"analyst": "Analyst",
@@ -298,30 +385,28 @@ def usage_comment(
work_item_id: str | None = None,
pr_number=None,
) -> str:
- """Build the per-agent finish comment, e.g.
- '\U0001f4bb Developer \u0433\u043e\u0442\u043e\u0432 \u00b7 8.5M in (8.4M cached) / 45.8k out \u00b7 $7.29'.
+ """DEPRECATED (ORCH-016 / ADR-001 §1): thin wrapper around build_status_comment.
- When repo/branch/work_item_id are supplied, the agent's artifact link(s) are
- appended (BUG: only analyst used to link its docs). Missing artifacts are
- silently skipped — link building never raises.
+ The historical one-line "{icon} Role готов · 8.5M in / 45.8k out · $7.29 + links"
+ format has been replaced by the unified status-comment format. This wrapper
+ is kept only so that legacy callers (notably the test suite in
+ ``tests/test_usage.py``) keep working; new code MUST call
+ ``build_status_comment(...)`` directly. There is no ``duration_s`` parameter
+ here because the old signature did not carry it.
"""
- usage = usage or {}
- name = AGENT_DISPLAY.get(agent, agent.capitalize())
- icon = AGENT_ICON.get(agent, "\u2705")
- line = (
- f"{icon} {name} \u0433\u043e\u0442\u043e\u0432 \u00b7 "
- f"{fmt_in(usage)} / "
- f"{fmt_tokens(usage.get('output_tokens'))} out \u00b7 "
- f"{fmt_cost(usage.get('cost_usd'))}"
+ return build_status_comment(
+ agent,
+ repo=repo,
+ branch=branch,
+ work_item_id=work_item_id,
+ pr_number=pr_number,
+ usage=usage,
)
- links = artifact_links(agent, repo, branch, work_item_id, pr_number)
- if links:
- line += "\n" + "\n".join(links)
- return line
-# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer use
-# special handling for ADR dirs / PR links, see artifact_links()).
+# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer/
+# deployer use special handling for ADR dirs, PR links, or staging logs —
+# see artifact_links()).
AGENT_ARTIFACT = {
"reviewer": ("Review", "12-review.md"),
"tester": ("Test report", "13-test-report.md"),
@@ -335,13 +420,35 @@ def artifact_links(
branch: str | None,
work_item_id: str | None,
pr_number=None,
+ *,
+ stage: str | None = None,
+ worktree_root: str | None = None,
) -> list[str]:
- """Markdown link(s) to the finishing agent's artifact(s) in Gitea.
+ """HTML - ...
link fragments for the finishing agent's artifacts.
- Uses gitea_public_url (falls back to gitea_url) for clickable links, mirroring
- the analyst doc links. Returns [] (never raises) when there is nothing to
- link or the required context is missing. analyst is intentionally NOT handled
- here — its richer doc list lives in stage_engine._build_analyst_ready_comment.
+ ORCH-016 (ADR-001 §7) breaking change: this function now emits HTML anchor
+ fragments to feed straight into the of build_status_comment(), instead
+ of the legacy markdown ``[label](url)`` strings. The base URL still prefers
+ settings.gitea_public_url (falls back to gitea_url) so links remain clickable
+ from outside the deploy host, exactly like the analyst doc list.
+
+ Returned strings are individual ``- label
`` items;
+ the caller wraps them in ````. Empty list (never raises) when
+ there is nothing to link or context is missing.
+
+ AC-8 graceful behaviour: when ``worktree_root`` is provided, a candidate
+ whose underlying file does NOT exist in the worktree is dropped silently.
+ With no worktree (unit-test / minimal context), every applicable link is
+ emitted without a file-existence probe (matches the legacy artifact_links
+ semantics; that's what existing tests in tests/test_usage.py exercise).
+
+ Per agent (ADR-001 §7, ТЗ §2.4):
+ developer -> Branch + (open) PR
+ architect -> ADR directory
+ reviewer -> 12-review.md
+ tester -> 13-test-report.md
+ deployer -> 14-deploy-log.md (deploy) or 15-staging-log.md (deploy-staging)
+ analyst -> NOT handled here; build_status_comment owns its richer list.
"""
try:
from .config import settings
@@ -351,37 +458,76 @@ def artifact_links(
).rstrip("/")
if not base or not repo:
return []
- links: list[str] = []
+
+ items: list[str] = []
+ rel_dir = f"docs/work-items/{work_item_id}" if work_item_id else None
+
+ def _file_exists(rel_path: str) -> bool:
+ if not worktree_root:
+ return True
+ try:
+ import os as _os
+ return _os.path.isfile(_os.path.join(worktree_root, rel_path))
+ except Exception:
+ return True
+
+ def _dir_exists(rel_path: str) -> bool:
+ if not worktree_root:
+ return True
+ try:
+ import os as _os
+ return _os.path.isdir(_os.path.join(worktree_root, rel_path))
+ except Exception:
+ return True
if agent == "developer":
if branch:
- links.append(
- f"\U0001f4c2 [Branch {branch}]({base}/{owner}/{repo}/src/branch/{branch})"
+ items.append(
+ f'- '
+ f"Branch {branch}
"
)
if pr_number:
- links.append(
- f"\U0001f517 [PR #{pr_number}]({base}/{owner}/{repo}/pulls/{pr_number})"
+ items.append(
+ f'- '
+ f"PR #{pr_number}
"
)
- return links
+ return items
if agent == "architect":
- if branch and work_item_id:
- adr_dir = (
- f"{base}/{owner}/{repo}/src/branch/{branch}/"
- f"docs/work-items/{work_item_id}/06-adr"
- )
- links.append(f"\U0001f4d0 [ADR]({adr_dir})")
- return links
+ if branch and rel_dir:
+ adr_rel = f"{rel_dir}/06-adr"
+ if _dir_exists(adr_rel):
+ items.append(
+ f'- ADR
'
+ )
+ return items
+
+ if agent == "deployer":
+ # Stage-aware (ORCH-35 + ORCH-016 §2.4): 'deploy-staging' picks the
+ # staging log; 'deploy' (or unknown) picks the deploy log. Other
+ # deployer artifacts (smoke output etc.) are out of scope.
+ if branch and rel_dir:
+ if (stage or "").strip() == "deploy-staging":
+ fname, label = "15-staging-log.md", "Staging log"
+ else:
+ fname, label = "14-deploy-log.md", "Deploy log"
+ if _file_exists(f"{rel_dir}/{fname}"):
+ items.append(
+ f'- {label}
'
+ )
+ return items
spec = AGENT_ARTIFACT.get(agent)
- if spec and branch and work_item_id:
+ if spec and branch and rel_dir:
label, fname = spec
- href = (
- f"{base}/{owner}/{repo}/src/branch/{branch}/"
- f"docs/work-items/{work_item_id}/{fname}"
- )
- links.append(f"\U0001f4c4 [{label}]({href})")
- return links
+ if _file_exists(f"{rel_dir}/{fname}"):
+ items.append(
+ f'- {label}
'
+ )
+ return items
except Exception:
return []
@@ -396,6 +542,295 @@ AGENT_ICON = {
}
+# ---------------------------------------------------------------------------
+# ORCH-016: unified status comment for every agent (analyst..deployer)
+# ---------------------------------------------------------------------------
+
+# Per-agent one-line description used in the status comment header (ADR-001 §2).
+# Trailing periods are kept to match the literal assertions in AC-1..AC-5.
+_AGENT_DESCRIPTIONS = {
+ "analyst": (
+ "Подготовил BRD / "
+ "ТЗ / Acceptance Criteria. "
+ "Для продвижения "
+ "переведите задачу "
+ "в статус Approved. "
+ "Для отклонения — "
+ "напишите причину "
+ "комментом и "
+ "переведите в Rejected."
+ ),
+ "architect": (
+ "Завершил "
+ "архитектурную "
+ "проработку. "
+ "См. ADR ниже."
+ ),
+ "developer": (
+ "Завершил "
+ "разработку. "
+ "См. PR / branch ниже."
+ ),
+ "reviewer": (
+ "Завершил "
+ "ревью "
+ "изменений."
+ ),
+ "tester": (
+ "Завершил "
+ "прогон "
+ "тестов."
+ ),
+ "deployer": (
+ "Завершил деплой."
+ ),
+}
+
+# Analyst-specific candidate artifact list (label -> filename in docs/work-items//).
+# Matches the legacy _build_analyst_ready_comment list 1:1 so the BUG-C
+# regression test (tests/test_analyst_comment.py) keeps passing under the
+# unified format.
+_ANALYST_CANDIDATES = [
+ ("Business request", "00-business-request.md"),
+ ("BRD", "01-brd.md"),
+ ("ТЗ (TRZ)", "02-trz.md"),
+ ("Acceptance Criteria", "03-acceptance-criteria.md"),
+ ("Test Plan", "04-test-plan.yaml"),
+ ("UI Test Cases", "04b-ui-test-cases.md"),
+]
+
+
+def _read_verdict_line(
+ agent: str, stage: str | None, worktree_root: str | None, work_item_id: str | None
+) -> str | None:
+ """Render the optional Verdict / Status line for reviewer / tester / deployer.
+
+ Sources (machine-readable YAML frontmatter, via src/frontmatter.py):
+ reviewer -> 12-review.md verdict: -> 'Verdict: '
+ tester -> 13-test-report.md verdict: (or status:) -> 'Verdict: '
+ deployer -> deploy-staging -> 15-staging-log.md staging_status: -> 'Status: '
+ else (deploy) -> 14-deploy-log.md deploy_status: -> 'Status: '
+
+ Returns None (line suppressed) for analyst / architect / developer, when
+ the worktree is unknown, the work-item id is missing, the artifact file is
+ absent, or the relevant frontmatter key is not present. Never raises.
+ """
+ if agent not in ("reviewer", "tester", "deployer"):
+ return None
+ if not worktree_root or not work_item_id:
+ return None
+ try:
+ import os as _os
+ from .frontmatter import read_frontmatter_value
+ base_dir = _os.path.join(worktree_root, "docs/work-items", work_item_id)
+ except Exception:
+ return None
+
+ if agent == "reviewer":
+ v = read_frontmatter_value(_os.path.join(base_dir, "12-review.md"), "verdict")
+ return f"Verdict: {v}" if v else None
+ if agent == "tester":
+ path = _os.path.join(base_dir, "13-test-report.md")
+ v = read_frontmatter_value(path, "verdict")
+ if not v:
+ v = read_frontmatter_value(path, "status")
+ return f"Verdict: {v}" if v else None
+ # deployer
+ if (stage or "").strip() == "deploy-staging":
+ v = read_frontmatter_value(
+ _os.path.join(base_dir, "15-staging-log.md"), "staging_status"
+ )
+ else:
+ v = read_frontmatter_value(
+ _os.path.join(base_dir, "14-deploy-log.md"), "deploy_status"
+ )
+ return f"Status: {v}" if v else None
+
+
+def _analyst_doc_items(
+ repo: str, branch: str, work_item_id: str, worktree_root: str | None
+) -> list[str]:
+ """Build the analyst's - ...
list (mirrors legacy behaviour).
+
+ Files absent from the worktree are skipped (graceful, as in BUG-C / PR #13).
+ """
+ if not (repo and branch and work_item_id):
+ return []
+ from .config import settings as _settings
+ owner = getattr(_settings, "gitea_owner", "admin")
+ base = (
+ getattr(_settings, "gitea_public_url", "") or getattr(_settings, "gitea_url", "")
+ ).rstrip("/")
+ if not base:
+ return []
+ rel_dir = f"docs/work-items/{work_item_id}"
+ items: list[str] = []
+ for label, fname in _ANALYST_CANDIDATES:
+ if worktree_root:
+ try:
+ import os as _os
+ if not _os.path.isfile(_os.path.join(worktree_root, rel_dir, fname)):
+ continue
+ except Exception:
+ # On filesystem error, fall through and link the candidate anyway
+ # (best-effort) rather than blanking the whole document list.
+ pass
+ href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
+ items.append(f'- {label}
')
+ return items
+
+
+def _usage_tail(usage: dict | None) -> str | None:
+ """Render the technical token/cost tail (``...``) or None when empty.
+
+ Format (ADR-001 §3): ``{fmt_in} / {out} out · {cost}``.
+ Returns None when usage is missing entirely AND all of the relevant
+ components are zero — i.e. nothing meaningful to print.
+ """
+ if not usage:
+ return None
+ in_total = _input_total(usage)
+ try:
+ out = int(usage.get("output_tokens") or 0)
+ except (TypeError, ValueError):
+ out = 0
+ try:
+ cost = float(usage.get("cost_usd") or 0.0)
+ except (TypeError, ValueError):
+ cost = 0.0
+ if in_total == 0 and out == 0 and cost == 0.0:
+ return None
+ return f"{fmt_in(usage)} / {fmt_tokens(out)} out · {fmt_cost(cost)}"
+
+
+def build_status_comment(
+ agent: str,
+ *,
+ repo: str | None = None,
+ branch: str | None = None,
+ work_item_id: str | None = None,
+ pr_number=None,
+ stage: str | None = None,
+ usage: dict | None = None,
+ duration_s=None,
+ task_id=None,
+ worktree_root: str | None = None,
+) -> str:
+ """Build the unified per-agent finish comment (ORCH-016 / ADR-001).
+
+ Single hot path for every agent's "I just finished a stage" comment in
+ Plane. Replaces the old ``usage_comment(...)`` one-liner AND the analyst-
+ special ``stage_engine._build_analyst_ready_comment(...)`` HTML; both now
+ flow through here. Format (HTML, rendered by Plane), separated by ``
``::
+
+ {ICON} {RoleName} — {DESCRIPTION}
+ [Verdict|Status: VALUE] # reviewer/tester/deployer + FM
+ [Длительность: 4m 12s]
+ Документы:
+ [8.5M in / 45.8k out · $7.29]
+
+ Arguments (all keyword-only except ``agent``):
+ agent one of analyst|architect|developer|reviewer|tester|deployer.
+ Unknown agents get a generic header — defensive.
+ repo/branch repository name + feature branch. Required for artifact
+ links; without them the Документы block is omitted.
+ work_item_id Plane work-item id used as the docs/work-items// slug.
+ pr_number developer only — appended as a PR link when set.
+ stage deployer only — 'deploy' vs 'deploy-staging' picks the
+ log file (14- vs 15-) and the verdict frontmatter key.
+ usage parsed token/cost dict (from parse_usage_from_text). When
+ None or all-zero the ```` tail is suppressed.
+ duration_s explicit per-agent wall-clock seconds. If None and
+ task_id is given, falls back to
+ get_agent_duration(task_id, agent). Negative / non-int
+ values are treated as unknown.
+ task_id tasks.id — required for the DB duration fallback. The
+ verdict / artifact code paths do NOT depend on it.
+ worktree_root path to the task's git worktree. Drives AC-8 graceful
+ skipping of missing files AND the verdict frontmatter
+ read. Omit (None) in unit tests where only format matters.
+
+ The function MUST NOT raise — at worst it returns a degraded one-liner
+ header, with the exception logged. Self-hosting risk R-1: a crash here
+ blinds the stakeholder for that very ORCH task.
+ """
+ try:
+ name = AGENT_DISPLAY.get(agent, (agent or "agent").capitalize())
+ icon = AGENT_ICON.get(agent, "✅")
+ description = _AGENT_DESCRIPTIONS.get(
+ agent,
+ "завершил стадию.",
+ )
+ if agent == "deployer":
+ if (stage or "").strip() == "deploy-staging":
+ description = (
+ "Завершил "
+ "staging-деплой."
+ )
+ elif (stage or "").strip() == "deploy":
+ description = (
+ "Завершил "
+ "прод-деплой."
+ )
+
+ lines: list[str] = [f"{icon} {name} — {description}"]
+
+ verdict_line = _read_verdict_line(agent, stage, worktree_root, work_item_id)
+ if verdict_line:
+ lines.append(verdict_line)
+
+ # Duration: explicit param wins; otherwise DB fallback (ADR-001 §6).
+ resolved_duration: int | None = None
+ if duration_s is not None:
+ try:
+ if int(duration_s) >= 0:
+ resolved_duration = int(duration_s)
+ except (TypeError, ValueError):
+ resolved_duration = None
+ if resolved_duration is None and task_id is not None:
+ resolved_duration = get_agent_duration(task_id, agent)
+ d_text = fmt_duration(resolved_duration)
+ if d_text:
+ lines.append(
+ "Длительность: "
+ f"{d_text}"
+ )
+
+ # Documents block (analyst gets its full BRD/TRZ/AC/Test Plan list).
+ if agent == "analyst":
+ doc_items = _analyst_doc_items(
+ repo or "", branch or "", work_item_id or "", worktree_root
+ )
+ else:
+ doc_items = artifact_links(
+ agent, repo, branch, work_item_id, pr_number,
+ stage=stage, worktree_root=worktree_root,
+ )
+ if doc_items:
+ lines.append(
+ "Документы:"
+ + "".join(doc_items)
+ + "
"
+ )
+
+ tail = _usage_tail(usage)
+ if tail:
+ lines.append(tail)
+
+ return "
".join(lines)
+ except Exception as e: # defensive — R-1 fallback
+ logger.exception(f"build_status_comment failed for agent={agent}: {e}")
+ try:
+ name = AGENT_DISPLAY.get(agent, str(agent).capitalize())
+ icon = AGENT_ICON.get(agent, "✅")
+ return (
+ f"{icon} {name} "
+ "готов"
+ )
+ except Exception:
+ return "✅ Agent готов"
+
+
def task_usage_summary(task_id: int) -> dict:
"""Aggregate agent_runs usage for a task.
@@ -441,14 +876,14 @@ def task_summary_comment(task_id: int) -> str:
s = task_usage_summary(task_id)
cached = s.get("total_cached", 0)
head_in = (
- f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434 ({fmt_tokens(cached)} cached)"
+ f"{fmt_tokens(s['total_in'])} вход ({fmt_tokens(cached)} cached)"
if cached > 0
- else f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434"
+ else f"{fmt_tokens(s['total_in'])} вход"
)
lines = [
- f"\U0001f4ca \u0418\u0442\u043e\u0433\u043e \u043f\u043e \u0437\u0430\u0434\u0430\u0447\u0435: "
+ f"\U0001f4ca Итого по задаче: "
f"{head_in} / "
- f"{fmt_tokens(s['total_out'])} \u0432\u044b\u0445\u043e\u0434 \u00b7 "
+ f"{fmt_tokens(s['total_out'])} выход · "
f"{fmt_cost(s['total_cost'])}"
]
for agent, ti, tc, to, cost in s["per_agent"]:
@@ -459,6 +894,6 @@ def task_summary_comment(task_id: int) -> str:
else f"{fmt_tokens(ti)} in"
)
lines.append(
- f"\u2022 {name}: {in_str} / {fmt_tokens(to)} out \u00b7 {fmt_cost(cost)}"
+ f"• {name}: {in_str} / {fmt_tokens(to)} out · {fmt_cost(cost)}"
)
return "\n".join(lines)
diff --git a/tests/test_analyst_comment_regression.py b/tests/test_analyst_comment_regression.py
new file mode 100644
index 0000000..4c541d8
--- /dev/null
+++ b/tests/test_analyst_comment_regression.py
@@ -0,0 +1,126 @@
+"""ORCH-016 / TC-11 + AC-6: analyst status-comment regression.
+
+Status-only verdict model from PR #12 / #13 must be preserved exactly:
+ - the analyst comment still asks the stakeholder for the **Approved** status,
+ - it still rejects the obsolete ``:approved:`` reaction and "move to In Progress",
+ - it still links the documents that actually exist (BRD / TRZ / AC / Test Plan,
+ skipping anything not on disk),
+ - it now also carries the new «Длительность: …» line when an agent_runs row
+ exists for (task_id, analyst).
+"""
+
+import os
+import tempfile
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_analyst_regression.db")
+os.environ["ORCH_DB_PATH"] = _test_db
+
+import pytest # noqa: E402
+
+from src import db as db_module # noqa: E402
+from src.db import init_db, get_db # noqa: E402
+
+
+@pytest.fixture(autouse=True)
+def setup_db(monkeypatch):
+ monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+ init_db()
+ yield
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+
+
+def _seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=180):
+ """Insert a task and a finished analyst run with a measurable duration."""
+ conn = get_db()
+ conn.execute(
+ "INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
+ "VALUES (?, 'orchestrator', 'feature/ORCH-016', 'analysis', 'ORCH-016')",
+ (task_id,),
+ )
+ conn.execute(
+ "INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
+ "VALUES (?, ?, datetime('now', ?), datetime('now'))",
+ (task_id, agent, f"-{duration_seconds} seconds"),
+ )
+ conn.commit()
+ conn.close()
+
+
+def test_tc11_analyst_text_preserved_with_links(monkeypatch, tmp_path):
+ """Analyst comment must keep all existing assertions from PR #12 / #13."""
+ from src import stage_engine as SE
+ from src.config import settings
+
+ wt = tmp_path / "wt"
+ docs = wt / "docs" / "work-items" / "ET-011"
+ docs.mkdir(parents=True)
+ for fname in (
+ "00-business-request.md", "01-brd.md", "02-trz.md",
+ "03-acceptance-criteria.md", "04-test-plan.yaml",
+ ):
+ (docs / fname).write_text("x")
+ # 04b-ui-test-cases.md intentionally absent
+
+ monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
+ monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
+ monkeypatch.setattr(
+ settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
+ )
+ monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
+
+ html = SE._build_analyst_ready_comment(
+ "enduro-trails", "ET-011", "feature/ET-011-gpx-upload-feature",
+ )
+
+ # Status-only verdict text (PR #12 contract).
+ assert "Approved" in html
+ assert "Rejected" in html
+ assert ":approved:" not in html
+ assert "In Progress" not in html
+
+ # Clickable links via public URL only.
+ assert " Plane state becomes In Review,
+ - ONE status comment is posted asking the stakeholder to flip the status to
+ Approved (or write a reason and switch to Rejected),
+ - NO auto-advance happens — the next stage waits for human approval.
+
+The ORCH-016 PR refactors the comment text into the unified status-comment
+helper. This regression test guards against:
+ (a) the analyst path silently auto-advancing,
+ (b) the analyst comment losing the «Approved» / «Rejected» instruction text,
+ (c) the comment switching authorship away from the analyst bot.
+
+We exercise `_handle_analysis_approved_flow` directly (the launcher path).
+"""
+
+import os
+import tempfile
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_analyst_so.db")
+os.environ["ORCH_DB_PATH"] = _test_db
+
+import pytest # noqa: E402
+
+from src import db as db_module # noqa: E402
+from src.db import init_db, get_db # noqa: E402
+
+
+REPO = "enduro-trails"
+BRANCH = "feature/ET-016-x"
+WID = "ET-016"
+
+
+@pytest.fixture(autouse=True)
+def setup_db(monkeypatch, tmp_path):
+ monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+ init_db()
+ conn = get_db()
+ conn.execute(
+ "INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
+ "VALUES (1, ?, ?, 'analysis', ?)",
+ (REPO, BRANCH, WID),
+ )
+ conn.commit()
+ conn.close()
+ yield
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+
+
+@pytest.fixture
+def fake_worktree(monkeypatch, tmp_path):
+ base = tmp_path / "wt"
+ docs = base / "docs" / "work-items" / WID
+ docs.mkdir(parents=True)
+ # All analyst artifacts present -> "files_check" returns True.
+ for f in ("01-brd.md", "02-trz.md", "03-acceptance-criteria.md",
+ "04-test-plan.yaml"):
+ (docs / f).write_text("x")
+ monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
+ monkeypatch.setattr("src.stage_engine.get_worktree_path", lambda r, b: str(base))
+ monkeypatch.setattr("src.qg.checks.get_worktree_path", lambda r, b: str(base))
+ return base
+
+
+@pytest.fixture
+def collect_calls(monkeypatch):
+ calls = {"in_review": 0, "advance": 0, "comments": [], "enqueued": []}
+
+ monkeypatch.setattr(
+ "src.stage_engine.set_issue_in_review",
+ lambda wid: calls.__setitem__("in_review", calls["in_review"] + 1),
+ )
+ monkeypatch.setattr(
+ "src.stage_engine.notify_approve_requested", lambda tid: None
+ )
+
+ def _add_comment(wid, body, author=None, **kw):
+ calls["comments"].append({"wid": wid, "body": body, "author": author})
+
+ monkeypatch.setattr("src.stage_engine.plane_add_comment", _add_comment)
+
+ # advance_stage isn't directly hit; if anything calls update_task_stage to
+ # 'architecture', we'd see it here.
+ def _update_task_stage(task_id, stage):
+ calls["advance"] += 1
+
+ monkeypatch.setattr("src.stage_engine.update_task_stage", _update_task_stage)
+
+ def _enqueue(*a, **k):
+ calls["enqueued"].append((a, k))
+ return 1
+
+ monkeypatch.setattr("src.stage_engine.enqueue_job", _enqueue)
+ return calls
+
+
+def test_tc16_analyst_goes_to_in_review_no_advance(fake_worktree, collect_calls):
+ """When the analyst finishes with complete artifacts, the task goes to In
+ Review and NO advance/enqueue happens — the human approves via Plane status.
+ """
+ from src.stage_engine import _handle_analysis_approved_flow, AdvanceResult
+
+ result = AdvanceResult(from_stage="analysis")
+ _handle_analysis_approved_flow(
+ task_id=1, current_stage="analysis", repo=REPO, work_item_id=WID,
+ branch=BRANCH, agent="analyst", result=result,
+ )
+
+ # In Review state requested in Plane.
+ assert collect_calls["in_review"] == 1, collect_calls
+ # NO stage-machine advance.
+ assert collect_calls["advance"] == 0, collect_calls
+ # NO new job enqueued by the analyst path.
+ assert collect_calls["enqueued"] == [], collect_calls
+
+ # Exactly one comment posted, authored by analyst, with required text bits.
+ assert len(collect_calls["comments"]) == 1, collect_calls["comments"]
+ c = collect_calls["comments"][0]
+ assert c["wid"] == WID
+ assert c["author"] == "analyst"
+ body = c["body"]
+ assert "Approved" in body
+ assert "Rejected" in body
+ assert ":approved:" not in body
+ assert "In Progress" not in body
+ # AC-6 +: the new unified format adds a Длительность line (DB fallback).
+ # No agent_runs row exists in this test, so the line should be ABSENT.
+ assert "Длительность" not in body
diff --git a/tests/test_fmt_duration.py b/tests/test_fmt_duration.py
new file mode 100644
index 0000000..c8f7da0
--- /dev/null
+++ b/tests/test_fmt_duration.py
@@ -0,0 +1,68 @@
+"""ORCH-016 / AC-13 + AC-22: fmt_duration formatting contract.
+
+Pure-function tests for the duration formatter used by build_status_comment.
+No DB, no I/O — just the table in ADR-001 §8 / AC-13.
+"""
+
+import os
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+from src.usage import fmt_duration # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# TC-21: table-driven happy path (AC-13)
+# ---------------------------------------------------------------------------
+def test_fmt_duration_boundary_table():
+ cases = [
+ (0, "0s"),
+ (12, "12s"),
+ (59, "59s"),
+ (60, "1m 00s"),
+ (252, "4m 12s"),
+ (3599, "59m 59s"),
+ (3600, "1h 00m"),
+ (3780, "1h 03m"),
+ (10020, "2h 47m"),
+ ]
+ for seconds, expected in cases:
+ assert fmt_duration(seconds) == expected, (
+ f"fmt_duration({seconds}) -> {fmt_duration(seconds)!r}; expected {expected!r}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# TC-22: None / negative -> empty string (caller drops the line) (AC-13)
+# ---------------------------------------------------------------------------
+def test_fmt_duration_none_returns_empty():
+ assert fmt_duration(None) == ""
+
+
+def test_fmt_duration_negative_returns_empty():
+ assert fmt_duration(-1) == ""
+ assert fmt_duration(-3600) == ""
+
+
+def test_fmt_duration_garbage_returns_empty():
+ # Non-coercible input must not raise (defensive).
+ assert fmt_duration("abc") == ""
+ assert fmt_duration([1, 2]) == ""
+
+
+def test_fmt_duration_float_seconds_truncated():
+ # int(12.9) == 12 — integer truncation, not rounding.
+ assert fmt_duration(12.9) == "12s"
+ assert fmt_duration(61.4) == "1m 01s"
+
+
+# ---------------------------------------------------------------------------
+# Caller contract: empty string => the 'Длительность:' line is NOT printed.
+# build_status_comment is unit-tested in test_status_comment_format; here we
+# just sanity-check the helper used to gate that decision.
+# ---------------------------------------------------------------------------
+def test_empty_string_is_falsy():
+ assert not fmt_duration(None)
+ assert not fmt_duration(-5)
+ assert fmt_duration(0) # "0s" IS truthy: AC-13 wants the line printed
diff --git a/tests/test_notify_done_regression.py b/tests/test_notify_done_regression.py
new file mode 100644
index 0000000..73f641b
--- /dev/null
+++ b/tests/test_notify_done_regression.py
@@ -0,0 +1,79 @@
+"""ORCH-016 / TC-18 + AC-7: notify_done / set_issue_done not regressed.
+
+The final deploy -> done transition still posts the «✅ Task completed!»
+comment under the deployer bot, alongside the new ORCH-016 status comment
+the deployer publishes when it finishes the stage. The two comments are
+independent — the status comment doesn't replace `notify_done`.
+"""
+
+import os
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+from src import plane_sync as PS # noqa: E402
+
+
+def test_notify_done_constants_unchanged():
+ # Emoji + message body — pinned to lock the contract.
+ assert PS.EMOJI_DONE == "✅"
+
+
+def test_notify_done_posts_completed_comment(monkeypatch):
+ """plane_sync.notify_done still posts the ✅ Task completed! comment
+ authored by the deployer."""
+ captured = {}
+
+ def _spy_update(work_item_id, state, project_id=None):
+ captured["update"] = (work_item_id, state, project_id)
+
+ def _spy_add(work_item_id, body, project_id=None, author=None, **kw):
+ captured.setdefault("comments", []).append(
+ {"wid": work_item_id, "body": body, "author": author}
+ )
+
+ monkeypatch.setattr(PS, "update_issue_state", _spy_update)
+ monkeypatch.setattr(PS, "add_comment", _spy_add)
+ monkeypatch.setattr(PS, "_resolve_project_id", lambda wid, pid=None: "p-1")
+
+ PS.notify_done("ET-016")
+
+ assert captured["update"] == ("ET-016", "done", "p-1")
+ assert len(captured["comments"]) == 1
+ c = captured["comments"][0]
+ assert c["wid"] == "ET-016"
+ assert c["author"] == "deployer"
+ # Body untouched: emoji + canonical Russian/English copy.
+ assert "✅" in c["body"]
+ assert "Task completed" in c["body"]
+
+
+def test_set_issue_done_still_exported():
+ """set_issue_done must remain importable from plane_sync — stage_engine
+ line ~269 invokes it on deploy->done. ORCH-016 must not remove or rename it.
+ """
+ assert callable(getattr(PS, "set_issue_done", None))
+ # And stage_engine still imports it at the module level (regression: ORCH-016
+ # touches stage_engine to wire the new analyst comment helper).
+ from src import stage_engine as SE
+ assert getattr(SE, "set_issue_done", None) is PS.set_issue_done
+
+
+def test_orch016_does_not_steal_done_signal(monkeypatch):
+ """build_status_comment is just a comment — it must NOT call set_issue_done
+ or notify_done as a side effect (that's stage_engine's job)."""
+ from src import usage as U
+ called = {"done": 0, "in_review": 0}
+
+ def _fail(*a, **k):
+ called["done"] += 1
+
+ monkeypatch.setattr(PS, "set_issue_done", _fail)
+ monkeypatch.setattr(PS, "notify_done", _fail)
+
+ html = U.build_status_comment(
+ "deployer", repo="enduro-trails", branch="b", work_item_id="ET-016",
+ stage="deploy", duration_s=12,
+ )
+ assert "\U0001f680 Deployer" in html
+ assert called["done"] == 0
diff --git a/tests/test_post_usage_comments_integration.py b/tests/test_post_usage_comments_integration.py
new file mode 100644
index 0000000..8c8411c
--- /dev/null
+++ b/tests/test_post_usage_comments_integration.py
@@ -0,0 +1,199 @@
+"""ORCH-016 / TC-13..TC-15: _post_usage_comments integration tests.
+
+End-to-end (DB + filesystem worktree, no network) verification that
+AgentLauncher._post_usage_comments:
+ - resolves the task by (repo, branch),
+ - threads the explicit duration_s into build_status_comment,
+ - posts exactly ONE status comment authored by the finishing agent,
+ - for deployer: ALSO posts the per-task usage summary (deployer authorship).
+
+The actual Plane HTTP call (plane_sync.add_comment) is patched out; we only
+check the (work_item_id, body, author) tuples the launcher passes to it.
+"""
+
+import os
+import tempfile
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_post_usage.db")
+os.environ["ORCH_DB_PATH"] = _test_db
+
+import pytest # noqa: E402
+
+from src import db as db_module # noqa: E402
+from src.db import init_db, get_db # noqa: E402
+from src.agents.launcher import AgentLauncher # noqa: E402
+
+
+REPO = "enduro-trails"
+BRANCH = "feature/ET-016-x"
+WID = "ET-016"
+
+
+@pytest.fixture
+def setup_db(monkeypatch):
+ monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+ init_db()
+ conn = get_db()
+ conn.execute(
+ "INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
+ "VALUES (1, ?, ?, 'review', ?)",
+ (REPO, BRANCH, WID),
+ )
+ conn.commit()
+ conn.close()
+ yield
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+
+
+@pytest.fixture
+def fake_worktree(monkeypatch, tmp_path):
+ """Stub get_worktree_path inside the launcher module to a tmp_path location."""
+ wt = tmp_path / "wt"
+ (wt / "docs" / "work-items" / WID).mkdir(parents=True)
+
+ def _get_wt(repo, branch):
+ return str(wt)
+
+ # The launcher imports get_worktree_path lazily inside the function body
+ # (`from ..git_worktree import get_worktree_path`); patch the source module.
+ monkeypatch.setattr("src.git_worktree.get_worktree_path", _get_wt)
+ monkeypatch.setattr("src.usage._input_total", lambda u: 0) # quiet tail
+ return wt
+
+
+@pytest.fixture
+def capture_comments(monkeypatch):
+ posts = []
+
+ def _spy(work_item_id, body, author=None, **kwargs):
+ posts.append({"wid": work_item_id, "body": body, "author": author})
+
+ monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
+ return posts
+
+
+@pytest.fixture
+def public_url(monkeypatch):
+ from src.config import settings
+ monkeypatch.setattr(
+ settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
+ )
+ monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
+ monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
+
+
+# ---------------------------------------------------------------------------
+# TC-13: reviewer comment.
+# ---------------------------------------------------------------------------
+def test_tc13_reviewer_posts_one_status_comment(
+ setup_db, fake_worktree, capture_comments, public_url
+):
+ (fake_worktree / "docs" / "work-items" / WID / "12-review.md").write_text(
+ "---\nverdict: APPROVE\n---\nReviewed.",
+ )
+
+ AgentLauncher()._post_usage_comments(
+ run_id=99, agent="reviewer", repo=REPO, branch=BRANCH,
+ usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
+ duration_s=180,
+ )
+
+ assert len(capture_comments) == 1
+ post = capture_comments[0]
+ assert post["wid"] == WID
+ assert post["author"] == "reviewer"
+ body = post["body"]
+ assert "\U0001f50e Reviewer" in body
+ assert "Verdict: APPROVE" in body
+ assert "Длительность: 3m 00s" in body
+ assert "12-review.md" in body
+
+
+# ---------------------------------------------------------------------------
+# TC-14: tester comment.
+# ---------------------------------------------------------------------------
+def test_tc14_tester_posts_one_status_comment(
+ setup_db, fake_worktree, capture_comments, public_url
+):
+ (fake_worktree / "docs" / "work-items" / WID / "13-test-report.md").write_text(
+ "---\nverdict: PASS\n---\n",
+ )
+
+ AgentLauncher()._post_usage_comments(
+ run_id=100, agent="tester", repo=REPO, branch=BRANCH,
+ usage=None, duration_s=42,
+ )
+
+ assert len(capture_comments) == 1
+ post = capture_comments[0]
+ assert post["author"] == "tester"
+ body = post["body"]
+ assert "\U0001f9ea Tester" in body
+ assert "Verdict: PASS" in body
+ assert "Длительность: 42s" in body
+
+
+# ---------------------------------------------------------------------------
+# TC-15: deployer comment + per-task summary (two comments, both from deployer).
+# ---------------------------------------------------------------------------
+def test_tc15_deployer_posts_status_then_summary(
+ setup_db, fake_worktree, capture_comments, public_url
+):
+ # Task stage = 'deploy' so build_status_comment uses 14-deploy-log.md.
+ conn = get_db()
+ conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
+ conn.commit()
+ conn.close()
+ (fake_worktree / "docs" / "work-items" / WID / "14-deploy-log.md").write_text(
+ "---\ndeploy_status: SUCCESS\n---\nDeployed.",
+ )
+
+ AgentLauncher()._post_usage_comments(
+ run_id=101, agent="deployer", repo=REPO, branch=BRANCH,
+ usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
+ duration_s=300,
+ )
+
+ # 2 comments: status + per-task summary.
+ assert len(capture_comments) == 2
+ status, summary = capture_comments
+ assert status["author"] == "deployer"
+ assert "Status: SUCCESS" in status["body"]
+ assert "Длительность: 5m 00s" in status["body"]
+ assert "14-deploy-log.md" in status["body"]
+
+ assert summary["author"] == "deployer"
+ # task_summary_comment header (Russian "Итого по задаче").
+ assert "\U0001f4ca" in summary["body"]
+ assert "Итого" in summary["body"]
+
+
+def test_deployer_staging_picks_15_log(
+ setup_db, fake_worktree, capture_comments, public_url
+):
+ conn = get_db()
+ conn.execute("UPDATE tasks SET stage='deploy-staging' WHERE id=1")
+ conn.commit()
+ conn.close()
+ (fake_worktree / "docs" / "work-items" / WID / "15-staging-log.md").write_text(
+ "---\nstaging_status: SUCCESS\n---\n",
+ )
+
+ AgentLauncher()._post_usage_comments(
+ run_id=102, agent="deployer", repo=REPO, branch=BRANCH,
+ usage=None, duration_s=10,
+ )
+
+ # deployer always also posts the summary; check the FIRST comment is status.
+ assert len(capture_comments) == 2
+ status = capture_comments[0]
+ assert "Status: SUCCESS" in status["body"]
+ assert "15-staging-log.md" in status["body"]
+ assert "14-deploy-log.md" not in status["body"]
+ assert "staging-деплой" in status["body"]
diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py
new file mode 100644
index 0000000..284c8ac
--- /dev/null
+++ b/tests/test_qg_registry_snapshot.py
@@ -0,0 +1,64 @@
+"""ORCH-016 / TC-20 + AC-11: Quality Gates + stage machine are unchanged.
+
+Smoke / change-detector test: the ORCH-016 PR touches comment formatting only.
+The QG registry (src/qg/checks.QG_CHECKS) and the stage-machine table
+(src/stages.STAGE_TRANSITIONS) MUST remain bit-identical to the contracts the
+pipeline depends on. If a future change moves the comment hot path into these
+files by accident, this guard breaks first.
+"""
+
+import os
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+from src.qg.checks import QG_CHECKS # noqa: E402
+from src.stages import STAGE_TRANSITIONS # noqa: E402
+
+
+# The set of QG names the pipeline DEPLOYS on. Order doesn't matter, identity does.
+_EXPECTED_QGS = {
+ "check_analysis_approved",
+ "check_analysis_complete",
+ "check_architecture_done",
+ "check_ci_green",
+ "check_review_approved",
+ "check_tests_passed",
+ "check_reviewer_verdict",
+ "check_tests_local",
+ "check_deploy_status",
+ "check_staging_status",
+}
+
+
+def test_tc20_qg_registry_unchanged():
+ assert set(QG_CHECKS.keys()) == _EXPECTED_QGS
+
+
+def test_tc20_qg_callables_unchanged():
+ # All entries must be callable — no stub / lambda / None.
+ for name, fn in QG_CHECKS.items():
+ assert callable(fn), f"QG {name} is not callable"
+
+
+# Reference snapshot of STAGE_TRANSITIONS (mirrors what's in docs/architecture
+# and src/stages.py — duplicated here on purpose as a regression yardstick).
+_EXPECTED_TRANSITIONS = {
+ "created": {"next": "analysis", "agent": "analyst", "qg": None},
+ "analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
+ "architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
+ "development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
+ "review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
+ "testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
+ "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
+ "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
+ "done": {"next": None, "agent": None, "qg": None},
+}
+
+
+def test_tc20_stage_transitions_unchanged():
+ assert STAGE_TRANSITIONS == _EXPECTED_TRANSITIONS, (
+ "STAGE_TRANSITIONS drift detected — ORCH-016 must not change the "
+ "stage machine. Touched stage_engine or stages.py? Update the snapshot "
+ "in a separate, intentional PR."
+ )
diff --git a/tests/test_status_comment_authorship.py b/tests/test_status_comment_authorship.py
new file mode 100644
index 0000000..a7bfed6
--- /dev/null
+++ b/tests/test_status_comment_authorship.py
@@ -0,0 +1,122 @@
+"""ORCH-016 / TC-19 + AC-1..AC-5 authorship: status comments use per-agent bots.
+
+When a status comment is posted by AgentLauncher._post_usage_comments, the
+underlying plane_sync.add_comment must be invoked with ``author=`` so
+plane_sync._headers_for() picks the agent's bot token
+(PLANE_BOT_TOKENS[role]) — falling back to PLANE_HEADERS when the bot token
+is empty / role unknown. Comment FORMAT changes (ORCH-016) must not affect
+that authorship contract.
+"""
+
+import os
+import tempfile
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_authorship.db")
+os.environ["ORCH_DB_PATH"] = _test_db
+
+import pytest # noqa: E402
+
+from src import db as db_module # noqa: E402
+from src.db import init_db, get_db # noqa: E402
+from src.agents.launcher import AgentLauncher # noqa: E402
+
+REPO = "enduro-trails"
+BRANCH = "feature/ET-016-x"
+WID = "ET-016"
+
+
+@pytest.fixture
+def db(monkeypatch):
+ monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+ init_db()
+ conn = get_db()
+ conn.execute(
+ "INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
+ "VALUES (1, ?, ?, 'review', ?)",
+ (REPO, BRANCH, WID),
+ )
+ conn.commit()
+ conn.close()
+ yield
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+
+
+@pytest.fixture
+def fake_wt(monkeypatch, tmp_path):
+ base = tmp_path / "wt"
+ (base / "docs" / "work-items" / WID).mkdir(parents=True)
+ monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
+ return base
+
+
+@pytest.fixture
+def capture(monkeypatch):
+ posts = []
+
+ def _spy(work_item_id, body, author=None, **kwargs):
+ posts.append({"wid": work_item_id, "body": body, "author": author})
+
+ monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
+ return posts
+
+
+@pytest.mark.parametrize("agent", ["architect", "developer", "reviewer", "tester"])
+def test_tc19_status_comment_carries_agent_author(agent, db, fake_wt, capture):
+ """Each agent's status comment must be POST-ed under that agent's bot."""
+ AgentLauncher()._post_usage_comments(
+ run_id=1, agent=agent, repo=REPO, branch=BRANCH,
+ usage=None, duration_s=10,
+ )
+ assert len(capture) >= 1
+ assert capture[0]["author"] == agent, (
+ f"Expected author={agent!r}, got {capture[0]['author']!r}"
+ )
+
+
+def test_tc19_deployer_status_and_summary_both_authored_by_deployer(db, fake_wt, capture):
+ """Deployer posts TWO comments (status + per-task summary) — both ``author='deployer'``."""
+ conn = get_db()
+ conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
+ conn.commit()
+ conn.close()
+
+ AgentLauncher()._post_usage_comments(
+ run_id=2, agent="deployer", repo=REPO, branch=BRANCH,
+ usage=None, duration_s=10,
+ )
+
+ assert len(capture) == 2
+ assert {c["author"] for c in capture} == {"deployer"}
+
+
+def test_tc19_headers_for_unknown_role_falls_back(monkeypatch):
+ """Ensure plane_sync._headers_for handles unknown agents (fallback contract)."""
+ from src import plane_sync
+ h = plane_sync._headers_for("unknown_role_xyz")
+ # PLANE_HEADERS fallback uses settings.plane_api_token (set to 'test-token').
+ assert isinstance(h, dict) and "X-API-Key" in h
+
+
+def test_tc19_status_comment_format_preserves_author_contract(db, fake_wt, capture):
+ """The ORCH-016 format change must not strip the author= kw from the call site."""
+ (fake_wt / "docs" / "work-items" / WID / "12-review.md").write_text(
+ "---\nverdict: APPROVE\n---\n",
+ )
+ AgentLauncher()._post_usage_comments(
+ run_id=3, agent="reviewer", repo=REPO, branch=BRANCH,
+ usage={"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0},
+ duration_s=180,
+ )
+ assert len(capture) == 1
+ post = capture[0]
+ assert post["author"] == "reviewer"
+ # And the new format is present in the body (sanity).
+ assert "\U0001f50e Reviewer" in post["body"]
+ assert "Verdict: APPROVE" in post["body"]
+ assert "Длительность: 3m 00s" in post["body"]
diff --git a/tests/test_status_comment_dedup_regression.py b/tests/test_status_comment_dedup_regression.py
new file mode 100644
index 0000000..74d13e6
--- /dev/null
+++ b/tests/test_status_comment_dedup_regression.py
@@ -0,0 +1,124 @@
+"""ORCH-016 / TC-17 + AC-7: status-comment de-dup contract.
+
+The «one comment per agent per stage» guarantee is enforced upstream of
+build_status_comment by:
+ - the webhook event-dedup table (events.delivery_id PARTIAL UNIQUE, ORCH-5 /
+ src.db.insert_event_dedup),
+ - the job queue claim-once contract (src.db.claim_next_job, ORCH-1).
+
+The ORCH-016 PR introduces a new comment FORMAT but must not weaken these
+guarantees. This regression test:
+ 1. exercises insert_event_dedup directly to confirm the same delivery_id is
+ accepted exactly once (sanity for the dedup primitive),
+ 2. exercises build_status_comment to confirm it is a PURE function (same
+ inputs -> same output), so a retried call from a poorly-isolated test or a
+ misbehaving caller doesn't silently produce two different comment bodies.
+"""
+
+import os
+import tempfile
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_dedup_regression.db")
+os.environ["ORCH_DB_PATH"] = _test_db
+
+import pytest # noqa: E402
+
+from src import db as db_module # noqa: E402
+from src.db import init_db, insert_event_dedup # noqa: E402
+
+
+@pytest.fixture(autouse=True)
+def setup_db(monkeypatch):
+ monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+ init_db()
+ yield
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+
+
+# ---------------------------------------------------------------------------
+# Primitive: event-dedup still rejects a re-delivered webhook.
+# ---------------------------------------------------------------------------
+def test_tc17_event_dedup_inserts_once_for_same_delivery_id():
+ """Two webhook deliveries with the same delivery_id -> one row inserted.
+
+ First call returns True (new row); second call returns False (rejected).
+ This is the primitive every status-comment trigger relies on.
+ """
+ assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is True
+ assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is False
+
+
+def test_tc17_event_dedup_distinguishes_delivery_ids():
+ """Distinct delivery IDs are independent — two different webhooks both go through."""
+ assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-A") is True
+ assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-B") is True
+
+
+# ---------------------------------------------------------------------------
+# Format: build_status_comment is deterministic. A double-fire from buggy code
+# still produces an IDENTICAL body -- so the upstream dedup primitive can
+# safely treat the second call as no-op without comparing prose.
+# ---------------------------------------------------------------------------
+def test_tc17_build_status_comment_is_pure(tmp_path):
+ """Same inputs produce byte-identical output (deterministic / side-effect free)."""
+ from src import usage as U
+
+ wt = tmp_path / "wt"
+ (wt / "docs" / "work-items" / "ET-016").mkdir(parents=True)
+ (wt / "docs" / "work-items" / "ET-016" / "12-review.md").write_text(
+ "---\nverdict: APPROVE\n---\n",
+ )
+
+ args = dict(
+ repo="enduro-trails",
+ branch="feature/ET-016-x",
+ work_item_id="ET-016",
+ duration_s=120,
+ worktree_root=str(wt),
+ usage={"input_tokens": 100, "output_tokens": 50, "cost_usd": 0.05},
+ )
+ a = U.build_status_comment("reviewer", **args)
+ b = U.build_status_comment("reviewer", **args)
+ c = U.build_status_comment("reviewer", **args)
+
+ assert a == b == c
+
+
+def test_tc17_build_status_comment_no_db_side_effects(tmp_path):
+ """A status-comment build must NOT write to the DB.
+
+ Otherwise a webhook-dedup hit would still touch state via the comment
+ builder. We check by counting rows in `tasks`/`agent_runs`/`jobs` before
+ and after.
+ """
+ from src import usage as U
+ from src.db import get_db
+
+ conn = get_db()
+ counts_before = [
+ conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
+ conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
+ conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
+ ]
+ conn.close()
+
+ U.build_status_comment(
+ "developer", repo="enduro-trails", branch="b",
+ work_item_id="ET-016", pr_number=1, duration_s=10,
+ usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
+ )
+
+ conn = get_db()
+ counts_after = [
+ conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
+ conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
+ conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
+ ]
+ conn.close()
+ assert counts_before == counts_after
diff --git a/tests/test_status_comment_duration_db_fallback.py b/tests/test_status_comment_duration_db_fallback.py
new file mode 100644
index 0000000..7109168
--- /dev/null
+++ b/tests/test_status_comment_duration_db_fallback.py
@@ -0,0 +1,145 @@
+"""ORCH-016 / TC-24 + TC-25 + AC-14: DB fallback for the duration line.
+
+When build_status_comment is called WITHOUT an explicit duration_s but with a
+task_id, it must:
+ - read the last finished agent_runs row for (task_id, agent),
+ - compute (julianday(finished_at) - julianday(started_at)) * 86400 in seconds,
+ - format it via fmt_duration and inject the «Длительность: …» line.
+
+Failure modes (DB locked / row missing / NULL finished_at / negative diff) must
+NEVER raise; they simply suppress the duration line and let the rest of the
+comment publish.
+"""
+
+import os
+import tempfile
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_duration_fallback.db")
+os.environ["ORCH_DB_PATH"] = _test_db
+
+import pytest # noqa: E402
+
+from src import db as db_module # noqa: E402
+from src.db import init_db, get_db # noqa: E402
+from src import usage as U # noqa: E402
+
+
+@pytest.fixture(autouse=True)
+def setup_db(monkeypatch):
+ monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+ init_db()
+ yield
+ if os.path.exists(_test_db):
+ os.unlink(_test_db)
+
+
+def _insert_run(task_id, agent, *, seconds_ago_start=None, finished=True):
+ """Insert an agent_runs row with controllable timestamps."""
+ conn = get_db()
+ if seconds_ago_start is None:
+ conn.execute(
+ "INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
+ (task_id, agent),
+ )
+ else:
+ if finished:
+ conn.execute(
+ "INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
+ "VALUES (?, ?, datetime('now', ?), datetime('now'))",
+ (task_id, agent, f"-{seconds_ago_start} seconds"),
+ )
+ else:
+ conn.execute(
+ "INSERT INTO agent_runs (task_id, agent, started_at) "
+ "VALUES (?, ?, datetime('now', ?))",
+ (task_id, agent, f"-{seconds_ago_start} seconds"),
+ )
+ conn.commit()
+ conn.close()
+
+
+# ---------------------------------------------------------------------------
+# TC-24: explicit duration_s missing -> DB lookup populates the line.
+# ---------------------------------------------------------------------------
+def test_tc24_fallback_reads_agent_runs_for_last_finished():
+ _insert_run(7, "reviewer", seconds_ago_start=240)
+ secs = U.get_agent_duration(7, "reviewer")
+ # SQLite's julianday math can be off by a second on either side.
+ assert secs is not None and abs(secs - 240) <= 1, secs
+
+ html = U.build_status_comment("reviewer", task_id=7)
+ assert any(
+ s in html for s in (
+ "Длительность: 4m 00s",
+ "Длительность: 4m 01s",
+ "Длительность: 3m 59s",
+ )
+ ), html
+
+
+def test_tc24_fallback_picks_last_run_when_multiple():
+ _insert_run(11, "developer", seconds_ago_start=120)
+ _insert_run(11, "developer", seconds_ago_start=10)
+ secs = U.get_agent_duration(11, "developer")
+ assert secs is not None and abs(secs - 10) <= 1, secs
+
+
+def test_tc24_no_row_returns_none():
+ assert U.get_agent_duration(999, "tester") is None
+
+
+def test_tc24_finished_at_null_returns_none():
+ _insert_run(13, "tester", seconds_ago_start=100, finished=False)
+ assert U.get_agent_duration(13, "tester") is None
+
+
+def test_tc24_missing_args_returns_none():
+ assert U.get_agent_duration(None, "tester") is None
+ assert U.get_agent_duration(7, "") is None
+ assert U.get_agent_duration(0, "tester") is None
+
+
+# ---------------------------------------------------------------------------
+# TC-25: read failure -> logged at debug, NO exception, comment still ships.
+# ---------------------------------------------------------------------------
+def test_tc25_db_read_failure_no_raise(monkeypatch, caplog):
+ """A locked / broken DB must not crash the status comment hot path."""
+ import logging
+
+ def _boom():
+ raise RuntimeError("simulated DB outage")
+
+ monkeypatch.setattr(U, "get_db", _boom)
+ with caplog.at_level(logging.DEBUG, logger="orchestrator.usage"):
+ assert U.get_agent_duration(1, "developer") is None
+ # build_status_comment must still publish (no duration line, no crash).
+ html = U.build_status_comment("developer", task_id=1, repo="r", branch="b")
+ assert "Длительность" not in html
+ assert "\U0001f4bb Developer" in html
+
+
+# ---------------------------------------------------------------------------
+# Sanity: explicit duration_s wins over DB fallback (no SELECT at all).
+# ---------------------------------------------------------------------------
+def test_explicit_duration_wins_over_db_fallback(monkeypatch):
+ called = {"n": 0}
+ real = U.get_agent_duration
+
+ def _spy(task_id, agent):
+ called["n"] += 1
+ return real(task_id, agent)
+
+ monkeypatch.setattr(U, "get_agent_duration", _spy)
+ _insert_run(5, "architect", seconds_ago_start=300)
+
+ html = U.build_status_comment(
+ "architect", task_id=5, duration_s=12, repo="r", branch="b",
+ )
+ assert "Длительность: 12s" in html
+ # Explicit value supplied -> DB fallback is short-circuited.
+ assert called["n"] == 0
diff --git a/tests/test_status_comment_format.py b/tests/test_status_comment_format.py
new file mode 100644
index 0000000..1b4556b
--- /dev/null
+++ b/tests/test_status_comment_format.py
@@ -0,0 +1,354 @@
+"""ORCH-016 / TC-01..TC-10, TC-12, TC-23: unified status comment format.
+
+Unit tests for src.usage.build_status_comment(...) — the single hot path for
+every agent's "I just finished a stage" comment in Plane (ADR-001).
+
+Covers:
+ * Header per agent (icon + role + description from AC-1..AC-5).
+ * Verdict / Status line read from frontmatter (reviewer / tester / deployer).
+ * Длительность line shown when duration_s is supplied; suppressed otherwise.
+ * link items per agent.
+ * URL base picks gitea_public_url, falls back to gitea_url.
+ * Graceful behaviour when files are missing / no frontmatter (AC-8).
+
+No DB / no network — only the worktree filesystem (via tmp_path).
+"""
+
+import os
+
+os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
+os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
+
+import pytest # noqa: E402
+
+from src import usage as U # noqa: E402
+
+
+WID = "ET-016"
+REPO = "enduro-trails"
+BRANCH = "feature/ET-016-status-comments"
+
+
+@pytest.fixture(autouse=True)
+def _set_urls(monkeypatch):
+ """gitea_public_url is the canonical clickable base (AC-9)."""
+ monkeypatch.setattr(U, "logger", U.logger)
+ from src.config import settings
+ monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
+ monkeypatch.setattr(
+ settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
+ )
+ monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
+ yield
+
+
+def _wt_with_files(tmp_path, files: dict) -> str:
+ """Create a worktree skeleton with given files. `files` maps rel-path -> body."""
+ base = tmp_path / "wt"
+ docs = base / "docs" / "work-items" / WID
+ docs.mkdir(parents=True)
+ for rel, body in files.items():
+ p = docs / rel if not rel.startswith("/") else base / rel.lstrip("/")
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(body)
+ return str(base)
+
+
+# ---------------------------------------------------------------------------
+# TC-01: architect comment
+# ---------------------------------------------------------------------------
+def test_tc01_architect_comment(tmp_path):
+ wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
+
+ html = U.build_status_comment(
+ "architect",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=312,
+ worktree_root=wt,
+ )
+ # Header
+ assert "\U0001f4d0 Architect — " in html, html
+ assert "архитектурную" in html
+ assert "См. ADR ниже" in html
+ # Duration: 312s -> 5m 12s
+ assert "Длительность: 5m 12s" in html
+ # ADR link via gitea_public_url
+ assert ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
+ f"{BRANCH}/docs/work-items/{WID}/06-adr") in html
+ # No Verdict for architect
+ assert "Verdict" not in html
+ assert "Status:" not in html
+
+
+# ---------------------------------------------------------------------------
+# TC-02: developer comment with PR + branch
+# ---------------------------------------------------------------------------
+def test_tc02_developer_comment_links_branch_and_pr():
+ html = U.build_status_comment(
+ "developer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ pr_number=42, duration_s=600,
+ )
+ assert "\U0001f4bb Developer — " in html
+ assert "разработку" in html
+ # Both branch and PR links
+ assert f"https://git.mva154.duckdns.org/admin/{REPO}/src/branch/{BRANCH}" in html
+ assert f"https://git.mva154.duckdns.org/admin/{REPO}/pulls/42" in html
+ assert f"PR #42" in html
+ assert "Длительность: 10m 00s" in html
+ assert "Verdict" not in html
+
+
+# ---------------------------------------------------------------------------
+# TC-03 / TC-04: reviewer verdict via frontmatter
+# ---------------------------------------------------------------------------
+def test_tc03_reviewer_verdict_approve(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "12-review.md": "---\nverdict: APPROVE\n---\nbody...",
+ })
+ html = U.build_status_comment(
+ "reviewer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=120, worktree_root=wt,
+ )
+ assert "\U0001f50e Reviewer — " in html
+ assert "Verdict: APPROVE" in html
+ assert "Длительность: 2m 00s" in html
+ assert "12-review.md" in html
+
+
+def test_tc04_reviewer_verdict_request_changes(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "12-review.md": "---\nverdict: REQUEST_CHANGES\n---\nblockers...",
+ })
+ html = U.build_status_comment(
+ "reviewer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=45, worktree_root=wt,
+ )
+ assert "Verdict: REQUEST_CHANGES" in html
+ assert "Длительность: 45s" in html
+
+
+# ---------------------------------------------------------------------------
+# TC-05: reviewer with NO 12-review.md -> graceful (no Verdict, no Review link)
+# but Длительность and header still present.
+# ---------------------------------------------------------------------------
+def test_tc05_reviewer_missing_artifact_graceful(tmp_path):
+ wt = _wt_with_files(tmp_path, {}) # empty docs dir
+ html = U.build_status_comment(
+ "reviewer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=30, worktree_root=wt,
+ )
+ assert "\U0001f50e Reviewer — " in html
+ assert "Verdict" not in html
+ # Link to 12-review.md is dropped (AC-8 graceful).
+ assert "12-review.md" not in html
+ # Duration still printed when known.
+ assert "Длительность: 30s" in html
+
+
+# ---------------------------------------------------------------------------
+# TC-06 / TC-07: tester verdict via frontmatter (verdict OR status)
+# ---------------------------------------------------------------------------
+def test_tc06_tester_pass(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "13-test-report.md": "---\nverdict: PASS\n---\n",
+ })
+ html = U.build_status_comment(
+ "tester",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=240, worktree_root=wt,
+ )
+ assert "\U0001f9ea Tester — " in html
+ assert "Verdict: PASS" in html
+ assert "Длительность: 4m 00s" in html
+ assert "13-test-report.md" in html
+
+
+def test_tc07_tester_fail(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "13-test-report.md": "---\nverdict: FAIL\n---\n",
+ })
+ html = U.build_status_comment(
+ "tester",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=240, worktree_root=wt,
+ )
+ assert "Verdict: FAIL" in html
+ assert "Длительность: 4m 00s" in html
+
+
+def test_tc07b_tester_falls_back_to_status_key(tmp_path):
+ # Some testers used `status:` instead of `verdict:` (ET-006 / ET-008 pattern).
+ wt = _wt_with_files(tmp_path, {
+ "13-test-report.md": "---\nstatus: PASSED\n---\n",
+ })
+ html = U.build_status_comment(
+ "tester",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=10, worktree_root=wt,
+ )
+ assert "Verdict: PASSED" in html
+
+
+# ---------------------------------------------------------------------------
+# TC-08 / TC-09: deployer status via frontmatter
+# ---------------------------------------------------------------------------
+def test_tc08_deployer_deploy_status_success(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "14-deploy-log.md": "---\ndeploy_status: SUCCESS\n---\n",
+ })
+ html = U.build_status_comment(
+ "deployer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ stage="deploy", duration_s=120, worktree_root=wt,
+ )
+ assert "\U0001f680 Deployer — " in html
+ assert "Status: SUCCESS" in html
+ assert "Длительность: 2m 00s" in html
+ assert "14-deploy-log.md" in html
+
+
+def test_tc09_deployer_staging_status_success(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "15-staging-log.md": "---\nstaging_status: SUCCESS\n---\n",
+ })
+ html = U.build_status_comment(
+ "deployer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ stage="deploy-staging", duration_s=60, worktree_root=wt,
+ )
+ assert "Status: SUCCESS" in html
+ assert "Длительность: 1m 00s" in html
+ # The staging-stage helper links 15-staging-log.md, not 14-deploy-log.md.
+ assert "15-staging-log.md" in html
+ assert "14-deploy-log.md" not in html
+
+
+def test_deployer_status_failed_drives_status_line(tmp_path):
+ wt = _wt_with_files(tmp_path, {
+ "14-deploy-log.md": "---\ndeploy_status: FAILED\n---\n",
+ })
+ html = U.build_status_comment(
+ "deployer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ stage="deploy", duration_s=5, worktree_root=wt,
+ )
+ assert "Status: FAILED" in html
+
+
+# ---------------------------------------------------------------------------
+# TC-10: gitea_public_url is preferred; falls back to gitea_url when empty.
+# ---------------------------------------------------------------------------
+def test_tc10_url_fallback_to_gitea_url(monkeypatch):
+ from src.config import settings
+ monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
+ monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
+ html = U.build_status_comment(
+ "developer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ pr_number=7, duration_s=15,
+ )
+ assert "http://localhost:3000/admin/enduro-trails/pulls/7" in html
+ # And the public URL is not there because it was empty.
+ assert "git.mva154.duckdns.org" not in html
+
+
+# ---------------------------------------------------------------------------
+# TC-12: frontmatter parser is graceful — missing file / empty / bad YAML -> None
+# (the comment still publishes the header + duration, just no Verdict / Status).
+# ---------------------------------------------------------------------------
+def test_tc12_frontmatter_missing_file_no_crash(tmp_path):
+ from src.frontmatter import read_frontmatter_value
+ assert read_frontmatter_value(str(tmp_path / "nope.md"), "verdict") is None
+
+
+def test_tc12_frontmatter_empty_no_crash(tmp_path):
+ p = tmp_path / "empty.md"
+ p.write_text("")
+ from src.frontmatter import read_frontmatter_value
+ assert read_frontmatter_value(str(p), "verdict") is None
+
+
+def test_tc12_frontmatter_bad_yaml_no_crash(tmp_path):
+ p = tmp_path / "bad.md"
+ p.write_text("---\nverdict: [unterminated\n---\nbody")
+ from src.frontmatter import read_frontmatter_value
+ assert read_frontmatter_value(str(p), "verdict") is None
+
+
+def test_tc12_frontmatter_missing_key_returns_none(tmp_path):
+ p = tmp_path / "ok.md"
+ p.write_text("---\nother: value\n---\nbody")
+ from src.frontmatter import read_frontmatter_value
+ assert read_frontmatter_value(str(p), "verdict") is None
+
+
+# ---------------------------------------------------------------------------
+# TC-23: duration_s=None and no task_id -> the Длительность line is OMITTED.
+# Header / description / artifact links remain.
+# ---------------------------------------------------------------------------
+def test_tc23_no_duration_no_line(tmp_path):
+ wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
+ html_none = U.build_status_comment(
+ "architect",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ duration_s=None, worktree_root=wt,
+ )
+ html_default = U.build_status_comment(
+ "architect",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ worktree_root=wt,
+ )
+ for html in (html_none, html_default):
+ assert "Длительность" not in html
+ # But the header, description and ADR link are still there.
+ assert "\U0001f4d0 Architect — " in html
+ assert "архитектурную" in html
+ assert "06-adr" in html
+
+
+# ---------------------------------------------------------------------------
+# Extra: usage tail is rendered as when non-zero, suppressed otherwise.
+# (Backs up ADR-001 §3 and keeps the old usage_comment test contract.)
+# ---------------------------------------------------------------------------
+def test_usage_tail_rendered_when_non_zero():
+ html = U.build_status_comment(
+ "developer",
+ repo=REPO, branch=BRANCH, work_item_id=WID,
+ usage={"input_tokens": 45231, "output_tokens": 12100, "cost_usd": 0.21},
+ )
+ assert "" in html and "" in html
+ assert "45.2k in" in html
+ assert "12.1k out" in html
+ assert "$0.21" in html
+
+
+def test_usage_tail_suppressed_when_all_zero():
+ html = U.build_status_comment("developer", repo=REPO, branch=BRANCH)
+ assert "" not in html
+
+
+# ---------------------------------------------------------------------------
+# AC-1 / AC-5 literal strings — fixed wording per role.
+# ---------------------------------------------------------------------------
+def test_ac1_architect_header_literal():
+ html = U.build_status_comment("architect", repo=REPO, branch=BRANCH,
+ work_item_id=WID, duration_s=10)
+ assert "\U0001f4d0 Architect — " in html
+
+
+def test_ac5_deployer_deploy_description():
+ html = U.build_status_comment(
+ "deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy",
+ )
+ assert "прод-деплой" in html
+
+
+def test_ac5_deployer_staging_description():
+ html = U.build_status_comment(
+ "deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy-staging",
+ )
+ assert "staging-деплой" in html