车东 Che, Dong

Mon 02 November, 2026

21:49 Merge pull request #266 from chedong/fix/install-sh-260-265 » Recent Commits to phpman:master
Merge pull request #266 from chedong/fix/install-sh-260-265

fix(install): harden installer updates, webroot deploys and data ownership
21:46 fix(install): port installer hardening onto the PHPMAN_HOME layout » Recent Commits to phpman:master
fix(install): port installer hardening onto the PHPMAN_HOME layout

Rebased onto master's current installer (src/ + PHPMAN_HOME + cli/).
Addresses the still-applicable findings from #260-#265:

- persist webroot/no-server/data-owner in .git/phpman-install-state so
  --update re-deploys without repeating flags (#261)
- add --data-owner/--cache-owner, chown data dirs and existing files,
  verify writability and source readability (#262)
- patch PHPMAN_HOME into the deployed copy with var_export instead of
  sed, write it atomically, and stop patching the tracked clone copy so
  git pull --ff-only works again (#263)
- rewrite argument parsing: --opt=value, missing-value/unknown/relative
  path/non-numeric port errors, --no-start alias (#265)
- pipefail, explicit clone/pull failures, post-clone checks, php -l and
  a non-empty target guard (#260)

#264 (mbstring/FTS5) is already fixed on master and is left as-is.

Tested with stubbed php/git: fresh install, fresh webroot + data owner,
state-driven update, reindex, failed pull, dirty file handling, missing
extensions and argument validation.

Co-Authored-By: Claude code 2.1.309 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
19:16 fix(json,cache): 非法 UTF-8 不再产生 0 字节 JSON;负行不再被每次请求续期 » Recent Commits to phpman:master
fix(json,cache): 非法 UTF-8 不再产生 0 字节 JSON;负行不再被每次请求续期

两项来自 /code-review (max) 的 findings(#3 已由上游 9f79615 修掉,不重复)。

## noMatchNotice() 的 0 字节 JSON

`$parameter` 直接来自 URL,`normalizeParameter()` 不要求合法 UTF-8。对
`/pydoc/%FF/json` 这类请求,`json_encode()` 返回 false,而 `: string` 返回类型
把 false 强转成 '' —— 于是 404 带 `Content-Type: application/json`、正文
**0 字节**。这正是 #252 加这个 notice 要消灭的「空 JSON」,只是从另一条路径
又回来了。

两个 json_encode 调用都加上 `JSON_INVALID_UTF8_SUBSTITUTE`(PHP 7.2+,本项目
下限 8.1,安全):非法序列替换成 U+FFFD,正文仍是合法 JSON、`results` 仍是
空数组。新增 test/unit/test_no_match_notice.php(12 条)。把 util.php 退回
旧版本再跑,**9 passed,3 failed** —— 三条失败正是 0 字节/非法 JSON 那组。

## man/perldoc/info 的负行每次请求都被续期

三个分支在内容为空时都写:

    $cache = new PageCache();
    $cache->set($mode, $name, $section, $format, null, 'not_found');

而缓存命中一条 not_found 行时 `cacheOrExecute()` 也返回 '',于是**每个请求都
再写一次**。`set()` 是 UPSERT,会刷新 updated_at —— 1 天的 TTL 永远走不完,
一次瞬时失败变成永久 404(crawler 只要还在访问就一直续期)。

新增 `cacheNotFoundOnce()`:行已存在就什么都不做,让 TTL 的含义回到
「距第一次未命中一天」,而不是「距最后一次请求一天」。已有 found 行同样不动
(既不会被降级,时间戳也不变)。三个调用点改为使用它。fetchRow 本身会在
TTL 过期时删除并返回 null,所以过期后下一次请求会正常重写。

test_negative_cache_body.php 增加 6 条断言:只写一次、二次未命中不改
updated_at、found 行不被降级;并加了一条**对照断言**钉住旧行为 —— 直接
`set()` 确实会把 updated_at 往后推,正是被替换掉的那个 bug。

## 不做 RENDERER_VERSION bump

notice 是在 cache 写入之后加的,负行里存的仍是空正文,措辞每次请求现算;
TTL 修复不改变任何成功渲染的字节。两者都不需要失效已有缓存。

验证:make test 通过;php test/run_all.php 751 passed, 0 failed。

Co-Authored-By: Claude code 2.1.309 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>

Sun 01 November, 2026

00:18 fix(cache): 负缓存守卫改按 status 判断,不再让 FTS 正文绕过它 » Recent Commits to phpman:master
fix(cache): 负缓存守卫改按 status 判断,不再让 FTS 正文绕过它

`PageCache::set()` 的守卫原本是:

    if ($status === 'not_found' && ($content === null || $content === '')) { ...保护已有的 found 行... }

「只在正文为空时保护」这个前提在 #247 之后不成立了 —— 超时的 `pydoc3 -k` 现在会
退到 FTS 索引并用它产出一个**非空**正文(#248),于是守卫被跳过,UPSERT 会用
not_found 行覆盖掉并发的 found 行。

竞态窗口很大,因为兜底本身要跑 **20 秒**:

  A: 页面渲染失败 → 进兜底扫描
  B: 同一名字渲染成功 → 写入 found 行
  A: 20 秒后写 not_found 行(带 FTS 正文)→ **覆盖 B 的 found**

后果是一个合法页面的缓存渲染结果被换成「未找到 + 搜索结果」,持续 10 分钟。
守卫的注释写的正是这个场景("Any transient content-generation failure can race
a reader that has already cached the good result")—— 现在它防不住了。

修法:只按 status 判断,不再看正文是否为空。不会误伤合法的负到负覆盖 —— 已有的
not_found 行经 get() 返回哨兵,isNotFound() 为真,内层条件不成立,写入照常。

新增 5 条回归断言,并**验证过它们在旧守卫下失败**(found 行被替换成
`###NOT_FOUND###`)、在新守卫下通过。738 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>

Thu 29 October, 2026

15:32 docs: 新增 09-QUALITY-GATES — 四层验证、原始材料、G1–G9 门 » Recent Commits to phpman:master
docs: 新增 09-QUALITY-GATES — 四层验证、原始材料、G1–G9 门

把本轮复盘整理成可执行的质量门设计:L0 语法/单测 → L1 行为契约
(RenderResult)→ L2 参考实现差分(groff/pydoc/perldoc/ri)→ L3
外部标准/第三方(W3C/WCAG/RFC/MCP、外部监控与扫描)。

同时记录「自证不够」的四次实例(opcache 装死、realpath 300s 理论、
#249 的慢渲染前提、#251 的 logcheck 撒谎)与原始材料清单;路线图
对应 issue #253–#257,并在 00-INDEX / 08 里建立交叉引用。

Wed 28 October, 2026

07:41 fix(pydoc,config): 超时不再丢弃 FTS 兜底正文;配置模板不再引用未定义常量 » Recent Commits to phpman:master
fix(pydoc,config): 超时不再丢弃 FTS 兜底正文;配置模板不再引用未定义常量

两处来自 /code-review (max) 的高优先级 findings。

## pydoc 超时丢弃已算出的结果

`getPydocSearchPage()` 在 `pydoc3 -k` 超时(exit 124)后会退到 FTS pydoc 索引,
并且**可能已经拼出真实结果**。但调用点写的是:

    $body = getPydocSearchPage(..., $timedOut);
    return $timedOut ? null : $body;

于是这份正文被丢掉,`cacheOrSearchFallback()` 收到 null,写入空负行,页面告诉
用户「什么都没匹配」—— 而匹配其实刚算出来。

`null` 同时承载了「没有正文」和「无法确定」两种含义,所以无法表达
「有正文,但结论不确定」。给 `cacheOrSearchFallback()` 增加第 8 个引用参数
`$indeterminate`:

    if ($content !== null && !$indeterminate) {
        $cache->set(..., $content, 'not_found');          // 确定未命中,24h
    } else {
        if (没有行) $cache->set(..., (string)$content, 'not_found',
                                PHPMAN_CACHE_TTL_TIMEOUT); // 不确定,600s,带正文
    }

调用点的闭包把 `$timedOut` 写进 `$indeterminate` 并原样返回 `$body`。ri 调用点
仍传 7 个参数,`$indeterminate` 默认 null,行为不变。

效果:超时但有 FTS 结果时,用户看到建议列表(仍 404,因为无法确认页面存在),
行按 600s 过期而不是 24h,也不会把「暂时算不出来」冻成一天。

## 配置模板的隐藏雷

`phpman.config.php.example` 里 `PHPMAN_MF_LOG_FILE` 原本写成:

    define('PHPMAN_MF_LOG_FILE', PHPMAN_LOG_DIR . '/phpman_mf.log');

但用户配置在 `src/config.php` 定义派生路径**之前**加载,单独取消注释这一行会
让每次请求在 bootstrap 抛 Undefined constant。改成字面路径,并在
PHPMAN_HOME 段写明这个约束(派生常量不能在 user config 的右侧使用)。

没有改加载顺序:派生路径必须在用户配置之后定义,否则 `PHPMAN_LOG_DIR` 等
覆盖项会失效。约束写进模板是正确做法。

## 不做 RENDERER_VERSION bump

受影响的只有 pydoc 超时兜底页,而它写入的是 600s 负行;旧行 10 分钟内自然过期。
bump 会让全部 210 天的 found 行一起失效,对一个错误路径的修复不成比例。

## 验证

make test 通过;php test/run_all.php 728 passed, 0 failed。

把两个代码文件退回 HEAD~1 再跑本测试:**45 passed, 1 failed** —— TTL 那条准确
抓到旧行为(旧代码把带正文的负行按 24h 写入)。正文被丢弃的那一半在
phpMan.php 的内联闭包里,要真实 `pydoc3` 超时才会触发,单测没有覆盖;它由新的
返回契约(返回 body + 独立 `$indeterminate`)保证,并靠代码审查确认。

Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>

Tue 27 October, 2026

00:38 docs: 按 code-review (max) 结果同步 CHANGELOG 与设计文档 » Recent Commits to phpman:master
docs: 按 code-review (max) 结果同步 CHANGELOG 与设计文档

对最新代码做了一轮 15 项审查(全部核实,见 /code-review 输出),本次只更新
文档,不改代码行为。

## CHANGELOG [Unreleased]

- 新增 #248 条目:`phpManExec()` 给所有渲染子进程加上界
  (`PHPMAN_CMD_TIMEOUT` 15s),`pydoc3 -k` 单独用 `PHPMAN_PYDOC_K_TIMEOUT`
  20s,超时负行用 `PHPMAN_CACHE_TTL_TIMEOUT` 600s(只写一次,不续期)。
- 新增 #249 条目:logcheck 5xx 分族 + 打印窗口范围 + pairing source;
  `LOGCHECK_DAYS` / `LOGCHECK_RECENT_LINES` 现在真的通过 make 传进 ssh。
- 修正三处与已上线行为矛盾的旧文案:
  * 遥测落盘从 `phpman_error.log` 改为 `phpman_mf.log`(#251 已迁移);
  * `PHPMAN_MF_LOG_MS=0` 不能单独关掉遥测 —— 两个阈值是 OR 关系;
  * pydoc 超时不再"什么都不写",而是写 600s 短 TTL 行。

## 设计文档

- 03-CACHE:补 600s 超时 TTL、带正文的负行 (`getWithStatus()`)、
  "超时行只写一次" 三条;原文只写了 24h not_found 和无正文负行。
- 04-SEARCH:pydoc 兜底不再无条件跑 `pydoc3 -k`(#247 对含点名字跳过、
  超时退 FTS),新增 §4.6 说明,并订正 5 处流程图/优先级表。
- 01-PRODUCT:同步 `getPydocSearchPage` 的裸词 / 含点名字差异。
- 05-PLAN:`RENDERER_VERSION` "currently 2" → 3。

## 配置模板

- `phpman.config.php.example`:
  * TTL 说明改为当前行为(search 负行用 found TTL;超时行 600s;
    删除已随 emoji 层消失的 "never expires");
  * 补 `PHPMAN_CACHE_TTL_TIMEOUT` 与 `PHPMAN_PYDOC_K_TIMEOUT`;
  * `PHPMAN_MF_LOG_FILE` 那行原本以 `PHPMAN_LOG_DIR` 拼接,但用户配置在
    `src/config.php` 定义派生路径**之前**加载 —— 单独取消注释会让每次请求
    抛 Undefined constant。改为字面路径并写明原因。

## 另修:10e2dc3 引入的两条错误警告

该提交称 "整条 v3.7 tag 链不在 master 上 / merge-base 为空" 与
"本地与远程 tag 对象不同"。实测均不成立:

  git merge-base master v3.7.12        → 4ab3aea(非空)
  merge-base --is-ancestor v3.7.12     → 成功(b7037a5f…4ab3aea 全在 master)
  git rev-parse v3.7.12 / ls-remote    → 同为 4ddc556 → 4ab3aea

改为陈述核实结果;保留"旧 checkout 的 tag 可能未更新"这个真实隐患
(73cab20 / 016b815 仍在对象库),并给出 `git ls-remote` 的核对方法。

验证:make test 通过;php test/run_all.php 720 passed, 0 failed;
所有代码块围栏配平;docs/ 相对链接全部可解析。

Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>

Sun 25 October, 2026

04:05 OpenClaw Telegram /update 升级失败:update-recovery-pending(Package publication journal does not match its installation)与 Node 26.12.1 版本门(2026.10.5→2026.10.7) » 学习笔记|Tech Notes on 笔记 by 车东

在 Telegram 发 /update 升 2026.10.7,bot 回复"已提交"但版本没动。日志只有 update.run … status=skipped 和 update-recovery-pending: Package publication journal does not match its installation。清掉 10-19 遗留的孤儿 journal 后,又卡在 Node 版本门(本机 26.10.0 < 2026.10.7 要求的 26.12.1);换成 Node 26.12.1 后升级成功,但 LaunchAgent 被 bootout 没重新拉起,openclaw gateway install --force 恢复。最后 journal 停在 publication-complete,要跑一次 recovery.mjs retire 才算真正收尾——否则下一次 /update 还会被拦。

📌 TLDR

  1. /update 什么都没做。它在准入闸门 assertNoPendingPackageActivation() 就返回了,日志表现为 update.run … status=skipped。闸门的判据是 readPackageActivationContinuation(),只有两种情况放行:
    • journal 不存在 且 <anchor> 与 <anchor>.control 都不存在;
    • 或 isPackageActivationComplete() 成立 —— phase === "anchor-retired" 且有 unlink-helper intent。
  2. 真正拦住它的是设备号漂移,不是 inode。matchesInstallation() 只比对 parentIdentity / journalParentIdentity / journalIdentity 三个 device:inode 字符串。10-19 那份 journal 记的是 16777220:*,APFS 卷重挂后实际是 16777221:*,inode 全对也没用 → inspectRemount() 抛 Package settlement inode or device mapping changed.。openclaw update repair --yes 救不了。
  3. 清掉孤儿 journal 后暴露出第二层:2026.10.7 的 engines 是 >=24.22.1 <25 || >=26.12.1,本机 Node 26.10.0 不满足,package-install 跑 scripts/preinstall-package-manager-warning.mjs exit 1,最终 global-install-failed。--omit=optional 重试同样失败(卡的不是可选依赖)。
  4. 升级成功 ≠ 服务起来。activating 完成后自动重启失败(state-migrated-no-rollback、Gateway did not settle),LaunchAgent 被 bootout 且没有重新 bootstrap。openclaw gateway install --force 一行恢复。
  5. 每次升级后都要看 openclaw update status 的 Package recovery。若是 publication-complete / aborted 而不是 complete,下次 /update 必挂;recovery.mjs repair 是空操作,retire 才有效。

现象

在 Telegram 里发 /update,bot 回得挺痛快,但 openclaw --version 一直是 2026.10.5。

Fri 23 October, 2026

02:21 docs(plan): 落实 code-review 5 项订正——v3.7.9–3.7.12 段的历史准确性 » Recent Commits to phpman:master
docs(plan): 落实 code-review 5 项订正——v3.7.9–3.7.12 段的历史准确性

/code-review (high) 对 9fd077c 的 5 个 findings 逐项核实后修订:

1. 本地/远程 tag 对象不同:本地 v3.7.11→73cab20、v3.7.12→016b815,
   远程回填版→df83482/4ab3aea,git fetch 不覆盖已有 tag。补 ⚠️ 括注
   写明两条 commit 各是什么、核对用 git ls-remote <tag>^。
2. 遗漏主要变更:Fix #129 (part 1–6) macOS apropos 系列与
   --build-index 清页缓存(016b815)补进 bullet。
3. 拓扑是孤立历史:git merge-base master v3.7.12 为空——整条 tag 链
   (含 3.7.13–19)都不在 master 上,是 reset/rebase 后废弃的支线。
   补 ⚠️ 括注,读历史要按 git log <tag> 走。
4. "tag 日期到此为止"与 v3.7.20/21 矛盾:它们打于 06-17,比 v4.0
   (06-16)还晚一天。改为「本节时间线只覆盖这五个 tag」,并写明
   20/21 是 batch_enhance 过渡 tag(v4.10 已移除)。
5. 顺序图省略 commit:73cab20..4ab3aea 区间实有 15 个提交,补括注
   两个未标记的 SEE ALSO 修复(5d3c34b/7ebbf16)+ merge + README
   截图更新(1fec571)。

纯文档改动,不动代码,不涉及渲染输出。

Co-Authored-By: Claude Code 2.1.308 with deepseek-v4-pro <noreply@taotoken.net>

Wed 21 October, 2026

08:37 docs(plan): 订正 v3.7.9–3.7.12 的日期,并标注 11/12 是回填 tag » Recent Commits to phpman:master
docs(plan): 订正 v3.7.9–3.7.12 的日期,并标注 11/12 是回填 tag

`### v3.7.9–3.7.12` 的日期原写 2026-06-11 → **06-14**,但 tag 实际只到 06-13
(v3.7.9 打于 06-11 07:04,v3.7.12 打于 06-13 14:06)。

同时把这一段最容易绕人的地方写清楚:**v3.7.11 与 v3.7.12 是回填的 tag,
编号顺序与提交顺序相反。**

  v3.7.13–v3.7.19   2026-06-11 15:30–17:02   一口气打完
  v3.7.11 / v3.7.12 2026-06-13 01:56 / 14:06 两天后才补打

拓扑上 **v3.7.13 的提交是 v3.7.12 提交的祖先**,实际提交顺序是
`13 → 14 → 15 → 16 → 17 → 18 → 19 → 11 → 12`,中间还有一个
`Merge remote: resolve v3.7.11 version conflict` 的合并提交 —— 当时就撞过版本号。
所以这一段不能按 `git log` 的先后读版本先后,要按 tag 读。

起因是核对 v3.7.12 这个 tag 的来历时发现的:tag 本身是人打的
(tagger=chedong,2026-06-13 14:06:34),它指向的提交 4ab3aea
(man page fallback cheat.sh → man7.org)署名是 Claude Opus 4.7。

纯文档改动,不动代码,不涉及渲染输出。

Co-Authored-By: Claude code 2.1.304 with deepseek-flash <noreply@taotoken.net>
01:38 fix(cache): 超时写短 TTL 负行,同名不再被无限重复触发 20s 扫描 (#248) » Recent Commits to phpman:master
00:25 feat(logs): 埋点加 bytes 触发条件,大响应不再对诊断隐形(#249) » Recent Commits to phpman:master

Tue 20 October, 2026

22:06 feat(logcheck): 5xx 按族分开计数,并把窗口范围写进输出 (#249 路线 1) » Recent Commits to phpman:master
08:20 fix(ri): 未命中不再跑第二遍相同命令;空兜底页也说人话 (#252) » Recent Commits to phpman:master
04:52 fix(pydoc): 兜底无匹配时给出说明,不再只剩一个标题 (#252) » Recent Commits to phpman:master

Mon 19 October, 2026

23:06 fix(pydoc): 兜底不再扫 dotted 名字;超时不再被缓存成 24 小时 404 (#247) » Recent Commits to phpman:master
18:29 fix(logs): mf 遥测改用独立文件,错误日志恢复成错误日志 (#251) » Recent Commits to phpman:master
01:37 fix(render): phpManExec 的 $rc 收窄成必填 int,12 个调用点全线 500 (#248) » Recent Commits to phpman:master

Sun 18 October, 2026

20:24 fix(render): 给所有文档渲染子进程加上界(PHPMAN_CMD_TIMEOUT,默认 15s)(#248 #247) » Recent Commits to phpman:master
04:14 fix(cache): 负缓存行一律复用 —— 空正文是「搜过了、没结果」,不是「没缓存」(#247) » Recent Commits to phpman:master