云手SAAS规范流程
-
date_range 27/07/2021 15:19
点击量:次infosortleetcodelabel
并行线工作流:一条线 = 一个终端 tab = 一个 worktree = 一个 claude 会话
用户 2026-08-02 拍板(ISO1 出路①),2026-08-09 合仓后复核并补齐。 工作目录就是这条线的边界。
★ 全流程总表:干活 → 收口(2026-08-12 补立)
★★补这张表的原因:本文档原先只覆盖 ④⑤⑥⑨(开工位/看板/合回/退役), 而 ①②③⑦⑧⑩ 散落在
docs/任务清单.md、docs/HANDOFF.md、docs/发版流程.md、 交付回执模板里。⇒ 没有一处能一眼看到全流程 —— 2026-08-12 架构线因此漏了第 ③ 步(登记了编号却没派工), 表现是「任务有编号,而工位看板上什么都没有」,且没有任何地方会报错。
| 阶段 | 落点 | 谁 |
|---|---|---|
| ① 方案设计 | docs/任务-*.md / kubernetes/任务规格-*.md |
架构线 |
| ② 任务编码 | docs/任务清单.md 登记编号 ★动手前先查,别撞号 |
架构线 |
| ③ 派工 | docs/HANDOFF.md 写一条「XX 交编码线」 |
架构线 |
| ④ 开工位 | line-worktree.sh new <线名> + 私有测试库(§9.2,脚本不做) |
编码线 |
| ⑤ 看板 | line-worktree.sh list(★显示的是开着哪些工位,不是任务列表) |
脚本实时算 |
| — 干活 — | 每天至少跑一次 check-line-collisions.py(§9.3) |
编码线 |
| ⑥ 合回 main | §4 那四步,第 ④ 步(rebase 后重跑 check)最容易漏 | 编码线 |
| ⑦ 写回执 | docs/交付回执-<编号>-给设计线.md,七段必填 |
编码线 |
| ⑧ 更新 HANDOFF | 把那条从「交编码线」改成「已闭环」 | 编码线 |
| ⑨ 退役工位 | line-worktree.sh rm <线名> + 手工回收测试库(§9.6) |
编码线 |
| ⑩ 发版(攒够 20 条) | docs/发版流程.md / scripts/release-all.sh |
谁发谁负责 |
🔴 收口那三步(⑦⑧⑨)有一道闸
scripts/check-line-closeout.sh <编号> # 例:GW4
★为什么单独给收口做闸:⑦⑧⑨ 全都是「不做也不会有任何东西报错」的步骤 —— 活已经合进 main 了、功能也上线了,少一份回执、HANDOFF 少改一行、工位没退役, 系统一切正常。⇒ 它们是这套流程里唯一靠自觉的一段,而自觉在忙的时候必失效。
⚠️ 已知缺口:主工作区落后时 §4 第 ⑤ 步走不通
git merge --ff-only feat/<线名> 要求主工作区能快进。
而主工作区常常落后远端十几到几十个提交(别的线在推),且带着未提交改动 ⇒ 拒绝快进。
★2026-08-12 架构线实测撞上:主工作区落后 28 个提交、有别的线的未提交改动,
于是改用「临时工位 cherry-pick + push origin HEAD:main」绕过去。
- 绕过去本身可以(它保住了「只推自己的文件」这条),
- 🔴 但代价必须知道:那条路跳过了 §4 的两次
make check。 跑分包测试 ≠make check—— 后者还含契约测试、OpenAPI 基线、四道 repo 闸。 - ⇒ 走这条路时,必须在回执第 5 段(偏离规格与未验前提)里如实写下来, 不要当作”应该没事”。
0. 为什么要这样(不是洁癖)
全机只有一个物理工作目录时,两条线编辑同一个文件,谁先提交谁把两份都带走。 内容不丢,丢的是「这行为什么这么写」—— git 里它指向一条不相干的提交说明, 而那是几个月后唯一能查的东西。★2026-08-02 一天内发生 3 次。
★而 ISO1 已论证过:这做不成 pre-commit 闸 ——「这半是别人的」这个信息 在 git 里根本不存在。所以只能从物理隔离入手。
1. 开一条线(3 步)
在主仓开新终端 tab,然后:
cd ~/dockersofts/CloudHandStack
scripts/line-worktree.sh new <线名> [简述] # 例:new bc9 generic-quota
它会:
| 动作 | 为什么(都是实测过的静默失败) |
|---|---|
建 ~/dockersofts/CloudHandStack-lines/<线名> |
物理隔离 |
建具名分支 feat/<线名>[-<简述>] |
detached 下提交没有任何 ref 指着,工作区一删就只剩 reflog |
软链主仓 .env |
不软链的话 docker compose config 退出码 0,19 个变量静默变空 |
软链 apps/agent-ui/node_modules、web/node_modules |
不软链的话这条线的 make check 必红在前端闸 |
写 .WORKTREE-README 并加进 .git/info/exclude |
不排除的话它会污染 git status——那是各条线互查占用的唯一视野 |
最后 cd 过去起 claude:
cd ~/dockersofts/CloudHandStack-lines/<线名> && claude
★先 cd 再起 claude,不要在主仓起了再 cd。claude 起在哪,哪就是它的项目根;
起错了它整场会话都以主仓为根,边界当场就没了。
2. 在线上能做什么、不能做什么
| 命令 | |
|---|---|
| ✅ 可以 | 编辑、go build、go test、make check、make docker-build、scripts/stack.sh build、scripts/stack.sh status |
| 🔴 绝不 | docker compose up / down / restart、scripts/stack.sh up / down / publish-plugin |
两个失效都不报错、退出码 0:
- compose 项目名跟着目录名走 ⇒ 在工位里
up会另起一整套容器,而不是管现有的; - 数据卷是相对路径(
./data等)⇒ 解析成工位下的空目录。
合起来的观感是「站点数据没了」,而真站点好好的 —— 极难当场看懂。
⇒ 部署一律回主仓做。
3. 边界靠什么保证(★分清「有闸」和「靠自觉」)
三层,2026-08-09 逐层验过:
| 层 | 是什么 | 验证 |
|---|---|---|
| ① 物理隔离 | 各线各目录,git worktree | 天然成立 |
| ② fail-CLOSED 守卫 | stack.sh 的 _refuse_in_worktree():比 --git-dir 与 --git-common-dir 的绝对路径,不等就拒 |
工位里实测二者确实不同(.git/worktrees/au1 vs .git);status 只读故意放行 |
③ .WORKTREE-README |
说明为什么不能跑 compose | 建线时自动写入 |
★守卫的两个设计点值得知道:
- 判据用脚本自己所在的目录,不是
$(pwd)—— 从别处调用时 pwd 是调用者的, 守卫会去检查一个不相干的地方,★而那种失效的方向是放行。 - 判定不出来就拦(fail-CLOSED)。误拦的代价是一条提示 + 一个环境变量
(
AYG_ALLOW_WORKTREE_DEPLOY=1),误放的代价是上面那套「像是数据没了」。
🔴 它不保证什么
- 裸
docker compose没有任何闸 —— 守卫只在stack.sh里。 这条路上唯一的防线就是.WORKTREE-README那张纸。 git -C <主仓> …照样能用 —— 工位挡不住你显式指向主仓。- claude 能读写工位以外的绝对路径 —— 边界是「默认在哪」,不是沙箱。
node_modules是软链,不是副本 —— 某条线npm ci会动到共享的那一份 (与.env软链同一个取舍)。改package.json的线要意识到这点。
4. 合回 main(★第 ④ 步最容易漏)
W=~/dockersofts/CloudHandStack-lines/<线名>
cd $W && make check # ② 本地全绿
git -C $W rebase main # ③ 冲突在自己线上解
cd $W && make check # ④ ★rebase 后**重跑**
git -C ~/dockersofts/CloudHandStack merge --ff-only feat/<线名> # ⑤ 主目录只做快进
- 第 ④ 步不能省:「绿」是 rebase 前那次的绿,rebase 之后代码已经不是那份了。
- 第 ⑤ 步用
--ff-only:它拒绝就说明 ③ 没做干净,回去重做,不要用普通 merge 糊过去。
⚠️ 多条线一起合回时这套要重做多遍 —— 见 §9.5。
5. 退役
cd ~/dockersofts/CloudHandStack
scripts/line-worktree.sh rm <线名>
- 有未提交改动会拒绝删 —— 那正是这套隔离要保住的东西。
- 分支用
git branch -d(不是-D):没全并时它会拒,等于免费多一道确认。 ★这道确认拦的是「以为合过了、其实没合」—— 那种情况下-D会安静地把活删掉, 而工作区已经先一步被拿走,两步都成功,东西没了,一条错误信息都没有。
🔴 ★它不回收测试库 —— line-worktree.sh 不知道 §9.2 那个库的存在。
多工位场景下漏这一步会攒一堆孤儿库,见 §9.6。
6. 查看所有线
scripts/line-worktree.sh list
它会额外替你看两件 git worktree list 一眼扫不出来的事:
- 有没有
(detached HEAD)—— 那种工位的提交没有任何 ref 指着; - 有没有 prunable 登记(目录已不存在)—— 清之前要先确认 HEAD 在别处可达。
7. 桌面端:New 会话怎么跟工位对上
Claude Code 按工作目录给会话建项目 —— ~/.claude/projects/ 下的目录名
就是工作目录路径编码来的:
/Users/andrewyang/dockersofts/CloudHandStack → -Users-andrewyang-dockersofts-CloudHandStack
/Users/andrewyang/dockersofts/CloudHandStack-lines/bc9 → -Users-andrewyang-dockersofts-CloudHandStack-lines-bc9
⇒ 换个工作目录 = 换一个项目 = 侧边栏里另起一组。 这就是「一个会话 = 一个工位」的全部机制,没有别的开关要拧。
| 起会话的方式 | 怎么绑到这条线 |
|---|---|
| 终端 | cd <工位> && claude |
| 桌面端 | New → 工作目录选 ~/dockersofts/CloudHandStack-lines/<线名> |
★先建工位、再 New 会话 —— 反过来的话 New 的时候那个目录还不存在。
★工位根目录为什么不带点
原先叫 .agenthands-lines(点开头 = 隐藏目录)。
2026-08-09 改成 <仓名>-lines,理由是功能性的:macOS 的文件选择器
默认看不见隐藏目录,桌面端 New 会话每次都得 Cmd+Shift+. 或手贴路径 ——
而「开一条线」正是这套流程里唯一的高频动作,不该有摩擦。
★同一份文档早先写过「工位根目录刻意不改名」,那句针对的是改称呼 (与「网关改叫 aiproxy 但运行时仍是 cliproxy」同一条取舍)。 这次不是称呼问题,是选不中目录,所以结论反过来 —— 记在这里免得被改回去。
默认值是 ${REPO}-lines,仓库将来改名会跟着走。要放别处:
AGENTHANDS_WORKTREE_BASE=<路径>。
8. 还有一个不是这套流程建的工位
CloudHandStack/.claude/worktrees/worktree-session-boundary-<hash>
这是 Claude Code 自己的隔离工位(isolation: worktree),会出现在
git worktree list 与侧边栏里。别把它当成一条业务线,也别用
line-worktree.sh rm 去动它。
🔴 ★★它不经过 line-worktree.sh,所以 §1 那五件事一件都没做 ——
最要命的是缺 node_modules 软链:在这种工位里 make check 会红在前端闸,
退出码 2(web/node_modules 是空的 —— 这道闸跑不了)。
2026-08-09 实测撞到过;补上两条软链后 check-console 与 check-agent-ui 均退 0:
R=~/dockersofts/CloudHandStack; W=$R/.claude/worktrees/<那个目录>; ln -sf $R/.env $W/.env; for n in apps/agent-ui web; do ln -sfn $R/$n/node_modules $W/$n/node_modules; done
9. 同时开多个工位(★以 8 个为例)
单工位的每一步照 §1–§7,本节只写多开之后才出现的那些事。
9.0 先回答「这几条能不能并行」——不能跳过
能并行 ⟺ 文件集合不相交。 与甘特图上是否同期、方案拆得多小,都无关。
判据表在 docs/任务清单.md 的「📁 文件领地总表」,排期先看那张表。
★物理隔离治得了「谁把谁的改动卷进提交」,★★治不了「两条线改同一个文件、 最后 rebase 时全撞在一起」 —— 后者只是把冲突从提交时推迟到合并时,还更难解。
认领每条线之前三步,顺序不能反(git log 那步最容易漏,它发现的是「已经做完了」):
git log --all --grep=<编号> && git status && git worktree list
🔴 ★2026-08-09 实测:本仓当前凑不出 8 条互不相交的线。
领地表里标 🟢 的本仓任务只有 ONB1 · DL1(P1/P2) · SK2 · TOS1 四条(AU1 已被占用),
其余不是 🔴 独占(ENT1 跨 port、SDK1 剩余项独占 adapter/httpapi/**)
就是 🟡 要排队(IAM1 等 ENT1、DX1 与 VA2 抢 engine/core),
DUN1 与 ENT1 同在 billingsync ⇒ 串行,SB2 暂缓。
要凑到 8 只能把 QG1/CP3、BC4/BC8、RB1 这三组仓外任务算进来 ——
★而它们在另外的仓(cliproxy-fork / andrewyg-agentplugin / docker_cliproxy),
不是本仓的 worktree,本文这套流程管不到它们。
⇒ 先按领地表定出真正能同开的条数,再决定开几个工位。「凑够 8 个」不是目标。
9.1 一次性前提
git -C ~/dockersofts/CloudHandStack status --porcelain
必须输出为空。 非空说明主仓里还有人在写 —— 那正是工位模型要消灭的状态。
(cd ~/dockersofts/CloudHandStack/web && npm ci) && (cd ~/dockersofts/CloudHandStack/apps/agent-ui && npm ci)
各工位的 node_modules 是软链到主仓这一份的。主仓这份是空的
⇒ 所有工位的 make check 一起红在前端闸(退出码 2,不是 1 —— 别去查代码)。
9.2 🔴 每条线一个私有测试库(★这一步 line-worktree.sh 不做,少了它必炸)
Makefile 已经把包级并行钉成 GO_TEST_PACKAGE_PARALLELISM ?= 1,注释写明理由:
controlstore / httpapi 都会对同一个 AGENTHANDS_TEST_PG_DSN 做真实清库,
并行会让两个包彼此删掉对方的夹具,表现为偶发红而非真实回归。
★★但 -p 1 只约束一个 go test 进程内部。 8 个工位是 8 个进程,
-p 1 对它们之间毫无作用 ⇒ 同一个坑原样重现,
★而它的表现是「偶发红」—— 人会去查自己刚写的代码,真实原因是隔壁工位在清库。
2026-08-09 实测(两个方向都验了,只验一边分不出「真成立」还是「检查失灵」):
| 场景 | controlstore |
httpapi |
|---|---|---|
| 并发 + 共用一个库 | 退出码 1(TestCrossAgentReleaseRejectedAgainstRealStore FAIL) |
退出码 1(TestControlAPIEndToEnd FAIL) |
| 并发 + 各自一个库 | 退出码 0 | 退出码 0 |
⇒ 在这个 tab 里导出,就一行(★不能写进 .env —— 那是各条线软链共用的
同一个文件,写进去等于又回到同一个库):
export AGENTHANDS_TEST_PG_DSN="$(scripts/test-db.sh)"
scripts/test-db.sh 按当前工位目录名算出库名(主仓 → agenthands_test_main,
工位 …-lines/onb6 → agenthands_test_onb6),不存在就建,存在就直接给 DSN。
- 为什么按工位而不是按 PID/时间戳:按 PID = 每次跑都是新库 = 每次重跑全部迁移, 且垃圾库无限增长。按工位 = 同一工位反复跑复用同一个库(快),不同工位天然隔离(安全)。 ⇒ 隔离粒度要对齐真实的并发单位,而这里的并发单位就是工位。
- 🔴 ★建库必须
-U postgres:agenthands角色rolcreatedb=f,用它会得到ERROR: permission denied to create database(实测)。脚本已经这么做。 - ★属主必须是
agenthands:属主不对时表会建成postgres的, 而各 store 用的CREATE TABLE IF NOT EXISTS会静默跳过已存在的表 ⇒ 错误属主永久留存,报出来的是permission denied(指向权限,不指向属主)。 2026-08-10 发 v1.0.11 时因此误诊过一轮。 - ★库名必须含
test:契约测试有硬保险,不含就t.Fatalf拒绝跑 —— 那些测试会TRUNCATE整张表,指向生产库会清空真实会话与租户。 命名规则agenthands_test_<工位>天然满足。 - ★不必预先建表:各 store 用
CREATE TABLE IF NOT EXISTS自举,空库直接能跑(实测)。
其他用法:scripts/test-db.sh --name 只看库名 · --list 列出所有测试库并标出
哪些已经没有对应工位(可回收)· --drop 删掉本工位那个库(夹具脏了想重来)。
🔴 2026-08-10:共享库 agenthands_test 已退役,并加了闸
本节的约定 2026-08-09 就写在这里了,仍然被绕过 —— 因为它要人做两件手工事
(建库 + 手打一长串 export),而 release-all.sh 自己推导 DSN 时
默认推出来的正是共享库。发 v1.0.11 当晚连红三次,每次红的包都不一样。
⇒ 三件一起做,缺任何一件都还会被绕过:
| 自动 | scripts/test-db.sh 一行搞定建库 + 取 DSN;release-all.sh 改成调它 |
| 闸 | make check 第一项 check-test-db-isolation:DSN 指向 agenthands_test 直接红(毫秒级,而它要防的故障要等十几分钟才显形,且显形时报的是别的包) |
| 退役 | 共享库已 ALTER DATABASE … RENAME TO agenthands_test_retired_20260810 ——改名不删:残留引用会立刻报 database "agenthands_test" does not exist,而数据仍可回滚 |
★不设 DSN 的后果不是 skip 是 fail:35 个用例会故意红。 一个绿色的 ok 配零个用例,比红色的失败危险得多。
9.3 批量开(在主仓 tab 里跑,线名换成你自己那批)
cd ~/dockersofts/CloudHandStack && for L in onb1:onboarding dl1:retention sk2:ablate tos1:tos; do N="${L%%:*}"; S="${L##*:}"; bash scripts/line-worktree.sh new "$N" "$S" || { echo "🔴 建线失败:$N —— 停下来看"; break; }; docker exec langfuse-postgres-1 psql -U postgres -d postgres -c "CREATE DATABASE agenthands_test_${N} OWNER agenthands;" || { echo "🔴 建库失败:$N —— 停下来看"; break; }; done
★ 用 || break 不用 || true:半成品工位比没有工位更糟 ——
它长得跟好的一样,要到跑 make check 时才以「连不上数据库」的形状暴露。
★★同理不要用 >/dev/null 吃掉错误:写这份文档时我就这么干过一次,
psql 报了 permission denied,而后面那行 ✓ 已建 照样打印了。
9.4 核对工位真的齐了(★三份清单条数必须一致)
cd ~/dockersofts/CloudHandStack && echo "— worktree —" && bash scripts/line-worktree.sh list && echo "— 分支 —" && git branch --list 'feat/*' && echo "— 测试库 —" && docker exec langfuse-postgres-1 psql -U postgres -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'agenthands_test_%' ORDER BY 1;"
对不上就是 §9.3 在某条线上断了。★每个工位再各开一个 tab,照 §9.2 export 后 cd … && claude。
9.4.1 干活期间:每天至少跑一次撞车检查
python3 ~/dockersofts/CloudHandStack/scripts/check-line-collisions.py
★它补的是隔离之后丢掉的那个信号:以前两条线重复造同一个东西,
是靠共用一个目录、在 git status 里看见对方的未跟踪文件才发现的
(2026-08-02 的 line-worktree.sh vs worktree-line.sh 就是这么抓到的)。
隔离之后各人只看得见自己那棵树。
⚠️ 它不在 make check 里,得手动跑;且只看得见已落盘的文件
—— 两条线都还在想的时候它看不见。
9.5 🔴 合回 main 是串行的,每条都要重来一遍 rebase + 重跑 check
main 每接受一条线就往前走一格 ⇒ 后面每条线的 rebase 基线都变了
⇒ ★②③④ 必须重跑,不能拿开工时那次绿来交差。
一条一条来,每条都等 merge --ff-only 成功再动下一条:
cd ~/dockersofts/CloudHandStack-lines/<线名> && make check && git rebase main && make check && git -C ~/dockersofts/CloudHandStack merge --ff-only feat/<线名>-<简述> && echo "✅ <线名> 已合入 main"
- 顺序建议:领地最大的先合(它最可能与别人冲突,早暴露早解),文档类最后合。
- ★时间要说在前面:N 条线 × (rebase + 重跑
make check) 是串行成本,make check每次几分钟 ⇒ 合并窗口是小时级的,不是「八条一起点一下」。 把它当成一个独立时段来排,别排在下班前十分钟。
9.6 批量退役并回收(★rm 不管测试库,得自己删)
cd ~/dockersofts/CloudHandStack && for N in onb1 dl1 sk2 tos1; do bash scripts/line-worktree.sh rm "$N" || { echo "🔴 $N 退役被拒 —— 多半是没合干净或还有未提交改动,停下来看"; break; }; docker exec langfuse-postgres-1 psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS agenthands_test_${N};" && echo " ✓ 已删库 agenthands_test_${N}"; done
★ 同样用 || break:退役被拒是信号不是噪音 ——
它通常意味着这条线的活根本没合进 main(branch -d 拒绝了),强删就是把活丢了。
9.7 收工核对(★四条都要归零)
cd ~/dockersofts/CloudHandStack && bash scripts/line-worktree.sh list && git branch --list 'feat/*' && docker exec langfuse-postgres-1 psql -U postgres -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'agenthands_test_%';" && bash scripts/check-stray-test-containers.sh
| 看什么 | 期望 | 不对时怎么读 |
|---|---|---|
worktree list |
只剩主仓 + Claude Code 自建那个(§8) | 多出来的是没退役干净的 |
feat/* |
空 | ★还剩的那条就是没合干净的,去查它,别删 |
agenthands_test_* |
空 | 孤儿库(agenthands_test、agenthands_bc8_test、cliproxy_billing_test 是常驻的,不在此模式内) |
| 残留测试容器 | 退出 0 | 有残留就 make clean-stray-test-containers。★它的代价不只是占内存 —— 它会在下一次跑测试时占住端口,而那时的报错是「连不上数据库」,人会去查数据库配置 |
9.8 ⚠️ 核对结果有保质期 —— 2026-08-09 写这节时当场撞到
写 §9.7 的过程中,feat/au1-audit-subagent 在几分钟内从「1 条未合回 main」
变成「0 条」—— 另一条线正好在这期间合了。同一时段 main 还从 ad60360 走到
78ead609(工位根目录由 .agenthands-lines 改成 <仓名>-lines),
★连 git worktree list 里那条线的路径都变了。
我据此先写下「抓到一个真实残留」,是错的 —— 那是一条正在飞的线,不是残留。
⇒ ★★判读 §9.7 的四条清单时,先分清「残留」和「在飞」:
| 现象 | 是残留 | 是在飞 |
|---|---|---|
feat/* 还有分支 |
工位目录已删、且 main..分支 > 0 |
工位还在、有人正在里面干活 |
agenthands_test_* 还有库 |
对应工位已退役 | 对应工位还开着 |
判据是「工位还在不在」,不是「分支还在不在」。 拿分支当判据会把活人当死人。
★★而这件事本身就是 §9.5 那条的现场证据:你 rebase 的那个 main,
在你跑 make check 的这几分钟里可能已经又往前走了。
⇒ merge --ff-only 被拒不是意外,是常态;被拒就回去重做 ③,别换成普通 merge。
评论:
技术文章推送
手机、电脑实用软件分享
微信公众号:AndrewYG的算法世界