feat(plane): unified status-comment format with duration line (ORCH-016) (#34)
This commit was merged in pull request #34.
This commit is contained in:
@@ -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 <ul> of artifact links scoped per agent,
|
||||
* a ``<sub>`` 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,
|
||||
)
|
||||
|
||||
75
src/frontmatter.py
Normal file
75
src/frontmatter.py
Normal file
@@ -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: ...
|
||||
---
|
||||
<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
|
||||
@@ -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}/<file>
|
||||
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'<li><a href="{href}">{label}</a></li>')
|
||||
|
||||
if links:
|
||||
text += "<br><b>\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b:</b><ul>" + "".join(links) + "</ul>"
|
||||
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)
|
||||
|
||||
543
src/usage.py
543
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 <li><a>...</a></li> 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 <ul> 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 ``<li><a href="...">label</a></li>`` items;
|
||||
the caller wraps them in ``<ul>...</ul>``. 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'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}">'
|
||||
f"Branch {branch}</a></li>"
|
||||
)
|
||||
if pr_number:
|
||||
links.append(
|
||||
f"\U0001f517 [PR #{pr_number}]({base}/{owner}/{repo}/pulls/{pr_number})"
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/pulls/{pr_number}">'
|
||||
f"PR #{pr_number}</a></li>"
|
||||
)
|
||||
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'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{adr_rel}">ADR</a></li>'
|
||||
)
|
||||
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'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{rel_dir}/{fname}">{label}</a></li>'
|
||||
)
|
||||
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'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{rel_dir}/{fname}">{label}</a></li>'
|
||||
)
|
||||
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/<wid>/).
|
||||
# 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: <VALUE>'
|
||||
tester -> 13-test-report.md verdict: (or status:) -> 'Verdict: <VALUE>'
|
||||
deployer -> deploy-staging -> 15-staging-log.md staging_status: -> 'Status: <VALUE>'
|
||||
else (deploy) -> 14-deploy-log.md deploy_status: -> 'Status: <VALUE>'
|
||||
|
||||
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 <li><a>...</a></li> 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'<li><a href="{href}">{label}</a></li>')
|
||||
return items
|
||||
|
||||
|
||||
def _usage_tail(usage: dict | None) -> str | None:
|
||||
"""Render the technical token/cost tail (``<sub>...</sub>``) or None when empty.
|
||||
|
||||
Format (ADR-001 §3): ``<sub>{fmt_in} / {out} out · {cost}</sub>``.
|
||||
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"<sub>{fmt_in(usage)} / {fmt_tokens(out)} out · {fmt_cost(cost)}</sub>"
|
||||
|
||||
|
||||
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 ``<br>``::
|
||||
|
||||
{ICON} {RoleName} — {DESCRIPTION}
|
||||
[Verdict|Status: VALUE] # reviewer/tester/deployer + FM
|
||||
[Длительность: 4m 12s]
|
||||
<b>Документы:</b><ul><li><a href="...">label</a></li>...</ul>
|
||||
[<sub>8.5M in / 45.8k out · $7.29</sub>]
|
||||
|
||||
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/<id>/ 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 ``<sub>`` 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(
|
||||
"<b>Документы:</b><ul>"
|
||||
+ "".join(doc_items)
|
||||
+ "</ul>"
|
||||
)
|
||||
|
||||
tail = _usage_tail(usage)
|
||||
if tail:
|
||||
lines.append(tail)
|
||||
|
||||
return "<br>".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)
|
||||
|
||||
Reference in New Issue
Block a user