# run9 API 参考与 Investibook 执行迁移评估

> 来源：`https://run.sys9.ai/docs/execute-commands` 与 `https://api.run.sys9.ai/swagger-ui/index.html` 当前暴露的 `/swagger.yaml`。
> 评估对象：把 Investibook 的 agent 执行从 backend 本地子进程迁移到 run9 box 内执行。

## 结论

可以迁移，但不是把 `subprocess_exec(opencode acp)` 简单替换成一次 `box exec`。Investibook 当前依赖一个长生命周期的 ACP 子进程、stdio 双向流、workspace 本地文件系统、session resume、cancel、tool 事件归一化和 token/cost 统计。run9 提供的是远程 box、命令执行、文件上传下载、background exec 输出拉取和 stdin 写入能力。

建议先做 **remote runtime provider**，保留现有 `AgentSession` / SSE / message persistence 抽象，只把“agent 进程在哪里跑”替换为 run9。工作量评估为 **中到偏大**：

- PoC：2-4 天。目标是一个 workspace 可以在 run9 box 内启动 `opencode acp`，并完成一轮聊天。
- 可用版本：1-2 周。覆盖 session 复用、远程文件展示、cancel、日志、错误处理、基本回收。
- 生产化：3-5 周。覆盖配额、隔离、安全、镜像管理、观测、故障恢复、压测、灰度和回滚。

如果只是让 agent 的 shell 工具命令跑到 run9，而 ACP agent 仍在 backend 本地跑，适配会小很多；但这不是“Investibook 的执行整体搬到 run9”，也解决不了 backend 承载长生命周期 agent 进程的问题。

## 当前实现状态

本分支已经落地第一阶段基础链路：

- `Run9Client`：box create/get、background exec、pull output、32 KiB stdin 分片、kill、stop、file streaming。
- `Run9ProcessTransport`：向 ACP 和 Pi RPC 暴露 subprocess 风格 stdio；stdout 保持纯协议，stderr 写入 box 内日志。
- `SandboxProvider`：统一隔离进程、远端 workspace storage、命令超时和 provider selection 契约。
- `Run9SandboxProvider`：作为第一种 sandbox provider，封装 Run9 command、box、bootstrap、process 和 storage 实现。
- `AgentSession`：从 `INVESTIBOOK_AGENT_SANDBOX_PROVIDERS` 选择 sandbox provider，默认空列表（本地执行）；支持 `INVESTIBOOK_SANDBOX_WORKSPACE_ALLOWLIST` 灰度。
- 一 workspace 一 box；每次复用前校验 `workspace_id + owner_user_id + environment` labels。
- Run9 模式关闭 ACP backend filesystem capability，避免远程 `/workspace` 路径落到 backend 本地。
- `/files` 在 box 内运行只输出 metadata 的 scanner；`/file` 仅 canonical owner 可访问，文件从 Run9 流式返回，不写 backend 磁盘。
- evidence renderer 需要读取 JSON 时只做有大小限制的内存读取，不持久化内容。

尚未完成真实 Run9 环境 e2e、source snap/image 发布、box ownership transfer、idle stop/reconciler 和 S3 mount。开关保持默认 local，完成 e2e 前不应全量启用。

## run9 核心概念

### Box

Box 是持久化执行环境。命令执行时 box 会自动唤醒，空闲后可以停止；box 内文件系统跨命令持久化。

CLI 语义：

```bash
sys9 run box create work
sys9 run box exec work uname -a
sys9 run box exec work --workdir /work/app git status
sys9 run box exec work -e NODE_ENV=test printenv NODE_ENV
sys9 run box stop work
```

注意：每次 `box exec` 是一个新的进程，`cd`、shell 变量、alias 不会跨 exec 保留。需要持续 shell 状态、管道、重定向、here-doc、多步交互或长期任务时，应使用交互 shell 或 background exec。

### Snap

Snap 是 box 文件系统状态的快照/分叉机制。适合做 Investibook 的基础运行环境：

