WorkBuddy 任务跨工作区迁移
任务列表里的一个任务,用户要求把它归到指定工作区 —— 本技能负责这件事,连产物一起。
何时用
- 「把『XX 分析』这个任务移到『YY』工作区」
- 「这几个任务重新归类一下」「整理一下工作区」
- 「产物也要一起搬过去」
- 「我在 YY 工作区没看到刚移过去的任务」「某个任务在列表里找不到了」
- 「怎么有两个 XX,能合并成一个吗」—— 先别急着认定是重复的工作区,见下方「同名 ≠ 重复」
同名 ≠ 重复:先查「工作区显示名登记表」
侧栏里显示的工作区名字不一定等于目录名 —— 用户可以在 UI 里给工作区改名,改名落在
~/.workbuddy/workspace-display-names.json({id, path, displayName, updatedAt},id 是小写正斜杠路径)。
「侧栏有两个 XX」的常见成因因此有两种,且可能同时发生:
- 一个目录名就叫「XX」的工作区,与一个被改名成「XX」的时间戳工作区(如
2026-03-05-10-21-33); - 上述任一个工作区里,还有个任务标题也叫「XX」。
排查顺序(别急着找同名目录去删):
python wb_move.py list # 现在会打印显示名与「孤儿显示名」段
python wb_move.py find "示例项目A" # 是否只是"任务"标题重名
用户口中的「XX 空间」(如 示例项目A)如果在 list 的路径列里搜不到,先用显示名去
workspace-display-names.json 定位真实路径,再对这个路径做迁移 —— 否则会误以为
"找不到这个工作区"。
方向别搞反:不是去找两个同名目录删一个,而是把散落的同类任务并进一个工作区
(迁完那个时间戳/临时工作区里的任务清空后,它会自动从侧栏消失)。并进去之后工作区与任务会同名,
建议顺手 rename 掉任务名,侧栏才不会继续出现两个一样的字样。
孤儿显示名(可清):某个时间戳工作区的任务被全部迁走后,它会从侧栏消失,但
workspace-display-names.json 里的改名登记不会自动删除 —— 成了无主条目(wb_move.py list
会单列「孤儿显示名」一段)。它不影响任何功能,纯属脏数据。清法:编辑该 JSON、删掉对应的
workspaces 键(先备份),判据是"该 path 下已无任何未删除会话"。
> rename 必须同时写 title 和 custom_title。用户当年手动重命名过的任务,custom_title
> 一定有值,而侧栏优先显示 custom_title —— 只改 title 的典型症状是"改完没生效",
> 且反复查 title 都是对的,极易误判成"应用缓存没刷新"。
30 秒上手
命令都在本技能的 scripts/ 下,用 Python 直接跑(无第三方依赖)。
cd <本技能目录>/scripts
python wb_move.py list # 看有哪些工作区、任务怎么分布
python wb_move.py find "关键词" # 按标题找到任务的 sessionId
python wb_move.py move --to "D:\WorkBuddy\我的工作区" --id <sessionId>
python wb_move.py check --to "D:\WorkBuddy\我的工作区" # 复核会出现在侧栏的成员
迁移后不需要让用户重启 WorkBuddy —— 侧栏由轮询器按 30 秒周期做 diff 自动收敛,
改库这种"绕过应用写路径"的修改同样能被兜住(见「核心机制 4」)。先让用户看一眼,久等不对再排查数据。
命令一览
| 命令 | 用途 |
|---|---|
list |
列出工作区及各自任务数(含显示名与孤儿显示名);未归入工作区的任务按原因分类计数 |
find <关键词> |
标题/自定义标题模糊匹配,拿到 sessionId |
move --to <路径> (--id <sid> \| --task <关键词>) |
核心:迁移任务到目标工作区 |
check (--id <sid> \| --to <路径>) [--fix] |
复核归属;--fix 把留在「独立任务」里的会话转正(仅 --id 分支有效,见下方陷阱) |
troubleshoot (--id <sid> \| --task <关键词>) |
任务看不见时逐项排查,每项给 PASS/WARN + 原因 |
restore --id <sid> [--to <路径>] |
捞回被软删除的任务(客户端无回收站) |
rename --id <sid> --title <新标题> |
改任务标题;title 与 custom_title 一起写(见下) |
move_artifacts.py --old-cwd <旧> --to <新> |
单独搬产物(move --with-artifacts 内部也调它) |
repair_index_pointers.py --old-root <旧> --new-base <新> |
修复索引里指向旧目录的绝对路径指针 |
recycle_remove.py (--scan <根> \| --plan <json>) [--apply] |
收尾清残留目录;默认只预演,--apply 才真删,且只进回收站(见文末「删除残留目录前的检查单」) |
两个会误导你的行为(实测确认,2026-09-22)
1. check --to <路径> --fix 是静默空操作。
cmd_check 里 --fix 的 UPDATE 挂在 if rows: 下,而 --id 才填充 rows;
走 --to 时 rows = [],于是一行都不改、也不报错,只打印一次只读干跑
(=== 干跑:<路径> 工作区块内共 N 个任务 ===)。看起来"跑过了、没报错",
实际什么都没发生 —— 最坏的情况是任务仍留在「独立任务」里,而你以为是别的问题。
> 把一个任务的 cwd 已经在目标工作区目录、只是 is_playground=1 的情况转正(典型场景:
> 该目录本身已是别人的工作区),正确做法是 move --to <同一目录> --id <sid>,不要加 --with-artifacts:
> cmd_move 中 same_cwd=True → need_files=False(跳过文件搬迁,会话记录本来就在对的地方);
> is_playground=1 → need_db=True → 把 cwd 与 is_playground=0 一起写。
> 实测对比:check --to X --fix 只输出"工作区块内共 2 个任务";move --to X --id S 输出"将迁移"。
>
> (move_artifacts.run 里那句"旧目录与目标工作区相同,无需迁移"只会在带产物时触发,
> 不带 --with-artifacts 走不到那里,所以不会被拒。)
2. move --dry-run 不覆盖产物预检。
cmd_move 的 dry-run 分支打印完 [dry-run] 未做任何改动 就 return,
早于产物预检(if args.with_artifacts: 那段 peers_of 检查)与 move_artifacts.run 的调用。
于是 dry-run 打印的"将迁移"只证明会话侧三项校验通过(deleted_at / project_id / same_cwd),
不证明产物能搬,也不告诉你到底会搬走什么(print_report 里那行 [dry-run] 将搬迁:
属 move_artifacts.run 的输出,而这条路径根本没被调用)。
> 要真正预演产物搬迁,自己独立复算两件事:move_artifacts.peers_of(old_cwd, sid)
> (共用者数量,>0 且无 --force-artifacts 就会被拦)+ os.listdir(old_cwd) 顶层清单与体积。
> 别把 dry-run 的"OK"当成"可以放心执行"。
move 的常用附加参数:
--dry-run 只演练,不落盘不改库(动手前建议先跑一次)
--with-artifacts 连同旧目录的产物一并迁移
--layout per-task 每个任务一个子目录(默认) / flat 平铺进目标根目录
--subdir <名> per-task 布局下的子目录名,默认取任务标题
--clear-project 把 project_id 置空,使其归入工作区而非「项目」分组
--allow-deleted 允许迁移已删除任务,并顺带清除删除标记
--touch 同时更新 updated_at(默认不动,保持最小改动)
核心机制(这四条决定了「为什么容易看起来没生效」)
1. 归属由两个字段共同决定,缺一不可
| 字段 | 作用 |
|---|---|
sessions.cwd |
归到哪个工作区 |
sessions.is_playground |
是否参与工作区分组;必须为 0 |
只写 cwd 是最经典的坑:库看起来对了、文件也搬了,但侧栏里任务仍在顶层的「独立任务」下面。历史遗留任务(自动创建的 WorkBuddy\<时间戳>\ 目录下的会话)绝大多数 is_playground = 1,这正是"移过去了但看不到"的头号原因。
反过来说,工作区列表是"从会话反推"的,不是一张独立清单(主进程
listSavedWorkspacePaths 拼 workspaces 表):
// 每个会话算出的 space.autoGenerated,决定它的 cwd 是否算一个"工作区"
const autoGenerated = summary.isPlayground ?? isAutoGeneratedWorkspace(cwd); // cwd 末段匹配 YYYY-MM-DD-HH-mm-ss
// 只收 type==="local" 且 autoGenerated !== true 的 cwd
paths.push(...items.filter(i => i.space.type === "local" && !!i.space.cwd && i.space.autoGenerated !== true).map(i => i.space.cwd));
两个推论,都是实操里会用到的:
- 把一个工作区的任务全部移走,它就从侧栏消失了(该 cwd 不再被任何会话引用),无需清理目录;
但workspace-display-names.json里的改名登记不会跟着删,成为无害孤儿(list会单独列出)。 is_playground=0的时间戳目录会显示成工作区(这就是本技能用--with-artifacts搬空后
用户仍能在侧栏看到「XX」的原因);只有is_playground=1的时间戳目录才落入「独立任务」。
2. 会话记录放在按 cwd 派生的目录里,转义规则必须一次到位
C:\Users\demo\WorkBuddy\某工作区 -> c-Users-demo-WorkBuddy-某工作区
必须一次性替换 : \ / 三者。分步 .replace() 会得到 c--Users-...(双短横线),目录定位落空 —— 表现为"库改了、文件没搬",任务点开是空的。
> 该规则只在 Windows 上验证过。为避免其他平台的转义差异,脚本按规则推导后还会按内容兜底反查(用 projects/*/<sid>.* 找真实目录),不把规则当唯一真理。
3. 产物文件不会跟着任务走,索引里存的是绝对路径
工作区就是文件夹。任务的历史输出(脚本、outputs、报告)留在旧工作区目录里。只改 cwd 是"半程迁移"。
搬完产物后,~/.workbuddy/ 下三套按 sessionId 落盘的索引仍存着旧绝对路径,不改就是死链(产物卡片点不开):
| 文件 | 字段 |
|---|---|
artifact-index/{sid}.json |
artifacts[].uri、artifacts[].fileChangeInfo.uri |
file-tree-manifests/{sid}.json |
requests.<reqId>.files[].path(含 children[].path) |
changes-index/{sid}.json |
changes[].files[].filePath |
其中 artifact-index 是产物面板的 canonical 数据源,三套都要管。
4. 侧栏靠 30 秒轮询 diff 自动收敛,改库不需要重启
daemon 侧单例 ConversationListPoller(app.asar 内的 packages/workbuddy-core)负责把列表变化推给渲染层:
intervalMs默认 30000;连续失败按intervalMs * 2^n退避,上限300000。- 每轮拉一次整页列表(
CONVERSATION_LIST_POLL_PAGE_SIZE = 100),与上轮快照按指纹做 diff,
只在有added/updated/deleted时才广播wb:conversations:listChanged。 - 生命周期由订阅者引用计数驱动:第一个订阅者到来时起跑(
schedule(0),即立刻跑一轮),
最后一个退订时停表。渲染层通过{event:"listChanged", active}控制订阅 —— 列表可见时才订阅。 - 源码注释里写得很直白:这类事件「覆盖跨进程/跨端的增删改 —— 这些变化不经过本地 SDK 的写操作」,
而「跨进程/跨账号变化(比如小程序端归档)仍靠 poller 兜底」。
我们的做法(直连 sqlite 改 cwd / is_playground)正属于"不经过应用写路径",所以:
无需重启,最迟 30 秒后侧栏自动正确;若窗口失焦期间轮到停表,回到窗口时重新订阅会立刻补跑一轮。
> 边界(据源码,非实测):轮询每轮只拉首页 100 条(CONVERSATION_LIST_POLL_PAGE_SIZE = 100),
> 排序 COALESCE(last_activity_at, updated_at) DESC,所以排在该窗口之外的会话不在 diff 范围内,
> 改完可能等不到 30 秒收敛。实践上通常碰不到 —— 迁移不改排序位次,"改前在侧栏看得见"基本等价于"在窗口内"。
> 判据:若迟迟不变,先用 check --to 确认数据是否正确;数据已对而界面没动,就是视图窗口的问题,
> 让用户切走再切回该工作区(触发视图自己的重载),不是迁移失败。
> 曾经的经验教训:早先误以为"必须重启",是因为当时没修复 is_playground。
> 重启给了"修复生效"的错觉,真正的修复其实是改库那一刀。别把因果关系记反。
> 用户可以主动拒绝重启 —— 只要他已经在侧栏看到任务,就说明 poller 已经收敛,不必再折腾。
标准流程
第 1 步 · 定位任务
python wb_move.py find "用户说的标题关键词"
命中多个时,把候选列给用户让他指定(move 在歧义时会自己拒绝并要求明确,不会猜)。
第 2 步 · 确认目标工作区
python wb_move.py list
先看是否已有同名/近名的工作区。目标路径不必预先存在 —— move --to 会自动创建目录;一旦有任务归入,该目录就会作为工作区出现在侧栏。
用户给的是"空间名"时,先把它翻译成路径:侧栏名多数等于目录名,但被改过名的工作区另有
displayName(见「同名 ≠ 重复」)。list 现在会同时打印显示名;若显示名与路径对不上,
再读 ~/.workbuddy/workspace-display-names.json 反查。
这一步值得跟用户确认一次,因为新建工作区时目录名即侧栏名。若用户已明确给了路径,直接用。
第 3 步 · 迁移
python wb_move.py move --to "<目标路径>" --id <sid> --dry-run # 先看计划
python wb_move.py move --to "<目标路径>" --id <sid> # 再执行
> ⚠️ dry-run 只覆盖会话侧,不覆盖产物侧(见「两个会误导你的行为」第 2 条)。
> 要带产物迁移,dry-run 全绿不等于执行不会翻车 —— 执行前请自行复算
> move_artifacts.peers_of(old_cwd, sid) 确认共用者数量,并看一眼旧目录顶层清单与体积。
> 批量迁移时建议写一个执行器,把这些预检和逐任务日志一起管起来。
脚本自己会:备份数据库(WAL 下用 backup API 取一致性快照,不直接 copy 文件)→ 搬会话记录 → 改库(cwd + is_playground=0 一起写)→ 重新读库复核 → 做一次分组干跑。备份目录与 move-manifest.json 会打印出来,回滚有据。
第 4 步 · 复核
python wb_move.py check --to "<目标路径>"
列出会出现在该工作区块下的任务。空列表不是"应该没问题",是必须查 —— 用 troubleshoot 看是哪一项没过。
> 复核用 check --to,不要自己拼 SQL。Windows 路径里全是反斜杠,把它塞进 python -c "..."
> 的双引号串时,shell 会把 \\ 塌成 / 之类,得到 30 字符 vs 42 字符的字面量、判等恒为假 ——
> 于是"查不到会话"的假否定,让人误以为迁移失败。要么用本命令,要么把路径从 sys.argv 传进脚本。
第 5 步 ·(可选)产物一并迁移
python wb_move.py move --to "<目标路径>" --id <sid> --with-artifacts --layout per-task
先问用户摆放方式(per-task 分子目录 / flat 平铺),两者结构性差别大、不好事后改。
⚠️ 搬产物前必须确认旧目录是否被多个任务共用。 本技能最早的使用场景里每个任务独占一个自动创建的时间戳目录,搬空它没有副作用;但用户自建的工作区(WorkBuddy\我的项目\)下往往有几十个任务,目录里的产物属于所有这些任务,整体搬走会让留在原地的任务的产物卡片全部变成死链。
脚本会查库统计旧 cwd 下的其他未删除任务,数量 > 0 直接拒绝,必须在知情后加 --force-artifacts。这个预检在所有改动之前完成,不会留下半完成状态。
> 两个任务同处一个目录、又都要迁走时的实操顺序(否则会白挨一次拒绝):
> 预检是按单个 sid 排除自身的,所以 A、B 两个待迁任务会互相把对方当作"共用者"而双双被拒。
> 此时不必急着 --force-artifacts —— 强搬会让产物落到"被处理的第一个任务"的子目录里,
> 若它并非产物的真正主人,就成了张冠李戴。更稳的顺序是:
> 先迁走不带产物的那个任务,再迁带产物的那个。第二次迁移时对端已离开该 cwd,预检自然放行,
> 产物也就干净地落进它真正所属任务的子目录。
> 判断"谁带产物":看 ~/.workbuddy/artifact-index/{sid}.json 是否存在及其 artifacts 条目数。
内部还包括:修正产物脚本里硬编码的旧绝对路径(二进制改写,文本模式会把 LF 悄悄变成 CRLF,让字节校验失效)、把旧工作区自己的 .workbuddy/memory/* 并入目标工作区(同名加来源后缀)、逐文件比对字节数(路径变长带来的增量是可预测的,预言值对不上就是有文件被改坏)。
第 6 步 · 索引重指(搬过产物才需要)
python repair_index_pointers.py --old-root "<旧目录>" --new-base "<新目录>" --scan
python repair_index_pointers.py --old-root "<旧目录>" --new-base "<新目录>" --scan --verify
per-task 布局下必须把落点信息一起给出,否则记忆指针解析不了(记忆文件合并时加了来源后缀):
python repair_index_pointers.py --old-root "<旧目录>" \
--to "<目标工作区根>" --subdir "<子目录名>" \
--ws-root "<目标工作区根>" \
--mem-dir "<目标工作区>\.workbuddy\memory" --mem-suffix "<子目录名>" \
--scan
--scan 会扫三套索引、凡正文里出现旧目录的都处理。不要用 --id 逐个列 —— 按内容扫描不会漏。脚本幂等,可反复跑。
> ⚠️ --ws-root / --mem-dir / --mem-suffix 三个都不能省,漏一个就产生"假残留"。
> per-task 布局下 .workbuddy、.workbuddy\memory、.workbuddy\memoryutomations 这些是
> 工作区级的路径,真实落点在工作区根,不在任务子目录里;不给 --ws-root 它们一律解析不出,
> 会被 --verify 记成"仍指旧工作区",看着像脚本没修干净,其实是参数没给全。
> (2026-09-22 实测:某任务只给 --to/--subdir 时 15 条指针全报残留,补齐三个参数后 15/15 改写、残留 0。)
> ⚠️ 但"扫三套"这件事本身曾经是坏的,且症状极其安静 —— 修过一次,别改回去:
>
> 1. JSON 转义导致另两套索引全被漏选。 file-tree-manifests / changes-index 存的是裸
> Windows 路径,落在 JSON 正文里写作 C:\\Users\\...(双反斜杠)。若拿原始文本直接跟
> 单反斜杠的 needle 比对,这两套索引一个都匹配不上;而 artifact-index 存的是
> file:///C:/... 正斜杠形态,不受影响。于是症状是「artifact-index 修得好好的、另两套
> 明明报"无需处理"、实际全是死链」。匹配前必须先做一层 JSON 反转义(raw.replace("\\\\","\\"))
> 再解百分号编码、再统一分隔符。
> 2. .workbuddy/ 是工作区级的,不在 per-task 子目录里。 per-task 布局下 --new-base
> 是任务子目录,但 .workbuddy、.workbuddy\memory 这些目录指针的真实落点在工作区根。
> 若不给工作区根,它们在 --scan 里全被判成"未能解析"(本轮实测一次就漏 10 条)。
> 脚本现已按 --to(或显式 --ws-root)解析,规则是:.workbuddy\** → 工作区根;
> .workbuddy\memory\** → 目标工作区记忆目录(带来源后缀的候选优先)。
>
> 教训:不要拿"修复脚本自己的计数"当作完备性证明。修完后再独立复核一遍
> (--verify:重新读盘、只比指针键,见收尾第 4 条;别用 grep 扫全文——diff 正文必然命中,是假阳性)。
- 只改指针字段:
diff/patch/content/cachedContent/hunks/text里是当年的真实记录,改了就是篡改历史;会话正文projects/**/{sid}.jsonl同理,一律不动。 - 只改写"落点真实存在"的条目。迁移前就已被删除的临时文件,其指针无解 —— 保留原值并记账,不要为了"零残留"去编造路径。
uri有四种形态,一个都不能漏:file:///C:/...(正斜杠)、file:///c:/...(小写盘符)、C:\...(裸路径)、file-changes://C%3A%5C...(整条百分号编码)。- "未能解析"里有一类是正常残留,不要硬修:同一个文件在索引里可能有多个历史版本条目
(例如某脚本既有一条指向任务根的旧版本条目,又有一条指向scripts\的新版本条目)。
旧版本那几条指向的文件早已不存在,--verify会报"仍指旧工作区"。不能把新版文件的落点配给旧版条目 —— 那是张冠李戴。
判断方法:比对索引条目的size/createdAt/updatedAt与磁盘文件,不一致就说明不是同一个版本。 - 已删除任务的索引也会被
--scan一并处理。它引用的文件往往早被清理,解析不出落点 —— 属预期残留,
不要硬修。识别方法:--verify报的"仍指旧工作区"若全部落在某个deleted_at非空的 sid 上,
且那些路径在磁盘上确实不存在(find全盘也搜不到),就是这一类。 - 第三类残留:
.workbuddy\tmp/.workbuddy\.trash根本没随迁(2026-09-22 实测)。
move_artifacts并入.workbuddy时只处理memory/,tmp/(会话临时目录,如tmp\eval_img\*.PNG)
与.trash/(应用自己的回收站)留在旧目录原地。于是索引里那些指针"物理文件还在、却解析不出落点" ——
--verify会把它记成"仍指旧工作区",与真正的"已删除文件"混在同一个计数里,
只看数字会误判成脚本没修干净。识别与分诊:
python
# rel 为去掉旧目录前缀后的相对路径
# 旧处仍在 → 属这一类;旧处已不存在 → 属前两类(预期的历史残留)
os.path.exists(os.path.join(old_root, rel))
这一类不是脚本 bug,也不该硬修:指针仍指向旧目录,只要旧目录不删就一直有效。
真正的后果是它挡住了"删掉空壳时间戳目录"这一步 —— 想彻底收尾,必须先把这两个目录挪到工作区根
(.workbuddy 属工作区级,脚本的 candidates() 正好把 .workbuddy\** 映射到 ws_root),
挪完再跑一次 repair_index_pointers.py 即可自动解析(脚本幂等,第二次对已修好的条目改写 0 条)。
挪/删都属额外动作,要先跟用户确认 —— 它们是应用管理的临时目录,不是产物。
★ 旧目录一旦真的删掉,① 类(旧处文件仍在)会从"临时有效"变成"永久死链",而 --verify
的输出不会跟着变:它照旧报「仍指旧工作区 = N」,且照旧打印"① 旧目录里文件仍在…"的定性提示 ——
那句提示此时已经过时、会把你带偏(判据 os.path.exists(旧目录/<相对路径>) 此时恒假,
① 类不可能再存在,剩下的全是③④类死链)。实测(2026-09-22 第三轮):删掉 2 个 tmp/trash 外壳后,
两个会话的索引里仍留着数百条此类指针。
这类死链不影响功能(指向的文件早已不存在,UI 不会展示),只是校验噪音;要清就得按
「旧目录前缀」删索引条目,属写索引动作 —— 必须单独取得授权并先备份,不要顺手做。
实测量级供参考:数十个任务收拢共改写数千条指针,残留约三分之一,其中约 85% 都是
第三类(tmp/trash),其余是 outputs 内已删/改名与根目录临时脚本。若不分类,
会得出"脚本漏修了大量指针"的错误结论。
第 7 步 · 收尾
- 让用户直接看一眼侧栏。多数情况下 30 秒内已自动生效,不需要重启(见「核心机制 4」);
只有久等仍不对,才建议重新加载窗口 —— 而且这时更该先怀疑数据(is_playground/deleted_at/project_id),
用troubleshoot排查,别拿重启当解释。 - 告诉用户三件事:任务去了哪、产物是否一起搬了、备份在哪;
- 若搬过产物,一并说明索引已重指、以及有多少条指针因指向已删除的临时文件而无法修复。
- 独立复核索引 —— 不要拿修复脚本自己的计数当完备性证明(第 6 步那两个坑正是这样被漏掉的)。
⚠️ 不要用grep -rl "<旧目录名>"三套索引(2026-09-22 实测会必然误报):
artifact-index里每条的fileChangeInfo.diff是当年的 diff 正文,头部天生带旧路径,
而它属NEVER_KEYS、按铁律本就不该改。于是 grep 一定命中,与"有没有漏修指针"无关 ——
实测 19 个目录里 grep 命中 14 个,而按指针键判定只有 4 个有残留。
正确做法是按结构比"指针键":解析 JSON → 只取PATH_KEYS(path/filePath/uri/…)减去NEVER_KEYS(diff/patch/content/…)
→ 断言无一指向旧目录。直接跑脚本的--verify即可(它重新读盘、只比指针键,与改写逻辑不共享状态):
bash
python repair_index_pointers.py --old-root "<旧目录>" --new-base "<新目录>" --verify
--verify 说"仍指旧工作区 = 0"才算这一类修干净。若仍非 0,核对它打印的 · 未能解析: <路径> 行,
按下方三类定性 —— 只有能归到那三类的才算"预期残留",归不进去就是真漏了。
批量收拢「独立任务」(侧栏一次清干净)
存量场景:侧栏有一堆「独立任务」,每个独占一个自动生成的时间戳目录(list 会报数量)。
它们通常都是干净可迁的,适合一次性收拢。此时不要一个个手敲命令。
先看清既有工作区的落点惯例(决定 --subdir 怎么取):
# 看某个工作区里既有任务的 cwd 长什么样
python wb_move.py list # 再对照:这些任务的 cwd 是工作区根,还是工作区下的项目子目录?
实测到的惯例(2026-09-22):既有工作区里所有任务的 cwd 都等于工作区根目录
(如 …\某工作区),而项目文件夹(…\某工作区\某项目\)是工作区内部的子目录,
不是独立工作区。因此把任务迁进来时应统一用:
python wb_move.py move --to "<工作区根>" --id <sid> \
--with-artifacts --layout per-task --subdir "<项目名>"
即 会话 cwd → 工作区根(这样才归入该工作区),*产物 → 工作区\项目名*。
批量执行要点(本技能不附带批量执行器,需自建;以下是数十个任务的实证做法):
- 一份
plan.json记录{n, sid, title, to, subdir},人可逐条勾选; - 先跑独立预检(补 dry-run 的盲区):
peers_of(old, sid)共用者 + 旧目录顶层清单 + 体积; - 再逐任务串行跑
move --dry-run,输出与日志落盘; - 先小批再全量:随附脚本只支持逐任务(
--id)执行,批量必须由你的执行器按序号分组,
先跑几个确认无误再放开全量; - 每个时间戳目录通常只装 1 个未删除会话 →
peers_of全为 0,无需--force-artifacts。
若两个待迁任务同处一个目录,预检会互相把对方算成共用者而双双被拒,处理顺序见第 5 步的提示。
记忆文件重名要提前算:merge_memory 现按相对路径递归搬迁,含 automations/<automation-id>/memory.md
(自动化自己的持久记忆)。早期版本只搬顶层文件、直接跳过子目录 —— 这类记忆被静默留在旧目录里,
而含文件的分支不会被 prune_empty 清掉,于是旧目录永远"看起来只剩空壳"却删不干净,
一旦手工删掉就把自动化记忆一起丢了。策略仍是"同名 → 加 .来源后缀 → 若还同名则 skip-exists(会丢)"。
重名只可能发生在"源目录与目标工作区同一天**的日志"上,加后缀后仍重名的情形要在动手前逐条排掉
(做法:把每个旧目录的 .workbuddy/memory/* 与目标工作区的文件名列表做一次集合运算)。
演练后必须独立验"什么都没变",而不是相信脚本自称只读:比对 is_playground=1 计数、
各工作区计数、旧目录是否仍在、是否新建了目标子目录、~/.workbuddy 是否新增备份目录。
时间戳目录的第二类主人:自动化任务
判断一个"看起来是孤儿"的时间戳目录能不能动,不能看目录名像不像时间戳,要看谁在用。
把 sessions.cwd 与 automations.cwds(JSON 数组)对一遍,按三类处置:
| 情况 | 判据 | 处置 |
|---|---|---|
目录是某自动化的 cwd,且该自动化 deleted_at 非空 |
自动化已删 | 纯孤儿,按普通任务收拢/删除 |
同上,但自动化仍存活(deleted_at 为空) |
定时还会跑 | 删了下次运行原样重建。要真正收拢,只能把该自动化的 cwds 改指到工作区根 |
| 目录名是时间戳,但本身就是某个工作区(有活跃会话) | sessions.cwd 里有它 |
不可删,它是别人的家 |
改 cwds 必须走 automation_update 工具,绝不许改库(工具 list 只返回未删除的自动化,
所以"工具里查不到"本身就说明它已删 —— 但仍建议拿 automations.deleted_at 复核一次,两者应一致)。
> 实测(2026-09-22):三个时间戳目录里,一个其实是工作区「WB 积分领取」的根(3 个活跃会话,
> 纯属命名巧合)、两个的自动化已删、一个是仍存活的月度任务。只看目录名会做错决定。
删除残留目录前的检查单(2026-09-22 第三轮实证)
收拢收尾时,用户通常会要求"把剩的空目录一起删了"。删除是不可逆动作的放大器 —— 先证明"没人用它",再动手。
- 白名单写死。扫描只用于发现候选,删谁由人逐条确认;把清单硬编码进脚本,
不要让os.walk的结果直接决定删除对象(否则目录结构一变就误伤)。
> 实测:第三轮清完 40 项后,扫描根目录仍报出 2 个空目录 —— 正是用户明确要求"不动"的两个
> 存活自动化目录。若拿扫描结果直接删,就会误删。 - 三项断言,缺一都可能误删:
- 空性:目标是files==0 and dirs==0。若它含空的子目录,要放宽为files==0
(实测乱码目录里挂着一个空render\,用严格断言会被拦下 —— 看着像失败,其实是断言过严);
- 白名单:目标不在保留名单里,且不是名单项的后代(防"删子目录时顺带端掉父目录");
- 无引用:sessions按cwd分组后,该 cwd 的存活会话数必须为 0
(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) = 0)。这一步才是真正的安全闸 ——
前两步只保证"删了不心疼",这一步保证"删了不出事"。 - 拿
automations.cwds交叉核对:若该目录是某自动化的cwd,先看deleted_at。
仍存活的自动化 → 目录不能删,删了下次运行会原样重建(见上一节)。 - 警惕"名字相近":实测同时存在
示例项目B(真实目录,有内容)与一个 mojibake 目录
(璁捐+ 私用区字符 U+E178 +浼樺寲,内部全空)。按子串匹配会把两个都删掉。
正确做法:用互斥的特征字符识别乱码目录("\ue178" in name),并把真实目录写进白名单断言。 - 走回收站,不走
rm:SHFileOperationW(FO_DELETE | FOF_ALLOWUNDO),pFrom是\0连接的
多重路径串、结尾再加一个\0。⚠️ PowerShell 侧的Add-Type在本机被安全策略拦下
(提示 "compiles and loads .NET code at runtime")→ 改在 Python 侧用ctypes调shell32最省事。 - 分批(每批 ≤10 项)+ 每批复验:
SHFileOperationW返回 0 不代表每项都删掉了
(被占用会静默跳过)→ 必须逐项os.path.exists复核,并把结果落 JSON 日志。 - 删完再扫一遍根目录:确认"只少了该少的",并专门回头看那几个相近名 / 白名单目录还在不在。
可复用实现:scripts/recycle_remove.py(白名单 + 三断言 + 回收站 + 分批 + 日志,计划走 JSON)。
必须遵守
- 改库前一定备份(脚本已自动做,不要绕过)。
- 只改
cwd不算迁移。is_playground=0必须同时写。 deleted_at非空的会话迁移后仍然不可见。要么先用restore捞回,要么用--allow-deleted连删除标记一起清 —— 但清删除标记是比迁移更重的动作,要跟用户说清楚。project_id非空的会话会归到「项目」分组而不是工作区。迁移前提示用户是否需要--clear-project,不要静默改。- 不要动
workspaces表。它只影响"最近打开"排序,归属只看sessions.cwd;贸然插行可能造出重复工作区。另:把一个工作区的任务全部移走后,它会自动从侧栏消失,无需清理。 - 不要张口就让用户重启。侧栏 30 秒轮询会自动收敛(核心机制 4);把"看不到"直接归因于"没重启"会掩盖真正的数据问题(
is_playground没改 /deleted_at非空 /project_id非空),也白白消耗用户的耐心。先check,再troubleshoot。 - 不要用本技能删任务。删除是软删除(写
deleted_at毫秒时间戳),客户端没有回收站也没有恢复入口,只能改库捞回。 - 调用脚本时不要接管道(
| head、| tail)。实测本机 MSYS 环境会偶发
head:\ ls:\ dirname: command not found(PATH 解析异常),管道会让整条命令失败并掩盖真实输出;
要截断输出就在 Python 侧做。同理,带反斜杠的路径一律走--to/--old-root参数,
不要塞进python -c "..."
(shell 会把\\塌成/,得到长度都不对的内联字面量,判等恒假 → "查不到会话"的假否定)。
验证方式:沙盒演练
不要在真实数据上试错。scripts/ 支持用环境变量把技能指向一份副本:
# 复制 ~/.workbuddy 到某处,然后
WB_CFG_DIR=/path/to/copy/.workbuddy python wb_move.py list
配合一个清空过 sessions 表、只插入合成数据的副本,就能把 move / check / with-artifacts / repair_index_pointers / restore 整条链路端到端跑一遍,全程不碰真实会话。
给维护者:本技能的泛用性约定
本技能要给别人用,因此不得出现:
- 任何本机绝对路径(含用户名)、任何具体工作区名或任务标题;
- 要求使用者先手工查 SQL 拿到
sessionId、再把值填进源码 —— 参数化必须走命令行; - 对"一个任务独占一个目录""旧目录可以随便搬空"这类特例的默认假设(见第 5 步的共用目录预检)。
新增脚本时沿用 wb_common.py 的路径/DB/分组判定,不要另起一套转义或连接逻辑。
参考
references/mechanism.md—— 机制依据:字段语义、渲染层判定、后端查询、目录转义、索引结构。references/troubleshooting.md—— 「看不到任务」的逐项排查、已删除任务的捞回、常见误判。