Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions docs/architecture/agent-runtime-deployment-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ flowchart TB
| Embedded TUI/Headless CLI/Peer Host | Session、Turn、Permission 和事件订阅统一通过同一个 Rust Runtime SDK(当前 preview);CLI crate 只保留第一方 adapter 和各形态自己的展示/断流策略 |
| ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 |
| Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 |
| Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 |
| 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 |
| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制租约、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter |
| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter |
| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded |
| Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 |

Expand Down Expand Up @@ -106,7 +107,7 @@ ownership 分成“产品决策”和“文件锁原语”两层;入口不再
```mermaid
flowchart TB
Entrypoints["Desktop · CLI · ACP · SDK Host · Server bootstrap"]
Entrypoints --> Core["CoreRuntimeOwnership<br/>deployment · product identity · process leases"]
Entrypoints --> Core["CoreRuntimeOwnership<br/>deployment · product identity · process-held lock"]
Core --> Primitive["services-core::runtime_ownership<br/>canonical key · RAII file lock"]
Primitive --> E["Embedded · shared lock"]
Primitive --> S["Shared · exclusive lock"]
Expand All @@ -119,21 +120,58 @@ flowchart TD
Read -->|"no · attach/mutate/turn"| Remote{"structured remote facts?"}
Remote -->|"yes"| RemoteHost["由目标 execution host 负责"]
Remote -->|"no"| Gate["Coordinator → CoreRuntimeOwnership"]
Gate --> Lease["按 canonical workspace 保留进程期 lease"]
Gate --> Lock["按 canonical workspace 持有文件锁"]
```

| 场景 | 行为 | 原因 |
|---|---|---|
| 多个 Embedded 进程访问同一 workspace | 共享锁允许并存 | 保持单实例、CI 和隔离测试的既有成本模型 |
| Shared 与任一 Embedded 访问同一 workspace | 后启动者返回稳定错误码和启动建议 | 防止同一 workspace 同时存在两种 Runtime deployment |
| Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并保留 lease | 不把窗口数、Session 数等同于 Runtime 进程数 |
| Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并持有文件锁 | 不把窗口数、Session 数等同于 Runtime 进程数 |
| 只读 list/view | 不加锁 | ownership 只管理 Runtime deployment,不扩大成读取权限 |
| 已解析且带有效 `connection_id` 的 remote workspace | 本机不加锁 | 与 Session storage 的远端判据一致;`host` 提示本身不能绕过本地锁 |
| 当前只读 HTTP Server | 不创建 Core owner | 没有 Agent Runtime 就没有 ownership 可声明 |

`CoreRuntimeOwnership` 只选择 deployment、产品 identity 并保留进程期 lease;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。
`CoreRuntimeOwnership` 只选择 deployment、产品 identity 并在进程存活期间持有锁;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。

### 4.2 私有本机 IPC
### 4.2 Session 单写

workspace 可以被多个 Embedded 进程同时打开,但持久化 Session 不能被多个进程同时写入。保护粒度是“实际 Session 存储位置 + Session ID”,不是窗口、TUI 实例或 workspace。

```mermaid
flowchart LR
subgraph W["同一 workspace"]
A["Session A"]
B["Session B"]
end

GUI["GUI 进程"] -->|"写入"| A
TUI["TUI 进程"] -->|"写入"| B
CLI["另一个 CLI 进程"] -.->|"写入 A:session_in_use"| A
View["任意入口的 list / view"] -.->|"只读"| A
View -.->|"只读"| B
```

BitFun Runtime Session 只有 `SessionManager` 决定何时开始和结束写入;底层持久化方法复用同一文件锁,不再实现第二套判断。Agent SDK、BitFun ACP adapter 和 Shared TUI 保留结构化的 `session_in_use` 分类;SDK Host 将其映射为可重试并建议 retry 的结构化 `action_required`。GUI、Embedded TUI 和 Headless CLI 当前只显示明确的冲突消息,尚未承诺结构化错误字段,自动化调用不能依赖该文案。Desktop 作为 ACP client 管理的外部 agent Session 不经过该 Runtime owner,不在本节的 Session 单写范围内。

