chore: 存一个「改完怎么验收怎么交付」的技能,省得下次重新踩

本次踩到两个纯靠猜出不来的坑,都写进技能里了:

* `RemoteFarm.snapshot()` 是本地缓存不发请求,非实时脚本轮询会一直读到同一份
  旧数据——服务器在推进却看起来像卡死,排查花了十几分钟。
* `git push` 报远端 `Failed to authenticate user` 其实是本地 GCM 拒绝交互
  (`Cannot prompt because user interactivity has been disabled`),
  加 `GCM_INTERACTIVE=always` 就好;再不行 `GCM_TRACE=1` 看日志。
  验证推送成功要看 `git ls-remote`,`git status -sb` 的 `[gone]` 是沙箱
  丢 `.git/refs/remotes/**` 写入导致的假象。

技能放在 .workbuddy/skills/simplefarm-verify-and-ship/,覆盖四步交付流程:
门禁 → 真机端到端验收(预置存档 + 后台任务起服务器)→ 写记忆 → 提交推送。
This commit is contained in:
snowy-yang committed 2026-09-19 17:06:15 +08:00
1 parent cf24cb43f1
commit 6268283fc6
1 file changed
+97
@@ -0,0 +1,97 @@
---
name: simplefarm-verify-and-ship
description: Use when finishing ANY change to the SimpleFarm project (D:/SimpleFarm) — after adding or refactoring features, before declaring the task done or pushing to gitea.4i.hk. Covers the four-step delivery ritual — run the quality gate (ruff format/check + pyright + unittest), verify the change end-to-end on a REAL server with seeded saves, write memory notes, then commit and push. Triggers include "改完了", "验收", "跑测试", "提交推送", "推送到 gitea", or any turn where farm/*.py was edited. Also load it when a test fails after a behaviour change, when the server seems frozen, or when `git push` says "Failed to authenticate user".
agent_created: true
---
# SimpleFarm:改完之后的验收与交付
任何一次改动,走完这四步才算做完。别跳过第 2 步——单元测试全绿**不代表**功能真的能跑
(本项目已经出现过:测试全绿,但真实服务器上 `--pid-file pid.txt` 直接崩、
非实时客户端轮询永远读到旧状态)。
## 第 1 步:质量门禁(唯一可信口径)
```bash
uv run ruff format . && uv run ruff check . && uv run pyright
uv run python -m unittest discover -s tests
uv run python tools/bench.py --repeat 5 # 只有碰了热路径才需要
```
预期:`All checks passed!` / `0 errors, 0 warnings` / `OK (skipped=35)`。
`skipped=35` 是没装 httpx 时 `TestClient` 那批用例的正常跳过,不是失败。
bench 里带调用计数器,计数变红说明轻量路径被改回重路径了。
**别用管道**:本机 Git Bash 缺 `head`/`tail`/`grep`/`ls`/`mkdir`,任何 `|` 都会整条失败。
查看/删除文件一律写 Python 脚本。
## 第 2 步:真机端到端验收
单元测试覆盖不到的,是"服务器起来了吗、接口真的返回新字段了吗"。必须起真服务器验。
**关键:服务器要用后台任务跑,别用 `--daemon`。** 脱离父进程的守护进程会随工具调用
结束被回收,下一条命令就连不上了(表现为 `ConnectionRefusedError`)。用
`run_in_background: true` 跑前台服务器,验完再停掉。
```bash
# 1) 预置存档:想验"买得起"的路径,就先塞一份有钱的档。
# farm_id 直接映射到 <save-dir>/<farm_id>.json;字段缺失一律走默认值,
# 所以最小延迟 {version:2, state:{gold,level}} 就够。
# 必须在同一条命令里 mkdir -p 好目录再写文件。
# 2) 起服务器(tick 小一点,3 秒一天)
uv run main.py serve --port 8799 --tick 3 --save-dir .e2e-saves
# 3) 用 RemoteFarm 驱动,跑完删掉脚本和 .e2e-saves/
```
**两个必须知道的坑:**
- **`RemoteFarm.snapshot()` 是本地缓存,不发请求。** 脚本里写
`while True: client.snapshot()` 会永远读到同一份旧数据——服务器在推进,
看起来却像卡死。非实时客户端必须自己调 `client.refresh()` 再读 `snapshot()`。
TUI/网页走 SSE 所以没这个问题。
- **作物会缺水停长。** 天气随机消耗水分(晴 1 / 多云 0 / 干旱 2),
等成熟的循环里要盯水分补浇,否则 120 秒也等不到成熟。这也顺带证明了
天气→浇水这条链真的在起作用。
验收脚本该断言什么:新字段真的出现在 `/api/state` 和 `/api/catalog` 里、
动作回执带新字段、错误码符合预期(如 `nothing_to_sell` / `locked` / `unknown_building`)。
错误码去 `farm/engine.py` 里核对,别猜。
## 第 3 步:写记忆
- 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。
- 长期约定改 `.workbuddy/memory/MEMORY.md`(原地编辑)。
- 用 `read_bytes()` / `write_bytes()` 保 LF;同一个文件**一条消息里改多处**时,
写一个幂等补丁脚本(整文件读入 → 精确匹配替换 → 逐条打印 `ALREADY`/`APPLIED`/`MISS`),
跑完即删。**不要**在一条消息里对同一文件连发多个 Edit——会被静默覆盖。
## 第 4 步:提交并推送
```bash
git add -A && git commit -F - <<'MSG'
...多行中文 commit message...
MSG
GCM_INTERACTIVE=always git push origin main
```
- **`GCM_INTERACTIVE=always` 是必需的。** 默认(非交互)下 GCM 直接拒绝
`Cannot prompt because user interactivity has been disabled`,
报错却显示成远端的 `Failed to authenticate user`,很容易误判成 token 失效。
加上它之后 GCM 会静默续期。
- 还是失败就加 `GCM_TRACE=1` 重新推一次,日志会明确说
`No existing credentials found` 还是拿到了 token。别急着去翻 tea 的
`credentials.json.enc`(加密的,读不出来)。
- **验证推送成功看 `git ls-remote origin`**,不要看 `git status -sb`——
沙箱会丢掉 `.git/refs/remotes/**` 新建目录的写入,status 会谎报
`## main...origin/main [gone]`。`ls-remote` 的 sha 与 `git rev-parse HEAD`
一致就是推上去了,`[gone]` 是无害的本地缓存问题。
## 别踩的坑(本机特有)
- `os.makedirs(os.path.dirname(path))` 在 path 只有文件名时会崩(dirname 是空串)。
用 `farm/server/daemon.py` 的 `ensure_parent_dir()`。
- 写文件必须显式保 LF(`write_bytes` 或 `newline="\n"`),别让 `\n` 被转成 `\r\n`。
- 访问本机 HTTP 走 `farm/net.py` 的 `open_url()`,环境里有 `HTTP_PROXY` 但没有
`no_proxy`,直连会被代理拦成 502。
- 端口自检用"能不能连上"判断,别用 bind;uvicorn 设了 SO_REUSEADDR,
Windows 上第二个实例能绑上已被监听的端口并抢走新连接。