- 预装 `opencode` 或其他 ACP runtime。
- 预装 Python/Node/Bun/uv/pnpm、诊断 skill 依赖和常用 CLI。
- 用 shared snap 或 source snap 创建 workspace box，减少冷启动安装时间。

### Exec

Exec 是在 box 内运行的命令。API 上分为：

- foreground exec：一次命令，返回 `ExecView` 或通过 stream 接收输出。
- background exec：长期命令，返回 exec id，后续 pull output / write stdin / kill。

Investibook 的 ACP agent 更接近 background exec：需要长期保持进程，并通过 stdin/stdout 进行双向协议通信。

## API 认证

Swagger 定义了两类认证：

- `BasicAuth`
- `SessionToken`：`Authorization` header API key

所有 workspace / project / box / exec / snap API 都需要认证。Investibook backend 应统一封装 run9 client，避免在业务逻辑中散落鉴权 header。

## API Endpoint 参考

Base URL:

```text
https://api.run.sys9.ai
```

### 身份与项目

| Method | Path | 用途 |
| --- | --- | --- |
| `GET` | `/whoami` | 当前 org-scoped caller |
| `GET` | `/projects` | 列出当前 org 的 projects |
| `POST` | `/projects` | 创建 project |
| `GET` | `/projects/{project_cid}` | 获取 project |
| `PATCH` | `/projects/{project_cid}` | 更新 project |
| `DELETE` | `/projects/{project_cid}` | 删除 project |

`CreateProjectPayload`:

```json
{
  "display_name": "Investibook",
  "description": "Remote runtime project"
}
```

建议：生产环境固定使用一个 run9 project，Investibook workspace 映射到 run9 box，而不是每个 Investibook workspace 创建一个 run9 project。

### Box 管理

| Method | Path | 用途 |
| --- | --- | --- |
| `GET` | `/projects/{project_cid}/workspace/boxes` | 列出 boxes，可按 creator/state/label 过滤 |
| `POST` | `/projects/{project_cid}/workspace/boxes` | 创建 box |
| `GET` | `/projects/{project_cid}/workspace/boxes/{id}` | 获取 box |
| `PATCH` | `/projects/{project_cid}/workspace/boxes/{id}` | 更新 box |
| `DELETE` | `/projects/{project_cid}/workspace/boxes/{id}` | 删除 box |
| `POST` | `/projects/{project_cid}/workspace/boxes/{id}/stop` | 停止 box runtime |

`CreateBoxPayload` 关键字段：

```json
{
  "box_id": "ib-dev-ws-xxx",
  "description": "Investibook workspace ws-xxx",
  "desired_shape": "small",
  "labels": {
    "app": "investibook",
    "workspace_id": "ws-xxx",
    "owner_user_id": "user-yyy"
  },
  "network_mode": "managed",
  "security_mode": "restricted",
  "source_snap_id": "snap-xxx"
}
```

也可以用 OCI image 创建：

```json
{
  "source_image_ref": "registry.example.com/investibook-agent:2026-07-14",
  "source_image_digest": "sha256:...",
  "source_image_platform": "linux/amd64"
}
```

Box states:

- `ready`
- `idle`
- `running`
- `error`
- `deleted`

迁移建议：

- `box_id` 使用稳定可推导 ID：`ib-{env}-{workspace_id}`，保证一个 workspace 只有一个远程文件系统。
- labels 必须带 `workspace_id`、`owner_user_id`、`environment`，每次复用 box 前都校验 ownership。
- `security_mode` 默认 `restricted`，只有明确需要时才使用 `unsafe`。
- `network_mode` 需要确认 `managed` 是否能访问 Clinic/Jira/GitHub/Bedrock 等 Investibook 技能依赖。

### 命令执行