| 场景 | 行为 |
|---|---|
| 同一进程重复 restore 同一 Session | 返回已加载的 Session,不重复取得或释放写入权 |
| 另一个进程打开同一存储位置中的同一 Session | 立即返回 `session_in_use`;不等待、不自动抢占 |
| 多个进程打开同一 workspace 中的不同 Session | 允许,各 Session 独立写入 |
| 多个进程更新同一 Session 列表索引 | 按存储位置串行更新共享索引,不影响不同 Session 文件并行写入 |
| `.`、`..`、符号链接或 Windows 路径大小写指向同一存储位置 | 视为同一个 Session 存储位置 |
| 相同 Session ID 位于不同存储位置 | 文件锁相互独立;同一 `SessionManager` 仍按 Session ID 保持唯一绑定,不能同时加载 |
| Session 存储路径无法解析或错误地指向文件系统根目录 | 在发布内存状态前返回错误,不创建可写 Session |
| create/restore 在发布到内存前失败、取消或超时 | 临时文件锁随操作释放;后续进程可以重试 |
| save、cleanup 或 unload 失败 | 已加载 Session 继续持有写入权,避免另一个进程接手不完整状态 |
| unload 或 delete 成功 | 释放写入权 |
| 进程崩溃或被强制结束 | 操作系统释放文件锁;残留锁文件本身不代表 Session 仍在使用 |
| Remote workspace | 在实际 Session 存储所在机器执行同一检查;控制端不得用本机路径替代 |

该机制不增加后台进程、轮询、连接或常驻线程,也不改变 Shared TUI 的连接控制规则。临时 Session 不写入磁盘,因此不参与此检查。

### 4.3 私有本机 IPC

