feat(ack): shard task records for merge-safe workspaces

This commit is contained in:
2026-09-20 19:15:43 +08:00
parent fd13dad2b0
commit 130dcc36c5
29 changed files with 985 additions and 218 deletions
+1 -1
View File
@@ -47,6 +47,6 @@
## 如何判断完成
- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。最近一次运行的结果在同一张表的 `last_result` / `last_executed` 列。
- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。每次运行的结果保存在独立的 `.pouch/tester/runs/<run-id>.*` 文件中。
- **维护**:目标 id 能 `查看` 到,且检查通过。
- **运行**:有本次 `runs/*.yaml` 快照和同名 `.csv` 成绩单(`executed` 列区分已跑 / 未跑);没填 HOST 时会停,而不是编造失败。
+1 -1
View File
@@ -71,7 +71,7 @@ python3 <skill-dir>/scripts/catalog.py list --root <project-root>
python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root>
```
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。有运行快照时,这份 CSV 末尾有 `last_result` / `last_executed`(`yes` 已跑,`no` 未跑——含 `skipped` 与 `not-run`,空=未进入最近一次运行)。
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。该 CSV 只包含用例定义;运行结果只写入独立的 `.pouch/tester/runs/<run-id>.yaml`,不会回写清单。
## 查看
+1 -1
View File
@@ -35,5 +35,5 @@ python3 <skill-dir>/scripts/catalog.py next-id --root <root> --category <CAT>
- 为了能在默认风险下跑而把 `risk` 改低
- 一次塞进无关的一批用例
- 手改 `runs/` 冒充执行记录
- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;`last_*` 列来自最近一次 `runs/*.yaml`)
- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;运行结果只在对应 `runs/<run-id>.*` 中保存)
- 手改 `runs/*.csv`(必须从对应 yaml 用 `generate_run_csv.py` 生成)
+3 -2
View File
@@ -83,13 +83,14 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。
写入 `.pouch/tester/runs/<UTC时间>-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。立刻用同一快照生成 CSV,不要手改:
写入 `.pouch/tester/runs/<UTC时间>-<范围>-<随机后缀>.yaml`,可用
`generate_run_csv.new_run_id(<范围>)` 生成同名 `runId`。记录包含唯一 `runId`、筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。记录文件只创建一次,不覆盖已有文件。立刻用同一快照生成 CSV,不要手改:
```bash
python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
```
这会写出 `.pouch/tester/runs/<UTC时间>-<范围>.csv`(`executed=yes|no`),并刷新 `cases.csv` 的 `last_result` / `last_executed` 列。对话里两个路径都报。
这会写出与快照同名的 `.pouch/tester/runs/<run-id>.csv`(`executed=yes|no`),不会修改 `cases.csv`。对话里两个路径都报。
对话报告:
+6 -2
View File
@@ -9,8 +9,8 @@ catalog.yaml 分类、风险阶梯、session、变量
local.yaml.example HOST 与授权开关模板(默认 127.0.0.1)
local.yaml 本机覆盖,不提交
cases/<category>.yaml 一个分类一个文件(执行源)
cases.csv 给人看的扁平清单;generate_cases_csv.py 读 cases/*.yaml,并拼最近一次运行的 last_* 列
runs/*.yaml 每次运行快照(执行源)
cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果
runs/<run-id>.yaml 每次运行快照(执行源);run-id 必须唯一
runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成
```
@@ -33,6 +33,10 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
`catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
运行记录使用 UTC 时间加随机后缀命名,例如
`20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。
不要使用固定的 `latest.yaml` 或覆盖已有运行文件。
## 一条用例
写在对应分类文件的 `cases:` 列表里。
+5 -33
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。"""
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。
运行结果属于 runs/ 下的追加式记录,不回写本清单,避免多个工作区共同
刷新运行状态造成合并冲突。
"""
from __future__ import annotations
@@ -32,11 +36,6 @@ FIELDS = (
"how",
"expected",
"notes",
"last_run",
"last_result",
"last_executed",
"last_evidence",
"last_notes",
)
@@ -148,28 +147,8 @@ def sort_cases(root: Path, cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
)
def last_run_lookup(root: Path) -> tuple[str, dict[str, dict[str, Any]]]:
from generate_run_csv import latest_run_yaml, load_run, results_by_id
run_path = latest_run_yaml(root)
if run_path is None:
return "", {}
try:
run = load_run(run_path)
except SystemExit:
return "", {}
try:
rel = str(run_path.relative_to(root))
except ValueError:
rel = str(run_path)
return rel, results_by_id(run)
def export_cases_csv(root: Path, out: Path | None = None) -> Path:
from generate_run_csv import executed_cell
cases = sort_cases(root, load_cases(root))
last_run, last_by_id = last_run_lookup(root)
path = out or (tester_dir(root) / "cases.csv")
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as handle:
@@ -177,8 +156,6 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
writer.writeheader()
for case in cases:
cid = str(case.get("id") or "")
last = last_by_id.get(cid) or {}
last_result = str(last.get("result") or "") if last else ""
writer.writerow(
{
"category": case.get("category") or "",
@@ -197,11 +174,6 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
"how": how_brief(case.get("how")),
"expected": one_line(case.get("expected")),
"notes": one_line(case.get("notes")),
"last_run": last_run if last else "",
"last_result": last_result,
"last_executed": executed_cell(last_result) if last else "",
"last_evidence": one_line(last.get("evidence")) if last else "",
"last_notes": one_line(last.get("notes")) if last else "",
}
)
return path
+24 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import Counter
from pathlib import Path
@@ -19,6 +20,7 @@ except ImportError: # pragma: no cover
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
EXECUTED_NO = frozenset({"skipped", "not-run"})
FIELDS = (
@@ -56,6 +58,22 @@ def latest_run_yaml(root: Path) -> Path | None:
return files[-1] if files else None
def new_run_id(scope: str, suffix: str | None = None) -> str:
"""Return a collision-resistant filename/id for a new run snapshot."""
from datetime import datetime, timezone
from secrets import token_hex
clean_scope = re.sub(r"[^A-Za-z0-9._-]+", "-", scope).strip("-") or "run"
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
clean_suffix = re.sub(r"[^A-Za-z0-9._-]+", "-", suffix or "").strip("-")
random_part = token_hex(3)
parts = [stamp, clean_scope]
if clean_suffix:
parts.append(clean_suffix)
parts.append(random_part)
return "-".join(parts)
def load_run(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
@@ -63,6 +81,11 @@ def load_run(path: Path) -> dict[str, Any]:
results = data.get("results")
if not isinstance(results, list):
raise SystemExit(f"{path} 缺少 results 列表")
run_id = data.get("runId")
if run_id is not None and (
not isinstance(run_id, str) or RUN_ID_RE.fullmatch(run_id) is None
):
raise SystemExit(f"{path} 的 runId 格式无效")
return data
@@ -88,7 +111,6 @@ def export_run_csv(
root: Path,
run_path: Path | None = None,
out: Path | None = None,
refresh_cases: bool = True,
) -> Path:
path = run_path or latest_run_yaml(root)
if path is None:
@@ -122,10 +144,6 @@ def export_run_csv(
"maps_to": case.get("maps_to") or "",
}
)
if refresh_cases:
from generate_cases_csv import export_cases_csv
export_cases_csv(root)
return dest
@@ -137,7 +155,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--no-refresh-cases",
action="store_true",
help="不要顺便刷新 cases.csv 的 last_* 列",
help="兼容旧命令;cases.csv 已不会被运行结果刷新",
)
args = parser.parse_args(argv)
root = resolve_root(args.root)
@@ -147,7 +165,6 @@ def main(argv: list[str] | None = None) -> int:
root,
run_path=run_path,
out=out,
refresh_cases=not args.no_refresh_cases,
)
counts: Counter[str] = Counter()
with path.open(encoding="utf-8-sig", newline="") as handle: