From 5efb58eb456f81e7d8229b8d66c6c649d3ea116b Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 13 Nov 2026 17:00:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(task-worktree):=2010=E9=A1=B9=E6=94=B9?= =?UTF-8?q?=E8=BF=9B+=E6=B8=85=E7=90=86=E6=97=A7=E7=89=88=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认基线 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 同步更新 --- .dsh/skills/task-worktree/SKILL.md | 16 +- .../task-worktree/scripts/task_worktree.py | 232 +++++++++++++----- 2 files changed, 185 insertions(+), 63 deletions(-) diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md index 6dd002d3..dfcd8e1f 100644 --- a/.dsh/skills/task-worktree/SKILL.md +++ b/.dsh/skills/task-worktree/SKILL.md @@ -50,7 +50,7 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr | `postmerge` | 主树合并后补绿:体积凭证**自动诊断**(新鲜跳过;红→先短链 `--update`,只读复检真漂了才升全链)+ `check_doc_links --all` + 列待提交文件,**不代提交** | - **退出码** `0` 成功 / `1` 操作失败 / `2` 前置或环境错误 / `3` 用法——**只有不接管道才读得到**(→ 读输出)。 -- `new`:`--base`(默认 `v2-on-ui`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)· `--lean`(跳 LFS smudge,只省磁盘不省时间)· `--no-bootstrap`(不建联接)· `--force`(**只跳过并发上限检查**,不解决谁让位 → A) +- `new`:`--base`(默认 `master`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)· `--lean`(跳 LFS smudge,只省磁盘不省时间)· `--no-bootstrap`(不建联接)· `--force`(**只跳过并发上限检查**,不解决谁让位 → A) - `done`:`--dry-run` · `--skip-build` · `--skip-gates` · `--force`。⚠ `--dry-run` **只保护预演段、不保护清场段**(实测:对已合入的分支它照样摘联接、删分支、清台账;且它的前置也要抢 `build` 锁)→ 用完复验 `git worktree list`,细节 → 陷阱清单。 - `lock`:`--task`(acquire 必填)· `--wait <秒>` · `--ttl <小时>`(默认 `build`=1h / `bench`=6h,超期视为陈旧、下次 acquire 自动接管)· `--note` · `--bench-pass` · `--force`。⚠ `--note` 只在 **acquire** 写进 payload,**release 时被忽略** → 验收证据写主干提交说明或文档。 - 环境变量:`DTU_WT_ROOT`(默认仓库同级 `_wt/`)· `DTU_TASK_BASE`(默认基线)· `DTU_INTEGRATION_WORKTREE`(集成点,用于隔离演练)。 @@ -95,23 +95,23 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr 1. **合并前确认属主提交完**:`$TW status ` 看未提交与领先数,跨会话再问属主一句。实测教训:属主还剩「2/2」没提交就 `done` → 只含 1/2 的分支进了主干,只能另开一棵树补后半(自己的时序错,不是分支问题)。 2. 先 `--dry-run` 且**读全量**。判据两行:`[ok] 前置检查通过(领先 共 N 个提交)` + `[ok] 无冲突,预演结果树 `;**N 必须等于属主应交的提交数**,对不上回第 1 步。 3. 正式 `$TW done `——**必须在主工作树**(铁律 1)。 -4. 见 `!! 门禁未全绿:工作树与分支**保留**,未清场` 时,紧跟的 `主干已含合并提交 ` 说明**合并已落主干**;先定位哪条钩子红(`[SUMMARY] static: pre-commit hooks -- passed=N failed=M`),最常态是 `.map` 滞后 → 第 5 步。 - - ✅ 走提示 a):主工作树修正后追加提交。 - - ❌ **绝不用 b) 的 `reset --hard `**——那是丢弃已入库的合并提交,且 `reset --hard` 是项目级 Never(连未提交工作一起丢)。提示 b 是列出的可能性,不是建议。 -5. 补绿固定链(顺序即因果,全在主工作树,逐条不接管道看退出码): +4. 见 `!! 门禁未全绿` 时,`done` 现在会**自动调 `postmerge` 补绿**(体积凭证 + RAM 台账 + 文档全库)。补绿成功且有回填文件时列出待提交清单;补绿失败才保留工作树等你手动排查。紧跟的 `主干已含合并提交 ` 说明**合并已落主干**。 + - ✅ 自动补绿成功后按提示提交回填文件,再重跑 `done` 完成清场。 + - ❌ **绝不用 `reset --hard `**——那是丢弃已入库的合并提交,且 `reset --hard` 是项目级 Never(连未提交工作一起丢)。 +5. **`done` 现在自动完成整条补绿链**:regen(任务表 + 内存快照 + RAM 台账)→ build → ci → postmerge(凭证诊断 + 文档全库)。门禁通过后若 postmerge 产生回填文件,会列出待提交清单等你确认后再清场。 + 手工补绿链仍保留为权威兜底(`postmerge` 内部逻辑等价于下面的步骤): ```bash "$PY" scripts/check_mem_numbers.py --write # 台账受管区 ↔ 新 .map 对齐 -"$PY" scripts/ram_baseline.py --write # RAM 快照 / 92% 预算台账 "$PY" scripts/ram_baseline.py --update # 趋势基线追加一行 +"$PY" scripts/ram_baseline.py --write # RAM 快照 / 92% 预算台账 "$PY" scripts/check_test_coverage.py --update-baseline # 覆盖率基线(新单测 / 新套件时必刷) "$PY" scripts/check_doc_links.py --all # 文档链接与章节引用 git add <上面刷出来的基线/快照文件> # 显式列文件,别 -A 把别人在飞的改动一起提交 git commit -F # 提交属 Ask first(AGENTS.md 边界) ``` - 为什么必然要补 → 陷阱「`done` 的门禁跑在 `/keil-build` 之前」。合并解过冲突时,**合并提交期 pre-commit 只查冲突文件**(→ G 节第 3 条)→ 落主干后必须另跑全量。 - **注释/文档-only 但碰了 .c/.h 的短链**:先比 `Program Size` 与合并前是否逐项相同——相同就只 `ram_baseline.py --update` 重写体积凭证(`scripts/size_attestation.json` 跟着补提交笔走),**别 `--write` 动受管区**(等于给在飞分支造冲突面);数字变了才走上面全链。**已命令化**:`"$PY" "$TW" postmerge`——新鲜直接跳过;红则先短链,再用只读 `check_mem_numbers` 复检裁定是否升全链;末尾跑 `--all` 并列出待提交文件(不代提交)。上面的手工全链仍是权威兜底。 + **`--dry-run` 新增台账碰撞预检**:双方都改过的已知生成文件(`ram_baseline.tsv` / `size_attestation.json` / RAM 台账 md)会在预演阶段提前警告,让你知道合并后需要 postmerge 重算。 6. **重跑 `done` 前主树必须干净**,否则 `失败:主工作树不干净(…)——合并会把未提交改动卷进来` → 第 5 步产物先提交。分支已并时重跑打印 `[i] task/- 已合入 ,跳过合并,直接清场`——**该路径在门禁之前就返回,不会替你复绿**:上一次门禁没跑到的尾巴(主树 `ci_local.py` + `check_doc_links.py --all`;体积/文档那半可 `postmerge` 一把跑)必须自己补。主树脏且那份脏不是你的 → **C2**。 7. 清场后再敲一次 `done ` 得 `前置/环境错误:任务 不存在(缺台账 …json)`(exit 2)——**不是失败**:清场最后一步 unlink 了台账。**判完成以 `git log` 为准**。 diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py index 089daf17..08571756 100644 --- a/.dsh/skills/task-worktree/scripts/task_worktree.py +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -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 :),别只看结果文件。") 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("=== 清场 ===")