| Method | Path | 用途 |
| --- | --- | --- |
| `POST` | `/projects/{project_cid}/workspace/boxes/{id}/execs` | foreground command |
| `POST` | `/projects/{project_cid}/workspace/boxes/{id}/execs/stream` | foreground command with output stream |
| `POST` | `/projects/{project_cid}/workspace/boxes/{id}/background-execs` | background command |
| `GET` | `/projects/{project_cid}/workspace/execs` | 列出 execs |
| `GET` | `/projects/{project_cid}/workspace/execs/{id}` | 获取 exec 状态 |
| `POST` | `/projects/{project_cid}/workspace/execs/{id}/pull-output` | 拉取 background exec 增量输出 |
| `POST` | `/projects/{project_cid}/workspace/execs/{id}/write-stdin` | 写入 background exec stdin |
| `POST` | `/projects/{project_cid}/workspace/execs/{id}/kill` | kill background exec |
| `GET` | `/projects/{project_cid}/workspace/execs/{id}/log-download` | 下载 foreground exec captured log |

`ExecBoxPayload`:

```json
{
  "command": ["opencode", "acp"],
  "workdir": "/workspace",
  "env_overrides": {
    "INVESTIBOOK_WORKSPACE_DIR": "/workspace",
    "CLINIC_API_KEY": "...",
    "GITHUB_TOKEN": "..."
  },
  "stdin_enabled": true,
  "tty": false,
  "deadline_at": "2026-07-14T23:59:59Z",
  "user": "root"
}
```

`BackgroundExecBoxPayload` 与 foreground 类似，但不支持 `tty` 字段：

```json
{
  "command": ["opencode", "acp"],
  "workdir": "/workspace",
  "env_overrides": {
    "INVESTIBOOK_WORKSPACE_DIR": "/workspace"
  },
  "stdin_enabled": true,
  "deadline_at": "2026-07-14T23:59:59Z"
}
```

`ExecView` 关键字段：

- `exec_id`
- `box_id`
- `state`: `pending` / `running` / `succeeded` / `failed` / `error` / `cancelled`
- `exit_code`
- `mode`: `foreground` / `background`
- `stdin_enabled`
- `idle_deadline_at`
- `hard_deadline_at`
- `reason`
- `output_summary`

### Background output / stdin

拉取输出：

```http
POST /projects/{project_cid}/workspace/execs/{exec_id}/pull-output
Content-Type: application/json

{
  "cursor": "",
  "wait_ms": 1000
}
```

响应 body 是 `application/octet-stream`，状态通过 header 返回：

- `X-Run9-Exec-State`
- `X-Run9-Exit-Code`
- `X-Run9-Next-Cursor`
- `X-Run9-Reason`
- `X-Run9-Idle-Deadline-At`

写 stdin：

```http
POST /projects/{project_cid}/workspace/execs/{exec_id}/write-stdin
Content-Type: application/octet-stream
X-Run9-Close-Stdin: false

<raw bytes>
```

单次 stdin 最大 32768 bytes。ACP JSON-RPC 消息可能需要分片写入，并保证消息 framing 不被破坏。

### 文件上传下载

| Method | Path | 用途 |
| --- | --- | --- |
| `POST` | `/projects/{project_cid}/workspace/boxes/{id}/files/upload` | 上传单文件或 tar archive |
| `GET` | `/projects/{project_cid}/workspace/boxes/{id}/files/download` | 下载单文件或 tar archive |
| `GET` | `/projects/{project_cid}/workspace/data-transfers/active` | 查看活跃文件传输 |

上传方式：

- `multipart/form-data`：字段 `file`，可用 query/form field 指定 `box_abs_path`。
- `application/x-tar`：request body 是 tar，必须用 query 指定 `box_abs_path`。

下载方式：

- 单文件：`box_abs_path=/workspace/foo.txt`
- tar：`box_abs_path=/workspace&archive=tar`

本方案不使用 files API 做 workspace 双向同步。Run9 `/workspace` 是文件内容的唯一 source of truth，backend 不持久化远程文件内容：

