Skip to content

[automation] getSuspendedScreen 只读内存热缓存 —— 重启后一个仍然活着的挂起 run 取不到它的 screen(404) #4515

Closed

Description

@os-zhuang

在做 #4470(让验证 harness 走真实持久化路径)时发现的,与那张卡本身无关,单独记录。

问题

AutomationEngine.getSuspendedScreen 只查内存热缓存:

// packages/services/service-automation/src/engine.ts
getSuspendedScreen(runId: string): ScreenSpec | null {
    return this.suspendedRuns.get(runId)?.screen ?? null;
}

它是同步的,所以没有能力去问挂起存储。而 SuspendedRun.screen 是会持久化的(sys_automation_run.screen_json),resume() 也确实会在热缓存未命中时经 loadSuspendedRunStrict 从库里冷读回来。

于是两条路径对同一个 run 给出相反的答案:

进程重启后,对一个 durably 挂起的 screen run 结果
POST …/runs/:runId/resume 正常继续(从库里 rehydrate)
GET …/runs/:runId/screen 404 No pending screen for run

为什么这有实际影响

这条路由的存在意义就是「刷新安全的重新拉取」——屏幕流 runner 在用户刷新页面、或换设备继续时用它把表单再画出来。而它恰恰在最需要它的那一刻失效:进程重启之后。用户看到的是「这个审批/表单不见了」,但 run 其实活得好好的,resume 也还能打通——只是客户端已经拿不到该渲染什么了。

这也正是 ADR-0019 持久化挂起要解决的场景:暂停要能跨进程存活。存活了,但它的渲染契约没跟上。

复现

packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts(#4502 引入)最初就是这样写的,在这一步红:

AssertionError: screen not rehydrated:
{"success":false,"error":{"code":"RESOURCE_NOT_FOUND","message":"No pending screen for run","httpStatus":404}}

修复方向(需要决策)

直接的修法是让它跟 resume 一样能读存储,但那要把方法变成异步,而它是公开契约的一部分:

// packages/spec/src/contracts/automation-service.ts
getSuspendedScreen?(runId: string): ScreenSpec | null;

改成 Promise<ScreenSpec | null> 是破坏性变更,需要同时改 packages/runtime/src/domains/automation.ts 里的路由调用点和任何其它消费方。所以这条没有顺手修,留给能拍板协议变更的人。

另一种不破坏签名的思路是在 resume 之外增加一个异步的 loadSuspendedScreen(runId),让路由改用它,把同步版本留作热路径快查——代价是两个名字做一件事。

参考

Activity

  1. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    决策请求(PM 循环第 1 轮分诊):本 issue 的修复方向涉及公开契约变更,按规程不派发、请你拍板。已打 needs-user-decision 标签。

    两个候选方案

    方案 A:把契约方法改为异步 —— getSuspendedScreen(runId): Promise<ScreenSpec | null>,同步改 packages/spec/src/contracts/automation-service.ts、packages/runtime/src/domains/automation.ts 路由调用点及其他消费方。破坏性变更。

    方案 B:新增异步孪生方法 —— 保留同步版作热路径快查,加 loadSuspendedScreen(runId): Promise<…>,路由改用异步版。签名不破坏,代价是两个名字做一件事。

    两轴分析

    ① 项目长远合理性

    • ADR-0019 已把挂起做成持久化的,「取挂起 screen」本质上就是一个可能触达存储的操作——同步签名是对这个本质的误报。方案 A 让契约恢复诚实。
    • 方案 B 是典型的 workaround(Prime Directive [WIP] Fix error in step four of the action run #5):同步版会永久留在契约里,每个新消费者都要「知道」该用哪个;两个名字一件事,与「一个契约一个来源」相悖。破坏性变更的迁移成本是有界的——这是内部契约,消费方可枚举,v5 本来就在做破坏性收敛。

    ② 防 AI 写代码犯错

    • 同步版正是 AI 最容易踩的那类陷阱:调用它在热进程里完全正常,只在重启后 404,常规测试根本照不出来——"looks right, works in dev, fails in production"。方案 A 把陷阱从契约里删除;方案 B 只是把陷阱文档化,而依赖「AI 记得读文档」正是我们不信任的东西。
    • declared = enforced:契约声明的行为应当就是运行时兑现的行为。A 达成;B 留下一个声明上看不出缺陷的方法。

    建议

    方案 A,按契约变更走:spec 契约改异步 → 路由与消费方同步迁移 → 一个 changeset 记录破坏性变更。若你担心一步到位的波及面,可以 A 的分步变体:先加异步版并把所有内部调用迁过去,同一个版本窗口内删除同步版(要有明确的删除排期,不能让 B 的状态常驻)。

    拍板后请回复评论(选 A / A 分步 / B / 其他),摘掉 needs-user-decision 或直接告诉我,即可进入下一轮派发。


    Generated by Claude Code

  2. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 2, 2026
  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    同意方案A

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 2, 2026
  5. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    维护者已拍板:方案 A(契约方法改异步,破坏性变更)。已摘除 needs-user-decision,转为可派发实现任务。

    排期说明:列为下一轮首位而非本轮立即派发——本轮已追加派发 #4525(spec 建模 DecisionOutputDef.required),两者都会改动 packages/spec 的公共表面并重新生成同一份 api-surface 基线,同批并行必然在生成产物上撞车;按批次独立性规则串行,#4525 的 PR 进入合并流程后即派发本条。


    Generated by Claude Code

  6. self-assigned this
    on Aug 2, 2026
  7. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 2 轮(维护者已拍板方案 A:契约方法改异步)
    分支:claude/issue-4515-suspended-screen-async
    Worktree:objectstack-issue-4515
    前置条件已满足(#4525 → PR #4561 已进入合并流程,且实测为纯测试改动、与本任务无生成基线冲突)。如另有会话已在做本 issue,以更早的认领评论为准,本认领让行。


    Generated by Claude Code

  8. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 验收:ACCEPT → PR #4566(转正并武装 merge-when-ready,检查全绿自动入队)。

    按你拍板的方案 A 落地:契约 getSuspendedScreen 改异步、无同步残留;引擎复用 resume 的同一个 loadSuspendedRun(一个 loader 两个调用方,零复制);全部消费方同批迁移。语义边界保持:不存在/已恢复/非 screen 挂起仍是 null(404 保持 404),存储故障降级 null 而 hasSuspendedRun 保持严格抛错。dogfood 层补上真实冷启动的 screen 重取断言(#4564 合入后可测):stop() → 冷启动 → GET …/screen 200 且字段契约完整、重取不消费挂起、run 消费后 screen 随之 404。回滚验证两层齐备,正是 issue 记录的那个 404。changeset:minor + 显式 BREAKING + FROM→TO 迁移。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions