diff --git a/.dsh/skills/task-worktree/.gitignore b/.dsh/skills/task-worktree/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/.dsh/skills/task-worktree/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md new file mode 100644 index 00000000..3ec5084e --- /dev/null +++ b/.dsh/skills/task-worktree/SKILL.md @@ -0,0 +1,96 @@ +--- +name: task-worktree +description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、build/bench 两类独占锁、done 预演合并回主干并审计并集残留。规格见 docs/环境与部署/并行任务工作树方案.md。 +whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时。 +--- + +# task-worktree — 多任务并行开发空间(短命令 `@task`) + +**执行体 = 本 skill 自带脚本** `.dsh/skills/task-worktree/scripts/task_worktree.py` +(纯标准库,任意 python3;拷整个 skill 目录到别的项目即可用)。 + +```powershell +# cwd = 仓库根(或任意子目录) +& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" <子命令> [参数] +``` + +退出码:`0`=成功 / `1`=操作失败 / `2`=前置或环境错误 / `3`=用法。 + +## 固定命令 + +| 命令 | 作用 | +|---|---| +| `new ` | 建分支 `task/-` + 工作树 + 共享联接 + 台账;打印新空间路径 | +| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 | +| `status ` | 单任务详情(未提交清单、相对基线漂移、台架记录、已建联接) | +| `sync ` | 在任务工作树里 `rebase` 追平主干,把冲突控制在任务分支上 | +| `lock acquire\|release\|status ` | 独占资源锁 | +| `done ` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 | +| `abort ` | 放弃任务(工作树 + 分支一起删,**永久丢弃**) | + +`new` 常用开关:`--base <分支>`(默认 `v2-on-ui`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)· +`--lean`(跳过 Git LFS smudge)· `--no-bootstrap`(不建联接)。 + +`done` 常用开关:`--dry-run`(只预演,零改动)· `--skip-build` · `--skip-gates` · `--force`。 + +**环境变量**(高级):`DTU_WT_ROOT`(任务容器位置,默认仓库同级 `_wt/`)· +`DTU_TASK_BASE`(默认基线分支,默认 `v2-on-ui`)· +`DTU_INTEGRATION_WORKTREE`(集成点,默认主工作树——用于隔离演练/测试)。 + +## 典型流程 + +```powershell +& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" new N21 dhara-isfree --needs-bench +# → 把新会话的工作目录切到打印出来的 _wt/N21-dhara-isfree/,在那里干活 + +& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" lock acquire bench --task N21 +# 烧录 / @test / 板级套件…… +& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" lock release bench --task N21 --bench-pass + +# 回到主工作树执行回收 +& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" done N21 +``` + +## 铁律 + +1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。 +2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。 +3. **`build` 与 `bench` 都必须持锁**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、 + 设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。** +4. **单写者文档**:`docs/待处理内容.md`、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、 + `.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。 +5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再 + `git worktree remove`。**手工清场时千万别直接 `git worktree remove`**——git 可能递归进联接, + 把主工作树的 `.venv` 一起删掉。 +6. **并发上限 3 棵工作树**(每棵约 83 MB 检出)。 +7. **工作树放仓库同级 `_wt/`,绝不在仓库内部**——否则污染 `git status`、`pre-commit --all-files` 与全库搜索。 + +## 陷阱(实测) + +- **联接不是符号链接**:本仓库 `core.symlinks=false`,git 里的 symlink 会被检出成 + 「内容是目标路径的文本文件」。共享 `.venv` 只能用 Windows **目录联接**(`mklink /J`, + 不需要管理员权限),且联接必须建在版本控制之外。 + 实测(2026-10-12):用 ctypes 直调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)` + 会被系统拒绝(Win32 4392),`mklink /J` 正常——所以脚本走后者,参数以列表交给 + subprocess,含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号。 +- **联接建失败不中止 `new`**:只告警并打印手工补建命令,避免留下半成品任务空间; + 但该任务里跑不了 `.venv\Scripts\python.exe`,`@build` / `ci_local.py` 会找不到解释器。 +- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并。 +- **并集合并残留**:双方都改过的文件(尤其 `.gitignore`)会被 git 自动并成**并集**, + 可能留下指向已删目录的死规则或重复条目。脚本会自动报「双方都改过」的文件清单 + + 连续重复行;**权威判据是 base / 两个 tip 三方对比**(`git show :` 逐项 `grep -F`), + 别用 `tail -N` 目测。 +- **`.workbuddy-ai/` 刻意不共享**:记忆不随任务分叉,也不给联接递归删除的机会。 + 任务工作树自己的记忆由 `done` / `abort` 在清场前归并回主工作树。 +- **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB, + 两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean`。 +- **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。 + 所以判「陈旧」只看**时效**(默认 6 小时,`LOCK_TTL_HOURS`)加显式 `--force`;pid 仅作排障记录。 + 同任务重复 acquire 是幂等的(不会把自己锁死)。 +- **陈旧锁可自动接管**:超过 6 小时未释放即视为陈旧,下次 acquire 会接管并提示;活锁需 `--force`。 + +## 与其它 skill 的衔接 + +- 构建 = `keil-build` skill(`@build`);烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill +- 新 `.c` 文件 = `new-source-file` skill(`uvprojx` 与 `CMakeLists.txt` 双端注册) +- 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md` diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py new file mode 100644 index 00000000..7a7ab99c --- /dev/null +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python3 +"""task-worktree — 多任务并行开发的空间管理(git worktree 生命周期) + +执行体 = 本脚本(纯标准库)。规格见 docs/环境与部署/并行任务工作树方案.md, +用法与铁律见同目录 SKILL.md。 + +退出码:0=成功 / 1=操作失败 / 2=前置或环境错误 / 3=用法错误 +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +# 输出统一 UTF-8,避免被控制台代码页(cp936)转码破坏 +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") + except Exception: + pass + +EXIT_OK, EXIT_FAIL, EXIT_ENV, EXIT_USAGE = 0, 1, 2, 3 + +BRANCH_PREFIX = "task/" +MAX_CONCURRENT = 3 +LOCK_NAMES = ("build", "bench") +LOCK_TTL_HOURS = 6.0 # 超过此持有时间即视为陈旧锁,下次 acquire 自动接管 +JUNCTION_SPECS = ( + (".venv", "Python 环境"), + ("simulator/SDL2", "模拟器 SDL2 SDK"), +) +FILE_ATTRIBUTE_REPARSE_POINT = 0x400 + +ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +class Fail(Exception): + """操作失败(退出码 1)""" + + +class EnvError(Exception): + """前置或环境错误(退出码 2)""" + + +# --------------------------------------------------------------------- 仓库定位 + + +def _main_worktree_of(start: Path): + """借 git 反查「主工作树」路径——脚本可能被从任务工作树里调用,不能只靠 __file__""" + for extra in (["--path-format=absolute"], []): + r = subprocess.run( + ["git", "-C", str(start), "rev-parse", *extra, "--git-common-dir"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + stdin=subprocess.DEVNULL, + ) + if r.returncode == 0 and r.stdout.strip(): + common = Path(r.stdout.strip()) + if not common.is_absolute(): + common = (start / common).resolve() + return common.parent if common.name == ".git" else common + return None + + +_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") + +STATE_DIR = WT_ROOT / ".state" +LOCKS_DIR = STATE_DIR / "locks" + + +# --------------------------------------------------------------------- 基础工具 + + +def git(*args, cwd=None, check=True, env=None): + cmd = ["git"] + [str(a) for a in args] + full_env = dict(os.environ) + if env: + full_env.update(env) + r = subprocess.run( + cmd, cwd=str(cwd or REPO_ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, env=full_env, + ) + if check and r.returncode != 0: + raise Fail("git {} 失败(rc={})\n{}".format( + " ".join(cmd[1:]), r.returncode, (r.stdout + r.stderr).strip())) + return r + + +def git_out(*args, **kw): + return git(*args, **kw).stdout.strip() + + +def worktree_entries(): + out = git_out("worktree", "list", "--porcelain") + entries, cur = [], None + for line in out.splitlines(): + if line.startswith("worktree "): + if cur: + entries.append(cur) + cur = {"path": Path(line[len("worktree "):]), "branch": None} + elif cur is None: + continue + elif line.startswith("branch refs/heads/"): + cur["branch"] = line[len("branch refs/heads/"):] + if cur: + entries.append(cur) + return entries + + +def main_worktree() -> Path: + """集成点(合并发生的地方)。默认 = 主工作树;可用环境变量覆盖,便于演练/测试。""" + override = os.environ.get("DTU_INTEGRATION_WORKTREE") + if override: + p = Path(override) + if not p.is_absolute(): + p = (REPO_ROOT / p).resolve() + if not p.is_dir(): + raise EnvError("DTU_INTEGRATION_WORKTREE 不存在:{}".format(p)) + return p + entries = worktree_entries() + if not entries: + raise EnvError("无法解析 git worktree 列表") + return entries[0]["path"] + + +def venv_python(tree: Path = None) -> Path: + """优先用该工作树自己的 .venv(任务树里是指向主树的联接),否则退回主工作树""" + for root in (tree, REPO_ROOT): + if root is None: + continue + p = Path(root) / ".venv" / "Scripts" / "python.exe" + if p.is_file(): + return p + return Path(sys.executable) + + +def run_script(script: str, args, cwd: Path, label: str, tail: int = 40) -> bool: + """跑项目脚本并回报。输出可能上千行,只回显尾部(结论与报错都在尾部) + + ⚠ 脚本必须取自 **cwd 那棵树**,不能固定用 REPO_ROOT: + 项目脚本普遍以 `__file__` 反推仓库根(如 `scripts/ci_local.py`、 + `scripts/check_registration.py`),若拿主树的脚本、却给集成树的 cwd, + 脚本仍会去校验主树——门禁就测错了对象(2026-10-12 实测踩到: + 报出主树才有的 iec104 未注册缺口,而集成树里根本没那些文件)。 + """ + script_path = Path(cwd) / script + if not script_path.is_file(): + script_path = REPO_ROOT / script + cmd = [str(venv_python(Path(cwd))), str(script_path)] + [str(x) for x in args] + r = subprocess.run( + cmd, cwd=str(cwd), capture_output=True, text=True, + encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, + ) + out = (r.stdout + r.stderr).strip() + print("[{}] {}".format("ok" if r.returncode == 0 else "!!", label)) + if out: + lines = out.splitlines() + if len(lines) > tail: + print(" …(前 {} 行略)".format(len(lines) - tail)) + lines = lines[-tail:] + print(" " + "\n ".join(lines)) + return r.returncode == 0 + + +# --------------------------------------------------------------------- 台账 + + +def state_file(task_id: str) -> Path: + return STATE_DIR / (task_id + ".json") + + +def load_state(task_id: str) -> dict: + p = state_file(task_id) + if not p.is_file(): + raise EnvError("任务 {} 不存在(缺台账 {})".format(task_id, p)) + return json.loads(p.read_text(encoding="utf-8")) + + +def save_state(st: dict) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + state_file(st["id"]).write_text( + json.dumps(st, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def all_states(): + if not STATE_DIR.is_dir(): + return [] + out = [] + for p in sorted(STATE_DIR.glob("*.json")): + try: + out.append(json.loads(p.read_text(encoding="utf-8"))) + except Exception: + continue + return out + + +# --------------------------------------------------------------------- 独占锁 + + +def lock_age_hours(info) -> float: + try: + return max(0.0, (time.time() - float(info.get("epoch") or 0)) / 3600.0) + except (TypeError, ValueError): + return float("inf") + + +def lock_path(name: str) -> Path: + return LOCKS_DIR / (name + ".lock") + + +def lock_read(name: str): + p = lock_path(name) + if not p.is_file(): + return None + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {"task": "?", "pid": 0, "epoch": 0, "at": "?"} + + +def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: str = ""): + """抢占独占资源。 + + ⚠ 锁**不**绑定进程存活:`lock acquire` 本身是短命进程,而用锁的烧录/构建 + 在另一个进程里跑。因此判「陈旧」只看**时效**(超过 LOCK_TTL_HOURS), + 外加显式 `--force`;pid 仅作记录便于排障。 + """ + LOCKS_DIR.mkdir(parents=True, exist_ok=True) + p = lock_path(name) + deadline = time.time() + max(0, wait) + while True: + try: + fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + info = lock_read(name) or {} + if info.get("task") == task: + return True, info + age = lock_age_hours(info) + if force or age > LOCK_TTL_HOURS: + print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时{})".format( + name, info.get("task"), age, ",--force" if force else ",视为陈旧")) + try: + p.unlink() + except OSError: + pass + continue + if time.time() >= deadline: + return False, info + print("[lock] {} 被 {} 占用(已持有 {:.1f} 小时),等待…".format( + name, info.get("task"), age)) + time.sleep(2) + else: + now = time.time() + payload = { + "name": name, "task": task, "pid": os.getpid(), + "host": os.environ.get("COMPUTERNAME", "?"), + "epoch": now, + "at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)), + "note": note, + } + os.write(fd, (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")) + os.close(fd) + return True, payload + + +def lock_release(name: str, task=None, force: bool = False): + p = lock_path(name) + if not p.is_file(): + return True, "锁 {} 未被持有".format(name) + info = lock_read(name) or {} + if not force and task and info.get("task") not in (task, None): + return False, "锁 {} 由 {} 持有,与 {} 不符;强制释放请加 --force".format( + name, info.get("task"), task) + try: + p.unlink() + except OSError as e: + return False, "释放锁 {} 失败:{}".format(name, e) + return True, "已释放锁 {}(原持有者 {})".format(name, info.get("task")) + + +# --------------------------------------------------------------------- 目录联接 + + +def is_reparse(p: Path) -> bool: + try: + st = os.lstat(str(p)) + except OSError: + return False + return bool(getattr(st, "st_file_attributes", 0) & FILE_ATTRIBUTE_REPARSE_POINT) + + +def make_junction(link: Path, target: Path) -> None: + """创建 Windows 目录联接(不需要管理员权限,也不是符号链接) + + 走 `mklink /J`——这是唯一不需要 SeCreateSymbolicLinkPrivilege 的目录链接形式。 + ⚠ 实测(2026-10-12):直接用 ctypes 调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)` + 会被系统拒绝(Win32 4392 / ERROR_REPARSE_TAG_INVALID,缓冲区经 hexdump 核对无误), + 而 `mklink /J` 正常,故采用后者。参数以**列表**交给 subprocess, + 含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号,不经手写引号。 + """ + if os.name != "nt": + raise EnvError("目录联接是 Windows 专属能力;当前平台 {}".format(os.name)) + if os.path.lexists(str(link)): + raise Fail("联接位置已被占用:{}".format(link)) + if not target.exists(): + raise Fail("联接源不存在:{}".format(target)) + link.parent.mkdir(parents=True, exist_ok=True) + r = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, stdin=subprocess.DEVNULL, + ) + if r.returncode != 0 or not is_reparse(link): + detail = (r.stdout or b"").decode("mbcs", "replace").strip() + raise Fail("创建目录联接失败:{} -> {}\n rc={} {}".format( + link, target, r.returncode, detail)) + + +def remove_junction(link: Path) -> bool: + """只摘掉联接本身,绝不触碰目标内容(os.rmdir = RemoveDirectoryW)""" + if not is_reparse(link): + return False + try: + os.rmdir(str(link)) + return True + except OSError as e: + print("[warn] 摘除联接失败 {}:{}".format(link, e)) + return False + + +def bootstrap(wt: Path): + """建立共享联接。单个联接失败只告警不中止——避免留下半成品任务空间""" + made, failed = [], [] + for rel, desc in JUNCTION_SPECS: + target = REPO_ROOT / rel + if not target.exists(): + print("[skip] {}({})不存在,跳过联接".format(rel, desc)) + continue + try: + make_junction(wt / rel, target) + except (Fail, EnvError) as e: + failed.append(rel) + print("[WARN] {} 联接失败:{}".format(rel, e)) + continue + made.append(rel) + print("[ok] 联接 {} -> {}".format(rel, target)) + if failed: + print() + print("!! {} 个共享联接未建成;该任务里跑 `.venv\\Scripts\\python.exe` 会找不到解释器。".format(len(failed))) + for rel in failed: + print(" 手工补:cmd /c mklink /J \"{}\\{}\" \"{}\\{}\"".format(wt, rel, REPO_ROOT, rel)) + return made + + +# --------------------------------------------------------------------- 子进程脚本 + + +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 快照") + else: + print("[skip] 未找到 .map,跳过内存快照同步(需先 @build)") + + +def run_gates(cwd: Path, skip_build: bool) -> bool: + ok = run_script("scripts/ci_local.py", [], cwd, "L0 + L0.5 本地等价 CI") + if skip_build: + print("[skip] 按 --skip-build 跳过 @build") + return ok + got, info = lock_acquire("build", "__done__", wait=900) + if not got: + print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task"))) + return False + try: + if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd, + "@build(0 错 0 警)"): + ok = False + finally: + lock_release("build", "__done__", force=True) + return ok + + +# --------------------------------------------------------------------- 合并辅助 + + +def write_merge_message(st: dict, base: str, cwd: Path) -> Path: + raw = git_out("log", "--format=%s", "{}..{}".format(base, st["branch"]), cwd=cwd) + subjects = [s.strip() for s in raw.splitlines() if s.strip()] + title = "merge: {} 合入 {}".format(st["branch"], base) + if len(title) > 100: + title = title[:97] + "..." + lines = [title, "", "任务 {}({} 个提交)经 task-worktree 回收:".format(st["id"], len(subjects))] + for s in subjects: + lines.append("- " + (s[:73] + "..." if len(s) > 76 else s)) + if st.get("note"): + note = st["note"] + lines.append("") + lines.append("备注:" + (note[:73] + "..." if len(note) > 76 else note)) + fd, path = tempfile.mkstemp(prefix="taskwt-merge-", suffix=".txt") + os.close(fd) + Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + return Path(path) + + +def audit_residue(base_before: str, branch: str, cwd: Path) -> None: + """并集合并残留审计:双方都改过的文件会被 git 自动并成并集""" + mb = git_out("merge-base", base_before, branch, cwd=cwd) + task_files = set(filter(None, git_out("diff", "--name-only", mb, branch, cwd=cwd).splitlines())) + base_files = set(filter(None, git_out("diff", "--name-only", mb, base_before, cwd=cwd).splitlines())) + both = sorted(task_files & base_files) + if not both: + print("[ok] 没有「双方都改过」的文件,无并集残留风险") + return + print("以下 {} 个文件双方都改过,git 会自动并成并集,需人工核对:".format(len(both))) + for f in both[:30]: + print(" - " + f) + if len(both) > 30: + print(" …(其余 {} 个略)".format(len(both) - 30)) + hits = [] + for f in both: + p = cwd / f + if not p.is_file(): + continue + try: + lines = p.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + 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: + print() + print("!! 连续重复行(并集合并残留嫌疑):") + for f, dups in hits: + print(" - {} 第 {} 行".format(f, ", ".join(str(d) for d in dups[:8]))) + else: + 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(): + 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)) + + +def teardown_worktree(st: dict, force: bool = False, base: str = None) -> None: + wt = Path(st["path"]) + base = base or st.get("base") + # 1) 先摘联接——绝不能让 git 递归进联接去删目标内容(.venv / SDL2 会被一起删掉) + for rel in st.get("junctions", []): + if remove_junction(wt / rel): + print("[ok] 已摘除联接 {}".format(rel)) + # 2) 归并任务侧记忆 + if wt.is_dir(): + harvest_memory(st) + # 3) 移除工作树 + r = git("worktree", "remove", *(["--force"] if force else []), str(wt), check=False) + if r.returncode != 0: + raise Fail("移除工作树失败:{}\n(提示:先处理未跟踪文件,或加 --force)".format( + (r.stdout + r.stderr).strip())) + # 4) 删分支。⚠ 不用 `git branch -d`——它按**环境 HEAD** 判断是否已合并, + # 集成点被 DTU_INTEGRATION_WORKTREE 改走时会误判。改为显式核对 base。 + if force or git("merge-base", "--is-ancestor", st["branch"], base, + check=False).returncode == 0: + r = git("branch", "-D", st["branch"], check=False) + if r.returncode != 0: + print("[warn] 删除分支 {} 失败:{}".format( + st["branch"], (r.stdout + r.stderr).strip())) + else: + print("[ok] 已删除分支 {}".format(st["branch"])) + else: + print("[warn] 分支 {} 未并入 {},保留不删(确认可弃再 `git branch -D {}`)".format( + st["branch"], base, st["branch"])) + state_file(st["id"]).unlink(missing_ok=True) + + +# --------------------------------------------------------------------- 子命令 + + +def cmd_new(a) -> int: + if not ID_RE.match(a.task_id): + raise EnvError("任务 ID 非法:{}(字母数字开头,可用 . _ -)".format(a.task_id)) + if not SLUG_RE.match(a.slug): + raise EnvError("slug 非法:{}(小写字母数字与连字符)".format(a.slug)) + branch = "{}{}-{}".format(BRANCH_PREFIX, a.task_id, a.slug) + wt = WT_ROOT / "{}-{}".format(a.task_id, a.slug) + + if state_file(a.task_id).is_file(): + raise EnvError("任务 {} 已存在({})".format(a.task_id, state_file(a.task_id))) + if os.path.lexists(str(wt)): + raise EnvError("目录已存在:{}".format(wt)) + if git("rev-parse", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0: + raise EnvError("分支已存在:{}".format(branch)) + if git("rev-parse", "--verify", "--quiet", a.base, check=False).returncode != 0: + raise EnvError("基线分支不存在:{}".format(a.base)) + + active = [s for s in all_states() if Path(s["path"]).is_dir()] + if len(active) >= MAX_CONCURRENT and not a.force: + raise EnvError("已有 {} 个活跃任务(上限 {});先 done/abort,或加 --force".format( + len(active), MAX_CONCURRENT)) + + WT_ROOT.mkdir(parents=True, exist_ok=True) + print("[1/3] 建工作树(分支 {},基线 {}{})…".format( + branch, a.base, ",跳过 LFS smudge" if a.lean else "")) + git("worktree", "add", "-b", branch, str(wt), a.base, + env={"GIT_LFS_SKIP_SMUDGE": "1"} if a.lean else None) + + print("[2/3] 建立共享联接…") + junctions = [] if a.no_bootstrap else bootstrap(wt) + + st = { + "id": a.task_id, "slug": a.slug, "branch": branch, "path": str(wt), + "base": a.base, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "junctions": junctions, "needs_bench": bool(a.needs_bench), + "bench_pass": False, "note": a.note or "", "lean": bool(a.lean), + } + save_state(st) + print("[3/3] 台账已写入 {}".format(state_file(a.task_id))) + print() + print("下一步:把新会话的工作目录切到") + print(" " + str(wt)) + print("在该目录里干活、跑 @build 与 ci_local.py(并行安全);") + print("要动台架先 `lock acquire bench --task {}`,完事 `lock release bench --task {} --bench-pass`。".format( + a.task_id, a.task_id)) + print("回收用 `done {}`(**必须在主工作树执行**)。".format(a.task_id)) + return EXIT_OK + + +def cmd_list(a) -> int: + states = all_states() + if not states: + print("(没有任务空间。用 `new ` 建一个)") + return EXIT_OK + print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format( + "任务", "分支", "落后", "领先", "脏", "台架")) + for st in states: + wt = Path(st["path"]) + if not wt.is_dir(): + print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format( + st["id"], st["branch"], "-", "-", "-", "[工作树缺失]")) + continue + ab = git_out("rev-list", "--left-right", "--count", + "{}...{}".format(st["base"], st["branch"]), cwd=wt, check=False).split() + behind, ahead = (ab[0], ab[1]) if len(ab) == 2 else ("?", "?") + dirty = len(git_out("status", "--porcelain", cwd=wt, check=False).splitlines()) + bench = "PASS" if st.get("bench_pass") else ("需测" if st.get("needs_bench") else "-") + print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format( + st["id"], st["branch"], behind, ahead, dirty, bench)) + print() + for n in LOCK_NAMES: + info = lock_read(n) + if not info: + print("锁 {:<6} 空闲".format(n)) + else: + print("锁 {:<6} {} 持有(已 {:.1f} 小时,于 {})".format( + n, info.get("task"), lock_age_hours(info), info.get("at"))) + return EXIT_OK + + +def cmd_status(a) -> int: + st = load_state(a.task_id) + wt = Path(st["path"]) + print("任务 {} ({})".format(st["id"], st["slug"])) + print("分支 {} ← 基线 {}".format(st["branch"], st["base"])) + print("工作树 {}{}".format(wt, "" if wt.is_dir() else " [缺失]")) + print("建立于 {}".format(st.get("created_at"))) + if st.get("note"): + print("备注 {}".format(st["note"])) + if wt.is_dir(): + dirty = git_out("status", "--porcelain", cwd=wt, check=False).splitlines() + print("未提交 {} 项".format(len(dirty))) + for line in dirty[:10]: + print(" " + line) + ab = git_out("rev-list", "--left-right", "--count", + "{}...{}".format(st["base"], st["branch"]), cwd=wt, check=False).split() + if len(ab) == 2: + print("相对基线 落后 {} / 领先 {} 个提交".format(ab[0], ab[1])) + print("台架 {}{}".format( + "需要," if st.get("needs_bench") else "不涉及,", + "已记录 PASS" if st.get("bench_pass") else "尚无 PASS 记录")) + if st.get("junctions"): + print("联接 {}".format(", ".join(st["junctions"]))) + return EXIT_OK + + +def cmd_sync(a) -> int: + st = load_state(a.task_id) + wt = Path(st["path"]) + base = a.base or st["base"] + if not wt.is_dir(): + raise EnvError("工作树不存在:{}".format(wt)) + if git_out("status", "--porcelain", cwd=wt, check=False): + raise Fail("任务工作树有未提交改动,先提交或 stash 再 sync") + r = git("rebase", base, cwd=wt, check=False) + if r.returncode != 0: + print((r.stdout + r.stderr).strip()) + raise Fail("rebase 有冲突:在 {} 里解决后 `git rebase --continue`,放弃用 `git rebase --abort`".format(wt)) + print("[ok] 任务 {} 已 rebase 到 {}".format(st["id"], base)) + return EXIT_OK + + +def cmd_lock(a) -> int: + if a.action == "status": + for n in LOCK_NAMES: + info = lock_read(n) + if not info: + print("{:<6} 空闲".format(n)) + else: + age = lock_age_hours(info) + mark = " [陈旧,可被自动接管]" if age > LOCK_TTL_HOURS else "" + print("{:<6} {} 持有(pid={},于 {},已 {:.1f} 小时,备注 {}){}".format( + n, info.get("task"), info.get("pid"), info.get("at"), age, + info.get("note") or "-", mark)) + return EXIT_OK + + if a.name not in LOCK_NAMES: + raise EnvError("未知资源 {}(可用:{})".format(a.name, " / ".join(LOCK_NAMES))) + + if a.action == "acquire": + if not a.task: + raise EnvError("acquire 需要 --task ") + got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force, note=a.note or "") + if not got: + print("获取失败:{} 被 {} 占用(pid={},于 {})".format( + a.name, info.get("task"), info.get("pid"), info.get("at"))) + return EXIT_FAIL + print("已获取 {} 锁(task={} pid={})".format(a.name, a.task, os.getpid())) + return EXIT_OK + + if a.bench_pass: + if not a.task: + raise EnvError("--bench-pass 需要 --task ") + st = load_state(a.task) + st["bench_pass"] = True + save_state(st) + print("[ok] 已为任务 {} 记录台架 PASS".format(a.task)) + ok, msg = lock_release(a.name, a.task, force=a.force) + print(msg) + return EXIT_OK if ok else EXIT_FAIL + + +def cmd_done(a) -> int: + st = load_state(a.task_id) + wt = Path(st["path"]) + branch = st["branch"] + base = a.base or st["base"] + main = main_worktree() + + print("=== 前置检查 ===") + if not wt.is_dir(): + raise EnvError("工作树不存在:{}".format(wt)) + if git_out("status", "--porcelain", cwd=wt, check=False): + raise Fail("任务工作树有未提交改动,先提交") + if git_out("status", "--porcelain", cwd=main, check=False): + raise Fail("主工作树不干净({})——合并会把未提交改动卷进来".format(main)) + cur = git_out("rev-parse", "--abbrev-ref", "HEAD", cwd=main) + if cur != base: + raise Fail("主工作树当前在 {},不是集成分支 {};先切过去再 done".format(cur, base)) + for n in LOCK_NAMES: + info = lock_read(n) + if info: + raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} 小时),先 release".format( + n, info.get("task"), lock_age_hours(info))) + + ahead = int(git_out("rev-list", "--count", "{}..{}".format(base, branch), cwd=wt) or "0") + already = git("merge-base", "--is-ancestor", branch, base, cwd=main, check=False).returncode == 0 + if ahead == 0 and not already: + raise Fail("任务分支相对 {} 没有新提交,无需合并".format(base)) + if already and ahead == 0: + print("[i] {} 已合入 {},跳过合并,直接清场".format(branch, base)) + print("=== 清场 ===") + teardown_worktree(st, force=a.force, base=base) + print("\n完成(分支此前已合入)。") + return EXIT_OK + + if st.get("needs_bench") and not st.get("bench_pass"): + raise Fail("任务标记 --needs-bench 但无台架 PASS 记录;跑完 @test 后执行 " + "`lock release bench --task {} --bench-pass`".format(st["id"])) + print("[ok] 前置检查通过(领先 {} 共 {} 个提交)".format(base, ahead)) + + print("=== 合并预演(git merge-tree,不改动任何东西)===") + r = git("merge-tree", "--write-tree", base, branch, cwd=main, check=False) + if r.returncode != 0: + print((r.stdout + r.stderr).strip()) + raise Fail("预演发现冲突:到任务工作树里 `git merge {}` 解决并提交后重跑 done".format(base)) + tree = (r.stdout.splitlines() or ["?"])[0].strip() + print("[ok] 无冲突,预演结果树 {}".format(tree)) + if a.dry_run: + print() + print("--dry-run:到此为止,未做任何改动。") + return EXIT_OK + + print("=== 合入主干({},--no-ff)===".format(main)) + base_before = git_out("rev-parse", "HEAD", cwd=main) + msgfile = write_merge_message(st, base, wt) + r = git("merge", "--no-ff", "--no-edit", branch, "-F", str(msgfile), cwd=main, check=False) + msgfile.unlink(missing_ok=True) + if r.returncode != 0: + raise Fail("合并失败:\n{}".format((r.stdout + r.stderr).strip())) + print((r.stdout or "").strip()) + base_after = git_out("rev-parse", "HEAD", cwd=main) + + print("=== 合并残留审计 ===") + audit_residue(base_before, branch, cwd=main) + + print("=== 重生成受管产物 ===") + regen(main) + + if a.skip_gates: + print("=== 门禁(--skip-gates 已跳过)===") + gates_ok = True + else: + print("=== 门禁 ===") + gates_ok = run_gates(main, skip_build=a.skip_build) + + if not gates_ok: + print() + print("!! 门禁未全绿:工作树与分支**保留**,未清场。") + print(" 主干已含合并提交 {}".format(base_after[:12])) + print(" 可选:a) 在主工作树修正后追加提交(推荐)") + print(" b) 回退本次合并:git -C \"{}\" reset --hard {}".format(main, base_before[:12])) + return EXIT_FAIL + + print("=== 清场 ===") + teardown_worktree(st, force=a.force, base=base) + print() + print("完成。主干 {} 现为 {}。".format(base, base_after[:12])) + print("push 需人工许可,本次未推送。") + return EXIT_OK + + +def cmd_abort(a) -> int: + st = load_state(a.task_id) + wt = Path(st["path"]) + if wt.is_dir(): + dirty = git_out("status", "--porcelain", cwd=wt, check=False).splitlines() + ahead = int(git_out("rev-list", "--count", + "{}..{}".format(st["base"], st["branch"]), cwd=wt, + check=False) or "0") + if (dirty or ahead) and not a.force: + print("任务 {} 有 {} 项未提交改动、{} 个未合并提交,abort 会**永久丢弃**:".format( + st["id"], len(dirty), ahead)) + for line in dirty[:20]: + print(" " + line) + raise Fail("确认丢弃请加 --force") + teardown_worktree(st, force=True) + print("\n已放弃任务 {}(工作树与分支均已删除)".format(st["id"])) + return EXIT_OK + + +# --------------------------------------------------------------------- 入口 + + +def build_parser(): + ap = argparse.ArgumentParser( + prog="task_worktree.py", + description="多任务并行开发的空间管理(git worktree 生命周期)") + sub = ap.add_subparsers(dest="cmd") + + p = sub.add_parser("new", help="新建任务空间") + p.add_argument("task_id") + p.add_argument("slug") + p.add_argument("--base", default=BASE_BRANCH, help="基线分支(默认 %(default)s)") + p.add_argument("--lean", action="store_true", + help="跳过 Git LFS smudge(省约 38MB,参考图变成指针文件)") + p.add_argument("--no-bootstrap", action="store_true", help="不建共享联接") + p.add_argument("--needs-bench", action="store_true", + help="需台架验证;无 PASS 记录则 done 拒绝合并") + p.add_argument("--note", default="") + p.add_argument("--force", action="store_true", help="忽略并发上限") + p.set_defaults(func=cmd_new) + + p = sub.add_parser("list", help="列出所有任务空间") + p.set_defaults(func=cmd_list) + + p = sub.add_parser("status", help="单个任务详情") + p.add_argument("task_id") + p.set_defaults(func=cmd_status) + + p = sub.add_parser("sync", help="rebase 追平主干") + p.add_argument("task_id") + p.add_argument("--base", default=None) + p.set_defaults(func=cmd_sync) + + p = sub.add_parser("lock", help="独占资源锁(build / bench)") + p.add_argument("action", choices=["acquire", "release", "status"]) + p.add_argument("name", nargs="?", choices=list(LOCK_NAMES)) + p.add_argument("--task", default=None) + p.add_argument("--wait", type=int, default=0, help="等待秒数(0=不等待)") + p.add_argument("--force", action="store_true") + p.add_argument("--note", default="") + p.add_argument("--bench-pass", action="store_true", + help="release bench 时顺带为该任务记录台架 PASS") + p.set_defaults(func=cmd_lock) + + p = sub.add_parser("done", help="回收:预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场") + p.add_argument("task_id") + p.add_argument("--base", default=None) + p.add_argument("--dry-run", action="store_true", help="只预演,不做任何改动") + p.add_argument("--skip-build", action="store_true", help="跳过 @build") + p.add_argument("--skip-gates", action="store_true", help="跳过全部门禁") + p.add_argument("--force", action="store_true", help="清场时强制移除工作树") + p.set_defaults(func=cmd_done) + + p = sub.add_parser("abort", help="放弃任务(不合并)") + p.add_argument("task_id") + p.add_argument("--force", action="store_true") + p.set_defaults(func=cmd_abort) + return ap + + +def main(argv=None) -> int: + ap = build_parser() + a = ap.parse_args(argv) + if not getattr(a, "func", None): + ap.print_help() + return EXIT_USAGE + if not (REPO_ROOT / ".git").exists(): + print("!! 无法定位仓库根(推导为 {})".format(REPO_ROOT), file=sys.stderr) + return EXIT_ENV + try: + return a.func(a) + except EnvError as e: + print("前置/环境错误:{}".format(e), file=sys.stderr) + return EXIT_ENV + except Fail as e: + print("失败:{}".format(e), file=sys.stderr) + return EXIT_FAIL + except KeyboardInterrupt: + print("已中断", file=sys.stderr) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AGENTS.md b/AGENTS.md index 77c3b406..c9e916e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,4 +177,5 @@ | 串口助手 | **`serial-assistant` skill**(命令/判据/陷阱,用到自动加载)、`docs/测试说明/串口助手使用指南.md`、`串口助手设计说明.md` | | UI 架构 / 模拟器 / UI 自动化回归 | **`lvgl-simulator` skill**(构建/自检/报错速查,用到自动加载)、`docs/UI设计/UI_设计架构.md`、`LVGL模拟器部署与使用说明.md`、`UI_自动化测试流程.md`;UI 改动回归走 **`ui-verify` skill** | | 待办与审计发现登记簿 | `docs/待处理内容.md` | +| 多任务并行开发(开空间 / 合并回收 / 台架与构建排队) | **`task-worktree` skill**(`@task`,用到自动加载)、`docs/环境与部署/并行任务工作树方案.md` | | 换机 / 工具路径环境变量 / CI | `docs/环境与部署/` | diff --git a/docs/README.md b/docs/README.md index 57e189e0..7a83c4c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -218,6 +218,7 @@ docs/ | 文档 | 内容 | |---|---| | [`项目部署环境要求.md`](环境与部署/项目部署环境要求.md) | 开发机软件环境、目标板资源预算、现场部署与验收步骤 | +| [`并行任务工作树方案.md`](环境与部署/并行任务工作树方案.md) | **多任务并行开发机制**:`git worktree` 隔离、目录与共享/独占分配(junction 规则)、`task-worktree`(`@task`)SKILL 命令面、单写者文档与合并残留审计、台架锁;**已落地并端到端实测**(见其 §11) | ### 9.3 台账与外部资料 @@ -266,6 +267,7 @@ docs/ | 日期 | 变更 | 说明 | |---|---|---| +| 2026-10-12 | 新增环境与部署 | 新建 `docs/环境与部署/并行任务工作树方案.md`(**多任务并行开发机制规格**:`git worktree` 隔离、目录与共享/独占分配、`task-worktree` / `@task` skill 设计、单写者文档与合并残留审计、台架锁;**已落地并端到端实测**);本索引 §9.2 补登记 | | 2026-10-11 | 功能 | N-19 FTP 鉴权:`/config.ini` 新增 `[ftp]` 段(`user` / `pwd_hash` = SHA-256(user‖password) / `write`)+ 恒定时间比较 + 连续 5 次失败锁 60 s + 未登录命令门;出厂默认 admin/admin,默认口令在用有启动告警。新增 `App/util/sha256.*`、`Drivers/BSP/NETCFG/ftp_cfg.*`、`Drivers/BSP/NET/lftpd/lftpd_auth.*`;主机侧单测 `test/host/sha256/`(13 例)+ `test/host/ftp_auth/`(36 例),pre-commit 钩子 26→28;台架脚本 `test/host/ftp_auth_bench.py`。文档同步:`docs/子系统设计/网络配置说明.md`、`docs/架构设计/STM32F4-Base存储架构说明.md`、`docs/代码规范/pre-commit使用说明.md`、`docs/产品需求/产品需求设计文档.md` | | 2026-10-11 | 文档调整 | `AGENTS编写指南.md` 新建后**同日删除**(要点已沉淀入 `AGENTS.md` 宏观重构与本索引变更记录,指南类内容不单独成文);撤销 §1 目录树与 §7.1b 登记;同日 `AGENTS.md` 完成宏观重构(补项目概况/目录地图、平台约束分组、边界块) | | 2026-10-10 | 新增子系统设计 | 新建 `docs/子系统设计/ADC采样节拍设计说明.md`(ADC 采集链路 1 kHz 硬件定时器触发的 CubeMX 配置规格与代码改造清单,B1 遗留项规划);本索引 §5.1 补登记 | diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md new file mode 100644 index 00000000..ff6fd81a --- /dev/null +++ b/docs/环境与部署/并行任务工作树方案.md @@ -0,0 +1,355 @@ +# 并行任务工作树工作流(worktree + `task-worktree` skill) + +> **这个文件是什么**:多任务并行开发的**机制规格**——如何为每个任务开一个互不干扰的工作空间、 +> 干完如何合并回主干、哪些资源必须串行、冲突怎么治理。 +> +> **状态**:**已落地并端到端实测**(2026-10-12)——skill 在 `.dsh/skills/task-worktree/`, +> 实测记录与暴露并修复的缺陷见 §11。 +> +> **决策基线**(2026-10-12 用户确认):工作树放**仓库同级 `_wt/`**;集成分支**沿用 `v2-on-ui`**; +> `done` 回收**不自动 push**(沿用「push 需用户明确许可」)。 + +--- + +## 1. 目的与范围 + +**要解决的问题**:多个任务同时推进时,若共用同一个工作目录,会互相踩——构建产物、日志目录、 +Python 环境、以及 `docs/待处理内容.md` 这类**全局唯一登记簿**都会被并发写坏。 + +**做法**:每个任务一棵 `git worktree` + 一条任务分支,任务之间物理隔离;干完由固定命令合回主干。 + +**范围**:只解决「开发期隔离 + 回收期合并」的机械流程。**不改变**既有的分支纪律 +(push 需许可)、门禁口径(pre-commit 30 钩子 + `@build` + `@test`)与文档约定。 + +--- + +## 2. 方案选型 + +| 方案 | 为什么不用 | +|---|---| +| 同一目录切分支 | 构建产物 / `.venv` / 日志目录 / `.workbuddy-ai` 全共享,两任务互相踩;且本仓库明令禁止用 `git checkout` 丢弃未提交工作 | +| 每任务 `git clone` | 对象库 234 MB × N 份、Git LFS 全量重复;克隆之间无法安全共享分支,推送关系也更绕 | +| `git stash` / 打补丁倒换 | 不具备并发安全性,冲突只能靠人工记忆 | +| **`git worktree`(采用)** | 唯一同时满足「共享对象库 + 独立工作目录 + 独立 HEAD/index」的机制;git 原生,本仓库已有先例 | + +**既有先例**:`D:/Code/DTU Code/_v1pin-build`、`_v2oui-build` 就是遗留 worktree(detached HEAD, +2026-09-19 建、已过期)。本方案沿用其「仓库同级 + `_` 前缀」的命名惯例,但把散落的目录收敛到统一的 +`_wt/` 容器下,并在落地时清理这两个遗留目录。 + +--- + +## 3. 目录与资源分配 + +### 3.1 目录布局 + +```text +D:/Code/DTU Code/ +├── STM32F4-Base/ # 主工作树 = 集成分支 v2-on-ui(唯一检出主干的地方) +│ ├── .git/ # 共享对象库 + hooks(worktree 自动共享,无需任何操作) +│ ├── .venv/ # 唯一实体(Python 环境) +│ ├── .workbuddy-ai/ # 唯一实体(记忆;**不**共享给任务树,见 3.2) +│ └── simulator/SDL2/ # 唯一实体(模拟器 SDK) +└── _wt/ # ★ 并行任务容器(不在任何 git 仓库内) + ├── .state/.json # 任务台账:分支 / 联接 / 门禁与台架戳 + ├── .state/locks/ # 独占锁:build.lock(UV4)+ bench.lock(COM6/ST-LINK/设备 IP) + └── -/ # ← 一棵工作树(分支 task/-) + ├── .git # 文件(指向共享对象库,git 自动创建) + ├── .venv # → junction 到主工作树 + ├── simulator/SDL2 # → junction 到主工作树 + ├── .workbuddy-ai # 本任务自己的(清场前归并回主工作树) + └── MDK-ARM/… # 本任务独占的构建产物 +``` + +**工作树绝不能建在仓库内部**(`STM32F4-Base/` 里):那会变成未跟踪目录,污染 `git status`、 +`pre-commit --all-files` 与全库搜索。故统一放仓库同级的 `_wt/`。 + +### 3.2 共享 vs 独占 + +| 资源 | 归属 | 原因 | +|---|---|---| +| `.git/` 对象库 + hooks | **共享**(git 机制自动) | 工作树共享 common dir,提交门禁无需在新空间重装 | +| `.venv/` | **共享**(junction) | 体积大、内容与任务无关;每任务重建一份纯属浪费 | +| `simulator/SDL2/` | **共享**(junction) | 下载型 SDK,与任务无关 | +| `.workbuddy-ai/` | **独占**(刻意不共享) | ① 避免并发写同一份日志丢更新;② 不给联接递归删除的机会——它是本项目最不可再生的目录。任务侧记忆由清场前归并回主工作树 | +| `MDK-ARM/` 构建产物、`simulator/build/` | **独占** | 正是需要隔离的部分:两个任务可各自构建互不干扰 | +| `.serialtest/config.json`、`.dsh/skills/`、`.pre-commit-config.yaml` | **独占但内容一致** | 受版本控制,随检出白拿,无需 bootstrap | +| `.mnemon/`、`ci-artifacts/` | **独占** | 本地运行态,gitignored | + +### 3.3 junction 规则(**不能用 symlink**) + +本仓库 `core.symlinks=false`:git 里的符号链接会被检出成「内容是目标路径的普通文本文件」, +不是链接。因此**共享 `.venv` 只能靠 Windows 目录联接(junction)**,且 junction 必须创建在 +**版本控制之外**(`_wt/-/` 里的那几个路径都不在 git 索引内,安全)。 + +创建方式用 `mklink /J`(**不需要管理员权限**,也不同于符号链接)。实测(2026-10-12): +用 ctypes 直调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)` 会被系统拒绝(Win32 4392), +缓冲区经 hexdump 逐字节核对无误仍失败,而 `mklink /J` 正常 → 采用后者;参数以**列表** +交给 subprocess,含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号。 + +**摘除联接必须用 `os.rmdir`**(= `RemoveDirectoryW`,只摘链不递归)。实测 `.venv` 条目数 +摘除前后一致(2809 → 2809)。**清场顺序铁律:先摘联接,再 `git worktree remove`**—— +否则 git 可能递归进联接,把主工作树的 `.venv` / SDL2 一起删掉。 + +> ⚠ 不要在仓库里提交任何指向 `../` 的 symlink 来实现共享——在本机就是坏文件。 +> ⚠ MSYS 的 `rmdir` 拒绝摘除联接(把它当符号链接),手工清场要用 Python 的 `os.rmdir` +> 或直接走本 skill 的 `abort` / `done`。 + +--- + +## 4. 分支模型 + +| 项 | 取值 | +|---|---| +| 集成分支(主干) | `v2-on-ui` | +| 任务分支 | `task/-`,从主干当前 HEAD 切出 | +| `` | **沿用 `docs/待处理内容.md` 的编号空间**(`P0-x`/`N-x`/`S-x`/`T-x`/`B-x`/`C-x`/`D-x`);无对应条目的临时任务用 `X-<日期>-` | +| 合并方式 | `git merge --no-ff` 合入主干 | +| 并发上限 | **同时最多 3 棵工作树** | + +**`` 复用登记簿编号**是为了可追溯:任务分支名直接对应待办条目,回收时销项有据可查。 + +**并发上限 3** 的依据:`.git` 已 234 MB,每棵工作树要检出约 2400 个受控文件(含 Git LFS 图片), +并发太多磁盘与 I/O 都不划算,人工也管不过来。 + +--- + +## 5. 任务生命周期 + +| 阶段 | 动作 | 并行性 | +|---|---|---| +| `new` | 建分支 + 工作树 + junction + 台账 | 可并行 | +| 编码 | 在新空间里改代码 | 可并行 | +| 门禁 | `scripts/ci_local.py`(L0 + L0.5,纯软件) | **可并行** | +| 构建 | `@build`(UV4 增量,需持 `build` 锁) | **必须串行** | +| 台架 | `@test` / 板级套件(需先持台架锁,见 §7) | **必须串行** | +| 提交 | 本地 `git commit`(多行正文用 `-F `) | 可并行 | +| `sync` | `rebase` 追平主干,把冲突控制在任务分支上 | 可并行 | +| `done` | 预演 → 合入主干 → 残留审计 → 重生成产物 → 门禁 → 清理工作树 | **必须串行** | +| `push` | 推送主干/任务分支 | **需用户明确许可** | + +**为什么 `done` 必须串行**:合并动作会移动主干引用,两处同时合入会互相覆盖;且主干只在主工作树检出。 + +--- + +## 6. 冲突治理 + +### 6.1 单写者文档 + +并行失败通常不是代码冲突,而是**多个任务同时改同一个「登记簿」**。以下文件在任务期间 +**禁止在任务工作树里修改**,一律由 `done` 回收阶段在主工作树统一处理: + +| 文件 | 冲突原因 | 回收时的处理 | +|---|---|---| +| `docs/待处理内容.md` | 全项目唯一登记簿,每个任务都要销项 | 由主工作树统一改,任务分支不碰 | +| `AGENTS.md`、`docs/README.md`(索引/登记类) | 每个任务都要在「按主题查哪份文档」表、`docs/README.md` §9.2 环境表、变更记录里**各加一行** → 两任务必冲突(2026-10-12 实测:主工作树里本任务的行与他任务的行已并排混在**同一 hunk**) | 由主工作树统一追加;提交时**只 `git add` 本任务的行**(`git apply --cached` 选择性入库),勿整文件 `git add` | +| `AGENTS.md` 的 `TASK_TABLE` 块 | 生成物,两个任务都抬栈就冲突 | 合并后重跑 `scripts/gen_task_table.py` **重新生成**,不手工并 | +| `MDK-ARM/STM32F407-Demo.uvprojx` | 新源文件注册 + UV4 自动改写 | 手工并 `` 条目 → 重跑 `registration-check` → `@build` | +| `.pre-commit-config.yaml` | 新增主机单测要加钩子 | 并集处理 | +| `docs/内存管理/RAM使用分析与优化方案.md`(及其 `mem-numbers` 快照) | `mem-numbers` 快照 | **绝不手工合数字**,合并后 `scripts/check_mem_numbers.py --write` 重生成 | + +### 6.2 合并残留审计(**必做**) + +双方都改过的文件(尤其 `.gitignore`)会被 git 自动并成**并集**,可能留下: + +- **死规则**:指向已删目录的忽略项 +- **重复条目**:同一规则出现两次 + +判据 = 拿 base / 两个 tip **三方对比**(`git show :` 逐项 `grep -F`), +**不要用 `tail -N` 目测**(会漏看被合并在中间的行),也**不要把合并前就存在的历史重复算进来**。 + +### 6.3 冲突时的解决位置 + +`done` 先做**非破坏性预演**:`git merge-tree --write-tree v2-on-ui task/-`。 +- `rc=0`(无冲突)→ 继续合并 +- `rc≠0` → **在任务分支上** `git merge v2-on-ui` 解决冲突并提交,再重跑 `done` + +在一次性分支上解冲突,**永不污染主干**。 + +--- + +## 7. 资源串行化 + +### 7.1 台架锁 + +以下物理资源**各只有一份**,故 `@test` / 板级套件 / 烧录必须串行: + +- 串口 `COM6` @115200 +- ST-LINK SN `0B250D12A218303030303032` +- 设备 IP `192.168.1.100` + +机制:`_wt/.state/locks/{build,bench}.lock` 记录持有者(任务 ID + pid + 时间戳 + 备注)。 + +**锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在另一个进程里跑。 +若按 pid 判活,acquire 一退出锁就被判为陈旧 → 锁形同虚设(实测踩到)。 +故判「陈旧」只看**时效**(`LOCK_TTL_HOURS` = 6 小时)加显式 `--force`;pid 仅作排障记录。 +同任务重复 acquire 幂等;他人持锁时 acquire 拒绝(`--wait` 可轮询等待)。 +`done` 前置检查会在任一锁被占用时拒绝回收。 + +> ⚠ 台架相关的既有陷阱(`--require-selftest-pass` 的陈旧台账、回读校验会复位目标) +> 不因引入工作树而改变,仍按 `docs/测试说明/自动化测试流程.md` 执行。 + +### 7.2 构建 + +`@build`(UV4 增量)在新空间里是**独占目录**,但 **UV4 不能两个实例同时 `-b`**(2026-10-12 用户确认, +无需实测)——因此构建**同样纳入串行锁**(`build`),与台架锁并列。 + +> 结论:任务期真正可并行的只有**编码与静态门禁**(`ci_local.py` 的 L0 / L0.5,纯软件、零硬件); +> `@build` 与烧录/台架都必须排队。 + +--- + +## 8. SKILL 设计 + +**名称**:`task-worktree` **短命令**:`@task`(沿用本仓库 `@build` / `@test` 的 `@` 惯例) + +**执行体**:`.dsh/skills/task-worktree/scripts/task_worktree.py`(纯标准库, +照 `keil_build.py` 的写法,整目录可拷到别的项目复用)。**已落地并端到端实测**(见 §11)。 + +| 子命令 | 作用 | +|---|---| +| `new [--base B] [--lean] [--needs-bench]` | 建分支 `task/-` + `_wt/-/` 工作树 + 共享联接 + 台账 | +| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 | +| `status ` | 单任务详情:未提交清单、相对基线漂移、台架记录、已建联接 | +| `sync ` | `rebase` 追平主干,把冲突控制在任务分支上 | +| `lock acquire\|release\|status ` | 独占资源锁(`--task` / `--wait` / `--force` / `--bench-pass`) | +| `done [--dry-run] [--skip-build] [--skip-gates]` | 预演 → 合入主干 → 残留审计 → 重生成产物 → 门禁 → 清场 | +| `abort [--force]` | 放弃任务(有未提交/未合并内容时不加 `--force` 会拒绝) | + +**环境变量**:`DTU_WT_ROOT`(任务容器位置)· `DTU_TASK_BASE`(默认基线)· +`DTU_INTEGRATION_WORKTREE`(集成点,默认主工作树;用于隔离演练/测试)。 + +**关键约束**: + +1. **`done` 只能在主工作树执行**——主干 `v2-on-ui` 检出在主工作树,git 不允许同一分支在两处检出。 +2. **`done` 前置检查主工作树干净**,脏则中止并提示(避免把未提交工作卷进合并)。 +3. **`done` 不含 `push`**:结束于本地合入 + 门禁全绿,打印提示等用户点头。 +4. `--needs-bench` 任务若没有台架 PASS 戳,`done` 拒绝合并。 +5. 门禁未全绿时 **`done` 保留工作树与分支**(只留下合并提交),便于修正或回退——不静默清场。 +6. 清场顺序:**先摘联接,再 `git worktree remove`**(见 3.3)。 + +--- + +## 9. 门禁与验收 + +| 层级 | 内容 | 时机 | +|---|---|---| +| 任务期 | `scripts/ci_local.py`(L0 + L0.5,可并行) | 每个任务各自跑 | +| 任务期 | `@build`(0 错 0 警,**持 `build` 锁串行**) | 每个任务各自跑 | +| 任务期(可选) | `@test` / 板级套件(持 `bench` 锁) | 需真机验证的任务 | +| 回收期 | 重生成产物 → `ci_local.py` → `@build` | `done` 内强制(`--skip-gates` 可跳过) | +| 人工 | push 许可 | 回收后 | + +**`done` 的完成判据**:主干上 `git merge --no-ff` 成功 + `ci_local.py` 全绿 + `@build` 0 错 0 警 ++ 残留审计无死规则/重复条目 + 工作树已移除。 + +--- + +## 10. 已知风险与待验证项 + +| 项 | 状态 | 处置 | +|---|---|---| +| UV4 能否并发构建 | **已确认不可**(2026-10-12) | `build` 与 `bench` 一并纳入串行锁 | +| 跳过 Git LFS smudge 是否影响门禁 | **已实测**(2026-10-12) | 见下表:只省磁盘、不省时间,两种方式工作区都干净 → 默认全量,`--lean` 备选 | +| 主工作树当前不干净 | 已知 | `done` 的前置检查会拦下 | +| 遗留 worktree(`_v1pin-build` / `_v2oui-build`) | 已知 | 落地时 `git worktree remove` 清理,避免新旧两套并存 | +| **「主工作树绿、干净工作树红」的隐藏依赖** | **已实证**(2026-10-12) | 见 10.2。属**原任务**漏提交,非本 skill 缺陷 | + +### 10.1 LFS 实测数据(2026-10-12,2396 个受控文件 / 707 个 LFS `*.jpg`) + +| 方式 | 建树耗时 | 检出体积 | `git status` | 参考图 | +|---|---|---|---|---| +| 全量 smudge(默认) | 7.25 s | 83 MB | 干净 | 真实 JPEG,可看 | +| `--lean`(`GIT_LFS_SKIP_SMUDGE=1`) | 7.05 s | 45 MB | 干净 | 指针文本 | + +**结论**:LFS 对象已在本地,smudge 几乎不耗时,跳过**只省约 38 MB 磁盘**。 +故默认走全量(保留参考图可读性),磁盘紧张时才用 `--lean`。 +另外两种方式在全新工作树里跑 `pre-commit`(含 `doc-links`)都**正常通过**, +证明 hooks 经 common dir 共享、门禁不因工作树而失效。 + +### 10.2 工作树是「提交物自洽性」的照妖镜(2026-10-12 实证) + +演练中 `done` 的门禁在干净工作树里报出 2 处失效引用,而**主工作树里同一门禁是绿的**: + +```text +docs\内存管理\RAM使用分析与优化方案.md:277 [路径引用] scripts/ram_baseline.tsv +docs\内存管理\RAM使用分析与优化方案.md:585 [路径引用] docs/内存管理/RAM预算台账.md +``` + +根因:该文档**已提交且无改动**,但它引用的这两个文件**从未 `git add`**(属另一在途任务), +只在作者的主工作树磁盘上存在 → `doc-links` 查的是**文件系统**,于是在主工作树里通过; +一旦换到干净工作树(本演练的 `_probe-base2`),文件不存在 → 必然 FAIL。 + +**判据与处置**:这不是本 skill 的缺陷,而是被工作树**逼出来的真问题**—— +「一个提交是否自洽」不能靠「在我的工作目录里能过」来证明。修复归属**引用方任务** +(补 `git add`,或给引用加 `[计划]` 前缀 / 写明「已删除」状态词,见 `docs/文档规范.md`)。 + +**副作用提醒**:这同时说明**共享主工作树里的绿门禁不可尽信**——只要有未提交文件躺在磁盘上, +门禁就可能对着「未提交的现状」放行。多任务并行期更该用工作树 + `done` 复核。 + +**已知不做**:不引入第二套并行机制(如 git submodule / 多克隆), +保持「同一能力只有一个实现」的收敛原则。 + +--- + +## 11. 落地清单与实测记录 + +| 动作 | 目标 | 状态 | +|---|---|---| +| 新建 | `.dsh/skills/task-worktree/SKILL.md`(含 `name` / `description` / `whenToUse` frontmatter) | ✅ | +| 新建 | `.dsh/skills/task-worktree/scripts/task_worktree.py`(纯标准库) | ✅ | +| 新建 | `.dsh/skills/task-worktree/.gitignore` | ✅ | +| 修改 | `AGENTS.md`「按主题查哪份文档」加一行指向本文档 | ✅ | +| 修改 | `docs/README.md` §9.2 登记本文档 | ✅ | +| 清理 | 移除遗留 worktree `_v1pin-build` / `_v2oui-build` | ⏳ 待用户确认(含未提交内容则先留档) | +| 验证 | §10 的两个待验证项 | ✅ 见 §10 / §10.1 | + +### 11.1 端到端实测记录(2026-10-12,隔离集成点演练) + +用 `DTU_INTEGRATION_WORKTREE` 把集成点指到一次性工作树、基线用一次性分支, +**全程未触碰真实主线 `v2-on-ui`**: + +| 场景 | 结果 | +|---|---| +| `new` 建树 + 双联接(`.venv` / `simulator/SDL2`) | ✅ 透过联接跑解释器得 `Python 3.12.4` | +| 新工作树里跑 `pre-commit`(共享 hooks) | ✅ `doc-links` 等通过;gitlint 也正常拦下非法邮箱 | +| `list` / `status` | ✅ 落后领先、脏文件数、联接清单、锁归属均正确 | +| `lock` acquire / 冲突拒绝 / 幂等 / 错误持有者拒绝 release | ✅ 期望的 rc 全部命中 | +| `done --dry-run` 无冲突 | ✅ 输出预演结果树,零改动 | +| `done --dry-run` 有冲突 | ✅ 拒绝合并(rc=1),提示到任务分支上解冲突 | +| `done` 完整回收(合并 + 审计 + 重生成 + 清场) | ✅ 合并提交生成合规(标题 40 字符 ≤100、正文 ≤80) | +| 清场安全性 | ✅ 联接摘除前后 `.venv` 文件数 2809 → 2809,解释器完好 | +| `abort` 未加 `--force`(有未合并提交) | ✅ 拒绝(rc=1)并列出将丢弃的内容 | +| `abort --force` | ✅ 联接摘除、工作树移除、分支删除、`.venv` 2809 → 2809 | + +**实测暴露并已修复的 4 个缺陷**: + +1. **锁绑定进程存活 → 形同虚设**:`lock acquire` 是短命进程,一退出锁就被判陈旧、下次必被接管。 + 改为**时效判定**(6 小时 TTL)+ 显式 `--force`,pid 仅作记录。 +2. **`git branch -d` 按环境 HEAD 判「已合并」**:集成点被改走时误判、拒绝删除。 + 改为显式 `merge-base --is-ancestor ` 后再删。 +3. **直调 Win32 建联接被拒**(Win32 4392):改走 `mklink /J`(详见 3.3)。 +4. **`done` 门禁跑错工作树**(最隐蔽的一个):`run_script` 原用 `REPO_ROOT / script` 拼脚本路径, + 而 `REPO_ROOT` 由 `task_worktree.py` 自身位置反推 = **主工作树**。于是 `done` 本意是在集成 + 工作树里校验,实际却跑了主工作树里的同名脚本;该脚本再按它自己的 `__file__` 反推,又去校验 + **主工作树**——校验对象整体错位。 + 证据(回归前/后对照):直接在工作树里跑 `ci_local.py` 报「扫描 .c: 68 / PASS」,经 `done` 调用 + 却报「扫描 .c: 71 / FAIL」并附 `[GAP] App/protocol/iec104/*.c | 缺 uvprojx`(那 3 个文件只以 + **未跟踪**状态存在于主工作树)。修法 = 从传入的 `cwd`(= 集成工作树)解析脚本路径 + (`scripts/ci_local.py` L46 / `scripts/check_registration.py` L52 的 `__file__` 反推是既定约定, + 脚本必须由**目标树**里的那一份执行)。修复后同一条命令的 + `双端注册反向校验 (uvprojx + CMakeLists)` 由 FAIL 转 **Passed**。 + +> 缺陷 4 的教训具有普适性:**任何「按 `__file__` 反推仓库根」的项目脚本,都必须由目标工作树里 +> 的那一份来执行**,否则门禁会静默地校验另一棵树。本 skill 的 `run_script` 已统一按 `cwd` 解析。 + +--- + +## 12. 关联文档 + +| 主题 | 去处 | +|---|---| +| 提交门禁 30 个钩子 | `docs/代码规范/pre-commit使用说明.md` | +| 自动化测试与台架流程 | `docs/测试说明/自动化测试流程.md` | +| 文档写作规范(编码/链接/登记) | `docs/文档规范.md` | +| 工具路径环境变量(换机) | `docs/环境与部署/本地工具路径环境变量方案.md` | +| 待办与排序登记簿 | `docs/待处理内容.md` |