refactor(task-worktree): 10项改进+清理旧版兼容

- 默认基线 v2-on-ui → master
- regen() 新增 ram_baseline.py --update/--write
- run_gates() 先 build 再 ci_local(修顺序错误)
- done 门禁失败时自动调 postmerge 补绿
- audit_residue 增加 git 冲突标记检测(JSON/TSV/CSV)
- plugin_trees() 同时扫描 .dsh-worktrees + .agents/worktrees
- harvest_memory() 只保留 dsh-mnemon,移除 workbuddy-ai 兼容
- done --dry-run 新增台账文件碰撞预检
- done 统一补绿链:regen→build→ci→postmerge
- SKILL.md 同步更新
This commit is contained in:
2026-11-13 17:00:01 +08:00
parent 8e1cc26c5d
commit 5efb58eb45
2 changed files with 185 additions and 63 deletions
@@ -77,7 +77,7 @@ def _main_worktree_of(start: Path):
_SCRIPT_ROOT = Path(__file__).resolve().parents[4]
REPO_ROOT = _main_worktree_of(_SCRIPT_ROOT) or _SCRIPT_ROOT
WT_ROOT = Path(os.environ.get("DTU_WT_ROOT") or (REPO_ROOT.parent / "_wt"))
BASE_BRANCH = os.environ.get("DTU_TASK_BASE", "v2-on-ui")
BASE_BRANCH = os.environ.get("DTU_TASK_BASE", "master")
STATE_DIR = WT_ROOT / ".state"
LOCKS_DIR = STATE_DIR / "locks"
@@ -86,11 +86,21 @@ LOCKS_DIR = STATE_DIR / "locks"
# /worktree)把任务树建在**仓库内**这个命名空间下,与本 skill 的仓外 _wt/ 刻意分开。
# .gitignore 里预置同名规则,正是为了让插件的 ensureIgnored() 见到就跳过、
# 不在会话中途往这份受控文件里追加一行。
PLUGIN_ROOT = REPO_ROOT / ".dsh-worktrees"
PLUGIN_NAMESPACE = "worktree" # 插件侧 WORKTREE_NAMESPACE:name 即分支尾段
PLUGIN_SUBDIR = PLUGIN_ROOT / PLUGIN_NAMESPACE
# DSH worktree 插件实际使用两个位置(历史演进):
# 旧版 dsh-task-worktree → .dsh-worktrees/worktree/
# 新版内置 create_worktree → .agents/worktrees/
# plugin_trees() 同时扫描两处,list 统一展示。
PLUGIN_ROOT_LEGACY = REPO_ROOT / ".dsh-worktrees"
PLUGIN_NAMESPACE_LEGACY = "worktree"
PLUGIN_SUBDIR_LEGACY = PLUGIN_ROOT_LEGACY / PLUGIN_NAMESPACE_LEGACY
PLUGIN_ROOT_AGENTS = REPO_ROOT / ".agents" / "worktrees"
PLUGIN_LOCK_PREFIX = "pw" # 插件树在锁 payload 里的持有者前缀
# 兼容旧引用:外部若直接读 PLUGIN_SUBDIR 仍指向 legacy 路径
PLUGIN_SUBDIR = PLUGIN_SUBDIR_LEGACY
# --------------------------------------------------------------------- 基础工具
@@ -472,29 +482,50 @@ def junction_label(st) -> str:
def plugin_trees():
""".dsh-worktrees/worktree/ 下的工作树(事实源 = git worktree list,不读插件 manifest)。
"""扫描两处 DSH 插件工作树(事实源 = git worktree list,不读插件 manifest)。
位置:
- .dsh-worktrees/worktree/ (旧版 dsh-task-worktree 插件)
- .agents/worktrees/ (新版内置 create_worktree 工具)
manifest.json 是插件私有格式(MANIFEST_VERSION 1,字段随版本变),而 git 的注册表
同时覆盖「插件正常建的树」和「manifest 丢了 / 树被手工删」的残留——桥接命令要看的
是真实存在的东西。
"""
try:
base = PLUGIN_SUBDIR.resolve()
except OSError:
bases = []
for candidate in (PLUGIN_SUBDIR_LEGACY, PLUGIN_ROOT_AGENTS):
try:
resolved = candidate.resolve()
if resolved.is_dir():
bases.append(resolved)
except OSError:
continue
if not bases:
return []
trees = []
seen_paths = set()
for ent in worktree_entries():
p = ent["path"]
try:
rp = p.resolve()
except OSError:
continue
if rp == base or base not in rp.parents:
matched_base = None
for base in bases:
if rp == base or base in rp.parents:
matched_base = base
break
if matched_base is None:
continue
# 去重(同一棵树不会出现在两个 base 下,但防御性检查)
path_key = str(rp)
if path_key in seen_paths:
continue
seen_paths.add(path_key)
dirty = (len(git_out("status", "--porcelain", cwd=p, check=False).splitlines())
if p.is_dir() else -1)
trees.append({
"name": rp.relative_to(base).as_posix(),
"name": rp.relative_to(matched_base).as_posix(),
"path": str(p),
"branch": ent.get("branch") or "(detached)",
"dirty": dirty,
@@ -605,30 +636,37 @@ def regen(cwd: Path) -> None:
run_script("scripts/gen_task_table.py", ["--write"], cwd, "重生成 FreeRTOS 任务表")
if list((Path(cwd) / "MDK-ARM").glob("**/*.map")):
run_script("scripts/check_mem_numbers.py", ["--write"], cwd, "同步内存/Flash 快照")
run_script("scripts/ram_baseline.py", ["--update"], cwd, "追加 RAM 趋势基线行")
run_script("scripts/ram_baseline.py", ["--write"], cwd, "同步 RAM 预算台账受管区")
else:
print("[skip] 未找到 .map,跳过内存快照同步(需先 /keil-build)")
print("[skip] 未找到 .map,跳过内存快照与 RAM 台账同步(需先 /keil-build)")
def run_gates(cwd: Path, skip_build: bool) -> bool:
ok = run_script("scripts/ci_local.py", [], cwd, "静态门禁 + 主机侧纯逻辑单测 本地等价 CI")
if skip_build:
# 先构建再跑门禁:ci_local.py 含 mem-budget 钩子,需要新鲜 .map 才能正确比对台账。
# 旧版先 ci 后 build 导致合并后 .map 仍是旧的 → mem-budget 必红。
ok = True
if not skip_build:
got, info = lock_acquire("build", "__done__", wait=900)
if not got:
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 /keil-build".format(info.get("task")))
return False
# 告知 /keil-build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
os.environ["DTU_LOCK_HELD"] = "build"
try:
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
"/keil-build(0 错 0 警)"):
ok = False
finally:
os.environ.pop("DTU_LOCK_HELD", None)
lock_release("build", "__done__", force=True)
else:
print("[skip] 按 --skip-build 跳过 /keil-build")
return ok
got, info = lock_acquire("build", "__done__", wait=900)
if not got:
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 /keil-build".format(info.get("task")))
return False
# 告知 /keil-build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
os.environ["DTU_LOCK_HELD"] = "build"
try:
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
"/keil-build(0 错 0 警)"):
ok = False
finally:
os.environ.pop("DTU_LOCK_HELD", None)
lock_release("build", "__done__", force=True)
# 构建完成(或跳过后)再跑静态门禁 + 单测,此时 .map 是新鲜的
if not run_script("scripts/ci_local.py", [], cwd, "静态门禁 + 主机侧纯逻辑单测 本地等价 CI"):
ok = False
return ok
@@ -668,45 +706,75 @@ def audit_residue(base_before: str, branch: str, cwd: Path) -> None:
print(" - " + f)
if len(both) > 30:
print(" …(其余 {} 个略)".format(len(both) - 30))
hits = []
hits_dup = []
hits_marker = []
conflict_markers = ("<<<<<<<", "=======", ">>>>>>>")
structured_exts = (".json", ".tsv", ".csv")
for f in both:
p = cwd / f
if not p.is_file():
continue
try:
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
content = p.read_text(encoding="utf-8", errors="replace")
lines = content.splitlines()
except OSError:
continue
# 检查 git 冲突标记(结构化文件尤其危险,会静默损坏 JSON/TSV)
ext = os.path.splitext(f)[1].lower()
if ext in structured_exts or any(m in content for m in conflict_markers):
marker_lines = [i + 1 for i, line in enumerate(lines)
if any(line.strip().startswith(m) for m in conflict_markers)]
if marker_lines:
hits_marker.append((f, marker_lines))
# 检查连续重复行(并集合并的经典症状)
dups = [i + 1 for i in range(1, len(lines))
if lines[i] == lines[i - 1] and lines[i].strip()]
if dups:
hits.append((f, dups))
if hits:
hits_dup.append((f, dups))
if hits_marker:
print()
print("!! 发现 git 冲突标记(结构化文件损坏风险极高):")
for f, lns in hits_marker:
print(" - {} 第 {} 行".format(f, ", ".join(str(l) for l in lns[:8])))
if hits_dup:
print()
print("!! 连续重复行(并集合并残留嫌疑):")
for f, dups in hits:
for f, dups in hits_dup:
print(" - {} 第 {} 行".format(f, ", ".join(str(d) for d in dups[:8])))
else:
print("[ok] 未检测到连续重复行")
if not hits_marker and not hits_dup:
print("[ok] 未检测到冲突标记或连续重复行")
print("提示:权威判据是 base / 两个 tip 三方对比(git show <ref>:<file>),别只看结果文件。")
def harvest_memory(st: dict) -> None:
"""任务工作树会被删除,先把它自己的记忆归并回主工作树,避免丢上下文"""
src = Path(st["path"]) / ".workbuddy-ai" / "memory"
if not src.is_dir():
"""任务工作树会被删除,先把它的 dsh-mnemon 记忆归并回主工作树,避免丢上下文。
只处理 .mnemon/ 目录(dsh-mnemon 插件)。目录级合并:只拷贝目标不存在的子项,
不覆盖已有内容。若无 .mnemon/ 则静默跳过。
"""
wt = Path(st["path"])
src_mn = wt / ".mnemon"
if not src_mn.is_dir():
return
dst_dir = REPO_ROOT / ".workbuddy-ai" / "memory"
dst_dir.mkdir(parents=True, exist_ok=True)
for f in sorted(src.glob("*.md")):
text = f.read_text(encoding="utf-8", errors="replace").strip()
if not text:
continue
dst = dst_dir / f.name
marker = "## [task-worktree 归并自任务 {} / {}]".format(st["id"], st["slug"])
with dst.open("a", encoding="utf-8", newline="\n") as fh:
fh.write("\n" + marker + "\n\n" + text + "\n")
print("[ok] 记忆归并 {} -> {}".format(f.name, dst))
dst_mn = REPO_ROOT / ".mnemon"
dst_mn.mkdir(parents=True, exist_ok=True)
import shutil
count = 0
for item in sorted(src_mn.iterdir()):
target = dst_mn / item.name
try:
if item.is_dir():
if not target.exists():
shutil.copytree(str(item), str(target))
count += 1
else:
if not target.exists():
shutil.copy2(str(item), str(target))
count += 1
except OSError as e:
print("[warn] 记忆拷贝失败 {}: {}".format(item.name, e))
if count:
print("[ok] 记忆归并(mnemon) {} 个条目 -> {}".format(count, dst_mn))
def teardown_worktree(st: dict, force: bool = False, base: str = None) -> None:
@@ -989,6 +1057,25 @@ def cmd_done(a) -> int:
raise Fail("预演发现冲突:到任务工作树里 `git merge {}` 解决并提交后重跑 done".format(base))
tree = (r.stdout.splitlines() or ["?"])[0].strip()
print("[ok] 无冲突,预演结果树 {}".format(tree))
# 台账类生成物碰撞预检:双方都改过的已知生成文件在合并后大概率需要重算
ledger_files = (
"scripts/ram_baseline.tsv",
"scripts/size_attestation.json",
"docs/内存管理/RAM预算台账.md",
"docs/内存管理/RAM使用分析与优化方案.md",
)
mb = git_out("merge-base", base, branch, cwd=main, check=False)
task_changed = set(filter(None, git_out("diff", "--name-only", mb, branch, cwd=main, check=False).splitlines()))
base_changed = set(filter(None, git_out("diff", "--name-only", mb, base, cwd=main, check=False).splitlines()))
collided = sorted(f for f in ledger_files if f in task_changed and f in base_changed)
if collided:
print()
print("[warn] 以下 {} 个台账/凭证文件双方都改过,合并后需 postmerge 重算:".format(len(collided)))
for f in collided:
print(" - " + f)
print(" (done 会自动调 postmerge 补绿;若 --dry-run 请留意此提示)")
if a.dry_run:
print()
print("--dry-run:到此为止,未做任何改动。")
@@ -1007,6 +1094,8 @@ def cmd_done(a) -> int:
print("=== 合并残留审计 ===")
audit_residue(base_before, branch, cwd=main)
# 统一补绿链:regen + build + ci + postmerge 收敛到一条路径。
# regen 先跑(任务表/内存快照),然后 build+ci,最后 postmerge 兜底凭证与文档。
print("=== 重生成受管产物 ===")
regen(main)
@@ -1017,13 +1106,46 @@ def cmd_done(a) -> int:
print("=== 门禁 ===")
gates_ok = run_gates(main, skip_build=a.skip_build)
if not gates_ok:
# 无论门禁是否通过,都跑一次 postmerge 确保凭证/文档全库一致。
# 门禁已通过时 postmerge 检测到新鲜凭证会直接跳过(零开销)。
print("=== postmerge 补绿(凭证 + 文档全库)===")
pm_args = argparse.Namespace(force=False, skip_mem=False, coverage=False, skip_docs=False)
try:
pm_rc = cmd_postmerge(pm_args)
except Exception as e:
print("[warn] postmerge 异常:{}".format(e))
pm_rc = EXIT_FAIL
# postmerge 完成后检查是否有回填产物需要提交
dirty = git_out("diff", "--name-only", "HEAD", cwd=main, check=False).strip()
if dirty and not gates_ok:
print()
print("!! 门禁未全绿:工作树与分支**保留**,未清场。")
print("!! 门禁未全绿且 postmerge 产生了回填文件,工作树与分支**保留**。")
print(" 主干已含合并提交 {}".format(base_after[:12]))
print(" 可选:a) 在主工作树修正后追加提交(推荐)")
print(" ——体积凭证/台账红就用 `postmerge` 自动诊断回填,别手抄补链")
print(" b) 回退本次合并:git -C \"{}\" reset --hard {}".format(main, base_before[:12]))
print("[i] 以下文件待提交(属 Ask first):")
for line in dirty.splitlines():
if line.strip():
print(" - " + line.strip())
print(" 建议:git add <上面列的文件> && git commit -F <正文文件>")
print(" 提交后重跑 `done {}` 完成清场。".format(a.task_id))
return EXIT_FAIL
elif not gates_ok:
print()
print("!! 门禁未全绿,工作树与分支**保留**,未清场。")
print(" 主干已含合并提交 {}".format(base_after[:12]))
print(" 请手动排查后重跑 `done {}`。".format(a.task_id))
print(" 或回退本次合并:git -C \"{}\" reset --hard {}".format(main, base_before[:12]))
return EXIT_FAIL
# 门禁通过 + postmerge 完成 → 若有回填文件则提示提交后再清场
if dirty:
print()
print("[i] postmerge 已回填,以下文件待提交(属 Ask first):")
for line in dirty.splitlines():
if line.strip():
print(" - " + line.strip())
print(" 建议:git add <上面列的文件> && git commit -F <正文文件>")
print(" 提交后重跑 `done {}` 完成清场。".format(a.task_id))
return EXIT_FAIL
print("=== 清场 ===")