- runtime、skills 和基础配置由 source snap/image 提供。
- agent 直接在 `/workspace` 创建 evidence/artifact。
- backend 只保存 artifact metadata，展示和用户下载时经鉴权代理流式读取 Run9 文件。
- 前端不获取 Run9 token，也不能传任意远程路径，只能通过 `artifact_id` 读取已登记文件。
- files API 保留给用户显式下载和后续受控导出，不用于每轮 prompt 回传目录。

### Snaps

| Method | Path | 用途 |
| --- | --- | --- |
| `GET` | `/projects/{project_cid}/workspace/snaps` | 列出 snaps |
| `GET` | `/projects/{project_cid}/workspace/snaps/{id}` | 获取 snap |
| `POST` | `/projects/{project_cid}/workspace/snaps/{id}/fork` | fork snap |
| `DELETE` | `/projects/{project_cid}/workspace/snaps/{id}` | 删除 snap |
| `POST` | `/projects/{project_cid}/workspace/snaps/import` | 从 OCI image import snap |

建议维护一个 Investibook agent base snap：

- 系统依赖和 runtime 预装。
- `agentkit/skills/builtin` 预装或随 workspace 上传。
- 常用诊断脚本和 CLI 预装。
- 模型 provider 配置通过 env/secret 注入，不固化到 snap。

## Investibook 当前执行模型

当前关键路径在 `backend/investibook/core/agent_runtime.py`：

1. `get_or_create_session()` 按 `workspace_id + user_id` 管理内存中的 `AgentSession`。
2. `_get_or_create_session_locked()` 创建本地 workspace 目录，写 `.opencode/opencode.json`，填充 `.env`。
3. `AgentSession.initialize()` 在 backend pod 本地 `asyncio.create_subprocess_exec()` 拉起 runtime：
   - `opencode acp`
   - `claude-code-acp`
   - `node agentkit/runtime/pi-core/index.mjs`
4. 对 ACP runtime，backend 通过 subprocess stdin/stdout 建立 ACP JSON-RPC：
   - `initialize`
   - `resume_session`
   - `new_session(cwd=workspace_dir)`
   - `set_session_mode("investigator")`
   - `set_session_model(...)`
   - `prompt(...)`
   - `cancel(...)`
5. `AgentEventBridge` 把 agent text/thought/tool/usage 事件归一化为 Investibook SSE 和 `messages_v2`。
6. session id 存入 DB，后续可 resume。
7. idle reaper / explicit release 会关闭本地 agent subprocess。

这个模型的关键假设是：backend 可以本地持有一个长生命周期进程，并能直接读写其 stdin/stdout。run9 迁移必须替代这个假设。

## 迁移架构选项

### 方案 A：只把 shell/tool execution 转发到 run9

agent runtime 仍在 backend 本地跑，只有 agent 执行 shell command 时走 run9。

优点：

- 改动小。
- 不需要实现远程 ACP stdio bridge。
- 现有 session、SSE、token 统计基本不动。

缺点：

- OpenCode/Claude/Pi 的工具执行路径需要可插拔，当前不一定容易截获所有 shell 行为。
- agent 仍消耗 backend pod 资源。
- workspace 文件系统会分裂：agent 本地看一份，run9 box 看一份。

结论：不建议作为最终方案，最多用于验证 run9 的网络、依赖和执行能力。

### 方案 B：ACP runtime 整体跑在 run9 background exec 内

backend 创建/复用 run9 box，在 box 内以 background exec 启动 `opencode acp`，通过 `pull-output` 读取 stdout，通过 `write-stdin` 写 stdin，实现一个远程 `AgentRuntimeConnection`。

优点：

- 最大程度复用现有 Investibook `AgentSession`、`AgentEventBridge`、SSE、DB 和成本统计。
- agent 进程和工具命令都在同一个远程 workspace 文件系统中。
- backend pod 不再承载 agent 子进程 CPU/内存压力。

缺点：

