Files
SimpleFarm/.workbuddy/memory/2026-09-19.md
T
snowy-yang a92c29ead2 docs: 记录发布到 gitea.4i.hk 的仓库地址与推送认证方式
远端仓库:https://gitea.4i.hk/hhhge/SimpleFarm(public,main)。

关键坑:非交互 shell 里必须 GCM_INTERACTIVE=always,否则 GCM 会因为「用户交互被禁用」
直接拒绝,git 报 could not read Username / Failed to authenticate user,看起来像认证配错了。

另记:tea 的 OAuth token 能建仓、能调 API、能加 SSH 公钥,但不能签发访问令牌
(POST /users/{u}/tokens 需要 basic auth)。
2026-09-19 13:33:48 +08:00

239 lines
20 KiB
Markdown
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.
## 2026-09-19 代码结构审查(只读,未改代码)
- 全量读了 engine/models/actions/app/session/hub/client/storage/bot,跑通 ruff/format/pyright/97 测试,全绿。
- 结论:分层健康,无需返工。发现 5 个可优化点:
1. engine.Scheduler 与 server.FarmClock 重复实现同一套 tick 决策(暂停顺延+deadline);
2. actions.py 表内注册的 save/reset 在服务端被 execute_action 拦截成死代码,未来绕过即触发默认路径覆盖存档的隐患;
3. app.brief() 手工挑 26 个字段,建议改 BRIEF_KEYS 常量派生;
4. hub.Publisher 的 except Exception: pass 静默吞异常(违反项目 contextlib.suppress 约定且无痕迹);
5. client.pause() 手工 merge paused 疑似冗余(回执 brief 已带 paused 且 _action 已 apply)。
- 未动手改,等用户决定优先级。
## 2026-09-19 结构审查 5 项优化已全部落地(+88/-76,7 个文件)
1. engine.py 新增 tick_decision() 纯函数,Scheduler 与 FarmClock 共用(原两处重复的暂停顺延+deadline 逻辑合并)。
2. actions.py 移除 save/reset 表项(服务端死代码隐患),注释说明存档类动作只能走 app.execute_action 会话拦截;/api/save、/api/reset 便捷路由随之消失,POST /api/actions {action:save} 仍可用。
3. app.py brief() 改为 BRIEF_KEYS 常量派生。
4. hub.Publisher 吞异常改为 errors 计数,/api/health 新增 publish_errors 字段。
5. client.pause() 删除冗余手工 merge,收敛为纯转发。
- README 动作清单与便捷入口数(13→11)同步更新。
- 门禁:ruff format/check、pyright、97 项测试全绿。
- 教训:对同一文件连续 Edit 时 old_string/new_string 写反了两次(一次添加了重复定义、一次误删 def 行),靠重读文件恢复。批量改动应先用幂等补丁脚本。
## 2026-09-19 实时通道从 WebSocket 整体迁移到 SSE
- 决策过程:用户先问「HTTP 能双向吗」「HTTP/2 也不行吗」,讲清 Server Push 已废弃、
H2 双向流等于 gRPC(本项目已否决)后,用户拍板「换 SSE,删掉 WebSocket」。
- 服务端:hub.py 改为 SSE 订阅池(新增 sse_frame / sse_heartbeat / CLOSE_MESSAGE /
close_all);app.py 用 GET /api/stream(StreamingResponse)替换 /ws,删掉全部 WS 分支;
生成器每秒空转一次以便发现停机、按 15s 发心跳注释。
- 客户端:client.py 删掉 asyncio/websockets/Future 回执机制,改为 urllib 流式读 SSE
(mode: http/sse/poll),写操作统一 HTTP,新增 _underlying_socket() 用于立即断流。
- 网页版 webui.py 改 EventSource + fetch POST;tools/ws_demo.py 换成 tools/sse_demo.py。
- 测试:WS 用例改写为 SSE,新增 test_sse_stream_speaks_the_protocol(裸流验协议)
与 test_writes_go_over_http,98 项全绿,耗时回到 5.2s。
- 踩坑(重要):SSE 长连接会拖死优雅停机,三处一起修才干净:
1) 停机路径 hub.close_all() 发 bye 让生成器自己 break;
2) uvicorn Config 加 timeout_graceful_shutdown=5 兜底;
3) 客户端 stop() 必须 shutdown 底层 socket——直接 response.close() 会等读线程的锁,
最长卡到下一次心跳(15s)。实测:带长连接停机 0.2s 完成。
- 文档:README 第 6 节重写为 SSE(含 curl/urllib/EventSource 示例)、架构图与结构表同步,
pyproject 描述与关键词更新,main.py/ai_player.py/__main__.py 帮助文案更新。
- 新增 env 依赖说明:uvicorn[standard] 仍会捎带 websockets,但项目已不再使用。
## 2026-09-19 ASGI 服务器从 uvicorn 换成 Hypercorn(拿到 HTTP/2)
- 依赖:`uv add hypercorn` / `uv remove uvicorn`(hypercorn 0.18 自带 h11/h2/wsproto),
requirements.txt 重新导出,pyproject 依赖注释改写。
- `__main__.py`:`hypercorn.asyncio.serve` + Config(bind/loglevel/accesslog/
graceful_timeout=5,语义等同 uvicorn 的 timeout_graceful_shutdown);shutdown_trigger
用 asyncio.Event,由 `handle.on_stop` 置位 —— 于是 POST /api/admin/shutdown 与
Ctrl+C/SIGTERM/SIGBREAK 走完全相同的收尾路径;第二次信号强制退出(os._exit(130))。
- `app.py` 新增 `as_asgi()`:Hypercorn 的 `hypercorn.typing.Scope` 与
`starlette.types.Scope` 两个 TypedDict 互不兼容,pyright 会在 serve() 处报错,用返回
Any 的桥函数收口(一处注释说明)。
- **踩坑 1(真 bug,早于本次改动)**:`clear_pid_file` 里"文件还开着就 os.remove",
Windows 抛 PermissionError(WinError 32),又被 `except OSError` 吞掉 →
每次正常退出都留下 stale pid 文件。改为先读完关句柄再删,并补 3 条回归测试。
- **踩坑 2**:Windows 上 SO_REUSEADDR 让第二个实例能绑上已监听端口(还会抢走新连接),
Hypercorn 因此不会报"端口占用"。新增 `daemon.port_busy(host, port)` 公用函数
("能不能连上"而不是"能不能 bind"),入口启动前自检,占用则退 3 并提示换端口;
`DaemonManager.port_busy()` 改为委托它,并补了测试。
- 测试:HAS_UVICORN→HAS_HYPERCORN;TestLiveServer 用 hypercorn serve 起后台线程、
靠 shutdown_trigger 收尾;新增 TestServerEntry(4 项)与 test_port_conflict_is_refused,
共 103 项 / 5.2s。
- 文档:README 第 6 节补 HTTP/2 说明、第 8 节补"lifespan 早于 bind"的 pid 细节、
第 12 节加"为什么是 Hypercorn(而不是 uvicorn)";测试清单按实际类重新计数。
顺手补掉上一轮 SSE 迁移漏掉的两处文案(tui.py 帮助页、farm/__init__.py 分层说明)。
- 实测(真实子进程):h2c 用 h2 库裸说协议 200;h2c 上读 SSE 正常;写操作→推送
0.08~0.15s;挂着长连接停机 0.08s 且收到 bye;SIGBREAK 停机 0.07s;
daemon start/status/stop 全通(stop 1.12s);端口冲突退 3 并给出提示。
## 2026-09-19 ASGI 服务器换回 uvicorn(HTTP/2 由 zttp 提供)
- 决策:用户要求「换回 uvicorn 并且安装 h2 支持并启用」。关键发现:uvicorn 0.53 自己不再
内置 h2,它的 HTTP/2 实现建立在 `zttp` 包上(`uvicorn/protocols/http/zttp_h2_impl.py`;
另有 `auto_zttp_impl.AutoZttpProtocol` 按 ALPN / h2c 前导字节自动分流 HTTP/1.1 与 h2)。
启用方式 `Config(app, http="zttp", http2=True)`;没装 zttp 时 `Config.load()` 直接 raise。
- 依赖:`uv remove hypercorn`(连带 h2/hpack/hyperframe/wsproto/priority);
`uv add "uvicorn[standard]" zttp`——HTTP/2 能力保留,[standard] 仍捎带 websockets(未使用)。
- `__main__.py`:uvicorn.Server + `Config(http="zttp", http2=True, timeout_graceful_shutdown=5)`;
信号接管改为**子类覆盖 `Server.handle_exit`**(0.53 没有 install_signal_handlers),
不调 super()、不写 `_captured_signals`(避免退出时重放信号),第二次信号 `os._exit(130)`。
服务器子类写在 `main()` 内部,因为 uvicorn 是延迟导入的(离线玩法不该被迫装它)。
- 修正一处历史错误注释:uvicorn 0.53 的启动顺序**也是**先 lifespan 再 bind 端口。
- 测试:`HAS_HYPERCORN` → `HAS_UVICORN`;TestLiveServer 用 `uvicorn.Server.run()` 起后台线程
(非主线程 → capture_signals 自动跳过,不会劫持测试进程的信号),停机走 `request_stop()`。
顺带删掉测试里已无用的 `import asyncio`。103 项全绿(6.0s)。
- 实测(真子进程,非 mock):h2c `GET /api/state` → `:status 200`(用 h2 库裸说协议,
本机 curl 没编进 HTTP/2);同一条 h2c 连接上读 SSE 收到 welcome;HTTP/1.1 写操作 →
推送到达 0.10s;**挂着 2 条长连接停机 0.52s**(长连接收到 bye);SIGBREAK 停机 0.49s;
端口冲突退 3 并提示换端口;守护进程 start/status/stop 全通;pid 文件每次都清理干净。
- 文档:README 第 6 节与第 12 节重写(uvicorn + zttp 的启用法,附配置片段)、结构表、
测试清单同步;pyproject 依赖注释说明为什么需要 zttp;requirements.txt 重新导出。
## 2026-09-19 修复 TUI 键盘监听(方向键/空格/Tab/ESC 全失效)
- 症状:只有字母键和数字键有反应,方向键、空格(暂停)、Tab(切牲畜)、ESC(关菜单)全都没用。
- 根因:`farm/keys.py` 的具名键返回**大写**(`"UP"`/`"SPACE"`/`"TAB"`/`"ESC"`),
而 `farm/tui.py` 的处理表里写的是**小写**(`"up"`/`" "`/`"tab"`/`"esc"`),
`handler.get(key)` 永远取不到 → 静默无动作。字母键因为 `char.lower()` 恰好对得上才没事。
- 修法(不是改一处字符串,而是把约定固化下来):
1. `keys.py` 定一套小写具名键常量 + `NAMED_KEYS`,两条平台路径都返回这套名字;
2. `tui.py` 全部改用 `keys.XXX` 引用,pyright 能直接查出拼写错误;删掉 `"tab\t"` 这个历史 hack;
3. 顺手修掉 POSIX 路径的老毛病:一次 `os.read` 读到多个字节时只取第一个、其余静默丢弃
(快打/粘贴会丢键);现在带缓冲区逐个返回,并能正确处理被拆成两次读到的转义序列与半个汉字;
补上 `ESC [ 3 ~`(Delete)等序列;单按 ESC 与序列开头用 20ms 等待区分。
4. 新增 `tests/test_tui_keys.py`(19 项):逐个具名键驱动真实 TuiApp 做**行为**校验,
外加"两端映射表必须落在同一套名字里"的约定校验——正是它抓出了 POSIX 缺 Delete 的不一致。
5. 读不到键盘时(管道/非 Win32 控制台,如 Git Bash 的 mintty)TUI 底部直接显示原因,
不再让玩家对着界面干等;预览工具用 `show_keyboard_notice=False` 保持按键提示。
- 门禁:ruff / pyright 零报错,测试 103 → **122 项**全过。
## 2026-09-19 一天改成 40 分钟 + 移除「休息」动作(纯放置)
- 用户反馈两条:① 键盘监听不对(方向键/空格/Tab/ESC 全失效);② 15 秒一天太快。
问清目标后拍板:**一天 40 分钟**、**「休息」直接去掉**(只剩时间自然流动)。
- 节奏:`DEFAULT_TICK_SECONDS` 15 → 2400;`MAX_TICK_SECONDS` 600 → 86400
(旧上限会把 40 分钟夹回 10 分钟,这是必须先拆的硬门槛)。
- 移除 sleep 的全部触点:`engine.sleep()`、`config.SLEEP_COOLDOWN`、`_last_sleep`、
`client.RemoteFarm.sleep()`、`server/actions.py` 的注册、TUI `[N]休息` 与帮助文案、
网页版按钮与文本清单、`app.rules_text()` 给 AI 的说明、README、`main.py` 提示、
`tools/sse_demo.py`(改成挑 welcome 里的第一个合法动作,不再写死动作名)。
- 机器人:`rest()` → `wait_for_next_day(timeout)`,`run()` 新增 `wait_seconds`(默认 0 = 不干等,
直接收工);顺带删掉再也不会出现的 cooldown 特例。CLI 加 `bot --wait`。
- 显示:新增 `farm.models.format_duration()`,40 分钟显示成 `40:00` 而不是 `2399.6s`
(TUI 头部 / `main.py state` / 网页版 JS 同名函数)。
- 顺手修:`FarmSession.__init__` 立刻设 `next_tick_at`,消除新建牧场后 ≤0.2 秒内
倒计时为 None(客户端会误显示"已暂停/手动模式")的空窗。
- 测试:删 `test_sleep_cooldown`,加 tick 夹取/默认值/`sleep` 已移除、`format_duration`
分档、`tests/test_bot.py`(等待策略)。103 → **131 项**全绿,ruff/pyright 零报错。
- 端到端实测(真服务器 + 真 CLI):动作表只剩 10 个(无 sleep)、`POST sleep` 返回
`unknown_action`、`seconds_to_tick=2400.0`、`main.py state` 显示「39:59 后进入下一天」。
## 2026-09-19 删除全部测试套件(tests/ 与所有引用一并清理)
- 用户要求「删除全部 test」;确认后选:删目录 + 清理全部引用、**不留备份**。
- 删除 `tests/`:test_engine / test_server / test_tui_keys / test_bot(共 131 项)+ `tests/_tmp/`。
- 引用清理(一次性幂等补丁脚本,跑完即删):
pyproject 的 ruff `src`、per-file-ignores `"tests/*"`、pyright `include`/`exclude`;
`.gitignore` 的 `tests/_tmp/`;`main.py` 帮助里的测试命令;`farm/keys.py` docstring 的引用;
README 目录树的三行 + 整节「10. 测试」(后续章节 11/12 顺延为 10/11)。
- 依赖:`uv remove --dev httpx`(连带 httpcore / certifi)——它只是 FastAPI TestClient 的底座,
没有测试就没人用它;uv.lock 同步。requirements.txt 不动(本来就只导出运行依赖)。
- 门禁:ruff check / format 通过,pyright 0 错误(25 个文件)。
- 代价与风险:未跟踪的 test_tui_keys.py / test_bot.py 就此永久丢失(未提交,用户明确选择不备份);
键盘约定的保障从"行为测试"退化为"改 keys.py 时人工核对 tui.py 的引用"。
README 第 307 行提到 httpx 是 HTTP/2 客户端写法示例,与依赖无关,保留。
## 2026-09-19 重建测试套件(500 项)+ 修两处 client 隐患
- 用户要求「完成测试代码」;确认后选:**完整重建**覆盖,但**只写测试文件**
(不恢复上一轮剥掉的工程配置:tests 仍不进 ruff `src` / pyright `include` / per-file-ignores,
`httpx` 保持移除)。
- 新增 6 个文件、**500 项**:test_engine 110 / test_actions 35 / test_server 131(skip 35) /
test_tui_keys 75 / test_bot 36 / test_client 113。
- skip-guard 模式:`try: import fastapi/httpx/uvicorn ... HAS_X = True except: False` +
`@unittest.skipUnless`。没装 httpx → `TestClient` 与真机 `LiveServer` 那 35 项自动跳过,
其余 465 项照跑;所以 `uv run python -m unittest discover -s tests` 是**真绿**而不是靠跳过伪装。
- `test_server.LiveServerTestCase` 起真 in-process uvicorn(`http="zttp", http2=True`,绑自由端口),
用 `RemoteFarm` + 裸 SSE 读取做端到端;`test_client.HttpTransportTestCase` 起**纯标准库**
`http.server` 回环桩(不依赖第三方),顺带证明 `net.open_url` 的回环直连真的生效。
- 顺手修 `farm/client.py` 两处(是测试逼出来的真隐患,不是测试写错):
1. `_normalize()` 原来**先 `rstrip("/")` 再补 scheme**,于是 `"http://"` → `"http:"` →
再拼成 `"http://http:"`;地址没有主机名 → `is_loopback(None) == False` → 请求走系统代理 →
报一个和真实原因无关的 502。改成**先补 scheme 再削尾部斜杠**,畸形端点
(`''` / `' '` / `'//'` / `'http://'`)统一回落 `DEFAULT_ENDPOINT`。
2. `StateView.__init__` 预置 `plots` / `animals` 为空列表:原先 `apply({})` 提前返回,
空视图**根本没有这两个属性**,`autostart=False` 或服务器连不上时读 `state.plots`
直接 AttributeError(本该显示一片空农田)。
- 写测试踩到的坑(记下来省下次):
- **bytes 字面量不能含非 ASCII**:`b"<html>不是 JSON</html>"` 直接 SyntaxError,
必须 `"...".encode()`(ruff 还会顺手提示 UP012 去掉多余的 `"utf-8"`)。
- `OpenerDirector.add_handler` **只在"至少挂上一个钩子"时才把 handler 记进 `handlers`**;
`ProxyHandler({})` 一个 `*_open` 都不注册 → 它自己反而不在 `handlers` 里。
这**正是**回环直连生效的机制,不能想当然断言"handlers 里应该有个 ProxyHandler"。
- 门禁:`ruff format .` 只动了 6 个测试文件(源码本来就干净)→ 但 `ruff check .` 一开始报
9 条(全在 `tests/`,因为没有 per-file-ignores)。既然不加配置,就把**测试文件本身改干净**
(SIM117 合并 with / RUF012 用 `ClassVar` / RUF100 删无效 noqa / UP012 / F401),
现在 `ruff check .` **All checks passed** + pyright **0 errors** + format 31 文件 unchanged。
- 收尾:6 个测试文件全部 LF(CRLF=0)。
## 2026-09-19 代码优化:结构拆分 + 热路径 + 消重(benchmark-first)
- 用户要求「优化代码」,勾选三项:结构拆分 / 性能热路径 / 消除重复逻辑(明确不要"只出报告")。
- **先量后改**:新增 tools/bench.py(ASGI 直调,不走 httpx;warmup + best-of-K),
并给 to_dict / snapshot / brief_source / net_worth 装调用计数器。它同时是**守卫**:
以后谁再把 brief 路径改回 snapshot(),计数会立刻变红。
- 热路径根因:brief()/summary()/动作回执本来只要 ~17 个标量,却调 session.snapshot()
做**整局序列化**;GameState.to_dict 内部还重复算 3 次 net_worth(每次访问都重算属性)。
- 改法:
1. engine.brief_source() —— 只读标量的新方法(不碰 snapshot);session.summary()、
app.brief() 改用它,BRIEF_KEYS + 新增 BRIEF_TODO_KEYS 白名单派生。
2. models.next_goal_after(index) 提为模块级;to_dict 里 net_worth 只算 1 次,复用于
net_worth / goal_index / next_goal(删掉 next_goal property)。
3. 结构:create_app 475 行 → 32 行(抽出 _Services dataclass 持 8 件接线 + 8 个模块级
_register_* 函数,内嵌 lifespan 生成器变成 _Services.lifespan 方法);
main.build_parser 拆 server/client 两组;engine.available_actions 拆
_plot_actions/_animal_actions/_investment_actions;__main__.main 160 → 7 行
(_resolve_save_dir / _start_daemon / _print_startup_banner / _ensure_port_free /
_run_foreground,uvicorn 那段含内嵌 FarmServer 逐字平移)。
4. 消重:engine 的"等级门槛"规则重复 5 处 → _is_locked/_require_level/_newly_unlocked;
hub.broadcast/broadcast_all/close_all 收敛到 _push_many;daemon.probe/world →
_get_json;main.py 的 _add_endpoint_args / _leaderboard_lines;app.py SSE 的
notice(text, exclude) 让"来了/走了"共用一条路径。
- 结果(16 地 + 8 牲畜终局,5 轮最快):brief 63.6→15.6µs(-76%)、summary 60.2→14.0µs(-77%)、
leaderboard(32) 1981.7→454.8µs(-78%)、snapshot 57.3→45.0µs(-21%)、
GET /api/world 193.6→132.3µs(-31%)、POST full=1 464.7→392.9µs(-16%)、
GET /api/actions 398.3→335.1µs(-16%)。动作回执的序列化次数 to_dict 2→1、
snapshot 2→1、net_worth 6→1。>60 行的函数 15 个 → 8 个。
- 门禁:ruff format/check 全绿、pyright 0 错误、**500 项测试 OK(skip 35)**。
bench 的 ASGI 直调**不跑 FastAPI lifespan**,所以 lifespan 的活改造用
test_server 里的真 in-process uvicorn(LiveServerTestCase)单独验。
- 临时补丁脚本一律 read_bytes/write_bytes 保 LF,跑完即删(本次 4 个:_splice_app /
_dedup_engine / _dedup_main / _split_entry)。
## 2026-09-19 发布到自建 Gitea(gitea.4i.hk)并推送
- 用户要求「放到 gitea.4i.hk 创建 public 仓库」。结果:**https://gitea.4i.hk/hhhge/SimpleFarm**
(public,默认分支 main,账号 hhhge),本地 origin 已设好并推送成功。
- 建仓用 `tea repos create --name SimpleFarm --description ... --login gitea.4i.hk`
——注意 tea **没有 `--public` 参数,默认就是公开**。
- 踩坑:`git push` 一开始被拒(`Failed to authenticate user`)。排查结论:
1. git 配置里 `credential.https://gitea.4i.hk.provider=generic`,GCM 不是走 OAuth 设备码,
而是走 generic provider 的交互式凭据;非交互 shell 下 GCM 直接拒绝(`Cannot prompt
because user interactivity has been disabled`)。
2. **解法:`GCM_INTERACTIVE=always git push`** —— GCM 会用 Windows 凭据管理器里那条
`refresh_token.gitea.4i.hk` 静默续期,不弹窗、不用手输,10 秒推完。
3. 中途误以为需要 token:`tea` 的 OAuth token 能读 API(`/user` 200,账号 is_admin=true),
但 **不能签发访问令牌**(`POST /users/hhhge/tokens` → 401 auth required,Gitea 防提权)。
顺便探到它**可以加 SSH 公钥**(`POST /user/keys` → 201),虽然最后没用上。
探测时用假 key 试出来的 key 已即时删掉(`DELETE /user/keys/1` → 204,`/user/keys` 回到 `[]`)。
- 另一个坑:推送后 `git status -sb` 一直显示 `## main...origin/main [gone]`,但 `git ls-remote`
明明能看到 refs/heads/main。原因是沙箱把 `.git/refs/remotes/**` **新建目录**的写入跨命令丢了;
在同一条命令里 `mkdir -p .git/refs/remotes/origin` + 写 ref 文件再跑 status 就正常了。
最后一次核对:`git status` → "up to date with 'origin/main',working tree clean"。
- 远端内容已用 API 核对:3 个提交齐全(450655b / f8e093c / 47dd818)、根目录 13 个条目
(.gitattributes / .githubignore / README.md 25KB / farm/ / tests/ / tools/ / uv.lock / pyproject.toml …)。