Files
Agent-System/scripts/agentctl_visibility.py
T
weichen 0d1840da96 feat(agentctl): governance visibility P0 — table/brief/archive + MCP reads
Add human-readable --format table, one-page brief, claim archive via docs
apply CAS, and four MCP readonly tools. Includes P1 console design doc
and engineering-tool test evidence.
2026-09-24 13:25:57 +00:00

412 lines
14 KiB
Python

#!/usr/bin/env python3
"""Pure rendering / brief / CURRENT claim-archive helpers for agentctl.
No I/O against the live repo except via callers. Safe to unit-test in isolation.
"""
from __future__ import annotations
import datetime as _dt
import re
from typing import Any, Iterable, Optional
TASK_DATE_RE = re.compile(r'(?<!\d)(20\d{2})-(\d{2})-(\d{2})(?!\d)')
CLAIM_HEADER = '| owner | task | branch | worktree | mutex | status |'
CLAIM_SECTION = '## 并行认领'
def format_table(headers: Iterable[str], rows: Iterable[Iterable[Any]]) -> str:
"""Render a plain monospace table. Empty rows → header-only block."""
cols = [str(h) for h in headers]
body = [[_cell(v) for v in row] for row in rows]
widths = [len(c) for c in cols]
for row in body:
for i, cell in enumerate(row):
if i < len(widths):
widths[i] = max(widths[i], len(cell))
else:
widths.append(len(cell))
while len(row) < len(widths):
row.append('')
def line(cells):
return ' '.join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
out = [line(cols), line(['-' * w for w in widths])]
out.extend(line(row) for row in body)
return '\n'.join(out)
def _cell(value: Any) -> str:
if value is None:
return '-'
text = str(value)
return text.replace('\n', ' ').strip() or '-'
def _duration(created: Any, now: Optional[float] = None) -> str:
try:
ts = float(created)
except (TypeError, ValueError):
return '-'
age = (now if now is not None else _dt.datetime.now().timestamp()) - ts
if age < 0:
age = 0
if age < 60:
return f'{int(age)}s'
if age < 3600:
return f'{int(age // 60)}m'
if age < 86400:
return f'{int(age // 3600)}h'
return f'{int(age // 86400)}d'
def render_status_table(payload: dict, *, now: Optional[float] = None) -> str:
"""Human table for `agentctl status` payload."""
lines = [
f"agentctl status main={payload.get('main', '-')} "
f"epoch={_short(payload.get('epoch'))} version={payload.get('version', '-')}",
'',
]
tasks = payload.get('tasks') or []
active = [t for t in tasks if t.get('state') not in ('done', 'cancelled')]
terminal = [t for t in tasks if t.get('state') in ('done', 'cancelled')]
lines.append(f'## tasks active={len(active)} closed={len(terminal)} total={len(tasks)}')
task_rows = [
[
t.get('id'),
t.get('owner'),
t.get('state'),
_short_path(t.get('worktree')),
_short(t.get('branch')),
]
for t in active
]
lines.append(format_table(['task', 'owner', 'state', 'worktree', 'branch'], task_rows) if task_rows
else '(no active tasks)')
lines.append('')
runs = payload.get('runs') or []
lines.append(f'## runs live={len(runs)}')
run_rows = [
[
r.get('id'),
r.get('task'),
r.get('state'),
r.get('supervisor_alive'),
_short(r.get('kind') or r.get('action')),
]
for r in runs
]
lines.append(format_table(['run', 'task', 'state', 'alive', 'kind'], run_rows) if run_rows
else '(no live runs)')
lines.append('')
queue = payload.get('queue') or []
lines.append(f'## queue depth={len(queue)}')
q_rows = [
[
q.get('id'),
q.get('task'),
q.get('state'),
q.get('priority'),
_short(q.get('head_sha')),
_duration(q.get('created'), now),
]
for q in queue
]
lines.append(format_table(['id', 'task', 'state', 'pri', 'head', 'age'], q_rows) if q_rows
else '(queue empty)')
policy = payload.get('policy') or {}
lines.append('')
lines.append(
f"## policy loaded={policy.get('loaded', policy.get('status', '-'))} "
f"revision={policy.get('policy_revision') or policy.get('revision') or '-'}"
)
return '\n'.join(lines) + '\n'
def render_queue_table(payload: dict, *, now: Optional[float] = None) -> str:
items = payload.get('items') or []
rows = [
[
i.get('id'),
i.get('task'),
i.get('state'),
i.get('priority'),
_short(i.get('head_sha')),
_short(i.get('expected_dev')),
_duration(i.get('created'), now),
]
for i in items
]
header = f"queue list count={len(items)}\n"
body = format_table(['id', 'task', 'state', 'pri', 'head', 'expected_dev', 'age'], rows) if rows \
else '(queue empty)'
return header + body + '\n'
def render_claim_table(payload: dict, *, now: Optional[float] = None) -> str:
claims = payload.get('claims') or []
rows = [
[
c.get('id') or c.get('resource'),
c.get('task'),
c.get('resource'),
c.get('state'),
_duration(c.get('updated') or c.get('created'), now),
]
for c in claims
]
header = f"claim list count={len(claims)}\n"
body = format_table(['id', 'task', 'resource', 'state', 'age'], rows) if rows else '(no claims)'
return header + body + '\n'
def render_cleanup_table(payload: dict) -> str:
trees = payload.get('worktrees') or []
ready = [t for t in trees if t.get('cleanup_allowed') or t.get('status') == 'allowed']
blocked = [t for t in trees if t not in ready]
rows = []
for t in trees:
status = 'allowed' if (t.get('cleanup_allowed') or t.get('status') == 'allowed') else 'blocked'
reason = t.get('blocked_reason') or t.get('reason') or ''
if not reason and isinstance(t.get('gates'), list):
fails = [g.get('name') for g in t['gates'] if g.get('status') != 'pass']
reason = ','.join(fails[:3])
rows.append([
_short_path(t.get('path') or t.get('worktree')),
status,
t.get('cleanup_owner') or '-',
reason or '-',
])
header = (
f"cleanup report total={len(trees)} allowed={len(ready)} "
f"blocked/pending={len(blocked)}\n"
)
body = format_table(['path', 'status', 'owner', 'reason'], rows) if rows else '(no worktrees)'
return header + body + '\n'
def build_brief(
*,
status: Optional[dict] = None,
docs_drift: Optional[dict] = None,
cleanup: Optional[dict] = None,
catalog_drift: Optional[dict] = None,
daemon: Optional[dict] = None,
now: Optional[float] = None,
) -> str:
"""Aggregate read-only sources into a one-page Markdown briefing."""
status = status or {}
docs_drift = docs_drift or {}
cleanup = cleanup or {}
catalog_drift = catalog_drift or {}
daemon = daemon or {}
tasks = status.get('tasks') or []
active = [t for t in tasks if t.get('state') not in ('done', 'cancelled')]
blocked = [t for t in active if t.get('state') == 'blocked']
runs = status.get('runs') or []
queue = status.get('queue') or []
trees = cleanup.get('worktrees') or []
pending_cleanup = [
t for t in trees
if not (t.get('cleanup_allowed') or t.get('status') == 'allowed')
]
regressions = docs_drift.get('regressions') or catalog_drift.get('regressions') or []
hard = [r for r in regressions if r.get('severity') == 'hard' or 'done' in str(r.get('message', ''))]
stamp = _dt.datetime.fromtimestamp(now or _dt.datetime.now().timestamp()).strftime('%Y-%m-%d %H:%M')
lines = [
f'# agentctl brief ({stamp})',
'',
f"- daemon: **{daemon.get('status', 'unknown')}** "
f"manager.lock.held={((daemon.get('manager_lock') or {}).get('held'))} "
f"queue pending={((daemon.get('queue') or {}).get('pending', '-'))}",
f"- tasks: active={len(active)} blocked={len(blocked)} "
f"live runs={len(runs)} integrate queue={len(queue)}",
f"- cleanup pending: {len(pending_cleanup)} / {len(trees)} worktrees",
f"- MATRIX drift hard alerts: {len(hard)}",
'',
'## Active tasks',
]
if active:
for t in active[:20]:
lines.append(
f"- `{t.get('id')}` owner={t.get('owner')} state={t.get('state')} "
f"wt={_short_path(t.get('worktree'))}"
)
if len(active) > 20:
lines.append(f'- … and {len(active) - 20} more')
else:
lines.append('- (none)')
lines.append('')
lines.append('## Blockers / alerts')
if blocked:
for t in blocked:
lines.append(f"- blocked task `{t.get('id')}`")
if hard:
for r in hard[:10]:
lines.append(
f"- MATRIX regression `{r.get('record_id')}`: "
f"{r.get('previous')}→{r.get('current')}"
)
if pending_cleanup:
lines.append(f"- cleanup backlog: {len(pending_cleanup)} worktrees not removable")
if not blocked and not hard and not pending_cleanup:
lines.append('- (none)')
lines.append('')
lines.append('## Integrate queue')
if queue:
for q in queue[:10]:
lines.append(
f"- `{q.get('task')}` state={q.get('state')} pri={q.get('priority')} "
f"head={_short(q.get('head_sha'))}"
)
else:
lines.append('- (empty)')
return '\n'.join(lines) + '\n'
def parse_current_claim_rows(content: str) -> list[dict]:
"""Extract parallel-claim table rows from CURRENT.md body."""
section = _claim_section_body(content)
if section is None:
return []
rows = []
for line in section.splitlines():
stripped = line.strip()
if not stripped.startswith('|'):
continue
cells = [c.strip() for c in stripped.strip('|').split('|')]
if not cells or len(cells) < 6:
continue
if cells[0].casefold() in ('owner', '---') or set(cells[0]) <= {'-', ':'}:
continue
if all(re.fullmatch(r':?-+:?', c.replace(' ', '') or '-') for c in cells):
continue
rows.append({
'owner': cells[0],
'task': cells[1],
'branch': cells[2],
'worktree': cells[3],
'mutex': cells[4],
'status': cells[5],
'line': stripped,
})
return rows
def parse_task_date(task_id: str) -> Optional[_dt.date]:
match = TASK_DATE_RE.search(task_id or '')
if not match:
return None
try:
return _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
except ValueError:
return None
def select_archive_candidates(
rows: list[dict],
*,
retain_days: int = 7,
today: Optional[_dt.date] = None,
include_undated: bool = False,
) -> list[dict]:
"""Pick status=done rows older than retain_days (by date embedded in task id)."""
today = today or _dt.date.today()
cutoff = today - _dt.timedelta(days=max(0, int(retain_days)))
selected = []
for row in rows:
if (row.get('status') or '').strip() != 'done':
continue
parsed = parse_task_date(row.get('task') or '')
entry = dict(row)
entry['parsed_date'] = parsed.isoformat() if parsed else None
if parsed is None:
entry['eligible'] = bool(include_undated)
entry['reason'] = 'undated' if include_undated else 'undated_skipped'
selected.append(entry)
continue
if parsed <= cutoff:
entry['eligible'] = True
entry['reason'] = f'older_than_{retain_days}d'
else:
entry['eligible'] = False
entry['reason'] = 'within_retain_window'
selected.append(entry)
return selected
def build_archived_current(content: str, remove_task_ids: Iterable[str]) -> str:
"""Return CURRENT.md with listed claim rows removed from the parallel table."""
remove = set(remove_task_ids)
start, end = _claim_section_span(content)
if start is None:
raise ValueError('CURRENT.md missing ## 并行认领 section')
lines = content.splitlines(keepends=True)
kept = []
for line in lines[start:end]:
stripped = line.strip()
if stripped.startswith('|'):
cells = [c.strip() for c in stripped.strip('|').split('|')]
if len(cells) >= 2 and cells[1] in remove:
continue
kept.append(line)
return ''.join(lines[:start] + kept + lines[end:])
def build_archive_section(rows: list[dict], archive_date: str) -> str:
"""Markdown fragment to append to the CURRENT history archive."""
lines = [
'',
f'## 并行认领归档 {archive_date}',
'',
f'由 `agentctl claim archive` 从 CURRENT 迁出(status=done)。共 {len(rows)} 行。',
'',
CLAIM_HEADER,
'|---|---|---|---|---|---|',
]
for row in rows:
lines.append(
f"| {row.get('owner', '')} | {row.get('task', '')} | {row.get('branch', '')} | "
f"{row.get('worktree', '')} | {row.get('mutex', '')} | {row.get('status', '')} |"
)
lines.append('')
return '\n'.join(lines)
def _claim_section_body(content: str) -> Optional[str]:
start, end = _claim_section_span(content)
if start is None:
return None
return ''.join(content.splitlines(keepends=True)[start:end])
def _claim_section_span(content: str) -> tuple[Optional[int], Optional[int]]:
lines = content.splitlines(keepends=True)
start = None
for i, line in enumerate(lines):
if line.startswith(CLAIM_SECTION):
start = i
break
if start is None:
return None, None
end = len(lines)
for j in range(start + 1, len(lines)):
if lines[j].startswith('## '):
end = j
break
return start, end
def _short(value: Any, n: int = 12) -> str:
if value is None:
return '-'
text = str(value)
return text if len(text) <= n else text[:n]
def _short_path(value: Any) -> str:
if not value:
return '-'
text = str(value).replace('\\', '/')
if '/.worktrees/' in text:
return '.worktrees/' + text.split('/.worktrees/', 1)[1]
return text if len(text) <= 48 else '…' + text[-47:]