docs/plan/technical-design/session-runtime-data.md
Rama main · réplica de lectura del repositorio original.
# 会话运行时数据目录(Session Runtime)技术方案
> 版本:v1.0 | 日期:2026-07-06 | 状态:已实现
## 1. 背景与问题
### 1.1 现状
当前平台在**用户工作区**(`Session.workspace` 指向的项目目录)内写入隐藏目录 `.mao/`,主要包括:
| 子目录 / 文件 | 写入方 | 用途 |
|--------------|--------|------|
| `.mao/skills/` | `SkillSyncService`(CLOUD)、Electron `skill-sync`(LOCAL) | Agent 技能同步副本,供 `read_file` 读取 `SKILL.md` |
| `.mao/shellOutput/` | `ShellSessionManager`(CLOUD)、Electron shell 工具(LOCAL) | Shell 超长输出落盘 |
| `.mao/git-askpass.sh` | `ShellSessionManager`(CLOUD,有 Git 凭证时) | Git HTTPS 认证辅助脚本 |
相关代码:
- `backend/.../harness/skill/SkillSyncService.java` — `SKILLS_DIR_NAME = ".mao/skills"`
- `backend/.../harness/shell/ShellSessionManager.java` — `OUTPUT_DIR = ".mao/shellOutput"`
- `backend/.../harness/core/PromptEngine.java` — Prompt 注入 `.mao/skills/...` 相对路径
- `desktop/electron/main.cjs` — LOCAL 模式 skill 解压与 shell 输出落盘
### 1.2 问题
1. **污染用户项目**:LOCAL 模式下工作区往往是真实 Git 仓库,`.mao` 会出现在文件树中,可能被 `git status` 看到。
2. **职责混淆**:Skills 源文件已在 `${app.root-dir}/data/skills` 与 `${app.root-dir}/data/userskills` 维护;工作区内的 `.mao/skills` 只是运行时副本,不应与项目代码混放。
3. **与平台数据目录不一致**:日志、上传、工作区根目录等已统一在 `app.root-dir`(默认 `/opt/mao` 或 `~/.mao`)下分层,`.mao` 进项目目录是历史遗留设计。
### 1.3 目标
将 **skills 同步副本、shell 输出、git-askpass 脚本** 从用户工作区迁出,放入按**会话**隔离的平台运行时目录(Session Runtime),使用户项目目录只保留业务代码。
---
## 2. 核心设计
### 2.1 隔离粒度:按会话(session.id)
本方案中的「工作区」在隔离语义上指**会话**,即使用 `Session.id` 作为 runtime 路径的关键段。
- 每个会话拥有独立的 `skills/`、`shellOutput/` 目录。
- 同一云端共享项目(`projects/{slug}`)下的多个会话,各自维护一份 skills 副本(接受一定磁盘冗余,换取实现简单与会话级隔离)。
- 子会话(`SUBAGENT`、`SIDE_TASK`)使用**自身**的 `session.id`,不复用父会话 runtime。
### 2.2 Skills 的管理维度 vs 运行维度
| 维度 | 说明 |
|------|------|
| **管理维护** | 按 Agent 配置(`agent.skillNames`)+ 用户个人 Skills(`user-skills-dir`) |
| **运行隔离** | 按会话:同步结果写入该会话的 runtime 目录;换 Agent 时通过移除逻辑清理不再需要的 skill |
同步状态键由 `agentId:workspace`(绝对路径)改为 **`agentId:sessionId`**。
### 2.3 目录布局
#### CLOUD(服务端)
```text
${app.harness.runtime-dir}/{userId}/{sessionId}/
├── skills/
│ ├── {skillName}/
│ │ └── SKILL.md
│ └── .sync-manifest.json
├── shellOutput/
│ └── {shellSessionId}_{seq}.out
└── git-askpass.sh # 按需生成
```
示例:
```text
/opt/mao/data/runtime/1/123/skills/bigdata-cli/SKILL.md
/opt/mao/data/runtime/1/123/shellOutput/sh-123-1700000000000_1.out
/opt/mao/data/runtime/1/123/git-askpass.sh
```
#### LOCAL(桌面端 Electron)
固定根目录,**不做可配置项**:
```text
~/.mao/runtime/{sessionId}/
├── skills/
├── shellOutput/
└── git-askpass.sh # 若 LOCAL 后续支持 Git 凭证则同理
```
示例:
```text
~/.mao/runtime/123/skills/bigdata-cli/SKILL.md
~/.mao/runtime/123/shellOutput/sh-123-1700000000000_1.out
```
> LOCAL 路径不含 `userId` 前缀:桌面端默认单 OS 用户,与 `~/.mao/logs` 等现有约定一致。
### 2.4 与用户工作区的关系
```text
Session.workspace → 用户项目目录(代码、Git 仓库),工具 cwd 仍在此
Session Runtime(本方案) → 平台内部数据,按 session.id 隔离,不在项目树内
CLOUD 示例:
workspace: /opt/mao/data/workspace/1/projects/my-app
runtime: /opt/mao/data/runtime/1/123/
LOCAL 示例:
workspace: /Users/dev/my-repo
runtime: ~/.mao/runtime/123/
```
---
## 3. 配置
### 3.1 新增配置项(仅服务端)
```yaml
app:
harness:
runtime-dir: ${RUNTIME_DIR:${app.root-dir}/data/runtime}
```
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `app.harness.runtime-dir` | `${app.root-dir}/data/runtime` | CLOUD 模式会话运行时数据根目录 |
LOCAL 端固定为 `path.join(os.homedir(), '.mao', 'runtime')`,不读取 Spring 配置。
### 3.2 与现有目录的关系
```text
${app.root-dir}/
├── data/
│ ├── workspace/ # 用户项目文件(已有)
│ ├── skills/ # 系统 Skills 源(已有)
│ ├── userskills/ # 用户 Skills 源(已有)
│ ├── uploads/ # 上传文件(已有)
│ └── runtime/ # 本会话运行时数据(新增)
└── logs/
```
---
## 4. 路径解析与访问控制
### 4.1 RuntimeDataResolver(新增)
统一封装 runtime 路径计算,供 `SkillSyncService`、`ShellSessionManager`、`PromptEngine` 等调用:
```java
public final class RuntimeDataResolver {
/** CLOUD: {runtime-dir}/{userId}/{sessionId} */
public Path resolveSessionRuntimeDir(Long userId, Long sessionId);
public Path resolveSkillsDir(Long userId, Long sessionId);
public Path resolveShellOutputDir(Long userId, Long sessionId);
public Path resolveGitAskpassScript(Long userId, Long sessionId);
/** LOCAL Prompt 用:~/.mao/runtime/{sessionId}/skills/... */
public String formatLocalSkillsPath(Long sessionId, String skillName);
/** CLOUD Prompt 用:绝对路径 */
public String formatCloudSkillsPath(Long userId, Long sessionId, String skillName);
}
```
Electron 侧实现等价函数(`resolveLocalRuntimeDir(sessionId)` 等)。
### 4.2 PathSandbox 扩展
runtime 目录在用户工作区**之外**,`read_file` / `write_file` 等工具需能访问:
1. 会话开始或首次写入 runtime 时,对该会话的 runtime 根目录调用 `pathSandbox.addAllowedRoot(runtimeDir)`。
2. `resolve()` 对已注册的 `allowedRoots` 保持现有放行逻辑。
### 4.3 Prompt 路径策略
| 模式 | Prompt 中 skills 路径格式 | 说明 |
|------|---------------------------|------|
| **CLOUD** | 服务端绝对路径 | 例:`/opt/mao/data/runtime/1/123/skills/foo/SKILL.md` |
| **LOCAL** | `~` 前缀路径 | 例:`~/.mao/runtime/123/skills/foo/SKILL.md`;Electron `read_file` 须展开 `~` |
Shell 截断返回的 `output_file` 与上述规则一致:
- CLOUD:服务端绝对路径
- LOCAL:`~/.mao/runtime/{sessionId}/shellOutput/{file}`
> LOCAL 不使用服务端绝对路径的原因:Prompt 在服务端生成,无法获知客户端 OS 用户名与家目录。
### 4.4 read_file 工具变更
**服务端(CLOUD)**
- `ReadFileTool` 在 `pathSandbox.resolve()` 前,对以 `~` 开头的路径不做处理(仅 LOCAL 委托场景不涉及服务端读文件)。
- 绝对路径落在已注册的 runtime `allowedRoot` 内则允许读取。
**客户端(LOCAL)**
- `resolveWorkspacePath()` 或 read 工具入口:若路径以 `~` 开头,替换为 `os.homedir()` 后再访问。
- 绝对路径若落在 `~/.mao/runtime/{sessionId}/` 下则允许(不再要求位于 `Session.workspace` 内)。
---
## 5. 各模块改造要点
### 5.1 SkillSyncService
| 项目 | 改前 | 改后 |
|------|------|------|
| 同步目标 | `{workspace}/.mao/skills/` | `{runtime-dir}/{userId}/{sessionId}/skills/` |
| 方法签名 | `syncToWorkspace(agent, workspace, userId)` | 增加 `sessionId` 参数,或传入 `Session` 对象 |
| 状态键 | `agentId:workspace` | `agentId:sessionId` |
| `cleanWorkspace` | 删除工作区内 `.mao/skills` | 重命名为 `cleanSessionRuntime(sessionId)` 或暂不实现(见 §7) |
LOCAL 模式仍通过 `writeSyncZip` 打包;Electron 解压目标改为 `~/.mao/runtime/{sessionId}/skills/`。
### 5.2 ShellSessionManager
| 项目 | 改前 | 改后 |
|------|------|------|
| 输出目录 | `{workspace}/.mao/shellOutput/` | `{runtime-dir}/{userId}/{sessionId}/shellOutput/` |
| git-askpass | `{workspace}/.mao/git-askpass.sh` | `{runtime-dir}/{userId}/{sessionId}/git-askpass.sh` |
| Shell cwd | `Session.workspace` | **不变**,仍在用户项目目录执行命令 |
`createSession` 需增加 `userId`、`sessionId` 参数以定位 runtime 目录。
### 5.3 PromptEngine
- 删除「技能会同步到工作区的 `.mao/skills/`」表述。
- `buildRelativeSkillCatalog` 改为 `buildSkillCatalog`,按执行模式注入绝对路径或 `~` 路径(见 §4.3)。
- 需从 `AgentExecutionContext` 获取 `sessionId`、`userId`、`executionMode`。
### 5.4 StreamingWsHandler
skill 同步调用处传入 `session.getId()`;LOCAL 的 `skill_sync_required` 事件需让客户端知晓 `sessionId`(通常 WS 已绑定 session,IPC 沿用现有 `sessionId` 参数)。
### 5.5 desktop/electron/main.cjs
| 位置 | 改动 |
|------|------|
| `skill-sync` IPC | 解压到 `~/.mao/runtime/{sessionId}/skills/` |
| `truncateAndSave` | 输出写入 `~/.mao/runtime/{sessionId}/shellOutput/` |
| `read_file` 等工具 | 支持 `~` 展开与 runtime 绝对路径 |
### 5.6 配置示例文件
在 `application-example.yml`、`application-acg.yml` 等 profile 中补充:
```yaml
app:
harness:
runtime-dir: ${RUNTIME_DIR:${app.root-dir}/data/runtime}
```
---
## 6. 数据流示意
### 6.1 CLOUD — Skills 同步
```text
用户发送消息
→ StreamingWsHandler
→ skillSyncService.syncToSession(agent, userId, sessionId)
→ 拷贝到 /opt/mao/data/runtime/{userId}/{sessionId}/skills/
→ pathSandbox.addAllowedRoot(runtimeDir)
→ PromptEngine 注入 /opt/mao/data/runtime/.../SKILL.md
→ Agent 调用 read_file(绝对路径) → PathSandbox 放行
```
### 6.2 LOCAL — Skills 同步
```text
用户发送消息
→ StreamingWsHandler 触发 skill_sync_required
→ Electron skill-sync(sessionId, ...)
→ 下载 zip → 解压到 ~/.mao/runtime/{sessionId}/skills/
→ PromptEngine 注入 ~/.mao/runtime/{sessionId}/skills/.../SKILL.md
→ Agent 调用 read_file → Electron 展开 ~ 后读取
```
### 6.3 Shell 输出落盘
```text
Shell 命令输出超长
→ 预览 + truncated: true
→ output_file: {runtime}/shellOutput/{shellSessionId}_{seq}.out
→ Agent 可用 read_file 读取完整输出
```
---
## 7. 范围外(本期不做)
| 项 | 说明 |
|----|------|
| **runtime 目录清理** | 会话删除或归档时不级联删除 runtime;后续可加 TTL 或手动清理 |
| **历史 `.mao` 迁移** | 不自动搬迁工作区内已有 `.mao`;新会话走新路径,旧目录可手动删除 |
| **LOCAL git-askpass** | 若 LOCAL 尚无 Git 凭证注入,本期可不改;架构上预留同路径 |
---
## 8. 实现清单
| 序号 | 任务 | 文件 / 模块 |
|------|------|-------------|
| 1 | 新增 `RuntimeDataResolver` | `backend/.../harness/runtime/` |
| 2 | 配置项 `app.harness.runtime-dir` | `application-*.yml` |
| 3 | 改造 Skill 同步与状态键 | `SkillSyncService` |
| 4 | 改造 Shell 输出与 git-askpass | `ShellSessionManager` |
| 5 | 改造 Prompt 技能路径 | `PromptEngine`、`AgentExecutionContext` |
| 6 | 注册 runtime allowedRoot | `StreamingWsHandler` 或会话执行入口 |
| 7 | PathSandbox / ReadFile 绝对路径 | `PathSandbox`、`ReadFileTool` |
| 8 | Electron runtime 路径与 `~` 展开 | `desktop/electron/main.cjs` |
| 9 | 更新相关设计文档交叉引用 | `skill-workspace-sync.md` 等(注明被本方案替代) |
---
## 9. 风险与注意事项
| 风险 | 缓解 |
|------|------|
| 同项目多会话重复同步 Skills | 接受冗余;后续可按需做共享缓存 |
| LOCAL `~` 路径在 Prompt 中不够「绝对」 | 文档与工具层统一约定;Electron 必须实现 `~` 展开 |
| 子会话 runtime 独立 | 子 Agent 需单独 skill 同步;与现有子会话模型一致 |
| 工作区文件浏览器不展示 skills | 预期行为;skills 非项目文件 |
---
## 10. 相关文档
- [Skills 工作区同步方案](./skill-workspace-sync.md) — 旧方案(`.skills/` / `.mao/skills` 在工作区内),**由本文档替代**;其第 11 节记录了下方「本地未同步 Skill」的详细实现
- [Shell 会话系统技术方案](../2026-05-29-shell-session-design.md) — shellOutput 落盘机制
- [用户 Git 凭证设计](../design/user-git-credential-design.md) — git-askpass 脚本(路径将迁至 runtime)
- [云端模式可选工作区](../2026-07-01-cloud-workspace-project-design.md) — `Session.workspace` 与项目共享
---
## 11. 后续演进:LOCAL 模式本地未同步 Skill
在上述 runtime 目录方案基础上,LOCAL 模式进一步支持**无需同步/上传**即可使用桌面端本地 Skill:
- 桌面端「技能管理」抽屉扫描到的 `~/.agents/skills` 下未上传 Skill,会在 LOCAL 模式发送消息时随 WS 消息一并上报(`LocalSkillRegistry`,内存态、按 `sessionId`)。
- `PromptEngine` 为这些 Skill 注入桌面端本机路径(`~/.agents/skills/{folderName}/SKILL.md`),而非本节的 runtime 副本路径;`read_file` 由 `LocalToolExecutor` 委托 Electron 直接读取本机文件,不经过 `SkillSyncService` 的 CLOUD 拷贝 / LOCAL zip 下发流程。
- CLOUD 模式不受影响:仍要求 Skill 已上传(写入 `user-skills-dir`)才能参与 `SkillSyncService.syncToSession`。
详见 [Skills 工作区同步方案 §11](./skill-workspace-sync.md#11-local-模式本地未上传-skill-直接可用无需上传)。