Files
STM32F4-Base/.dsh/skills/task-worktree/scripts/task_worktree.py
T
wandering1 21c68521ff feat(task-worktree): 桥接 DSH worktree 插件——补共享联接、锁归属 pw:<name>、修释放不掉
诉求最初是升级 dsh-task-worktree:实测已装 0.4.6 = npm dist-tags.latest,无需升级;
真正的缺口是本仓 skill 看不见插件建的树(.dsh-worktrees/),故按用户追加指令把两套
系统接起来。回收方向刻意不打通:本 skill 永不删插件的树。

脚本 task_worktree.py:新增 plugin 子命令 list/adopt/unadopt,事实源是 git worktree
list(不是插件 manifest,免得被绕过);adopt 只补缺失的 .venv 与 simulator/SDL2
联接,已有联接跳过、实体目录绝不碰;锁归属用 pw:<name> 前缀;new 顺带提示另有 N 棵
插件树、不占活跃上限 3。
一并修一处真 bug:lock release --bench-pass 在任务无台账时先 EnvError 退出、锁释放
不掉,导致每个任务的 done 收口连坐失败;现在改为警告后仍然释放。

keil-build:default_lock_task() 增加 .dsh-worktrees/ 反推分支,插件树内的构建归属
pw:<name> 而不是兜底的 build-cli(主树行为未变,本次 @build 实测仍走 build-cli)。

.gitignore 预先忽略 .dsh-worktrees/,让插件的 ensureIgnored() 找到同一字面量、不再
改写这份受控文件。check_doc_links / check_layer_deps / gen_refgraph 三处扫描面同步
排除:仓里再放一份仓会让 doc-links 假红、并让生成的配图字节漂移到 generated-fresh。

文档:并行任务工作树方案新增 §3.4 两套系统边界对照;SKILL.md 加 §F 共存一节,同时把
§B 从 53 行压到 29 行(净 +39,用户此前在飞的改写已 rebase 保留、未被覆盖)。

验证:@build 0 错误 0 警告;scripts/ci_local.py 48 根提交期钩子与 gitlint 全绿;
check_doc_links --all --sections-strict 通过(2596 章节引用、无法判定 0);id-check-new
通过;改动文件字节体检无 BOM、CRLF 与 U+FFFD。桥接行为在临时 scratch 仓端到端跑过
(adopt 建两个 reparse、unadopt 只摘联接、pw 锁取放、缺台账也能释放、越界 name 退 2)。
2026-10-29 07:50:05 +08:00

