8cab390aa7
唯一事实源是脚本常量 MAX_CONCURRENT(task_worktree.py:30),报错文案与 new 的插件树提示都插值它; 其余全是引用面同步:SKILL.md 12 行(含 frontmatter 的 description 与 whenToUse——它会进技能目录)、 方案文档表格行 + 依据段重写、启动日志原子性复验流程 1 行去掉数字。 依据段按今天的实测重写,不再拿磁盘当理由:一棵纯净检出 84.0 MiB / 2857 个受控文件(docs 独占 46 MiB),.venv 与 simulator/SDL2 走目录联接共享、不随树复制,D 盘余 1.4 TB → 8 棵全开最坏约 3.2 GB(每棵再叠一次构建产物:Keil 中间件 289 MB + 模拟器 35 MB)。真正随并行数上涨的是排队: build 锁 TTL 1.0h、bench 锁 TTL 6.0h 分别串行化 @build 与 @test,done 的合并也必须串行——8 棵是 8 个空间,仍是一套 Keil 授权 + 一块板。 扫描按全形态跑(上限 3 / 最多 3 / 第 4 棵 / MAX_CONCURRENT = 3 / 硬上限 3 / 名额),整行打印后 另抓到两处只按数字搜不到的形态:whenToUse 里的「白占 3 个名额之一」、「确实要同时开第 4 棵」 (上限变了它必须跟着变第 9 棵,否则自相矛盾)。 冻结不改并写明类别:带日期戳的历史测量(2026-10-28 一次回收 3 棵、各 89 MB)、用户原话引语、 列表与步骤序号。「83 MB」是当时 du 口径、与本次内容字节和 84.0 MiB 同数不同义 → 只补口径注并 指回方案 §4,不合并成同一个数;方案 §2 的「对象库 234 MB」保留——今天的 294 MB 含待两周宽限期 过期的不可达对象,不把临时态钉进文档。 本轮我自己写错并已改掉:新段落引用了不存在的子命令 lock list,真命令是 lock status(全仓零残留)。 验证:上限生效用 %TEMP% 隔离小仓实测(脚本按同深度拷贝,因 REPO_ROOT 由 __file__ 反推)——前 8 棵 全成功,第 9 棵 exit 2 且文案为「已有 8 个活跃任务(上限 8)」,--force 照旧放行,list 9 行;真仓 零副作用(worktree list 仍 1 条、同级 _wt 无新建目录、无 t0* 残留)。门禁:doc-links --all --sections-strict RC=0(2601 处、无法判定 0)、编号门禁 [OK]、py_compile RC=0、四文件无 BOM、 纯 LF、无 U+FFFD、无行尾空格。改动不落体积门禁指纹面,不需要 @build。
644 lines
57 KiB
Markdown
644 lines
57 KiB
Markdown
---
|
||
name: task-worktree
|
||
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、活跃任务硬上限 8 与腾位、build/bench 两类独占锁(含非登记任务占锁后「缺台账」释放不掉的正解)、done 预演合并回主干、门禁未全绿的补基线收尾、并集合并残留审计、**非登记工作树(detached HEAD / 无台账 / 树内有在飞或未跟踪内容)的安全回收**(逐文件内容分桶 + 联接/reparse 预扫 + 唯一残留先备份再 --force + 悬挂台账清理复验),以及**把仓外 detached worktree 当净室测量仪**——门禁指标异常增长时一次只注入一份在飞文件、与基线差分做逐文件归因;**以及与 DSH worktree 插件(`/worktree`、模型工具 `worktree_create`,树在仓内 .dsh-worktrees/)的桥接**——`plugin adopt` 补共享联接、锁归属 `pw:<name>`、回收仍归 /worktree。规格见 docs/环境与部署/并行任务工作树方案.md。
|
||
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到 `new` 报「已有 8 个活跃任务(上限 8)」、`lock release` 报「任务 X 不存在(缺台账)」、`done` 报「门禁未全绿:工作树与分支保留,未清场」、两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时;或**门禁指标异常增长(如 id-check 的不可判定数从 1,162 涨到 1,178、+16)却查不出是哪个文件 / 哪条任务道造成的**、主工作树压着多条在飞改动而不许 stash / 提交 / 改门禁脚本、要按 HEAD 基线只读出像验收时;或**要回收 `@task list` 里根本看不到的工作树**(`git worktree list` 有它、台账没有 / detached HEAD / 树里疑似留着别的会话的在飞或未跟踪内容)、或**要清台账里 branch / path 都已不存在的悬挂条目**(`@task list` 输出发怪、`new` 报「任务已存在」或白占 8 个名额之一);或**这棵隔离空间是 DSH worktree 插件开的**(会话选了「Worktree模式」/ `/worktree create`,树在仓库内 `.dsh-worktrees/`)——进去发现 `.venv` 不在、`@build` 与门禁全部起跑失败,或想看清谁在占 UV4 / 台架 → F 节。
|
||
---
|
||
|
||
# task-worktree — 多任务并行开发空间(短命令 `@task`)
|
||
|
||
**执行体 = 本 skill 自带脚本** `.dsh/skills/task-worktree/scripts/task_worktree.py`
|
||
(纯标准库,任意 python3;拷整个 skill 目录到别的项目即可用)。
|
||
|
||
@T3@powershell
|
||
# cwd = 仓库根(或任意子目录)
|
||
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" <子命令> [参数]
|
||
@T3@
|
||
|
||
bash(`ops_bash`)里固定两个变量,下文用 `$TW` 代指脚本:
|
||
|
||
@T3@bash
|
||
PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktree.py"
|
||
"$PY" "$TW" list
|
||
@T3@
|
||
|
||
退出码:`0`=成功 / `1`=操作失败 / `2`=前置或环境错误 / `3`=用法。
|
||
**⚠ 这几个码只有「不接管道」时才读得到**——原因与对策见「读输出」一节。
|
||
|
||
## 固定命令
|
||
|
||
| 命令 | 作用 |
|
||
|---|---|
|
||
| `new <ID> <slug>` | 建分支 `task/<ID>-<slug>` + 工作树 + 共享联接 + 台账;打印新空间路径 |
|
||
| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 |
|
||
| `status <ID>` | 单任务详情(未提交清单、相对基线漂移、台架记录、已建联接) |
|
||
| `sync <ID>` | 在任务工作树里 `rebase` 追平主干,把冲突控制在任务分支上 |
|
||
| `lock acquire|release|status <build|bench>` | 独占资源锁 |
|
||
| `plugin list|adopt|unadopt [插件树名]` | 与 DSH worktree 插件的桥:看清单 / 补共享联接 / 摘联接(**不删树**)→ F 节 |
|
||
| `done <ID>` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 |
|
||
| `abort <ID>` | 放弃任务(工作树 + 分支一起删,**永久丢弃**) |
|
||
|
||
`new` 常用开关:`--base <分支>`(默认 `v2-on-ui`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)·
|
||
`--lean`(跳过 Git LFS smudge)· `--no-bootstrap`(不建联接)·
|
||
`--force`(**仅跳过并发上限 8 的检查**,不解决「谁该让位」,见 A 节)。
|
||
|
||
`done` 常用开关:`--dry-run`(只预演,零改动)· `--skip-build` · `--skip-gates` · `--force`(清场时强制移除工作树)。
|
||
|
||
`lock` 开关:`--task <ID>`(acquire 必填)· `--wait <秒>` · `--ttl <小时>`(自报窗口)· `--note <文本>` ·
|
||
`--bench-pass`(release 时给台账记 PASS)· `--force`。
|
||
⚠ `--note` 只在 **acquire** 时写进锁 payload,**release 时被忽略**——别指望它留下验收证据(证据写进主干提交说明或文档)。
|
||
|
||
**环境变量**(高级):`DTU_WT_ROOT`(任务容器位置,默认仓库同级 `_wt/`)·
|
||
`DTU_TASK_BASE`(默认基线分支,默认 `v2-on-ui`)·
|
||
`DTU_INTEGRATION_WORKTREE`(集成点,默认主工作树——用于隔离演练/测试)。
|
||
|
||
## 典型流程
|
||
|
||
@T3@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
|
||
@T3@
|
||
|
||
## 读输出:管道与截断(两起误判的共同根因)
|
||
|
||
1. **`... 2>&1 | tail -N` 的退出码是 `tail` 的,恒为 0** → 脚本的 `1` / `2` 全被吞。实测三条印着
|
||
「rc=0」的输出其实都是 exit 2:`new` 撞活跃上限、`lock release` 缺台账两次。
|
||
所以**判据先看文本**(`[ok]` / `!!` / `前置/环境错误:` / `失败:`),要真码就**直接执行不接管道**或补一句
|
||
`echo RC=$?`;坚持接管道就在 bash 里取 `${PIPESTATUS[0]}`。
|
||
2. **`done` / `done --dry-run` 的输出不要用 `tail -2` 截**:接管道时 python 的 stdout 是块缓冲、git
|
||
子进程的 stderr 不缓冲,两股流错位混排,`tail -2` 只剩「--dry-run:到此为止…」加上 stderr 里带
|
||
`conflict` 字样的片段 → 据此误判「有冲突」。实测:`tail -2` 误读成 conflict,放宽到 `tail -10` 才看到
|
||
`[ok] 无冲突,预演结果树 <sha>`。dry-run 全文只有 5~8 行,直接读全量。
|
||
|
||
## 分步操作(六条运行时机制)
|
||
|
||
### A. `new` 被拒「已有 8 个活跃任务(上限 8)」——怎么腾位
|
||
|
||
1. 读原文:`前置/环境错误:已有 8 个活跃任务(上限 8);先 done/abort,或加 --force`(exit 2)。
|
||
上限是脚本常量 `MAX_CONCURRENT = 8`;**「活跃」的判据 = `_wt/.state/<ID>.json` 台账存在,且台账里
|
||
`path` 指向的目录还在**(手工删了工作树目录就不占名额,但会留下悬挂台账,见第 5 步与 **E 第 6 步**)。
|
||
2. `$TW list` 看全景(分支 / 落后领先 / 脏文件数)决定谁让位:**已完成的先 `done`**(合并回收,一步不省)。
|
||
3. 没人认领、零提交、尚未 claim 的空闲树 → `abort <ID>` 腾位(实测就是 abort 掉排在后面的空闲树换出名额)。
|
||
`abort` = 工作树 + 分支一起删、**永久丢弃**;工作树有未提交改动或已有提交时脚本会拒绝,确认可弃再加 `--force`。
|
||
4. 确实要同时开第 9 棵才 `new <ID> <slug> --force`:磁盘代价约 **83 MB/棵**(`--lean` 约 45 MB),
|
||
并行度上去了、**冲突面与台架排队也一起上去**——不是免费的。
|
||
5. **别用手工 `git worktree remove` 给「登记任务」腾位**:台账不会被删(下次 `new` 同 ID 会报「任务 <ID> 已存在」),
|
||
还绕过了「先摘联接」(铁律 5),有让 git 递归进联接把主工作树 `.venv` 删掉的风险。要回收的若是**非登记树**
|
||
(`$TW list` 里根本没有它:`new` 出来又被手工删掉只剩悬挂台账的残骸 / D 节 `--detach` 净室 / 别的会话手工建的)
|
||
→ 腾位与回收一起走 **E 节**,那里有「怎么证明这棵树零联接所以可以 remove」的可执行判据。
|
||
6. 腾位后复验:`$TW list` 的条目数与 `ls _wt` 的目录数一致,再 `new`。
|
||
|
||
### B. 非登记持有者占的锁:`lock release --bench-pass` 曾报「任务 X 不存在(缺台账)」
|
||
|
||
**症状**(lead 在主工作树跑台架验收窗,用临时名占锁 `lock acquire bench --task acceptwin`):
|
||
|
||
@T3@bash
|
||
"$PY" "$TW" lock release bench --task acceptwin --bench-pass
|
||
前置/环境错误:任务 acceptwin 不存在(缺台账 _wt/.state/acceptwin.json) # 锁照旧挂着
|
||
"$PY" "$TW" lock release bench --task acceptwin --force --bench-pass # 同一句错误,锁仍在
|
||
@T3@
|
||
|
||
**根因**:`release` 分支里只要带 `--bench-pass` 就先 `load_state(task)` 去台账写 PASS,而 `acquire` 侧
|
||
**不查台账**——这个不对称让「释放」被「记账」挡死(`--force` 只覆盖持有者不符与陈旧接管,管不到台账)。
|
||
后果比报错本身大:锁没放,`done` 的前置 `release_stale_locks()` 对未到 TTL 的活锁直接判失败 →
|
||
**一个名字没登记,就挡住所有任务的收口**。
|
||
|
||
**2026-10-29 起已修**(`task_worktree.py`:无台账降级为 `[warn] …跳过 PASS 记录,仅释放锁` 并照常释放)。
|
||
现在的正解只有三步,**不再需要手写占位台账**:
|
||
|
||
1. `$TW lock status` 认现场(持有者 / pid / 已持有时长 / TTL)——别对同一句报错反复重试 release。
|
||
2. `$TW lock release <build|bench> --task <NAME> [--bench-pass]` 直接放;PASS 记不上会打印 warn,锁照放。
|
||
3. **PASS 后面还要用**(`done` 只认台账里的 `bench_pass`)→ 从一开始就用已 `new` 出来的任务 ID 占锁,
|
||
或把验收结论写进主干提交说明 / 文档。**插件树 `pw:<name>` 天然没有台账**,别指望它留 PASS → F 节。
|
||
|
||
最后手段(脚本本身都走不通时):锁就是 `_wt/.state/locks/<name>.lock` 里的单行 JSON
|
||
(`name/task/pid/host/epoch/at/note/ttl`),`release` 的实现就是 unlink 它——手工删这个文件等价于
|
||
「不记 PASS 的强制释放」,只在前面几步都不可用时才用。**历史遗留的占位台账**(早期为绕这个坑手写的
|
||
`_wt/.state/<NAME>.json`)现在已无必要:它活着时既占 `MAX_CONCURRENT = 8` 名额又被 `list` 当任务列出,
|
||
按 E 第 6 步备份后删掉即可。
|
||
|
||
### C. `done <ID>` 的正确用法(它是合并点,不是查看器)
|
||
|
||
1. **合并前确认任务属主已把全部改动提交完**。**`$TW status <ID>`** 看未提交清单与领先提交数;跨会话任务
|
||
再问一句属主(`send_message`)。实测教训:属主还剩「2/2」没提交就 `done`,把只含 1/2 的分支合进了主干,
|
||
只能另开一棵树补后半并向对方说明(是自己的时序错,不是分支问题)。
|
||
2. **先 `--dry-run`,并读全量输出**(别 `tail -2`,见「读输出」)。判据两行:
|
||
`[ok] 前置检查通过(领先 <base> 共 N 个提交)` + `[ok] 无冲突,预演结果树 <sha>`;
|
||
**N 必须等于属主应交的提交数**,对不上回到第 1 步。dry-run 零改动。
|
||
3. 正式 `$TW done <ID>`——**必须在主工作树**(铁律 1)。
|
||
4. 输出 `!! 门禁未全绿:工作树与分支**保留**,未清场` 时,紧跟的 `主干已含合并提交 <sha>` 已经说明
|
||
**合并落进主干了**。
|
||
- ✅ 走提示里的 **a) 在主工作树修正后追加提交**;
|
||
- ❌ **绝不用 b) 的 `git -C "<主树>" reset --hard <sha>`**——那是丢弃已入库的合并提交,而
|
||
`reset --hard` 本身是项目级 Never(会连未提交工作一起丢)。提示 b 只是列出的可能性,不是建议。
|
||
- 先定位是**哪条钩子**红:门禁输出里的 `[SUMMARY] static: pre-commit hooks -- passed=N failed=M`,
|
||
不同根因处置不同(见陷阱「`done` 的门禁 = `pre-commit run --all-files`」)。最常态是 `.map` 滞后 → 第 5 步。
|
||
5. 补绿固定链(2026-10-25 实测;顺序即因果,全在**主工作树**跑,逐条不接管道看退出码):
|
||
|
||
@T3@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/check_test_coverage.py --update-baseline # 覆盖率基线(新单测 / 新套件时必刷)
|
||
"$PY" scripts/check_doc_links.py --all # 文档链接与章节引用
|
||
git add <上面几条刷出来的基线/快照文件> # 显式列文件,别 -A 把别人在飞的改动一起提交
|
||
git commit -F <UTF-8 无 BOM 的正文文件> # 提交属 Ask first(AGENTS.md 边界),按用户指示再做
|
||
@T3@
|
||
|
||
为什么必然要补,见陷阱「`done` 的门禁跑在 `@build` 之前」。
|
||
6. **重跑 `done` 之前主工作树必须干净**——否则前置检查直接
|
||
`失败:主工作树不干净(…)——合并会把未提交改动卷进来`,所以第 5 步的产物必须**先提交**。重跑时若分支
|
||
已并入,会打印 `[i] task/<ID>-<slug> 已合入 <base>,跳过合并,直接清场` → 摘联接 → `完成(分支此前已合入)`。
|
||
7. 清场完成后再敲一次 `done <ID>` 会得到 `前置/环境错误:任务 <ID> 不存在(缺台账 …json)`(exit 2)——
|
||
**这不是失败**:清场的最后一步 unlink 了台账。判完成以 `git log` 为准。
|
||
|
||
### D. 门禁指标异常增长 → 逐文件归因(把净室工作树当**测量仪**,主树全程只读)
|
||
|
||
**场景**(2026-10-27 实测):`id-check` 突然 FAIL「不可判定 1,178 > 上限 1,166」——比上一轮量到的 1,162 又涨了 **+16**,
|
||
而主工作树里同时压着 4 条在飞任务道的未提交改动。**不许 stash / checkout / reset**(那是搬动甚至丢弃别人未提交的工作,
|
||
项目级 Never;`git status` 一乱,整条道的现场就没了),**不许改门禁脚本本身**(它就是那把尺子,改了数字就不可信),
|
||
**也别在脏主树里跑门禁**(那测的是 4 条道 + 未跟踪文件的混合体,永远分不出谁)。正解:**在仓库之外另起一棵
|
||
detached HEAD 的干净工作树当净室,一次只往里注入一份在飞文件**,与基线差分即得每个文件的独立贡献。两步:
|
||
**净室建树 → 单文件注入测量**。
|
||
|
||
1. **建净室 + 先落基线数**(路径必须**在仓库目录之外**;铁律 7 对临时树同样成立——放仓内就污染 `git status` /
|
||
`pre-commit --all-files` / 全库搜索):
|
||
|
||
@T3@bash
|
||
MAIN="D:/Code/DTU Code/STM32F4-Base" # 主工作树绝对路径(含空格 → 变量整体加引号)
|
||
WT="$MAIN/../_wt/und-attr" # 仓外同级 _wt/ 下;一次测量固定用同一个名字
|
||
rm -rf "$WT" 2>/dev/null
|
||
git -C "$MAIN" worktree add --detach "$WT" HEAD >/dev/null 2>&1 || { echo WT_FAIL; exit 9; }
|
||
git -C "$WT" status --porcelain # 应为空
|
||
"$PY" "$WT/scripts/archive/id_gate/check_ids.py" 2>&1 | tail -25 # ← 口径:id-check 钩子已于 2026-10-28 退役(脚本移入 scripts/archive/id_gate/),按该脚本自身默认参数跑;基线与注入用**同一条命令**
|
||
@T3@
|
||
|
||
- `--detach`(**不要 `-b`**):净室没有分支、不写 `_wt/.state/<ID>.json` 台账 → **不占 `MAX_CONCURRENT = 8`
|
||
名额、不出现在 `$TW list`**,也没人会被误指去对它跑 `done`(建与拆都不走 `$TW new` / `$TW done`)。
|
||
- 能这么量的前提:该门禁**只读仓库内文本**。依赖本树构建产物的门禁(`mem-numbers` / `mem-budget` 要 `.map`)
|
||
在净室里必然报「找不到 .map 事实源」(见陷阱「新建工作树从未构建过」)——**那类指标不能这么归因**。
|
||
- `git status --porcelain` 非空 = 有别人的未跟踪文件被带进净室 → 先 `git clean -xdn` **干扫**(别去 `n`)
|
||
看清会删什么,只挪走多出来的那些再测;语料被污染出来的数字与 CI 不可比。
|
||
2. **一次只注入一份在飞文件 → 跑同一个扫描器 → 与基线差分**(得到的是每个文件的**独立**贡献,不是合计):
|
||
|
||
@T3@bash
|
||
for f in "App/log/sys_log_mcu.c" "test/host/sys_log_mcu/sys_log_mcu_test.c" "docs/子系统设计/运行与自检记录设计说明.md"; do
|
||
mkdir -p "$WT/$(dirname "$f")"
|
||
cp "$MAIN/$f" "$WT/$f" # 拷进净室;主树那份只读、一字节不动
|
||
echo "== $f =="; "$PY" "$WT/scripts/archive/id_gate/check_ids.py" 2>&1 | tail -25 # ← 与第 1 步同一条命令
|
||
git -C "$WT" checkout -- "$f" # 只还原净室里自己刚拷进来的那份
|
||
done
|
||
@T3@
|
||
|
||
实测:`run_par.c` / `board_ui_nav.py` / `check_nested_comment.py` / keil `SKILL.md` **各 +0**,
|
||
而 log 道那三个文件 **+6 / +8 / +2**,合计**正好 +16**。**Σ(逐文件增量) ≠ 观察到的涨幅 → 还有没被注入的
|
||
在飞文件,或存在文件间交互项**,不许就此交卷。
|
||
3. **定性增量是哪一类字面量**(只有「谁」没有「为什么」= 给不出可执行的改法):同一计数器在 **HEAD 版**与
|
||
**在飞版**各数一遍,**差值才是新增行带来的**(存量的不算这条道的账);再对新增行直接 grep。本次 16 处全是
|
||
**裸形 「字母 T 累进数字 4」**(`T` 在 `scripts/archive/id_gate/id_registry.json` 的 `bare_check_letters` 里,规范形态是连字符形),
|
||
全部落进 `skip_bare_unmanaged` —— 到这一步结论才配发给属主。
|
||
4. **闭合 + 复验主树一个字节没被污染**:
|
||
|
||
@T3@bash
|
||
git -C "$MAIN" worktree remove --force "$WT" # 净室里只有自己的拷贝、没有原件,--force 安全
|
||
git -C "$MAIN" worktree list # 不该再有 und-attr
|
||
git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(测量前先存一份基线清单)
|
||
@T3@
|
||
|
||
⚠ **上面第一行的前提是有条件的**:树里混进了别人的在飞 / 未跟踪内容时「只有自己的拷贝」就不成立了,
|
||
`--force` 直接拆等于替别人把唯一版本删掉 → 先走 **E 节**(全景 → 逐文件分桶 → 联接预扫 → 唯一残留先备份),
|
||
再回来执行本步;复验按 **E 第 5 步**做(拆前/拆后主树 `status --porcelain -uall` 行数必须相同)。
|
||
**同一棵净室树的第二种用法**:按指定 commit **只读**构建出像 / 烧录验收——不搬动任何在飞改动,也不为台架
|
||
提前抢发一笔提交;构建与烧录都在这棵树里做(不在主树),仍受 `bench` / `build` 锁串行(铁律 3)。
|
||
|
||
### E. 回收**非登记**工作树(`$TW list` 为空 / detached HEAD / 树里有在飞或未跟踪内容)
|
||
|
||
**这类树的判定特征**(命中任意一条就适用本节):`git worktree list` 里有它、但 `_wt/.state/<ID>.json` 台账不存在;
|
||
HEAD 是 detached(`git rev-parse --abbrev-ref HEAD` 打 `HEAD`);或树里的改动不属于本会话(别的会话留下的未提交 /
|
||
未跟踪文件)。典型来源:D 节的 `--detach` 净室、`new` 之后被手工删目录只剩台账的残骸、别人手工 `git worktree add` 建的分析树
|
||
(2026-10-28 实测一次回收 3 棵:`verify-task16` / `verify-batch` / `und-attr`,各 89 MB,全部无台账 + detached)。
|
||
|
||
**为什么 `done` / `abort` 在这里整体不适用**(铁律 9):两条子命令的前置都是 `load_state()`,无台账 →
|
||
`前置/环境错误:任务 <ID> 不存在(缺台账 …json)`(exit 2)当场停住、什么也不会发生;而**假如有**一份残留台账,
|
||
`abort --force` 会拿台账的 `path` 直接 `git worktree remove` —— **绕过下面第 1~4 步的全部内容保全**,把别的会话的唯一版本一次删光;
|
||
`done` 则会去找那条(多半已不存在的)`task/` 分支做合并。另:本路径没有「合并」这一步,所以 `done` 的并集合并残留审计
|
||
在这里不发生,也没有三方对比可依据 —— **唯一性只由第 2 步的内容分桶给出**。回收只能手工走本节第 1~5 步 + 第 6 步清台账。
|
||
|
||
1. **建全景(全程只读)**:先看 `git worktree list` 的全集,再**逐棵**记三件事(脏行数含未跟踪 / 树顶提交 / 有无分支)
|
||
加一条台账判定,同时存下主树的拆前基线清单:
|
||
|
||
@T3@bash
|
||
MAIN="D:/Code/DTU Code/STM32F4-Base"; WTROOT="$MAIN/../_wt"
|
||
git -c core.quotePath=false worktree list # 全集:登记树 + 非登记树都在这份里
|
||
MAIN_SHA=$(git -C "$MAIN" rev-parse --short HEAD) # 下面所有比对都以这一版为主线尺子
|
||
mkdir -p ci-artifacts
|
||
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-before.txt # 拆前主树基线(第 5 步逐行比)
|
||
wc -l < ci-artifacts/wt-main-before.txt # 记下行数(实测 0)
|
||
for t in "$WTROOT"/*/; do
|
||
echo "--- $t"
|
||
git -c core.quotePath=false -C "$t" status --porcelain -uall | wc -l # 脏 + 未跟踪行数
|
||
git -c core.quotePath=false -C "$t" log --oneline -1 # 树顶提交
|
||
git -C "$t" rev-parse --abbrev-ref HEAD # 打 HEAD = detached,压根没分支可 done
|
||
ls "$WTROOT/.state/$(basename "$t").json" 2>/dev/null || echo "NO-LEDGER" # 台账位置随 DTU_WT_ROOT
|
||
done
|
||
@T3@
|
||
|
||
- **`-uall` 不是可选**:默认 `status --porcelain` 会把未跟踪目录折叠成一行 `dir/`,`-uall` 才展开到文件级;少了它
|
||
第 2 步的分桶就漏项(实测 `und-attr` 唯一那份就是未跟踪 `?? scripts/check_nested_comment.py`)。
|
||
- 路径含空格(本项目 `DTU Code`)→ 变量整体加引号;`core.quotePath=false` 让中文路径原样输出,便于逐行核对。
|
||
- 判定「是不是登记树」看两样:**台账在不在 + HEAD 是否 detached**。两者都指「非登记」→ 从这里开始**禁止**
|
||
`done` / `abort`(见本节开头),继续第 2 步。
|
||
2. **逐文件与主线 HEAD 比内容并分三桶**:`IN-MAINLINE`(内容等价,拆 = 零损失)/ `UNIQUE-UNMERGED`(主线没有这份
|
||
内容,拆 = 丢)/ `OLDER-DRAFT`(同路径主线已有更新版本)。落成独立只读探针跑(别用 heredoc 现写现改,
|
||
见陷阱「别用 heredoc 打补丁造探针」):
|
||
|
||
@T3@python
|
||
# ci-artifacts/wt_bucket_scan.py — 只读:逐文件与主线工作区/HEAD 比对,三桶归类
|
||
import os, subprocess, tempfile
|
||
MAIN = r"D:\Code\DTU Code\STM32F4-Base"
|
||
WTROOT = os.path.join(os.path.dirname(MAIN), "_wt")
|
||
TREES = ["verify-task16", "verify-batch", "und-attr"] # ← 第 1 步全景里挑出的非登记树
|
||
SKIP_TOP = {".venv", "MDK-ARM", ".git", "ci-artifacts"} # 联接 / 构建产物 / 忽略区,按项目调
|
||
def git(*a):
|
||
return subprocess.run(("git", "-c", "core.quotePath=false", "-C", MAIN) + a,
|
||
capture_output=True, text=True, errors="replace")
|
||
def bucket(rel, tree):
|
||
p = os.path.join(tree, rel)
|
||
if not os.path.exists(p):
|
||
return "OTHER", "工作树里已不存在(多为已 staged 删除)"
|
||
tf = tempfile.NamedTemporaryFile(delete=False)
|
||
tf.write(open(p, "rb").read()); tf.close()
|
||
# 1) 与主线工作区(= 当前 HEAD 的检出内容)逐字节比
|
||
if git("diff", "--no-index", "--quiet", tf.name, rel).returncode == 0:
|
||
os.unlink(tf.name); return "IN-MAINLINE", "与主线 HEAD 逐字节相同"
|
||
# 2) 与主线历史各版比:内容等价于某个旧 commit → 只是主线又往前走了
|
||
hist = [x.strip() for x in git("log", "--format=%H", "HEAD", "--", rel).stdout.splitlines()]
|
||
for sha in hist[1:]:
|
||
old = git("show", f"{sha}:{rel}")
|
||
if old.returncode == 0 and old.stdout == open(p, encoding="utf-8", errors="replace").read():
|
||
os.unlink(tf.name); return "IN-MAINLINE", f"内容等价于主线历史版 {sha[:8]}"
|
||
# 3) 主线从没这条路径 → 唯一;否则同路径主线有更新版 → 更旧草稿(再用 blob diff 定性方向)
|
||
os.unlink(tf.name)
|
||
if not hist:
|
||
return "UNIQUE-UNMERGED", "主线 HEAD 与历史都没有此路径"
|
||
return "OLDER-DRAFT", "同路径主线有更新版本(用 diff --stat <HEAD> -- <f> 定性)"
|
||
for name in TREES:
|
||
tree = os.path.join(WTROOT, name)
|
||
lines = subprocess.run(("git", "-c", "core.quotePath=false", "-C", tree,
|
||
"status", "--porcelain", "-uall"),
|
||
capture_output=True, text=True, errors="replace").stdout.splitlines()
|
||
tally = {}
|
||
for line in lines:
|
||
rel = line[3:].split(" -> ")[-1].strip('"') # 兼容 ?? / M / A / R 的箭头形态
|
||
if rel.split("/")[0] in SKIP_TOP:
|
||
continue
|
||
b, why = bucket(rel, tree)
|
||
tally.setdefault(b, []).append((rel, why))
|
||
g = lambda k: len(tally.get(k, []))
|
||
print(f"== {name}: IN-MAINLINE={g('IN-MAINLINE')} UNIQUE-UNMERGED={g('UNIQUE-UNMERGED')} "
|
||
f"OLDER-DRAFT={g('OLDER-DRAFT')} OTHER={g('OTHER')}")
|
||
for b in ("UNIQUE-UNMERGED", "OLDER-DRAFT", "OTHER"):
|
||
for rel, why in tally.get(b, []):
|
||
print(f" {b}: {rel} # {why}")
|
||
@T3@
|
||
|
||
报告里必须**逐桶给计数 + 判定证据**(实测:`verify-task16` IN-MAINLINE=6 / UNIQUE=0;`verify-batch` IN-MAINLINE=35 /
|
||
UNIQUE=1;`und-attr` IN-MAINLINE=0 / UNIQUE=1)。两个非空桶再各钉一条命令把证据坐实:
|
||
|
||
@T3@bash
|
||
# 「唯一未合并」是真更新还是更旧草稿?blob 级 diff + 逐行看方向
|
||
git -c core.quotePath=false -C "$MAIN" diff --stat "$MAIN_SHA" -- simulator/tests/README.md
|
||
diff <(git -C "$MAIN" show "$MAIN_SHA":simulator/tests/README.md) "$WTROOT/verify-batch/simulator/tests/README.md"
|
||
# 「树顶提交要不要保」只看一件事:是否已并入 HEAD。别拿「目录很久没动」当判据
|
||
for c in 77779d49 1d4b6a17; do
|
||
git -C "$MAIN" merge-base --is-ancestor "$c" HEAD && echo "$c MERGED" || echo "$c NOT-merged"
|
||
done
|
||
@T3@
|
||
|
||
实测:那份 `simulator/tests/README.md` 树内写「27 页 × 3200 B」、主线 HEAD 已是「60 张 × 3200 B」→ **更旧草稿**(被取代的
|
||
早先版本),不是未合并成果;`bench-21-77779d49` / `bench-23-1d4b6a17` 两棵 `merge-base --is-ancestor` 判 **MERGED** 且脏数 = 0
|
||
→ **保留不动**(不属本节,等属主自己收,报告里点名说清为什么留)。
|
||
**回收判据不是「看着旧」**:唯一内容如果没被提交过,`git log` 里根本看不见它;反过来「提交已 MERGED」也不等于可以拆——
|
||
还得看未跟踪那一层。`UNIQUE-UNMERGED` 非空又确实要留 → 它进第 4 步的备份,**不许靠 `--force` 顺手带走**。
|
||
3. **拆前扫联接 / reparse point(物理安全性 = 铁律 5 的可执行判据)**。存成 `ci-artifacts/wt_junction_scan.py` 再跑:
|
||
|
||
@T3@python
|
||
# 只读预扫:一个路径是不是 reparse point(Windows 目录联接在这里现形);发现即停止深入,绝不 walk 进联接
|
||
import os, ctypes
|
||
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
||
GA = ctypes.windll.kernel32.GetFileAttributesW
|
||
def is_reparse(p):
|
||
a = GA(p)
|
||
return a != -1 and bool(a & FILE_ATTRIBUTE_REPARSE_POINT)
|
||
for name in ["verify-task16", "verify-batch", "und-attr", "bench-21-77779d49", "bench-23-1d4b6a17"]:
|
||
top = os.path.join(r"D:\Code\DTU Code\_wt", name)
|
||
hits, entries = [], 0
|
||
for root, ds, fs in os.walk(top):
|
||
bad = [d for d in ds if is_reparse(os.path.join(root, d))]
|
||
if bad:
|
||
hits += [os.path.join(root, d) + "/" for d in bad]
|
||
ds[:] = [] # 有联接就不再深入:防转进主树 .venv 把别人整棵树数一遍
|
||
hits += [os.path.join(root, f) for f in fs if is_reparse(os.path.join(root, f))]
|
||
entries += len(ds) + len(fs)
|
||
print(f"== {name} scanned entries={entries}")
|
||
print(" REPARSE: none → 可直接 git worktree remove --force"
|
||
if not hits else " REPARSE:\n " + "
|
||
".join(hits))
|
||
@T3@
|
||
|
||
实测输出 `== verify-task16 ... REPARSE: none`(每棵的体积 / 条目数 + 零联接就是证据),而 `bench-*` 两棵各挂一个 `.venv`
|
||
联接 → 判 **KEEP**(本节不拆它们;将来真要拆,先 `rmdir` 摘掉联接再 remove)。
|
||
**判据是「扫出来一个都没有」,不是「我记得没建过联接」**:`new --no-bootstrap` 的树、别的手工 `mklink /J` 的树都可能带着指向主树的联接。
|
||
摘联接用 `rmdir`(只删联接本身;**不是** `rd /s`,更不是 `rm -rf`——那会顺着联接删目标)。
|
||
非 Windows 环境改用 `os.path.islink` / `find <tree> -type l`(本项目是 Windows + Keil 工具链,按上面这条走)。
|
||
**到这里才算「拆前唯一性自检 + 物理安全预扫」齐**:`--force` 的安全前提 = 第 2 步 `UNIQUE-UNMERGED` 为 0
|
||
(或剩下的已全部进第 4 步备份)**且**这里 `REPARSE: none`。
|
||
4. **唯一内容先备份,再 `--force` 拆**。`UNIQUE-UNMERGED` 与需要留档的 `OLDER-DRAFT` 一律先备份进主树
|
||
`ci-artifacts/wt-leftover-backup/`(`ci-artifacts/` 已被 `.gitignore` 忽略 → **备份不会改变主树脏行数**;仓库根不放一次性产物),
|
||
**文件名 + 字节数必须打进输出**当证据:
|
||
|
||
@T3@bash
|
||
BK="$MAIN/ci-artifacts/wt-leftover-backup"; mkdir -p "$BK"
|
||
# 命名约定:<树名>__<原路径里的 / 换成 _>,反向可定位;台账备份统一后缀 .state-bak
|
||
cp "$WTROOT/verify-batch/simulator/tests/README.md" "$BK/verify-batch__simulator_tests_README.md"
|
||
cp "$WTROOT/und-attr/scripts/check_nested_comment.py" "$BK/und-attr__scripts_check_nested_comment.py"
|
||
ls -l "$BK" # 实测:verify-batch__simulator_tests_README.md 1769 bytes / und-attr__scripts_check_nested_comment.py 14827 bytes
|
||
for t in verify-task16 verify-batch und-attr; do
|
||
git -C "$MAIN" worktree remove --force "$WTROOT/$t"; echo "$t remove_rc=$?" # 不接管道才读得到码(见「读输出」)
|
||
done
|
||
git -C "$MAIN" worktree prune # 顺带清 .git/worktrees 里的残籍
|
||
@T3@
|
||
|
||
自检:**备份条目数 = 第 2 步里判「要留档」的条目数**,且每个都带字节数。这一步就是本节相对 D 第 4 步的增量——
|
||
D 的前提是「净室里只有自己的拷贝」,本节的树里混着别人在飞的东西,`--force` 是一次不可逆删除,**先备份才谈得上安全**。
|
||
5. **复验:主树一个字节没动 + 树确实没了**:
|
||
|
||
@T3@bash
|
||
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-after.txt
|
||
wc -l < ci-artifacts/wt-main-before.txt; wc -l < ci-artifacts/wt-main-after.txt # 两个数必须相同(实测 0 → 0)
|
||
diff ci-artifacts/wt-main-before.txt ci-artifacts/wt-main-after.txt # 空输出 = 逐行一致
|
||
git -c core.quotePath=false -C "$MAIN" worktree list # 已拆路径逐条消失;主树与判「保留」的树原样在列
|
||
@T3@
|
||
|
||
行数不同 = 备份或摘除碰到了主线(多半是备份写进了未被忽略的目录,或漏摘联接)→ 立刻定位,别报「回收完成」。
|
||
第 2 步判「保留不动」的树还在 `worktree list` 里是**预期结果**,在报告里点名解释为什么留(附 `MERGED` 判据)。
|
||
6. **顺手清悬挂台账**(branch / path 都已不存在的 `_wt/.state/<ID>.json`;它会让 `$TW list` 打出一行怪条目、并让下次
|
||
`new` 同 ID 报「任务 <ID> 已存在」)。先给证据、后动手:
|
||
|
||
@T3@bash
|
||
for j in "$WTROOT"/.state/*.json; do # 台账位置随 DTU_WT_ROOT
|
||
id=$(basename "$j" .json)
|
||
br=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('branch',''))" "$j")
|
||
pp=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('path',''))" "$j")
|
||
git -C "$MAIN" rev-parse --verify "$br" >/dev/null 2>&1 && b=branch-OK || b=branch-MISSING
|
||
[ -d "$pp" ] && p=path-OK || p=path-MISSING
|
||
echo "$id $b $p <- $j"
|
||
done
|
||
@T3@
|
||
|
||
实测这条判据打出的正是 `v5tool2 branch-MISSING path-MISSING`:`git rev-parse task/v5tool2-v11` 报
|
||
`fatal: ambiguous argument 'task/v5tool2-v11': unknown revision ...`,台账 `path` 指的目录已无,`git worktree list` 里也没有这棵树。
|
||
**三条齐(rev-parse fatal + path 目录不存在 + worktree list 无此树)才叫悬挂**;缺任何一条都回到第 1~2 步查是不是真有东西要保。
|
||
然后**备份 → 删 → 复跑两条 list 复验**:
|
||
|
||
@T3@bash
|
||
cp "$WTROOT/.state/v5tool2.json" "$BK/v5tool2.json.state-bak" && rm "$WTROOT/.state/v5tool2.json"
|
||
"$PY" "$TW" list # 该条目消失(全空时打「(没有任务空间。用 new <ID> <slug> 建一个)」),echo RC=$? 应为 0
|
||
"$PY" "$TW" lock status # build 空闲(TTL 1.0h)/ bench 空闲(TTL 6.0h)—— 证明清台账没牵连两把锁
|
||
ls "$WTROOT"/.state/locks/ 2>/dev/null # 若该 ID 名下还有 .lock,先按 B 节释放,再删台账
|
||
@T3@
|
||
|
||
- **必须 `cp` 先备份再 `rm`**:台账里存着 base / junctions / needs_bench / bench_pass,是真出纠纷时唯一的现场记录。
|
||
- 悬挂条目按定义 `path` 已不存在 → 通常**不占** `MAX_CONCURRENT = 8` 名额(A 第 1 步的「活跃」判据要求 path 目录还在),
|
||
但它把 `$TW list` 搅成怪输出、并永久挡住同 ID 复用;**path 还在的占位/残留台账才占名额**(早期为绕 B 节那个坑手写的占位台账就是这种)。两种都要清。
|
||
- **仍不对这个 ID 跑 `done` / `abort`**:清场会拿台账里的死 `path` / 死 `branch` 去做 `git worktree remove` 和删分支
|
||
(这正是 A 第 5 步说的「手工删目录留下的尾巴」,正解就是本步)。历史遗留的占位台账同理——活着时也别
|
||
`done` / `abort`,用完直接删文件(来历与现状见 B 末条)。
|
||
|
||
### F. 与 DSH worktree 插件(`/worktree`、模型工具 `worktree_create`)共存
|
||
|
||
本仓现在有**两套**隔离空间。别混用,也别互相回收:
|
||
|
||
| | `@task`(本 skill) | DSH worktree 插件(`dsh-task-worktree`) |
|
||
|---|---|---|
|
||
| 树在哪 | 仓库**外** `../_wt/<ID>-<slug>/` | 仓库**内** `.dsh-worktrees/worktree/<name>/` |
|
||
| 谁建 | `new <ID> <slug>`(人来敲命令) | 会话选「Worktree模式」/ 模型调 `worktree_create`(零命令) |
|
||
| 台账 | `_wt/.state/<ID>.json`(本 skill) | `.dsh-worktrees/manifest.json`(插件私有格式,别去改) |
|
||
| 分支名 | `task/<ID>-<slug>` | `worktree/<name>` |
|
||
| 共享联接 | `new` 自动建 | **建树时没有** → 要 `plugin adopt` 补 |
|
||
| 回收 | `done`(预演 / 合并 / 门禁 / 清场)或 `abort` | 用户敲 `/worktree finish\|bring-back\|remove\|prune`(模型碰不到收尾命令) |
|
||
|
||
**为什么允许它把树建在仓内**(铁律 7 的唯一例外):路径写死在插件里(`WORKTREE_NAMESPACE = worktree`
|
||
+ `<repo>/.dsh-worktrees/`),而暴露给模型的工具只有 `worktree_create` / `worktree_list` /
|
||
`worktree_status` 三个,配置面改不动。兜法三件(均已落地):
|
||
|
||
1. `.gitignore` **预置** `.dsh-worktrees/` —— 插件首次 `create` 会往 `.gitignore` 追加一模一样的字面量,
|
||
`ensureIgnored()` 见到同名规则即跳过;不预置就等于让插件在会话中途改这份受控文件(diff 里冒出
|
||
一行"不是我加的")。
|
||
2. 全库扫描的排除面登记了它:`scripts/check_doc_links.py` 的 `SKIP_PARTS`、
|
||
`tools/refgraph/gen_refgraph.py` 的 `EXCLUDE_DIRNAMES`、`scripts/check_layer_deps.py` 的 `os.walk` 兜底。
|
||
一份仓库副本被全库扫描吞进去 = 文档链接假红 + 配图生成物逐字节漂移砸在某位同事头上。
|
||
3. 联接与锁归属由本节命令接上(下面三步)。
|
||
|
||
**这棵树是不是插件的**:`git worktree list` 里路径含 `.dsh-worktrees/` 就是。`$TW list` 现在会在台账段
|
||
下面另起一段列出它们,联接列**点名缺哪一样**(`已联` / `缺 .venv` / `缺 SDL2` / `齐(实体SDL2)`)。**插件树不占
|
||
`MAX_CONCURRENT = 8` 名额**——回收不归本 skill,
|
||
硬计入只会让别人那棵关不掉的树把 `new` 挡死(`new` 会打印一行「另有 N 个插件工作树不计入上限」提醒并发代价)。
|
||
|
||
三步接上(`<name>` 可带 `worktree/` 前缀,也可多段如 `refactor/logging`):
|
||
|
||
@T3@bash
|
||
"$PY" "$TW" plugin list # 一览:分支 / 脏 / 缺哪个联接(缺项另起一行说后果)
|
||
"$PY" "$TW" plugin adopt N21-dhara-isfree # 只补缺失的 .venv 与 simulator/SDL2 联接
|
||
"$PY" "$TW" plugin unadopt N21-dhara-isfree # 只摘联接(os.rmdir),绝不动目标内容与树里文件
|
||
@T3@
|
||
|
||
- **两条联接坏了的样子完全不同**:缺 `.venv` → `ci_local.py` 的四级解释器回退**静默**落到机器级
|
||
python(无 fontTools = `generated-fresh` 假红的老根因,AGENTS.md 记着);缺 `simulator/SDL2` →
|
||
`build_sim.bat` 当场报错(`SDL2_DIR` 是裸 `set`,没有 env 逃生口)。所以列表只说「已联」不够——
|
||
它按缺的那一样分别给后果,`adopt` 没补齐也只报「还不能算就绪」,不许说「现在可以跑」。
|
||
- **adopt 只碰联接**:已联的跳过;`simulator/SDL2` 若已是实体目录只告警不动;`.dsh-worktrees/` 之外的路径
|
||
一律 exit 2(实测 `plugin adopt ".."` 被拒)——这道闸防的是把主树或 `_wt` 目录当插件树遮蔽掉真实内容。
|
||
- **锁归属现在认得出来**:`@build` 在插件树里自持自释时,持有者名从 cwd 反推成 `pw:<name>`(多段名压平成
|
||
`-`),与 `plugin adopt` 打印的那个名字逐字相同(`keil_build.py: plugin_tree_owner()` ↔
|
||
`task_worktree.py: plugin_lock_owner()`)→ `lock status` 不再一律落成 `build-cli`。
|
||
- 上台架仍须手工占锁、用同一个名:`lock acquire bench --task pw:<name> --note <说明>` →
|
||
`lock release bench --task pw:<name> --bench-pass`。插件树没有台账 → 按 B 节只 warn、锁照常放。
|
||
- **命名建议**:插件 `<name>` 沿用 `@task` 的 `<ID>-<slug>` 形态(如 `N21-dhara-isfree`),两套视图同名好对账;
|
||
但分支前缀必然不同(`worktree/` vs `task/`),别把两者说成同一个分支。
|
||
- **收尾不在这**:`done` / `abort` 拿插件树名下手只会 exit 2(这是保护,别绕过去手工 `git worktree remove`;
|
||
要拆先 `plugin unadopt` 摘联接,再由**用户** `/worktree remove`)。`/worktree bring-back` 要求主工作树干净,
|
||
本仓常有在飞改动 → 提交或 stash 由用户定。反过来插件也不会碰 `_wt/`(路径根不同,谁也删不掉谁)。
|
||
## 铁律
|
||
|
||
1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。
|
||
2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。
|
||
3. **`build` 与 `bench` 都必须串行**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、
|
||
设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。**
|
||
- `build` 锁由 **`@build` 自持自释**,调用方无需手工 acquire/release;
|
||
- `bench` 锁**必须手工**:`lock acquire bench --task <ID>` → 烧录/`@test`/探针 →
|
||
`lock release bench --task <ID> --bench-pass`。非登记持有者(lead 的验收窗、插件树 `pw:<name>`)占了锁
|
||
怎么放 → B 节(2026-10-29 起无台账也只 warn、照常释放);插件树整体口径 → F 节。
|
||
4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、
|
||
`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。
|
||
5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再
|
||
`git worktree remove`。**手工清场时千万别直接 `git worktree remove`**——git 可能递归进联接,
|
||
把主工作树的 `.venv` 一起删掉。手工拆任何一棵树之前先跑 **E 第 3 步**的预扫,拿到 `REPARSE: none` 才动手。
|
||
6. **活跃任务硬上限 8**(脚本常量 `MAX_CONCURRENT = 8`,每棵约 83 MB 检出——du 口径,内容字节和 84.0 MiB → `docs/环境与部署/并行任务工作树方案.md` §4)。满了 `new` 直接 exit 2,
|
||
腾位顺序与 `--force` 的代价 → A 节;悬挂台账的清理与复验 → **E 第 6 步**。
|
||
7. **`@task` 的工作树放仓库同级 `_wt/`,绝不在仓库内部**——否则污染 `git status`、`pre-commit --all-files`
|
||
与全库搜索。唯一例外 = DSH worktree 插件写死的仓内 `.dsh-worktrees/`(改不了;已用「预置 .gitignore +
|
||
三个全库扫描门禁排除」兜住,见 **F 节**)。自己新建的树一律走 `_wt/`。
|
||
8. **`done` 是合并点**:属主没提交完就别 `done`;合并提交一旦进了主干,只用「主工作树补提交」收尾,
|
||
**不用 `reset --hard` 反悔** → C 节。
|
||
9. **非登记 / detached 的树绝不跑 `done` / `abort`**:两者都以台账为前置,无台账直接 exit 2;带着残留台账时
|
||
`abort --force` 会绕过内容保全把别人的在飞改动连树一起删。回收走 **E 节**(全景 → 分桶 → 联接预扫 → 备份再拆 →
|
||
复验 + 清悬挂台账)。
|
||
|
||
## 陷阱(实测)
|
||
|
||
- **`--force` 不是万能钥匙**:它只覆盖四类检查——并发上限、持有者与 `--task` 不符、陈旧锁接管、
|
||
清场时强制移除工作树。**台账缺失这类前置/环境错误(exit 2)跟它无关**(实测 `lock release
|
||
--force --bench-pass` 报的是同一句话,锁照旧没放)。同理 `abort --force` 也只覆盖「树里有东西」这一条拒绝,
|
||
**不会**替你做 E 节的内容保全——它只会删得更快。
|
||
- **未跟踪文件在 `git log`、提交门禁与 `done` 的合并里都不留痕**(2026-10-29 回收现场):钩子按 HEAD tree 的
|
||
diff-index 取文件,未跟踪文件从没进过任何钩子的视野;`git log` 也照不出「唯一未合并」——它压根没被提交过。
|
||
所以「这棵树没有遗留改动」这种结论**只能**由 E 第 2 步的逐文件内容比对给出,`log --oneline -1` + 脏计数都不够。
|
||
- **`git status --porcelain` 不带 `-uall` 会折叠未跟踪目录**(E 第 1 步):「全景」少一项、分桶少一个文件,
|
||
最后拆掉的是别人唯一的草稿。回收路径上**一律 `-uall`**。
|
||
- **`$TW list` 空 ≠ 没有工作树**:`list` 只遍历台账(A 第 1 步),用户说的「那 3 棵 done 掉」很可能在 `$TW list` 里
|
||
一个都不存在(实测就是)→ 别照 `$TW list` 的空结果回「没有任务」,先 `git worktree list` 拿全集再逐棵定性(E 节)。
|
||
2026-10-29 起 `list` 还会另起一段列出插件树(`.dsh-worktrees/`,F 节)——**"看见"不等于"归 `done` 管"**。
|
||
- **「这棵树很久没动」不是回收判据**:实测 `bench-*` 两棵既没台账也没脏文件、mtime 很旧,但真正决定去留的是
|
||
`git merge-base --is-ancestor <c> HEAD` = MERGED(提交已进主线)+ 未跟踪层为空;而另一棵 mtime 更新的树反而
|
||
全是已在主线的副本。**判据是内容与祖先关系,不是时间戳。**
|
||
- **「我记得没建过联接」不是安全判据**:判据只有扫出来的 `REPARSE: none`(E 第 3 步实测 `bench-*` 各挂一个 `.venv`
|
||
联接,正是「不是自己建的」那类)。
|
||
- **联接不是符号链接**:本仓库 `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 自动加引号。
|
||
但**读属性**用 ctypes 没问题:`GetFileAttributesW` + `FILE_ATTRIBUTE_REPARSE_POINT` 就是 E 第 3 步的预扫手段。
|
||
- **联接建失败不中止 `new`**:只告警并打印手工补建命令,避免留下半成品任务空间;
|
||
但该任务里跑不了 `.venv\Scripts\python.exe`,`@build` / `ci_local.py` 会找不到解释器。
|
||
- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并(补完基线再重跑 `done` 时最容易忘,见 C 第 6 步)。
|
||
- **⚠ `done` 的门禁跑在 `@build` 之前 → 依赖 `.map` 的钩子会误报**(2026-10-12 实测):
|
||
`done` 的顺序是「合并 → 重生成 → **门禁(ci_local)** → `@build`」,而
|
||
`mem-budget`(登记簿条目「缩堆迁 CCM 后半(暂缓)」)/ `mem-numbers` 是拿**文档快照 vs `.map`** 比对 —— 门禁跑时
|
||
`.map` 还是**主工作树合并前**的旧产物,与刚合并进来的新台账**必然不一致** →
|
||
误报 `[FAIL] 台账受管区与 .map 不一致`。`@build` 跑完后 `.map` 才是新的。
|
||
**处置(2026-10-25 补全)**:按 C 节第 5 步的完整链补——`check_mem_numbers.py --write` →
|
||
`ram_baseline.py --write` → `ram_baseline.py --update` → `check_test_coverage.py --update-baseline` →
|
||
`check_doc_links.py --all` → 提交 → 重跑 `done`(它会检测到「已合入」直接清场)。
|
||
此时主干已含合并提交,**绝不能用提示里的 `reset --hard` 回退**。
|
||
- **`done` 的门禁 = `ci_local.py` = `pre-commit run --all-files`(50 条钩子全跑)**(2026-10-26):红了先读
|
||
`[SUMMARY] static: pre-commit hooks -- passed=N failed=M skipped=K`,**按钩子 id 定根因**,别一律当「`.map` 滞后」
|
||
去跑补基线链。典型例外:`generated-fresh`(生成器幂等性,触发面 `tools/**`、`docs/images/*.svg`、UI 生成文件)——
|
||
合进来的分支改了生成器却没重跑产物时,补基线链救不了,处置是在**主工作树重跑那个生成器**、脚本与产物同一笔提交,
|
||
再重跑 `done`。另注意 `pre-commit run --all-files` 只跑 pre-commit stage,个别只在 commit-msg / 手工阶段跑的守卫不在此列
|
||
(口径见 `scripts/ci_local.py` 头部说明)。
|
||
- **⚠ 新建工作树从未构建过 → 体积类门禁必然红**(2026-10-25 实测):`.map` 是每棵树各自的
|
||
构建产物、不入版本库,所以 `new` 出来的空间里 `mem-numbers` / `mem-budget` / `ci_local.py`
|
||
的体积门禁步都拿不到事实源,报 `[FAIL] 找不到 .map 事实源`(脚本提示已写明原因,不是代码缺陷)。
|
||
同理 `done` 的重生成步骤会 `[skip] 未找到 .map,跳过内存快照同步(需先 @build)`。
|
||
**处置**:新空间第一次提交前先 `@build` 一次(`build` 锁自动排队,与别的树串行)。与上一条
|
||
的区别要分清:上一条是「有 `.map` 但是旧的」,本条是「根本没有 `.map`」,处置动作不同。
|
||
- **⚠ 增量构建可能不重链 → `.map` 数字瞬态不一致**(2026-10-12 实测):
|
||
`@build` 若判定无需重链,`.map` 保持旧值,此时 `--write` 生成的快照是**瞬态值**
|
||
(实测同一份源码两次读到 223,560 与 223,724,差 164 B)。**以主工作树(集成点)
|
||
的 `.map` 为准**;改完 RAM 相关代码后确认重链再刷新快照。
|
||
- **`ram_baseline.py` 的 help 串里有未转义的 `%`**(2026-10-25 实测:某些直调姿势会渲染 argparse help 并崩)——
|
||
查用法读脚本头部 docstring,别靠 `--help`;修脚本本身属 `mem-budget` skill 的范围。
|
||
- **合并冲突常落在「生成物」上**:`docs/内存管理/RAM使用分析与优化方案.md`(mem-numbers
|
||
快照)在双方都改过时必冲突,且**两边都不能直接选**(各自基于不同基线)——
|
||
正确做法是解冲突后用**合并后的实际构建**重新生成,不要挑一侧。
|
||
- **并集合并残留**:双方都改过的文件(尤其 `.gitignore`)会被 git 自动并成**并集**,
|
||
可能留下指向已删目录的死规则或重复条目。脚本会自动报「双方都改过」的文件清单 +
|
||
连续重复行;**权威判据是 base / 两个 tip 三方对比**(`git show <ref>:<file>` 逐项 `grep -F`),
|
||
别用 `tail -N` 目测。
|
||
- **⚠ 别用 heredoc 打补丁改门禁脚本造探针**(2026-10-27 实测三连败,白烧一整轮):heredoc 里的 python
|
||
把门禁脚本改坏——`patched 3` 之后 `PROBE_RC=1`、`SyntaxError: unterminated string literal (detected at
|
||
line 882)`,而它**正是被测对象**,改完的数不可信;另一处 `replace('\', '/')`(想把 Windows 反斜杠换成正斜杠)里的反斜杠被多层引号吞掉
|
||
→ `SyntaxError (detected at line 16)`。**正解**:探针落成**独立 `.py` 文件**(写文件的工具,UTF-8 无 BOM)
|
||
再 `"$PY" probe.py`;非用 heredoc 不可就用单引号定界 `<<'PY'`(防变量展开),且**路径一律 `as_posix()`、
|
||
一个反斜杠都不写**——反斜杠字面量要穿「JS 模板串 → bash → shell 引号 → python」四层,必被吞。
|
||
E 节那两段扫描脚本同理:**落成 `ci-artifacts/*.py` 再跑**(该目录已被忽略,不会污染第 5 步复验用的脏行数)。
|
||
- **取门禁数字:import 门禁模块,别猜输出、别打补丁**(2026-10-27;grep 源码 / 手抄正则 / 解析 stdout 三种姿势
|
||
都拿不到可复核的数,换成这句立刻拿到):先读门禁脚本的 `main()`(直读末尾那几十行)看它怎么装配检查器,
|
||
把**那几行照抄**进探针——`sys.path.insert(0, 'scripts')` → `import check_ids as ci` →
|
||
`ci.load_json(ci.REGISTRY_FILE, 'reg')` + `ci.load_json(ci.ALIASES_FILE, 'alias')` →
|
||
`ck = ci.Checker(reg, al, verbose=False)` → `ck.run()` → `print(ck.stats)`。别猜键名:先
|
||
`print(sorted(ck.stats))` 列出全量计数项,再按精确键取数(顺带 `git diff` 确认门禁脚本自己**没被改过**)。
|
||
- **别把「不可判定」当成一个数**(2026-10-27):`id-check` 有两种口径——检查器自己算的**硬不可判定**实测只有
|
||
**2** 处,而门禁红线(`scripts/archive/id_gate/id_registry.json` 的 `undecidable_total_ceiling`)量的是**派生值**
|
||
`refs_total - decidable`(实测 8,744 - 7,582 = **1,162**,拆开 = `skip_bare_unmanaged` 834 +
|
||
`noise_allowed` 326 + 硬不可判定 2,**三数精确闭合**)。所以**带属主登记处的族(TC-XXX 这类)加多少引用都算
|
||
「可判定」、一点不吃这条余量**,真正吃余量的是**裸形字母+数字**字面量;反过来「把可判定引用改写成散文」才是
|
||
**稀释比值**的动作,那才是绝对数红线要防的。口径混用会当场给整条道下错指令(本次就下错过、量完立刻撤回)。
|
||
- **队友的自查结论要复核,小心它测的是错变量**(2026-10-27):log 道做过受控实验、结论是「我们没推高这个数」——
|
||
他们改的是 TC 号、盯的是 `skip_bare_unmanaged`,那个组合当然不动;而 +16 走的是另一条路(新增裸形 「字母 T 累进数字 4」)。
|
||
定责用 D 节:逐文件注入 + HEAD/在飞两版同一计数器相减 + 对新增行 grep;闭合后 +16 = 16 处裸形 「字母 T 累进数字 4」,
|
||
**推翻**了自查结论。口头自查与 `--note` 都不是证据,**可复核的差分数字才是**。
|
||
- **`.workbuddy-ai/` 刻意不共享**:记忆不随任务分叉,也不给联接递归删除的机会。
|
||
任务工作树自己的记忆由 `done` / `abort` 在清场前归并回主工作树。⚠ **手工回收(E 节)没有这一步归并**:
|
||
拆之前顺手 `ls <tree>/.workbuddy-ai/` 看一眼,有内容就一并备份进 `ci-artifacts/wt-leftover-backup/`。
|
||
- **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB,
|
||
两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean`。
|
||
- **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。
|
||
所以判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。
|
||
同任务重复 acquire 是幂等的(不会把自己锁死)。
|
||
- **TTL 按锁分设**(2026-10-12):`build` = **1h**、`bench` = **6h**(`LOCK_TTL_HOURS`)。
|
||
口径是「兜底窗口应与操作真实时长同量级」——编译是秒~分钟级,6h 的兜底太钝;
|
||
bench 是会话级(flash→`@test`→探针),才配得上小时级。持有者还可用
|
||
`lock acquire ... --ttl <小时>` **自报**更精确的窗口(`keil_build.py` 即按其 `--timeout` 自报)。
|
||
- **陈旧锁自动接管**:超过该锁 TTL 未释放即视为陈旧,下次 `acquire` 会接管并提示;
|
||
未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败),但
|
||
**未到 TTL 的活锁会把 `done` 挡在前置检查**(B 节第 8 步)。
|
||
- **`build` 锁不必手工持**(2026-10-12 起):`@build`(`keil_build.py`)**自持自释**——
|
||
进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release、锁一直挂到 TTL"这条在构建路径上已消除。
|
||
`done` 的门禁自己持锁并给子进程设 `DTU_LOCK_HELD=build` 告知跳过(新旧脚本都兼容)。
|
||
**`bench` 锁仍须 Agent 手工持**(会话作用域:跨烧录→`@test`→探针,见 `keil-build` SKILL.md)。
|
||
|
||
## 验证
|
||
|
||
- `$TW list`:台账段条目数 = `ls _wt` 的目录数且 ≤ 3;刚回收的任务与历史占位条目都不再出现
|
||
(插件树另起一段,不参与这条计数)。
|
||
- `$TW lock status`:`build 空闲(TTL 1.0h)` + `bench 空闲(TTL 6.0h)`——没有本会话留下的持有者。
|
||
- `git log --oneline -3`:能看到 `merge: task/<ID>-<slug> 合入 <base>` 与其后的补基线提交;
|
||
`git status --porcelain` 为空(补链改动已提交,才谈得上重跑 `done`)。
|
||
- `ls _wt/.state/`:该 ID 的台账已无(清场最后一步 unlink);重复 `done` 报「任务不存在」不必再追。
|
||
- **E 节回收**:`git worktree list` 只剩主树 + 判「保留不动」的树(已拆路径逐条消失);**拆前/拆后主树
|
||
`git status --porcelain -uall` 行数相同且 `diff` 为空**(实测 0 → 0);`ci-artifacts/wt-leftover-backup/` 里每个留档件都打印过
|
||
文件名与字节数、条目数 = 分桶判「要留档」的条目数;每棵被拆的树都留有 `REPARSE: none` 预扫记录;全景输出里
|
||
`UNIQUE-UNMERGED` 归零(或全部已备份);保留树在报告里点名给出判据(`merge-base --is-ancestor` = MERGED)。
|
||
- **E 第 6 步清悬挂台账**:该 json 已从 `_wt/.state/` 消失(`.state-bak` 在备份目录里)、`$TW list` 不再打出它、
|
||
`$TW lock status` 两把锁仍空闲;全程未对该 ID 跑 `done` / `abort`。
|
||
- **F 节桥接**:`plugin list` 的「联接」列按实际三态点名(`已联` / `缺 .venv` / `缺 SDL2` /
|
||
`齐(实体SDL2)`——旧实现只取样 `.venv`,半联树会说谎);**adopt/unadopt 前后主树 `.venv` 与树内文件都
|
||
还在**;主线缺 SDL2 时 `adopt` 打 `[skip]` + 「还不能算就绪」而不打「现在可以跑」;`plugin adopt ".."`
|
||
必须 exit 2;打印的 `pw:<name>` 与 `keil_build.py` 从 cwd 反推的名字逐字相同;`done <插件树名>` 只 exit 2。
|
||
- D 节归因:测量前后 `git -C "$MAIN" status --porcelain` **逐行一致**(主树全程只读);
|
||
`git worktree list` 里净室路径已消失;逐文件增量之和 **等于**观察到的涨幅(实测 +6+8+2 = +16),且每处增量
|
||
都指到具体字面量类别(裸形 「字母 T 累进数字 4」)与属主;取数走 `import check_ids` 读 `ck.stats`,且门禁脚本自身 `git diff` 为空。
|
||
- 补绿链的每一条都自己 `echo RC=$?` 看到 0,最后以重跑 `done` 打出的**清场日志**(`已摘除联接` /
|
||
`已删除分支` / `完成(分支此前已合入)`)为成功判据,而不是 `tail` 出来的最后一行。
|
||
|
||
## 与其它 skill 的衔接
|
||
|
||
- 构建 = `keil-build` skill(`@build`);烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
|
||
- 烧录产物的真实 hex 路径以 `.serialtest/config.json` 的 hex 键为准(实测
|
||
`MDK-ARM/Objects/STM32F407-Demo.hex` 这个路径并不存在),拷错等于烧了旧镜像 → `serial-boot-test` skill
|
||
- 新 `.c` 文件 = `new-source-file` skill(`uvprojx` 与 `CMakeLists.txt` 双端注册)
|
||
- **DSH worktree 插件**(人类命令 `/worktree`、模型工具 `worktree_create`,树在仓内 `.dsh-worktrees/`)
|
||
与本 skill 的分工、桥接命令与锁归属 → F 节(本 skill 不删插件树,插件也不删 `_wt/`)
|
||
- `.map` 口径 / RAM 台账与趋势 → `mem-budget` skill;UI 回归 → `ui-verify` skill
|
||
- 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md`
|