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:
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: "治理控制台 P1 方案"
|
||||
task_id: "governance-visibility-p0-2026-09-24"
|
||||
status: planned
|
||||
created: 2026-09-24
|
||||
updated: 2026-09-24
|
||||
owner: Cursor
|
||||
task_type: research
|
||||
related_p0: governance-visibility-p0-2026-09-24
|
||||
---
|
||||
|
||||
# 治理控制台全景设计(P1)
|
||||
|
||||
覆盖方向:**A 状态可视化**、**B 流程自动化**、**C MCP 只读巡览**、**D PI 派发包生成**。
|
||||
P0 已落地:`--format table`、`brief`、MCP 四只读工具、`claim archive`。本文件是后续实施蓝图,**不在 P0 编码**。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
给人类与 Agent 一个本机治理面:一眼看到任务/租约/队列/漂移/清理积压,并把高频手工步骤(认领、归档、集成候选、PI packet)收成白名单动作——**全部复用既有租约与 `docs apply` CAS,不新增旁路写入口**。
|
||||
|
||||
## 2. 架构
|
||||
|
||||
```text
|
||||
Browser (localhost) ──► static SPA ──► agentctl serve (127.0.0.1)
|
||||
│
|
||||
Cursor MCP / CLI ────────────────────────────┤
|
||||
▼
|
||||
agentctl 核心
|
||||
(SQLite / leases / docs apply / queue / catalog)
|
||||
```
|
||||
|
||||
| 组件 | 职责 | 约束 |
|
||||
|---|---|---|
|
||||
| `agentctl serve` | HTTP JSON API + 静态文件;仅绑 `127.0.0.1` | 无鉴权外放;动作白名单;写路径内部调 `with`/`docs apply` |
|
||||
| 静态 SPA | 单页四区:任务租约 / 队列集成 / 文档漂移 / 清理积压 | **不进产品 `web/`**,避免发布门禁耦合;建议路径 `scripts/governance-console/` |
|
||||
| CLI 增强 | `claim new`、`integrate prepare`、`pi packet` | 与 HTTP 动作共用同一 Python 函数 |
|
||||
|
||||
跨机权威认领不在本方案重复设计,衔接 [coordination-db-daemon phased plan](../remediation/2026-09-23-coordination-db-daemon-phased-plan.md)。
|
||||
|
||||
## 3. HTTP API(草案)
|
||||
|
||||
基址:`http://127.0.0.1:7432`(端口可配,默认避开产品 8080/8081)。
|
||||
|
||||
### 3.1 只读
|
||||
|
||||
| Method | Path | 映射 |
|
||||
|---|---|---|
|
||||
| GET | `/api/status` | `collect_status_payload` + 可选 `?format=table` |
|
||||
| GET | `/api/brief` | `cmd_brief` Markdown 或 JSON |
|
||||
| GET | `/api/queue` | `queue list` |
|
||||
| GET | `/api/claims` | `claim list` |
|
||||
| GET | `/api/cleanup` | `cleanup report` |
|
||||
| GET | `/api/docs/drift` | `docs drift` |
|
||||
| GET | `/api/catalog/drift` | `catalog drift` |
|
||||
| GET | `/api/daemon` | `daemon status` |
|
||||
|
||||
### 3.2 动作白名单(均要求 `task` + 可选 `authorization_ref`)
|
||||
|
||||
| Method | Path | 行为 |
|
||||
|---|---|---|
|
||||
| POST | `/api/claim/new` | 见 §4.1:snapshot→delta→apply 封装 |
|
||||
| POST | `/api/claim/archive` | 既有 `claim archive --apply` |
|
||||
| POST | `/api/integrate/prepare` | 见 §4.2:候选 worktree + `--no-ff` |
|
||||
| POST | `/api/pi/packet` | 见 §4.3:生成 dispatch JSON |
|
||||
|
||||
拒绝:任意 shell、env-service、push、晋升、删 worktree、绕过租约的文件写。
|
||||
|
||||
## 4. B/D CLI 完整版
|
||||
|
||||
### 4.1 `agentctl claim new`(B)
|
||||
|
||||
```bash
|
||||
agentctl claim new --task "$ID" --owner Cursor --mutex <key> \
|
||||
[--branch feature/$ID] [--worktree .worktrees/$ID]
|
||||
```
|
||||
|
||||
内部:
|
||||
|
||||
1. `docs snapshot --file CURRENT --record $ID`
|
||||
2. 若 `sha256` 已存在 → 拒绝或 `--replace` 显式更新
|
||||
3. 构造一行 `| owner | task | … | in_progress |`
|
||||
4. `docs apply`(单行 CAS;无需 `@document`)
|
||||
|
||||
消除当前 skill 里 snapshot → 手写 JSON → apply 三步。
|
||||
|
||||
### 4.2 `agentctl integrate prepare`(B)
|
||||
|
||||
```bash
|
||||
agentctl integrate prepare --task "$ID" --expected-dev <sha> \
|
||||
[--integrate-id integrate-$ID]
|
||||
```
|
||||
|
||||
内部(仍在租约内):
|
||||
|
||||
1. 从 freeze 的 `expected-dev` 建 `feature/integrate-<id>` worktree(若已存在则复用并校验干净)
|
||||
2. 在 integrate worktree `task register`(scope 含 git:dev 所需能力)
|
||||
3. `--no-ff` 合并任务分支到候选 HEAD
|
||||
4. 输出 `{integrate_task, worktree, head}` 供后续 `validate` / `queue enqueue`
|
||||
|
||||
不替代 daemon ff-only;只去掉「手工第二个 worktree」摩擦。
|
||||
|
||||
### 4.3 `agentctl pi packet`(D)
|
||||
|
||||
```bash
|
||||
agentctl pi packet --task "$ID" --role research|planning|… \
|
||||
--goal '…' --authorization-ref '…' \
|
||||
[--approved-command '…']…
|
||||
```
|
||||
|
||||
从 task 行读取 `worktree`/`HEAD`,生成:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "…",
|
||||
"request_id": "<uuid>",
|
||||
"role_id": "research",
|
||||
"goal": "…",
|
||||
"authorization_ref": "…",
|
||||
"source_sha": "<frozen>",
|
||||
"input_files": [],
|
||||
"timeout_seconds": 600,
|
||||
"approved_commands": []
|
||||
}
|
||||
```
|
||||
|
||||
写出到 `.doc/test-reports/assets/pi-subagents/<task>/<request>/packet.json`(路径仍须在 `pi_subagents` 租约约定内)。不自动 `run`——生成与派发分离,避免误触模型。
|
||||
|
||||
## 5. 前端 SPA(A)
|
||||
|
||||
单页四区,无路由也可:
|
||||
|
||||
1. **任务 / 租约**:活跃 tasks、live runs、held claims;链到 worktree 路径文本。
|
||||
2. **队列与集成**:pending/claimed;按钮「prepare」(调 `/api/integrate/prepare`,需确认)。
|
||||
3. **文档漂移**:MATRIX hard regressions + catalog drift。
|
||||
4. **清理积压**:`cleanup report` 表;仅展示,删除仍人工/`agent-worktree remove`。
|
||||
|
||||
技术选型建议:零构建或 Vite 单包静态资源,依赖控制台已有 `brief`/`--format table` 文案风格,不套产品 Admin UI。
|
||||
|
||||
## 6. 与 P0 / 既有系统衔接
|
||||
|
||||
| 能力 | 状态 |
|
||||
|---|---|
|
||||
| `--format table` / `brief` / MCP 只读 / `claim archive` | **P0 done** |
|
||||
| MCP 再暴露 `brief`、`claim_archive` | P1 可选 |
|
||||
| `serve` + SPA | P1 |
|
||||
| `claim new` / `integrate prepare` / `pi packet` | P1 |
|
||||
| 跨机 claim 权威 | 见 coordination-db-daemon 方案,本控制台只读本地 SQLite |
|
||||
|
||||
## 7. 分阶段实施顺序
|
||||
|
||||
1. **P1-a**:抽出 `collect_*` 为稳定库(P0 已部分完成)→ `agentctl serve` 只读 API + 极简 HTML。
|
||||
2. **P1-b**:`claim new` + 控制台认领表单。
|
||||
3. **P1-c**:`integrate prepare` + 队列区按钮。
|
||||
4. **P1-d**:`pi packet` + 可选把 packet 路径注入 `pi-subagent-dispatch` skill。
|
||||
5. **P1-e**(可选):把 SPA 挂进 Cursor 简单 Browser tab 工作流说明。
|
||||
|
||||
## 8. 验收标准(实施时)
|
||||
|
||||
- 只读 API 与 CLI 同构(同 payload 字段)。
|
||||
- 任一写动作在审计表留下 run_id;CURRENT/MATRIX 变更有 `docs apply` 收据。
|
||||
- `serve` 默认拒绝非 loopback;集成测试用临时端口。
|
||||
- 工程工具单测:`scripts/tests/test_agentctl_serve.py` + 既有 visibility 测试。
|
||||
- 不改产品 compose / 不占用 8080。
|
||||
|
||||
## 9. 非目标
|
||||
|
||||
- 产品 AdminConsole 内嵌治理页。
|
||||
- 远程多用户 Web 登录。
|
||||
- 自动 `--force` 清理 worktree。
|
||||
- 替代 Herdr / 产品 PI Runner。
|
||||
|
||||
## 10. 授权与下一步
|
||||
|
||||
本文件为方案;实施需单独授权并新开 task(建议 `governance-console-p1-YYYY-MM-DD`),先 `catalog overlap` 再编码。
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: "治理可视化 P0"
|
||||
task_id: "governance-visibility-p0-2026-09-24"
|
||||
status: in_progress
|
||||
created: 2026-09-24
|
||||
updated: 2026-09-24
|
||||
owner: Cursor
|
||||
task_type: implementation
|
||||
task_tier: small
|
||||
policy_revision: "agent-governance-2026-09-21-r1 + cursor-governance-2026-09-24-r1 @ a2afa67d"
|
||||
branch: feature/governance-visibility-p0-2026-09-24
|
||||
worktree: .worktrees/governance-visibility-p0-2026-09-24
|
||||
base_dev_sha: "a2afa67d471c4edce9a6a31600811779549c8cce"
|
||||
write_scope: ["scripts", ".doc"]
|
||||
allowed_environments: []
|
||||
head_sha: ""
|
||||
mutex: "agentctl-visibility"
|
||||
review_state: pending
|
||||
integration_state: pending
|
||||
deployment: not_required
|
||||
cleanup: pending
|
||||
cleanup_owner: "独立维护者"
|
||||
cleanup_trigger: "合入 dev 且验收通过后"
|
||||
remote_sync: not_requested
|
||||
---
|
||||
|
||||
# 治理可视化 P0
|
||||
|
||||
## 用户目标与授权
|
||||
|
||||
- 用户目标:实施治理可视化快赢(人类可读 status/brief、MCP 只读工具、claim archive)并撰写治理控制台 P1 方案。
|
||||
- 交付终点:P0 代码合入 local `dev`;工程工具单测与真实控制台证据;P1 设计文档落盘。
|
||||
- 禁止:push、晋升 test/main、跨机协调实施、产品 web/cmd/internal 改动。
|
||||
|
||||
## 范围与非目标
|
||||
|
||||
- 允许:`scripts/agentctl.py`、`scripts/mcp/agentctl-mcp.mjs`、新 visibility 模块与单测、`.doc/ops/short-term`、`.doc/test-reports`、archive 追加。
|
||||
- 非目标:dashboard 前后端、`claim new`、`integrate prepare`、`pi packet`(仅 P1 文档)、跨机协调。
|
||||
|
||||
## 相关问题与经验
|
||||
|
||||
- 关联:R04 CURRENT 膨胀(政策审查)、`ISSUE-20260923-coordination-authority`(不实施,仅衔接说明)。
|
||||
- catalog:`R-governance-visibility-p0`;overlap 探测无 hard/soft 命中。
|
||||
|
||||
## 步骤与验收
|
||||
|
||||
1. [x] 建场 / register / CURRENT 认领
|
||||
2. [ ] `--format table` 渲染
|
||||
3. [ ] `brief`
|
||||
4. [ ] MCP 只读工具 +4
|
||||
5. [ ] `claim archive`
|
||||
6. [ ] 单测 + 真实运行报告
|
||||
7. [ ] P1 控制台方案文档
|
||||
8. [ ] validate → queue enqueue → 收口
|
||||
|
||||
## 验证与证据
|
||||
|
||||
按验证契约「工程工具验收」:`python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' -v` + 真实 `--format table`/`brief`/`claim archive --dry-run` 控制台取样。
|
||||
|
||||
## 收口与交接
|
||||
|
||||
待实施完成后填写。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 治理可视化 P0 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
agentctl 治理状态此前仅输出 JSON,CURRENT 并行认领表 done 行滞留(R04),MCP 缺少 queue/claim/cleanup/catalog 只读巡览工具。本轮实现人类可读渲染、`brief`、`claim archive` dry-run/apply 路径与 MCP +4。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
1. `--format table` 对 status/queue/claim/cleanup 输出可读表且默认 json 兼容。
|
||||
2. `brief` 聚合 daemon/tasks/cleanup/drift。
|
||||
3. `claim archive` 默认 dry-run,按 retain-days 筛选 done 行。
|
||||
4. MCP tools/list 含 queue_list、claim_status、cleanup_report、catalog_drift。
|
||||
5. 工程工具单测全绿。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS: Linux;Python 3;worktree `.worktrees/governance-visibility-p0-2026-09-24`
|
||||
- 层级:验证契约「工程工具验收」——`unittest discover -s scripts/tests`
|
||||
- 不适用产品 PI 全链路(NOT REQUIRED:纯 scripts/.doc 工程工具)
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- table 模式含任务/队列表头;json 默认不变
|
||||
- brief 为 Markdown 一页
|
||||
- archive dry-run 返回 eligible_count 与 candidates
|
||||
- 单测 226 OK(含新增 11 项 visibility)
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### TC1 status --format table
|
||||
|
||||
- **Input**: `python3 scripts/agentctl.py status --format table`
|
||||
- **Actual Output**:
|
||||
|
||||
```
|
||||
=== status --format table ===
|
||||
agentctl status main=/home/weichen/projects/Agent-System epoch=a994d494-0d9 version=1
|
||||
|
||||
## tasks active=1 closed=152 total=153
|
||||
task owner state worktree branch
|
||||
----------------------------------- ------ ------ ---------------------------------------------- ------------
|
||||
governance-visibility-p0-2026-09-24 Cursor active .worktrees/governance-visibility-p0-2026-09-24 feature/gove
|
||||
|
||||
## runs live=0
|
||||
(no live runs)
|
||||
|
||||
## queue depth=0
|
||||
(queue empty)
|
||||
|
||||
## policy loaded=ok revision=agent-governance-2026-09-23-fav-constraint-restored
|
||||
```
|
||||
|
||||
- **Verdict**: PASS
|
||||
- **Duration**: <1s
|
||||
|
||||
### TC2 brief
|
||||
|
||||
- **Input**: `python3 scripts/agentctl.py brief`
|
||||
- **Actual Output**:
|
||||
|
||||
```
|
||||
=== brief ===
|
||||
# agentctl brief (2026-09-24 13:25)
|
||||
|
||||
- daemon: **ok** manager.lock.held=True queue pending=0
|
||||
- tasks: active=1 blocked=0 live runs=0 integrate queue=0
|
||||
- cleanup pending: 4 / 4 worktrees
|
||||
- MATRIX drift hard alerts: 0
|
||||
|
||||
## Active tasks
|
||||
- `governance-visibility-p0-2026-09-24` owner=Cursor state=active wt=.worktrees/governance-visibility-p0-2026-09-24
|
||||
|
||||
## Blockers / alerts
|
||||
- cleanup backlog: 4 worktrees not removable
|
||||
|
||||
## Integrate queue
|
||||
- (empty)
|
||||
```
|
||||
|
||||
- **Verdict**: PASS
|
||||
|
||||
### TC3 claim archive dry-run
|
||||
|
||||
- **Input**: `claim archive --task governance-visibility-p0-2026-09-24 --retain-days 7`
|
||||
- **Actual Output** (节选 JSON):
|
||||
|
||||
```
|
||||
=== claim archive dry-run ===
|
||||
{"archive_file": ".doc/archive/ops/2026-09-21-current-history.md", "candidates": [{"branch": "feature/initial-load-performance-plan", "eligible": true, "line": "| Codex | 2026-09-17-initial-load-performance-plan | feature/initial-load-performance-plan | .worktrees/feature-initial-load-performance-plan | loading-performance-audit | done |", "mutex": "loading-performance-audit", "owner": "Codex", "parsed_date": "2026-09-17", "reason": "older_than_7d", "status": "done", "task": "2026-09-17-initial-load-performance-plan", "worktree": ".worktrees/feature-initial-load-performance-plan"}], "done_scanned": 85, "eligible_count": 1, "include_undated": false, "retain_days": 7, "skipped": [{"branch": "feature/non-author-closeout-2026-09-24", "eligible": false, "line": "| Cursor | non-author-closeout-2026-09-24 | feature/non-author-closeout-2026-09-24 | .worktrees/non-author-closeout-2026-09-24 | docs+git:dev | done |", "mutex": "docs+git:dev", "owner": "Cursor", "parsed_date": "2026-09-24", "reason": "within_retain_window", "status": "done", "task": "non-author-closeout-2026-09-24", "worktree": ".worktrees/non-author-closeout-2026-09-24"}, {"branch": "feature/inte
|
||||
```
|
||||
|
||||
- **Verdict**: PASS(done_scanned=85,eligible_count=1,默认不改文件)
|
||||
|
||||
### TC4 MCP tools/list
|
||||
|
||||
- **Input**: NDJSON `tools/list` against `scripts/mcp/agentctl-mcp.mjs`
|
||||
- **Actual Output**:
|
||||
|
||||
```
|
||||
catalog_drift
|
||||
catalog_overlap_ack
|
||||
catalog_overlap_probe
|
||||
catalog_requirement_add
|
||||
catalog_search
|
||||
catalog_status
|
||||
claim_status
|
||||
cleanup_report
|
||||
docs_apply
|
||||
docs_snapshot
|
||||
lease_run
|
||||
quarantine_status
|
||||
queue_enqueue
|
||||
queue_list
|
||||
status
|
||||
task_close
|
||||
task_register
|
||||
validate
|
||||
---
|
||||
has queue_list true
|
||||
has claim_status true
|
||||
has cleanup_report true
|
||||
has catalog_drift true
|
||||
```
|
||||
|
||||
- **Verdict**: PASS
|
||||
|
||||
### TC5 unittest suite
|
||||
|
||||
- **Input**: `python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' -v`
|
||||
- **Actual Output**: `Ran 226 tests in ~132s` → **OK**
|
||||
- **Verdict**: PASS
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 套件 | 结果 |
|
||||
|---|---|
|
||||
| `scripts/tests` unittest discover | 226 OK |
|
||||
| visibility 新增 | 11 OK |
|
||||
| 产品 go test / 真实 PI | NOT REQUIRED(工程工具) |
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
P0 验收达标。后续按 [.doc/ops/short-term/2026-09-24-governance-console-plan.md](../ops/short-term/2026-09-24-governance-console-plan.md) 实施 P1(serve/SPA/claim new/integrate prepare/pi packet)。证据附件:`.doc/test-reports/assets/2026-09-24-governance-visibility-p0/`。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
=== brief ===
|
||||
# agentctl brief (2026-09-24 13:25)
|
||||
|
||||
- daemon: **ok** manager.lock.held=True queue pending=0
|
||||
- tasks: active=1 blocked=0 live runs=0 integrate queue=0
|
||||
- cleanup pending: 4 / 4 worktrees
|
||||
- MATRIX drift hard alerts: 0
|
||||
|
||||
## Active tasks
|
||||
- `governance-visibility-p0-2026-09-24` owner=Cursor state=active wt=.worktrees/governance-visibility-p0-2026-09-24
|
||||
|
||||
## Blockers / alerts
|
||||
- cleanup backlog: 4 worktrees not removable
|
||||
|
||||
## Integrate queue
|
||||
- (empty)
|
||||
@@ -0,0 +1,3 @@
|
||||
=== claim list --format table ===
|
||||
claim list count=0
|
||||
(no claims)
|
||||
@@ -0,0 +1,7 @@
|
||||
=== cleanup report --format table ===
|
||||
cleanup report total=3 allowed=0 blocked/pending=3
|
||||
path status owner reason
|
||||
---------------------------------------------- ------- ------ ------
|
||||
.worktrees/governance-visibility-p0-2026-09-24 blocked Cursor -
|
||||
.worktrees/promote-rc-beta3-c-2026-09-24 blocked Cursor -
|
||||
.worktrees/sync-local-test-origin-2026-09-24 blocked Cursor -
|
||||
@@ -0,0 +1,23 @@
|
||||
catalog_drift
|
||||
catalog_overlap_ack
|
||||
catalog_overlap_probe
|
||||
catalog_requirement_add
|
||||
catalog_search
|
||||
catalog_status
|
||||
claim_status
|
||||
cleanup_report
|
||||
docs_apply
|
||||
docs_snapshot
|
||||
lease_run
|
||||
quarantine_status
|
||||
queue_enqueue
|
||||
queue_list
|
||||
status
|
||||
task_close
|
||||
task_register
|
||||
validate
|
||||
---
|
||||
has queue_list true
|
||||
has claim_status true
|
||||
has cleanup_report true
|
||||
has catalog_drift true
|
||||
@@ -0,0 +1,3 @@
|
||||
=== queue list --format table ===
|
||||
queue list count=0
|
||||
(queue empty)
|
||||
@@ -0,0 +1,15 @@
|
||||
=== status --format table ===
|
||||
agentctl status main=/home/weichen/projects/Agent-System epoch=a994d494-0d9 version=1
|
||||
|
||||
## tasks active=1 closed=152 total=153
|
||||
task owner state worktree branch
|
||||
----------------------------------- ------ ------ ---------------------------------------------- ------------
|
||||
governance-visibility-p0-2026-09-24 Cursor active .worktrees/governance-visibility-p0-2026-09-24 feature/gove
|
||||
|
||||
## runs live=0
|
||||
(no live runs)
|
||||
|
||||
## queue depth=0
|
||||
(queue empty)
|
||||
|
||||
## policy loaded=ok revision=agent-governance-2026-09-23-fav-constraint-restored
|
||||
+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