fix(tasks): make status git-authoritative, not Vikunja
修掉一处会让并行保护失效的设计缺口:AGENTS.md 声明「状态」权威在 Vikunja, 但离线门禁的检查 1 读的是 frontmatter 的 status,两者之间没有任何同步。 有人在看板上拖了卡片,frontmatter 不变,门禁就用过期数据放行了。 status 的权威定为 git frontmatter,理由与 write_paths 相同:它是判定 「两个活跃任务不得写同一路径」的输入,而门禁必须离线可跑;status 变更 决定谁能碰哪些文件,本来就该产生 commit。 - AGENTS.md:把 status 从 Vikunja 权威列表移入 git 原生表,并说明 bucket 与 done 仅为人类视图,不一致时以 git 为准 - agent-context.json:tracker.git_native_fields 增加 status - validate_agent_context.py:强制 git_native_fields 必须含 status, 已用反例验证缺失时报错 - vikunja_export.py:新增只读漂移检测,导出时比对 frontmatter status 与 Vikunja done,不一致则提示;只提示不修正,不反向写回 - docs/tasks/README.md、T-008 方案第 1/2 节:同步口径 顺带解决了窄 token 时期的 bucket 401 遗留问题——status 权威在 git, agent 不再依赖 bucket 移动来表达状态。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -354,6 +354,33 @@ def frontmatter_task_id(text: str) -> int | None:
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def frontmatter_status(text: str) -> str | None:
|
||||
parts = text.split("---\n", 2)
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
match = re.search(r"^status:[ \t]*(\S+)", parts[1], re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def report_status_drift(path: Path, status: str | None, task: dict[str, Any]) -> None:
|
||||
"""只读比对 frontmatter 的 status 与 Vikunja 的 done 标志。
|
||||
|
||||
git 是 status 的权威,看板只是人类视图,所以这里只提示、不修正,
|
||||
更不会反向写回——反向同步会重新引入两个权威。
|
||||
"""
|
||||
if status is None:
|
||||
return
|
||||
expected_done = status == "DONE"
|
||||
actual_done = bool(task.get("done"))
|
||||
if expected_done != actual_done:
|
||||
name = path.resolve().relative_to(REPO_ROOT)
|
||||
print(
|
||||
f"提示 {name}:frontmatter status={status},但 Vikunja done={actual_done}。"
|
||||
"以 git 为准;看板卡片需要人工归位。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 自检
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -402,6 +429,24 @@ def selftest() -> int:
|
||||
if "只允许 GET" not in str(error):
|
||||
failures.append(f"{method} 被拒绝的原因不对:{error}")
|
||||
|
||||
# status 漂移检测:只提示不修正,且不得反向写回。
|
||||
import io
|
||||
from contextlib import redirect_stderr
|
||||
|
||||
for status, done, should_warn in (
|
||||
("DONE", True, False), ("DOING", False, False),
|
||||
("DONE", False, True), ("DOING", True, True),
|
||||
):
|
||||
buffer = io.StringIO()
|
||||
with redirect_stderr(buffer):
|
||||
report_status_drift(REPO_ROOT / "docs/tasks/T-000.md", status, {"done": done})
|
||||
warned = "提示" in buffer.getvalue()
|
||||
if warned != should_warn:
|
||||
failures.append(
|
||||
f"漂移检测 status={status} done={done}:预期 {'提示' if should_warn else '静默'},"
|
||||
f"实际 {'提示' if warned else '静默'}"
|
||||
)
|
||||
|
||||
# 幂等:同样输入两次拼接结果必须一致。
|
||||
template = ("---\nid: T-999\nvikunja_task_id: 7\n---\n\n"
|
||||
f"{EXPORT_BEGIN_TEMPLATE.format(task_id=7, synced='x', sha256='0' * 64)}\n"
|
||||
@@ -436,6 +481,7 @@ def export_file(path: Path, apiurl: str, apikey: str, check_only: bool) -> bool:
|
||||
|
||||
task = http_get(apiurl, apikey, f"/tasks/{task_id}")
|
||||
comments = http_get(apiurl, apikey, f"/tasks/{task_id}/comments") or []
|
||||
report_status_drift(path, frontmatter_status(text), task)
|
||||
body = build_block_body(task, comments)
|
||||
|
||||
existing = EXPORT_BEGIN_RE.search(text)
|
||||
|
||||
Reference in New Issue
Block a user