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.
412 lines
14 KiB
Python
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:]
|