Skip to content

Workflow 后台运行时历史工具卡持续动画可触发 Pi regular 全量重绘,用户上滚位置丢失 #96

Description

@tt-a1i

问题概述

在 Pi regular TUI 中运行 detached/background Workflow 时,用户向上滚动查看历史内容,视口可能突然被打回底部。

这次现象是在 Workflow V2 的真实长任务中暴露的:Workflow 工具调用已经返回、主会话已经释放,但后台 run 仍在执行;此时 OpenPI 会继续让 transcript 中那张已经进入历史区的 Workflow 工具卡每 120ms 更新 spinner。Pi regular renderer 发现“旧 viewport 之上的行发生变化”时,会走 fullRender(true) 并发送 CSI 3J 清除终端 scrollback。用户正在阅读的位置因此丢失。

这不是一个单纯的终端“新输出自动滚到底”设置问题,也不能用 fullscreen ScrollView 的 follow 状态解释当前现场:本次运行明确使用 tuiMode: regular

真实现场

  • OpenPI runtime:PR feat(workflows): release parent turns and harden large fan-out #92 合入后的 cc92f05,由本地 checkout 单一加载;
  • Pi / pi-tui:@earendil-works/* 0.84.2
  • TUI:regular
  • 终端:Ghostty;其默认 scroll-to-bottom = keystroke,no-output,普通新输出默认不会自动吸底;
  • 相关 Workflow:
    • wf_490469b9fb68:background,1 agent;
    • wf_815de99b4a45:background,1 agent;
    • wf_5d38d5d3e879:background,10 agents,运行约 8 分钟。

当时没有用 PI_DEBUG_REDRAW=1 留下 renderer receipt,因此不能声称已经证明“用户看到的那一次跳转”对应哪一个具体 redraw tick;但下面的运行链路、Pi 清 scrollback 行为和 OpenPI 的持续历史更新均已独立证实。

已证实的运行链路

1. Workflow V2 让历史工具卡在 tool call 返回后继续存活

PR #92 把交互式 Workflow 改为默认 detached:工具先返回 launch receipt,run 在后台继续执行。

这是正确的生命周期方向,但也改变了 UI 的时间关系:Workflow 工具卡会先进入 transcript 历史区,而 run 仍可能继续数分钟。

2. OpenPI 每 120ms 改写这张历史工具卡

当前 renderResult 不只为 Pi 仍标记为 partial 的工具调用开 spinner;只要 activeRuns 里仍存在该 run,也会启动定时 invalidation:

这意味着一张已经稳定写进 transcript 的旧卡片仍以约 8.3 FPS 变化。它违反了 regular 主屏很重要的 locality 条件:历史区最好是 append/finalize 后不再按时钟变化。

3. Pi regular renderer 会为 viewport 之上的变化清空 scrollback

Pi regular 模式不是 app-owned transcript viewport;滚轮操作的是终端原生 scrollback。Pi 的差分 renderer 无法修改已经离开可见 viewport 的旧行,因此当 firstChanged < prevViewportTop 时只能走全量重绘:

所以确定性的危险链路是:

detached Workflow 已返回 launch receipt
  ↓
工具卡进入 transcript 历史区,但 run 仍 active
  ↓
OpenPI 每 120ms 改 spinner,并 invalidate 历史卡
  ↓
Pi regular renderer:firstChanged < prevViewportTop
  ↓
fullRender(true) → CSI 3J 清 scrollback
  ↓
用户上滚位置丢失 / 视觉上突然回到底部

Pi 上游已经有高度同构的真实报告:

上游给出的现有规避方式是切到 fullscreen;但 OpenPI 不应要求所有用户切换 Pi 的全局 TUI 模式,尤其这条高频触发是我们自己可以消除的。

为什么现在更容易出现

Pi regular renderer 的限制并不是 PR #92 新增的。新的地方在于:

  • 过去 blocking Workflow 的活动卡通常仍是当前 in-flight 工具调用,靠近 transcript 底部;
  • V2 默认 detached 后,工具调用先结束,用户可以继续对话,而同一张卡仍跟随后台 run 动画;
  • 随着后续消息进入 transcript,这张仍每 120ms 变化的卡自然成为“历史区中的活动组件”,更容易命中 Pi 的 full redraw 分支。

因此应把它描述为:Pi regular 主屏的既有边界,被 OpenPI Workflow V2 的历史动画放大成了高概率用户问题。

建议方案

第一阶段:在 OpenPI 内切断高频触发链路

把 transcript 视为已提交的历史投影:

  1. isPartial === true 时,Workflow 工具卡仍可显示实时 spinner;这是当前仍在执行的 tool call,通常位于底部。
  2. detached Workflow 返回 launch receipt 后,历史工具卡冻结为静态 running/launched 状态,不再按时钟 invalidate。
  3. 后台实时状态继续由现有的 below-editor strip 和 /workflows dashboard 承担。
  4. run 终态由现有 completion follow-up 投递,不靠回写旧 transcript 卡片表达。

实现边界应类似:

Pi tool result 仍 partial
→ 可以读取 active run + 动画

Pi tool result 已 settled / launch receipt 已进入 transcript
→ 冻结当时 details
→ 不再启动 transcript spinner
→ live 状态只存在于 strip/dashboard

这同时符合 OpenPI 的投影原则:canonical run state、model-visible completion 和 operator-facing live UI 是不同投影;不应让对象生命周期持续泄漏进已提交的历史 transcript。

第二阶段:用真实 receipt 判断是否还有 layout shrink 路径

Workflow 完成、用户确认 settled 状态、widget/status 移除时,也可能造成文档高度收缩并触发 Pi full redraw。它是否参与本次现场尚未证实。

先完成第一阶段并启用 PI_DEBUG_REDRAW=1 做真实 smoke:

  • 若周期性的 firstChanged < viewportTop 消失且上滚稳定,则问题已经被最小修复覆盖;
  • 若只在 widget/status 消失时仍出现一次 full redraw,再单独设计 height-stable settlement/placeholder;
  • 不要一开始就为所有 dock 组件加入复杂 viewport 状态机。

不建议的方案

  1. 只让用户改 Ghostty/iTerm 设置:本机 Ghostty 默认已经是 no-outputCSI 3J 清 scrollback 不是普通 output follow。
  2. 强制所有用户切 fullscreen:fullscreen 的 app-owned ScrollView 确实更容易正确实现 scroll lock,但 TUI 模式属于 Pi 用户选择,OpenPI 不应静默改写。
  3. 仅把 120ms 改成 500ms/1s:只能降低发生频率,历史卡仍会周期性触发同一 destructive redraw。
  4. OpenPI 自造 regular-mode scroll lock:regular 模式的 viewport 由终端拥有,扩展拿不到可靠滚动位置;这应留在 Pi renderer seam。
  5. 立即修改 Pi 全量重绘算法:这是更大的上游问题;OpenPI 先消除自己制造的非必要历史动画,收益更确定、风险更低。

验收标准

确定性测试

  • detached/background Workflow 返回 launch receipt 后,即使对应 run 仍 active,历史 renderResult 也不再建立时钟驱动的 invalidation;
  • fake timer 前进多个 SPINNER_INTERVAL_MS,settled tool result 的 context.invalidate 计数保持为 0;
  • isPartial === true 的当前工具调用仍可动画并显示真实进度;
  • below-editor strip、dashboard 和 completion follow-up 的状态表达不退化;
  • wait: true 路径继续按当前同步语义工作。

真实 TUI smoke

在 Ghostty + Pi 0.84.2 下分别运行:

PI_DEBUG_REDRAW=1 pi
  1. regular:启动一个持续至少 30 秒的 detached Workflow,让 launch card 离开当前 viewport,向上滚动并保持阅读 10 秒;
  2. 修复前记录是否出现周期性的 fullRender: firstChanged < viewportTop
  3. 修复后同场景不应再由 Workflow transcript spinner 产生该日志,阅读 anchor 不应移动;
  4. fullscreen:同一场景中手动上滚后,内容增长不得恢复 follow;
  5. 若 regular 仍在终态时单次跳转,记录 exact redraw reason,再另行处理 widget/status shrink。

对同类产品的启示

  • Codex CLI 主对话同样主要依赖终端原生 scrollback,所以会尽量让已经提交的历史保持稳定;
  • Claude Code fullscreen、Hermes 和 OpenCode 使用 app-owned viewport,手动上滚会显式退出 follow,只有滚回底部或执行明确动作才恢复;
  • OpenPI 当前运行在 Pi 之上,正确做法不是复制另一套 viewport,而是遵守当前 renderer 的 locality:历史 transcript 不做无业务状态变化的时钟动画,实时状态放在专门的 live projection。

问题级别

建议标记为 bug。它不会破坏 Workflow 执行结果,但会让用户在长 Workflow/多 Agent 场景下无法稳定阅读历史,并且正是 detached Workflow 设计希望改善的“主会话可继续使用”体验的一部分。

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions