做 #5912(PR #6228)时在同一个文件扫到的旁生发现,但在另一个方法、另一条分支上,且 #5912 的分诊评论(2026-08-06 16:57Z)已把那一单范围明确钉死在 :3017 一处、⛔ 不扩面,因此不在那一单范围内,按 Prime Directive #10 / objectstack#4949 单开。
现象
packages/services/service-automation/src/engine.ts:2931(origin/main 1549605f6;⚠️ 行号会漂,以内容定位),loadSuspendedRun —— 也就是 loadSuspendedRunStrict 的降级版读取器:
private async loadSuspendedRun(runId: string): Promise<SuspendedRun | null> {
try {
return await this.loadSuspendedRunStrict(runId);
} catch (err) {
this.logger.warn(
`[automation] failed to load suspended run '${runId}' from durable store: ${(err as Error).message}`,
);
return null;
}
}
(err as Error).message 来自 loadSuspendedRunStrict 底下的数据源驱动,和 #5912 是同一个 thrown 值 —— 我们不控制它有几行。它被插进 logger.warn 的 message。
与 #5912 的关系:同一次失败的另一半
两者读的是同一个 loadSuspendedRunStrict。一次「resume 时存储不可达」会同时走这两条:
所以 PR #6228 落地后,这条路径上 stderr 那条已经干净,stdout 这条仍会被换行切碎。PR #6228 的测试正是靠「只抓 stderr」把待验接缝与这条 warn 隔开的(见该 PR 测试文件里 resumeAgainstUnreadableStore 的注释)。
危害机制:比 #5912 那条更重一档
同族基础危害相同 —— ObjectLogger.write() 一次调用只加一个「时间戳 + 级别」记录头,message 里的换行把一条记录变成多个物理行,后几行无级别无时间戳,grep WARN 只捞到不含事实的那一行。
但多一条:ObjectLogger 把 debug / info / warn 路由到 stdout,而 serve 的 boot-quiet 窗口只包了 process.stdout.write,其 BootLogCapture.offer() 仅在 classifyBootLogLine 找得到级别头时才保留该物理行。这条是 warn ⇒ 落在那个缓冲的过滤面上,无头续行是被直接丢弃而不只是被误读。
而且它在 boot 期真实可达:plugin.ts 的 start()(:939)调 rearmSuspendedWaitTimers,后者对 overdue 运行调 engine.resume(run.runId)(builtin/wait-node.ts:378),resume() 的 gate 就走到这个降级版读取器。
对照:#5912 那条是 error 走 stderr,不经该缓冲,危害是被误读;这条是被误读 + boot 期被丢弃。这正是 thrown-cause-diagnostics.ts 模块 docblock 里 warn / error 下游不同的那段所描述的形态,也是 #5661 第 2、3 条与 cloud#971 的形态。
级别本身是对的,不要顺手改
warn 在这里是正确的:这是一个刻意的降级读取器,注释写明它服务于「只需要 best-effort 答案」的顺带读取方(gate 查询、screen 取数),真正需要区分「存储挂了」与「运行没了」的 resumeInternal 用的是严格版。按 #4632 的判据,这是功能性降级而非耐久性降级,不应上调到 error —— 上调才是 #4632 明确警告的镜像错误。本单只谈 message 拼接。
可达性(为什么是 finding 而不是事故报告)
与 #5575 / #5636 / #5661 / #5737 / #5912 同样的理由:今天库内的驱动错误均为单行,所以这是 finding。第一个包装多行 SDK 错误的驱动撞上 —— 数据库驱动的多行错误(Postgres 的 detail: / hint: 续行、better-sqlite3 包装器)在生态里很常见,#5737 与 #5912 的实测都是照这个形状造的。
修法(同族既定模板,零新词汇)
与前六次完全一致,复用同包 thrown-cause-diagnostics.ts 的 describeThrownForLog:message 保持单行自足,cause 走 meta。按 Logger 契约(packages/spec/src/contracts/logger.ts)warn(message, meta?) 是第二参(不是 error 的第三参 —— warn 没有 Error 槽)。新增字段名不得含 key / token / secret / password 子串(#5573);直接用 helper 自己的 error / issues 即可,无需新字段。
顺带值得判断(不是阻塞项):message 里可否同时补上这条降级的后果(返回 null,调用方会当作「没有这个挂起运行」)—— 目前文本只说「读失败」,没说读失败被翻译成了什么。倾向补,但属于同一处改动的自然范围。
关联
#5912 / PR #6228(本发现的来源,同文件另一方法)、#5737 / PR #5911、#5661、#5636 / PR #5662、#5575 / PR #5639、#5048 / PR #5572、#5660(同在 engine.ts 的 registerDegradedConnector,已闭)、#5573、#4632、#4420、cloud#971。
做 #5912(PR #6228)时在同一个文件扫到的旁生发现,但在另一个方法、另一条分支上,且 #5912 的分诊评论(2026-08-06 16:57Z)已把那一单范围明确钉死在
:3017一处、⛔ 不扩面,因此不在那一单范围内,按 Prime Directive #10 / objectstack#4949 单开。现象
packages/services/service-automation/src/engine.ts:2931(origin/main1549605f6;loadSuspendedRun—— 也就是loadSuspendedRunStrict的降级版读取器:(err as Error).message来自loadSuspendedRunStrict底下的数据源驱动,和 #5912 是同一个 thrown 值 —— 我们不控制它有几行。它被插进logger.warn的 message。与 #5912 的关系:同一次失败的另一半
两者读的是同一个
loadSuspendedRunStrict。一次「resume 时存储不可达」会同时走这两条:resume()先跑 gate(refuseGatedResume→resolveEffectiveSuspension),用的是这个降级版,把失败吞成 stdout 上的一条warn;resumeInternal用严格版,产出 stderr 上的那条error—— finding(service-automation): engine.ts 的 resumeInternal 把驱动错误插进 message —— #5737 修完后,这条 resume 路径上仅剩的一处外来 cause 拼接 #5912 / PR fix(service-automation): resumeInternal 存储不可达的日志 cause 移出 message,改走 meta (#5912) #6228 治的就是它。所以 PR #6228 落地后,这条路径上 stderr 那条已经干净,stdout 这条仍会被换行切碎。PR #6228 的测试正是靠「只抓 stderr」把待验接缝与这条
warn隔开的(见该 PR 测试文件里resumeAgainstUnreadableStore的注释)。危害机制:比 #5912 那条更重一档
同族基础危害相同 ——
ObjectLogger.write()一次调用只加一个「时间戳 + 级别」记录头,message 里的换行把一条记录变成多个物理行,后几行无级别无时间戳,grep WARN只捞到不含事实的那一行。但多一条:
ObjectLogger把debug/info/warn路由到 stdout,而serve的 boot-quiet 窗口只包了process.stdout.write,其BootLogCapture.offer()仅在classifyBootLogLine找得到级别头时才保留该物理行。这条是warn⇒ 落在那个缓冲的过滤面上,无头续行是被直接丢弃而不只是被误读。而且它在 boot 期真实可达:
plugin.ts的start()(:939)调rearmSuspendedWaitTimers,后者对 overdue 运行调engine.resume(run.runId)(builtin/wait-node.ts:378),resume()的 gate 就走到这个降级版读取器。对照:#5912 那条是
error走 stderr,不经该缓冲,危害是被误读;这条是被误读 + boot 期被丢弃。这正是thrown-cause-diagnostics.ts模块 docblock 里warn/error下游不同的那段所描述的形态,也是 #5661 第 2、3 条与 cloud#971 的形态。级别本身是对的,不要顺手改
warn在这里是正确的:这是一个刻意的降级读取器,注释写明它服务于「只需要 best-effort 答案」的顺带读取方(gate 查询、screen 取数),真正需要区分「存储挂了」与「运行没了」的resumeInternal用的是严格版。按 #4632 的判据,这是功能性降级而非耐久性降级,不应上调到error—— 上调才是 #4632 明确警告的镜像错误。本单只谈 message 拼接。可达性(为什么是
finding而不是事故报告)与 #5575 / #5636 / #5661 / #5737 / #5912 同样的理由:今天库内的驱动错误均为单行,所以这是
finding。第一个包装多行 SDK 错误的驱动撞上 —— 数据库驱动的多行错误(Postgres 的detail:/hint:续行、better-sqlite3 包装器)在生态里很常见,#5737 与 #5912 的实测都是照这个形状造的。修法(同族既定模板,零新词汇)
与前六次完全一致,复用同包
thrown-cause-diagnostics.ts的describeThrownForLog:message 保持单行自足,cause 走 meta。按Logger契约(packages/spec/src/contracts/logger.ts)warn(message, meta?)是第二参(不是error的第三参 ——warn没有Error槽)。新增字段名不得含 key / token / secret / password 子串(#5573);直接用 helper 自己的error/issues即可,无需新字段。顺带值得判断(不是阻塞项):message 里可否同时补上这条降级的后果(返回
null,调用方会当作「没有这个挂起运行」)—— 目前文本只说「读失败」,没说读失败被翻译成了什么。倾向补,但属于同一处改动的自然范围。关联
#5912 / PR #6228(本发现的来源,同文件另一方法)、#5737 / PR #5911、#5661、#5636 / PR #5662、#5575 / PR #5639、#5048 / PR #5572、#5660(同在
engine.ts的registerDegradedConnector,已闭)、#5573、#4632、#4420、cloud#971。