- 需要实现可靠的 ACP stdio over HTTP bridge。
- background output 是 long-poll bytes，需要处理 cursor、半包、backpressure、重连和 exec exit。
- stdin 单次 32KB，需要分片。
- 文件同步、session 恢复、box 回收都要新增逻辑。

结论：推荐路线。

### 方案 C：把整个 backend chat worker 放到 run9

每个 workspace 或每个请求在 run9 内运行一个 Investibook worker，由 worker 自己管理 agent runtime、文件和 SSE。

优点：

- 远程环境自包含。
- 减少 backend 与 run9 间 stdio bridge 复杂度。

缺点：

- 需要重新设计 backend 分布式 worker、SSE 转发、DB 访问、鉴权、部署和可观测。
- 改动大，风险高。

结论：不建议作为第一阶段。

## 推荐适配设计

新增通用 sandbox provider 层，Run9 作为其中一个实现，保留现有上层契约：

```text
Chat API / run_chat_and_wait
  -> get_or_create_session()
    -> AgentSession
      -> SandboxProvider
        -> Run9SandboxProvider
          -> Run9ProcessTransport
            -> run9 box
              -> background exec: opencode acp
```

### 新增配置

建议新增环境变量：

```text
INVESTIBOOK_AGENT_SANDBOX_PROVIDERS=
INVESTIBOOK_SANDBOX_WORKSPACE_ALLOWLIST=ws-xxx,ws-yyy
RUN9_API_BASE_URL=https://api.run.sys9.ai
RUN9_AUTHORIZATION=...
RUN9_PROJECT_CID=...
RUN9_SOURCE_SNAP_ID=...
RUN9_BOX_SHAPE=...
RUN9_NETWORK_MODE=managed
RUN9_SECURITY_MODE=restricted
RUN9_REMOTE_WORKSPACE_DIR=/workspace
```

agent 类型和执行位置是两件事，因此使用独立开关：

```text
INVESTIBOOK_AGENT_SANDBOX_PROVIDERS=run9
ACP_AGENT_TYPE=opencode
```

默认值是空列表。配置 `run9` 后，allowlist 中的 workspace 强制选择列表中的第一个 provider，其他 workspace 由 owner 在用户 Settings 中的 `sandbox_provider` 偏好决定，默认关闭。未知 provider 直接报错，不静默回落。

用户偏好 API：

```http
GET /api/auth/me
Authorization: Bearer <investibook-token>
```

响应中的 `available_sandbox_providers` 表示当前环境提供的 sandbox 列表，`sandbox_provider` 表示当前用户选择；空字符串表示本地执行。

```http
PUT /api/auth/sandbox
Authorization: Bearer <investibook-token>
Content-Type: application/json

{"provider": "run9"}
```

选择未注册或当前环境未提供的 provider 时返回 `409`。偏好按用户持久化，但 workspace placement 始终读取 workspace owner 的偏好，读取共享 workspace 的其他用户不能改变其执行位置。切换不会搬迁已有 artifact；下一次空闲 agent session 会在新位置重建。ACP session 记录携带 provider name，不会跨 local/sandbox 恢复 session ID。

### 数据映射

| Investibook | run9 |
| --- | --- |
| `workspace_id` | box label + `/workspace` |
| `user_id` | `owner_user_id` box label，必须和 workspace owner 匹配 |
| `agent_runtime` | background command；OpenCode/Pi 共用 workspace box |
| `runtime_session_id` | ACP session id，仍存 Investibook DB |
| remote exec id | 新增 DB 字段或 sidecar metadata |
| workspace files | Run9 `/workspace`，内容不落 backend |
| session release | kill background exec + optional stop box |
| idle reaper | release session + stop box |

目前 `workspace_sessions` 只存 runtime session id，不够表达 run9 的 box id / exec id。需要新增持久化位置，至少包含：

