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.
This commit is contained in:
+313
-25
@@ -27,6 +27,7 @@ sys.dont_write_bytecode = True
|
||||
os.environ.setdefault('PYTHONDONTWRITEBYTECODE', '1')
|
||||
import agentctl_w4 as w4 # noqa: E402
|
||||
import agentctl_catalog as catalog # noqa: E402
|
||||
import agentctl_visibility as visibility # noqa: E402
|
||||
|
||||
VERSION = 1
|
||||
POLICY_REL = 'configs/agent-governance.json'
|
||||
@@ -163,6 +164,18 @@ def emit(value, stream=sys.stdout):
|
||||
print(json.dumps(value, ensure_ascii=False, sort_keys=True), file=stream, flush=True)
|
||||
|
||||
|
||||
def emit_payload(payload, fmt='json', renderer=None, stream=sys.stdout):
|
||||
"""Emit JSON (default) or a human table via an optional renderer callable."""
|
||||
if (fmt or 'json') == 'table' and renderer is not None:
|
||||
text = renderer(payload)
|
||||
if not text.endswith('\n'):
|
||||
text += '\n'
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
return
|
||||
emit(payload, stream)
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(value.encode() if isinstance(value, str) else value).hexdigest()
|
||||
|
||||
@@ -1781,7 +1794,8 @@ def queue_list(repo, args):
|
||||
states).fetchall()
|
||||
else:
|
||||
rows = db.execute('SELECT * FROM queue_items ORDER BY created ASC').fetchall()
|
||||
emit({'status': 'ok', 'items': [queue_row(r) for r in rows]})
|
||||
payload = {'status': 'ok', 'items': [queue_row(r) for r in rows]}
|
||||
emit_payload(payload, getattr(args, 'format', 'json'), visibility.render_queue_table)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -2211,7 +2225,8 @@ def cleanup_report(repo, args=None):
|
||||
item['cleanup_owner'] = (active[0]['owner'] if active
|
||||
else (rows[0]['owner'] if rows else None))
|
||||
record_audit(repo, 'cleanup_report', payload)
|
||||
emit(payload)
|
||||
emit_payload(payload, getattr(args, 'format', 'json') if args is not None else 'json',
|
||||
visibility.render_cleanup_table)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -2457,7 +2472,280 @@ def claim_list(repo, args):
|
||||
"SELECT * FROM claims WHERE state='held' ORDER BY updated DESC").fetchall()]
|
||||
except sqlite3.Error:
|
||||
rows = []
|
||||
emit({'status': 'ok', 'claims': rows})
|
||||
payload = {'status': 'ok', 'claims': rows}
|
||||
emit_payload(payload, getattr(args, 'format', 'json'), visibility.render_claim_table)
|
||||
return 0
|
||||
|
||||
|
||||
def claim_archive(repo, args):
|
||||
"""Migrate done CURRENT claim rows to archive via docs apply @document CAS."""
|
||||
task = task_row(repo, args.task)
|
||||
if repo.current != Path(task['worktree']) or repo.current == repo.main:
|
||||
deny('WORKTREE_MISMATCH', 'claim archive must run inside the task worktree')
|
||||
caps = json.loads(task['capabilities']) if isinstance(task['capabilities'], str) else task['capabilities']
|
||||
if 'docs:admin' not in caps:
|
||||
deny('CAPABILITY_REQUIRED', 'claim archive rewrites CURRENT via @document and needs docs:admin')
|
||||
current_rel = '.doc/ops/CURRENT.md'
|
||||
archive_rel = '.doc/archive/ops/2026-09-21-current-history.md'
|
||||
current_path = safe_path(repo.current, current_rel)
|
||||
content = current_path.read_text(encoding='utf-8')
|
||||
rows = visibility.parse_current_claim_rows(content)
|
||||
selected = visibility.select_archive_candidates(
|
||||
rows,
|
||||
retain_days=getattr(args, 'retain_days', 7),
|
||||
include_undated=bool(getattr(args, 'include_undated', False)),
|
||||
)
|
||||
eligible = [r for r in selected if r.get('eligible')]
|
||||
payload = {
|
||||
'status': 'dry_run',
|
||||
'retain_days': getattr(args, 'retain_days', 7),
|
||||
'include_undated': bool(getattr(args, 'include_undated', False)),
|
||||
'done_scanned': len(selected),
|
||||
'eligible_count': len(eligible),
|
||||
'candidates': eligible,
|
||||
'skipped': [r for r in selected if not r.get('eligible')],
|
||||
'archive_file': archive_rel,
|
||||
}
|
||||
if not getattr(args, 'apply', False):
|
||||
emit(payload)
|
||||
return 0
|
||||
if not eligible:
|
||||
emit({**payload, 'status': 'noop', 'message': 'no eligible done rows to archive'})
|
||||
return 0
|
||||
remove_ids = [r['task'] for r in eligible]
|
||||
new_content = visibility.build_archived_current(content, remove_ids)
|
||||
archive_date = time.strftime('%Y-%m-%d', time.localtime())
|
||||
section = visibility.build_archive_section(eligible, archive_date)
|
||||
request = {
|
||||
'request_id': f"{args.task}-claim-archive-{archive_date.replace('-', '')}",
|
||||
'changes': [{
|
||||
'file': current_rel,
|
||||
'record_id': '@document',
|
||||
'expected_sha256': digest(content),
|
||||
'replacement': new_content,
|
||||
}],
|
||||
}
|
||||
req_path = Path(tempfile.mkstemp(prefix='agentctl-claim-archive-', suffix='.json')[1])
|
||||
try:
|
||||
req_path.write_text(json.dumps(request, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
apply_args = argparse.Namespace(task=args.task, request=str(req_path))
|
||||
rc = docs_apply(repo, apply_args)
|
||||
if rc not in (0, None):
|
||||
return rc
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
req_path.unlink()
|
||||
archive_path = safe_path(repo.current, archive_rel)
|
||||
with Lease(repo, task, [f'path:{archive_rel}'], f'{args.task}-archive-append',
|
||||
digest(section)) as lease:
|
||||
if lease.replay:
|
||||
emit({'status': 'replayed', 'run_id': lease.request_id})
|
||||
return lease.row['exit_code'] or 0
|
||||
existing = archive_path.read_text(encoding='utf-8') if archive_path.is_file() else ''
|
||||
atomic_write(archive_path, existing.rstrip() + '\n' + section)
|
||||
lease.finish()
|
||||
emit({
|
||||
'status': 'archived',
|
||||
'archived_count': len(eligible),
|
||||
'tasks': remove_ids,
|
||||
'archive_file': archive_rel,
|
||||
'current_sha256': digest(new_content),
|
||||
})
|
||||
return 0
|
||||
|
||||
|
||||
def collect_status_payload(repo):
|
||||
identity = repo.identity()
|
||||
policy = policy_status(repo.main)
|
||||
try:
|
||||
with repo.db(True) as db:
|
||||
ensure_schema(db)
|
||||
except Denied:
|
||||
pass
|
||||
with repo.db() as db:
|
||||
tasks = [dict(r) for r in db.execute('SELECT * FROM tasks')]
|
||||
runs = [dict(r) for r in db.execute(
|
||||
"SELECT * FROM runs WHERE state IN ('running','queued','recovery_required','busy','suspect','quarantine')")]
|
||||
try:
|
||||
queue = [dict(r) for r in db.execute(
|
||||
"SELECT * FROM queue_items WHERE state IN ('pending','claimed') ORDER BY priority DESC, created")]
|
||||
except sqlite3.Error:
|
||||
queue = []
|
||||
for row in runs:
|
||||
row['supervisor_alive'] = alive_status(row['pid'], row['start'], row['boot'])
|
||||
return {
|
||||
'status': 'ok',
|
||||
'lock_root': str(repo.root),
|
||||
'main': str(repo.main),
|
||||
'version': VERSION,
|
||||
'epoch': identity['epoch'],
|
||||
'tasks': tasks,
|
||||
'runs': runs,
|
||||
'queue': queue,
|
||||
'policy': policy,
|
||||
}
|
||||
|
||||
|
||||
def collect_cleanup_payload(repo):
|
||||
import agent_worktree as aw # noqa: E402
|
||||
payload = aw.build_cleanup_report(repo.main, repo.main)
|
||||
with repo.db() as db:
|
||||
try:
|
||||
ensure_schema(db)
|
||||
except Exception:
|
||||
pass
|
||||
for item in payload.get('worktrees', []):
|
||||
path = item.get('path')
|
||||
if not path:
|
||||
continue
|
||||
rows = [dict(r) for r in db.execute(
|
||||
'SELECT id,owner,state FROM tasks WHERE worktree=?', (path,)).fetchall()]
|
||||
item['tasks'] = rows
|
||||
active = [r for r in rows if r.get('state') == 'active']
|
||||
item['cleanup_owner'] = (active[0]['owner'] if active
|
||||
else (rows[0]['owner'] if rows else None))
|
||||
return payload
|
||||
|
||||
|
||||
def collect_docs_drift_payload(repo):
|
||||
"""Same logic as docs_drift but returns payload without emitting."""
|
||||
matrix_path = repo.main / '.doc/status/FEATURE-MATRIX.md'
|
||||
if not matrix_path.is_file():
|
||||
return {'status': 'error', 'error': 'FEATURE-MATRIX.md missing'}
|
||||
tip = git(repo.main, 'show', 'refs/heads/dev:.doc/status/FEATURE-MATRIX.md', check=False)
|
||||
content = tip.stdout if tip.returncode == 0 else matrix_path.read_text()
|
||||
current = matrix_states(content)
|
||||
previous = {}
|
||||
with repo.db(True) as db:
|
||||
ensure_schema(db)
|
||||
for row in db.execute('SELECT record_id, status FROM matrix_state_snapshot'):
|
||||
previous[row['record_id']] = row['status']
|
||||
regressions = []
|
||||
for record_id, prev in previous.items():
|
||||
now = current.get(record_id)
|
||||
if prev == 'done' and now is not None and now != 'done':
|
||||
regressions.append({
|
||||
'record_id': record_id, 'previous': prev, 'current': now,
|
||||
'severity': 'hard', 'message': 'MATRIX status regressed from done',
|
||||
})
|
||||
elif prev == 'done' and record_id not in current:
|
||||
regressions.append({
|
||||
'record_id': record_id, 'previous': prev, 'current': None,
|
||||
'severity': 'hard', 'message': 'MATRIX done row disappeared',
|
||||
})
|
||||
now_ts = time.time()
|
||||
db.execute('DELETE FROM matrix_state_snapshot')
|
||||
for record_id, status in current.items():
|
||||
db.execute(
|
||||
'INSERT INTO matrix_state_snapshot(record_id, status, updated) VALUES(?,?,?)',
|
||||
(record_id, status, now_ts))
|
||||
open_rows = sorted(
|
||||
[{'record_id': rid, 'status': st} for rid, st in current.items()
|
||||
if st not in ('done', 'removed')],
|
||||
key=lambda item: item['record_id'])
|
||||
return {
|
||||
'status': 'ok',
|
||||
'file': '.doc/status/FEATURE-MATRIX.md',
|
||||
'record_count': len(current),
|
||||
'previous_count': len(previous),
|
||||
'regressions': regressions,
|
||||
'open_capabilities': open_rows,
|
||||
'baseline': 'first_snapshot' if not previous else 'compared',
|
||||
}
|
||||
|
||||
|
||||
def collect_daemon_payload(repo):
|
||||
try:
|
||||
identity = repo.identity()
|
||||
except Denied as exc:
|
||||
return {'status': 'denied', 'code': exc.code, 'message': exc.message}
|
||||
lock = manager_lock_status(repo)
|
||||
heartbeat = read_daemon_heartbeat(repo)
|
||||
heartbeat_age = None
|
||||
if heartbeat and isinstance(heartbeat.get('updated'), (int, float)):
|
||||
heartbeat_age = max(0.0, time.time() - float(heartbeat['updated']))
|
||||
pending = claimed = 0
|
||||
try:
|
||||
with repo.db() as db:
|
||||
try:
|
||||
pending = db.execute(
|
||||
"SELECT count(*) FROM queue_items WHERE state='pending'").fetchone()[0]
|
||||
claimed = db.execute(
|
||||
"SELECT count(*) FROM queue_items WHERE state='claimed'").fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
pending = claimed = 0
|
||||
except Denied:
|
||||
pass
|
||||
return {
|
||||
'status': 'ok',
|
||||
'lock_root': str(repo.root),
|
||||
'epoch': identity.get('epoch'),
|
||||
'manager_lock': lock,
|
||||
'heartbeat': heartbeat,
|
||||
'heartbeat_age_seconds': heartbeat_age,
|
||||
'queue': {'pending': pending, 'claimed': claimed},
|
||||
}
|
||||
|
||||
|
||||
def collect_catalog_drift_payload(repo):
|
||||
matrix_path = repo.main / '.doc/status/FEATURE-MATRIX.md'
|
||||
try:
|
||||
content = git(repo.main, 'show', 'refs/heads/dev:.doc/status/FEATURE-MATRIX.md')
|
||||
except Exception:
|
||||
content = matrix_path.read_text(encoding='utf-8') if matrix_path.is_file() else ''
|
||||
states = matrix_states(content) if content else {}
|
||||
with repo.db(True) as db:
|
||||
return catalog.catalog_drift(db, matrix_states=states)
|
||||
|
||||
|
||||
def cmd_brief(repo, args):
|
||||
"""One-page Markdown briefing aggregating status/drift/cleanup/catalog/daemon."""
|
||||
status_payload = collect_status_payload(repo)
|
||||
try:
|
||||
docs = collect_docs_drift_payload(repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
docs = {'status': 'error', 'error': str(exc)}
|
||||
try:
|
||||
cleanup = collect_cleanup_payload(repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
cleanup = {'status': 'error', 'error': str(exc), 'worktrees': []}
|
||||
try:
|
||||
cat = collect_catalog_drift_payload(repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
cat = {'status': 'error', 'error': str(exc)}
|
||||
try:
|
||||
daemon = collect_daemon_payload(repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
daemon = {'status': 'error', 'error': str(exc)}
|
||||
text = visibility.build_brief(
|
||||
status=status_payload,
|
||||
docs_drift=docs,
|
||||
cleanup=cleanup,
|
||||
catalog_drift=cat,
|
||||
daemon=daemon,
|
||||
)
|
||||
if getattr(args, 'format', 'markdown') == 'json':
|
||||
emit({
|
||||
'status': 'ok',
|
||||
'brief': text,
|
||||
'sources': {
|
||||
'status': {
|
||||
'active_tasks': len([
|
||||
t for t in (status_payload.get('tasks') or [])
|
||||
if t.get('state') not in ('done', 'cancelled')
|
||||
]),
|
||||
'runs': len(status_payload.get('runs') or []),
|
||||
'queue': len(status_payload.get('queue') or []),
|
||||
},
|
||||
'docs_drift': docs,
|
||||
'cleanup': {'worktrees': len((cleanup or {}).get('worktrees') or [])},
|
||||
'catalog_drift': cat,
|
||||
'daemon': daemon,
|
||||
},
|
||||
})
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
@@ -2678,7 +2966,12 @@ def daemon_status(repo, args):
|
||||
def parser():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--repo',default='.')
|
||||
sub=p.add_subparsers(dest='action',required=True)
|
||||
for name in ('init','status','doctor'):sub.add_parser(name)
|
||||
for name in ('init','status','doctor'):
|
||||
sp=sub.add_parser(name)
|
||||
if name in ('status','doctor'):
|
||||
sp.add_argument('--format',choices=['json','table'],default='json')
|
||||
brief=sub.add_parser('brief', help='One-page governance briefing (Markdown)')
|
||||
brief.add_argument('--format',choices=['markdown','json'],default='markdown')
|
||||
task=sub.add_parser('task').add_subparsers(dest='task_action',required=True)
|
||||
reg=task.add_parser('register');reg.add_argument('--id',required=True);reg.add_argument('--owner',required=True);reg.add_argument('--scope',action='append',required=True);reg.add_argument('--capability',action='append',default=[])
|
||||
reg.add_argument('--requirement',help='Catalog R-ID to bind at register')
|
||||
@@ -2705,12 +2998,13 @@ def parser():
|
||||
q=sub.add_parser('queue').add_subparsers(dest='queue_action',required=True)
|
||||
qe=q.add_parser('enqueue');qe.add_argument('--task',required=True);qe.add_argument('--expected-dev',required=True)
|
||||
qe.add_argument('--head');qe.add_argument('--priority',type=int,default=0);qe.add_argument('--request-id')
|
||||
ql=q.add_parser('list');ql.add_argument('--all',action='store_true')
|
||||
ql=q.add_parser('list');ql.add_argument('--all',action='store_true');ql.add_argument('--format',choices=['json','table'],default='json')
|
||||
qc=q.add_parser('cancel');qc.add_argument('--task',required=True);qc.add_argument('--id',required=True);qc.add_argument('--reason',required=True)
|
||||
qs=q.add_parser('schedule');qs.add_argument('--drain',action='store_true')
|
||||
cu=sub.add_parser('cleanup').add_subparsers(dest='cleanup_action',required=True)
|
||||
cs=cu.add_parser('status');cs.add_argument('--path');cs.add_argument('--branch')
|
||||
cu.add_parser('report', help='Batch cleanup readiness for .worktrees (never deletes)')
|
||||
crep=cu.add_parser('report', help='Batch cleanup readiness for .worktrees (never deletes)')
|
||||
crep.add_argument('--format',choices=['json','table'],default='json')
|
||||
rec=sub.add_parser('recover');rec.add_argument('--run',required=True);rec.add_argument('--reason',required=True)
|
||||
op=sub.add_parser('operation');op.add_argument('run')
|
||||
for action in ('freeze','unfreeze'):
|
||||
@@ -2735,6 +3029,12 @@ def parser():
|
||||
crl=cl.add_parser('release');crl.add_argument('--task',required=True);crl.add_argument('--resource',required=True);crl.add_argument('--reason',default='')
|
||||
cst=cl.add_parser('status');cst.add_argument('--resource',required=True);cst.add_argument('--json',action='store_true')
|
||||
clist=cl.add_parser('list');clist.add_argument('--all',action='store_true')
|
||||
clist.add_argument('--format',choices=['json','table'],default='json')
|
||||
carc=cl.add_parser('archive', help='Archive done CURRENT claim rows via docs apply')
|
||||
carc.add_argument('--task',required=True)
|
||||
carc.add_argument('--retain-days',type=int,default=7)
|
||||
carc.add_argument('--include-undated',action='store_true')
|
||||
carc.add_argument('--apply',action='store_true')
|
||||
cc=cl.add_parser('cancel');cc.add_argument('--task',required=True);cc.add_argument('--id',required=True);cc.add_argument('--reason',required=True)
|
||||
qtn=sub.add_parser('quarantine').add_subparsers(dest='quarantine_action',required=True)
|
||||
qtn.add_parser('sweep')
|
||||
@@ -2801,6 +3101,8 @@ def main():
|
||||
pass
|
||||
elif args.action == 'claim' and getattr(args, 'claim_action', None) in ('status', 'list'):
|
||||
require_policy(repo) # status/list need schema constants but are non-mutating
|
||||
elif args.action == 'claim' and getattr(args, 'claim_action', None) == 'archive' and not getattr(args, 'apply', False):
|
||||
require_policy(repo) # dry-run is non-mutating
|
||||
elif args.action == 'quarantine' and getattr(args, 'quarantine_action', None) == 'list':
|
||||
require_policy(repo) # list needs TTL/schema constants
|
||||
elif args.action == 'daemon' and getattr(args, 'daemon_action', None) == 'status':
|
||||
@@ -2817,25 +3119,10 @@ def main():
|
||||
require_policy(repo)
|
||||
if args.action=='init':initialize(repo)
|
||||
elif args.action in ('status','doctor'):
|
||||
identity=repo.identity()
|
||||
policy=policy_status(repo.main)
|
||||
try:
|
||||
with repo.db(True) as db:
|
||||
ensure_schema(db)
|
||||
except Denied:
|
||||
pass
|
||||
with repo.db() as db:
|
||||
tasks=[dict(r) for r in db.execute('SELECT * FROM tasks')]
|
||||
runs=[dict(r) for r in db.execute(
|
||||
"SELECT * FROM runs WHERE state IN ('running','queued','recovery_required','busy','suspect','quarantine')")]
|
||||
try:
|
||||
queue=[dict(r) for r in db.execute(
|
||||
"SELECT * FROM queue_items WHERE state IN ('pending','claimed') ORDER BY priority DESC, created")]
|
||||
except sqlite3.Error:
|
||||
queue=[]
|
||||
for row in runs:row['supervisor_alive']=alive_status(row['pid'],row['start'],row['boot'])
|
||||
emit({'status':'ok','lock_root':str(repo.root),'main':str(repo.main),'version':VERSION,'epoch':identity['epoch'],
|
||||
'tasks':tasks,'runs':runs,'queue':queue,'policy':policy})
|
||||
payload = collect_status_payload(repo)
|
||||
emit_payload(payload, getattr(args, 'format', 'json'), visibility.render_status_table)
|
||||
elif args.action=='brief':
|
||||
return cmd_brief(repo, args)
|
||||
elif args.action=='task':
|
||||
if args.task_action=='register':
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}',args.id):deny('INVALID_TASK','Invalid task id')
|
||||
@@ -2941,6 +3228,7 @@ def main():
|
||||
if args.claim_action=='release':return claim_release(repo,args)
|
||||
if args.claim_action=='status':return claim_status(repo,args)
|
||||
if args.claim_action=='list':return claim_list(repo,args)
|
||||
if args.claim_action=='archive':return claim_archive(repo,args)
|
||||
if args.claim_action=='cancel':return claim_cancel(repo,args)
|
||||
elif args.action=='quarantine':
|
||||
if args.quarantine_action=='sweep':return quarantine_sweep(repo)
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/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:]
|
||||
@@ -222,6 +222,46 @@ const TOOLS = [
|
||||
description: 'Catalog table counts and gate/vector config summary',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'queue_list',
|
||||
description: 'List integrate queue items (pending/claimed by default)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
all: { type: 'boolean', description: 'include terminal queue items' },
|
||||
format: { type: 'string', enum: ['json', 'table'] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'claim_status',
|
||||
description: 'List held claims (or all with all=true)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
all: { type: 'boolean' },
|
||||
format: { type: 'string', enum: ['json', 'table'] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cleanup_report',
|
||||
description: 'Batch cleanup readiness for .worktrees (never deletes)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
format: { type: 'string', enum: ['json', 'table'] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'catalog_drift',
|
||||
description: 'Refresh catalog features from MATRIX; report done→non-done regressions',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
];
|
||||
|
||||
function callTool(name, args = {}) {
|
||||
@@ -332,6 +372,30 @@ function callTool(name, args = {}) {
|
||||
const r = runAgentctl(['catalog', 'status']);
|
||||
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
|
||||
}
|
||||
case 'queue_list': {
|
||||
const argv = ['queue', 'list'];
|
||||
if (args.all) argv.push('--all');
|
||||
if (args.format) argv.push('--format', args.format);
|
||||
const r = runAgentctl(argv);
|
||||
return r.ok ? textResult(r.stdout.trim() || '{"items":[]}') : errResult(r.stderr || r.stdout || r.error);
|
||||
}
|
||||
case 'claim_status': {
|
||||
const argv = ['claim', 'list'];
|
||||
if (args.all) argv.push('--all');
|
||||
if (args.format) argv.push('--format', args.format);
|
||||
const r = runAgentctl(argv);
|
||||
return r.ok ? textResult(r.stdout.trim() || '{"claims":[]}') : errResult(r.stderr || r.stdout || r.error);
|
||||
}
|
||||
case 'cleanup_report': {
|
||||
const argv = ['cleanup', 'report'];
|
||||
if (args.format) argv.push('--format', args.format);
|
||||
const r = runAgentctl(argv, { timeout: 180000 });
|
||||
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
|
||||
}
|
||||
case 'catalog_drift': {
|
||||
const r = runAgentctl(['catalog', 'drift']);
|
||||
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
|
||||
}
|
||||
default:
|
||||
return errResult(`unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class WorktreeTest(unittest.TestCase):
|
||||
self.root = Path(self.tmp.name) / "repo"
|
||||
self.root.mkdir()
|
||||
(self.root / "scripts").mkdir()
|
||||
for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py"):
|
||||
for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py", "agentctl_visibility.py"):
|
||||
shutil.copy2(SOURCE / name, self.root / "scripts" / name)
|
||||
(self.root / "configs").mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(SOURCE.parent / "configs" / "agent-governance.json",
|
||||
|
||||
@@ -38,6 +38,7 @@ class AgentctlTests(unittest.TestCase):
|
||||
shutil.copy2(SOURCE, self.root / "scripts" / "agentctl.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_w4.py", self.root / "scripts" / "agentctl_w4.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_catalog.py", self.root / "scripts" / "agentctl_catalog.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_visibility.py", self.root / "scripts" / "agentctl_visibility.py")
|
||||
install_governance_policy(self.root)
|
||||
(self.root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
|
||||
for directory in ("src/a", "src/b", ".doc/ops", ".doc/status"):
|
||||
@@ -1553,6 +1554,7 @@ class ProcessPlatformTests(unittest.TestCase):
|
||||
shutil.copy2(SOURCE, root / "scripts" / "agentctl.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_w4.py", root / "scripts" / "agentctl_w4.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_catalog.py", root / "scripts" / "agentctl_catalog.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_visibility.py", root / "scripts" / "agentctl_visibility.py")
|
||||
install_governance_policy(root)
|
||||
(root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
|
||||
(root / ".doc/ops").mkdir(parents=True)
|
||||
|
||||
@@ -33,6 +33,9 @@ class CatalogTests(unittest.TestCase):
|
||||
shutil.copy2(SOURCE, self.root / "scripts" / "agentctl.py")
|
||||
shutil.copy2(SOURCE.parent / "agentctl_w4.py", self.root / "scripts" / "agentctl_w4.py")
|
||||
shutil.copy2(CATALOG_SOURCE, self.root / "scripts" / "agentctl_catalog.py")
|
||||
vis = SOURCE.parent / "agentctl_visibility.py"
|
||||
if vis.exists():
|
||||
shutil.copy2(vis, self.root / "scripts" / "agentctl_visibility.py")
|
||||
install_governance_policy(self.root)
|
||||
(self.root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
|
||||
for directory in ("src/a", "src/b", ".doc/ops", ".doc/status"):
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for agentctl visibility helpers (table/brief/claim-archive)."""
|
||||
|
||||
import datetime as dt
|
||||
import pathlib
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SCRIPTS = pathlib.Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
import agentctl_visibility as vis # noqa: E402
|
||||
|
||||
|
||||
SAMPLE_CURRENT = """# 当前焦点
|
||||
|
||||
## 焦点
|
||||
|
||||
1. something
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|---|---|---|---|---|---|
|
||||
| Cursor | keep-active-2026-09-24 | feature/keep | .worktrees/keep | m1 | in_progress |
|
||||
| Cursor | recent-done-2026-09-23 | feature/recent | .worktrees/recent | m2 | done |
|
||||
| Codex | old-done-2026-09-10 | feature/old | .worktrees/old | m3 | done |
|
||||
| Cursor | undated-done | feature/undated | .worktrees/u | m4 | done |
|
||||
|
||||
## 集成槽位
|
||||
|
||||
| holder | phase |
|
||||
|---|---|
|
||||
| - | idle |
|
||||
"""
|
||||
|
||||
|
||||
class FormatTableTests(unittest.TestCase):
|
||||
def test_format_table_aligns_columns(self):
|
||||
text = vis.format_table(['a', 'bb'], [['1', '22'], ['333', '4']])
|
||||
lines = text.splitlines()
|
||||
self.assertEqual(len(lines), 4)
|
||||
self.assertIn('a', lines[0])
|
||||
self.assertTrue(lines[1].startswith('-'))
|
||||
|
||||
def test_render_status_table_active_only(self):
|
||||
payload = {
|
||||
'main': '/repo',
|
||||
'epoch': 'abc-epoch',
|
||||
'version': 1,
|
||||
'tasks': [
|
||||
{'id': 't1', 'owner': 'A', 'state': 'active', 'worktree': '/repo/.worktrees/t1', 'branch': 'feature/t1'},
|
||||
{'id': 't2', 'owner': 'B', 'state': 'done', 'worktree': '/repo/.worktrees/t2', 'branch': 'feature/t2'},
|
||||
],
|
||||
'runs': [],
|
||||
'queue': [{'id': 'q1', 'task': 't1', 'state': 'pending', 'priority': 1, 'head_sha': 'deadbeef', 'created': 0}],
|
||||
'policy': {'loaded': 'ok', 'policy_revision': 'r1'},
|
||||
}
|
||||
text = vis.render_status_table(payload, now=100.0)
|
||||
self.assertIn('active=1', text)
|
||||
self.assertIn('t1', text)
|
||||
self.assertNotIn('t2', text.split('## runs')[0]) # closed not in active table
|
||||
self.assertIn('queue depth=1', text)
|
||||
|
||||
|
||||
class BriefTests(unittest.TestCase):
|
||||
def test_build_brief_markdown(self):
|
||||
text = vis.build_brief(
|
||||
status={
|
||||
'tasks': [{'id': 't1', 'owner': 'A', 'state': 'active', 'worktree': '/x/.worktrees/t1'}],
|
||||
'runs': [],
|
||||
'queue': [],
|
||||
},
|
||||
docs_drift={'regressions': []},
|
||||
cleanup={'worktrees': [{'path': 'a', 'cleanup_allowed': False}]},
|
||||
catalog_drift={'regressions': []},
|
||||
daemon={'status': 'ok', 'manager_lock': {'held': True}, 'queue': {'pending': 0}},
|
||||
now=1_700_000_000,
|
||||
)
|
||||
self.assertTrue(text.startswith('# agentctl brief'))
|
||||
self.assertIn('daemon: **ok**', text)
|
||||
self.assertIn('`t1`', text)
|
||||
self.assertIn('cleanup backlog: 1', text)
|
||||
|
||||
|
||||
class ClaimArchiveTests(unittest.TestCase):
|
||||
def test_parse_current_claim_rows(self):
|
||||
rows = vis.parse_current_claim_rows(SAMPLE_CURRENT)
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual(rows[0]['task'], 'keep-active-2026-09-24')
|
||||
self.assertEqual(rows[2]['status'], 'done')
|
||||
|
||||
def test_parse_task_date(self):
|
||||
self.assertEqual(vis.parse_task_date('foo-2026-09-10'), dt.date(2026, 9, 10))
|
||||
self.assertEqual(vis.parse_task_date('2026-09-10-bar'), dt.date(2026, 9, 10))
|
||||
self.assertIsNone(vis.parse_task_date('no-date-here'))
|
||||
|
||||
def test_select_archive_candidates_respects_retain_window(self):
|
||||
rows = vis.parse_current_claim_rows(SAMPLE_CURRENT)
|
||||
selected = vis.select_archive_candidates(
|
||||
rows, retain_days=7, today=dt.date(2026, 9, 24), include_undated=False)
|
||||
eligible = [r for r in selected if r['eligible']]
|
||||
self.assertEqual([r['task'] for r in eligible], ['old-done-2026-09-10'])
|
||||
skipped_reasons = {r['task']: r['reason'] for r in selected if not r['eligible']}
|
||||
self.assertEqual(skipped_reasons['recent-done-2026-09-23'], 'within_retain_window')
|
||||
self.assertEqual(skipped_reasons['undated-done'], 'undated_skipped')
|
||||
|
||||
def test_select_include_undated(self):
|
||||
rows = vis.parse_current_claim_rows(SAMPLE_CURRENT)
|
||||
selected = vis.select_archive_candidates(
|
||||
rows, retain_days=0, today=dt.date(2026, 9, 24), include_undated=True)
|
||||
eligible_ids = {r['task'] for r in selected if r['eligible']}
|
||||
self.assertIn('undated-done', eligible_ids)
|
||||
self.assertIn('old-done-2026-09-10', eligible_ids)
|
||||
self.assertIn('recent-done-2026-09-23', eligible_ids)
|
||||
self.assertNotIn('keep-active-2026-09-24', eligible_ids)
|
||||
|
||||
def test_build_archived_current_removes_rows(self):
|
||||
out = vis.build_archived_current(SAMPLE_CURRENT, ['old-done-2026-09-10', 'undated-done'])
|
||||
self.assertNotIn('old-done-2026-09-10', out)
|
||||
self.assertNotIn('undated-done', out)
|
||||
self.assertIn('keep-active-2026-09-24', out)
|
||||
self.assertIn('## 集成槽位', out)
|
||||
self.assertIn('## 焦点', out)
|
||||
|
||||
def test_build_archive_section(self):
|
||||
rows = [{'owner': 'A', 'task': 't', 'branch': 'b', 'worktree': 'w', 'mutex': 'm', 'status': 'done'}]
|
||||
section = vis.build_archive_section(rows, '2026-09-24')
|
||||
self.assertIn('## 并行认领归档 2026-09-24', section)
|
||||
self.assertIn('| A | t | b | w | m | done |', section)
|
||||
|
||||
|
||||
class QueueClaimCleanupRenderTests(unittest.TestCase):
|
||||
def test_render_queue_and_claims(self):
|
||||
q = vis.render_queue_table({'items': [
|
||||
{'id': '1', 'task': 't', 'state': 'pending', 'priority': 0,
|
||||
'head_sha': 'abc', 'expected_dev': 'def', 'created': 0},
|
||||
]}, now=10)
|
||||
self.assertIn('queue list count=1', q)
|
||||
c = vis.render_claim_table({'claims': [
|
||||
{'id': 'c1', 'task': 't', 'resource': 'claim:x', 'state': 'held', 'updated': 0},
|
||||
]}, now=10)
|
||||
self.assertIn('claim list count=1', c)
|
||||
|
||||
def test_render_cleanup(self):
|
||||
text = vis.render_cleanup_table({'worktrees': [
|
||||
{'path': '/repo/.worktrees/a', 'cleanup_allowed': True, 'cleanup_owner': 'X'},
|
||||
{'path': '/repo/.worktrees/b', 'status': 'blocked', 'gates': [
|
||||
{'name': 'dev_ancestor', 'status': 'fail'},
|
||||
]},
|
||||
]})
|
||||
self.assertIn('allowed=1', text)
|
||||
self.assertIn('blocked/pending=1', text)
|
||||
self.assertIn('dev_ancestor', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -21,7 +21,8 @@ class CodegraphGovernanceTest(unittest.TestCase):
|
||||
self.root.mkdir()
|
||||
(self.root / 'scripts').mkdir()
|
||||
for name in ('agent-worktree.sh', 'agent_worktree.py', 'agentctl.py',
|
||||
'agentctl', 'agentctl_w4.py', 'agentctl_catalog.py'):
|
||||
'agentctl', 'agentctl_w4.py', 'agentctl_catalog.py',
|
||||
'agentctl_visibility.py'):
|
||||
shutil.copy2(SOURCE / name, self.root / 'scripts' / name)
|
||||
(self.root / 'configs').mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(SOURCE.parent / 'configs' / 'agent-governance.json',
|
||||
|
||||
@@ -19,7 +19,7 @@ class OpsGuardsTest(unittest.TestCase):
|
||||
self.root = Path(self.tmp.name) / "repo"
|
||||
self.root.mkdir()
|
||||
(self.root / "scripts").mkdir()
|
||||
for name in ("env-service.sh", "candidate-images-build.sh", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py"):
|
||||
for name in ("env-service.sh", "candidate-images-build.sh", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py", "agentctl_visibility.py"):
|
||||
shutil.copy2(SOURCE / name, self.root / "scripts" / name)
|
||||
(self.root / ".gitignore").write_text(".worktrees/\n.locks/\nconfigs/.env.*\n!configs/.env.*.example\ndata/\n")
|
||||
(self.root / ".dockerignore").write_text(".doc\n.locks\n.worktrees\n")
|
||||
|
||||
@@ -149,6 +149,9 @@ class PiSubagentsTests(unittest.TestCase):
|
||||
catalog_src = ROOT / "scripts" / "agentctl_catalog.py"
|
||||
if catalog_src.exists():
|
||||
shutil.copy2(catalog_src, self.root / "scripts" / "agentctl_catalog.py")
|
||||
vis_src = ROOT / "scripts" / "agentctl_visibility.py"
|
||||
if vis_src.exists():
|
||||
shutil.copy2(vis_src, self.root / "scripts" / "agentctl_visibility.py")
|
||||
if SOURCE_EXEC.exists():
|
||||
shutil.copy2(SOURCE_EXEC, self.root / "scripts" / "pi_subagents.py")
|
||||
if SOURCE_MCP.exists():
|
||||
|
||||
Reference in New Issue
Block a user