```mermaid
sequenceDiagram
Expand All @@ -147,7 +185,7 @@ sequenceDiagram
alt valid
S-->>C: initialized(health + interactive_tui)
C->>S: create or restore Session
S-->>C: controller lease + Session facts
S-->>C: Session control + Session facts
C->>S: submit/cancel Turn or answer Permission/UserInput
S-->>C: Session-filtered authoritative events
else invalid
Expand Down Expand Up @@ -177,7 +215,7 @@ sequenceDiagram
- 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task;
- 未知字段、未知 operation、错误身份和不兼容版本 fail closed;
- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session。
- Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 租约;无法确认时租约保持隔离,直到 Runtime 进程退出。
- Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。
- Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。
- Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。
- 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。
Expand Down Expand Up @@ -223,9 +261,9 @@ flowchart TB
| canonical workspace + product | 防止 Embedded 与 Shared 同时拥有同一工作区 Runtime |
| workspace + product + release channel + user + protocol | 定位兼容的本机 Shared instance |
| stable local endpoint + bearer token + owner id | endpoint 定位同一 instance;随机 token 认证本轮 server;owner id 防止旧实例误删新 discovery |
| Session identity | 未来 Runtime 内的持久化和写入隔离;不由 IPC foundation 定义 |
| 实际 Session 存储位置 + Session ID | 限制持久化 Session 的跨进程并发写入;不由 IPC 协议定义 |

当前 Shared TUI 只有 controller,没有 observer 或 detached Query:一个 Client 关闭不会删除 Session;它会取消仍拥有的活动 Turn,只有取消得到确认才释放 Session 控制租约,否则该租约隔离到 Runtime 退出。最后一个 Client 关闭后,Runtime 进入 30 秒空闲期;期间重连可继续使用,超时后 Runtime 正常关闭。若未来增加后台任务、observer 或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。
当前 Shared TUI 只有 controller,没有 observer 或 detached Query:一个 Client 关闭不会删除 Session;它会取消仍拥有的活动 Turn,只有取消得到确认才释放 Session 控制权,否则继续隔离该 Session,直到 Runtime 退出。最后一个 Client 关闭后,Runtime 进入 30 秒空闲期;期间重连可继续使用,超时后 Runtime 正常关闭。若未来增加后台任务、observer 或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。

对普通单实例用户,未显式启用 Shared deployment 时不增加后台进程、连接、发现扫描或常驻内存。

Expand Down
31 changes: 26 additions & 5 deletions src/apps/cli/src/shared_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use bitfun_agent_runtime::sdk::{
AgentRuntime, AgentSessionRestoreRequest, AgentUserAnswersRequest, DialogSubmitOutcome,
PermissionRequest, PermissionRequestEvent, RuntimeError, SessionTranscriptRequest,
PermissionRequest, PermissionRequestEvent, PortErrorKind, RuntimeError,
SessionTranscriptRequest,
};
use bitfun_agent_runtime_ipc::{
DiscoveryStore, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcError,
Expand Down Expand Up @@ -914,8 +915,14 @@ fn runtime_error_message(error: RuntimeError) -> anyhow::Error {
}

fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError {
let code = match &error {
RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::SessionInUse => {
RuntimeIpcErrorCode::SessionInUse
}
_ => RuntimeIpcErrorCode::Unavailable,
};
RuntimeIpcError {
code: RuntimeIpcErrorCode::Unavailable,
code,
message: error.into_message(),
}
}
Expand All @@ -925,19 +932,33 @@ mod tests {
use super::{
await_permission_route, connect_existing, index_user_question, invalidate_event_stream,
permission_event_session, permission_targets_session, project_subagent_link_route,
project_user_question_route, publish_event, route_agent_event, subscribe_session_events,
SessionEventSenders, EVENT_BUFFER,
project_user_question_route, publish_event, route_agent_event, runtime_ipc_error,
subscribe_session_events, SessionEventSenders, EVENT_BUFFER,
};
use bitfun_agent_runtime::sdk::{
PermissionDelegationContext, PermissionReplySource, PermissionRequest,
PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind,
PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PortError,
PortErrorKind, RuntimeError,
};
use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{watch, Notify};

#[test]
fn session_writer_conflict_reuses_the_existing_ipc_error() {
let error = runtime_ipc_error(RuntimeError::Port(PortError::new(
PortErrorKind::SessionInUse,
"Session is already open for writing: session-1",
)));

assert_eq!(
error.code,
bitfun_agent_runtime_ipc::RuntimeIpcErrorCode::SessionInUse
);
}

#[tokio::test]
async fn existing_runtime_connection_errors_are_not_hidden_as_absence() {
let root = tempfile::tempdir().unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8930,6 +8930,10 @@ fn runtime_port_error_from_bitfun(error: BitFunError) -> bitfun_runtime_ports::P
(bitfun_runtime_ports::PortErrorKind::Cancelled, message)
}
BitFunError::Timeout(message) => (bitfun_runtime_ports::PortErrorKind::Timeout, message),
BitFunError::SessionInUse { session_id } => (
bitfun_runtime_ports::PortErrorKind::SessionInUse,
format!("Session is already open for writing: {session_id}"),
),
BitFunError::NotImplemented(message) => {
(bitfun_runtime_ports::PortErrorKind::NotAvailable, message)
}
Expand Down Expand Up @@ -11778,7 +11782,7 @@ mod tests {
.session_storage_dir();
assert!(!session_manager
.persistence_manager()
.session_storage_exists(&storage_path, &transient.session_id)
.session_storage_exists(&workspace_path, &transient.session_id)
.expect("persistence probe should succeed"));

let transient_child = session_manager
Expand Down
14 changes: 14 additions & 0 deletions src/crates/assembly/core/src/agentic/coordination/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ impl SchedulerSubmitError {
Self::Core(BitFunError::Timeout(message)) => {
PortError::new(PortErrorKind::Timeout, message)
}
Self::Core(BitFunError::SessionInUse { session_id }) => PortError::new(
PortErrorKind::SessionInUse,
format!("Session is already open for writing: {session_id}"),
),
Self::Core(BitFunError::NotImplemented(message)) => {
PortError::new(PortErrorKind::NotAvailable, message)
}
Expand Down Expand Up @@ -2390,6 +2394,16 @@ mod tests {
use bitfun_runtime_ports::{AgentDialogPrependedReminder, AgentInputAttachment, PortErrorKind};
use tokio::sync::RwLock as TokioRwLock;

#[test]
fn scheduler_preserves_session_writer_conflicts() {
let error = SchedulerSubmitError::Core(BitFunError::SessionInUse {
session_id: "session-1".to_string(),
})
.into_port_error();

assert_eq!(error.kind, PortErrorKind::SessionInUse);
}

fn test_scheduler() -> (
Arc<DialogScheduler>,
Arc<SessionManager>,
Expand Down
Loading