```json
{
  "execution_backend": "run9",
  "box_id": "ib-dev-ws-xxx",
  "exec_id": "exec-xxx",
  "remote_workdir": "/workspace",
  "runtime_session_id": "acp-session-xxx",
  "last_seen_at": "..."
}
```

可以先用 DB 新表，也可以短期用 workspace sidecar 文件；生产建议用 DB。

### Run9ProcessTransport

Run9 适配放在进程 I/O 层，向现有 ACP 和 Pi RPC adapter 暴露 asyncio subprocess 风格的 `stdin`、`stdout`、`wait()`、`kill()` 和 `returncode`。ACP 继续使用 `connect_to_agent(...)`，Pi 继续使用 JSONL adapter，两者共用同一个 Run9 transport。

底层机制：

1. 创建/复用 box。
2. background exec 启动 `opencode acp` 或 Pi core，`stdin_enabled=true`。
4. 建立一个 stdout pump：
   - 循环调用 `pull-output(cursor, wait_ms)`。
   - 把 bytes 喂给 ACP JSON-RPC parser。
   - 根据 `X-Run9-Next-Cursor` 更新 cursor。
   - 根据 `X-Run9-Exec-State` 识别退出。
5. stdin 写入：
   - ACP client 写出的 bytes 通过 `write-stdin` 发送。
   - 超过 32768 bytes 时分片。
6. close/cancel：
   - prompt cancel 调 ACP cancel。
   - session shutdown kill background exec。
   - idle release stop box。

### Artifact 展示策略

- prompt 成功后，backend 在同一 box 内执行确定性的 artifact scanner。
- scanner 只扫描约定目录并输出 metadata manifest，不读取文件内容到 backend。
- backend 校验路径 allowlist、symlink、类型、大小和 renderer 后持久化 metadata。
- Studio 用 `artifact_id` 请求内容；backend 校验 workspace owner 后从 Run9 files API 流式转发。
- prompt 失败或取消时不刷新 metadata，继续展示上一版成功结果。
- MVP 不做 turn staging，也不做 workspace 双向同步。

### Secret 与网络

当前 `AgentSession.initialize()` 会把以下环境注入 agent 子进程：

- `CLINIC_API_KEY`
- `GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_PAT`
- `INVESTIBOOK_JIRA_BASE_URL`
- `INVESTIBOOK_JIRA_USERNAME`
- `INVESTIBOOK_JIRA_API_TOKEN`
- `DEVOPS_BASE_URL`
- `DEVOPS_API_KEY`
- `INVESTIBOOK_WORKSPACE_DIR`

迁移到 run9 时，这些 env 要放到 `env_overrides` 或 run9 project/box secret。生产建议：

- MVP：只通过显式 allowlist 将所需环境变量放入 `env_overrides`，绝不传完整 backend 环境。
- 后续：能使用 Run9 egress secret placeholder 的凭据迁移到 project/box secrets；需先验证它是否支持目标 CLI/provider 的认证方式。

需要验证 run9 box 网络能访问：

- Clinic API / data proxy / Loki / Grafana
- Jira
- GitHub
- Bedrock / Anthropic / OpenAI / Google provider
- TiDB Cloud / DevOps 内部 endpoints

## 需要修改的模块

### 后端 runtime 层

主要文件：

- `backend/investibook/core/agent_runtime.py`
- `backend/investibook/core/agent_provider.py`
- `backend/investibook/core/workspace_setup.py`
- `backend/investibook/db/queries.py`

改动：

1. 增加 `execution_backend` 概念，和 `agent_type` 解耦。
2. 增加 `Run9Client`，封装 REST API。
3. 增加 `Run9ProcessTransport`，实现通用 stdio over run9 background exec。
4. `AgentSession.initialize()` 根据 backend 选择本地 subprocess 或 run9。
5. `AgentSession.shutdown()` 对 run9 调 kill exec / stop box。
6. `trigger_hot_reload()` 对 run9 需要重新设计。OpenCode 的 `127.0.0.1` HTTP hot reload 在远程 box 内不可直接访问，PoC 可以禁用。
7. token/cost/event bridge 尽量不改。

