Agent 状态存储¶
状态¶
当前边界是 PR 2A:结构化会话使用 hook 服务器的完全作用域规范存储;未绑定的 PTY/relay 证据仍保留在隔离的遗留适配器中。不要在本切片中移除渲染器桥接或其发布过滤器:它们仍然承载 native-chat 子行。
以下章节记录原始的 2026-09-09 发布计划。其 PR 1a 和 PR 1b 已落地;其提议的 PR 2/3 顺序已被该边界取代:
- 仅 main:每个生产者写入同一个存储,并且
worktree ps读取它,拆分为 1a(结构化会话加入存储)和 1b(删除运行时中重复保留的存储); - 渲染器:侧边栏成为订阅者,并停止重新派生行;
- 共享:为每个读取者提供一个 worktree-status 汇总和一条新鲜度规则。
本方案解决的问题¶
Orca 在四个地方展示“这个 agent 正在做什么”:桌面侧边栏、orca worktree ps 命令、移动应用和 agent 仪表盘。在 #19217 之前,这些读取者甚至没有共享它们的输入。在 #19217 之后,它们共享结构化会话映射,且仅此而已。
2026-09-09 的一次审计发现了六个生产者和三个消费者,并且仅在 main 进程内部就有同一行的三个独立副本:
| main 进程副本 | 键 | 所有者 | 持久化 | 驱逐 |
|---|---|---|---|---|
hook 服务器 lastStatusByPaneKey |
paneKey | src/main/agent-hooks/server.ts |
last-status.json |
标签页关闭、pty 退出、hydrate |
运行时 RuntimeAgentRowStore |
paneKey | runtime-agent-row-store.ts(在 PR 1b 中删除) |
否 | 仅 pty 退出 |
结构化 feed published |
sessionId | src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts |
否 | 从不(一个广播缓存) |
第二份副本是重复写入:在 main 中解析的 OSC 状态被转发到 hook 服务器 并且 从同一调用中保留在运行时存储中(orca-runtime-create-terminal-side-effect-command-code-detector.ts)。第三份副本使用不同的键,并且根本不会到达 hook 服务器,这就是为什么 worktree ps 在 #19217 中为它增加了自己的适配器。
每个读取者随后应用自己的优先级和新鲜度规则,因此同一个 pane 在桌面、手机和 CLI 中可能合理地读取到不同结果。
规则¶
执行主机拥有 agent 状态,位于一个存储中,并且每个读取者都订阅它。 这遵循 ssh-execution-boundary.md 中的边界:运行进程的主机是唯一能够观察它的当事方,客户端对于执行状态永远不是权威来源。
三个推论:
- 每个执行主机一个存储。远程主机保留自己的存储,客户端将其镜像下来,就像 web-session 镜像已经做的那样。镜像不是合并:客户端永远不会将其观察结果写回主机。
- 优先级在写入时一次性决定,并在行上记录来源。读取者永远不会重新裁定 hook、terminal 和 structured 之间的优先级。
- 读取者仅保留展示策略和用户事实:30 分钟显示衰减、确认、关闭、未读。这些保留在读取者侧,但成为一个共享实现(PR 3)。
该存储已经存在¶
对于每个基于 PTY 的 agent,hook 服务器的状态如今就是那个存储。审计确认:
- hook HTTP 请求、WSL 和 SSH relay 接收器,以及 main 自身的 OSC 解析,都汇聚到同一条
applyNormalizedStatus路径,并打上权威 idmain-agent-hooks; - 只有它持有 pane 权限:启动令牌及其哈希承诺、退役 pane 栅栏、pane-key 别名、每连接顺序水位线,以及必须比传输清除存活更久的证据年龄映射;
- 只有它持久化,具有七天的 hydrate 窗口和
restoredUnconfirmed戳,使已 hydrate 的行永远不会被读取为实时真相; - 它已经通过
agentStatus:set和agentStatus:clear扇出到两个渲染器窗口,并提供agentStatus:getSnapshot。
main 中没有其他东西承载这些保证,构建一个具有这些保证的第二存储会是错误方向。因此设计不是“添加一个存储”。而是:将绕过 hook 服务器的两个生产者通过它路由,然后删除副本。
PR 1a:结构化会话发布到存储¶
渲染器行为没有变化。侧边栏继续接收它今天接收的相同 IPC 事件,外加它当前自行派生的结构化会话行。
结构化会话发布到 hook 服务器¶
结构化 feed 保留其将会话日志投影为摘要并将其流式传输给订阅者的职责。在每次发布时,它还会将摘要作为状态行摄取到 hook 服务器中:
| 行字段 | 来源 |
|---|---|
paneKey |
structuredAgentSessionPaneKey(tabId, sessionId),渲染器已经使用的键;其叶子是 UUID 形状,因此 pane-key 验证接受它 |
tabId |
structuredAgentSessionTabId(sessionId) |
worktreeId |
summary.workspaceId(文件夹 workspace id 是一个有效值) |
| 行字段 | 来源 |
|---|---|
state |
structuredAgentSessionStatusState(summary.status),#19217 中共享的映射 |
structuredHost |
当 summary.hostExecutionOwned 已设置时为 'owned',否则为 'held';worktree ps 由此派生其行的 structuredHostOwned |
| prompt、tool、last message、model、provider session | 摘要的字段 |
没有已持久化回合(status === null)的会话不会生成行,这与聊天显示的内容一致。当宿主撤销实时所有权时,该行会被重新设置且不带该标志;当宿主关闭或驱逐会话时,该行会被删除。这两者已经作为 feed 事件存在(revokeLive 以及 liveSessionSummaries 中的 roster 过滤);PR 1 将它们变为 store 写入。
从宿主的 map 中删除会话和删除其行是同一个操作,forgetStructuredAgentSession。store 会保留一行直到被告知,而宿主拥有的行会绕过陈旧检查,因此如果删除路径忘记删除该行,就会遗留一个看起来永久处于工作状态中的 agent。
ingest 必须遵守两条规则:
- 绝不持久化结构化行。 journal 是结构化会话的持久事实来源,宿主在恢复时会重新发布。
last-status.json中的结构化行会以restoredUnconfirmed水合,然后与实时重新发布发生冲突。序列化器会跳过携带structuredHost的行,hydrate 会丢弃在磁盘上发现的任何此类行。因此应用一条这样的行也会跳过持久化调度:遍历和 stringify 只能复现已经位于磁盘上的文件,对于每个流式聊天,每个防抖窗口一次。 - 绝不让它与 hook 行冲突。 结构化会话没有 PTY,因此没有 hook 或 OSC 事件携带其 pane key。ingest 仍然经过处置门控,因此已退役的 pane key 会像其他任何 key 一样被拒绝。
应用一条这样的行确实仍会运行两个状态 fan-out,这是有意为之,而非偶然。notifyStatusChangeListeners 为 agentAwakeService 的电源节省阻塞器提供输入,subscribeEnrichedStatus 为 AgentSessionTransitionRecorder 的统计提供输入,因此加入 store 会将原生聊天同时纳入这两者。正在工作的原生聊天是真实工作,应该像 PTY agent 一样让机器保持唤醒。
删除侧通过 dropStatusEntry 路由,而不是 clearPaneState:pane 状态清除会到达 renderer,而在 PR 2 之前,renderer 自己的 feed bridge 是该 pane key 的 writer。它还传递 preserveResumeIdentity: false —— 被用户关闭的 pane 保留的 providerSessionOnly 残留之所以存在,是为了让 agent 可以在该 pane 中恢复,而结构化会话没有 pane,并将恢复身份保留在 record store 中。与其他所有 dropStatusEntry 调用方一样,它不发出 pane 清除,因此在 working 中途被删除的会话会让 AgentSessionTransitionRecorder 保持一个打开的 stats 会话,直到其 LRU 将其驱逐;该缺口与用户关闭路径共享,并非结构化行特有。
ingest 位于 feed 中,而不是位于 structured-agent-session-host.ts 中,后者已达到文件长度上限。
worktree ps 变为读取器¶
19217 中添加的结构化 adapter 已被删除,结构化行通过与其他所有行相同的快照到达 worktree ps。collectRuntimeWorktreePtyAgentSources 中的保留与 hook 对账一直保留到 PR 1b 删除了为其提供数据的 store。这一步确定的是准入门控,它决定 worktree 列表可以显示哪些行:¶
- hook 或 OSC 行需要其 tab 被镜像或存在已连接的 pty,与当前一样,SSH 行仍然豁免,因为其 tab 可能只存在于远程;
- 携带
structuredHost的行在宿主持有会话期间被准入,而宿主在关闭时的删除才是将其移除的原因。没有 tab 镜像要求:结构化会话的 tab 位于 renderer 自己的 tab 状态中,无头宿主没有 renderer 可供镜像。该论证只有在无头宿主本身已连接到 store 时才成立,这是每个入口点的单独义务:Electron 宿主(desktop 和orca serve)共享main-process-runtime-service.ts,而orcad在src/main/orcad/orcad-entry.ts中构造自己的 runtime。缺少该连接的宿主不会列出任何 agent,而不仅仅是没有结构化 agent,因为worktree ps对每一行读取相同的快照。
针对宿主拥有的结构化行的新鲜度绕过已经存在于 isFreshNonDoneAgentStatus 中;现在标志位于行上,它成为唯一路径,runtime-worktree-agent-rows.ts 中的手工检查被移除。
线协议兼容性¶
AgentStatusIpcPayload 增加一个可选字段 structuredHost,worktree ps 行增加 structuredHostOwned。根据 remote-wire-compatibility.md 的规则 1,两者都是安全的:旧客户端会忽略它们。worktree ps 行保持其形状和词汇,因此移动应用看不到变化。
在 PR 2 之前,主进程不会通过 agentStatus:set 或 agentStatus:getSnapshot 将结构化行转发到 renderer。renderer 的 feed bridge 仍然自行写入这些行,如果也转发它们,就会让一个 pane key 拥有两个 writer。移除该过滤器是 PR 2 的第一步。
PR 1b:运行时保留行存储已删除¶
已落地。RuntimeAgentRowStore 已移除,随之移除的还有 collectRuntimeWorktreePtyAgentSources 中的保留与 hook 对账。hook server 的 store 现在是 PTY agent 行的唯一主进程副本。
五个调用点¶
| 调用点 | 之前 | 之后 |
|---|---|---|
orca-runtime-create-terminal-side-effect-command-code-detector.ts retain() |
OSC 负载的第二次写入,该负载已经发送到 hook 服务器 | 已删除;事件现在携带 pane 的 terminalHandle,hook 摄取保留唯一副本 |
...command-code-detector.ts clearPty() |
在 pty 退出时丢弃行 | 已删除;pane 拆除已经清除 hook 行 |
orca-runtime-get-worktree-ps.ts values() |
提供 retainedSnapshots |
已删除;读取方只保留 hookSnapshots |
orca-runtime-serialize-agent-prompt-submission.ts getFreshExplicit() |
先保留行,后 hook 行 | selectFreshExplicitAgentStatus,仅 hook 行 |
orca-runtime-prune-mobile-session-tab-group-layout.ts getFreshForMobile() |
pane 键,然后 pty id | selectFreshAgentRowForMobileTab:pane 键,然后 terminalHandle |
两个读取方都移入了 runtime-hook-agent-row-selection.ts,由于现在没有任何东西保留快照,它现在也拥有 RuntimeAgentRowSnapshot。
terminalHandle 是行回连到其终端的关联键¶
保留存储中唯一真正多出来的内容是 pty id,并且有两个读取方使用了它。
原计划是把事件的 ptyId 写入 terminalHandle;这是错误的。
终端句柄(term_<uuid>)和 pty id 是不同的标识符,
而 getFreshExplicit 已经在将 hook 行与真实句柄进行比较。实际落地的是:
AgentHookEventPayload和运行时终端状态事件新增了一个可选的terminalHandle。检测器通过getAgentStatusTerminalHandleForPaneKey对每个块解析一次——这与面向渲染器的 IPC 边界为每一行已经执行的查找相同, 因此两个表面不会就某个 pane 属于哪个终端产生分歧。- 当传入事件未解析出任何句柄时,
applyNormalizedStatus会向前传递该句柄。 只有 main 的 OSC 解析能够解析出句柄,否则针对同一 pane 的 HTTP hook 提交 会将其擦除。 - 它永远不会被持久化。句柄属于发出它的运行时,而一个已恢复的句柄只会把某行 重新关联到别人的终端。
toAgentStatusIpcPayload会发布它,这也让getFreshExplicit中早已失效的 句柄比较重新生效:运行时读取的是原始快照行,而在此之前没有任何东西曾写入 该字段。
worktree ps 也使用它。ConnectedPtyEvidence 将其扁平的 ptyIds 集合
替换为 ptyIdByTerminalHandle,因此某行仍然可以解析其背后的已连接 PTY
——这既是工作终端汇总的匹配键,也是当某行的 pane 绑定因控制器实例变化
而被置空时的最后补救。
变更检测器必须随存储一起迁移¶
retain() 不仅仅是一个存储:它的布尔返回值是用于在仅状态转换时重新发布
session.tabs 的信号,而任何标题变更都不覆盖这种情况(#7970)。
hook-status-session-tabs-invalidation.ts 已经镜像了该投影变更集,
包括恢复来源和终端句柄关联,因此替代方案是将信号从存储中路由出去,
而不是构建第二个比较器。
installHookStatusSessionTabsRepublish 现在拥有全部三个分支——增强状态、
pane 清除以及关闭操作发出的状态丢弃 tap——并且两个宿主都会安装它。
两个宿主,而不仅是桌面宿主¶
orcad 构造其运行时时没有 onTerminalAgentStatus,因此 main 的 OSC 解析
在那里从未到达存储,保留的副本是唯一载体。如果删除它而不接入 orcad,
无头宿主将完全无法列出任何 PTY 代理。orcad-entry.ts 现在绑定生产者
并安装重新发布信号,同时保留它已有的快照和结构化 sink。
预期的行为变更¶
用户在桌面上关闭的行会立即从 worktree ps 和手机中消失,
而不是一直保留到 pty 退出。一个存储意味着一次关闭。
遗留的数字 pane 键仍然是一个有界的兼容情况。持久化布局会为其稳定的叶子所有者 注册别名;进程内 OSC 观察也只有在运行时提供匹配的 tab、PTY 和终端句柄时, 才可能保留一个数字键。HTTP 和 relay 入口仍然要求稳定的键或已注册的别名, 并且数字行永远不会被持久化。
PR 2:渲染器订阅¶
随着结构化行通过 agentStatus:set 到达,渲染器的
StructuredAgentSessionStatusBridge 不再需要写入状态;其卸载清理变成
向宿主发送的 tab 关闭信号。IPC 应用器是观察状态的唯一写入方。
2026-09-09 的审计整理了其他写入方:
| 写入方 | 处置 |
|---|---|
| Command Code 输出种子、停放 pane 种子、pty 退出移除 | 删除;main 已经发出相同的事实 |
| 写入方 | 处置 |
|---|---|
| 结构化桥接状态写入 | 删除;main 现在发布该行 |
| 启动占位种子(用户使用 prompt 启动了 agent) | 暂时保留;main 持有启动配置,之后可以播种 |
| 关闭、确认、卸载 | 保留;用户事实和组件生命周期 |
| 远程运行时 OSC 解析(字节从不经过本地 main) | 保留,在主机足够新时置于主机已发布行的围栏之后;线协议文档规则 3 适用 |
| web-session 镜像接收时钟 | 保留;衰减规则需要来自同一台机器的两个时钟 |
Command Code 的 done-settle 窗口是渲染器策略,main 中没有对应项。PR 2 要么将其移入 main 的检测器,要么保留,并说明选择哪一种。
PR 3:一个汇总,一个时钟¶
worktree 卡片状态被派生三次:渲染器中的 lib/worktree-status.ts、main 中的 runtime-worktree-status-projection.ts,以及 mobile 中的 agent-row-display.ts,后者手工复制了 30 分钟常量。PR 3 将汇总和衰减移入 src/shared,并让这三处都调用它。
不变的内容¶
- 钩子脚本、OSC 9999 线格式和 relay 协议。
- 状态词汇表。行使用
working / blocked / done,结构化摘要使用working / attention / idle,只映射一次。 - 远程工作的
live / unverifiable / exited判定。失去联系不会清除任何内容;准入门中的 SSH 豁免保持不变。 - 水合诚实性:恢复的非 done 行是
restoredUnconfirmed,并且永远不会是新鲜状态。
PR 1b 可靠性契约¶
- 不变量(
agent-session.status-host-ownership): 每个执行主机有一个 agent-status 存储;OSC、钩子和结构化会话写入它,而 desktop、worktree ps和 mobile 只投影它。关闭、经过认证的 PTY 退出以及 provider-generation 替换会在所有位置移除同一行;仅传输丢失不会移除任何内容。 - 故障来源: 被删除的运行时行存储重复了 OSC 观测,使用不同的终端身份作为键,并且在来自钩子存储的关闭之后仍然存活。Relay 重放也可能在读取方使用其新的投递时间戳时,让旧证据看起来是新鲜的。
- Oracle: 一个 OSC 观测通过钩子快照出现在
worktree ps和 mobile 中,一次存储关闭会从两者中移除它,而不会停止 PTY。聚焦测试还要求 leaf/incarnation-handle 重新加入、旧版 numeric-pane 兼容性、认证退出和 provider-generation 清理、证据年龄新鲜度,以及恰好一次的启动/停止清理。 - 门:
terminal-performance.osc-status-scan-budget覆盖未更改的有界 OSC 解析器和运行时投影。目前还没有专门的多表面状态存储阻塞门;下面的聚焦测试套件是被接受的缺口,直到它们积累可靠性门浸泡证据。 - Provider/平台覆盖: 本地和 daemon 支持的 PTY 由运行时测试覆盖,SSH relay 丢失/重放语义由 relay 集成测试覆盖。该投影由 git worktrees 和文件夹工作区共享。WSL 使用相同的存储和准入代码,但这里没有实时运行;Linux 和 Windows 运行时执行、原生 mobile 客户端以及混合版本配对客户端仍然是验证缺口。
- 性能预算: 发布保持事件驱动,没有新的轮询或子进程。一个 mobile 投影克隆一次状态快照,构建一次 pane/handle 索引,并有一个确定性的调用次数测试;生命周期清理受现有状态和 handle 清单约束,orcad 测试证明监听器在启动失败和重复停止时各清理一次。
- 诊断: 现有 hook-listener 错误会指明 pane 和 PTY,而 status-store 测试固定投递时钟与证据时钟。不会发出新的遥测或原始终端数据。
- 剩余缺口: 渲染的 Electron/mobile 行为、实时 SSH 重连以及 Linux/Windows/WSL 执行需要平台 QA 通过。当前跨版本门不覆盖
session.tabs内容。
验证¶
- 单元测试:摄取一个结构化摘要,并通过
getStatusSnapshot、worktree ps和 mobile 投影读回;断言序列化器从不写入携带structuredHost的行;断言某个水合文件如果以某种方式包含一个,则会被丢弃。 - 单元测试:针对保留存储编写的
worktree ps测试套件被重新接线到真实的AgentHookServer(agent-status-store-wiring.test-fixture.ts),而不是删除,因此每个测试仍然断言其命名的列表行为。关闭变更在orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts中端到端固定,如果恢复保留存储,该测试会失败。 - 实时:将 #19217 中的 parity 检查(working、done、close、reload)针对合并后的存储重复执行,两个表面都从同一行读取。
已退役 OMP 窗格恢复¶
桌面渲染器退役通过现有的 agentStatus:retirePaneAuthority IPC 消息携带一个可选 UUID。钩子服务器使用其有界退役围栏保留它。经过验证的实时 OMP 新回合会消费该 UUID,并仅在实时通知中回显 authorityRestartId。缓存行、持久化和启动重放从不携带该确认。较旧的对等端会省略或忽略它,并保留显式 attach 恢复。
渲染器将其 UUID 保留在现有的非持久化退役墓碑中;
每次重新退役都会生成一个新的 UUID。匹配的确认只有在针对现有窗格且
匹配的 workspace/connection 成功写入状态后,才能清除该墓碑。已关闭的墓碑保持 true,
包括在标签页 LRU 逐出其条目之后。关闭一个已退役的物理别名会撤销其整个组。
这是控制平面退役关联,而不是第二个 agent-status 存储。
回退通过现有的 attach-restoration 路径恢复 hook 服务器记录的状态别名。 被接受的渲染器写入也会恢复匹配的状态别名路由,从而为下一次退役保留组成员身份。它不会 恢复编排或启动凭据。 其作用范围限定为发起请求的桌面渲染器。不同窗口的退役 UUID 无法通过该确认清除,Web 镜像保持其现有的 host-snapshot/attach 行为。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca