修复: 集合指纹**不是集合的函数**(顺序敏感 + 只含 name:size);"读不到"曾静默报成"全 0 且指纹正常";"设备忙"的跳过**加界**;criteria-hygiene 两处按字面量裁射程
pi 2026-09-18 报的两个洞我**都当场复现了**(不是我信了,是跑出来了),修完都验了阳性对照。
## 1. 集合指纹必须是集合的函数(pi 洞 1,两个都复现)
原来 `'\n'.join(f'{f}:{getsize(f)}' for f in listed)`。实测:
· **顺序敏感**:只把 `jobs.manifest.json` 反序(集合/内容/计数全不变)
⇒ `sha=02502771` → `3439e049`。于是"集合变了 ⇒ 一眼看得出"失效,
**每次清单整理都假变**。
· **只含 name:size**:字节级等长改写(`jobs-one.json` 的 `name: 'image'` → `'imoge'`,
265 字节不变)⇒ **指纹仍是 02502771**。更尖锐的是 file 路径改成等长的
`ApiClienX.ets`(指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变而
**指纹不变** —— **"集合没变而数变了"恰恰是它声称要抓的情况,它抓不到。**
⇒ `sorted()` + 内容哈希。实测:原序/反序/排序三种**同一哈希**(`33ff3bea`);
等长改内容 ⇒ 变(`89b2aa41`)。
## 2. "读不到" ≠ "不存在"(pi 洞 2,复现 + 比 pi 说的更糟)
隔离副本 + `runuser -u nobody` + `chmod 644 jobs/` 实测:
```
nobody: mutants=0 ran=0 skipped=0 … 原始条目 0 sha=e3b0c442 rc=0
```
`e3b0c442` = **空字符串的 sha256**;而且退出码 **0**。根因:`exists()` 对
**不可进入目录里的文件**返回 **False**(实测),12 个全被跳过;而 `glob` 那一半
照样列得出 12 个 ⇒ `unlisted`/`ghosts` 全空 ⇒ **"清单与磁盘不一致"那条警告一声不响**。
**同一份权限,两个半边给出互相矛盾的结论。**
⚠️ 比 pi 说的更糟的一点:那行**同时**印着 `清单 12 个 job 文件` 和 `sha=e3b0c442`
—— 一句话里说"12 个"却一个都没读到。
⇒ ① 判目录可进入性(`access(R_OK|X_OK)`),读不到就 `✗✗` 明说数字**全部无效**、
并点明"这不是空集合";② `open` 接住 `OSError`,把读不到的文件**攒起来一次报全**
(原来会死在第一个文件上抛 `PermissionError`,让人以为"就这一个");
③ 退出码 **2**(本仓约定:2=环境)—— 读不到就是没读数,而**没读数不是成功**。
④ `unreadable` 与 `ghosts` **分开报**:修法完全不同(修权限 vs 删条目)。
★ 这里我先写了个**不可达的分支**:`unreadable` 先探后读,而真读时 `PermissionError`
会提前抛出 ⇒ 那段报告永远走不到("判据在,但走不到",这次长在报告分支上)。
自己查出来并改成"读的时候接住",才让它成为可达的真分支。
## 3. "不抢前台"的跳过**加界**(pi 2026-09-18 §3,我接受)
只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红不算绿 ⇒ 永远不必被升级
—— 到期机制要防的正是这个,只是入口换成了"设备忙"。⇒ 连续 K(默认 3)轮没跑成,
**跳过自己变红**并给出接管路径(K 轮内是礼貌,K 轮外是闹钟)。
边界:**只对"设备在、前台不是我们的"计数**;**设备不在不计数也不变红** ——
那是 `PROBES.device` 的既有裁定(没装 SDK 的机器不该天天假红),超出本模块职责。
账本 `.tmp/harmony-busy-skips.json`(已 gitignore):判"**这台机器上**连续多少轮没验成",
换机器不继承。实测 1→2→3 轮 skip、第 4 轮起 FAIL;跑成一次即清零、再从 1 重新数。
自检(不连设备)把 ①K 轮内礼貌 ②超了必红 ③跑成清零 ④上限可覆盖 都钉住,
变异验证 M7(over 恒 false)⇒自检②红、M8(noteRan 不清零)⇒自检③红。
## 4. 顺带修掉 criteria-hygiene 自己两处"按字面量裁射程"
我按纪律把账本读取从裸 `readFileSync` 改成 `prose()`(`criteria-hygiene` 立刻红,
**那条判据是对的、我错了**),接着暴露出该判据自身两个洞:
· **import 按写法硬匹配**:只认 `'./lib/read.mjs'`/`'../lib/read.mjs'`,
而 `harmony-device.mjs` **就在 `lib/` 里**、按惯例写 `'./read.mjs'` ⇒
假红"根本没 import"(其实 import 了、运行时完全正常)。改成**解析说明符后与
`SELF` 比**(本仓已用"解析后比较"解决过同一族:`abspath`、`join(HERE,…)`)。
· **用 `prose()`(原文)判"用没用"** ⇒ **注释里**写 `prose(…)` 就算用了
(实测该文件命中 3 处、只有 1 处是真调用)。改成 `code()`。这与它上面那条
"不许裸用 readFileSync"踩过的是同一个坑,我在那条上写了理由、**这条漏了**。
两处修法都做了承重验证:删真 import ⇒ 红;import 换成 `code as prose2`(别名)⇒ 红;
只在注释里写 `prose(` ⇒ **绿**(对照旧实现:同一份样本 ⇒ **红**)。
## 验证
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 红的仍是同样 9 条(都是并发
会话的"自报 > 清单"与 4 条 exit 1),我中途引入的两条(裸 readFileSync、import 假红)
已消。`mutants=48` 不变。
· 所有 chmod/清单反序/等长改写**都已还原**,`git status` 只剩本次 5 个文件。
This commit is contained in:
@ -11,7 +11,8 @@
|
||||
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { code, prose, stripComments } from './lib/read.mjs';
|
||||
import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk } from './lib/harmony-device.mjs';
|
||||
import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk,
|
||||
noteBusySkip, noteRan, busyLimit, busyStreak } from './lib/harmony-device.mjs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
@ -796,14 +797,38 @@ function navItemsOf(root) {
|
||||
}
|
||||
|
||||
test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)', async (t) => {
|
||||
const CRIT = 'harmony-nav/底栏可点项';
|
||||
const hdc = findHdc();
|
||||
if (!hdc || !hasTarget(hdc)) {
|
||||
// 设备不在 ⇒ **不计数、不超限**:这是 run-all.mjs 里 PROBES.device 的既有裁定
|
||||
// (没装 SDK / 没起模拟器的机器不该天天假红),探针已经决定"不到期"。
|
||||
noteRan(CRIT);
|
||||
return t.skip('设备不在 —— 行为部分本次不跑(静态层仍把住源码契约)');
|
||||
}
|
||||
const fg = foregroundBundle(hdc);
|
||||
if (fg !== 'com.agentmail.harmony') {
|
||||
return t.skip(`应用不在前台(当前是 ${fg || '空'})—— 设备被别的会话占用,不抢前台`);
|
||||
/*
|
||||
* ★ 设备在、但前台不是我们的 ⇒ 记一次"忙",**连续超 K 轮就变红**(pi 2026-09-18 §3)。
|
||||
*
|
||||
* 只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红也不算绿,
|
||||
* 于是永远不需要被升级 —— 到期机制要防的正是这个。所以 K 轮之内是礼貌,
|
||||
* K 轮之外是闹钟。
|
||||
*/
|
||||
const { streak, over } = noteBusySkip(CRIT);
|
||||
if (over) {
|
||||
assert.fail(
|
||||
`设备连续 ${streak} 轮被别的会话占着(前台是 ${fg || '空'}),本条行为判据` +
|
||||
`**连续 ${busyLimit()} 轮以上没能真跑** —— 不再当作礼貌跳过。\n` +
|
||||
` 这说明"不抢前台"已经变成永久状态:判据既不绿也不红 ⇒ 永远不必被升级。\n` +
|
||||
` 需要人来接管:要么给本会话排到设备时间,要么显式决定这台设备上不再跑行为判据` +
|
||||
`(那就要回到静态并写清理由,不能靠"一直忙"糊过去)。\n` +
|
||||
` (账本:.tmp/harmony-busy-skips.json,已连续 ${streak} 轮;` +
|
||||
`跑成一次即清零。上限 K=${busyLimit()},可用 AGENTMAIL_BUSY_SKIP_LIMIT 覆盖。)`);
|
||||
}
|
||||
return t.skip(`应用不在前台(当前是 ${fg || '空'})—— 设备被别的会话占用,不抢前台` +
|
||||
`(连续第 ${streak}/${busyLimit()} 轮,超过就要变红)`);
|
||||
}
|
||||
noteRan(CRIT); // 真跑成了 ⇒ 清掉连续计数
|
||||
const root = dumpLayout(hdc);
|
||||
assert.ok(screenHeightOf(root) > 100,
|
||||
`要能从 dumpLayout 里量出屏幕高度(实际 ${screenHeightOf(root)})—— 拿不到说明树是空的`);
|
||||
@ -897,3 +922,59 @@ test('★ 判据自检:底栏取值逻辑 —— 好样本取得到、缺底
|
||||
'★ 自检失败:自洽可点 Text(叶子 FAB / 带子文本的可点 Text)被当成了导航项 —— ' +
|
||||
'它们会去核 NAV_ITEMS 标签而**误红**;这正是"排除 Text"那行必须存在的理由');
|
||||
});
|
||||
|
||||
/*
|
||||
* ★ 判据自检:**"设备忙"的跳过必须有界**(pi 2026-09-18 §3)。
|
||||
*
|
||||
* 上面那条行为判据在"应用不在前台"时跳过(别人的会话在用模拟器)。只跳过不设界,
|
||||
* "设备忙"就会变成到期判据的**永久灰区**:不算红也不算绿 ⇒ 永远不必被升级 ——
|
||||
* 这正是到期机制要防的东西("不等谁想起来"),只是入口换成了"设备忙"。
|
||||
*
|
||||
* 所以给 skip 加了界:连续 K 轮没跑成 ⇒ 跳过**自己变红**(K 轮内是礼貌,K 轮外是闹钟)。
|
||||
* 这里把那条界的**取值逻辑**钉住 —— 不连设备、走临时账本,所以每次跑都真的在跑:
|
||||
* ① K 轮以内 ⇒ `over=false`(礼貌);
|
||||
* ② 第 K+1 轮 ⇒ `over=true`(闹钟,调用方必须红);
|
||||
* ③ 真跑成一次(`noteRan`)⇒ 计数清零,从 1 重新数("跑成了就不该再记前账");
|
||||
* ④ **设备不在**那条路径不计数 —— 那是探针的既有裁定,不是"忙"。
|
||||
*/
|
||||
test('★ 判据自检:设备忙的跳过有界 —— K 轮内礼貌、超了必红、跑成即清零', async () => {
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join: pjoin } = await import('node:path');
|
||||
// 账本与上限都走 env 覆盖到临时位置(默认那份是 `.tmp/` 下的真账本,自检不许碰它)
|
||||
const dir = mkdtempSync(pjoin(tmpdir(), 'busy-ledger-'));
|
||||
const prevLedger = process.env.AGENTMAIL_BUSY_LEDGER;
|
||||
const prevLimit = process.env.AGENTMAIL_BUSY_SKIP_LIMIT;
|
||||
process.env.AGENTMAIL_BUSY_LEDGER = pjoin(dir, 'ledger.json');
|
||||
process.env.AGENTMAIL_BUSY_SKIP_LIMIT = '3';
|
||||
try {
|
||||
const C = 'selftest/忙';
|
||||
// ① K 轮以内是礼貌
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const r = noteBusySkip(C);
|
||||
assert.equal(r.streak, i, `第 ${i} 次跳过应当记到 streak=${i}(实际 ${r.streak})`);
|
||||
assert.equal(r.over, false, `第 ${i}/${busyLimit()} 轮还不该红(礼貌期内)`);
|
||||
}
|
||||
// ② 第 K+1 轮 ⇒ 必须红
|
||||
const over = noteBusySkip(C);
|
||||
assert.equal(over.streak, 4, `第 4 次应当 streak=4(实际 ${over.streak})`);
|
||||
assert.equal(over.over, true,
|
||||
'★ 自检失败:连续跳过超过 K 轮仍不红 —— 那"设备忙"就成了永久灰区,' +
|
||||
'到期判据既不绿也不红、永远不必被升级(这正是要防的那件事)');
|
||||
// ③ 真跑成一次 ⇒ 清零,再忙从 1 重新数
|
||||
noteRan(C);
|
||||
assert.equal(busyStreak(C), 0, '★ 自检失败:跑成之后计数没清零 —— 会攒出假超限');
|
||||
assert.equal(noteBusySkip(C).streak, 1, '清零之后应当从 1 重新数');
|
||||
// ④ 上限可覆盖(否则判据自检没法验边界,测不了的边界等于没写)
|
||||
process.env.AGENTMAIL_BUSY_SKIP_LIMIT = '1';
|
||||
noteRan(C);
|
||||
assert.equal(noteBusySkip(C).over, false, 'K=1 时第 1 轮仍是礼貌');
|
||||
assert.equal(noteBusySkip(C).over, true, 'K=1 时第 2 轮必须红');
|
||||
} finally {
|
||||
if (prevLedger === undefined) delete process.env.AGENTMAIL_BUSY_LEDGER;
|
||||
else process.env.AGENTMAIL_BUSY_LEDGER = prevLedger;
|
||||
if (prevLimit === undefined) delete process.env.AGENTMAIL_BUSY_SKIP_LIMIT;
|
||||
else process.env.AGENTMAIL_BUSY_SKIP_LIMIT = prevLimit;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user