`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean.
8.4 KiB
Agent Note: 跨 workspace 会话恢复
Status: implemented
English | 中文
Problem
/resume 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。
存储是那个决定性的原因。已交付的 TUI 组合把持久化根默认成相对路径 ./.sessions,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 session-query.db。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在同一个根目录内部按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。
接着选择器又过滤了一次。它在展示前丢弃 cwd 与当前会话不同的记录,而 summarizeResumeCandidate 又独立地把不同的 cwd 标记为 disabledReason: 'different workspace',于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。
最后,恢复流程从不切换目录。宿主通过 process.execve 重新执行 dsh --resume=<id>,而它会继承 cwd。会话头部的 cwd 会从日志中还原,但 dsh-fs-local、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。
Decision
dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
存储。 dsh-paths 以 resolveSessionsRoot() 拥有该位置(按 resolveDshHome 的优先级,取 Harness home 下的 sessions),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 SESSIONS_ROOT_KEY 启动槽位(在 Loader 条目挂载前 ctx.provide)提供该根目录,dsh web 则在 apps/cli/src/app-cli-entry.ts 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 join,这与 run 已有的 registryRoot() 先例一致。
共享 base 直接在配置项自身表达这一优先级:apps/cli/base.cordis.yml 的 session-persistence-jsonl 配置项写作 root: !!js launcherSessionsRoot ?? './.sessions',因此启动器槽位优先,而没有槽位的裸启动则保留项目本地默认值。把它写成配置项自身的 !!js 取值、而不是 schemastery 的 .default(),其原因与在组合包中相同:schema 默认值会在能够读取槽位之前就物化。若 overlay 或个人 patch 显式声明了根目录,则始终以其为准——对于要求自洽封闭的部署,这仍是正确选择。
是范围,不是排除。 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。showResume() 汇总每一条记录,ResumePicker 持有一个 'workspace' | 'all' 的 scope,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。
因此 summarizeResumeCandidate 去掉了 'different workspace',并新增 'session has no recorded workspace'。这是一条真正新增的拒绝理由,而不是改名:没有 cwd 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。
交接。 TuiResumeHost.handoff 在 SessionId 之外还接收目标 cwd。preflightResume 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 cwd 发生了移动的记录,会在重新读取到的目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。resumeArgs 只在目标就是本 checkout 时才保留 meta 子命令形式,因为 dsh meta 会切换到 harness 源码本身,从而覆盖任何其他 workspace。
Alternatives considered
从 dsh 启动器给 persistenceRoot 打补丁,而不是改动组合包默认值。 在发现 loader 补丁会整体赋值 config 之后否决。个人的 ~/.dsh/config.yaml 覆盖层已经用一份局部配置给 tui-agent 那一项打了补丁,这恰恰就是 persistenceRoot 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。
保留 ./.sessions,并额外扫描 Harness home 根目录。 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。
把现有的项目本地日志迁移到共享根目录。 被需求方否决。项目 ./.sessions 下的会话仍留在磁盘上,从该目录显式执行 dsh --resume <id> 仍可恢复,只是不再出现在 /resume 中。
把所有 workspace 铺成一个扁平列表。 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。
让宿主从还原后的会话头部推断目录。 否决:会话头部是面向模型与提示词的状态,在启动之后才还原,而目录必须在 execve 之前进入。显式传递它能让这个顺序在边界处保持可见。
Consequences
- 已经存放在项目本地
./.sessions下的会话会从/resume中消失。这是不做迁移所接受的代价。 - 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。
record.live来自进程内的SessionQueryService,因此预检只会拒绝在本运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的seq计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:SessionRegistry.list()已经为dsh list-sessions在同一个 Harness home 下跨进程发布活跃会话,因此在summarizeResumeCandidate中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。 - 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
Testing
TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。dsh-paths 测试固定 resolveSessionsRoot 的优先级与 resolveDshHome 的一致。apps/cli 测试钉住项目本地默认值与派生的 session-query.db 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。