### DB

需要持久化 run9 远程状态：

- box id
- background exec id
- cursor 可选，通常内存即可；重启恢复需要保存
- remote workdir
- execution backend
- last seen / health metadata

可以新增表：

```sql
CREATE TABLE workspace_runtime_bindings (
  workspace_id TEXT NOT NULL,
  user_id TEXT,
  agent_runtime TEXT NOT NULL,
  execution_backend TEXT NOT NULL,
  runtime_session_id TEXT,
  remote_project_cid TEXT,
  remote_box_id TEXT,
  remote_exec_id TEXT,
  remote_workdir TEXT,
  metadata_json TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  PRIMARY KEY (workspace_id, user_id, agent_runtime, execution_backend)
);
```

也可以扩展现有 `workspace_sessions`，但容易把 ACP session 与 remote execution resource 混在一起。

### 镜像 / snap

需要一个 run9 base image/snap：

- Python/Node/Bun/uv/pnpm
- `opencode`
- Investibook `agentkit`
- clinic-cli 依赖
- 常用 shell 工具：`curl`、`jq`、`git`、`tar`、`gzip`
- 可选：预热模型 provider 依赖

当前 backend Docker image 安装 `opencode`，run9 box 内也必须有相同 runtime。否则 backend 能启动、本地测试通过，但 run9 执行失败。

### 测试

最低测试集：

- `Run9Client` mock API 单元测试。
- stdin 分片测试。
- pull-output cursor / half-frame 测试。
- background exec exit/error/cancel 测试。
- box owner label 冲突测试。
- artifact metadata scanner 和鉴权流式下载测试。
- 一轮 chat e2e：create session -> prompt -> SSE text/tool -> complete。
- resume 测试：backend 重启后复用 DB session，或在不能 resume 时创建新 session。

## 风险点

### ACP stdio bridge 风险

ACP 依赖持续 stdin/stdout。run9 background output 是 HTTP long poll，必须保证：

- bytes 顺序不乱。
- JSON-RPC frame 不因 chunk 边界损坏。
- stdout pump 和 stdin writer 并发安全。
- exec 退出时能把错误映射到现有 SSE `error`。

这是迁移中技术风险最高的部分。

### Session resume 风险

session 文件只存在 Run9 `/workspace`。停止 box 可以保留文件并继续 resume；删除 box 会丢失 session，除非后续启用 Run9 snap/S3 mount。MVP 对活跃 workspace 只 stop、不 delete，resume 失败时创建新 runtime session。

### 文件一致性风险

Run9 remote 是 artifact 文件内容的 source of truth。任何仍直接读取本地 workspace 的 Studio、export、eval 或 postpass 路径都必须改走 `WorkspaceStorage`/artifact API；在完成这些调用点前不能将该 workspace 灰度到 Run9。

### Secret 泄露风险

`env_overrides` 方便但可能出现在 exec 记录、调试日志或 API payload 里。长期应迁移到 run9 secrets。

### Hot reload / localhost 风险

当前 OpenCode hot reload 调的是 backend 本地 `http://127.0.0.1:{port}`。迁移到 run9 后这个地址在远程 box 内。需要：

- PoC 禁用 hot reload。
- 或通过 run9 exec 调本地 box 内 curl。
- 或不依赖 hot reload，session 重启生效。

## 分阶段计划

### Phase 0：确认 run9 能跑 Investibook agent

目标：不用改 Investibook 主链路，手动验证。

1. 创建 run9 project / box。
2. 从预装 runtime、skills 和配置的 source snap/image 创建测试 box。
3. 在 box 内运行：

```bash
opencode acp
```

4. 验证环境变量、skills、Clinic/GitHub/Jira 网络访问。
5. 验证 `.opencode` session 文件会写到 `/workspace`。

