Repository navigation
[bug] checkForUpdate 的缓存短路条件在「已是最新版」时恒为假,导致每次 Stop hook 都执行一次 npm view #671
Description
English summary —
checkForUpdate()guards its cache with
if (!options?.force && isCacheValid(state.lastUpdateCheck) && state.availableUpdate).
availableUpdateis persisted asnullwhenever the registry has nothing newer, so that
short-circuit can never fire in the common case, andfetchLatestVersion()—
execFile(npm, ['view', <pkg>, 'version'])— runs on every Stop hook, i.e. once per agent
turn, instead of once perCACHE_TTL_MS(12h).Verified on
main:src/update.ts:422(the condition is unchanged from 0.24.0).
Two consequences: one npm child process plus one network round-trip per turn; and on the
released 0.24.0 that same call had nowindowsHide, which is exactly why it showed up as a
console window on Windows after every turn. #559 addedwindowsHidethere onmain, but the
root cause — this condition — is still present, so the npm spawn still happens every turn, just
invisibly now.Suggested fix: short-circuit on
isCacheValid(state.lastUpdateCheck)alone, and return
{ available: false, current, latest: current }whenstate.availableUpdateis null.
Description
checkForUpdate() 本意是「12 小时内不重复查版本」,但短路条件写成了合取式,把「缓存有效」和「缓存里有新版本」绑在了一起:
// src/update.ts:417-429(main,与 0.24.0 的打包产物一致)
export async function checkForUpdate(options?: { force?: boolean }): Promise<CheckResult> {
const state = await loadState();
const current = getCurrentVersion();
// Use cached result if valid
if (!options?.force && isCacheValid(state.lastUpdateCheck) && state.availableUpdate) {
const cmp = compareVersions(current, state.availableUpdate);
return { available: cmp < 0, current, latest: state.availableUpdate };
}
// Fetch latest version from registry
const latest = await fetchLatestVersion(); // ← 每次都会走到这里
...
await saveState({
...state,
lastUpdateCheck: new Date().toISOString(),
availableUpdate: available ? latest : null, // ← 没有新版本时写 null
});isCacheValid(state.lastUpdateCheck) 本身已经隐含了「lastUpdateCheck 存在且不超过 TTL」(它的实现对空值返回 false),所以 && state.availableUpdate 是多余的一层,而且方向是反的:
- 有新版本可升级 →
availableUpdate非空 → 短路生效(此时反而最该提示用户,却可能被缓存住 12 小时); - 没有新版本(绝大多数机器、绝大多数时间) →
availableUpdate恒为null→ 短路永不生效 → 每次都真的去查。
CACHE_TTL_MS = 12h 的存在说明这里本意就是要缓存,因此判断这是笔误而非有意设计;不过「负结果该缓存多久」是个产品取舍,留给你们定。
影响
- 每轮对话结束都会 spawn 一个 npm 子进程 + 一次 registry 往返。
updateHandler挂在 Stop hook 上(src/hook-handlers.ts:530,background: true),所以频率 = 每轮回答一次。 - 在 0.24.0 上,这正是「每轮弹一次终端窗口」的直接成因。 0.24.0 的
fetchLatestVersion是execFileAsync(npm.cmd, [...npm.args, 'view', pkgName, 'version', `--registry=${...}`], { timeout, encoding: 'utf-8' }) // 没有 windowsHide
#559(merged 2026-09-15)在main上给这一行补了windowsHide: true,但 0.24.0 发布于 2026-09-14 —— 比该 PR 早一天,之后没有新的稳定版,所以线上装 0.24.0 的 Windows 用户同时吃到「窗口可见」与「每轮都跑」。这条已在下面第 3 节说明。
实测证据
本机 0.24.0 的 ~/.teamai/state.json,在正常使用(不手动执行 teamai 命令)期间被反复改写:
02:03:47.137Z (一次 Stop hook 之后)
02:11:03.188Z (又一次 Stop hook 之后) ← 中间没有调用任何 teamai 命令
给短路条件打上「只按 TTL 判断」的本地补丁后,连续执行两次 teamai update --check:
$ node dist/index.js update --check # ℹ Already up to date (v0.24.0)
$ node dist/index.js update --check # ℹ Already up to date (v0.24.0)
修复前 lastUpdateCheck = 2026-09-20T02:11:03.188Z
修复后 lastUpdateCheck = 2026-09-20T02:11:03.188Z ← 未变,说明已短路、未进入 fetchLatestVersion
建议改法
if (!options?.force && isCacheValid(state.lastUpdateCheck)) {
if (state.availableUpdate) {
const cmp = compareVersions(current, state.availableUpdate);
return { available: cmp < 0, current, latest: state.availableUpdate };
}
return { available: false, current, latest: current };
}如果你们希望「负结果」用更短的 TTL(比如 1 小时)以便更快发现新版本,那就在 saveState 时多记一个标记并给 isCacheValid 传不同的 ttlMs —— 关键是别让 availableUpdate 的取值决定要不要读缓存。
附:为什么这条容易被忽略
fetchLatestVersion() 里的 catch 只 log.error 然后返回 null,调用方拿到 null 后走 return { available: false, current, latest: current } —— 路径很安静,从输出上看不出它每轮都跑过一次。
相关
- 同一台机器上的另一个 Windows 现象(子进程缺
windowsHide)见配套 issue。 #559fix(update): hide Windows console windows during self-update —— 修了窗口,未修这条根因。
Activity
fix(update): short-circuit checkForUpdate when cache is valid but no …