1101 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")
# 按锁分设陈旧窗口(小时)——兜底窗口应与「操作真实时长」同量级:
# build:编译是秒~分钟级(keil_build.py 自持自释,持有窗口 ≈ UV4 运行时长,
# 上限 = --timeout,默认 600s);崩溃泄漏时应在数十分钟内即可接管 → 1h。
# bench:会话级(flash → @test → 探针,天然几十分钟~数小时)→ 保留 6h。
# 另:锁 payload 里持有者可用 `ttl` 字段自报窗口(见 lock_acquire(ttl=...)),
# 优先于这里的默认值——这样 build 锁的窗口能自动跟随其 --timeout。
LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}
DEFAULT_LOCK_TTL_HOURS = 6.0
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"
# DSH 的 worktree 插件(包名 dsh-task-worktree;模型工具 worktree_create、人类命令
# /worktree)把任务树建在**仓库内**这个命名空间下,与本 skill 的仓外 _wt/ 刻意分开。
# .gitignore 里预置同名规则,正是为了让插件的 ensureIgnored() 见到就跳过、
# 不在会话中途往这份受控文件里追加一行。
PLUGIN_ROOT = REPO_ROOT / ".dsh-worktrees"
PLUGIN_NAMESPACE = "worktree" # 插件侧 WORKTREE_NAMESPACE:name 即分支尾段
PLUGIN_SUBDIR = PLUGIN_ROOT / PLUGIN_NAMESPACE
PLUGIN_LOCK_PREFIX = "pw" # 插件树在锁 payload 里的持有者前缀
# --------------------------------------------------------------------- 基础工具
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_ttl_hours(name: str, info=None) -> float:
"""锁 `name` 的陈旧判定窗口(小时)。
优先用锁 payload 里持有者自报的 `ttl`(例如 keil_build.py 按其 --timeout 声明),
否则回落到该锁的默认窗口(build 1h / bench 6h)。
"""
if info:
try:
v = info.get("ttl")
if v:
return max(0.0, float(v))
except (TypeError, ValueError):
pass
return LOCK_TTL_HOURS.get(name, DEFAULT_LOCK_TTL_HOURS)
def lock_is_stale(name: str, info) -> bool:
return lock_age_hours(info) > lock_ttl_hours(name, info)
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 = "",
ttl: float = None):
"""抢占独占资源。
⚠ 锁**不**绑定进程存活:`lock acquire` 本身是短命进程,而用锁的烧录/构建
在另一个进程里跑。因此判「陈旧」只看**时效**(超过该锁的 TTL),外加显式
`--force`;pid 仅作记录便于排障。TTL 按锁分设(build 1h / bench 6h),
持有者也可用 `ttl`(小时)自报更精确的窗口(如 build 按 --timeout 声明)。
"""
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)
ttl_h = lock_ttl_hours(name, info)
if force or age > ttl_h:
print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时 / TTL {:.1f} 小时{})".format(
name, info.get("task"), age, ttl_h, ",--force" if force else ",视为陈旧"))
try:
p.unlink()
except OSError:
pass
continue
if time.time() >= deadline:
return False, info
print("[lock] {} 被 {} 占用(已持有 {:.1f} / TTL {:.1f} 小时),等待…".format(
name, info.get("task"), age, ttl_h))
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,
"ttl": ttl,
}
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
# --------------------------------------------------------------------- DSH 插件工作树
#
# 插件建的树(.dsh-worktrees/worktree/<name>)缺三样东西,下面的命令逐条补。
# 回收**不在这里**:插件树由「/worktree remove|prune」收,done/abort 也不该拿去处理它。
# 1. 共享联接——没有 .venv 与 simulator 的 SDL2 SDK,插件树里「.venv\Scripts\python.exe」
# 直接找不到解释器:@build、ci_local.py、所有 scripts/check_*.py 全部起跑失败;
# 2. 可见性——插件树不在台账里,「list」看不见它,3 个活跃名额也只数登记任务;
# 3. 独占资源归属——build 锁由 keil_build.py 自持自释(按 cwd 反推持有者名,
# 插件树里算成 pw:<name>),bench 锁仍要人占,占锁名得和「lock status」对得上。
def plugin_lock_owner(name: str) -> str:
"""插件树在锁 payload 里的持有者名:pw:<name>(多段名压平成 -)。
必须与 keil_build.py 的 default_lock_task() 对同一种树算出的名字一致——这样构建
自持自释留下的归属,和手工「lock acquire bench --task pw:x」才是同一个可 grep 的标识。
"""
# 与 plugin_dir_for 同样容忍「worktree/」前缀,否则同一棵树会出现两个持有者名
# (pw:refactor-logging vs pw:worktree-refactor-logging),lock status 就散了。
segs = [s for s in name.replace(os.sep, "/").strip("/").split("/") if s]
if segs and segs[0] == PLUGIN_NAMESPACE:
segs = segs[1:]
return "{}:{}".format(PLUGIN_LOCK_PREFIX, "-".join(segs) or "?")
def plugin_dir_for(name: str) -> Path:
"""插件 worktree 名 → 树目录;**只允许落在 .dsh-worktrees/worktree/ 里面**。
adopt/unadopt 会在解析出的目录上建/摘目录联接;不加这道闸,等于允许把主工作树或
任意 _wt 目录当插件树动手(联接会遮蔽真实内容,事后很难看出来)。
"""
raw = (name or "").strip().replace(os.sep, "/").strip("/")
if not raw:
raise EnvError("缺插件 worktree 名(先跑「plugin list」看现有树)")
if raw == PLUGIN_NAMESPACE or raw.startswith(PLUGIN_NAMESPACE + "/"):
raw = raw[len(PLUGIN_NAMESPACE):].strip("/")
p = (PLUGIN_SUBDIR / raw).resolve()
base = PLUGIN_SUBDIR.resolve()
if p == base or base not in p.parents:
raise EnvError("拒绝 {}:只接受 {} 里面的树".format(p, PLUGIN_SUBDIR))
return p
def plugin_trees():
""".dsh-worktrees/worktree/ 下的工作树(事实源 = git worktree list,不读插件 manifest)。
manifest.json 是插件私有格式(MANIFEST_VERSION 1,字段随版本变),而 git 的注册表
同时覆盖「插件正常建的树」和「manifest 丢了 / 树被手工删」的残留——桥接命令要看的
是真实存在的东西。
"""
try:
base = PLUGIN_SUBDIR.resolve()
except OSError:
return []
trees = []
for ent in worktree_entries():
p = ent["path"]
try:
rp = p.resolve()
except OSError:
continue
if rp == base or base not in rp.parents:
continue
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(),
"path": str(p),
"branch": ent.get("branch") or "(detached)",
"dirty": dirty,
"adopted": is_reparse(p / JUNCTION_SPECS[0][0]),
})
return trees
def print_plugin_trees() -> int:
"""打印插件树一览(无树则静默),返回数量——「list」与「new」共用同一事实。"""
trees = plugin_trees()
if not trees:
return 0
print()
print("DSH 插件工作树({} 下;**不在本 skill 台账** → 不占 {} 个名额、done/abort "
"管不到,回收走 /worktree remove|prune):".format(PLUGIN_SUBDIR, MAX_CONCURRENT))
print("{:<26} {:<34} {:>4} {:>6}".format("名称", "分支", "脏", "联接"))
for t in trees:
print("{:<26} {:<34} {:>4} {:>6}".format(
t["name"], t["branch"], "-" if t["dirty"] < 0 else str(t["dirty"]),
"已联" if t["adopted"] else "未联"))
if any(not t["adopted"] for t in trees):
print("未联的进去跑不了 Python:「plugin adopt <name>」只建共享联接,不碰树内容")
return len(trees)
def adopt_junctions(wt: Path):
"""只补**缺失**的共享联接(已联的跳过、实体目录绝不碰),返回 (已有, 本次新建)。"""
already, made = [], []
for rel, desc in JUNCTION_SPECS:
link = wt / rel
if is_reparse(link):
already.append(rel)
continue
if os.path.lexists(str(link)):
print("[warn] {} 已是实体目录/文件(不是联接)——不动它".format(rel))
already.append(rel)
continue
target = REPO_ROOT / rel
if not target.exists():
print("[skip] {}({})主线里不存在,跳过".format(rel, desc))
continue
try:
make_junction(link, target)
except (Fail, EnvError) as err:
print("[WARN] {} 联接失败:{}".format(rel, err))
continue
made.append(rel)
print("[ok] 联接 {} -> {}".format(rel, target))
return already, made
def cmd_plugin(a) -> int:
if a.action == "list":
if print_plugin_trees() == 0:
print("({} 下没有插件工作树)".format(PLUGIN_SUBDIR))
print(" 建法:新会话输入框选「Worktree模式」/ 让模型调 worktree_create / "
"/worktree create <name>")
print(" 建完回来「plugin adopt <name>」补共享联接,才能在里面跑 @build 与门禁。")
return EXIT_OK
wt = plugin_dir_for(a.name)
if not wt.is_dir():
raise EnvError("目录不存在:{}(先用插件建树:/worktree create 或 worktree_create)".format(wt))
if a.action == "adopt":
if git("rev-parse", "--git-dir", cwd=wt, check=False).returncode != 0:
raise EnvError("{} 不是 git 工作树(别拿普通目录当插件树)".format(wt))
print("为插件树补共享联接:{}".format(wt))
already, made = adopt_junctions(wt)
owner = plugin_lock_owner(a.name)
print()
print("[ok] 联接:已有 {} 个、本次新建 {} 个".format(len(already), len(made)))
print("该目录现在可以跑 @build / ci_local.py / @test;build 锁由 keil_build.py "
"自持自释,持有者名就是 {}".format(owner))
print("上台架前:$TW lock acquire bench --task {} --note <说明>".format(owner))
print("下台架后:$TW lock release bench --task {}".format(owner))
print("回收仍归插件:/worktree remove {}(本 skill 不删插件树)".format(a.name))
return EXIT_OK
removed = [rel for rel, _spec in JUNCTION_SPECS if remove_junction(wt / rel)]
print("[ok] 摘掉联接:{}(只摘联接本身,绝不动目标内容与树里文件)".format(
"、".join(removed) if removed else "无(该树没有联接着)"))
return EXIT_OK
# --------------------------------------------------------------------- 子进程脚本
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, "静态门禁 + 主机侧纯逻辑单测 本地等价 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
# 告知 @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,
"@build(0 错 0 警)"):
ok = False
finally:
os.environ.pop("DTU_LOCK_HELD", None)
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 <ref>:<file>),别只看结果文件。")
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))
n_plugin = len(plugin_trees())
if n_plugin:
print("[i] 另有 {} 个 DSH 插件工作树(.dsh-worktrees/,不计入上限 {});"
"磁盘与 UV4 / 台架仍是共享资源——「plugin list」看清单".format(n_plugin, 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 <ID> <slug>` 建一个)")
print_plugin_trees()
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")))
print_plugin_trees()
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} 空闲(TTL {:.1f}h)".format(n, lock_ttl_hours(n)))
else:
age = lock_age_hours(info)
ttl_h = lock_ttl_hours(n, info)
mark = " [陈旧,可被自动接管]" if age > ttl_h else ""
print("{:<6} {} 持有(pid={},于 {},已 {:.1f} / TTL {:.1f} 小时,备注 {}){}".format(
n, info.get("task"), info.get("pid"), info.get("at"), age,
ttl_h, 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 <ID>")
got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force,
note=a.note or "", ttl=a.ttl)
if not got:
print("获取失败:{} 被 {} 占用(pid={},于 {})".format(
a.name, info.get("task"), info.get("pid"), info.get("at")))
return EXIT_FAIL
print("已获取 {} 锁(task={} pid={} ttl={})".format(
a.name, a.task, os.getpid(),
"{:.2f}h".format(a.ttl) if a.ttl else "默认"))
return EXIT_OK
if a.bench_pass:
if not a.task:
raise EnvError("--bench-pass 需要 --task <ID>")
# 非登记持有者(lead 的验收窗、插件树 pw:*)没有台账。以前这里先 load_state
# 就抛「任务 X 不存在(缺台账)」,**根本走不到释放**:锁还挂着,而 done 的前置
# 会因活锁判失败——一个名字没登记,就挡住所有任务的收口。现在降级为显式告警 +
# 照常释放:PASS 没进台账是事实,但不该拿「锁放不掉」当代价。
if state_file(a.task).is_file():
st = load_state(a.task)
st["bench_pass"] = True
save_state(st)
print("[ok] 已为任务 {} 记录台架 PASS".format(a.task))
else:
print("[warn] 任务 {} 无台账({})→ **跳过 PASS 记录,仅释放锁**;要把 PASS 留在"
"台账里就得用已「new」出来的任务 ID 占锁,或把验收结论写进主干提交说明/文档"
.format(a.task, state_file(a.task)))
ok, msg = lock_release(a.name, a.task, force=a.force)
print(msg)
return EXIT_OK if ok else EXIT_FAIL
def release_stale_locks() -> None:
"""`done` 前置:陈旧锁自动接管;仍被有效占用的则拒绝收口。
「陈旧」= 持有时间 > 该锁 TTL(build 1h / bench 6h,或持有者自报的 ttl)。
崩溃/遗忘留下的锁不该阻塞收口——故自动释放而非硬失败(2026-10-12)。
"""
for n in LOCK_NAMES:
info = lock_read(n)
if not info:
continue
age = lock_age_hours(info)
ttl_h = lock_ttl_hours(n, info)
if age > ttl_h:
ok, msg = lock_release(n, force=True)
print("[i] {} 锁已陈旧({} 持有 {:.1f}h > TTL {:.1f}h),自动接管:{}".format(
n, info.get("task"), age, ttl_h, msg))
continue
raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} / TTL {:.1f} 小时),先 release".format(
n, info.get("task"), age, ttl_h))
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))
release_stale_locks()
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("--ttl", type=float, default=None,
help="本次持有自报的陈旧窗口(小时);缺省用该锁默认值(build 1h / bench 6h)")
p.add_argument("--bench-pass", action="store_true",
help="release bench 时顺带为该任务记录台架 PASS")
p.set_defaults(func=cmd_lock)
p = sub.add_parser("plugin", help="DSH worktree 插件的树(.dsh-worktrees/):list / adopt / unadopt")
p.add_argument("action", choices=["list", "adopt", "unadopt"])
p.add_argument("name", nargs="?", default="",
help="插件 worktree 名(可带 worktree/ 前缀或多段斜杠)")
p.set_defaults(func=cmd_plugin)
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())