验收：run9 box 内的 agent 可以独立完成一次诊断相关命令。

### Phase 1：PoC remote ACP

目标：Investibook web chat 可以通过 run9 完成一轮 prompt。

改动：

- `Run9Client`
- `Run9ProcessTransport`
- `INVESTIBOOK_AGENT_SANDBOX_PROVIDERS=run9`
- workspace owner label 校验
- 默认 local + workspace allowlist 灰度开关

不做：

- 热更新
- 增量同步
- 多 runtime 完整支持
- 完整配额和回收

验收：单用户单 workspace 可稳定完成 10 轮 chat。

### Phase 2：可用版本

目标：支持 dev/staging 灰度。

改动：

- DB runtime binding。
- idle reaper 关联 stop box。
- cancel/kill 行为完整。
- box lookup by label，backend 重启可恢复。
- artifact metadata scanner 和鉴权流式读取。
- run9 错误映射到 Investibook SSE。
- metrics：冷启动、首 token、exec duration、sync bytes、run9 API error。

验收：部分用户可配置走 run9，失败可回退 local。

### Phase 3：生产化

目标：默认远程执行。

改动：

- base snap/image 发布流程。
- run9 secrets。
- 可选 S3 mount/snap 持久化。
- quota/billing guard。
- 大文件策略。
- 安全审计。
- 压测和故障演练。

验收：run9 后端执行稳定性、成本和延迟达到当前 local backend 方案要求。

## 工作量评估

| 项目 | 估算 | 说明 |
| --- | ---: | --- |
| run9 REST client | 0.5-1 天 | 包括认证、错误处理、基础重试 |
| box lifecycle | 1-2 天 | create/list/get/stop/delete、label lookup |
| ACP over background exec | 3-5 天 | stdin/stdout bridge、cursor、half-frame、exit |
| 接入 `AgentSession` | 2-3 天 | backend selection、shutdown、cancel、model/mode |
| DB runtime binding | 1-2 天 | migration + query helpers |
| artifact registry/storage | 2-4 天 | metadata scan、owner 鉴权、流式读取 |
| 错误/metrics/日志 | 1-2 天 | run9 API observability |
| e2e 测试与灰度开关 | 2-4 天 | mock + real integration |
| secrets/snap 生产化 | 3-7 天 | 依赖 run9 secret/snap 运维路径 |

整体：

- PoC：2-4 天。
- staging 可用：1-2 周。
- production-ready：3-5 周。

## 最小代码落点建议

新增：

```text
backend/investibook/clients/run9.py
backend/investibook/core/run9_process.py
backend/investibook/core/workspace_storage.py
```

修改：

```text
backend/investibook/core/agent_runtime.py
backend/investibook/config.py
backend/investibook/db/queries.py
backend/investibook/db/schema*.py
backend/tests/test_agent_provider.py
backend/tests/test_acp_session_lifecycle.py
```

配置：

```text
INVESTIBOOK_AGENT_SANDBOX_PROVIDERS=run9
INVESTIBOOK_SANDBOX_WORKSPACE_ALLOWLIST=ws-xxx
RUN9_API_BASE_URL=...
RUN9_AUTHORIZATION=...
RUN9_PROJECT_CID=...
RUN9_SOURCE_SNAP_ID=...
RUN9_BOX_SHAPE=...
```

## 建议的第一步

先不要直接改主链路。第一步做一个 backend 内部 debug command 或脚本：

```text
scripts/run9_agent_smoke.py
```

它做四件事：

1. 从 production 候选 source snap/image 创建测试 box。
2. 验证 box owner labels 和网络策略。
3. 在 run9 box 内跑一个非交互验证命令：

```bash
pwd
ls -la
opencode --version
test -d /opt/investibook/agentkit/skills
```

再跑 background `opencode acp` 的启动验证。这个 smoke 通过后，再接 `AgentSession`，否则会把网络、镜像、secret、ACP bridge 问题混在一起，定位成本很高。
