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:
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