From bd224f9e83e62c57ecf5e5f50514ece75d6f3bf3 Mon Sep 17 00:00:00 2001 From: weishao Date: Tue, 11 Aug 2026 11:39:36 +0800 Subject: [PATCH] feat(app-server): add sharedv2 command --- AGENTS-CN.md | 2 +- AGENTS.md | 2 +- Cargo.lock | 23 +- Cargo.toml | 1 - .../agent-runtime-deployment-design.md | 213 +- .../agent-sdk-product-architecture.md | 4 +- docs/architecture/app-server-architecture.md | 46 +- docs/architecture/cli-product-line-design.md | 18 +- .../opencode-config-assets-adapter-design.md | 2 +- docs/architecture/product-architecture.md | 33 +- ...tui-app-server-decoupling-refactor-plan.md | 285 +- .../core-boundaries/rules/crate-layout.mjs | 1 - scripts/core-boundaries/rules/crate-rules.mjs | 63 - .../rules/source/forbidden-rules.mjs | 24 +- .../rules/source/required-rules.mjs | 43 - .../rules/tui-boundary-rules.mjs | 1 - scripts/core-boundaries/self-test.mjs | 129 - src/apps/cli/AGENTS.md | 20 +- src/apps/cli/Cargo.toml | 5 +- src/apps/cli/README.md | 14 +- src/apps/cli/src/actions.rs | 10 +- src/apps/cli/src/agent/exec_runtime_client.rs | 441 +++ src/apps/cli/src/agent/mod.rs | 2 +- src/apps/cli/src/agent/runtime_client.rs | 2559 ----------------- src/apps/cli/src/agent/tui_client.rs | 107 +- src/apps/cli/src/diagnostics.rs | 20 - src/apps/cli/src/main.rs | 105 +- src/apps/cli/src/modes/chat.rs | 2 +- src/apps/cli/src/modes/chat/mcp.rs | 30 +- src/apps/cli/src/modes/chat/run.rs | 4 +- src/apps/cli/src/modes/chat/tests.rs | 15 +- src/apps/cli/src/modes/exec/lifecycle.rs | 2 +- src/apps/cli/src/shared_app_server.rs | 746 +++++ src/apps/cli/src/shared_runtime.rs | 1939 ------------- src/apps/cli/src/shared_tui_backend.rs | 1608 ----------- .../product_assembly_cli.rs | 96 +- src/apps/server/src/app_server.rs | 10 +- src/apps/server/src/routes/websocket.rs | 8 +- src/apps/server/src/routes/ws_transport.rs | 8 + src/crates/adapters/AGENTS-CN.md | 1 - src/crates/adapters/AGENTS.md | 1 - .../adapters/agent-runtime-ipc/AGENTS-CN.md | 29 - .../adapters/agent-runtime-ipc/AGENTS.md | 55 - .../adapters/agent-runtime-ipc/Cargo.toml | 32 - .../adapters/agent-runtime-ipc/src/client.rs | 579 ---- .../agent-runtime-ipc/src/discovery.rs | 525 ---- .../adapters/agent-runtime-ipc/src/framing.rs | 248 -- .../adapters/agent-runtime-ipc/src/handler.rs | 27 - .../adapters/agent-runtime-ipc/src/ipc.rs | 316 -- .../adapters/agent-runtime-ipc/src/lib.rs | 51 - .../agent-runtime-ipc/src/operation.rs | 563 ---- .../agent-runtime-ipc/src/protocol.rs | 147 - .../adapters/agent-runtime-ipc/src/server.rs | 1147 -------- .../agent-runtime-ipc/src/session_lease.rs | 154 - .../src/tests/discovery_and_framing.rs | 270 -- .../src/tests/local_health.rs | 466 --- .../src/tests/protocol_contracts.rs | 605 ---- .../src/tests/shared_controller.rs | 1778 ------------ .../src/agentic/coordination/coordinator.rs | 53 +- .../interfaces/app-server-client/src/lib.rs | 24 + .../app-server-protocol/src/schemas/app.rs | 33 +- .../src/schemas/session.rs | 53 + src/crates/interfaces/app-server/src/agent.rs | 17 + .../app-server/src/management/service.rs | 2 +- .../interfaces/app-server/src/server.rs | 211 +- .../app-server/src/server/event_forwarder.rs | 32 + .../app-server/src/server/handlers/account.rs | 9 + .../app-server/src/server/handlers/agent.rs | 54 +- .../app-server/src/server/handlers/app.rs | 17 +- .../src/server/handlers/capability.rs | 103 +- .../src/server/handlers/external_source.rs | 6 + .../app-server/src/server/handlers/hook.rs | 6 + .../app-server/src/server/handlers/mcp.rs | 7 + .../app-server/src/server/handlers/model.rs | 22 +- .../src/server/handlers/permission.rs | 30 +- .../app-server/src/server/handlers/session.rs | 147 +- .../app-server/src/server/handlers/skill.rs | 3 + .../src/server/handlers/subagent.rs | 3 + .../src/server/handlers/worktree.rs | 4 + .../app-server/tests/agent_kernel.rs | 282 +- 80 files changed, 2685 insertions(+), 14068 deletions(-) create mode 100644 src/apps/cli/src/agent/exec_runtime_client.rs delete mode 100644 src/apps/cli/src/agent/runtime_client.rs create mode 100644 src/apps/cli/src/shared_app_server.rs delete mode 100644 src/apps/cli/src/shared_runtime.rs delete mode 100644 src/apps/cli/src/shared_tui_backend.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md delete mode 100644 src/crates/adapters/agent-runtime-ipc/AGENTS.md delete mode 100644 src/crates/adapters/agent-runtime-ipc/Cargo.toml delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/client.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/discovery.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/framing.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/handler.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/ipc.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/lib.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/operation.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/protocol.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/server.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/session_lease.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs delete mode 100644 src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 597aed1bbb..4072867534 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -26,7 +26,7 @@ Stable Contracts and Security Control Plane 的边界以 |---|---|---|---|---|---| | 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp`、`sdk-host` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线、不可变内置 Agent 内容、adapter/service 注册和生态无关的来源协调 | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | -| 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver 协议 adapter、外部 AI work source adapter(OpenCode/Claude Code/Codex)和外部 provider 转换 | `agent-runtime-ipc`、`ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver 协议 adapter、外部 AI work source adapter(OpenCode/Claude Code/Codex)和外部 provider 转换 | `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、harness、stream、DeepReview policy/report、插件运行时客户端、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、LSP protocol/plugin DTO、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | diff --git a/AGENTS.md b/AGENTS.md index 9063259234..7ff01f659f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Keep crate dependencies inside each layer to the smallest set needed. |---|---|---|---|---|---| | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp`, `sdk-host` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, immutable built-in Agent content, adapter/service registration, and ecosystem-neutral source coordination | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | -| 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver protocol adapters, external AI work source adapters (OpenCode/Claude Code/Codex), and external-provider translation | `agent-runtime-ipc`, `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver protocol adapters, external AI work source adapters (OpenCode/Claude Code/Codex), and external-provider translation | `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `miniapp-market-service`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, plugin runtime client, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, LSP protocol/plugin DTOs, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | diff --git a/Cargo.lock b/Cargo.lock index 51e9c22e5e..800b7c1ad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,26 +810,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "bitfun-agent-runtime-ipc" -version = "0.2.17" -dependencies = [ - "async-trait", - "bitfun-events", - "bitfun-product-domains", - "bitfun-runtime-ports", - "dunce", - "fs2", - "serde", - "serde_json", - "sha2", - "tempfile", - "thiserror 2.0.19", - "tokio", - "uuid", - "windows 0.61.3", -] - [[package]] name = "bitfun-agent-stream" version = "0.2.17" @@ -970,13 +950,13 @@ dependencies = [ name = "bitfun-cli" version = "0.2.17" dependencies = [ + "agent-client-protocol", "anyhow", "arboard", "async-trait", "base64 0.22.1", "bitfun-acp", "bitfun-agent-runtime", - "bitfun-agent-runtime-ipc", "bitfun-agent-tools", "bitfun-app-server", "bitfun-app-server-client", @@ -1018,6 +998,7 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "tokio", + "tokio-util", "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 2e53e5769d..44bbcb62f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ members = [ "src/crates/interfaces/app-server-client", "src/crates/interfaces/app-server-protocol", "src/crates/interfaces/sdk-host", - "src/crates/adapters/agent-runtime-ipc", "src/crates/assembly/agent-content", "src/crates/assembly/core", "src/crates/assembly/external-sources", diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index bb8c5c5214..1666f9cf64 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -20,12 +20,12 @@ flowchart TB Web["Web UI"] --> WebAS["loopback WebSocket App Server"] TUI["Interactive TUI"] --> Backend["TuiBackend"] Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] - Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] + Backend -->|"--shared"| SharedAS["local Shared App Server"] Other["Headless CLI · ACP · Peer Host · SDK Host"] --> Adapter["独立 first-party adapters"] DesktopAdapter --> API["Agent Runtime API / owner ports"] WebAS --> API EmbeddedAS --> API - SharedIPC --> API + SharedAS --> API Adapter --> API API --> Coordinator["ConversationCoordinator"] Coordinator --> Owners["Session / Tool / Permission / MCP owners"] @@ -57,8 +57,7 @@ flowchart TB SDKHost --> API ``` -该图是待评审目标,不是当前调用链。Shared App Server 只有达到 v17 的连接治理、安全、恢复、取消、限制、性能和回滚门槛后, -才可替换 compatibility transport;评审也可以决定保留 private v17 作为 Shared TUI 的物理 wire。 +该图对 Shared TUI 已是当前调用链;Desktop 迁移仍是待评审目标。Shared App Server 的跨连接恢复、取消、未知结果、慢客户端治理、性能和安全合同仍需继续收敛。 ### 1.3 Current implementation facts @@ -71,13 +70,14 @@ flowchart TB | 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 | 已组装 Embedded Runtime 和 `BitfunAppServer`,每个 `/ws` 连接通过 WebSocket transport 运行一条 App Server connection;当前固定 loopback、单用户且缺少连接级身份与作用域绑定,不构成远程或多用户 Server API | -| Shared local IPC | 未发布的 v17 本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI compatibility adapter;是否由 Shared App Server 替换仍待评审与等价证据 | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理由 Shared CLI Host 显式装配 App Server 的具体 `AppManagementService` 保留;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供,默认仍是 Embedded | -| Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | +| Shared App Server | `bitfun --shared` / `bitfun chat --shared` 按 workspace 启动或连接一个本机 Host 和 Runtime owner;loopback TCP 使用随机 token、canonical identity、实例锁、128 KiB request / 8 MiB response-event 上限、最多 64 连接和 30 秒空闲退出 | +| Shared TUI | 复用正式 `AppServerClient`、`AppServerTuiBackend` 和完整本机 `AppManagementService`;连接显式订阅 Session,多个客户端可同时订阅、观察、steer 或按精确 Turn ID cancel 同一 Session,独立 Turn 由唯一 Runtime owner 串行准入 | +| Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;跨连接持久 replay、透明 resume、通用未知结果恢复和完整慢客户端治理同样尚未交付 | -因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment,不是通用本机 Server。 -具体 `EventQueue` 仍由 Core 产品装配;当前 Shared IPC 只把 TUI 必需的强类型操作和事件映射到同一个 Runtime owner, -没有公开协议承诺。是否以 App Server Shared transport 替换并删除它,由行为等价、性能、安全和回滚证据决定。 +因此当前交付的是 Embedded TUI App Server 和显式启用的 Shared TUI App Server deployment,不是通用本机 Server。 +具体 `EventQueue` 仍由 Core 产品装配;Shared Host 把正式 App Server 的强类型操作和事件映射到同一个 Runtime owner,没有公开或远程协议承诺。 + +Shared TUI 采用无 controller lease 的多客户端模型:多个连接可订阅并操作同一 Session,`steer` / `cancel` 携带精确 Turn ID;独立 Turn 仍由唯一 Runtime owner 的 Session scheduler 串行准入。客户端并发发送请求不会产生两个 Session writer,也不会允许两个独立 Turn 同时改写同一 Session 历史。 ## 2. 最少名词 @@ -85,7 +85,7 @@ flowchart TB |---|---|---| | Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、事件和持久化行为的既有模块 | 进程名、Server 或 SDK | | Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | -| Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | +| Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过受控本机 App Server transport 使用 | 新 Runtime、公开 Server 或 Agent SDK | | Embedded App Server | 与 Rich Client Host 同进程的私有 App Server 实例和 in-memory transport | Runtime 直连、后台进程或网络 Server | | Shared App Server | 独立本机 Host 承载、由多个已认证 Rich Client 通过受控 transport 使用的 App Server | 公网 API 或每个 Client 一个 Runtime | | Agent SDK Host | 将公开 SDK 合同映射到 Runtime API 的私有进程/adapter | CLI、Shared deployment 或 Plugin Host | @@ -109,14 +109,14 @@ flowchart TB EmbeddedTUI["Embedded TUI"] --> AppServer DesktopAdapter --> API AppServer --> API - SharedCompat["Shared Runtime IPC · temporary compatibility"] --> API + SharedAS["Shared TUI App Server"] --> API Headless["Headless / ACP adapters"] --> API SDK["SDK Host adapter"] --> API Remote["Remote adapter"] --> API ``` -当前复用的是 Runtime API、权威事实和 owner;Web 与 Embedded TUI 额外复用 App Server wire,Shared TUI 使用 private v17,Desktop -仍使用自己的 adapter。第 1.2 节目标只有通过评审并完成迁移后才扩大 App Server 复用范围。各入口不复用 renderer、CLI 参数、SDK +当前复用的是 Runtime API、权威事实和 owner;Web、Embedded TUI 与 Shared TUI 额外复用 App Server wire,Desktop +仍使用自己的 adapter。第 1.2 节目标只有通过评审并完成迁移后才扩大到 Desktop。各入口不复用 renderer、CLI 参数、SDK wire、远程认证或平台窗口生命周期。任何新能力必须先进入既有 Runtime owner,再由 App Server 或需要它的独立 adapter 映射,禁止 在 Embedded、Shared 或其他入口复制业务实现。 @@ -143,9 +143,8 @@ flowchart LR - 当前 App Server 为每条 connection/stream 发送单调 sequence 和 connection-local cursor;`app/syncEvents` 返回当前连接的 cursor 与 pending Permission snapshot,`session/sync` 恢复 Session state、transcript、workspace binding 和 pending Permission。它没有跨连接 持久化 replay/resume:重连后的旧 cursor 不能继续消费,client 必须重新 initialize 并执行权威 sync。 -- Shared Runtime IPC v17 不复用 App Server cursor。它按自己的有界队列规则处理 lag/closed:Agent 流失效后 fail closed;Permission lag - 尝试从 Runtime 的 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。任何路径都不能把流失效伪装成透明恢复。 -- 这条链路仍全部位于当前 Embedded 进程;Rich Client 使用 private in-memory transport,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 +- Shared TUI 复用同一 App Server cursor 与 `session/sync` 合同,但 cursor 仍是 connection-local;重连后的旧 cursor 不能透明续用,client 必须重新 initialize、subscribe 并执行权威 sync。任何路径都不能把流失效伪装成透明恢复。 +- Embedded Rich Client 使用 private in-memory transport,不增加后台进程;只有显式 `--shared` 启动或复用本机 Shared Host。 ## 4. Process View · Level 1 @@ -230,81 +229,51 @@ Desktop 作为 ACP client 管理的外部 agent Session 不经过该 Runtime own 该机制不增加后台进程、轮询、连接或常驻线程,也不改变 Shared TUI 的连接控制规则。临时 Session 不写入磁盘,因此不参与此检查。 -### 4.3 私有本机 IPC +### 4.3 Shared App Server 本机 transport ```mermaid sequenceDiagram participant C as Shared TUI client participant D as User-private discovery - participant S as Shared Runtime Host process + participant S as Shared App Server Host C->>D: read endpoint + token + identity + protocol - C->>S: connect via Named Pipe / UDS - C->>S: initialize(identity, protocol, token) + C->>S: connect via loopback TCP + C->>S: authenticate(token, identity, protocol) + C->>S: app/initialize alt valid - S-->>C: initialized(health + interactive_tui) - C->>S: create or restore Session - S-->>C: Session control + Session facts - C->>S: rename or update current Session - C->>S: delete idle non-current Session - C->>S: reload current Session context - C->>S: compact current Session context - C->>S: submit/cancel Turn or answer Permission/UserInput - S-->>C: Session-filtered authoritative events + S-->>C: initialized(capabilities + limits) + C->>S: session/subscribe + C->>S: typed App Server requests + S-->>C: Session-filtered events and Permissions else invalid S-->>C: typed error and close end ``` -当前私有协议(v17)只覆盖 TUI 已有用户旅程需要的窄操作: +Shared TUI 使用与 Embedded TUI 相同的 App Server method、DTO、错误和事件合同。区别只在 Host 和 transport: -| 已支持 | 明确不支持 | -|---|---| -| Health、只读 workspace-scoped main Agent 摘要、Session list/create、原子 restore(含 transcript 与 pending Permission)、删除未被控制的空闲 Session、当前 Session fork(含 transcript)、rename、Agent mode/model update、声明式上下文 reload、根 Session lineage 查询与后代 transcript 读取 | Session archive、跨 workspace attach、transcript 分页、模型目录/默认值和完整 Agent/Subagent 管理 | -| Turn submit/cancel、当前 Session 手动 context compaction、lineage 成员校验后的单个后代执行子树取消 | replay、cursor、resume event stream、独立的根级批量后代取消 API | -| pending/respond Permission、submit UserInput answers、只读 workspace diff | observer、通用 controller transfer、多 Session multiplex | -| 连接断开清理、Session-filtered events | detach/observer/通用 controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | - -这些操作先满足以下本机 IPC 地基,而不把协议升级为公开 SDK: - -- workspace、产品、release channel、用户和协议版本共同生成实例身份; -- instance lock 而不是 PID/discovery 文件决定唯一 server owner; -- Windows 使用拒绝远程连接的 Named Pipe;Unix 使用短且由 instance identity 决定的稳定 Domain Socket 名称,权限为 `0600`; -- discovery 所在目录必须由未来 composition 选择为当前用户私有目录; -- discovery 通过同目录临时文件原子替换;Unix endpoint 保留原生路径字节,路径过长时在 bind 前返回明确错误; -- 第一帧必须完成 token、instance identity 和 protocol version 校验; -- 未认证握手预算为 2 秒;认证后的单次操作、响应写入和断线取消预算为 120 秒,避免坏客户端长期占用连接或 Runtime handler; -- JSON frame 使用 4-byte 长度前缀;request 在发送前执行 128 KiB 上限(覆盖 TUI 已有的 64 KiB 粘贴输入及类型化信封),response/event 在序列化时执行 8 MiB 上限。超限返回类型化错误,不能进行无界分配;超过该上限的历史 Session 暂由 Embedded TUI 打开,不在本阶段引入分页协议; -- 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task; -- 未知 frame/operation 信封字段、未知 operation、错误身份和不兼容版本 fail closed;复用的 Runtime DTO 按其既有反序列化契约处理字段; -- v10 增加两个只读、current-controller 限定的工作区引用 operation:按当前 Session 搜索文件/目录,以及按 user message ID 读取已持久化的结构化引用。两者复用 Agent Runtime 的 workspace-reference port,不赋予 IPC adapter 文件系统或 Session 持久化所有权,也不扩展为 Remote 或公开 SDK 协议。 -- v11 增加无请求体、无 Session lease 的只读 workspace diff operation。它只在当前连接没有活动 Turn 时查询 Runtime 启动时绑定的 canonical workspace,避免单连接请求排序阻塞流事件或 Turn 控制,并返回 Runtime Port DTO;Git 行为仍由 `services-integrations` provider 持有。文本 patch 总量限制为 3 MiB,为 JSON 转义和 envelope 预留既有 8 MiB response frame 的空间;该 operation 不隐式获得 stage/reset/commit、Remote 或公开 SDK 能力。 -- v12 增加用户显式 Shell Turn;v13 增加活动 Turn steering。两者都复用 Agent Runtime 的原有准入、Tool、权限、持久化和取消 owner,不在 IPC 内复制执行状态机。 -- v14 增加三个 current-root-controller 限定的 lineage operation:查询 Runtime 归一化后的扁平 lineage、读取已验证后代的权威 transcript,以及取消指定后代的活动执行子树。查询和读取可在根 Turn 活动时执行;取消复用现有 Session abort 语义,但不切换 controller,也不引入 observer、detach、分页或通用 Session RPC。 -- v15 为后代 transcript 读取增加 `required_settled_turn_ids` 一致性前置条件:Runtime 必须确认这些 Turn 已由 owner 持久化为终态,否则返回 `outcome_unknown`,由 TUI 在同一绝对期限内退避重试;TUI 只保留事件投影和该读屏障,不合并或重写权威 transcript。后代取消同时携带用户实际看到的 `expected_active_turn_id`,并在 owner 锁内拒绝已经切换的 Turn,避免迟到操作取消后续执行。lineage 查询和 transcript 读取是每连接至多一个的可抢占推测读取;更新的请求会取消旧读取,使后代取消和 Session 切换不会排在慢 transcript I/O 之后。该行为不放宽 controller 校验,不引入 observer 或通用多路复用。 -- v16 增加只读、workspace-scoped main Agent 摘要,用于 Shared TUI 与 Runtime host 的 selector 投影一致。启动页以 Runtime 启动工作区查询且不取得 Session lease;已有 Session 由 Runtime owner 解析其执行工作区并要求当前 controller。响应只包含逻辑 ID、描述、可选固定 model ID 与 ecosystem-neutral 的 external-source 分类;发现、审批、冲突消解、generation 与执行仍由既有 Agent Registry 和 external-source owner 负责,不经 IPC 暴露安装、变更、激活、Subagent 管理或 runtime lifecycle API。 -- v17 扩展原子 restore,使响应带回 Runtime Session state;增加结构化 Session usage、等待指定 Turn settlement,以及记录本地命令 - transcript turn 的 operation。它只补齐当前 Shared TUI 与 `TuiBackend` 的行为等价,没有增加 replay、observer、通用 controller - transfer、多 Session multiplex 或公开 SDK 能力。 -- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore/fork 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。fork 只接受当前 controller 的空闲 Session;无选中 Turn 时复制到最新持久化 Turn,指定 `before_turn_id` 时只复制该 Turn 之前的历史。活动 Turn 期间不能切换或 fork Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 -- Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 -- Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模型目录以及完整 Agent/Subagent 管理仍是同版本第一方产品事实,不加入 IPC;v16 的 main Agent 摘要只是 host-owned selector 所需的最小只读投影。 -- 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 -- v17 保留 `update current Session model` operation 及其 controller/idle/unknown-outcome 合同,但模型目录和默认值不进入该 wire。Phase 3 移除 TUI controller 对本机产品配置 owner 的直连后,`SharedTuiBackend` 通过 Host 显式注入的具体 `AppManagementService` 保留模型选择和配置;该 capability 只描述当前本机 Shared CLI adapter,不伪装成 v17 或 Remote capability。 -- 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。 - -这是一条本机同用户边界,不是沙箱、远程协议或公开兼容承诺。 +- canonical workspace 与产品 identity 生成实例 key;instance lock 而不是 PID 或 discovery 文件决定唯一 Host owner; +- discovery 位于当前用户私有目录,通过同目录临时文件原子替换,并携带 loopback endpoint、随机 bearer token、owner id 和协议版本; +- 认证预算为 2 秒,失败、错误身份和不兼容版本均 fail closed; +- request 上限为 128 KiB,response/event 上限为 8 MiB;未知字段、无效 JSON 和超限 frame 在 transport 边界拒绝; +- Host 最多接受 64 条连接;每条连接运行独立 App Server connection,并显式 subscribe/unsubscribe Session; +- 事件和 Permission 按连接的 Session subscription 过滤,多个连接可以同时订阅、观察和操作同一 Session; +- 不使用 controller lease。独立 Turn 由唯一 Runtime owner 的 Session scheduler 串行准入,steer/cancel 必须携带精确 Turn ID; +- Host 通过 `AppManagementService::load_for_local_host` 装配本机管理能力,不在 TUI controller 中复制 Model、Skill、Subagent、MCP、Hook、Account 或 Worktree owner; +- Windows Host 在初始化前进入 kill-on-close Job;Unix 通过受管进程组执行应用内优雅回收。这是生命周期机制,不是安全沙箱; +- 最后一条连接离开 30 秒后退出,新连接会取消空闲退出;cleanup 只删除当前 owner 发布的 discovery。 + +当前 connection-local cursor、`app/syncEvents` 和 `session/sync` 可以在同一连接内恢复权威状态,但尚未提供跨连接持久 replay 或透明 resume。断连后的副作用请求也没有通用 `outcome_unknown` 查询/恢复合同,慢客户端治理仍不完整;客户端不得盲目重试无法确认结果的操作。这是一条本机同用户边界,不是 Remote、Peer、SDK、浏览器或公网兼容承诺。 ### 4.4 Serialization、并发与性能 ```mermaid flowchart LR - T1["TUI 1"] --> IPC["有界本机 IPC"] - T2["TUI 2"] --> IPC - TN["TUI N"] --> IPC - IPC --> Runtime["一个 Shared Runtime"] + T1["TUI 1"] --> AS["有界 Shared App Server transport"] + T2["TUI 2"] --> AS + TN["TUI N"] --> AS + AS --> Runtime["一个 Shared Runtime"] Runtime --> Tasks["Tokio tasks"] Runtime --> Owner["一个 Session owner"] ``` @@ -315,10 +284,10 @@ flowchart LR |---|---|---| | Embedded Rich Client | `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 不初始化跨进程 IPC 或后台进程;保持与 Shared 相同的 JSON-RPC、DTO、错误和事件语义,编解码成本通过测量优化而不增加直连旁路 | | Embedded non-Rich Client | Headless、ACP、Peer 和 SDK Host 的独立 adapter 以 Rust 类型调用 Runtime API | 不因 Rich Client 合同承担 App Server wire;保持各自协议和生命周期 | -| Shared request | Client 将 operation 编码一次并写入一个长度前缀 frame | 请求保持 128 KiB 上限;业务层只接收类型化 operation | -| Shared response/event | Server 将结果或事件编码一次后写出 | 响应/事件保持 8 MiB 上限;超限使事件流明确失效,不能无界分配 | -| Shared receive | 每个方向只有一个严格 transport decode 边界 | 未知信封字段和不兼容版本 fail closed;严格校验可以检查规范化 JSON,但不能把动态 JSON 传入 Runtime owner | -| 多 TUI | 一个 Runtime、最多 64 个连接;每个 Client 的 command channel 容量为 64、event channel 容量为 256 | request gate 使每个 Client 同时只有一个控制请求进入 channel;lineage 推测读取写入后释放 gate,Server 同时只保留一个且允许更新请求抢占;Client 使用并发 reader 与单一有序 writer,避免大 transcript 响应和后续大请求互相阻塞;事件落后时失效而非无限缓存 | +| Shared request | Client 将一行认证或 App Server JSON-RPC request 编码一次 | 请求保持 128 KiB 上限;认证完成后只接收正式 App Server method | +| Shared response/event | Host 将 App Server response 或 notification 编码一次后写出 | 响应/事件保持 8 MiB 上限;超限关闭该连接,不能无界分配 | +| Shared receive | 每个方向只有一个严格 line-codec decode 边界 | 未知认证字段、不兼容版本和无效 JSON fail closed;动态 JSON 只进入 App Server protocol decoder,不传入 Runtime owner | +| 多 TUI | 一个 Runtime、最多 64 个连接;每条连接有独立 App Server task 和有界 event stream | 多连接可订阅同一 Session;Runtime owner 串行准入独立 Turn,慢 client 不能制造无界连接或 frame,但完整背压治理仍待补齐 | 协议只承载当前交互所需的小型控制请求、受 3 MiB 文本上限保护的 workspace diff 快照和既有事件。大 transcript 继续受 frame 上限约束;本阶段不为假设场景增加通用分页、二进制 side channel、压缩或批处理协议。 @@ -331,10 +300,9 @@ flowchart TB GUI["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> AppServer["App Server"] EmbeddedTUI["Embedded TUI"] --> AppServer - SharedTUI["Shared TUI"] --> SharedCompat["Runtime IPC v17 compatibility"] + SharedTUI["Shared TUI"] --> AppServer DesktopAdapter --> API["Agent Runtime API / owner ports"] AppServer --> API["Agent Runtime API / owner ports"] - SharedCompat --> API CLI["Headless CLI adapter"] --> API SDK["SDK Host adapter"] --> API ACP["ACP adapter"] --> API @@ -359,18 +327,17 @@ flowchart LR Backend -->|"Embedded"| Client["AppServerClient"] Client --> Memory["in-memory transport"] Memory --> AppServer["BitfunAppServer"] - Backend -->|"Shared compatibility"| IPC["adapters/agent-runtime-ipc v17"] - IPC --> Handler["CLI Shared handler"] + Backend -->|"--shared"| SharedClient["AppServerClient"] + SharedClient --> Loopback["authenticated loopback transport"] + Loopback --> SharedHost["BitfunAppServer"] AppServer --> Runtime["execution/agent-runtime / owners"] - Handler --> Runtime + SharedHost --> Runtime ``` -CLI Host 负责命令解析、TUI 状态、错误文案、App Server 组装和 transport 生命周期;`TuiBackend` 隔离当前 Shared compatibility adapter。 -App Server 或私有 IPC 只负责协议、连接控制和类型映射;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。 -TUI 业务代码不根据部署形态复制业务分支,Shared 达到 App Server 语义等价后替换 compatibility adapter。 +CLI Host 负责命令解析、TUI 状态、错误文案、App Server 组装和 transport 生命周期。Embedded 与 Shared 都通过 `AppServerTuiBackend`;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。TUI 业务代码不根据部署形态复制业务分支。 - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 -- 交互式 TUI 的启动页和会话页复用 app-local `TuiBackend`;Embedded backend 使用正式 `AppServerClient`,Shared backend 暂时映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 +- 交互式 TUI 的启动页和会话页复用 app-local `TuiBackend`;Embedded 与 Shared backend 都使用正式 `AppServerClient`。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 Host transport 实现。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 - TUI 不是 Server;Embedded Host 在同进程组装私有 App Server,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或 App Server 行为合同。 - Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 @@ -387,7 +354,7 @@ flowchart TB Headless["Headless / CI"] --> Runtime end subgraph Shared["显式 --shared"] - Clients["one or more TUI processes"] -->|"Named Pipe / UDS · current compatibility"| SharedRuntime["Shared Runtime Host process"] + Clients["one or more TUI processes"] -->|"authenticated loopback App Server"| SharedRuntime["Shared App Server Host process"] end Runtime --> Data["workspace + Session storage"] SharedRuntime --> Data @@ -404,31 +371,30 @@ sequenceDiagram participant U as User participant T as TUI adapter participant B as TuiBackend - participant E as Embedded App Server adapter - participant S as Shared Runtime IPC v17 adapter + participant A as App Server participant R as Agent Runtime U->>T: /rename Auth refactor T->>T: trim + require idle Session T->>B: typed TuiBackend request alt Embedded - B->>E: typed App Server request - E->>R: owner port call + B->>A: in-memory App Server request + A->>R: owner port call R->>R: validate ownership + persist - R-->>E: applied / failed / outcome_unknown - E-->>B: mapped typed result - else Shared compatibility - B->>S: Runtime IPC v17 request - S->>R: owner port call + R-->>A: applied / failed + A-->>B: typed result + else Shared + B->>A: loopback App Server request + A->>R: owner port call R->>R: validate ownership + persist - R-->>S: applied / failed / outcome_unknown - S-->>B: mapped typed result + R-->>A: applied / failed + A-->>B: typed result end B-->>T: typed result T-->>U: update name only after applied ``` -Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Runtime 只有在确认旧名称已保留时才返回明确失败;持久化恢复无法确认时,两种部署都返回 `outcome_unknown`。Shared 还会在请求已发送但权威响应丢失时返回该结果并关闭连接。用户恢复 Session、检查当前名称后再决定是否重试。 +Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Shared transport 断连后尚无通用未知结果查询,因此客户端不得盲目重试;用户重新同步 Session、检查当前名称后再决定是否再次提交。 ### 5.4 Scenario (+1) · Delete an idle Session @@ -437,31 +403,30 @@ sequenceDiagram participant U as User participant T as TUI adapter participant B as TuiBackend - participant E as Embedded App Server adapter - participant S as Shared Runtime IPC v17 adapter + participant A as App Server participant R as Agent Runtime U->>T: /sessions then Ctrl+D T->>T: reject current or active target T->>B: typed TuiBackend request alt Embedded - B->>E: typed App Server request - E->>R: owner port call + B->>A: in-memory App Server request + A->>R: owner port call R->>R: existing delete owner - R-->>E: applied / failed / outcome_unknown - E-->>B: mapped typed result - else Shared compatibility - B->>S: Runtime IPC v17 request - S->>R: owner port call + R-->>A: applied / failed + A-->>B: typed result + else Shared + B->>A: loopback App Server request + A->>R: owner port call R->>R: existing delete owner - R-->>S: applied / failed / outcome_unknown - S-->>B: mapped typed result + R-->>A: applied / failed + A-->>B: typed result end B-->>T: typed result T-->>U: remove only after applied ``` -Embedded 和 Shared 最终调用同一个 Agent Runtime。Shared Runtime Host 通过 v17 handler 调用 Runtime;它不是 Shared App Server。Shared Runtime Host 只在请求方没有活动 Turn、目标 Session 未被任何 Client 控制时调用 Runtime owner;`session_in_use` 和 `not_found` 保持结构化错误。TUI 复用现有单个 Session 异步任务槽位,不阻塞事件循环,也不自动重试结果不确定的删除。 +Embedded 和 Shared 最终调用同一个 Agent Runtime。Shared App Server Host 通过同一 typed handler 调用 Runtime owner;`session_in_use` 和 `not_found` 保持结构化错误。TUI 复用现有单个 Session 异步任务槽位,不阻塞事件循环,也不自动重试结果不确定的删除。 ## 6. 隔离和生命周期原则 @@ -470,11 +435,11 @@ Embedded 和 Shared 最终调用同一个 Agent Runtime。Shared Runtime Host | 事实 | 用途 | |---|---| | canonical workspace + product | 防止 Embedded 与 Shared 同时拥有同一工作区 Runtime | -| workspace + product + release channel + user + protocol | 定位兼容的本机 Shared instance | +| canonical workspace + product identity | 定位兼容的本机 Shared instance,并与 Runtime ownership 使用同一 identity 事实源 | | stable local endpoint + bearer token + owner id | endpoint 定位同一 instance;随机 token 认证本轮 server;owner id 防止旧实例误删新 discovery | -| 实际 Session 存储位置 + Session ID | 限制持久化 Session 的跨进程并发写入;不由 IPC 协议定义 | +| 实际 Session 存储位置 + Session ID | 限制持久化 Session 的跨进程并发写入;不由 App Server 协议定义 | -当前 Shared TUI 只有 controller,没有 observer 或 detached Query:一个 Client 关闭不会删除 Session;它会取消仍拥有的活动 Turn,只有取消得到确认才释放 Session 控制权,否则继续隔离该 Session,直到 Runtime 退出。最后一个 Client 关闭后,Runtime 进入 30 秒空闲期;期间重连可继续使用,超时后 Runtime 正常关闭。若未来增加后台任务、observer 或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。 +当前 Shared TUI 没有 controller lease。多个 Client 可以订阅、观察和操作同一 Session;一个 Client 关闭不会删除 Session,也不会转移或释放独占控制权,因为独立 Turn 的单写由 Runtime owner 按 Session 串行准入。最后一个 Client 关闭后,Host 进入 30 秒空闲期;期间重连可继续使用,超时后正常关闭。若未来增加后台任务或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。 对普通单实例用户,未显式启用 Shared deployment 时不增加后台进程、连接、发现扫描或常驻内存。 @@ -483,23 +448,23 @@ Embedded 和 Shared 最终调用同一个 Agent Runtime。Shared Runtime Host 未来每增加一类 Shared 能力,都必须同时满足: 1. 已有明确第一方 consumer 和用户旅程; -2. 行为由现有 Runtime owner 提供,IPC 只映射 typed request/result/event; +2. 行为由现有 Runtime owner 提供,App Server 只映射 typed request/result/event; 3. 定义权限、取消、deadline、断线、背压和副作用结果不确定性; 4. Embedded 与 Shared 使用同一行为 fixture; 5. 新能力不被顺带发布为 Agent SDK、Remote 或浏览器 API。 -Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理和 Remote 应分别通过上述门槛,不能一次性加入一个“全量 Shared API”。 +Session/Turn、事件恢复、Permission/UserInput、配置管理和 Remote 应分别通过上述门槛,不能一次性加入一个“全量 Shared API”。 -当前 IPC crate 只是一条可删除的预集成边界: +当前 Shared App Server transport 是本机第一方产品边界: | 约束 | 当前决定 | |---|---| -| 当前 consumer | 仅第一方交互式 TUI adapter;不自动包含 GUI、Headless CLI、Remote 或 SDK Host | -| 稳定测试合同 | 本机 endpoint、initialize-first、128 KiB request / 8 MiB response-event 上限、连接上限、owner-checked cleanup、原子 Session controller 切换、单连接单活动 Turn、事件流失效后 fail closed、断连取消、30 秒空闲退出 | -| 当前业务范围 | Session list/create/restore/delete、Turn/transcript、当前 Session name/Agent mode/model、Permission/UserInput 的 TUI 必需子集;任何新增操作都需要真实 consumer 和 owner 等价测试 | -| 协议地位 | crate 保持 `publish = false`;这是 workspace 内私有协议,不是 Agent SDK 或远程兼容承诺 | +| 当前 consumer | 仅第一方交互式 TUI;不自动包含 GUI、Headless CLI、Remote、Peer、ACP 或 SDK Host | +| 稳定测试合同 | loopback endpoint、token + identity 认证、initialize-first、128 KiB request / 8 MiB response-event 上限、连接上限、owner-checked cleanup、连接级 Session subscription、Runtime Turn 串行准入和 30 秒空闲退出 | +| 当前业务范围 | 正式 App Server handler 暴露的 TUI Session、Turn、Permission、Workspace 和本机 management 用例;Host 只能发布真实装配的 capability | +| 协议地位 | workspace 内第一方本机 transport,不是 Agent SDK、Remote、Peer 或浏览器兼容承诺 | -架构守卫只允许 CLI 消费该 crate;IPC 可以复用稳定的 Event、Product Domain 与 Runtime Port DTO,但禁止依赖 Runtime 实现、SDK Host、services、Tauri 或远程网络 transport。 +架构守卫要求 TUI controller 只依赖 `TuiBackend`,Shared Host 通过正式 App Server protocol/client/server crate 接线;controller 不得依赖 Host transport、Runtime 实现或服务 owner。 ## 8. 与竞品的取舍 @@ -518,8 +483,8 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 - 当前入口使用第 1.1 节列出的 adapter;若第 1.2 节目标通过评审并迁移完成,Desktop GUI、Web UI 和交互式 TUI 才统一使用 App Server。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 -- 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。 -- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理暂由 CLI Host 显式装配的 App Server `AppManagementService` 承接;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供并返回 typed unsupported。这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 +- 当前 Shared App Server transport 只服务第一方 TUI,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。 +- Shared TUI 的 Model、Skill、Subagent、MCP、External Source、Hook、Account、Settings Sync 和 Worktree 管理由 Shared Host 显式装配的本机 App Server `AppManagementService` 承接。Host 端 owner 负责真实状态,TUI controller 不执行本机 fallback;Remote workspace 仍需独立的目标执行域 Host contract。 - 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 5d72ba2275..0d49ab5b99 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -205,8 +205,8 @@ flowchart TB 一次性 Headless CLI 继续 Embedded;公开 SDK 默认连接私有 SDK Host。Shared Agent Runtime process 和 SDK Host 都是 Rust 产品进程, 与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。 -当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、当前 Session 的 name/Agent mode/model、Permission/UserInput、 -ownership 和生命周期治理;Model、Skill、Subagent 和 MCP 管理暂由 Shared CLI adapter 的本地 compatibility provider 保留,不属于 v17 或公开 SDK 合同。GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 +当前代码已经交付显式启用的 Shared TUI App Server,包含本机 transport、身份、握手、Session/Turn、当前 Session 的 name/Agent mode/model、Permission/UserInput、 +ownership 和生命周期治理;Model、Skill、Subagent、MCP 及其他本机管理面由 Shared Host 装配的 `AppManagementService` 提供,不属于公开 SDK 合同。GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 当前事实,除 Shared TUI 外的跨进程 Shared deployment 仍是目标架构。 ### 4.3 各形态能做什么 diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md index 765d7afc61..f8a2dcc0c9 100644 --- a/docs/architecture/app-server-architecture.md +++ b/docs/architecture/app-server-architecture.md @@ -29,15 +29,15 @@ | --- | --- | --- | --- | --- | | A. App Server-first Rich Clients(当前首选) | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Desktop/Web 迁移面大;Shared 必须重新交付连接治理 | 真实 Desktop/TUI consumer、跨 transport parity、性能和安全门槛全部通过 | | B. Deployment-specific product adapters | Desktop、Web、Embedded TUI、Shared TUI 各保留窄 adapter,共享 owner ports | 每个 Host 可按自身生命周期优化,迁移风险较低 | DTO、错误、恢复和行为 fixture 可能分叉;跨入口一致性需额外治理 | 证明长期重复成本低于统一 wire 成本,并建立跨 adapter 行为合同 | -| C. Shared Runtime use cases with separate wires | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v17 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | 证明共享 use case 不泄漏 Runtime 实现,并分别验证每条 wire 的故障语义 | +| C. Shared Runtime use cases with separate wires | 提取稳定用例/结果,Embedded、Shared 和 Web 使用各自受控 wire | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | 证明共享 use case 不泄漏 Runtime 实现,并分别验证每条 wire 的故障语义 | -评审可以选择 A、B、C 或其受限组合。已有 `TuiBackend`、App Server 和 v17 是评估证据,不自动决定最终架构。 +评审可以选择 A、B、C 或其受限组合。已有 `TuiBackend` 和 App Server 是评估证据,不自动决定 Desktop 的最终架构。 ### 1.2 Costs of the preferred candidate - Embedded Rich Client 需要承担 App Server client/server、JSON-RPC 编解码、事件队列和专用 runtime/thread 的启动、内存与延迟成本;必须以基准证明该成本可接受。 -- 迁移期会同时维护 App Server 与 Runtime IPC v17 两条 wire;新增核心用例需保持 `TuiBackend` 行为等价,不能让双写期形成两个业务 owner。 -- Shared App Server 需要重新交付 v17 已有的 framing、方向性 limits、鉴权、实例身份、controller/lease、断连取消、未知结果和空闲退出,不能只复用 method/DTO。 +- Shared App Server 当前已交付 framing、方向性 limits、鉴权、实例身份、连接级 Session subscription 和空闲退出;跨连接 replay、完整慢客户端治理、断连取消和未知结果恢复仍需继续补齐。 +- Shared 与 Embedded 复用同一 typed client 和 App Server handler,但 Host-specific 连接治理仍必须有独立测试,不能只用 method/DTO 相同证明可靠性等价。 - Desktop 迁移必须划清 controller-local capability、Tauri 生命周期和工作区 Host capability;Web/Remote 扩展还需要独立的认证、授权和多租户资源治理。 ### 1.3 当前实现状态 @@ -47,28 +47,30 @@ | 范围 | 当前状态 | 目标 | | --- | --- | --- | | Embedded TUI | 已创建私有 `BitfunAppServer`,通过 in-memory transport 连接 `AppServerClient` | 完成剩余管理面迁移和行为等价验证 | -| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | App Server Shared transport 达到可靠性等价后迁移 | +| Shared TUI | `--shared` 启动或连接本机 Shared App Server Host,通过正式 `AppServerClient` 与 `AppServerTuiBackend` 工作 | 补齐跨连接恢复、未知结果和慢客户端治理 | | Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Tauri 收窄为 Host adapter,产品请求统一进入 App Server | | Web Host | 当前 Server 已组装 Embedded Runtime,WebSocket 直接承载 `BitfunAppServer`;仅适用于 loopback 单用户模式 | 补齐连接身份、作用域绑定和 Host allowlist 后才能扩展部署范围 | | App Server protocol/client | 已拆为 behavior-light crate,已有版本、能力、限制、错误和部分事件恢复类型 | 补齐 Host 注入能力、可靠性语义及跨 transport 合同测试 | | App Server server | 已注册 app、agent、session、permission、TUI/workspace、git、config 和 i18n handler | 按真实 owner 和 Host 装配收窄能力,不以已存在 DTO 代替可用性证据 | -Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。只有候选 A 获批且替换门槛通过后,才迁移或删除该 IPC;候选 B/C 可能将 private v17 或后继协议保留为受控的长期物理 wire。 +`bitfun --shared` 按 canonical workspace 启动一个独立 Host 和一个 Runtime owner,使用 loopback TCP、随机 bearer token、实例身份、实例锁、严格 frame 上限和 30 秒无连接退出;每条连接通过 `session/subscribe` / `session/unsubscribe` 选择事件与 Permission 作用域。多个已认证客户端可以同时订阅、观察、steer 或按精确 Turn ID cancel 同一 Session,不使用 controller lease。独立 Turn 的提交仍由唯一 Runtime owner 按 Session 串行准入,因此“多客户端可操作”不等于“同一 Session 历史可并行改写”。 + +该入口不是公网、多用户或远程 API。当前尚未补齐跨连接持久 replay、透明 resume、通用 `outcome_unknown` 恢复和完整慢客户端治理;这些限制必须明确暴露,不能以静默重试或控制端本机 fallback 掩盖。 ### 1.4 Decision and replacement gates -在满足下列门槛前,不得把候选 A 标记为 approved,也不得用 Shared App Server 替换 v17: +Shared TUI 已采用 App Server;下列门槛继续约束该 transport 的可靠性收敛,以及 Desktop 等后续 Rich Client 迁移: | 门槛 | 必需证据 | | --- | --- | | Framing 与 limits | request/response/event/attachment 的方向性上限、无界分配防护、慢 client/backpressure 和超限结果均有跨 transport 测试 | | 身份与作用域 | 实例身份、每连接认证、user/product/workspace/execution-domain 绑定和 method allowlist fail closed | -| Controller 与 Session 单写 | controller/observer/lease、断连隔离、跨进程 Session writer 冲突和转移规则有 owner-level 决策与竞争测试 | +| 多客户端与 Session 单写 | controller/observer/lease 或无 lease 的 Runtime 串行准入模型、断连隔离、跨进程 Session writer 冲突和精确 Turn 控制均有 owner-level 决策与竞争测试 | | 事件恢复 | 明确 snapshot/replay owner、连接内 cursor、跨连接是否持久化、lag/closed/invalidation 和 resync 行为 | | 取消与未知结果 | disconnect/shutdown 取消、迟到响应、operation identity、`outcome_unknown` 查询/恢复和禁止盲重试 | | Host capability | Desktop local effect 与工作区 capability 边界、provider 注入、Remote unsupported 和 Web/Remote auth 已定稿 | | 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐、内存和 Embedded thread/runtime 成本有预算与测量 | -| 迁移与回滚 | 同一第一方 consumer 完成 opt-in 双栈 parity;升级/降级和 v17 rollback 可重复验证;删除条件有明确 owner 批准 | +| 迁移与回滚 | 同一第一方 consumer 完成 Embedded/Shared parity;升级、降级、Host 重启和过期 discovery 恢复可重复验证;删除条件有明确 owner 批准 | ## 2. 问题与目标 @@ -77,7 +79,7 @@ GUI、Web 和 TUI 若分别围绕 Tauri command、WebSocket route、CLI/Core 直 - 同一用例出现多套 DTO、错误码、默认值和字段归一化。 - 某一入口完成权限、取消或远程工作区支持,其他入口仍静默缺失。 - 事件被不同 Host 投影后丢失身份、顺序或恢复信息。 -- UI 组件与 Tauri、Core singleton 或私有 Runtime IPC 绑定,无法验证跨入口行为等价。 +- UI 组件与 Tauri、Core singleton 或 Host 私有 transport 绑定,无法验证跨入口行为等价。 - “handler 已存在”“DTO 已生成”或“能力被硬编码为 available”被误当成端到端能力已交付。 App Server 的目标是提供一个可版本化、可生成 client、可跨 Embedded/Shared transport 验证的 Rich Client 合同,同时保持业务 owner 平台无关。它统一的是产品后端行为,不统一 GUI/TUI renderer、布局、键位、窗口、终端或 controller-local effect。 @@ -196,13 +198,13 @@ Shared Host 在基础 App Server 合同之外必须提供: - initialize-first 握手、协议版本和 client identity 校验。 - workspace、用户、产品和 execution domain 绑定。 - 连接数、请求队列、事件队列和 frame 大小上限。 -- 每个 Session 的 controller/lease、冲突和转移规则。 -- 断连时取消连接拥有的活动操作,并隔离未完成清理的 lease。 +- 使用连接级 Session subscription 与 Runtime owner 的 Turn 串行准入,不使用 controller lease。 +- 断连时隔离该连接未完成的请求,并以精确 Turn identity 执行可确认的取消。 - 有序 writer、并发 reader、背压和慢 client 失效策略。 - 无客户端且无活动任务时的受控空闲退出。 - 副作用请求在超时或断连后的 `outcome_unknown` 结果。 -当前 Runtime IPC v17 已具有 128 KiB request、8 MiB response/event、token、实例身份、controller/lease、断连取消、有界事件流、`outcome_unknown` 和空闲退出等合同。在 App Server Shared transport 逐项获得等价测试前,该 IPC 可以作为 Shared TUI 的兼容 adapter 保留;不得先切换 transport 再以功能回退换取表面统一。 +当前 Shared App Server 已具有 128 KiB request、8 MiB response/event、token、实例身份、连接级 Session subscription、有界连接数和空闲退出等合同。它仍缺少跨连接持久 replay、透明 resume、通用 `outcome_unknown` 恢复与完整慢客户端治理;后续工作必须以故障测试补齐这些合同,不能用表面 wire 统一替代可靠性证据。 ## 8. Desktop GUI 与 Tauri @@ -259,7 +261,7 @@ WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web H 4. transport limits 必须反映当前连接的真实限制;不能把 server 内部默认值宣传为所有 transport 的通用事实。 5. method 级 allowlist 必须是 capability 声明的子集,fallback handler 不能扩大可调用面。 -当前实现中通用 App Server 初始化声明 16 MiB frame,而 WebSocket Host 接收上限为 256 KiB,Shared Runtime IPC 又区分 128 KiB request 与 8 MiB response/event。目标合同需要表达方向和 transport 的真实限制;在扩展 schema 前,Host 至少必须返回不超过底层 transport 的有效上限。 +当前实现中通用 App Server 初始化声明 16 MiB frame,而 WebSocket Host 接收上限为 256 KiB,Shared App Server Host 又区分 128 KiB request 与 8 MiB response/event。目标合同需要表达方向和 transport 的真实限制;在扩展 schema 前,Host 至少必须返回不超过底层 transport 的有效上限。 ## 11. 事件、恢复与取消 @@ -333,10 +335,10 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 迁移按行为闭环推进,不按 method 数量推进: 1. **锁定合同基础**:稳定 protocol/client crate、版本、错误、能力、限制和事件 envelope;增加 Embedded contract test。 -2. **完成 Embedded TUI**:所有交互式 TUI 产品请求经 `TuiBackend -> AppServerClient`;移除 Core、Runtime SDK、Service singleton 和 Runtime IPC 的 TUI-facing 依赖。 -3. **迁移 Desktop GUI**:按 Session/Turn/Permission、Workspace、Config/MCP/Extension 等垂直切片迁移;每片完成后删除重复 Tauri DTO/handler。 -4. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 -5. **评审 Shared TUI 迁移**:候选 A 获批且 1.4 节门槛通过后,才用同一 client/schema 替换 Runtime IPC compatibility adapter;旧 wire 仅在 rollback 窗口结束并获得 owner 批准后删除。若选择 B/C,则记录 v17 的长期 owner、版本和删除条件。 +2. **完成 Embedded TUI**:所有交互式 TUI 产品请求经 `TuiBackend -> AppServerClient`;移除 Core、Runtime SDK、Service singleton 和 Host 私有 transport 的 TUI-facing 依赖。 +3. **完成 Shared TUI 替换**:`--shared` 使用同一 typed client/schema 连接 Shared App Server Host,删除旧命令、旧 adapter 和旧 wire crate。 +4. **补齐 Shared 语义**:在现有 authentication、instance identity、framing、连接级 Session subscription 和 idle exit 基础上,补齐背压、断连取消、跨连接 event recovery 和 `outcome_unknown`。 +5. **迁移 Desktop GUI**:按 Session/Turn/Permission、Workspace、Config/MCP/Extension 等垂直切片迁移;每片完成后删除重复 Tauri DTO/handler。 6. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback 单用户限制。 7. **删除旁路**:移除 Rich Client 的 Core/Runtime 直连、重复事件投影和无生产消费方的旧 route。 @@ -362,12 +364,12 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 1. Desktop GUI、Web UI 和交互式 TUI 的产品后端请求与订阅均经过 App Server。 2. Embedded 与 Shared 使用同一 client、method、DTO、错误和事件恢复合同,UI 不包含部署分支。 -3. Shared transport 达到现有 Runtime IPC 的鉴权、lease、取消、背压、限制、失效和生命周期等价。 +3. Shared transport 的鉴权、Session subscription、取消、背压、方向性限制、失效和生命周期合同均有独立故障测试。 4. capability 和 limits 来自 Host 的真实装配与 transport,不再由通用 handler 无条件硬编码。 -5. Rich Client 不直接依赖 Core singleton、Runtime SDK、Tauri 业务 command 或私有 Runtime IPC。 +5. Rich Client 不直接依赖 Core singleton、Runtime SDK、Tauri 业务 command 或 Host 私有 transport。 6. App Server handler 不持有业务权威状态,不复制 owner 校验和策略。 7. Remote workspace 和多用户连接具有明确身份、作用域、授权和 fail-closed 行为。 -8. 重复 Tauri/Web/IPC DTO、旧 handler 和事件旁路已删除,或有明确的兼容期限与删除证据。 +8. 重复 Tauri/Web/transport DTO、旧 handler 和事件旁路已删除,或有明确的兼容期限与删除证据。 9. 上述合同、行为、安全、依赖和跨入口测试全部通过。 ## 17. Proposed constraints and open decisions @@ -391,4 +393,4 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 - Desktop client-local capability 的请求方向:App Server 反向 request、Host provider port,或显式两段式工作流。 - Web/Remote 的认证凭据来源、刷新、撤销和多租户资源配额。 -这些待决项会影响候选选择,不能被实现默认值或迁移进度替代。评审结论必须记录所选候选、拒绝其他候选的理由、门槛 owner、验证证据和回滚/删除条件;在此之前,当前 Embedded App Server 与 Shared v17 路径都保持有效。 +这些待决项会影响候选选择,不能被实现默认值或迁移进度替代。评审结论必须记录所选候选、拒绝其他候选的理由、门槛 owner、验证证据和回滚/删除条件;当前 Embedded 与 Shared TUI 都使用 App Server,Desktop 的最终迁移选择仍须独立评审。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index a4492d158a..ca7d0af107 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -74,16 +74,16 @@ Platform services ### 3.2 Shared TUI -`bitfun chat --shared` 通过本机版本化 IPC 连接工作区 Runtime: +`bitfun --shared` 或 `bitfun chat --shared` 通过认证的本机 loopback transport 连接 Shared App Server: -- 多个 TUI 可以复用一个工作区 Runtime。 -- 一个 TUI 同时控制至多一个 Session;一个 Session 同时只有一个 controller。 -- 有副作用的请求携带稳定 identity,并声明 controller、idle、序列化和 side-effect 规则。 -- 超时或断线后无法证明是否已提交的请求返回 `OutcomeUnknown`,关闭连接并按已知 turn id 取消;客户端不能盲目重试。 -- IPC 只暴露经过评审的闭集操作,不演变成通用 Tool 或 Core RPC。 -- 不支持的附件或本地 effect 在 IPC 前失败。 +- 多个 TUI 复用一个工作区 Runtime 和同一套 App Server typed method/event 合同。 +- 每条连接显式订阅 Session;多个已认证客户端可以订阅、观察和操作同一 Session。 +- 独立 Turn 仍由唯一 Runtime owner 按 Session 串行准入;steer/cancel 必须携带精确 Turn ID。 +- request 为 128 KiB 上限,response/event 为 8 MiB 上限;实例发现、随机 bearer token、连接数上限和 30 秒空闲退出由 CLI Host 管理。 +- 当前没有跨连接持久 replay、透明 resume、完整慢客户端治理或通用 `outcome_unknown` 恢复,客户端不能盲目重试无法确认结果的副作用请求。 +- 不支持的附件或 controller-local effect 在 App Server 提交前失败。 -Automation、Desktop、Server、Relay 和公开 SDK 不因 Shared TUI 自动改用同一协议。 +Automation、Desktop、Server、Relay、Peer、ACP 和公开 SDK 不因 Shared TUI 自动改用同一协议。 ## 4. 关键运行路径 @@ -201,7 +201,7 @@ CLI 只消费 typed summary 与 typed action: | --- | --- | --- | | Core 兼容面收敛 | 部分 Peer/ACP/维护操作仍通过兼容 facade | 只按真实调用方提取窄 port;保持旧路径等价测试,不把 DTO 提取描述成 owner migration。 | | TUI 模块化 | 少数编排文件仍聚集输入、effect 与生命周期 | 沿现有模块增量提取,不重写 TUI,不复制状态机。 | -| Shared Runtime 闭集扩展 | 各形态能力仍有差异 | 逐操作评审 identity、lease、timeout、cancel 和 unsupported;不做通用 RPC。 | +| Shared App Server 治理 | 跨连接 replay、未知结果恢复和慢客户端治理仍不完整 | 逐操作评审 identity、timeout、cancel、recovery 和 unsupported;不做通用 Core RPC。 | | 配置来源解释 | 非 MCP 资产的来源/冲突解释仍不统一 | 共享 owner 给出 typed provenance;CLI 只展示,不建立第二套合并器。 | | 插件真实执行 | 静态来源与真实可调用能力容易混淆 | 只有 worker 健康、定义已加载并注册到 Tool Runtime 后才显示 available。 | | 平台验证 | Windows/macOS/Linux 的终端和进程行为不同 | 在对应切片增加 PTY/ConPTY、取消、路径和进程清理测试;HarmonyOS PC 以平台专题的真机证据为准。 | diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 7aad29f849..af0c3e7025 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -296,7 +296,7 @@ Agent Registry 按 role 从同一个 workspace route 和 generation registry 投 同名本地模式静默接管,但不持久化 provider、generation 或 `runtime_agent_key`。每个主 Turn 或 fresh Task 在执行前取得运行租约, 固定 `runtime_agent_key`、prompt、工具、权限与模型绑定直到结束,更新和撤下只影响下一次调用;外部 route 不可用时保持失败关闭, 只有用户显式选择当前冲突候选才会改变 route owner。显式模型只作为新 Session 无显式选择时的 profile 默认值,后续用户模型 -选择仍归 Session owner。Shared TUI 通过既有私有 Agent Runtime IPC 读取执行宿主的同一主 Agent 投影视图,协议只携带选择器所需 +选择仍归 Session owner。Shared TUI 通过 Shared App Server 读取执行宿主的同一主 Agent 投影视图,wire 只携带选择器所需 摘要,不携带来源状态或 prompt;Embedded TUI、Desktop 和 Peer Host 仍消费同一个 Agent Registry owner。当前不支持外部 session follow-up、OpenCode 会话内核、完整 permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt 正文,静态 system prompt 也不因该适配而改写。来源 `description` 只进入审批和管理界面;已批准 Agent diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 9c171546ba..fe42dfd031 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -212,7 +212,7 @@ flowchart TB subgraph AdaptersLayer[" "] direction LR - AdaptersTitle["3 · Adapters"] ~~~ RuntimeIPC["Runtime IPC"] ~~~ ModelAdapters["Model Adapters"] ~~~ SourceAdapters["Source Adapters"] ~~~ Transport["Transport"] ~~~ WebDriver["WebDriver"] + AdaptersTitle["3 · Adapters"] ~~~ ModelAdapters["Model Adapters"] ~~~ SourceAdapters["Source Adapters"] ~~~ Transport["Transport"] ~~~ WebDriver["WebDriver"] end subgraph ServicesLayer[" "] @@ -235,7 +235,7 @@ flowchart TB classDef header fill:#fafafa,stroke:#404040,stroke-width:1.6px,color:#171717; classDef module fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717; class AppsTitle,AssemblyTitle,AdaptersTitle,ServicesTitle,ExecutionTitle,ContractsTitle header; - class Desktop,CLI,Server,Relay,WebUI,MobileUI,ACP,SDKHost,AgentContent,CoreAssembly,ExternalSources,ProductCaps,RuntimeIPC,ModelAdapters,SourceAdapters,Transport,WebDriver,CoreServices,Integrations,MiniAppMarket,RelayService,Terminal,PageRuntime,AgentRuntime,AgentStream,ToolRuntime,PluginClient,Harness,RuntimeServices,CoreTypes,Events,RuntimePorts,ProductDomains module; + class Desktop,CLI,Server,Relay,WebUI,MobileUI,ACP,SDKHost,AgentContent,CoreAssembly,ExternalSources,ProductCaps,ModelAdapters,SourceAdapters,Transport,WebDriver,CoreServices,Integrations,MiniAppMarket,RelayService,Terminal,PageRuntime,AgentRuntime,AgentStream,ToolRuntime,PluginClient,Harness,RuntimeServices,CoreTypes,Events,RuntimePorts,ProductDomains module; style AppsLayer fill:#ffffff,stroke:#a3a3a3; style AssemblyLayer fill:#ffffff,stroke:#a3a3a3; style AdaptersLayer fill:#ffffff,stroke:#a3a3a3; @@ -309,7 +309,7 @@ flowchart LR ### 2.4 Physical View · Level 0 Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; -Embedded 交互式 TUI 已在同一 CLI 进程内通过私有 App Server 使用 Runtime,交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 +Embedded 交互式 TUI 已在同一 CLI 进程内通过私有 App Server 使用 Runtime,交互式 TUI 也可以显式连接 Shared App Server Host。 Desktop GUI 的 App Server 迁移尚未完成。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server 不承载 Agent Runtime。 @@ -321,7 +321,7 @@ flowchart LR direction LR DesktopApp["Desktop App"] ~~~ CLIApp["CLI App"] ~~~ ACPApp["ACP"] ~~~ SDKHost["SDK Host"] end - SharedRuntime["Shared Runtime"] + SharedHost["Shared App Server Host"] WorkspaceData["Workspace Data"] ToolProcesses["Tool Processes"] end @@ -348,22 +348,22 @@ flowchart LR MobileClient -->|HTTPS| RelayServer DesktopApp <-->|WebSocket| RelayServer CLIApp <-->|WebSocket| RelayServer - CLIApp -.->|Local IPC| SharedRuntime + CLIApp -.->|Authenticated loopback App Server| SharedHost WebServer --> WorkspaceData WebServer -->|spawn| ToolProcesses WebServer -->|HTTPS| AIProviders RelayServer --> RelayDB RelayServer --> AssetStore EmbeddedNodes --> WorkspaceData - SharedRuntime --> WorkspaceData + SharedHost --> WorkspaceData EmbeddedNodes -->|spawn| ToolProcesses - SharedRuntime -->|spawn| ToolProcesses + SharedHost -->|spawn| ToolProcesses EmbeddedNodes -->|HTTPS| AIProviders - SharedRuntime -->|HTTPS| AIProviders + SharedHost -->|HTTPS| AIProviders DesktopApp -->|SSH| RemoteHosts classDef unit fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717; - class DesktopApp,CLIApp,ACPApp,SDKHost,SharedRuntime,WorkspaceData,ToolProcesses,WebClient,MobileClient,WebServer,RelayServer,RelayDB,AssetStore,AIProviders,RemoteHosts unit; + class DesktopApp,CLIApp,ACPApp,SDKHost,SharedHost,WorkspaceData,ToolProcesses,WebClient,MobileClient,WebServer,RelayServer,RelayDB,AssetStore,AIProviders,RemoteHosts unit; style LocalHost fill:#ffffff,stroke:#737373; style EmbeddedNodes fill:#ffffff,stroke:#a3a3a3; style UserDevice fill:#ffffff,stroke:#a3a3a3; @@ -376,7 +376,7 @@ flowchart LR |---|---| | Desktop App | Web UI、Tauri Host、embedded Agent Runtime;Rich Client App Server 迁移尚未完成 | | CLI App | 交互式 TUI 通过 private in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | -| Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer;是否迁入 Shared App Server transport 仍待评审与等价证据 | +| Shared App Server Host | 认证的本机 loopback App Server;当前只有显式 `--shared` 的交互式 TUI consumer;一个 Host 持有一个 workspace Runtime owner | | ACP | Embedded Agent Runtime、ACP 协议生命周期 | | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | | Web Server | Embedded Agent Runtime、WebSocket App Server、Health/Info;当前只允许 loopback 单用户模式 | @@ -480,7 +480,7 @@ client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapte 前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 `contracts/*`、Agent Runtime API 或能力归属模块。当前 Desktop GUI 仍使用 Tauri adapter,Web UI 使用 loopback WebSocket -App Server,Embedded TUI 使用 in-process App Server,Shared TUI 通过 `TuiBackend` 映射 private Runtime IPC v17。待评审目标是让 +App Server,Embedded TUI 使用 in-process App Server,Shared TUI 通过 `TuiBackend` 使用本机 Shared App Server。待评审目标是让 Desktop GUI、Web UI 和交互式 TUI 复用同一 Rich Client App Server 行为与 wire contract;Tauri 和各 Rich Client Host 负责 transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 adapter 映射到稳定 owner 接口,不因该目标 复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO 搬进共享 crate。 @@ -549,19 +549,19 @@ flowchart LR Web["Web UI"] --> WebHost["loopback WebSocket App Server"] TUI["Interactive TUI"] --> Backend["TuiBackend"] Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] - Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] + Backend -->|"--shared"| SharedAS["authenticated Shared App Server"] Other["Headless CLI · ACP · Server · Remote"] --> Adapter["独立入口适配器"] SDK["Rust Runtime SDK / SDK Host preview"] --> SDKAdapter["独立 SDK adapter"] Tauri --> API["Runtime API / owner ports"] WebHost --> API EmbeddedAS --> API - SharedIPC --> API + SharedAS --> API Adapter --> API SDKAdapter --> API API --> Runtime["共享 Runtime"] ``` -当前 Embedded TUI 核心路径经过 App Server,Shared TUI 则由 `TuiBackend` compatibility adapter 映射到 private Runtime IPC v17。 +当前 Embedded 与 Shared TUI 都由 `TuiBackend -> AppServerClient` 进入正式 App Server handler,只在 Host 与 transport 生命周期上不同。 Desktop GUI 尚未完成 App Server 迁移;当前 loopback Web Host 已通过 WebSocket 承载 App Server。Headless CLI/CI、ACP、Peer Host 和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner port,部署选择不能进入业务 owner。 @@ -592,9 +592,8 @@ flowchart LR API --> Runtime["共享 Runtime"] ``` -提案目标是让 Desktop GUI、Web UI 和交互式 TUI 复用 App Server 行为与 wire contract,并让 Embedded/Shared 只在 Host 与 -transport 层不同。是否用 Shared App Server 替换 v17,仍取决于鉴权、实例身份、controller/lease、事件恢复、取消、限制、性能和 -回滚门槛;目标图不表示这些能力已经交付。各入口仍各自拥有 renderer、平台能力和生命周期。Headless CLI/CI、ACP、Peer Host 和 +提案目标是让 Desktop GUI 也与当前 Web UI、Embedded/Shared TUI 复用 App Server 行为与 wire contract。Shared TUI 已完成 wire +切换,但跨连接 replay、未知结果恢复和慢客户端治理仍需继续补齐。各入口仍各自拥有 renderer、平台能力和生命周期。Headless CLI/CI、ACP、Peer Host 和 公开 SDK 不共享 App Server wire。 ### 4.3 插件调用 diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md index dbd5dfc96d..6d34f3e839 100644 --- a/docs/plans/tui-app-server-decoupling-refactor-plan.md +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -1,10 +1,8 @@ # TUI 与 App Server 解耦重构计划 -> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Shared App Server 目标待评审。 +> 状态:Phase 0-5 已完成当前定义;Shared TUI 已切换到 Shared App Server。 > -> 当前状态基线:2026-08-09。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 -> -> 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 +> 当前状态基线:2026-08-11。一次性运行证据保留在对应 PR/Actions 记录中;本文只保留可重复执行的当前事实与后续可靠性工作。 相关文档: @@ -13,29 +11,19 @@ - [Agent Runtime 部署设计](../architecture/agent-runtime-deployment-design.md) - [产品架构](../architecture/product-architecture.md) -## 1. 范围与目标 +## 1. 范围与不变量 -本计划只迁移交互式 TUI 的产品后端调用: +本计划只约束交互式 TUI 的产品后端调用: 1. TUI 保留终端输入、状态、渲染和 controller-local effect。 -2. TUI 通过 app-local `TuiBackend` 使用产品后端,不直接依赖 Core、Runtime 实现、Service、全局 singleton 或私有 IPC operation。 -3. Embedded TUI 使用 `AppServerTuiBackend`;Shared TUI 在 Shared App Server 交付前使用 `SharedTuiBackend` compatibility adapter。 +2. TUI 通过 app-local `TuiBackend` 使用产品后端,不直接依赖 Core、Runtime 实现、Service 或全局 singleton。 +3. Embedded 与 Shared TUI 都使用 `AppServerTuiBackend` 和正式 `AppServerClient`。 4. App Server 只适配稳定合同,不接管 Runtime、Service 或 Product Domain 的业务所有权。 5. Headless `exec`、ACP、Peer Host 和公开 SDK 保留各自经评审的 adapter。 -不在本计划范围内: - -- 重写 Ratatui 状态机或界面布局。 -- 把 App Server 变成通用 Tool/Core RPC。 -- 迁移 Runtime owner 或重新设计产品领域模型。 -- 为旧 Web Server 私有协议建立长期兼容层。 -- 把 clipboard、editor、terminal raw mode 等 controller-local effect 下沉到工作区 Host。 - -## 2. 当前路径与目标路径 - -### 2.1 Current +本计划不重写 Ratatui 状态机,不把 App Server 变成通用 Tool/Core RPC,不迁移 Runtime owner,也不把 clipboard、editor、terminal raw mode 等本地 effect 下沉到工作区 Host。 -当前 head 有两条交互式 TUI 后端路径: +## 2. 当前路径 ```text Embedded TUI @@ -50,247 +38,86 @@ Embedded TUI Shared TUI (--shared) -> TuiAgentClient -> TuiBackend - -> SharedTuiBackend compatibility adapter - -> private Runtime IPC v17 - -> Shared Runtime Host process + -> AppServerTuiBackend + -> AppServerClient + -> authenticated loopback transport + -> Shared BitfunAppServer Host -> Runtime API / owners ``` -两条路径统一的是 TUI 可见的行为端口。Shared compatibility adapter 会把 Runtime IPC 的结果和事件映射为 `TuiBackend` 使用的类型,但它没有运行 `BitfunAppServer`,也不是 Shared App Server transport。 - -Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 `TuiBackend` 的 owner-specific typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。具体 DTO/owner 适配由 App Server 的 `AppManagementService` 持有,并由 Host 显式装配;Embedded TUI 经 App Server 访问既有 owner,TUI controller 不再直接访问这些 compatibility owner。 - -Shared 的 Session/chat/mode authority 继续映射 v17;Host 实际提供的本机管理 capability 由 `SharedTuiBackend` 委托同一个具体 management service。当前 Shared Host 提供 Phase 4 的 External Source V1 和 Hook 管理,但不注入 Account/Settings Sync 或 Worktree owner;这些能力返回 typed unsupported。Remote workspace 对所有 controller-local management capability fail closed,不回落到控制端本机。Phase 4 完成表示接口边界已迁移,不表示所有 deployment 的 capability 完全相同。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。 - -### 2.2 Proposed target - -若 [App Server 目标架构](../architecture/app-server-architecture.md) 通过评审,交互式 Rich Client 的目标路径为: - -```text -TUI renderer / input / state / local effects - | - v - TuiBackend trait - | - v - AppServerTuiBackend adapter - | - v - AppServerClient - | - Host-selected transport - / \ - in-memory Embedded controlled Shared local - \ / - v - App Server - | - v - Runtime API / Services / Product Domain owners -``` - -Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controller/lease、事件恢复、断连取消、`outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留。是否最终删除 v17 由等价测试、性能数据、真实 Rich Client 消费方和回滚证据决定,不能只依据 schema 相同或 adapter 已存在。 +两条路径复用同一 method、DTO、类型化错误、capability 和事件合同,只在 Host、transport、实例发现和生命周期上不同。Shared Host 按 canonical workspace 持有一个 Runtime owner,通过随机 bearer token 和实例 identity 认证连接;每条连接显式订阅 Session。多个已认证客户端可以订阅、观察和操作同一 Session,独立 Turn 仍由 Runtime owner 串行准入,steer/cancel 使用精确 Turn ID。 -## 3. 当前能力矩阵 +Mode/Model、Skill、Subagent、MCP、External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面均经过 `TuiBackend` 的 owner-specific typed API。Shared Host 使用 `AppManagementService::load_for_local_host` 装配真实本机 capability;TUI controller 不访问 compatibility owner,也不在 Remote workspace 静默回落控制端本机。 -状态定义: +## 3. 当前能力与限制 -- **已交付**:生产 handler/client 已接线,并被当前 Embedded TUI 路径使用。 -- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供等价 TUI 用例,但没有经过 App Server wire。 -- **部分交付**:已有合同或 handler,但 Host 能力、恢复、安全或 TUI 调用路径仍不完整。 -- **未迁移**:当前 TUI 仍使用既有 compatibility owner 路径,或尚无生产接口。 -- **本地保留**:属于 TUI 或 controller-local effect,不迁移。 - -### 3.1 核心聊天与 Session - -| TUI 用例 | Embedded App Server | Shared v17 compatibility | 当前结论 | -| --- | --- | --- | --- | -| 初始化、版本、健康 | `app/initialize`、`app/health` | adapter 根据 v17 握手结果合成 TUI-facing initialize/health | Embedded 已交付;Shared 尚不是 App Server connection | -| Agent、Permission 事件 | `agent/event`、`agent/permissionEvent` | IPC 事件桥映射为 `AppServerEvent` | 两边均可驱动当前核心 TUI;底层恢复合同不同 | -| Config 事件 | `config/event` | 当前 Shared bridge 不投影 Config 事件 | Embedded 已接线;Shared 的 TUI-facing 管理 capability 来自 Host 装配的 App Server management service,不代表 v17 已有 Config 事件 | -| 流失效与重同步 | `app/eventStreamState`、`app/syncEvents`、`session/sync` | adapter 投影 connection-local cursor、invalidation/resync 和 closed | 已有连接内 cursor/sync;没有跨连接持久 replay/resume | -| Session list/create/sync | `agent/listSessions`、`agent/createSession`、`session/sync` | list/create/atomic restore operation | 已交付;sync 包含 Runtime 状态、transcript、workspace binding 和 pending Permission | -| Session delete/rename/fork | typed App Server methods | v17 controller-scoped operations | 已交付或兼容映射;Shared 继续执行 controller/idle 规则 | -| Model/mode update | `session/updateModel`、`session/updateMode` | v17 current-controller operations | Session update wire 已覆盖;Embedded 与 Shared 都经 typed 管理 API 取得目录/defaults,Shared 的目录来自 Host 装配的 App Server management service | -| Submit/cancel/steer | typed Agent methods | v17 Turn operations | 已交付或兼容映射 | -| User Shell/UserInput | `agent/runUserShellCommand`、`agent/submitUserAnswers` | v17 typed operations | 已交付或兼容映射;执行和权限仍由 Runtime owner 持有 | -| Permission pending/respond | typed Permission methods/events | v17 pending/respond and event stream | 已交付或兼容映射 | -| Transcript/local command record | `session/readTranscript`、`session/recordLocalCommandTurn` | v17 transcript/record operation | 已交付或兼容映射 | -| Compact/undo/redo/reload | typed Session methods | v17 current-controller operations | 已交付或兼容映射 | -| Usage/settlement | `session/usage`、`session/waitForSettlement` | v17 usage/settlement operations | 已交付或兼容映射 | -| Workspace references/diff | typed Workspace methods | v17 reference/diff operations | 已交付或兼容映射 | -| Lineage query/inspect/cancel | typed Session methods | v17 root-controller operations | 已交付或兼容映射 | - -### 3.2 事件恢复的准确边界 - -当前 App Server 已发送带 `connection_id + stream + sequence` 的 cursor,并在 server receiver lag/closed 时提供明确 resync directive。`session/sync` 可恢复 Session、Runtime 状态、transcript、workspace binding 和 pending Permission;`app/syncEvents` 返回所请求 stream 的当前 connection-local cursor 与 pending Permission snapshot,但当前不提供 Agent 或 Config snapshot。 - -当前未交付的是跨连接持久化 cursor、历史事件 replay 和断线后的透明 resume。Shared Runtime IPC v17 仍按自己的 lag/closed、断连取消和 controller 隔离规则工作;`SharedTuiBackend` 只为当前 TUI connection 投影单调 cursor,不能把该投影描述为底层 IPC 已有 replay。 - -### 3.3 管理面状态 - -| Domain | 当前状态 | 当前结论 / 后续 | -| --- | --- | --- | -| Mode/Model 管理 | Embedded 经 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成;Shared mode catalog 来自 Runtime Host,model 管理由 Host 装配的 App Server management service 转发,Session model mutation 仍由 v17 owner 提交 | -| Skill/Subagent | TUI 经 typed list/toggle API 消费 visible/manageable read model;App Server management service 委托既有 registry owner | Phase 3 已完成;Embedded 与 Shared 共用具体 service,Shared capability 明确属于本机 CLI compatibility scope | -| MCP | 当前 TUI 用例经 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 通过当前 CLI 进程的本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 | -| External Source/Tool/Command/Agent | TUI 经 typed snapshot/control/review、conflict choice、command expansion 和事件接口消费既有 owner;后续 External Application V2 snapshot/review/action 已在 Embedded 接线 | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机 | -| Hooks | TUI 经 typed native overview 与 external snapshot/plan/apply/mutate API 消费既有 owner | Phase 4 已完成;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported | -| Account/Settings Sync | typed snapshot/login/finalize/logout 与 sync start/snapshot/cancel/local-changed 已接线;凭据不进入 read model 或 Debug 输出 | Phase 4 接口迁移已完成;Embedded Host 注入共享 `AccountRuntime`,App Server 直接做 domain-to-wire 适配;当前 Shared Host 未注入并返回 typed unsupported | -| Worktree | typed repository status、bind/release 和 operation identity 已接线 | Phase 4 接口迁移已完成;Embedded Host 注入 Worktree owner,当前 Shared Host 与 Remote workspace 明确 unsupported | -| Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 尚未迁移为 App Server Host | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | - -### 3.4 本地保留 - -以下能力不新增 App Server method: - -| 能力 | 所有者 | +| 范围 | 当前状态 | | --- | --- | -| Terminal raw/alternate screen/cursor lifecycle | TUI Host | -| Ratatui render/input/mouse/resize/scroll | TUI | -| Composer draft/history/prompt stash | TUI | -| Theme、terminal color、palette、help、key bindings | TUI | -| Clipboard、图片捕获、外部编辑器 | controller-local capability | -| Controller-local copy/export、notification、bell | controller-local capability | - -图片提交仍须转成受限附件 DTO 并进入后端合同。导出到 controller-local 路径是本地 effect;写入工作区或后端 artifact 必须由工作区 owner 提供数据,再由本地 effect 选择保存位置。 +| 初始化、健康和能力 | Embedded 与 Shared 都使用 `app/initialize`、`app/health` 和 Host 发布的真实 capability/limit | +| Session/Turn | list、create、sync、rename、delete、fork、model/mode、submit、cancel、steer、Shell、UserInput、compact、undo/redo、usage、settlement 和 lineage 均走 typed App Server method | +| Permission 与事件 | 连接按 Session subscription 接收 Agent、Permission 和 Config notification;pending Permission 可由 `session/sync` 恢复 | +| Workspace | binding、reference search、message reference、diff 和 Worktree 管理均在 App Server 边界内 | +| Management | Model、Mode、Skill、Subagent、MCP、External Source、Hook、Account、Settings Sync 和 Worktree 由 Host 装配的 owner service 提供 | +| Attachment | Shared TUI 图片附件仍明确 unsupported,不静默回落 Embedded 或控制端本机 | +| Remote | `--shared` 只选择本机 Shared TUI deployment,不隐式启用 Remote workspace、Remote control、Peer、Detached Dispatch、ACP 或 SDK Host | -## 4. Crate 与 ownership +事件 cursor 仍是 connection-local。`app/syncEvents` 返回当前连接 cursor 与 pending Permission snapshot,`session/sync` 恢复 Session state、transcript、workspace binding 和 pending Permission;当前没有跨连接持久 replay、历史事件重放或透明 resume。 -当前职责拆分如下: +Shared transport 已提供 loopback 限制、随机 token、canonical identity、实例锁、128 KiB request、8 MiB response/event、最多 64 连接和 30 秒空闲退出。仍需补齐通用 `outcome_unknown` 查询/恢复、断连取消的完整合同、慢客户端背压治理和对应故障测试。客户端不得盲目重试无法确认结果的副作用请求。 -| 路径 | 职责 | -| --- | --- | -| `src/crates/interfaces/app-server-protocol` | behavior-light method、DTO、wire error、event envelope 和角色定义 | -| `src/crates/interfaces/app-server-client` | 类型化请求、事件分发和 host-supplied transport 抽象 | -| `src/crates/interfaces/app-server` | server 生命周期、生产 handler 注册、Runtime/domain 与 wire 转换、错误映射 | -| `src/apps/cli` | `TuiBackend`、Embedded/Shared adapter 选择、transport 和进程生命周期、TUI-local effect | -| Runtime/Service/Product Domain owners | Session、Turn、Permission、Workspace、配置和其他业务权威事实 | +## 4. 分阶段状态 -边界规则: - -- protocol/client 的依赖闭包不得引入 `bitfun-core`、Runtime 实现、Service 实现、UI framework 或 `product-full`。 -- `bitfun-app-server` 可依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 -- Host 负责 transport、认证、作用域、真实 capability/limits、平台能力和进程生命周期。 -- handler 只做合同校验、DTO 转换和错误映射,不持有第二份业务权威状态。 -- DTO 提取不代表 Runtime owner 迁移。 - -## 5. 分阶段状态 - -计划状态以完成条件和验证证据为准,不以 method 数量或文件存在为准: - -| 阶段 | 完成条件 | 验证方式 | 当前状态 | 验证记录 | -| --- | --- | --- | --- | --- | -| Phase 0:边界 | `TuiBackend`、behavior-light protocol/client crate、source/Cargo guard 已建立 | Core boundary tests 和 dependency checks | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 2:核心聊天 | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 3:配置管理 | TUI controller 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成,CLI Host adapter 可保留显式 compatibility forwarding | owner tests、App Server contract tests、CLI behavior tests | 已完成当前定义 | 本变更的 protocol/client/server/CLI focused tests 与 Core boundary checks | -| Phase 4:外部集成 | External Source、Hook、Account、Settings Sync、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 已完成当前定义 | [PR #2146 checks](https://github.com/GCWing/BitFun/pull/2146/checks)、zero-budget contract 与 Core boundary checks | -| Phase 5:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | - -### 5.1 Phase 0-2 已交付摘要 - -- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`。 -- Embedded Host 在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime。 -- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例。 -- `SharedTuiBackend` 将相同用例映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。 -- App Server 核心 handler 覆盖 sync、turn、Permission、revert、context、usage、settlement、Workspace 和 lineage;Config 事件也已在 Embedded connection 接线。 -- Runtime IPC v17 为当前 parity 增加 restore Runtime 状态、usage、settlement 和本地命令 transcript 记录;没有增加 replay、observer、通用 controller transfer 或公开 SDK 能力。 -- capability 声明列出当前注册方法,但 Host-specific availability 和方向性 limits 仍是后续收紧项。 - -### 5.2 Phase 3 - -目标:移除 TUI 对全局 config、registry 和 MCP service 的直接访问。 - -状态:已完成当前定义。 - -完成条件: - -- 模型、Mode、Skill、Subagent 和 MCP 使用 owner-specific typed APIs。 -- secret 不出现在 read model、日志或 generic config payload 中。 -- capability 由 Host 注入的 management service、授权和健康状态决定。 -- management service unavailable 时返回明确 unsupported;Shared 的本机 compatibility forwarding 必须显式装配并发布真实 capability,不能在 Remote workspace 静默回落控制端本机。 - -交付摘要: - -- `app-server-protocol` 提供 Mode、Model、Skill、Subagent 和 MCP 的 owner-specific DTO 与 method;model read model 不返回 secret 值,model mutation 使用 preserve/replace/clear 语义。 -- App Server 由 Host 显式注入具体 `AppManagementService`,按 `tui.modes`、`tui.models`、`tui.skills`、`tui.subagents` 和 `tui.mcp` 发布真实 availability;service 缺失或 unavailable 时返回带 capability id 的 structured unsupported。 -- `AppManagementService` 位于 App Server server wiring,复用现有 config、registry、MCP 和 external-source owner,不成为第二个业务 owner;Startup 与 Chat controller 只经 `TuiAgentClient -> TuiBackend` 调用这些管理用例。 -- `SharedTuiBackend` 继续映射 v17 mode catalog,并将 Model、Skill、Subagent 和 MCP 管理委托 Host 装配的具体 App Server management service。v17 不承载这些目录、CRUD 或 defaults;Shared 发布的是 adapter-scoped 本地 capability,current-Session model update 仍按 v17 controller/idle/outcome-unknown 合同提交给 Runtime Host。Shared MCP service 的运行态只属于当前 CLI 进程,不宣称可以即时控制已经运行的 Shared Runtime Host。 -- Core boundary budgets 已移除 Phase 3 owner 直连债务,并要求 Startup 的 Subagent 管理继续使用 typed backend。 - -### 5.3 Phase 4 - -目标:迁移外部来源、Hook、Account、Settings Sync 和 Worktree 管理面。 - -状态:已完成当前定义。 - -完成条件: - -- mutation 有 identity/revision、stale、取消和 audit 语义。 -- external source 的发现、审批、冲突和运行时可用性保持由既有 owner 管理。 -- native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 保持分离。 -- remote workspace 不支持的能力返回 typed unsupported,不在 controller 本机执行。 - -交付摘要: - -- `app-server-protocol`、client 和 production handlers 已提供 External Source、native/external Hook、Account、Settings Sync 与 Worktree 的 owner-specific typed API;side-effecting 请求使用 operation identity,External Source 与 Hook mutation 保留 owner revision/stale 合同,Settings Sync 提供显式取消与 snapshot。 -- `TuiAgentClient`、Startup 和 Chat controller 只经 `TuiBackend` 调用这些用例。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零。 -- Embedded Host 显式注入共享 `AccountRuntime` 并启用 App Server 内建的本机 Worktree 映射;App Server management service 直接适配 owner,不定义 `AccountManagementHost` 或持有第二份账户、同步、外部来源、Hook、Worktree 权威状态。CLI 的窄 `AccountRuntimeHost` 只实现 daemon、Relay/Peer 路由宿主效果,Session 备份通过独立端口读取 Agent Runtime compatibility owner。 -- Shared adapter 只发布 Host 实际可用的 capability。External Source V1 与 Hook 管理可使用当前本机 compatibility service;Account/Settings Sync、Worktree、Remote workspace 和后续未接线的 External Application V2 返回 typed unsupported,不静默回落本机。 -- Phase 4 未扩展 private Runtime IPC v17,也未改变 Phase 5 的评审门槛。 - -### 5.4 Phase 5 +| 阶段 | 当前状态 | 完成事实 | +| --- | --- | --- | +| Phase 0:边界 | 已完成 | `TuiBackend`、behavior-light protocol/client crate 与 source/Cargo guard 已建立 | +| Phase 1:协议基础 | 已完成 | initialize/health、typed events、connection-local cursor、resync 和稳定错误已接线 | +| Phase 2:核心聊天 | 已完成 | Embedded 核心 TUI 用例全部经过 App Server;TUI controller 不引用 Runtime SDK | +| Phase 3:配置管理 | 已完成 | Mode/Model、Skill、Subagent、MCP 使用 secret-safe typed API | +| Phase 4:外部集成 | 已完成 | External Source、Hook、Account、Settings Sync、Worktree 经过 typed backend;Remote 不回落本机 | +| Phase 5:Shared App Server | 已完成当前定义 | `--shared` 使用 Shared App Server;旧 Shared transport、CLI compatibility adapter 和独立协议 crate 已删除 | -Phase 5 不以“删除 v17”为起点。建议顺序: +## 5. 后续可靠性工作 -1. 在 Shared Host 中增加默认关闭的 App Server local transport。 -2. 两条 transport 复用同一 Host-scoped connection authority、controller registry、Session 事件过滤、operation identity/deadline/cancel 和未知结果登记。 -3. 使用一个第一方 Rich Client 进行 opt-in 双栈验证,覆盖跨 transport 竞争、断连、迟到结果、Host 崩溃和回滚。 -4. 记录 startup、延迟、内存、frame/queue 上限和长期维护成本。 -5. 只有行为、安全、恢复和性能达到完成门槛后,才评审是否切换 `--shared` 默认实现并删除 v17。 +后续工作以现有 Shared App Server 为基础,不恢复双栈: -保留 private v17 作为稳定终态也是允许的:只要业务用例和 owner 仍统一,物理 wire 不必为了形式统一而提前收敛。 +1. 定义跨连接 cursor/replay owner,或明确保持重新 initialize + authoritative sync 的非透明恢复合同。 +2. 为副作用请求增加 operation identity、未知结果查询/恢复和禁止盲重试的端到端测试。 +3. 补齐断连取消、迟到响应、Host 崩溃、慢 client/backpressure 和 frame 超限的故障矩阵。 +4. 记录 startup、延迟、内存、连接/队列上限和长时间多客户端运行数据。 +5. Remote、Peer、Detached Dispatch、ACP 和 SDK Host 如需 Shared 能力,必须各自增加协商 capability 和目标执行域设计,不能复用本机入口作隐式 fallback。 ## 6. 验证 -### 6.1 当前 focused commands - ```bash cargo check -p bitfun-app-server --offline cargo test -p bitfun-app-server --offline cargo test -p bitfun-app-server-protocol --offline cargo test -p bitfun-app-server-client --offline -cargo test -p bitfun-agent-runtime-ipc --offline cargo check -p bitfun-cli --bin bitfun --offline cargo test -p bitfun-cli --bin bitfun --offline pnpm run check:core-boundaries ``` -Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) 中。Phase 3 和 Phase 4 分别运行了对应的 protocol、client、server、CLI binary、owner contract 与 Core boundary focused checks;Phase 4 另有 zero-budget contract 防止 TUI controller 恢复旧 owner 直连。一次性结果保留在对应 PR/Actions 记录中,本文只保留可重复执行的验证命令和阶段状态,后续阶段必须重新记录自己的验证结果。 +行为 fixture 至少覆盖: -### 6.2 行为等价场景 - -| 场景组 | 当前必须覆盖 | +| 场景组 | 必须覆盖 | | --- | --- | -| Chat | create、sync、submit、stream、Permission、UserInput、cancel、steer、shell | +| Chat | create、sync、submit、stream、Permission、UserInput、cancel、steer、Shell | | Session | rename、model/mode、fork、undo/redo、compact、usage、settlement | -| Workspace | binding、references、diff、remote facts | +| Workspace | binding、references、diff、worktree、remote unsupported | | Lineage | tree、descendant transcript、settlement、targeted cancellation | -| Failure | unsupported、lag、invalidated、disconnect、deadline、`outcome_unknown` | -| Deployment | Embedded App Server 与 Shared v17 compatibility 的 TUI behavior parity | - -Shared App Server 实现后,同一 fixture 必须增加 Embedded App Server、Shared App Server 和 v17 rollback 三方验证,直到 v17 被正式保留或删除。 +| Failure | unsupported、lag、invalidated、disconnect、deadline、unknown outcome | +| Deployment | Embedded 与 Shared App Server 的 TUI behavior parity | ## 7. 完成定义 -只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成: +当前定义满足以下条件: + +1. TUI 产品请求和订阅只经过 `TuiBackend`,view/reducer 不执行 backend I/O。 +2. Embedded 与 Shared TUI 都使用 App Server client/server 合同,不保留旧 transport 或 compatibility adapter。 +3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现或 `product-full`。 +4. capability、limits、身份和作用域来自真实 Host/transport。 +5. Remote workspace 不存在 controller-local fallback。 +6. 重复 DTO、无效 handler 和无生产消费方的旁路已删除。 -1. Phase 3/4 当前定义的管理面已迁移;后续新增 capability 也不得绕过 `TuiBackend` 或恢复旧 owner 直连。 -2. TUI 产品请求和订阅只经过 `TuiBackend`,TUI view/reducer 不执行 backend I/O。 -3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation。 -4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值。 -5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试。 -6. remote workspace 不存在 controller-local fallback。 -7. 重复 DTO、无效 handler 和无生产消费方的旁路已删除。 -8. 若采用 Shared App Server,迁移满足 Phase 5 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 +事件跨连接恢复、未知结果和慢客户端治理是明确记录的后续可靠性缺口,不改变上述架构完成事实。 diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 47d3c80247..c6c54782b9 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -34,7 +34,6 @@ export const crateLayoutRules = [ { crateName: 'app-server-client', layer: 'interfaces', path: 'src/crates/interfaces/app-server-client' }, { crateName: 'app-server-protocol', layer: 'interfaces', path: 'src/crates/interfaces/app-server-protocol' }, { crateName: 'sdk-host', layer: 'interfaces', path: 'src/crates/interfaces/sdk-host' }, - { crateName: 'agent-runtime-ipc', layer: 'adapters', path: 'src/crates/adapters/agent-runtime-ipc' }, { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index ab5288042e..6e45d02264 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -1,43 +1,5 @@ // Boundary rules for crate dependencies and lightweight profiles. -const agentRuntimeIpcForbiddenDeps = [ - 'bitfun-acp', - 'bitfun-agent-runtime', - 'bitfun-agent-stream', - 'bitfun-agent-tools', - 'bitfun-ai-adapters', - 'bitfun-claude-code-adapter', - 'bitfun-codex-adapter', - 'bitfun-core', - 'bitfun-core-types', - 'bitfun-external-sources', - 'bitfun-harness', - 'bitfun-opencode-adapter', - 'bitfun-page-function-runtime', - 'bitfun-plugin-runtime-client', - 'bitfun-product-capabilities', - 'bitfun-relay-service', - 'bitfun-runtime-services', - 'bitfun-sdk-host', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-static-hook-support', - 'bitfun-tool-call-jsonrepair', - 'bitfun-tool-packs', - 'bitfun-transport', - 'bitfun-webdriver', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', -]; - export const noCoreDependencyCrates = [ 'agent-content', 'core-types', @@ -46,7 +8,6 @@ export const noCoreDependencyCrates = [ 'agent-stream', 'tool-call-jsonrepair', 'agent-runtime', - 'agent-runtime-ipc', 'app-server-client', 'app-server-protocol', 'harness', @@ -83,15 +44,6 @@ export const forbiddenManifestDependencyRules = [ reason: 'the RMCP SDK is a concrete MCP integration service dependency', message: 'rmcp must stay in services-integrations and be consumed through its MCP owner facade', }, - { - dependencyNames: ['bitfun-agent-runtime-ipc'], - scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], - workspaceManifestPath: 'Cargo.toml', - allowManifestPaths: ['src/apps/cli/Cargo.toml'], - reason: 'the private local IPC protocol has one reviewed first-party Shared TUI consumer', - message: - 'agent-runtime-ipc may only be consumed by the CLI Shared TUI adapter; SDK Host, GUI, remote, and other products require separate review', - }, { dependencyNames: ['sherpa-onnx'], scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], @@ -154,7 +106,6 @@ export const lightweightBoundaryRules = [ forbiddenDeps: [ 'bitfun-core', 'bitfun-agent-runtime', - 'bitfun-agent-runtime-ipc', 'bitfun-services-core', 'bitfun-services-integrations', 'bitfun-runtime-services', @@ -180,7 +131,6 @@ export const lightweightBoundaryRules = [ forbiddenDeps: [ 'bitfun-core', 'bitfun-agent-runtime', - 'bitfun-agent-runtime-ipc', 'bitfun-services-core', 'bitfun-services-integrations', 'bitfun-runtime-services', @@ -198,12 +148,6 @@ export const lightweightBoundaryRules = [ 'rmcp', ], }, - { - crateName: 'agent-runtime-ipc', - reason: - 'agent-runtime-ipc is the non-published local protocol for the reviewed Shared TUI adapter, not a Runtime, SDK Host, service, or remote product surface', - forbiddenDeps: agentRuntimeIpcForbiddenDeps, - }, { crateName: 'core-types', reason: 'core-types must stay low-level DTO-only', @@ -458,13 +402,6 @@ export const lightweightBoundaryRules = [ ]; export const dependencyProfileRules = [ - { - crateName: 'agent-runtime-ipc', - profileName: 'private Shared TUI local IPC profile', - reason: - 'agent-runtime-ipc may share stable event and Runtime DTO contracts but must not acquire Runtime owners, SDK Host, services, remote transports, or product implementations', - forbiddenNonOptionalDeps: agentRuntimeIpcForbiddenDeps, - }, { crateName: 'core', profileName: 'no-default runtime-surface-light profile', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index d6eaab22e7..9f9b96d357 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -8,7 +8,7 @@ export const forbiddenContentRules = [ patterns: [ { regex: - /\b(?:bitfun_core|bitfun_agent_runtime|bitfun_agent_runtime_ipc|bitfun_services_core|bitfun_services_integrations|bitfun_runtime_services|bitfun_product_capabilities|bitfun_external_sources|bitfun_app_server)::/, + /\b(?:bitfun_core|bitfun_agent_runtime|bitfun_services_core|bitfun_services_integrations|bitfun_runtime_services|bitfun_product_capabilities|bitfun_external_sources|bitfun_app_server)::/, message: 'CLI-local TuiBackend must stay on the App Server client/protocol boundary', }, @@ -24,17 +24,6 @@ export const forbiddenContentRules = [ }, ], }, - { - path: 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', - reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', - patterns: [ - { - regex: /^\s+(?!(?:Health|ListAgentModes|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SessionUsage|WaitForSettlement|RecordLocalCommandTurn|SearchWorkspaceReferences|WorkspaceReferencesForMessage|GetSessionLineage|InspectLineageSession|CancelLineageSession|WorkspaceDiff|SubmitTurn|SteerTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|AgentModes|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|SessionLineage|LineageSessionInspection|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnSteered|TurnCancelled|LocalCommandTurnRecorded|Idle|Processing|Error|Starting|Compacting|Thinking|Streaming|ToolCalling|ToolConfirming|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|RuntimeSessionState|RuntimeSessionProcessingPhase|AgentContextReloadRequest|AgentDialogSteerRequest|AgentDialogTurnRequest|AgentLocalCommandTurnRecordRequest|AgentLocalCommandTurnRecordResult|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionLineageCancellationRequest|AgentSessionLineageInspection|AgentSessionLineageRequest|AgentSessionLineageSnapshot|AgentSessionLineageTranscriptRequest|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentSessionUsageRequest|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentTurnSettlementRequest|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|SessionUsageReport|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, - message: - 'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice', - }, - ], - }, { path: 'src/crates/execution/plugin-runtime-client/src/adapter.rs', reason: 'plugin-runtime-client adapter trait method surface must stay narrow', @@ -4049,17 +4038,6 @@ export const forbiddenContentRules = [ ]; export const forbiddenContentUnderRules = [ - { - path: 'src/crates/adapters/agent-runtime-ipc/src', - reason: 'agent-runtime-ipc transport is restricted to Named Pipe and Unix Domain Socket', - patterns: [ - { - regex: - /\b(?:(?:Tcp|Udp)[A-Za-z0-9_]*|tokio_tungstenite|reqwest|hyper|WebSocket)\b/, - message: 'agent-runtime-ipc must not add network or remote transports', - }, - ], - }, { path: 'src/apps', reason: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8b4dc744d5..da97f7a56a 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -8088,49 +8088,6 @@ export const requiredContentRules = [ }, ], }, - { - path: 'src/apps/cli/src/shared_tui_backend.rs', - reason: - 'Shared TUI must retain local Model, Skill, Subagent, and MCP compatibility management without expanding Runtime IPC or leaking owners into controllers', - patterns: [ - { - regex: /management: Arc/, - message: 'missing injected Shared TUI App Server management service', - }, - { - regex: /\bfn management_service\b/, - message: 'missing Shared TUI management capability gate', - }, - { - regex: /\bfn set_management_scope_from_binding\b/, - message: 'missing Shared TUI Remote workspace compatibility guard', - }, - { - regex: /\.list_models\(ListModelsRequest \{\}\)/, - message: 'missing Shared TUI model compatibility delegation', - }, - { - regex: /\.list_skills\(request\)/, - message: 'missing Shared TUI skill compatibility delegation', - }, - { - regex: /\.list_subagents\(request\)/, - message: 'missing Shared TUI subagent compatibility delegation', - }, - { - regex: /\.list_mcp_servers\(request\)/, - message: 'missing Shared TUI MCP compatibility delegation', - }, - { - regex: /\bshared_management_capabilities_follow_the_local_management_service\b/, - message: 'missing Shared TUI management capability regression', - }, - { - regex: /\bremote_workspace_cannot_use_the_local_management_service\b/, - message: 'missing Shared TUI Remote management scope regression', - }, - ], - }, { path: 'src/apps/cli/src/ui/startup.rs', reason: diff --git a/scripts/core-boundaries/rules/tui-boundary-rules.mjs b/scripts/core-boundaries/rules/tui-boundary-rules.mjs index 4487bb85c0..f2db0d9179 100644 --- a/scripts/core-boundaries/rules/tui-boundary-rules.mjs +++ b/scripts/core-boundaries/rules/tui-boundary-rules.mjs @@ -8,7 +8,6 @@ export const tuiLegacyBackendMarkers = [ 'bitfun_agent_runtime::', 'bitfun_services_', 'bitfun_runtime_services', - 'bitfun_agent_runtime_ipc', 'CliAgentRuntimeClient', 'CliContextReloadClient', 'CoreAgentRuntimeCompatibility', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2f4e8d9b34..fb98782a92 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4065,20 +4065,6 @@ export function runManifestParserSelfTest({ 'AppManagementCapabilities::available\\(\\)', ], }, - { - path: 'src/apps/cli/src/shared_tui_backend.rs', - contracts: [ - 'management: Arc', - 'fn management_service', - 'fn set_management_scope_from_binding', - '\\.list_models\\(ListModelsRequest \\{\\}\\)', - '\\.list_skills\\(request\\)', - '\\.list_subagents\\(request\\)', - '\\.list_mcp_servers\\(request\\)', - 'shared_management_capabilities_follow_the_local_management_service', - 'remote_workspace_cannot_use_the_local_management_service', - ], - }, { path: 'src/apps/cli/src/ui/startup.rs', contracts: [ @@ -5373,119 +5359,4 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis } } - if (!noCoreDependencyCrates.includes('agent-runtime-ipc')) { - throw new Error('agent-runtime-ipc must be covered by the no-core dependency guard'); - } - const runtimeIpcBoundary = lightweightBoundaryRules.find( - (rule) => rule.crateName === 'agent-runtime-ipc', - ); - for (const dependency of [ - 'bitfun-agent-runtime', - 'bitfun-sdk-host', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'tokio-tungstenite', - 'bitfun-cli', - ]) { - if (!runtimeIpcBoundary?.forbiddenDeps.includes(dependency)) { - throw new Error(`agent-runtime-ipc lightweight boundary must forbid ${dependency}`); - } - } - for (const sharedContract of [ - 'bitfun-events', - 'bitfun-product-domains', - 'bitfun-runtime-ports', - ]) { - if (runtimeIpcBoundary?.forbiddenDeps.includes(sharedContract)) { - throw new Error(`agent-runtime-ipc must be allowed to reuse ${sharedContract}`); - } - } - const runtimeIpcProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'agent-runtime-ipc', - ); - for (const dependency of ['bitfun-agent-runtime', 'bitfun-services-core', 'tauri', 'reqwest']) { - if (!runtimeIpcProfile?.forbiddenNonOptionalDeps.includes(dependency)) { - throw new Error(`agent-runtime-ipc dependency profile must forbid ${dependency}`); - } - } - const runtimeIpcOperationRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', - ); - const runtimeIpcOperationPattern = runtimeIpcOperationRule?.patterns[0]?.regex; - if ( - !runtimeIpcOperationPattern || - ![ - 'ReplayEvents', - 'ReadTranscript', - 'DetachSession', - 'ManageModels', - 'ManageAgents', - 'LineageSessionTranscript', - ].every((name) => runtimeIpcOperationPattern.test(` ${name},`)) || - runtimeIpcOperationPattern.test(' Health,') || - runtimeIpcOperationPattern.test(' DeleteSession {') || - runtimeIpcOperationPattern.test(' ForkSession {') || - runtimeIpcOperationPattern.test(' RenameSession {') || - runtimeIpcOperationPattern.test(' UpdateSessionMode {') || - runtimeIpcOperationPattern.test(' UpdateSessionModel {') || - runtimeIpcOperationPattern.test(' UndoSession {') || - runtimeIpcOperationPattern.test(' RedoSession {') || - runtimeIpcOperationPattern.test(' SearchWorkspaceReferences {') || - runtimeIpcOperationPattern.test(' WorkspaceReferencesForMessage {') || - runtimeIpcOperationPattern.test(' GetSessionLineage {') || - runtimeIpcOperationPattern.test(' InspectLineageSession {') || - runtimeIpcOperationPattern.test(' CancelLineageSession {') || - runtimeIpcOperationPattern.test(' SessionLineage {') || - runtimeIpcOperationPattern.test(' LineageSessionInspection {') || - runtimeIpcOperationPattern.test(' AgentSessionLineageCancellationRequest,') || - runtimeIpcOperationPattern.test(' AgentSessionLineageRequest,') || - runtimeIpcOperationPattern.test(' AgentSessionLineageSnapshot,') || - runtimeIpcOperationPattern.test(' AgentSessionLineageTranscriptRequest,') || - runtimeIpcOperationPattern.test(' WorkspaceReferenceSearch {') || - runtimeIpcOperationPattern.test(' WorkspaceReferences {') || - runtimeIpcOperationPattern.test(' WorkspaceDiff {') || - runtimeIpcOperationPattern.test(' WorkspaceDiffSnapshot,') || - runtimeIpcOperationPattern.test(' SubmitTurn {') || - runtimeIpcOperationPattern.test(' SteerTurn {') || - runtimeIpcOperationPattern.test(' AgentDialogSteerRequest {') || - runtimeIpcOperationPattern.test(' RunUserShellCommand {') || - runtimeIpcOperationPattern.test(' AgentUserShellCommandRequest {') || - runtimeIpcOperationPattern.test(' SessionForked {') || - runtimeIpcOperationPattern.test(' SessionReverted {') || - runtimeIpcOperationPattern.test(' TurnSteered {') - ) { - throw new Error('agent-runtime-ipc operation guard must preserve the Shared TUI operation budget'); - } - const runtimeIpcTransportRule = forbiddenContentUnderRules.find( - (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src', - ); - const runtimeIpcTransportRuleText = runtimeIpcTransportRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - const runtimeIpcTransportPattern = runtimeIpcTransportRule?.patterns[0]?.regex; - for (const transport of ['TcpListener', 'TcpStream', 'TcpSocket', 'UdpSocket', 'WebSocket']) { - if (!runtimeIpcTransportPattern?.test(transport)) { - throw new Error(`agent-runtime-ipc local-only guard must forbid ${transport}`); - } - } - for (const dependency of ['reqwest', 'tokio_tungstenite']) { - if (!runtimeIpcTransportRuleText.includes(dependency)) { - throw new Error(`agent-runtime-ipc local-only guard must forbid ${dependency}`); - } - } - const privateTransportModule = 'src/crates/adapters/agent-runtime-ipc/src/tcp.rs'; - if ( - !runtimeIpcTransportRule || - !privateTransportModule.startsWith(`${runtimeIpcTransportRule.path}/`) || - !runtimeIpcTransportPattern?.test('use tokio::net::TcpListener;') - ) { - throw new Error( - 'agent-runtime-ipc local-only guard must cover network transports in new private modules', - ); - } } diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 9d7c98b672..398284240a 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -19,7 +19,7 @@ CLI owns only surface concerns: - TUI state, rendering, popups, local draft history, and local effects such as clipboard or external-editor integration - projection of Runtime events into text, JSON, JSONL, and user diagnostics -- Shared Runtime client/server adaptation and Peer Device host presentation +- Shared App Server host/client adaptation and Peer Device host presentation Session, turn, model round, tool execution, permissions, cancellation, persistence, context, workspace binding, MCP, Subagent, and other product facts @@ -39,13 +39,13 @@ ChatView -> TuiAgentClient -> TuiBackend -> App Server client -> App Server handler -> Agent Runtime owner -> ToolPipeline ``` -Embedded TUI uses an in-memory App Server connection. During the migration, -Shared TUI uses a CLI Host compatibility adapter that implements `TuiBackend` -over the private versioned Runtime IPC; the TUI client and controllers must not -reference that IPC. Replacing the physical Shared transport with App Server -Pipe/UDS framing belongs to Phase 5. Side-effecting operations need stable -identities, controller/idle rules, bounded frames, and outcome-unknown handling -before a connection can retry. +Embedded TUI uses an in-memory App Server connection. Shared TUI uses the same +`AppServerTuiBackend` over an authenticated loopback connection to a CLI-owned +Shared App Server Host. The Host binds one canonical workspace and one Runtime +owner, while each connection explicitly subscribes to the Sessions whose +events and Permissions it consumes. Side-effecting operations need stable Turn +identity, bounded frames, and explicit unknown-outcome handling before a +connection can retry. Explicit Shell input follows: @@ -74,7 +74,7 @@ restrictions remain enforced. keeps chat/shell histories separate, treats `/` as command text, and rejects images and structured `@` references before Runtime submission. - Direct paste, `Ctrl+V`, and bracketed paste share `ComposerDraft`. Shared TUI - rejects unsupported image payloads before IPC. + rejects unsupported image payloads before App Server submission. - Local effects such as `/editor`, copy, and export stay local. Product work such as shell execution, session mutation, and permissions goes through typed Runtime owners. @@ -124,7 +124,7 @@ cargo test -p bitfun-cli ``` When a CLI change crosses a shared boundary, use the focused command maintained -by that owner: Agent Runtime for port/SDK behavior, the IPC adapter for shared +by that owner: Agent Runtime for port/SDK behavior, App Server for Shared protocol behavior, Core for turn/tool/persistence behavior, Terminal for PTY/ConPTY lifecycle, and Product Assembly for packaging. Do not copy those owners' commands into this guide. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 2d57b3f0de..a0d65dd9b3 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -54,7 +54,6 @@ bitfun-events = { path = "../../crates/contracts/events" } bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-acp = { path = "../../crates/interfaces/acp" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } -bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } bitfun-services-core = { path = "../../crates/services/services-core", default-features = false, features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } @@ -63,6 +62,7 @@ bitfun-product-domains = { path = "../../crates/contracts/product-domains", defa bitfun-app-server = { path = "../../crates/interfaces/app-server" } bitfun-app-server-client = { path = "../../crates/interfaces/app-server-client" } bitfun-app-server-protocol = { path = "../../crates/interfaces/app-server-protocol" } +agent-client-protocol = { workspace = true } # CLI framework clap = { workspace = true } @@ -109,7 +109,7 @@ libc = { workspace = true } arboard = { workspace = true } # Inherited from workspace -tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { workspace = true, features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -117,6 +117,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } +tokio-util = { workspace = true, features = ["codec"] } fs2 = { workspace = true } base64 = { workspace = true } image = { workspace = true } diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 97d7f7dce5..11189a8ff8 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -145,12 +145,14 @@ unsupported and keeps the draft unchanged. bitfun chat --shared ``` -Shared TUI lets multiple terminal processes reuse one workspace Runtime. Each -TUI controls at most one session and a session has one controller. Core chat, -Shell mode, session navigation, read-only subagent transcript inspection, -model/mode selection, permissions, and transcript events use the same behavior -as Embedded TUI. Some local management and attachment capabilities remain -Embedded-only and report that limitation instead of silently falling back. +Shared TUI lets multiple terminal processes reuse one workspace Runtime through +the Shared App Server. Clients can subscribe to, observe, and control the same +session; independent turns are still admitted serially by the single Runtime +owner, and steer or cancel targets an exact turn. Core chat, Shell mode, session +navigation, read-only subagent transcript inspection, model/mode selection, +permissions, management, and transcript events use the same typed App Server +behavior as Embedded TUI. Unsupported attachments report that limitation +instead of silently falling back. Exit all Shared TUI clients and wait briefly before returning to the default Embedded mode for the same workspace. diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index c85e359182..19d3105791 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -143,7 +143,7 @@ pub(crate) fn shared_tui_image_attachment_error() -> String { format!("Image attachments are unavailable in Shared TUI. {SHARED_TUI_EMBEDDED_HANDOFF}.") } pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `View subagents` in the command palette to inspect this Session's subagents; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` and `/connect` to manage models, `/skills` to manage skills, `/mcp` to manage MCP servers, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Extensions, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime through the Shared App Server and can subscribe to the same Session; independent turns remain serialized by the Runtime owner. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `View subagents` in the command palette to inspect this Session's subagents; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` and `/connect` to manage models, `/skills` to manage skills, `/mcp` to manage MCP servers, and `/reload [skills|instructions]` to refresh declarative context for the next message. Management requests run in the Shared App Server Host. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui(self, _context: ActionContext) -> bool { @@ -2148,7 +2148,8 @@ mod tests { assert!(ActionHandler::Skills.available_in_shared_tui(ActionContext::Startup)); assert!(ActionHandler::McpServers.available_in_shared_tui(ActionContext::Startup)); assert!(SHARED_TUI_HELP_NOTE.contains("bitfun chat --shared")); - assert!(SHARED_TUI_HELP_NOTE.contains("one Session")); + assert!(SHARED_TUI_HELP_NOTE.contains("subscribe to the same Session")); + assert!(SHARED_TUI_HELP_NOTE.contains("turns remain serialized")); assert!(SHARED_TUI_HELP_NOTE.contains("`/models`")); assert!(SHARED_TUI_HELP_NOTE.contains("`/connect`")); assert!(SHARED_TUI_HELP_NOTE.contains("`/skills`")); @@ -2159,10 +2160,7 @@ mod tests { assert!(SHARED_TUI_HELP_NOTE.contains("`/reload [skills|instructions]`")); assert!(SHARED_TUI_HELP_NOTE.contains("Ctrl+D")); assert!(SHARED_TUI_HELP_NOTE.contains("idle, non-current Session")); - assert!(SHARED_TUI_HELP_NOTE.contains("local compatibility owner")); - assert!(SHARED_TUI_HELP_NOTE - .contains("do not reconfigure an already-running Shared Runtime Host")); - assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); + assert!(SHARED_TUI_HELP_NOTE.contains("Shared App Server Host")); } #[test] diff --git a/src/apps/cli/src/agent/exec_runtime_client.rs b/src/apps/cli/src/agent/exec_runtime_client.rs new file mode 100644 index 0000000000..7b22ebe195 --- /dev/null +++ b/src/apps/cli/src/agent/exec_runtime_client.rs @@ -0,0 +1,441 @@ +//! Headless CLI client for the portable Agent Runtime SDK. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use anyhow::Result; +use bitfun_agent_runtime::sdk::{ + AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, AgentRuntime, + AgentSessionCreateRequest, AgentSessionForkRequest, AgentSessionForkResult, + AgentSessionListRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, + AgentTurnCancellationRequest, AgentTurnSettlementRequest, PortErrorKind, RuntimeError, +}; +use bitfun_runtime_ports::{ + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionSource, DialogSubmissionPolicy, SessionExecutionTarget, +}; +use tokio::sync::Mutex; + +use crate::diagnostics::with_session_conflict_help; +use crate::runtime::approval::{approval_metadata, CliApprovalPolicy}; +use crate::runtime::CliRuntimeContext; + +#[derive(Clone, Debug)] +struct ExecWorkspacePaths { + project: Option, + execution: Option, + execution_target: Option, +} + +impl ExecWorkspacePaths { + fn new(workspace_path: Option) -> Self { + Self { + project: workspace_path.clone(), + execution: workspace_path, + execution_target: None, + } + } + + fn execution(&self) -> PathBuf { + self.execution + .clone() + .or_else(|| self.project.clone()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + fn project(&self) -> PathBuf { + self.project + .clone() + .or_else(|| self.execution.clone()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + fn apply_binding(&mut self, binding: &AgentSessionWorkspaceBinding) { + let fallback_project = self.project(); + self.execution = Some(PathBuf::from(&binding.workspace_path)); + self.project = Some( + binding + .project_workspace_path + .as_deref() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or(fallback_project), + ); + self.execution_target = binding.execution_target.clone(); + } +} + +pub(crate) struct ExecAgentRuntimeClient { + runtime: AgentRuntime, + approval_policy: CliApprovalPolicy, + workspace_paths: Arc>, + session_id: Arc>>, + current_turn_id: Arc>>, +} + +impl ExecAgentRuntimeClient { + pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { + Self { + runtime: runtime.agent_runtime().clone(), + approval_policy: runtime.approval_policy(), + workspace_paths: Arc::new(RwLock::new(ExecWorkspacePaths::new(workspace_path))), + session_id: Arc::new(Mutex::new(None)), + current_turn_id: Arc::new(Mutex::new(None)), + } + } + + pub(crate) fn subscribe_events(&self) -> std::result::Result { + self.runtime.subscribe_events() + } + + pub(crate) async fn list_sessions(&self) -> Result> { + self.runtime + .list_sessions(AgentSessionListRequest { + workspace_path: self.project_workspace_path_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())) + } + + pub(crate) async fn update_session_model( + &self, + session_id: &str, + model_id: &str, + ) -> Result<()> { + self.runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: session_id.to_string(), + model_id: model_id.to_string(), + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())) + } + + pub(crate) async fn branch_session_at_latest_turn( + &self, + source_session_id: &str, + ) -> Result { + self.runtime + .fork_session(AgentSessionForkRequest { + workspace_path: self.project_workspace_path_string(), + source_session_id: source_session_id.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())) + } + + pub(crate) async fn restore_session(&self, session_id: &str) -> Result<()> { + let project_workspace = self.project_workspace_path_buf(); + self.runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: project_workspace.to_string_lossy().to_string(), + session_id: session_id.to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(anyhow::Error::new) + .map_err(with_session_conflict_help)?; + self.resolve_session_workspace_binding(session_id, &project_workspace) + .await?; + *self.session_id.lock().await = Some(session_id.to_string()); + *self.current_turn_id.lock().await = None; + Ok(()) + } + + pub(crate) async fn create_session_with_id( + &self, + session_id: String, + agent_type: &str, + ) -> Result { + let session = self + .runtime + .create_session_with_id( + session_id, + AgentSessionCreateRequest { + session_name: Self::build_default_session_name(), + agent_type: agent_type.to_string(), + workspace_path: Some(self.workspace_path_string()), + project_workspace_path: Some(self.project_workspace_path_string()), + execution_target: self.execution_target(), + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: serde_json::Map::new(), + }, + ) + .await + .map_err(anyhow::Error::new) + .map_err(with_session_conflict_help)?; + let id = session.session_id; + *self.session_id.lock().await = Some(id.clone()); + Ok(id) + } + + pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { + let mut session_id = self.session_id.lock().await; + if let Some(id) = session_id.as_ref() { + return Ok(id.clone()); + } + let session = self + .runtime + .create_session(AgentSessionCreateRequest { + session_name: Self::build_default_session_name(), + agent_type: agent_type.to_string(), + workspace_path: Some(self.workspace_path_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: serde_json::Map::new(), + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))?; + let id = session.session_id; + *session_id = Some(id.clone()); + Ok(id) + } + + pub(crate) async fn send_message(&self, message: String, agent_type: &str) -> Result { + let session_id = self.ensure_session(agent_type).await?; + let turn_id = uuid::Uuid::new_v4().to_string(); + *self.current_turn_id.lock().await = Some(turn_id.clone()); + let request = AgentDialogTurnRequest { + session_id: session_id.clone(), + message, + original_message: None, + turn_id: Some(turn_id.clone()), + execution: AgentDialogTurnExecution::Standard, + agent_type: agent_type.to_string(), + workspace_path: Some(self.project_workspace_path_string()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: approval_metadata(self.approval_policy), + }; + let submission = self.runtime.submit_dialog_turn(request.clone()).await; + if let Err(error) = submission { + if Self::is_session_not_found_error(&error) { + self.ensure_backend_session_alive(&session_id, agent_type) + .await?; + self.runtime + .submit_dialog_turn(request) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))?; + } else { + *self.current_turn_id.lock().await = None; + return Err(anyhow::anyhow!(error.into_message())); + } + } + Ok(turn_id) + } + + pub(crate) async fn cancel_current_turn(&self) -> Result<()> { + let session_id = self.session_id.lock().await.clone(); + let turn_id = self.current_turn_id.lock().await.clone(); + if let (Some(session_id), Some(turn_id)) = (session_id, turn_id) { + self.runtime + .cancel_turn(AgentTurnCancellationRequest { + session_id, + turn_id: Some(turn_id.clone()), + source: Some(AgentSubmissionSource::Cli), + requester_session_id: None, + reason: Some("user_cancelled".to_string()), + wait_timeout_ms: None, + cancel_descendants: true, + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))?; + let mut current_turn_id = self.current_turn_id.lock().await; + if current_turn_id.as_deref() == Some(turn_id.as_str()) { + *current_turn_id = None; + } + } + Ok(()) + } + + pub(crate) async fn wait_for_turn_settlement( + &self, + session_id: &str, + turn_id: &str, + wait_timeout_ms: u64, + ) -> std::result::Result<(), RuntimeError> { + self.runtime + .wait_for_turn_settlement(AgentTurnSettlementRequest { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + wait_timeout_ms, + }) + .await + } + + async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { + let project_workspace = self.project_workspace_path_buf(); + match self + .runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: project_workspace.to_string_lossy().to_string(), + session_id: session_id.to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + { + Ok(_) => { + self.resolve_session_workspace_binding(session_id, &project_workspace) + .await?; + Ok(()) + } + Err(error) if Self::is_session_not_found_error(&error) => { + self.recreate_session_with_id(session_id, agent_type).await + } + Err(error) => Err(with_session_conflict_help(anyhow::Error::new(error))), + } + } + + async fn recreate_session_with_id(&self, session_id: &str, agent_type: &str) -> Result<()> { + let project_workspace = self.project_workspace_path_buf(); + let workspace = self.workspace_path_buf(); + let sessions = self + .runtime + .list_sessions(AgentSessionListRequest { + workspace_path: project_workspace.to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .unwrap_or_default(); + let previous = sessions + .iter() + .find(|session| session.session_id == session_id); + self.runtime + .create_session_with_id( + session_id.to_string(), + AgentSessionCreateRequest { + session_name: previous + .map(|session| session.session_name.clone()) + .unwrap_or_else(Self::build_default_session_name), + agent_type: previous + .map(|session| session.agent_type.clone()) + .unwrap_or_else(|| agent_type.to_string()), + workspace_path: Some(workspace.to_string_lossy().to_string()), + project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), + execution_target: self.execution_target(), + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: serde_json::Map::new(), + }, + ) + .await + .map_err(anyhow::Error::new) + .map_err(with_session_conflict_help)?; + Ok(()) + } + + async fn resolve_session_workspace_binding( + &self, + session_id: &str, + fallback_project_workspace: &Path, + ) -> Result<()> { + let fallback_project = fallback_project_workspace.to_string_lossy().to_string(); + let binding = self + .runtime + .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { + session_id: session_id.to_string(), + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))? + .unwrap_or_else(|| AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: fallback_project.clone(), + project_workspace_path: Some(fallback_project.clone()), + execution_target: Some(SessionExecutionTarget::local(fallback_project)), + remote_connection_id: None, + remote_ssh_host: None, + }); + self.workspace_paths + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .apply_binding(&binding); + Ok(()) + } + + fn workspace_path_buf(&self) -> PathBuf { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .execution() + } + + fn workspace_path_string(&self) -> String { + self.workspace_path_buf().to_string_lossy().to_string() + } + + fn project_workspace_path_buf(&self) -> PathBuf { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .project() + } + + fn project_workspace_path_string(&self) -> String { + self.project_workspace_path_buf() + .to_string_lossy() + .to_string() + } + + fn execution_target(&self) -> Option { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .execution_target + .clone() + } + + fn build_default_session_name() -> String { + format!( + "CLI Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ) + } + + fn is_session_not_found_error(error: &RuntimeError) -> bool { + matches!( + error, + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::NotFound + ) + } +} + +#[cfg(test)] +mod tests { + use super::ExecAgentRuntimeClient; + use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; + + #[test] + fn session_recovery_requires_structured_not_found_error() { + let missing = RuntimeError::Port(PortError::new(PortErrorKind::NotFound, "missing")); + let unrelated = RuntimeError::Port(PortError::new(PortErrorKind::Backend, "missing")); + + assert!(ExecAgentRuntimeClient::is_session_not_found_error(&missing)); + assert!(!ExecAgentRuntimeClient::is_session_not_found_error( + &unrelated + )); + } +} diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index ff77ce4dad..b5ea5c5199 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -3,5 +3,5 @@ /// Session operations use the shared Agent Runtime SDK. Event consumption /// remains in the chat and exec mode loops. pub(crate) mod agentic_system; -pub(crate) mod runtime_client; +pub(crate) mod exec_runtime_client; pub(crate) mod tui_client; diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs deleted file mode 100644 index 369677501a..0000000000 --- a/src/apps/cli/src/agent/runtime_client.rs +++ /dev/null @@ -1,2559 +0,0 @@ -//! CLI/TUI Agent Runtime SDK client. -//! -//! Keeps CLI session state while product operations remain behind portable -//! Runtime SDK ports. -//! Event consumption is NOT done here — it's done in the chat/exec mode main loops. - -use anyhow::Result; -use std::collections::HashMap; -use std::fmt; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; -use tokio::sync::{broadcast, Mutex}; - -use bitfun_agent_runtime::sdk::{ - AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, - AgentInputAttachment, AgentLocalCommandTurnRecordRequest, - AgentMessageWorkspaceReferencesRequest, AgentRuntime, AgentSessionCompactionRequest, - AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest, - AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, - AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, - AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRestoreRequest, - AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionUsageRequest, - AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, - AgentUserShellCommandRequest, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, DialogSteerOutcome, PermissionReply, PermissionRequest, - PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, - SessionTranscriptRequest, SessionUsageReport, WorkspaceDiffSnapshot, -}; -use bitfun_agent_runtime_ipc::{ - RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, - RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcStreamInvalidationReason, RuntimeSessionForkRequest, RuntimeSessionRenameRequest, - RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, -}; -use bitfun_events::{AgenticEvent, AgenticEventEnvelope}; -use bitfun_runtime_ports::{ - put_agent_workspace_references, AgentSessionSummary, AgentSessionWorkspaceBinding, - AgentSessionWorkspaceRequest, AgentSubmissionSource, DialogSubmissionPolicy, - SessionExecutionTarget, -}; - -use crate::actions::SHARED_TUI_EMBEDDED_HANDOFF; -use crate::diagnostics::with_session_conflict_help; -use crate::runtime::approval::{approval_metadata, CliApprovalPolicy}; -use crate::runtime::CliRuntimeContext; - -fn shared_restore_error(error: RuntimeIpcClientError) -> anyhow::Error { - let error = if matches!(&error, RuntimeIpcClientError::Remote(remote) if remote.code == RuntimeIpcErrorCode::FrameTooLarge) - { - anyhow::anyhow!( - "Session history is too large for Shared TUI. {SHARED_TUI_EMBEDDED_HANDOFF}." - ) - } else { - anyhow::Error::new(error) - }; - with_session_conflict_help(error) -} - -fn validated_session_summary( - sessions: &[AgentSessionSummary], - session_id: &str, - workspace_path: &Path, -) -> Result { - sessions - .iter() - .find(|summary| summary.session_id == session_id) - .cloned() - .ok_or_else(|| { - anyhow::anyhow!( - "Session {session_id} was not found in the current workspace: {}", - workspace_path.display() - ) - }) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum SessionMigrationNotice { - Mode { - previous_id: String, - restored_id: String, - }, - Model { - previous_id: String, - restored_id: String, - }, -} - -impl SessionMigrationNotice { - pub(crate) fn user_message(&self) -> String { - let (setting, previous_id, restored_id) = match self { - Self::Mode { - previous_id, - restored_id, - } => ("mode", previous_id, restored_id), - Self::Model { - previous_id, - restored_id, - } => ("model", previous_id, restored_id), - }; - format!( - "Session {setting} \"{previous_id}\" is unavailable. This session was restored with \"{restored_id}\". Review the {setting} before continuing." - ) - } -} - -fn session_migration_notices( - previous: &AgentSessionSummary, - restored: &AgentSessionSummary, -) -> Vec { - let mut notices = Vec::with_capacity(2); - if previous.agent_type != restored.agent_type { - notices.push(SessionMigrationNotice::Mode { - previous_id: previous.agent_type.clone(), - restored_id: restored.agent_type.clone(), - }); - } - if let (Some(previous_id), Some(restored_id)) = - (previous.model_id.as_ref(), restored.model_id.as_ref()) - { - if previous_id != restored_id { - notices.push(SessionMigrationNotice::Model { - previous_id: previous_id.clone(), - restored_id: restored_id.clone(), - }); - } - } - notices -} - -#[derive(Debug)] -pub(crate) struct SessionOperationError { - message: String, - outcome_unknown: bool, -} - -impl fmt::Display for SessionOperationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for SessionOperationError {} - -impl SessionOperationError { - fn runtime(error: RuntimeError) -> Self { - let outcome_unknown = matches!( - &error, - RuntimeError::Port(port_error) - if port_error.kind == PortErrorKind::OutcomeUnknown - ); - Self { - message: error.into_message(), - outcome_unknown, - } - } - - fn shared(error: RuntimeIpcClientError) -> Self { - let outcome_unknown = matches!( - &error, - RuntimeIpcClientError::Remote(remote) - if remote.code == RuntimeIpcErrorCode::OutcomeUnknown - ) || matches!( - &error, - RuntimeIpcClientError::Timeout - | RuntimeIpcClientError::Disconnected - | RuntimeIpcClientError::UnexpectedResponse - | RuntimeIpcClientError::Io(_) - ); - Self { - message: error.to_string(), - outcome_unknown, - } - } - - fn unexpected(error: anyhow::Error) -> Self { - Self { - message: error.to_string(), - outcome_unknown: true, - } - } - - fn read_only_shared(error: RuntimeIpcClientError) -> Self { - let outcome_unknown = matches!( - &error, - RuntimeIpcClientError::Remote(remote) - if remote.code == RuntimeIpcErrorCode::OutcomeUnknown - ); - Self { - message: shared_restore_error(error).to_string(), - outcome_unknown, - } - } - - fn read_only_unexpected(error: anyhow::Error) -> Self { - Self { - message: error.to_string(), - outcome_unknown: false, - } - } - - pub(crate) fn outcome_unknown(&self) -> bool { - self.outcome_unknown - } -} - -#[derive(Clone, Debug)] -struct CliWorkspacePaths { - project: Option, - execution: Option, - execution_target: Option, - remote: bool, -} - -impl CliWorkspacePaths { - fn new(workspace_path: Option) -> Self { - Self { - project: workspace_path.clone(), - execution: workspace_path, - execution_target: None, - remote: false, - } - } - - fn execution(&self) -> PathBuf { - self.execution - .clone() - .or_else(|| self.project.clone()) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")) - } - - fn project(&self) -> PathBuf { - self.project - .clone() - .or_else(|| self.execution.clone()) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")) - } - - fn apply_binding(&mut self, binding: &AgentSessionWorkspaceBinding) { - let execution = PathBuf::from(&binding.workspace_path); - let project = binding - .project_workspace_path - .as_deref() - .filter(|path| !path.trim().is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| self.project()); - self.execution = Some(execution); - self.project = Some(project); - self.execution_target = binding.execution_target.clone(); - self.remote = binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some(); - } - - fn reset_execution_to_project(&mut self) -> PathBuf { - let project = self.project(); - self.execution = Some(project.clone()); - self.execution_target = Some(SessionExecutionTarget::local( - project.to_string_lossy().to_string(), - )); - self.remote = false; - project - } - - fn workspace_diff_unavailable_reason(&self) -> Option<&'static str> { - if self.remote { - return Some("Workspace diff is unavailable for remote Sessions"); - } - let execution = self.execution(); - let project = self.project(); - if !same_workspace_location(&execution, &project) { - return Some( - "Workspace diff is unavailable when the Session uses a different worktree", - ); - } - None - } -} - -fn same_workspace_location(left: &Path, right: &Path) -> bool { - left == right - || dunce::canonicalize(left) - .ok() - .zip(dunce::canonicalize(right).ok()) - .is_some_and(|(left, right)| left == right) -} - -/// CLI-owned client for the portable Agent Runtime SDK. -/// Stateless regarding agent_type; callers pass it per call. -pub(crate) struct ExecAgentRuntimeClient { - backend: CliAgentRuntimeBackend, - approval_policy: Arc>, - workspace_paths: Arc>, - /// Session ID — uses Mutex for interior mutability - session_id: Arc>>, - /// Current turn ID (for cancellation) - current_turn_id: Arc>>, - shared_agent_events: Option>, - shared_permission_events: - Option>, - shared_pending_permissions: Arc>>, -} - -enum CliAgentRuntimeBackend { - Embedded(AgentRuntime), - Shared(RuntimeIpcClient), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CliAgentMode { - pub(crate) id: String, - pub(crate) description: String, - pub(crate) model_id: Option, - pub(crate) is_external: bool, -} - -type SharedBroadcast = Arc>>>; - -impl ExecAgentRuntimeClient { - pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { - Self { - backend: CliAgentRuntimeBackend::Embedded(runtime.agent_runtime().clone()), - approval_policy: Arc::new(RwLock::new(runtime.approval_policy())), - workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), - session_id: Arc::new(Mutex::new(None)), - current_turn_id: Arc::new(Mutex::new(None)), - shared_agent_events: None, - shared_permission_events: None, - shared_pending_permissions: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub(crate) fn new_shared(client: RuntimeIpcClient, workspace_path: Option) -> Self { - let (agent_sender, _) = broadcast::channel(256); - let (permission_sender, _) = broadcast::channel(64); - let shared_agent_events = Arc::new(RwLock::new(Some(agent_sender.clone()))); - let shared_permission_events = Arc::new(RwLock::new(Some(permission_sender.clone()))); - let shared_pending_permissions = Arc::new(RwLock::new(HashMap::new())); - let session_id = Arc::new(Mutex::new(None)); - spawn_shared_event_bridge( - client.subscribe_events(), - agent_sender, - permission_sender, - shared_agent_events.clone(), - shared_permission_events.clone(), - shared_pending_permissions.clone(), - ); - Self { - backend: CliAgentRuntimeBackend::Shared(client), - approval_policy: Arc::new(RwLock::new(CliApprovalPolicy::Ask)), - workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), - session_id, - current_turn_id: Arc::new(Mutex::new(None)), - shared_agent_events: Some(shared_agent_events), - shared_permission_events: Some(shared_permission_events), - shared_pending_permissions, - } - } - - pub(crate) fn is_shared(&self) -> bool { - matches!(self.backend, CliAgentRuntimeBackend::Shared(_)) - } - - /// Read the main-agent catalog from the execution owner. Embedded and - /// Shared TUI expose the same small presentation projection; Shared never - /// consults the controller process's local registry. - pub(crate) async fn available_agent_modes(&self) -> Result> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(_) => { - let workspace = self.workspace_path_buf(); - if let Err(error) = - bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( - &workspace, - )) - .await - { - tracing::warn!("Failed to initialize external agent sources: {error}"); - } - let registry = bitfun_core::agentic::agents::get_agent_registry(); - Ok(registry - .get_modes_info_for_workspace(Some(&workspace), true) - .await - .into_iter() - .map(|mode| CliAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model, - is_external: mode.source - == bitfun_core::agentic::agents::AgentSource::External, - }) - .collect()) - } - CliAgentRuntimeBackend::Shared(client) => { - let session_id = self.session_id.lock().await.clone(); - match client - .request(RuntimeIpcOperation::ListAgentModes { session_id }) - .await? - { - RuntimeIpcOperationResult::AgentModes { modes } => Ok(modes - .into_iter() - .map(|mode| CliAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect()), - other => Err(anyhow::anyhow!( - "Shared Runtime returned an unexpected result for list_agent_modes: {other:?}" - )), - } - } - } - } - - fn embedded_runtime(&self, operation: &str) -> Result<&AgentRuntime> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => Ok(runtime), - CliAgentRuntimeBackend::Shared(_) => Err(anyhow::anyhow!( - "{operation} is not available in the first Shared TUI slice; use default Embedded `bitfun chat`" - )), - } - } - - pub(crate) fn subscribe_events(&self) -> std::result::Result { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime.subscribe_events(), - CliAgentRuntimeBackend::Shared(_) => shared_receiver( - self.shared_agent_events.as_ref(), - "Shared Runtime agent event stream is unavailable", - ), - } - } - - pub(crate) fn subscribe_permission_requests( - &self, - ) -> std::result::Result { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime.subscribe_permission_requests(), - CliAgentRuntimeBackend::Shared(_) => shared_receiver( - self.shared_permission_events.as_ref(), - "Shared Runtime permission event stream is unavailable", - ), - } - } - - pub(crate) fn pending_permission_requests( - &self, - ) -> std::result::Result, RuntimeError> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime.pending_permission_requests(), - CliAgentRuntimeBackend::Shared(_) => Ok(self - .shared_pending_permissions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .values() - .cloned() - .collect()), - } - } - - pub(crate) async fn respond_permission( - &self, - request_id: &str, - reply: PermissionReply, - ) -> Result<()> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .respond_permission(request_id, reply) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => { - let session_id = self.require_session_id().await?; - expect_unit( - client - .request(RuntimeIpcOperation::RespondPermission { - session_id, - request_id: request_id.to_string(), - reply, - }) - .await?, - "respond_permission", - ) - } - } - } - - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.embedded_runtime("recording local command turns")? - .record_completed_local_command_turn(request) - .await - .map(|_| ()) - .map_err(|error| anyhow::anyhow!(error.into_message())) - } - - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { - *self - .approval_policy - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = policy; - } - - pub(crate) fn approval_policy(&self) -> CliApprovalPolicy { - *self - .approval_policy - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - pub(crate) fn workspace_path_buf(&self) -> PathBuf { - self.workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .execution() - } - - pub(crate) fn workspace_path_string(&self) -> String { - self.workspace_path_buf().to_string_lossy().to_string() - } - - pub(crate) fn project_workspace_path_buf(&self) -> PathBuf { - self.workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .project() - } - - pub(crate) fn project_workspace_path_string(&self) -> String { - self.project_workspace_path_buf() - .to_string_lossy() - .to_string() - } - - pub(crate) fn set_workspace_binding(&self, binding: &AgentSessionWorkspaceBinding) { - self.workspace_paths - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .apply_binding(binding); - } - - fn execution_target(&self) -> Option { - self.workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .execution_target - .clone() - } - - fn reset_execution_to_project_workspace(&self) -> PathBuf { - self.workspace_paths - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .reset_execution_to_project() - } - - fn current_workspace_path(&self) -> PathBuf { - self.project_workspace_path_buf() - } - - async fn list_sessions_in_workspace( - &self, - workspace_path: &Path, - ) -> Result> { - let request = AgentSessionListRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .list_sessions(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::ListSessions { request }) - .await? - { - RuntimeIpcOperationResult::Sessions { sessions } => Ok(sessions), - _ => Err(unexpected_shared_result("list_sessions")), - }, - } - } - - pub(crate) async fn list_sessions(&self) -> Result> { - let workspace_path = self.current_workspace_path(); - self.list_sessions_in_workspace(&workspace_path).await - } - - pub(crate) async fn session_lineage( - &self, - root_session_id: &str, - ) -> Result> { - let request = AgentSessionLineageRequest { - workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), - anchor_session_id: root_session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .get_session_lineage(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::GetSessionLineage { request }) - .await? - { - RuntimeIpcOperationResult::SessionLineage { snapshot } => Ok(snapshot), - _ => Err(unexpected_shared_result("get_session_lineage")), - }, - } - } - - pub(crate) async fn inspect_lineage_session( - &self, - root_session_id: &str, - session_id: &str, - required_settled_turn_ids: &[String], - ) -> std::result::Result { - let request = AgentSessionLineageTranscriptRequest { - workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), - root_session_id: root_session_id.to_string(), - session_id: session_id.to_string(), - required_settled_turn_ids: required_settled_turn_ids.to_vec(), - remote_connection_id: None, - remote_ssh_host: None, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .read_lineage_session_transcript(request) - .await - .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::InspectLineageSession { request }) - .await - .map_err(SessionOperationError::read_only_shared)? - { - RuntimeIpcOperationResult::LineageSessionInspection { inspection } => { - Ok(inspection) - } - _ => Err(SessionOperationError::read_only_unexpected( - unexpected_shared_result("inspect_lineage_session"), - )), - }, - } - } - - pub(crate) async fn cancel_lineage_session( - &self, - root_session_id: &str, - session_id: &str, - expected_active_turn_id: &str, - ) -> Result { - let request = AgentSessionLineageCancellationRequest { - workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), - root_session_id: root_session_id.to_string(), - session_id: session_id.to_string(), - expected_active_turn_id: Some(expected_active_turn_id.to_string()), - source: Some(AgentSubmissionSource::Cli), - reason: Some("user_cancelled".to_string()), - wait_timeout_ms: Some(5_000), - remote_connection_id: None, - remote_ssh_host: None, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .cancel_lineage_session(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::CancelLineageSession { request }) - .await? - { - RuntimeIpcOperationResult::TurnCancelled { cancellation } => Ok(cancellation), - _ => Err(unexpected_shared_result("cancel_lineage_session")), - }, - } - } - - pub(crate) async fn restore_session_in_current_workspace( - &self, - session_id: &str, - ) -> Result<( - AgentSessionSummary, - AgentSessionWorkspaceBinding, - Vec, - SessionTranscript, - )> { - tracing::info!("Restoring session: {}", session_id); - - let project_workspace = self.current_workspace_path(); - let sessions = self.list_sessions_in_workspace(&project_workspace).await?; - let previous_summary = - validated_session_summary(&sessions, session_id, &project_workspace)?; - - let (restored, transcript, shared_pending, shared_workspace_binding) = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let restored = runtime - .restore_session(AgentSessionRestoreRequest { - workspace_path: project_workspace.to_string_lossy().to_string(), - session_id: session_id.to_string(), - include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map(|restored| restored.session) - .map_err(anyhow::Error::new) - .map_err(with_session_conflict_help)?; - let transcript = runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: session_id.to_string(), - turn_id: None, - }) - .await - .unwrap_or_else(|error| { - tracing::warn!( - "Failed to read Embedded session transcript: {}", - error.into_message() - ); - SessionTranscript { - session_id: session_id.to_string(), - messages: Vec::new(), - } - }); - (restored, transcript, None, None) - } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: project_workspace.to_string_lossy().to_string(), - session_id: session_id.to_string(), - }, - }) - .await - .map_err(shared_restore_error)? - { - RuntimeIpcOperationResult::SessionRestored { - session, - workspace_binding, - transcript, - pending_permissions, - .. - } => ( - session, - transcript, - Some(pending_permissions), - Some(workspace_binding), - ), - _ => return Err(unexpected_shared_result("restore_session")), - }, - }; - - let binding = if let Some(binding) = shared_workspace_binding { - self.set_workspace_binding(&binding); - binding - } else { - self.resolve_session_workspace_binding(session_id, &project_workspace) - .await? - }; - let mut session_id_guard = self.session_id.lock().await; - let mut turn_id_guard = self.current_turn_id.lock().await; - *session_id_guard = Some(session_id.to_string()); - *turn_id_guard = None; - drop(session_id_guard); - drop(turn_id_guard); - if let Some(requests) = shared_pending { - let mut pending = self - .shared_pending_permissions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - pending.clear(); - pending.extend( - requests - .into_iter() - .map(|request| (request.request_id.clone(), request)), - ); - } - - let migration_notices = session_migration_notices(&previous_summary, &restored); - Ok((restored, binding, migration_notices, transcript)) - } - - async fn resolve_session_workspace_binding( - &self, - session_id: &str, - fallback_project_workspace: &Path, - ) -> Result { - let fallback_project = fallback_project_workspace.to_string_lossy().to_string(); - let resolved = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { - session_id: session_id.to_string(), - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(_) => None, - }; - let binding = resolved.unwrap_or_else(|| AgentSessionWorkspaceBinding { - workspace_id: None, - workspace_path: fallback_project.clone(), - project_workspace_path: Some(fallback_project.clone()), - execution_target: Some(SessionExecutionTarget::local(fallback_project)), - remote_connection_id: None, - remote_ssh_host: None, - }); - - self.set_workspace_binding(&binding); - Ok(binding) - } - - pub(crate) async fn session_workspace_binding( - &self, - session_id: &str, - ) -> Result { - let project_workspace = self.project_workspace_path_buf(); - self.resolve_session_workspace_binding(session_id, &project_workspace) - .await - } - - pub(crate) async fn delete_session( - &self, - session_id: &str, - ) -> std::result::Result<(), SessionOperationError> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .delete_session(AgentSessionDeleteRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => { - let result = client - .request(RuntimeIpcOperation::DeleteSession { - session_id: session_id.to_string(), - }) - .await - .map_err(SessionOperationError::shared)?; - expect_unit(result, "delete_session").map_err(SessionOperationError::unexpected) - } - } - } - - pub(crate) async fn update_session_model( - &self, - session_id: &str, - model_id: &str, - ) -> std::result::Result<(), SessionOperationError> { - let request = AgentSessionModelUpdateRequest { - session_id: session_id.to_string(), - model_id: model_id.to_string(), - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .update_session_model(request) - .await - .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => { - let result = client - .request(RuntimeIpcOperation::UpdateSessionModel { request }) - .await - .map_err(SessionOperationError::shared)?; - expect_unit(result, "update_session_model") - .map_err(SessionOperationError::unexpected) - } - } - } - - pub(crate) async fn rename_session( - &self, - session_id: &str, - session_name: &str, - ) -> std::result::Result<(), SessionOperationError> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let request = AgentSessionRenameRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.to_string(), - session_name: session_name.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }; - runtime - .rename_session(request) - .await - .map_err(SessionOperationError::runtime) - } - CliAgentRuntimeBackend::Shared(client) => { - let result = client - .request(RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: session_id.to_string(), - session_name: session_name.to_string(), - }, - }) - .await - .map_err(SessionOperationError::shared)?; - expect_unit(result, "rename_session").map_err(SessionOperationError::unexpected) - } - } - } - - pub(crate) async fn update_session_mode( - &self, - session_id: &str, - mode_id: &str, - ) -> std::result::Result<(), SessionOperationError> { - let request = AgentSessionModeUpdateRequest { - session_id: session_id.to_string(), - mode_id: mode_id.to_string(), - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .update_session_mode(request) - .await - .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => { - let result = client - .request(RuntimeIpcOperation::UpdateSessionMode { request }) - .await - .map_err(SessionOperationError::shared)?; - expect_unit(result, "update_session_mode") - .map_err(SessionOperationError::unexpected) - } - } - } - - pub(crate) async fn branch_session_at_latest_turn( - &self, - source_session_id: &str, - ) -> Result { - self.embedded_runtime("forking sessions")? - .fork_session(AgentSessionForkRequest { - workspace_path: self.project_workspace_path_string(), - source_session_id: source_session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) - } - - pub(crate) async fn fork_current_session( - &self, - before_turn_id: Option<&str>, - ) -> Result<( - AgentSessionSummary, - AgentSessionWorkspaceBinding, - SessionTranscript, - )> { - let source_session_id = self.require_session_id().await?; - let workspace_path = self.project_workspace_path_string(); - let (session, transcript, shared_workspace_binding) = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let forked = match before_turn_id { - Some(source_turn_id) => { - runtime - .fork_session_before_turn(AgentSessionForkBeforeTurnRequest { - workspace_path: workspace_path.clone(), - source_session_id, - source_turn_id: source_turn_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - } - None => { - runtime - .fork_session(AgentSessionForkRequest { - workspace_path: workspace_path.clone(), - source_session_id, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - } - } - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - let restored = runtime - .restore_session(AgentSessionRestoreRequest { - workspace_path: workspace_path.clone(), - session_id: forked.session_id.clone(), - include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - let transcript = runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: forked.session_id, - turn_id: None, - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - (restored.session, transcript, None) - } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::ForkSession { - request: RuntimeSessionForkRequest { - session_id: source_session_id, - before_turn_id: before_turn_id.map(str::to_string), - }, - }) - .await? - { - RuntimeIpcOperationResult::SessionForked { - session, - workspace_binding, - transcript, - } => (session, transcript, Some(workspace_binding)), - _ => return Err(unexpected_shared_result("fork_session")), - }, - }; - - let binding = if let Some(binding) = shared_workspace_binding { - self.set_workspace_binding(&binding); - binding - } else { - self.resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) - .await? - }; - *self.session_id.lock().await = Some(session.session_id.clone()); - *self.current_turn_id.lock().await = None; - self.shared_pending_permissions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clear(); - Ok((session, binding, transcript)) - } - - pub(crate) async fn revert_current_session( - &self, - undo: bool, - ) -> Result { - let session_id = self.require_session_id().await?; - let request = AgentSessionRevertRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.clone(), - remote_connection_id: None, - remote_ssh_host: None, - }; - let locally_active_turn_id = self.current_turn_id.lock().await.clone(); - let mut reverted = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => if undo { - runtime.undo_session(request).await - } else { - runtime.redo_session(request).await - } - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(client) => { - let operation = if undo { - RuntimeIpcOperation::UndoSession { request } - } else { - RuntimeIpcOperation::RedoSession { request } - }; - match client.request(operation).await? { - RuntimeIpcOperationResult::SessionReverted { revert } - if revert.session_id == session_id => - { - revert - } - _ => return Err(unexpected_shared_result("revert_session")), - } - } - }; - if reverted.session_id != session_id { - return Err(anyhow::anyhow!( - "Runtime reverted an unexpected session identity" - )); - } - if let Some(turn_id) = locally_active_turn_id { - if !reverted.retired_turn_ids.contains(&turn_id) { - reverted.retired_turn_ids.push(turn_id); - } - } - *self.current_turn_id.lock().await = None; - Ok(reverted) - } - - pub(crate) async fn workspace_diff(&self) -> Result { - if let Some(reason) = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .workspace_diff_unavailable_reason() - { - return Err(anyhow::anyhow!(reason)); - } - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .workspace_diff() - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => { - match client.request(RuntimeIpcOperation::WorkspaceDiff).await? { - RuntimeIpcOperationResult::WorkspaceDiff { snapshot } => Ok(snapshot), - _ => Err(unexpected_shared_result("workspace_diff")), - } - } - } - } - - pub(crate) async fn generate_session_usage_report( - &self, - request: AgentSessionUsageRequest, - ) -> Result { - self.embedded_runtime("generating session usage")? - .generate_session_usage(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) - } - - pub(crate) async fn wait_for_turn_settlement( - &self, - session_id: &str, - turn_id: &str, - wait_timeout_ms: u64, - ) -> std::result::Result<(), RuntimeError> { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - runtime - .wait_for_turn_settlement(AgentTurnSettlementRequest { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - wait_timeout_ms, - }) - .await - } - CliAgentRuntimeBackend::Shared(_) => Err(RuntimeError::Port(PortError::new( - PortErrorKind::NotAvailable, - "turn settlement waiting is not available in the first Shared TUI slice", - ))), - } - } - - fn build_default_session_name() -> String { - format!( - "CLI Session - {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ) - } - - fn is_session_not_found_error(error: &RuntimeError) -> bool { - matches!( - error, - RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::NotFound - ) - } - - async fn recreate_session_with_id(&self, session_id: &str, agent_type: &str) -> Result<()> { - let runtime = self.embedded_runtime("recreating sessions with fixed identifiers")?; - let mut session_name = Self::build_default_session_name(); - let mut effective_agent_type = agent_type.to_string(); - - let workspace = self.workspace_path_buf(); - let project_workspace = self.project_workspace_path_buf(); - if let Ok(sessions) = runtime - .list_sessions(AgentSessionListRequest { - workspace_path: project_workspace.to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - { - if let Some(summary) = sessions.iter().find(|s| s.session_id == session_id) { - session_name = summary.session_name.clone(); - effective_agent_type = summary.agent_type.clone(); - } - } - - runtime - .create_session_with_id( - session_id.to_string(), - AgentSessionCreateRequest { - session_name, - agent_type: effective_agent_type, - workspace_path: Some(workspace.to_string_lossy().to_string()), - project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), - execution_target: self.execution_target(), - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id: None, - metadata: serde_json::Map::new(), - }, - ) - .await - .map_err(anyhow::Error::new) - .map_err(with_session_conflict_help)?; - - tracing::info!("Recreated backend session with existing id: {}", session_id); - Ok(()) - } - - async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { - let runtime = self.embedded_runtime("recovering Embedded sessions")?; - let project_workspace = self.project_workspace_path_buf(); - match runtime - .restore_session(AgentSessionRestoreRequest { - workspace_path: project_workspace.to_string_lossy().to_string(), - session_id: session_id.to_string(), - include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - { - Ok(_) => { - self.resolve_session_workspace_binding(session_id, &project_workspace) - .await?; - tracing::info!("Backend session restored: {}", session_id); - Ok(()) - } - Err(error) => { - let session_not_found = Self::is_session_not_found_error(&error); - if session_not_found { - tracing::warn!( - "Session is unavailable, recreating backend session: {}", - session_id - ); - self.recreate_session_with_id(session_id, agent_type).await - } else { - Err(with_session_conflict_help(anyhow::Error::new(error))) - } - } - } - } - - pub(crate) async fn create_session_with_id( - &self, - session_id: String, - agent_type: &str, - ) -> Result { - let runtime = self.embedded_runtime("creating sessions with fixed identifiers")?; - let mut session_id_guard = self.session_id.lock().await; - let workspace_path = self.workspace_path_string(); - let project_workspace_path = self.project_workspace_path_string(); - - let session = runtime - .create_session_with_id( - session_id, - AgentSessionCreateRequest { - session_name: Self::build_default_session_name(), - agent_type: agent_type.to_string(), - workspace_path: Some(workspace_path), - project_workspace_path: Some(project_workspace_path), - execution_target: self.execution_target(), - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id: None, - metadata: serde_json::Map::new(), - }, - ) - .await - .map_err(anyhow::Error::new) - .map_err(with_session_conflict_help)?; - - let id = session.session_id.clone(); - *session_id_guard = Some(id.clone()); - tracing::info!("Created runtime session with fixed id: {}", id); - - Ok(id) - } -} - -impl ExecAgentRuntimeClient { - pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { - self.ensure_session_with_model(agent_type, None).await - } - - /// Ensure the startup Session exists, preserving an explicit user model - /// selection in the authoritative creation request. - pub(crate) async fn ensure_session_with_model( - &self, - agent_type: &str, - model_id: Option, - ) -> Result { - let mut session_id_guard = self.session_id.lock().await; - - if let Some(ref id) = *session_id_guard { - return Ok(id.clone()); - } - - let request = AgentSessionCreateRequest { - session_name: Self::build_default_session_name(), - agent_type: agent_type.to_string(), - workspace_path: Some(self.workspace_path_string()), - project_workspace_path: None, - execution_target: None, - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id, - metadata: serde_json::Map::new(), - }; - let session = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .create_session(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::CreateSession { request }) - .await? - { - RuntimeIpcOperationResult::SessionCreated { session } => session, - _ => return Err(unexpected_shared_result("create_session")), - }, - }; - - let id = session.session_id.clone(); - - *session_id_guard = Some(id.clone()); - drop(session_id_guard); - self.refresh_shared_pending_permissions().await?; - tracing::info!("Created core session: {}", id); - - Ok(id) - } - - pub(crate) async fn start_session_compaction(&self, session_id: &str) -> Result { - let turn_id = uuid::Uuid::new_v4().to_string(); - let request = AgentSessionCompactionRequest { - session_id: session_id.to_string(), - turn_id: turn_id.clone(), - }; - *self.current_turn_id.lock().await = Some(turn_id.clone()); - - let submission: Result = async { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let accepted = runtime - .start_session_compaction(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - if accepted.session_id != session_id || accepted.turn_id != turn_id { - return Err(anyhow::anyhow!( - "Runtime accepted manual compaction with an unexpected identity" - )); - } - Ok(accepted.turn_id) - } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::CompactSession { request }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id: accepted_session, - turn_id: accepted_turn, - } if accepted_session == session_id && accepted_turn == turn_id => { - Ok(accepted_turn) - } - _ => return Err(unexpected_shared_result("compact_session")), - }, - } - } - .await; - if submission.is_err() { - *self.current_turn_id.lock().await = None; - } - submission - } - - pub(crate) async fn send_message(&self, message: String, agent_type: &str) -> Result { - self.send_message_with_context(message, Vec::new(), Vec::new(), agent_type) - .await - } - - pub(crate) async fn send_message_with_context( - &self, - message: String, - workspace_references: Vec, - attachments: Vec, - agent_type: &str, - ) -> Result { - if !attachments.is_empty() && self.is_shared() { - return Err(anyhow::anyhow!( - crate::actions::shared_tui_image_attachment_error() - )); - } - let session_id = self.ensure_session(agent_type).await?; - self.submit_dialog_turn_request( - session_id, - message, - None, - workspace_references, - attachments, - AgentDialogTurnExecution::Standard, - agent_type, - ) - .await - } - - pub(crate) async fn send_external_subagent_command( - &self, - prompt: String, - original_command: String, - ecosystem_id: String, - logical_id: String, - agent_type: &str, - ) -> Result { - if self.is_shared() { - return Err(anyhow::anyhow!( - "External subagent commands require Embedded TUI; Shared TUI does not transport delegated command submissions" - )); - } - let session_id = self.ensure_session(agent_type).await?; - self.submit_dialog_turn_request( - session_id, - prompt, - Some(original_command), - Vec::new(), - Vec::new(), - AgentDialogTurnExecution::FreshExternalSubagent { - ecosystem_id, - logical_id, - }, - agent_type, - ) - .await - } - - #[allow(clippy::too_many_arguments)] - async fn submit_dialog_turn_request( - &self, - session_id: String, - message: String, - original_message: Option, - workspace_references: Vec, - attachments: Vec, - execution: AgentDialogTurnExecution, - agent_type: &str, - ) -> Result { - tracing::info!("Sending message to session {}: {}", session_id, message); - - // Generate a turn_id - let turn_id = uuid::Uuid::new_v4().to_string(); - - // Store current turn_id for cancellation - { - let mut turn_guard = self.current_turn_id.lock().await; - *turn_guard = Some(turn_id.clone()); - } - - // Start the dialog turn; events arrive through the shared broadcast source. - let mut metadata = approval_metadata(self.approval_policy()); - put_agent_workspace_references(&mut metadata, &workspace_references) - .map_err(|error| anyhow::anyhow!(error.message))?; - let request = AgentDialogTurnRequest { - session_id: session_id.clone(), - message: message.clone(), - original_message, - turn_id: Some(turn_id.clone()), - execution, - agent_type: agent_type.to_string(), - // Dialog submission uses this path to locate persisted session - // state. Execution still comes from the session's resolved binding. - workspace_path: Some(self.project_workspace_path_string()), - remote_connection_id: None, - remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), - reply_route: None, - prepended_reminders: Vec::new(), - attachments, - metadata, - }; - let submission: Result = async { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let start_result = runtime.submit_dialog_turn(request.clone()).await; - if let Err(err) = start_result { - let session_not_found = Self::is_session_not_found_error(&err); - let error_message = err.into_message(); - if session_not_found { - tracing::warn!( - "Session missing when starting turn, attempting recovery and retry: session_id={}, error={}", - session_id, - error_message - ); - self.ensure_backend_session_alive(&session_id, agent_type) - .await?; - runtime - .submit_dialog_turn(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - } else { - return Err(anyhow::anyhow!(error_message)); - } - } - Ok(turn_id) - } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::SubmitTurn { request }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id: accepted_session, - turn_id: accepted_turn, - } if accepted_session == session_id => { - *self.current_turn_id.lock().await = Some(accepted_turn.clone()); - Ok(accepted_turn) - } - _ => Err(unexpected_shared_result("submit_turn")), - }, - } - } - .await; - if submission.is_err() { - *self.current_turn_id.lock().await = None; - } - submission - } - - pub(crate) async fn steer_current_turn( - &self, - content: String, - display_content: Option, - ) -> Result { - if content.trim().is_empty() { - return Err(anyhow::anyhow!("Steering content cannot be empty")); - } - let session_id = self - .session_id - .lock() - .await - .clone() - .ok_or_else(|| anyhow::anyhow!("No active session is available for steering"))?; - let turn_id = self - .current_turn_id - .lock() - .await - .clone() - .ok_or_else(|| anyhow::anyhow!("No active turn is available for steering"))?; - let request = AgentDialogSteerRequest { - session_id: session_id.clone(), - turn_id: turn_id.clone(), - content, - display_content, - }; - - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => match runtime - .steer_dialog_turn(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))? - { - DialogSteerOutcome::Buffered { steering_id, .. } => Ok(steering_id), - }, - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::SteerTurn { request }) - .await? - { - RuntimeIpcOperationResult::TurnSteered { - session_id: steered_session, - turn_id: steered_turn, - steering_id, - } if steered_session == session_id && steered_turn == turn_id => Ok(steering_id), - _ => Err(unexpected_shared_result("steer_turn")), - }, - } - } - - pub(crate) async fn run_user_shell_command( - &self, - command: String, - agent_type: &str, - ) -> Result { - let session_id = self.ensure_session(agent_type).await?; - let turn_id = uuid::Uuid::new_v4().to_string(); - let request = AgentUserShellCommandRequest { - session_id: session_id.clone(), - turn_id: turn_id.clone(), - command, - }; - *self.current_turn_id.lock().await = Some(turn_id.clone()); - - let submission: Result = async { - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let accepted = match runtime.run_user_shell_command(request.clone()).await { - Ok(accepted) => accepted, - Err(error) if Self::is_session_not_found_error(&error) => { - tracing::warn!( - "Session missing when starting Shell turn, attempting recovery and retry: session_id={}", - session_id - ); - self.ensure_backend_session_alive(&session_id, agent_type) - .await?; - runtime - .run_user_shell_command(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))? - } - Err(error) => return Err(anyhow::anyhow!(error.into_message())), - }; - if accepted.session_id == session_id && accepted.turn_id == turn_id { - Ok(accepted.turn_id) - } else { - Err(anyhow::anyhow!( - "Runtime accepted a Shell command with an unexpected identity" - )) - } - } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::RunUserShellCommand { request }) - .await - { - Ok(RuntimeIpcOperationResult::TurnAccepted { - session_id: accepted_session, - turn_id: accepted_turn, - }) if accepted_session == session_id && accepted_turn == turn_id => { - Ok(accepted_turn) - } - Ok(_) => Err(unexpected_shared_result("run_user_shell_command")), - Err(error) => Err(anyhow::Error::new(error)), - }, - } - } - .await; - if submission.is_err() { - *self.current_turn_id.lock().await = None; - } - submission - } - - pub(crate) async fn search_workspace_references( - &self, - query: String, - ) -> Result { - let session_id = self - .session_id - .lock() - .await - .clone() - .ok_or_else(|| anyhow::anyhow!("No active session"))?; - let request = AgentWorkspaceReferenceSearchRequest { - session_id, - query, - limit: 20, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .search_workspace_references(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::SearchWorkspaceReferences { request }) - .await? - { - RuntimeIpcOperationResult::WorkspaceReferenceSearch { search } => Ok(search), - _ => Err(unexpected_shared_result("search_workspace_references")), - }, - } - } - - pub(crate) async fn workspace_references_for_message( - &self, - session_id: String, - message_id: String, - ) -> Result> { - let request = AgentMessageWorkspaceReferencesRequest { - session_id, - message_id, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .workspace_references_for_message(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::WorkspaceReferencesForMessage { request }) - .await? - { - RuntimeIpcOperationResult::WorkspaceReferences { references } => Ok(references), - _ => Err(unexpected_shared_result("workspace_references_for_message")), - }, - } - } - - pub(crate) async fn cancel_current_turn(&self) -> Result<()> { - let session_id = self.session_id.lock().await.clone(); - let turn_id = self.current_turn_id.lock().await.clone(); - - if let (Some(session_id), Some(turn_id)) = (session_id, turn_id) { - tracing::info!("Cancelling turn: session={}, turn={}", session_id, turn_id); - let request = AgentTurnCancellationRequest { - session_id, - turn_id: Some(turn_id.clone()), - source: Some(AgentSubmissionSource::Cli), - requester_session_id: None, - reason: Some("user_cancelled".to_string()), - wait_timeout_ms: None, - cancel_descendants: true, - }; - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - runtime - .cancel_turn(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::CancelTurn { request }) - .await? - { - RuntimeIpcOperationResult::TurnCancelled { .. } => {} - _ => return Err(unexpected_shared_result("cancel_turn")), - }, - } - - let mut turn_id_guard = self.current_turn_id.lock().await; - if turn_id_guard.as_deref() == Some(turn_id.as_str()) { - *turn_id_guard = None; - } - } - - Ok(()) - } - - pub(crate) async fn create_new_session(&self, agent_type: &str) -> Result { - let project_workspace = self.reset_execution_to_project_workspace(); - let project_workspace_path = project_workspace.to_string_lossy().to_string(); - let request = AgentSessionCreateRequest { - session_name: Self::build_default_session_name(), - agent_type: agent_type.to_string(), - workspace_path: Some(project_workspace_path.clone()), - project_workspace_path: Some(project_workspace_path.clone()), - execution_target: Some(SessionExecutionTarget::local(project_workspace_path)), - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id: None, - metadata: serde_json::Map::new(), - }; - let session = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .create_session(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::CreateSession { request }) - .await? - { - RuntimeIpcOperationResult::SessionCreated { session } => session, - _ => return Err(unexpected_shared_result("create_session")), - }, - }; - - let id = session.session_id.clone(); - - *self.session_id.lock().await = Some(id.clone()); - *self.current_turn_id.lock().await = None; - self.shared_pending_permissions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clear(); - tracing::info!("Created new core session: {}", id); - - Ok(id) - } - - pub(crate) async fn restore_session(&self, session_id: &str) -> Result<()> { - self.restore_session_in_current_workspace(session_id) - .await?; - Ok(()) - } - - pub(crate) async fn submit_user_answers( - &self, - tool_id: &str, - answers: serde_json::Value, - ) -> Result<()> { - tracing::info!("Submitting user answers for tool: {}", tool_id); - match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .submit_user_answers(AgentUserAnswersRequest { - tool_id: tool_id.to_string(), - answers, - }) - .await - .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e.into_message())), - CliAgentRuntimeBackend::Shared(client) => { - let session_id = self.require_session_id().await?; - expect_unit( - client - .request(RuntimeIpcOperation::SubmitUserAnswers { - request: RuntimeUserAnswersRequest { - session_id, - tool_id: tool_id.to_string(), - answers, - }, - }) - .await?, - "submit_user_answers", - ) - } - } - } - - async fn require_session_id(&self) -> Result { - self.session_id - .lock() - .await - .clone() - .ok_or_else(|| anyhow::anyhow!("Shared TUI has no attached session")) - } - - async fn refresh_shared_pending_permissions(&self) -> Result<()> { - let CliAgentRuntimeBackend::Shared(client) = &self.backend else { - return Ok(()); - }; - let session_id = self.require_session_id().await?; - let RuntimeIpcOperationResult::PendingPermissions { requests } = client - .request(RuntimeIpcOperation::PendingPermissions { session_id }) - .await? - else { - return Err(unexpected_shared_result("pending_permissions")); - }; - let mut pending = self - .shared_pending_permissions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - pending.clear(); - pending.extend( - requests - .into_iter() - .map(|request| (request.request_id.clone(), request)), - ); - Ok(()) - } -} - -fn shared_receiver( - source: Option<&SharedBroadcast>, - message: &str, -) -> std::result::Result, RuntimeError> { - source - .and_then(|source| { - source - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .map(broadcast::Sender::subscribe) - }) - .ok_or_else(|| RuntimeError::Port(PortError::new(PortErrorKind::NotAvailable, message))) -} - -fn spawn_shared_event_bridge( - mut source: broadcast::Receiver, - agent_sender: broadcast::Sender, - permission_sender: broadcast::Sender, - agent_owner: SharedBroadcast, - permission_owner: SharedBroadcast, - pending: Arc>>, -) { - tokio::spawn(async move { - loop { - match source.recv().await { - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { envelope, .. })) => { - let _ = agent_sender.send(envelope); - } - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Permission { - session_id, - mut event, - })) => { - project_routed_permission_event(&mut event, &session_id); - match &event { - bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { request } => { - pending - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(request.request_id.clone(), request.clone()); - } - bitfun_agent_runtime::sdk::PermissionRequestEvent::Replied { - request_id, - .. - } - | bitfun_agent_runtime::sdk::PermissionRequestEvent::Cancelled { - request_id, - .. - } => { - pending - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(request_id); - } - } - let _ = permission_sender.send(event); - } - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::StreamInvalidated { - reason, - })) => { - let event = AgenticEvent::SystemError { - session_id: None, - error: shared_disconnect_message(Some(reason)), - recoverable: false, - }; - let _ = agent_sender.send(AgenticEventEnvelope::new( - event, - bitfun_events::AgenticEventPriority::Critical, - )); - break; - } - Ok(RuntimeIpcClientEvent::Disconnected) - | Err(broadcast::error::RecvError::Closed) - | Err(broadcast::error::RecvError::Lagged(_)) => { - let event = AgenticEvent::SystemError { - session_id: None, - error: shared_disconnect_message(None), - recoverable: false, - }; - let _ = agent_sender.send(AgenticEventEnvelope::new( - event, - bitfun_events::AgenticEventPriority::Critical, - )); - break; - } - } - } - *agent_owner - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - *permission_owner - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - }); -} - -fn shared_disconnect_message(reason: Option) -> String { - if reason == Some(RuntimeIpcStreamInvalidationReason::FrameTooLarge) { - format!( - "Shared Runtime event exceeded the supported size; active-turn cancellation was requested. {SHARED_TUI_EMBEDDED_HANDOFF}." - ) - } else { - "Shared Runtime connection was lost; this view is no longer authoritative".to_string() - } -} - -fn project_routed_permission_event( - event: &mut bitfun_agent_runtime::sdk::PermissionRequestEvent, - routed_session_id: &str, -) { - let bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { request } = event else { - return; - }; - if request.session_id == routed_session_id { - return; - } - if let Some(delegation) = request.delegation.as_mut() { - delegation.parent_session_id = routed_session_id.to_string(); - } -} - -pub(super) fn expect_unit(result: RuntimeIpcOperationResult, operation: &str) -> Result<()> { - match result { - RuntimeIpcOperationResult::Unit => Ok(()), - _ => Err(unexpected_shared_result(operation)), - } -} - -fn unexpected_shared_result(operation: &str) -> anyhow::Error { - anyhow::anyhow!("Shared Runtime returned an unexpected result for {operation}") -} - -#[cfg(test)] -mod recovery_tests { - use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; - - use super::ExecAgentRuntimeClient; - - #[test] - fn session_recovery_requires_structured_not_found_error() { - let missing_session = - RuntimeError::Port(PortError::new(PortErrorKind::NotFound, "session not found")); - let unrelated_backend_error = - RuntimeError::Port(PortError::new(PortErrorKind::Backend, "model not found")); - - assert!(ExecAgentRuntimeClient::is_session_not_found_error( - &missing_session - )); - assert!(!ExecAgentRuntimeClient::is_session_not_found_error( - &unrelated_backend_error - )); - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use bitfun_runtime_ports::{ - AgentSessionSummary, AgentSessionWorkspaceBinding, SessionExecutionTarget, - SessionExecutionTargetKind, WorktreeLifecycle, - }; - - use bitfun_agent_runtime::sdk::{ - PermissionDelegationContext, PermissionRequest, PermissionRequestEvent, - PermissionRequestSource, PermissionRequestSourceKind, PortError, PortErrorKind, - RuntimeError, - }; - use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; - - use super::{ - project_routed_permission_event, session_migration_notices, shared_disconnect_message, - shared_restore_error, validated_session_summary, CliWorkspacePaths, SessionMigrationNotice, - SessionOperationError, - }; - use bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason; - - #[test] - fn oversized_shared_restore_explains_the_embedded_handoff() { - let error = shared_restore_error(RuntimeIpcClientError::Remote(RuntimeIpcError { - code: RuntimeIpcErrorCode::FrameTooLarge, - message: "response too large".to_string(), - })); - let message = error.to_string(); - assert!(message.contains("history is too large")); - assert!(message.contains("default Embedded `bitfun chat`")); - } - - #[test] - fn shared_session_update_preserves_unknown_outcome_as_a_typed_fact() { - let error = SessionOperationError::shared(RuntimeIpcClientError::Remote(RuntimeIpcError { - code: RuntimeIpcErrorCode::OutcomeUnknown, - message: "inspect authoritative state before retrying".to_string(), - })); - - assert!(error.outcome_unknown()); - assert!(error.to_string().contains("OutcomeUnknown")); - - for transport_error in [ - RuntimeIpcClientError::Timeout, - RuntimeIpcClientError::Disconnected, - RuntimeIpcClientError::UnexpectedResponse, - ] { - assert!(SessionOperationError::shared(transport_error).outcome_unknown()); - } - assert!( - SessionOperationError::unexpected(anyhow::anyhow!("unexpected response shape")) - .outcome_unknown() - ); - assert!( - !SessionOperationError::shared(RuntimeIpcClientError::Remote(RuntimeIpcError { - code: RuntimeIpcErrorCode::InvalidRequest, - message: "unknown mode".to_string(), - },)) - .outcome_unknown() - ); - assert!( - !SessionOperationError::shared(RuntimeIpcClientError::RequestEncoding( - bitfun_agent_runtime_ipc::RuntimeIpcIoError::FrameTooLarge { - size: 129, - max_bytes: 128, - }, - )) - .outcome_unknown() - ); - } - - #[test] - fn embedded_runtime_unknown_outcome_is_preserved() { - let error = SessionOperationError::runtime(RuntimeError::Port(PortError::new( - PortErrorKind::OutcomeUnknown, - "inspect authoritative state", - ))); - - assert!(error.outcome_unknown()); - } - - #[test] - fn read_only_lineage_retry_requires_typed_remote_outcome_unknown() { - let settling = SessionOperationError::read_only_shared(RuntimeIpcClientError::Remote( - RuntimeIpcError { - code: RuntimeIpcErrorCode::OutcomeUnknown, - message: "turn is settling".to_string(), - }, - )); - let permanent = SessionOperationError::read_only_shared(RuntimeIpcClientError::Remote( - RuntimeIpcError { - code: RuntimeIpcErrorCode::NotFound, - message: "session missing".to_string(), - }, - )); - - assert!(settling.outcome_unknown()); - assert!(!permanent.outcome_unknown()); - assert!( - !SessionOperationError::read_only_shared(RuntimeIpcClientError::Timeout) - .outcome_unknown() - ); - assert!( - !SessionOperationError::read_only_unexpected(anyhow::anyhow!("unexpected response")) - .outcome_unknown() - ); - } - - #[test] - fn oversized_shared_event_explains_cancellation_and_handoff() { - let message = - shared_disconnect_message(Some(RuntimeIpcStreamInvalidationReason::FrameTooLarge)); - assert!(message.contains("cancellation was requested")); - assert!(message.contains("default Embedded `bitfun chat`")); - } - - #[test] - fn workspace_paths_keep_project_and_execution_roots_separate() { - let mut paths = CliWorkspacePaths::new(Some("/project".into())); - let binding = AgentSessionWorkspaceBinding { - workspace_id: Some("workspace-1".to_string()), - workspace_path: "/managed-worktree".to_string(), - project_workspace_path: Some("/project".to_string()), - execution_target: Some(SessionExecutionTarget { - kind: SessionExecutionTargetKind::ManagedWorktree, - worktree_id: Some("worktree-1".to_string()), - root_path: "/managed-worktree".to_string(), - base_ref: Some("main".to_string()), - base_commit: Some("123456789abcdef".to_string()), - branch: None, - lifecycle: Some(WorktreeLifecycle::Managed), - }), - remote_connection_id: None, - remote_ssh_host: None, - }; - - paths.apply_binding(&binding); - - assert_eq!(paths.project(), Path::new("/project")); - assert_eq!(paths.execution(), Path::new("/managed-worktree")); - assert_eq!( - paths - .execution_target - .as_ref() - .and_then(|target| target.worktree_id.as_deref()), - Some("worktree-1") - ); - - assert_eq!( - paths.reset_execution_to_project(), - Path::new("/project").to_path_buf() - ); - assert_eq!(paths.execution(), Path::new("/project")); - assert!(paths - .execution_target - .as_ref() - .and_then(|target| target.worktree_id.as_ref()) - .is_none()); - } - - #[test] - fn workspace_diff_fails_closed_for_other_worktrees_and_remote_sessions() { - let mut paths = CliWorkspacePaths::new(Some("/project".into())); - assert_eq!(paths.workspace_diff_unavailable_reason(), None); - - paths.apply_binding(&AgentSessionWorkspaceBinding { - workspace_id: Some("workspace-1".to_string()), - workspace_path: "/managed-worktree".to_string(), - project_workspace_path: Some("/project".to_string()), - execution_target: Some(SessionExecutionTarget { - kind: SessionExecutionTargetKind::ManagedWorktree, - worktree_id: Some("worktree-1".to_string()), - root_path: "/managed-worktree".to_string(), - base_ref: Some("main".to_string()), - base_commit: Some("123456789abcdef".to_string()), - branch: None, - lifecycle: Some(WorktreeLifecycle::Managed), - }), - remote_connection_id: None, - remote_ssh_host: None, - }); - assert!(paths - .workspace_diff_unavailable_reason() - .is_some_and(|reason| reason.contains("different worktree"))); - - paths.apply_binding(&AgentSessionWorkspaceBinding { - workspace_id: None, - workspace_path: "/project".to_string(), - project_workspace_path: Some("/project".to_string()), - execution_target: Some(SessionExecutionTarget::local("/project")), - remote_connection_id: Some("remote-1".to_string()), - remote_ssh_host: Some("example.test".to_string()), - }); - assert!(paths - .workspace_diff_unavailable_reason() - .is_some_and(|reason| reason.contains("remote Sessions"))); - } - - #[test] - fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let compatibility_update = - ["self.compatibility", "\n .update_session_model"].concat(); - - assert!(source.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(source.contains("runtime.update_session_model(request)")); - assert!(source.contains("CliAgentRuntimeBackend::Shared(client)")); - assert!(source.contains("RuntimeIpcOperation::UpdateSessionModel { request }")); - assert!(!source.contains(&compatibility_update)); - } - - #[test] - fn startup_model_selection_is_sent_in_the_session_creation_request() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let creation = source - .split_once("pub(crate) async fn ensure_session_with_model(") - .expect("explicit startup session creation method") - .1 - .split_once("pub(crate) async fn start_session_compaction(") - .expect("explicit startup session creation boundary") - .0; - - assert!(creation.contains("model_id: Option")); - assert!(creation.contains("AgentSessionCreateRequest")); - assert!(creation.contains("model_id,")); - assert!(creation.contains("RuntimeIpcOperation::CreateSession { request }")); - } - - #[test] - fn session_rename_uses_direct_runtime_or_private_shared_ipc() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let rename = source - .split_once("pub(crate) async fn rename_session(") - .expect("rename method") - .1 - .split_once("pub(crate) async fn update_session_mode(") - .expect("rename method boundary") - .0; - - assert!(source.contains("pub(crate) async fn rename_session(")); - assert!(rename.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(rename.contains(".rename_session(request)")); - assert!(rename.contains("RuntimeIpcOperation::RenameSession")); - assert!(!rename.contains("serde_json::to_value")); - assert!(!rename.contains("serde_json::from_value")); - } - - #[test] - fn session_compaction_uses_direct_runtime_or_private_shared_ipc() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let compact = source - .split_once("pub(crate) async fn start_session_compaction(") - .expect("compaction method") - .1 - .split_once("pub(crate) async fn send_message(") - .expect("compaction method boundary") - .0; - - assert!(compact.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(compact.contains(".start_session_compaction(request)")); - assert!(compact.contains("RuntimeIpcOperation::CompactSession { request }")); - assert!(compact.contains("RuntimeIpcOperationResult::TurnAccepted")); - assert!(!compact.contains("serde_json::to_value")); - assert!(!compact.contains("serde_json::from_value")); - } - - #[test] - fn steering_uses_the_existing_runtime_contract_in_both_deployments() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let steering = source - .split_once("pub(crate) async fn steer_current_turn(") - .expect("steering method") - .1 - .split_once("pub(crate) async fn run_user_shell_command(") - .expect("steering method boundary") - .0; - - assert!(steering.contains("AgentDialogSteerRequest")); - assert!(steering.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(steering.contains(".steer_dialog_turn(request)")); - assert!(steering.contains("CliAgentRuntimeBackend::Shared(client)")); - assert!(steering.contains("RuntimeIpcOperation::SteerTurn { request }")); - assert!(steering.contains("RuntimeIpcOperationResult::TurnSteered")); - assert!(!steering.contains("RuntimeIpcOperation::SubmitTurn")); - assert!(!steering.contains("Uuid::new_v4")); - } - - #[test] - fn image_attachments_use_the_runtime_contract_and_fail_before_shared_ipc() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let submission = source - .split_once("pub(crate) async fn send_message_with_context(") - .expect("context submission method") - .1 - .split_once("pub(crate) async fn search_workspace_references(") - .expect("context submission method boundary") - .0; - - let shared_rejection = submission - .find("if !attachments.is_empty() && self.is_shared()") - .expect("shared attachment rejection"); - let session_creation = submission - .find("let session_id = self.ensure_session") - .expect("session creation"); - assert!(shared_rejection < session_creation); - assert!(submission.contains("attachments,")); - assert!(!submission.contains("imagePath")); - } - - #[test] - fn delegated_external_commands_fail_before_shared_ipc() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let submission = source - .split_once("pub(crate) async fn send_external_subagent_command(") - .expect("delegated command submission method") - .1 - .split_once("async fn submit_dialog_turn_request(") - .expect("delegated command submission boundary") - .0; - - let shared_rejection = submission - .find("if self.is_shared()") - .expect("shared runtime rejection"); - let session_creation = submission - .find("let session_id = self.ensure_session") - .expect("session creation"); - assert!(shared_rejection < session_creation); - assert!(submission.contains("AgentDialogTurnExecution::FreshExternalSubagent")); - assert!(!submission.contains("RuntimeIpcOperation::SubmitTurn")); - } - - #[test] - fn interactive_session_fork_uses_the_same_runtime_boundary_in_both_deployments() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let fork = source - .split_once("pub(crate) async fn fork_current_session(") - .expect("interactive fork method") - .1 - .split_once("pub(crate) async fn generate_session_usage_report(") - .expect("interactive fork method boundary") - .0; - - assert!(fork.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(fork.contains(".fork_session_before_turn(")); - assert!(fork.contains(".fork_session(AgentSessionForkRequest")); - assert!(fork.contains("CliAgentRuntimeBackend::Shared(client)")); - assert!(fork.contains("RuntimeIpcOperation::ForkSession")); - assert!(fork.contains("RuntimeIpcOperationResult::SessionForked")); - } - - #[test] - fn workspace_diff_uses_the_same_runtime_boundary_in_both_deployments() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let workspace_diff = source - .split_once("pub(crate) async fn workspace_diff(") - .expect("workspace diff method") - .1 - .split_once("pub(crate) async fn generate_session_usage_report(") - .expect("workspace diff method boundary") - .0; - - assert!(workspace_diff.contains("workspace_diff_unavailable_reason")); - assert!(workspace_diff.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(workspace_diff.contains(".workspace_diff()")); - assert!(workspace_diff.contains("CliAgentRuntimeBackend::Shared(client)")); - assert!(workspace_diff.contains("RuntimeIpcOperation::WorkspaceDiff")); - assert!(workspace_diff.contains("RuntimeIpcOperationResult::WorkspaceDiff")); - } - - #[test] - fn session_revert_uses_the_same_authoritative_result_in_both_deployments() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let revert = source - .split_once("pub(crate) async fn revert_current_session(") - .expect("session revert method") - .1 - .split_once("pub(crate) async fn generate_session_usage_report(") - .expect("session revert method boundary") - .0; - - assert!(revert.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(revert.contains("runtime.undo_session(request)")); - assert!(revert.contains("runtime.redo_session(request)")); - assert!(revert.contains("RuntimeIpcOperation::UndoSession")); - assert!(revert.contains("RuntimeIpcOperation::RedoSession")); - assert!(revert.contains("RuntimeIpcOperationResult::SessionReverted")); - } - - #[test] - fn session_delete_uses_direct_runtime_or_private_shared_ipc() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let delete = source - .split_once("pub(crate) async fn delete_session(") - .expect("delete method") - .1 - .split_once("pub(crate) async fn update_session_model(") - .expect("delete method boundary") - .0; - - assert!(delete.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(delete.contains(".delete_session(AgentSessionDeleteRequest {")); - assert!(delete.contains("CliAgentRuntimeBackend::Shared(client)")); - assert!(delete.contains("RuntimeIpcOperation::DeleteSession")); - assert!(!delete.contains("embedded_runtime")); - assert!(!delete.contains("serde_json::to_value")); - assert!(!delete.contains("serde_json::from_value")); - } - - #[test] - fn mode_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let compatibility_update = [ - "self.compatibility", - "\n .update_session_agent_type", - ] - .concat(); - - assert!(source.contains("CliAgentRuntimeBackend::Embedded(runtime)")); - assert!(source.contains("runtime.update_session_mode(request)")); - assert!(source.contains("CliAgentRuntimeBackend::Shared(client)")); - assert!(source.contains("RuntimeIpcOperation::UpdateSessionMode { request }")); - assert!(!source.contains(&compatibility_update)); - } - - #[test] - fn agent_events_use_the_runtime_sdk_without_a_core_event_source() { - let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let runtime_subscription = ["runtime", ".subscribe_events()"].concat(); - let core_event_field = ["event_source", ": CliAgent", "EventSource"].concat(); - let core_event_method = ["pub(crate) fn event", "_source("].concat(); - - assert!(source.contains(&runtime_subscription)); - assert!(!source.contains(&core_event_field)); - assert!(!source.contains(&core_event_method)); - } - - fn session_summary(session_id: &str) -> AgentSessionSummary { - AgentSessionSummary { - session_id: session_id.to_string(), - session_name: "Workspace session".to_string(), - agent_type: "agentic".to_string(), - model_id: None, - reasoning_preset: None, - last_user_dialog_agent_type: None, - last_submitted_agent_type: None, - turn_count: 1, - created_at_ms: 1, - last_active_at_ms: 2, - } - } - - #[test] - fn workspace_restore_validation_accepts_listed_session() { - let sessions = vec![session_summary("session-in-workspace")]; - - let summary = validated_session_summary( - &sessions, - "session-in-workspace", - Path::new("D:/workspace/current"), - ) - .expect("listed session should be restorable"); - - assert_eq!(summary.session_id, "session-in-workspace"); - } - - #[test] - fn workspace_restore_validation_rejects_session_outside_current_workspace() { - let sessions = vec![session_summary("different-session")]; - - let error = validated_session_summary( - &sessions, - "session-from-another-workspace", - Path::new("D:/workspace/current"), - ) - .expect_err("a session absent from the workspace-scoped list must be rejected"); - - let message = error.to_string(); - assert!(message.contains("session-from-another-workspace")); - assert!(message.contains("D:/workspace/current")); - } - - #[test] - fn restore_reports_a_cli_local_notice_when_core_migrates_the_mode() { - let previous = AgentSessionSummary { - agent_type: "removed-mode".to_string(), - ..session_summary("mode-migration") - }; - let restored = session_summary("mode-migration"); - - let notices = session_migration_notices(&previous, &restored); - - assert_eq!( - notices, - vec![SessionMigrationNotice::Mode { - previous_id: "removed-mode".to_string(), - restored_id: "agentic".to_string(), - }] - ); - } - - #[test] - fn restore_reports_a_cli_local_notice_when_core_migrates_the_model() { - let previous = AgentSessionSummary { - model_id: Some("removed-model".to_string()), - ..session_summary("model-migration") - }; - let restored = AgentSessionSummary { - model_id: Some("auto".to_string()), - ..session_summary("model-migration") - }; - - let notices = session_migration_notices(&previous, &restored); - - assert_eq!( - notices, - vec![SessionMigrationNotice::Model { - previous_id: "removed-model".to_string(), - restored_id: "auto".to_string(), - }] - ); - assert!(notices[0].user_message().contains("unavailable")); - } - - #[test] - fn restore_does_not_report_notices_when_session_settings_are_unchanged() { - let summary = session_summary("unchanged-mode"); - - assert!(session_migration_notices(&summary, &summary).is_empty()); - } - - #[test] - fn nested_permission_projects_to_the_routed_controller_session() { - let mut permission = PermissionRequestEvent::Asked { - request: PermissionRequest { - request_id: "permission".to_string(), - round_id: "round".to_string(), - order: 0, - tool_call_id: None, - project_path: None, - project_id: "project".to_string(), - session_id: "child".to_string(), - agent_id: "agentic".to_string(), - action: "run command".to_string(), - resources: Vec::new(), - save_resources: Vec::new(), - source: PermissionRequestSource { - kind: PermissionRequestSourceKind::ToolCall, - identity: "shell".to_string(), - }, - delegation: Some(PermissionDelegationContext { - parent_session_id: "child".to_string(), - parent_dialog_turn_id: None, - parent_tool_call_id: "delegate".to_string(), - subagent_type: "general".to_string(), - }), - display_metadata: serde_json::Map::new(), - }, - }; - project_routed_permission_event(&mut permission, "root"); - assert!( - matches!(permission, PermissionRequestEvent::Asked { request } if request.delegation.as_ref().is_some_and(|delegation| delegation.parent_session_id == "root")) - ); - } -} diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index 7c85d763d0..4876dff571 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -10,7 +10,7 @@ use anyhow::Result; use bitfun_app_server_client::AppServerEvent; use bitfun_app_server_protocol::account::*; use bitfun_app_server_protocol::agent::*; -use bitfun_app_server_protocol::event::EventStreamState; +use bitfun_app_server_protocol::event::{EventStream, EventStreamState}; use bitfun_app_server_protocol::external_source::*; use bitfun_app_server_protocol::hook::*; use bitfun_app_server_protocol::mcp::*; @@ -1686,10 +1686,11 @@ fn spawn_event_bridge( // The next TUI snapshot request is the authoritative recovery path. } Ok(AppServerEvent::StreamState(notification)) - if matches!( - notification.state, - EventStreamState::Closed | EventStreamState::Invalidated - ) => + if notification.stream == EventStream::Agent + && matches!( + notification.state, + EventStreamState::Closed | EventStreamState::Invalidated + ) => { send_stream_error( &agent_sender, @@ -1772,3 +1773,99 @@ fn default_session_name() -> String { chrono::Local::now().format("%Y-%m-%d %H:%M:%S") ) } + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_app_server_protocol::event::{ + AgentEventNotification, EventCursor, EventStreamStateNotification, ResyncDirective, + }; + + fn cursor(stream: EventStream, sequence: u64) -> EventCursor { + EventCursor { + connection_id: "connection-1".to_string(), + stream, + sequence, + } + } + + fn stream_state(stream: EventStream, state: EventStreamState, sequence: u64) -> AppServerEvent { + AppServerEvent::StreamState(EventStreamStateNotification { + cursor: cursor(stream, sequence), + stream, + state, + missed: None, + resync: ResyncDirective { + method: "app/syncEvents".to_string(), + snapshot_available: true, + reason: Some("test stream state".to_string()), + }, + }) + } + + #[tokio::test] + async fn non_agent_stream_closure_keeps_agent_delivery_alive() { + let (source, source_rx) = broadcast::channel(8); + let (agent_sender, _) = broadcast::channel(8); + let (permission_sender, _) = broadcast::channel(8); + let (external_source_sender, _) = broadcast::channel(8); + let agent_owner = Arc::new(RwLock::new(Some(agent_sender.clone()))); + let permission_owner = Arc::new(RwLock::new(Some(permission_sender.clone()))); + let external_source_owner = Arc::new(RwLock::new(Some(external_source_sender.clone()))); + let pending = Arc::new(RwLock::new(HashMap::new())); + let mut agent_events = agent_sender.subscribe(); + + spawn_event_bridge( + source_rx, + agent_sender, + permission_sender, + external_source_sender, + agent_owner.clone(), + permission_owner, + external_source_owner, + pending, + ); + + source + .send(stream_state( + EventStream::Permission, + EventStreamState::Closed, + 1, + )) + .expect("permission stream state"); + source + .send(stream_state( + EventStream::Config, + EventStreamState::Invalidated, + 1, + )) + .expect("config stream state"); + source + .send(AppServerEvent::Agent(AgentEventNotification { + cursor: cursor(EventStream::Agent, 1), + event: AgenticEventEnvelope::new( + AgenticEvent::SessionStateChanged { + session_id: "session-1".to_string(), + new_state: "idle".to_string(), + }, + AgenticEventPriority::Normal, + ), + })) + .expect("agent event"); + + let delivered = + tokio::time::timeout(std::time::Duration::from_secs(1), agent_events.recv()) + .await + .expect("agent event delivery timed out") + .expect("agent event stream closed"); + assert!(matches!( + delivered.event, + AgenticEvent::SessionStateChanged { ref session_id, .. } + if session_id == "session-1" + )); + assert!(agent_owner + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some()); + } +} diff --git a/src/apps/cli/src/diagnostics.rs b/src/apps/cli/src/diagnostics.rs index 7fea00ee03..c88571b478 100644 --- a/src/apps/cli/src/diagnostics.rs +++ b/src/apps/cli/src/diagnostics.rs @@ -3,7 +3,6 @@ use std::path::Path; use bitfun_agent_runtime::sdk::{PortErrorKind, RuntimeError}; -use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcErrorCode}; pub(crate) const EXIT_LINE_PREFIX: &str = "BITFUN_EXIT: "; pub(crate) const DETAIL_MAX_LEN: usize = 500; @@ -77,10 +76,6 @@ pub(crate) fn cli_error_code(error: &anyhow::Error) -> Option<&'static str> { cause.downcast_ref::(), Some(RuntimeError::Port(port_error)) if port_error.kind == PortErrorKind::SessionInUse - ) || matches!( - cause.downcast_ref::(), - Some(RuntimeIpcClientError::Remote(remote)) - if remote.code == RuntimeIpcErrorCode::SessionInUse ) }); session_in_use.then_some(SESSION_IN_USE_ERROR_CODE) @@ -118,7 +113,6 @@ pub(crate) fn emit_exit_diagnostic(kind: ExitKind, detail: &str, ctx: &ExitConte mod tests { use super::*; use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; - use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; #[test] fn format_exit_line_uses_stable_prefix_and_kind() { @@ -157,20 +151,6 @@ mod tests { ); } - #[test] - fn shared_session_conflict_uses_the_same_cli_projection() { - let error = anyhow::Error::new(RuntimeIpcClientError::Remote(RuntimeIpcError { - code: RuntimeIpcErrorCode::SessionInUse, - message: "Session is already open for writing: session-1".to_string(), - })); - - assert_eq!(cli_error_code(&error), Some(SESSION_IN_USE_ERROR_CODE)); - assert_eq!( - user_facing_error_message(&error), - SESSION_IN_USE_USER_MESSAGE - ); - } - #[test] fn unrelated_errors_keep_their_original_message() { let error = anyhow::anyhow!("provider unavailable"); diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 2cfc3d4f36..21c745271c 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -33,8 +33,7 @@ mod prompts; mod root_handlers; mod runtime; mod self_update; -mod shared_runtime; -mod shared_tui_backend; +mod shared_app_server; mod terminal_attention; mod tui_backend; mod ui; @@ -111,9 +110,8 @@ fn ensure_cli_mcp_service( bitfun_core::service::mcp::set_global_mcp_service(service.clone()); get_mcp_init_status().store(1, Ordering::Relaxed); - // Shared TUI keeps the pre-migration CLI-local MCP compatibility path. It - // is intentionally separate from the MCP manager inside Shared Runtime; - // this process must not be mistaken for that Runtime's owner. + // Initialize the MCP owner in the process that owns this Runtime. For + // Shared TUI this runs in the Shared App Server Host, not its clients. let initializing = service.clone(); tokio::spawn(async move { match initializing.server_manager().initialize_all().await { @@ -143,8 +141,8 @@ struct Cli { #[arg(short, long, global = true)] verbose: bool, - /// Use the opt-in Shared Runtime for interactive TUI mode - /// Multiple TUIs may share one workspace Runtime; each controls a Session at a time. + /// Use the Shared App Server for interactive TUI mode + /// Multiple local TUIs may observe and control the same Session. /// Automation, desktop, and remote modes remain unchanged. #[arg(long, verbatim_doc_comment)] shared: bool, @@ -183,13 +181,13 @@ enum Commands { #[arg(short, long, default_value = "agentic")] agent: String, - /// Use the opt-in Shared Runtime for this interactive TUI + /// Use the Shared App Server for this interactive TUI #[arg(long)] shared: bool, }, - #[command(name = "__shared-runtime", hide = true)] - SharedRuntime { + #[command(name = "__shared-app-server", hide = true)] + SharedAppServer { #[arg(long)] workspace: std::path::PathBuf, #[arg(long)] @@ -539,6 +537,18 @@ pub(crate) enum BootstrapProfile { Management, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InteractiveDeployment { + Embedded, + Shared, +} + +impl InteractiveDeployment { + const fn is_embedded(self) -> bool { + matches!(self, Self::Embedded) + } +} + impl BootstrapProfile { const fn starts_peer_host( self, @@ -905,7 +915,7 @@ async fn run_interactive( config: CliConfig, default_agent: String, _workspace_str: String, - shared: bool, + deployment: InteractiveDeployment, agent_override: Option, model_id: Option, session_override: Option, @@ -924,9 +934,7 @@ async fn run_interactive( .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from(".")); - let runtime = if shared { - None - } else { + let runtime = if deployment.is_embedded() { Some( initialize_core_services( &workspace_path, @@ -935,6 +943,8 @@ async fn run_interactive( ) .await?, ) + } else { + None }; let embedded_app_server = if let Some(runtime) = &runtime { Some(embedded_app_server::EmbeddedAppServerHost::start(runtime).await?) @@ -953,26 +963,20 @@ async fn run_interactive( runtime.approval_policy(), )) } else { - let client = shared_runtime::connect_or_start(&workspace_path).await?; - let config_service = bitfun_core::service::config::get_global_config_service() - .await - .map_err(|error| anyhow!("Failed to load Shared TUI management config: {error}"))?; - ensure_cli_mcp_service(config_service); - let management = Arc::new(bitfun_app_server::AppManagementService::load().await?); - let backend: Arc = Arc::new( - shared_tui_backend::SharedTuiBackend::new(client, management), - ); - let backend_initialized = backend + let client = shared_app_server::connect_or_start(&workspace_path).await?; + let backend: Arc = + Arc::new(tui_backend::AppServerTuiBackend::new(client)); + let initialized = backend .initialize(bitfun_app_server_protocol::app::InitializeRequest { protocol_version: bitfun_app_server_protocol::PROTOCOL_VERSION, client: bitfun_app_server_protocol::app::ClientInfo { - name: "bitfun-tui".to_string(), + name: "bitfun-tui-shared".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }, }) .await?; - if backend_initialized.protocol_version != bitfun_app_server_protocol::PROTOCOL_VERSION { - anyhow::bail!("Shared TUI Host negotiated an incompatible protocol"); + if initialized.protocol_version != bitfun_app_server_protocol::PROTOCOL_VERSION { + anyhow::bail!("Shared App Server negotiated an incompatible protocol"); } backend.health().await?; Arc::new(TuiAgentClient::new( @@ -983,7 +987,7 @@ async fn run_interactive( )) }; // 3.5 Restore persisted account session (if any) - if !shared { + if deployment.is_embedded() { let runtime = runtime .as_ref() .expect("Embedded account startup requires the CLI Runtime"); @@ -1009,7 +1013,7 @@ async fn run_interactive( // 3.6 Continuous account settings sync (30s pull + debounced push). // Safe to start before login: cycles skip while logged out. - if !shared { + if deployment.is_embedded() { runtime .as_ref() .expect("Embedded settings sync requires the CLI Runtime") @@ -1042,7 +1046,7 @@ async fn run_interactive( } let chat_result = chat_mode.run(Some(terminal)); - if !shared { + if deployment.is_embedded() { shutdown_mcp_servers().await; } let _exit_reason = chat_result?; @@ -1192,8 +1196,8 @@ async fn run_cli() -> Result<()> { }; let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - let is_shared_service = matches!(cli.command, Some(Commands::SharedRuntime { .. })); - let use_shared_runtime = match shared_tui_requested(cli.shared, &cli.command) { + let is_shared_service = matches!(cli.command, Some(Commands::SharedAppServer { .. })); + let use_shared_app_server = match shared_tui_requested(cli.shared, &cli.command) { Ok(shared) => shared, Err(error) if exec_requests_json_output(&raw_args) => { modes::exec::emit_preflight_json_error(ExecOutputFormat::Json, &error)?; @@ -1218,7 +1222,7 @@ async fn run_cli() -> Result<()> { if is_shared_service { let service_log_dir = - logging::resolve_logs_root().join(format!("shared-runtime-{}", std::process::id())); + logging::resolve_logs_root().join(format!("shared-app-server-{}", std::process::id())); logging::init_file_logging_at(&service_log_dir, file_log_level); } else if is_tui_mode || is_exec_mode || is_daemon_run || is_dispatch_mode { logging::init_file_logging(file_log_level); @@ -1249,7 +1253,11 @@ async fn run_cli() -> Result<()> { config, agent, ".".to_string(), - use_shared_runtime, + if use_shared_app_server { + InteractiveDeployment::Shared + } else { + InteractiveDeployment::Embedded + }, cli.agent.clone(), cli.model.clone(), None, @@ -1257,10 +1265,10 @@ async fn run_cli() -> Result<()> { .await?; } - Some(Commands::SharedRuntime { + Some(Commands::SharedAppServer { workspace, instance_identity, - }) => shared_runtime::run_service(workspace, instance_identity).await?, + }) => shared_app_server::run_service(workspace, instance_identity)?, Some(Commands::Exec { message, @@ -1527,7 +1535,11 @@ async fn run_cli() -> Result<()> { config, default_agent, workspace_str, - use_shared_runtime, + if use_shared_app_server { + InteractiveDeployment::Shared + } else { + InteractiveDeployment::Embedded + }, cli.agent.clone(), cli.model.clone(), session_override, @@ -2006,7 +2018,19 @@ mod shared_tui_command_tests { } #[test] - fn shared_runtime_does_not_start_the_peer_host() { + fn sharedv2_is_removed_and_internal_host_role_is_hidden() { + assert!(Cli::try_parse_from(["bitfun", "sharedv2"]).is_err()); + assert!(Cli::try_parse_from(["bitfun", "--sharedv2"]).is_err()); + assert!(Cli::try_parse_from(["bitfun", "chat", "--sharedv2"]).is_err()); + let help = Cli::command().render_long_help().to_string(); + assert!(!help.contains("sharedv2")); + assert!(help.contains("Shared App Server")); + assert!(help.contains("same Session")); + assert!(!help.contains("__shared-app-server")); + } + + #[test] + fn shared_app_server_does_not_start_the_peer_host() { assert!(BootstrapProfile::Interactive.starts_peer_host(RuntimeDeployment::Embedded)); assert!(!BootstrapProfile::Interactive.starts_peer_host(RuntimeDeployment::Shared)); } @@ -2014,10 +2038,9 @@ mod shared_tui_command_tests { #[test] fn help_explains_shared_scope_and_hides_internal_process_role() { let help = Cli::command().render_long_help().to_string(); - assert!(help.contains("one workspace Runtime")); - assert!(help.contains("controls a Session at a time")); + assert!(help.contains("Shared App Server")); + assert!(help.contains("observe and control the same Session")); assert!(help.contains("Automation, desktop, and remote modes")); - assert!(!help.contains("__shared-runtime")); let exec_help = Commands::augment_subcommands(Cli::command()) .find_subcommand("exec") .expect("exec command") diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 82aa7d6513..2ae3bb60eb 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -451,7 +451,7 @@ fn terminal_event_allowed_while_local_effect_pending(event: &Event) -> bool { } const SESSION_OPERATION_SLOW_NOTICE: Duration = Duration::from_secs(15); -const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, and declarative context via /reload [skills|instructions]. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Local extension, account-sync, usage, and other management remain Embedded."; +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI: connected through the Shared App Server. Multiple clients can subscribe to the same Session; independent turns remain serialized by the Runtime owner. Session and management requests run in the Shared App Server Host."; #[derive(Default)] struct NonKeyEventOutcome { diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index aafb1a539e..53aada3bde 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -60,18 +60,8 @@ fn mcp_item_from_summary(server: McpServerSummary) -> McpItem { } } -/// Completion message for the `/mcp` add flow. In Shared TUI mode the add -/// mutates the local MCP compatibility owner of this CLI process, not the -/// already-running Shared Runtime Host, so it must not be reported as -/// "started" for that Runtime. -fn mcp_add_completion_message(name: &str, shared: bool) -> String { - if shared { - format!( - "MCP server '{name}' added to the local compatibility owner; the Shared Runtime Host is not reconfigured" - ) - } else { - format!("MCP server '{name}' added and started") - } +fn mcp_add_completion_message(name: &str) -> String { + format!("MCP server '{name}' added and started") } impl ChatMode { @@ -273,7 +263,7 @@ impl ChatMode { tokio::task::block_in_place(|| rt_handle.block_on(handle)), ), PendingMcpTask::Add { name, handle } => ( - Some(mcp_add_completion_message(&name, self.agent.is_shared())), + Some(mcp_add_completion_message(&name)), format!("add MCP server '{name}'"), tokio::task::block_in_place(|| rt_handle.block_on(handle)), ), @@ -493,19 +483,9 @@ mod mcp_terminal_tests { } #[test] - fn shared_add_does_not_report_runtime_started() { - // In Shared TUI mode the add mutates this CLI process's local MCP - // compatibility owner, not the already-running Shared Runtime Host, - // so the completion message must not claim the server "started" for - // that Runtime and must state the local scope. - let shared = mcp_add_completion_message("srv", true); - assert!(shared.contains("local compatibility owner")); - assert!(shared.contains("Shared Runtime Host is not reconfigured")); - assert!(!shared.contains("added and started")); - - // Embedded mode owns the runtime, so "added and started" stays accurate. + fn add_reports_the_host_owned_server_started() { assert_eq!( - mcp_add_completion_message("srv", false), + mcp_add_completion_message("srv"), "MCP server 'srv' added and started" ); } diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index db85947a20..7abe453d6a 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -710,7 +710,7 @@ impl ChatMode { } Err(error) => { let mut failure = format!( - "Shared Runtime permission state could not be resynchronized: {}", + "Shared App Server permission state could not be resynchronized: {}", error ); let agent = self.agent.clone(); @@ -730,7 +730,7 @@ impl ChatMode { } Err(TryRecvError::Closed) => { let mut failure = - "Shared Runtime permission event stream closed".to_string(); + "Shared App Server permission event stream closed".to_string(); let agent = self.agent.clone(); if let Err(error) = tokio::task::block_in_place(|| { rt_handle.block_on(agent.cancel_current_turn()) diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 966650120d..4dece8064d 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -2217,16 +2217,11 @@ mod tests { } #[test] - fn shared_chat_status_describes_local_compatibility_management() { - assert!(SHARED_TUI_CHAT_STATUS.contains("current Session Agent mode")); - assert!(!SHARED_TUI_CHAT_STATUS.contains("current Session model")); - assert!(SHARED_TUI_CHAT_STATUS.contains("current Session name")); - assert!(SHARED_TUI_CHAT_STATUS.contains("/reload [skills|instructions]")); - assert!(SHARED_TUI_CHAT_STATUS.contains("Model, Skill, Subagent, and MCP management")); - assert!(SHARED_TUI_CHAT_STATUS.contains("local compatibility owner")); - assert!(SHARED_TUI_CHAT_STATUS - .contains("do not reconfigure an already-running Shared Runtime Host")); - assert!(SHARED_TUI_CHAT_STATUS.contains("other management remain Embedded")); + fn shared_chat_status_describes_app_server_multi_client_ownership() { + assert!(SHARED_TUI_CHAT_STATUS.contains("Shared App Server")); + assert!(SHARED_TUI_CHAT_STATUS.contains("subscribe to the same Session")); + assert!(SHARED_TUI_CHAT_STATUS.contains("turns remain serialized")); + assert!(SHARED_TUI_CHAT_STATUS.contains("management requests run")); } #[test] diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index f5894ffb07..0679810a6a 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -19,7 +19,7 @@ use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::{AgenticEvent, ToolEventIdentity}; use tokio::time::Instant; -use crate::agent::runtime_client::ExecAgentRuntimeClient; +use crate::agent::exec_runtime_client::ExecAgentRuntimeClient; use crate::config::CliConfig; use crate::diagnostics::{ cli_error_code, emit_exit_diagnostic, user_facing_error_message, ExitContext, ExitKind, diff --git a/src/apps/cli/src/shared_app_server.rs b/src/apps/cli/src/shared_app_server.rs new file mode 100644 index 0000000000..d689977a7d --- /dev/null +++ b/src/apps/cli/src/shared_app_server.rs @@ -0,0 +1,746 @@ +//! Multi-client Shared App Server host for `bitfun --shared`. + +use std::fs::{File, OpenOptions}; +use std::io; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use agent_client_protocol::Lines; +use anyhow::{anyhow, Context, Result}; +use bitfun_app_server::{AppManagementService, BitfunAppRuntime, BitfunAppServer}; +use bitfun_app_server_client::AppServerClient; +use bitfun_app_server_protocol::app::TransportLimits; +use bitfun_app_server_protocol::PROTOCOL_VERSION; +use bitfun_core::runtime_ownership::CoreRuntimeOwnership; +use bitfun_services_core::runtime_ownership::{RuntimeDeployment, RuntimeOwnershipKey}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio::task::JoinSet; +use tokio_util::codec::{Framed, LinesCodec, LinesCodecError}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const STARTUP_TIMEOUT: Duration = Duration::from_secs(45); +const AUTH_TIMEOUT: Duration = Duration::from_secs(2); +const IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_CONNECTIONS: usize = 64; +const MAX_REQUEST_BYTES: usize = 128 * 1024; +const MAX_RESPONSE_EVENT_BYTES: usize = 8 * 1024 * 1024; +const MAX_DISCOVERY_BYTES: u64 = 16 * 1024; + +struct SharedInstanceLock { + file: File, +} + +impl SharedInstanceLock { + fn try_acquire(root: &Path, identity: &RuntimeOwnershipKey) -> Result { + ensure_private_directory(root).map_err(InstanceLockError::PrepareDirectory)?; + let path = root.join(format!("{}.instance.lock", identity.as_str())); + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + configure_private_file(&mut options); + let file = options + .open(&path) + .map_err(|source| InstanceLockError::Open { path, source })?; + fs2::FileExt::try_lock_exclusive(&file).map_err(InstanceLockError::AlreadyOwned)?; + Ok(Self { file }) + } +} + +impl Drop for SharedInstanceLock { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.file); + } +} + +#[derive(Debug, thiserror::Error)] +enum InstanceLockError { + #[error("prepare Shared App Server instance directory")] + PrepareDirectory(#[source] anyhow::Error), + #[error("open Shared App Server instance lock {path}")] + Open { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("Shared App Server instance is already owned")] + AlreadyOwned(#[source] io::Error), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SharedAppServerDiscovery { + protocol_version: u32, + instance_identity: String, + endpoint: String, + process_id: u32, + token: String, + owner_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthRequest { + protocol_version: u32, + instance_identity: String, + token: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthResponse { + accepted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + message: Option, +} + +pub(crate) async fn connect_or_start(workspace: &Path) -> Result { + prepare_client_environment().await?; + let identity = instance_identity(workspace)?; + let root = shared_root()?; + let mut last_connect_error = None; + + match connect_existing(&root, &identity).await { + Ok(Some(client)) => return Ok(client), + Ok(None) => {} + Err(error) => last_connect_error = Some(error), + } + + let mut child = StartupChild::spawn(workspace, identity.as_str())?; + let mut started = Instant::now(); + let mut respawned = false; + loop { + match connect_existing(&root, &identity).await { + Ok(Some(client)) => { + child.disarm(); + return Ok(client); + } + Ok(None) => {} + Err(error) => last_connect_error = Some(error), + } + + if let Some(status) = child.try_wait().context("poll Shared App Server startup")? { + if embedded_runtime_owner_present(workspace)? { + return Err(anyhow!( + "Agent Runtime ownership failed (runtime_ownership_unavailable): an Embedded Runtime owns this workspace; close it before starting --shared ({status})" + )); + } + if shared_instance_present(&root, &identity)? { + // A concurrent Shared child may hold its instance lock before + // it acquires Runtime ownership and publishes discovery. + } else if runtime_owner_present(workspace)? { + return Err(anyhow!( + "Agent Runtime ownership failed (runtime_ownership_unavailable): another Shared Runtime deployment owns this workspace; close its clients before starting --shared ({status})" + )); + } else { + if respawned { + return Err(anyhow!( + "Shared App Server exited before becoming ready ({status})" + )); + } + child = StartupChild::spawn(workspace, identity.as_str())?; + respawned = true; + started = Instant::now(); + } + } + + if started.elapsed() >= STARTUP_TIMEOUT { + let detail = last_connect_error + .as_ref() + .map(|error| format!("; last connection error: {error:#}")) + .unwrap_or_default(); + return Err(anyhow!( + "Shared App Server did not become ready within {} seconds{detail}", + STARTUP_TIMEOUT.as_secs() + )); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +pub(crate) fn run_service(workspace: PathBuf, expected_identity: String) -> Result<()> { + // The App Server handler graph and the first dialog-turn future are deep + // enough that the default Tokio worker stack is not a reliable host for + // this service on Windows. Keep the process-level host topology explicit, + // matching the Embedded App Server's dedicated large-stack thread. + let thread = std::thread::Builder::new() + .name("bitfun-shared-app-server".to_string()) + .stack_size(16 * 1024 * 1024) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build Shared App Server Tokio runtime")?; + runtime.block_on(run_service_inner(workspace, expected_identity)) + }) + .context("start Shared App Server service thread")?; + + match thread.join() { + Ok(result) => result, + Err(_) => Err(anyhow!("Shared App Server service thread panicked")), + } +} + +async fn run_service_inner(workspace: PathBuf, expected_identity: String) -> Result<()> { + bitfun_services_core::process_manager::contain_current_process_tree() + .context("contain Shared App Server process tree")?; + prepare_client_environment().await?; + let identity = instance_identity(&workspace)?; + if identity.as_str() != expected_identity { + return Err(anyhow!( + "Shared App Server identity does not match its workspace" + )); + } + + let root = shared_root()?; + ensure_private_directory(&root)?; + let _instance_lock = SharedInstanceLock::try_acquire(&root, &identity) + .context("acquire Shared App Server instance lock")?; + remove_discovery(&root, &identity)?; + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .context("bind Shared App Server loopback endpoint")?; + + let runtime = crate::initialize_core_services_for_deployment( + &workspace, + crate::runtime::approval::CliApprovalPolicy::Ask, + crate::BootstrapProfile::Interactive, + RuntimeDeployment::Shared, + ) + .await?; + let app_runtime = BitfunAppRuntime::new( + runtime.agent_runtime().clone(), + runtime.agent_event_source(), + ) + .with_context_reload(std::sync::Arc::new(runtime.compatibility().clone())); + let management = std::sync::Arc::new( + AppManagementService::load_for_local_host(Some(runtime.account_runtime().clone())).await?, + ); + let server = BitfunAppServer::new(app_runtime) + .with_management(management) + .with_transport_limits(shared_transport_limits()); + + let owner_id = uuid::Uuid::new_v4().to_string(); + let discovery = SharedAppServerDiscovery { + protocol_version: PROTOCOL_VERSION, + instance_identity: identity.as_str().to_string(), + endpoint: listener.local_addr()?.to_string(), + process_id: std::process::id(), + token: format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ), + owner_id, + }; + write_discovery(&root, &identity, &discovery)?; + tracing::info!( + endpoint = %discovery.endpoint, + "Shared App Server is ready" + ); + + let result = serve_connections(listener, server, discovery.clone()).await; + let _ = remove_discovery_if_owned(&root, &identity, &discovery); + crate::shutdown_mcp_servers().await; + drop(runtime); + result +} + +async fn serve_connections( + listener: TcpListener, + server: BitfunAppServer, + discovery: SharedAppServerDiscovery, +) -> Result<()> { + let mut connections = JoinSet::new(); + loop { + if connections.is_empty() { + tokio::select! { + accepted = listener.accept() => { + let (stream, _) = accepted.context("accept Shared App Server connection")?; + spawn_connection(&mut connections, stream, server.clone(), discovery.clone()); + } + _ = tokio::time::sleep(IDLE_TIMEOUT) => break, + } + } else { + tokio::select! { + accepted = listener.accept() => { + let (stream, _) = accepted.context("accept Shared App Server connection")?; + if connections.len() < MAX_CONNECTIONS { + spawn_connection(&mut connections, stream, server.clone(), discovery.clone()); + } else { + tracing::warn!("Shared App Server connection limit reached"); + } + } + joined = connections.join_next() => { + if let Some(Err(error)) = joined { + tracing::warn!("Shared App Server connection task failed: {error}"); + } + } + } + } + } + connections.abort_all(); + while connections.join_next().await.is_some() {} + Ok(()) +} + +fn spawn_connection( + connections: &mut JoinSet<()>, + stream: TcpStream, + server: BitfunAppServer, + discovery: SharedAppServerDiscovery, +) { + connections.spawn(async move { + if let Err(error) = serve_connection(stream, server, &discovery).await { + tracing::warn!("Shared App Server connection ended: {error:#}"); + } + }); +} + +async fn serve_connection( + stream: TcpStream, + server: BitfunAppServer, + discovery: &SharedAppServerDiscovery, +) -> Result<()> { + stream.set_nodelay(true)?; + let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_REQUEST_BYTES)); + let auth_line = tokio::time::timeout(AUTH_TIMEOUT, framed.next()) + .await + .context("Shared App Server authentication timed out")? + .ok_or_else(|| anyhow!("Shared App Server connection closed before authentication"))??; + let auth: AuthRequest = + serde_json::from_str(&auth_line).context("decode Shared App Server authentication")?; + let accepted = auth.protocol_version == PROTOCOL_VERSION + && auth.instance_identity == discovery.instance_identity + && constant_time_eq(auth.token.as_bytes(), discovery.token.as_bytes()); + framed + .send(serde_json::to_string(&AuthResponse { + accepted, + message: (!accepted).then(|| "Shared App Server authentication failed".to_string()), + })?) + .await?; + if !accepted { + return Err(anyhow!("Shared App Server authentication failed")); + } + + let (sink, stream) = framed.split(); + let outgoing = sink + .sink_map_err(line_error) + .with(|line: String| async move { bounded_line(line, MAX_RESPONSE_EVENT_BYTES) }); + let incoming = stream.map(|result| result.map_err(line_error)); + let (incoming, disconnected) = observe_disconnect(incoming); + let serving = server + .require_session_subscriptions(true) + .serve(Lines::new(outgoing, incoming)); + tokio::pin!(serving); + tokio::select! { + biased; + result = &mut serving => result.map_err(anyhow::Error::from), + disconnected = disconnected => match disconnected { + Ok(()) => Ok(()), + Err(_) => serving.await.map_err(anyhow::Error::from), + }, + } +} + +fn observe_disconnect( + stream: S, +) -> ( + impl futures_util::Stream + Send, + oneshot::Receiver<()>, +) +where + S: futures_util::Stream + Send + 'static, + S::Item: Send + 'static, +{ + // agent-client-protocol keeps waiting for the server's foreground event + // loop after a clean EOF, so notify the Host and park until it cancels it. + let (disconnected_tx, disconnected_rx) = oneshot::channel(); + let stream = futures_util::stream::unfold( + (Box::pin(stream), Some(disconnected_tx)), + |(mut stream, mut disconnected_tx)| async move { + match stream.as_mut().next().await { + Some(item) => Some((item, (stream, disconnected_tx))), + None => { + if let Some(disconnected_tx) = disconnected_tx.take() { + let _ = disconnected_tx.send(()); + } + std::future::pending().await + } + } + }, + ); + (stream, disconnected_rx) +} + +async fn connect_existing( + root: &Path, + identity: &RuntimeOwnershipKey, +) -> Result> { + let Some(discovery) = read_discovery(root, identity)? else { + return Ok(None); + }; + if discovery.protocol_version != PROTOCOL_VERSION + || discovery.instance_identity != identity.as_str() + { + return Err(anyhow!("Shared App Server discovery is incompatible")); + } + let endpoint: SocketAddr = discovery + .endpoint + .parse() + .context("parse Shared App Server endpoint")?; + if !endpoint.ip().is_loopback() { + return Err(anyhow!("Shared App Server endpoint is not loopback")); + } + let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(endpoint)) + .await + .context("connect Shared App Server timed out")? + .context("connect Shared App Server")?; + stream.set_nodelay(true)?; + let mut framed = Framed::new( + stream, + LinesCodec::new_with_max_length(MAX_RESPONSE_EVENT_BYTES), + ); + framed + .send(serde_json::to_string(&AuthRequest { + protocol_version: PROTOCOL_VERSION, + instance_identity: identity.as_str().to_string(), + token: discovery.token, + })?) + .await?; + let response_line = tokio::time::timeout(AUTH_TIMEOUT, framed.next()) + .await + .context("Shared App Server authentication response timed out")? + .ok_or_else(|| anyhow!("Shared App Server closed during authentication"))??; + let response: AuthResponse = serde_json::from_str(&response_line) + .context("decode Shared App Server authentication response")?; + if !response.accepted { + return Err(anyhow!( + "{}", + response + .message + .unwrap_or_else(|| "Shared App Server authentication failed".to_string()) + )); + } + + let (sink, stream) = framed.split(); + let outgoing = sink + .sink_map_err(line_error) + .with(|line: String| async move { bounded_line(line, MAX_REQUEST_BYTES) }); + let incoming = stream.map(|result| result.map_err(line_error)); + bitfun_app_server_client::connect(Lines::new(outgoing, incoming)) + .await + .context("connect typed Shared App Server client") + .map(Some) +} + +fn bounded_line(line: String, maximum: usize) -> io::Result { + if line.len() > maximum { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("App Server frame exceeds {maximum} bytes"), + )) + } else { + Ok(line) + } +} + +fn shared_transport_limits() -> TransportLimits { + TransportLimits { + max_request_bytes: MAX_REQUEST_BYTES as u64, + max_response_bytes: MAX_RESPONSE_EVENT_BYTES as u64, + max_frame_bytes: MAX_REQUEST_BYTES.min(MAX_RESPONSE_EVENT_BYTES) as u64, + event_buffer_capacity: 1024, + } +} + +fn line_error(error: LinesCodecError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + difference |= usize::from( + left.get(index).copied().unwrap_or_default() + ^ right.get(index).copied().unwrap_or_default(), + ); + } + difference == 0 +} + +async fn prepare_client_environment() -> Result<()> { + crate::agent::agentic_system::select_agentic_system_profile( + bitfun_core::product_assembly::DeliveryProfile::Cli, + )?; + bitfun_core::service::config::initialize_global_config() + .await + .map_err(|error| anyhow!("Failed to initialize Shared App Server configuration: {error}")) +} + +fn instance_identity(workspace: &Path) -> Result { + RuntimeOwnershipKey::for_workspace(workspace, CoreRuntimeOwnership::distribution_identity()) + .context("resolve Shared App Server identity") +} + +fn shared_root() -> Result { + Ok(path_manager()? + .user_data_dir() + .join("agent-runtime") + .join(format!("shared-app-server-v{PROTOCOL_VERSION}"))) +} + +fn path_manager() -> Result> { + bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow!(error.to_string())) +} + +fn runtime_owner_present(workspace: &Path) -> Result { + CoreRuntimeOwnership::runtime_owner_present(path_manager()?.as_ref(), workspace) + .map_err(anyhow::Error::from) +} + +fn embedded_runtime_owner_present(workspace: &Path) -> Result { + CoreRuntimeOwnership::embedded_runtime_owner_present(path_manager()?.as_ref(), workspace) + .map_err(anyhow::Error::from) +} + +fn shared_instance_present(root: &Path, identity: &RuntimeOwnershipKey) -> Result { + match SharedInstanceLock::try_acquire(root, identity) { + Ok(lock) => { + drop(lock); + Ok(false) + } + Err(InstanceLockError::AlreadyOwned(_)) => Ok(true), + Err(error) => Err(error).context("inspect Shared App Server instance lock"), + } +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .with_context(|| format!("create Shared App Server directory {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +fn configure_private_file(options: &mut OpenOptions) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(unix))] + let _ = options; +} + +fn discovery_path(root: &Path, identity: &RuntimeOwnershipKey) -> PathBuf { + root.join(format!("{}.json", identity.as_str())) +} + +fn read_discovery( + root: &Path, + identity: &RuntimeOwnershipKey, +) -> Result> { + let path = discovery_path(root, identity); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("read Shared App Server discovery"), + }; + if bytes.len() as u64 > MAX_DISCOVERY_BYTES { + return Err(anyhow!("Shared App Server discovery is too large")); + } + let record = serde_json::from_slice::(&bytes) + .context("decode Shared App Server discovery")?; + Ok(Some(record)) +} + +fn write_discovery( + root: &Path, + identity: &RuntimeOwnershipKey, + record: &SharedAppServerDiscovery, +) -> Result<()> { + use std::io::Write as _; + + ensure_private_directory(root)?; + let bytes = serde_json::to_vec(record)?; + if bytes.len() as u64 > MAX_DISCOVERY_BYTES { + return Err(anyhow!("Shared App Server discovery is too large")); + } + let path = discovery_path(root, identity); + let mut temporary = tempfile::NamedTempFile::new_in(root)?; + temporary.write_all(&bytes)?; + temporary.as_file().sync_all()?; + temporary + .persist(&path) + .map_err(|error| error.error) + .context("publish Shared App Server discovery")?; + Ok(()) +} + +fn remove_discovery_if_owned( + root: &Path, + identity: &RuntimeOwnershipKey, + expected: &SharedAppServerDiscovery, +) -> Result<()> { + if read_discovery(root, identity)?.as_ref() == Some(expected) { + let path = discovery_path(root, identity); + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("remove Shared App Server discovery"), + } + } + Ok(()) +} + +fn remove_discovery(root: &Path, identity: &RuntimeOwnershipKey) -> Result<()> { + let path = discovery_path(root, identity); + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("remove stale Shared App Server discovery"), + } +} + +struct StartupChild { + child: Option, +} + +impl StartupChild { + fn spawn(workspace: &Path, identity: &str) -> Result { + let executable = std::env::current_exe().context("resolve BitFun executable")?; + let mut command = bitfun_services_core::process_manager::create_command(executable); + command + .arg("__shared-app-server") + .arg("--workspace") + .arg(workspace) + .arg("--instance-identity") + .arg(identity) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_detached_process(&mut command); + let child = command.spawn().context("start Shared App Server process")?; + Ok(Self { child: Some(child) }) + } + + fn try_wait(&mut self) -> io::Result> { + self.child + .as_mut() + .expect("startup child is armed") + .try_wait() + } + + fn disarm(mut self) { + self.child.take(); + } +} + +impl Drop for StartupChild { + fn drop(&mut self) { + let Some(child) = self.child.as_mut() else { + return; + }; + #[cfg(unix)] + if let Ok(process_id) = i32::try_from(child.id()) { + let _ = unsafe { libc::kill(-process_id, libc::SIGKILL) }; + } + let _ = child.kill(); + let _ = child.wait(); + } +} + +fn configure_detached_process(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + } + #[cfg(windows)] + let _ = command; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authentication_comparison_rejects_length_and_value_mismatches() { + assert!(constant_time_eq(b"same-token", b"same-token")); + assert!(!constant_time_eq(b"same-token", b"other-token")); + assert!(!constant_time_eq(b"same-token", b"same-token-longer")); + } + + #[test] + fn bounded_lines_reject_oversized_frames() { + assert!(bounded_line("ok".to_string(), 2).is_ok()); + assert!(bounded_line("too-large".to_string(), 2).is_err()); + } + + #[test] + fn advertised_transport_limits_match_shared_framing() { + let limits = shared_transport_limits(); + assert_eq!(limits.max_request_bytes, MAX_REQUEST_BYTES as u64); + assert_eq!(limits.max_response_bytes, MAX_RESPONSE_EVENT_BYTES as u64); + assert_eq!( + limits.max_frame_bytes, + MAX_REQUEST_BYTES.min(MAX_RESPONSE_EVENT_BYTES) as u64 + ); + } + + #[tokio::test] + async fn observed_stream_reports_disconnect_after_eof() { + let (stream, disconnected) = observe_disconnect(futures_util::stream::iter(["request"])); + tokio::pin!(stream); + + assert_eq!(stream.next().await, Some("request")); + tokio::time::timeout(Duration::from_millis(100), stream.next()) + .await + .expect_err("observed stream should park after reporting EOF"); + tokio::time::timeout(Duration::from_secs(1), disconnected) + .await + .expect("EOF should report the disconnected client") + .expect("disconnect observer should remain alive through EOF"); + } + + #[tokio::test] + async fn observed_stream_does_not_report_disconnect_while_open() { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let source = futures_util::stream::unfold(receiver, |mut receiver| async move { + receiver.recv().await.map(|item| (item, receiver)) + }); + let (stream, mut disconnected) = observe_disconnect(source); + tokio::pin!(stream); + + sender.send("request").expect("source should stay open"); + assert_eq!(stream.next().await, Some("request")); + assert_eq!( + disconnected.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + + drop(sender); + tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .expect_err("observed stream should park after reporting EOF"); + disconnected + .await + .expect("dropping the source should report the disconnected client"); + } +} diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs deleted file mode 100644 index 3c2cad6a38..0000000000 --- a/src/apps/cli/src/shared_runtime.rs +++ /dev/null @@ -1,1939 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use async_trait::async_trait; -use bitfun_agent_runtime::sdk::{ - AgentRuntime, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest, - AgentSessionForkRequest, AgentSessionRenameRequest, AgentSessionRestoreRequest, - AgentUserAnswersRequest, DialogSubmitOutcome, PermissionRequest, PermissionRequestEvent, - PortErrorKind, RuntimeError, SessionTranscriptRequest, -}; -use bitfun_agent_runtime_ipc::{ - DiscoveryStore, RuntimeAgentModeSummary, RuntimeInstanceIdentity, RuntimeIpcClient, - RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, - RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, - RuntimeIpcStreamInvalidationReason, RuntimeSessionProcessingPhase, RuntimeSessionRenameRequest, - RuntimeSessionState, PROTOCOL_VERSION, -}; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::runtime_ownership::CoreRuntimeOwnership; -use bitfun_events::{AgenticEvent, ToolEventData}; -use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest}; -use bitfun_services_core::runtime_ownership::RuntimeDeployment; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use tokio::sync::{broadcast, watch, Notify}; - -const RELEASE_CHANNEL: &str = "stable"; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); -const STARTUP_TIMEOUT: Duration = Duration::from_secs(45); -const IDLE_TIMEOUT: Duration = Duration::from_secs(30); -const SERVER_OPERATION_TIMEOUT: Duration = Duration::from_secs(120); -const CLIENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(125); -const EVENT_BUFFER: usize = 256; -const SUBAGENT_ROUTE_TIMEOUT: Duration = Duration::from_secs(2); -type SessionEventSenders = Mutex>>; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct SubagentRoute { - root_session_id: String, - root_turn_id: String, - root_tool_call_id: String, - source_turn_id: String, -} - -type SubagentRoutes = Mutex>; - -pub(crate) struct SharedRuntimeHandler { - runtime: AgentRuntime, - compatibility: CoreAgentRuntimeCompatibility, - workspace: PathBuf, - events: Arc, - question_sessions: Arc>>, - subagent_routes: Arc, - event_stream_available: watch::Sender, -} - -impl SharedRuntimeHandler { - pub(crate) fn build( - runtime: AgentRuntime, - compatibility: CoreAgentRuntimeCompatibility, - workspace: &Path, - ) -> Result { - let mut agent_events = runtime - .subscribe_events() - .map_err(runtime_error_message) - .context("subscribe Shared Runtime agent events")?; - let mut permission_events = runtime - .subscribe_permission_requests() - .map_err(runtime_error_message) - .context("subscribe Shared Runtime permission events")?; - let events = Arc::new(Mutex::new(HashMap::new())); - let permission_sessions = Arc::new(Mutex::new(HashMap::new())); - let question_sessions = Arc::new(Mutex::new(HashMap::new())); - let subagent_routes = Arc::new(SubagentRoutes::new(HashMap::new())); - let route_updates = Arc::new(Notify::new()); - let (event_stream_available, _) = watch::channel(true); - - let agent_output = events.clone(); - let agent_questions = question_sessions.clone(); - let agent_routes = subagent_routes.clone(); - let agent_route_updates = route_updates.clone(); - let agent_stream_available = event_stream_available.clone(); - tokio::spawn(async move { - loop { - match agent_events.recv().await { - Ok(mut envelope) => { - let Some(source_session_id) = - envelope.event.session_id().map(ToOwned::to_owned) - else { - continue; - }; - let (session_id, routed_turn_id, routed_tool_call_id) = - route_agent_event(&envelope.event, &source_session_id, &agent_routes); - project_subagent_link_route( - &mut envelope.event, - &session_id, - routed_turn_id.as_deref(), - routed_tool_call_id.as_deref(), - ); - project_user_question_route( - &mut envelope.event, - &session_id, - routed_turn_id.as_deref(), - ); - if matches!(envelope.event, AgenticEvent::SubagentSessionLinked { .. }) { - agent_route_updates.notify_waiters(); - } - index_user_question(&envelope.event, &session_id, &agent_questions); - publish_event( - &agent_output, - &session_id, - RuntimeIpcEvent::Agent { - session_id: session_id.clone(), - envelope, - }, - ); - } - Err(broadcast::error::RecvError::Lagged(_)) => { - invalidate_event_stream( - &agent_stream_available, - &agent_output, - RuntimeIpcStreamInvalidationReason::Lagged, - ); - break; - } - Err(broadcast::error::RecvError::Closed) => { - invalidate_event_stream( - &agent_stream_available, - &agent_output, - RuntimeIpcStreamInvalidationReason::Closed, - ); - break; - } - } - } - }); - - let permission_output = events.clone(); - let permission_index = permission_sessions.clone(); - let permission_routes = subagent_routes.clone(); - let permission_route_updates = route_updates.clone(); - let permission_stream_available = event_stream_available.clone(); - tokio::spawn(async move { - loop { - let event = match permission_events.recv().await { - Ok(event) => event, - Err(broadcast::error::RecvError::Lagged(_)) => { - invalidate_event_stream( - &permission_stream_available, - &permission_output, - RuntimeIpcStreamInvalidationReason::Lagged, - ); - break; - } - Err(broadcast::error::RecvError::Closed) => { - invalidate_event_stream( - &permission_stream_available, - &permission_output, - RuntimeIpcStreamInvalidationReason::Closed, - ); - break; - } - }; - if let PermissionRequestEvent::Asked { request } = &event { - if !await_permission_route( - request, - &permission_routes, - &permission_route_updates, - ) - .await - { - invalidate_event_stream( - &permission_stream_available, - &permission_output, - RuntimeIpcStreamInvalidationReason::Closed, - ); - break; - } - } - let session_id = - permission_event_session(&event, &permission_index, &permission_routes); - if let Some(session_id) = session_id { - publish_event( - &permission_output, - &session_id, - RuntimeIpcEvent::Permission { - session_id: session_id.clone(), - event, - }, - ); - } - } - }); - - Ok(Self { - runtime, - compatibility, - workspace: dunce::canonicalize(workspace) - .context("canonicalize Shared Runtime workspace")?, - events, - question_sessions, - subagent_routes, - event_stream_available, - }) - } -} - -#[async_trait] -impl RuntimeIpcRequestHandler for SharedRuntimeHandler { - fn ensure_available(&self) -> std::result::Result<(), RuntimeIpcError> { - (*self.event_stream_available.borrow()) - .then_some(()) - .ok_or_else(event_stream_unavailable_error) - } - - fn subscribe_availability(&self) -> Option> { - Some(self.event_stream_available.subscribe()) - } - - async fn execute( - &self, - operation: RuntimeIpcOperation, - ) -> std::result::Result { - self.validate_workspace(&operation)?; - match operation { - RuntimeIpcOperation::Health => unreachable!("Health is owned by the IPC server"), - RuntimeIpcOperation::ListAgentModes { session_id } => { - let workspace = match session_id { - Some(session_id) => PathBuf::from( - self.session_workspace_binding(&session_id) - .await? - .workspace_path, - ), - None => self.workspace.clone(), - }; - if let Err(error) = - bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( - &workspace, - )) - .await - { - tracing::warn!( - "Failed to initialize external agent sources for Shared TUI mode catalog: {}", - error - ); - } - let registry = bitfun_core::agentic::agents::get_agent_registry(); - let modes = registry - .get_modes_info_for_workspace(Some(&workspace), true) - .await - .into_iter() - .map(|mode| RuntimeAgentModeSummary { - id: mode.id, - description: mode.description, - model_id: mode.model, - is_external: mode.source - == bitfun_core::agentic::agents::AgentSource::External, - }) - .collect(); - Ok(RuntimeIpcOperationResult::AgentModes { modes }) - } - RuntimeIpcOperation::ListSessions { request } => self - .runtime - .list_sessions(request) - .await - .map(|sessions| RuntimeIpcOperationResult::Sessions { sessions }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::CreateSession { request } => self - .runtime - .create_session(request) - .await - .map(|session| RuntimeIpcOperationResult::SessionCreated { session }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::RestoreSession { request } => { - let restored = self - .runtime - .restore_session(AgentSessionRestoreRequest { - workspace_path: request.workspace_path, - session_id: request.session_id, - include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(runtime_ipc_error)?; - let transcript = self - .runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: restored.session.session_id.clone(), - turn_id: None, - }) - .await - .map_err(runtime_ipc_error)?; - let pending_permissions = self - .runtime - .pending_permission_requests() - .map_err(runtime_ipc_error)? - .into_iter() - .filter(|request| { - permission_targets_session( - request, - &restored.session.session_id, - &self.subagent_routes, - ) - }) - .collect(); - let workspace_binding = self - .session_workspace_binding(&restored.session.session_id) - .await?; - Ok(RuntimeIpcOperationResult::SessionRestored { - session: restored.session, - state: runtime_session_state(restored.state), - workspace_binding, - transcript, - pending_permissions, - }) - } - RuntimeIpcOperation::ForkSession { request } => { - let workspace_path = self.workspace.to_string_lossy().into_owned(); - let forked = match request.before_turn_id { - Some(source_turn_id) => { - self.runtime - .fork_session_before_turn(AgentSessionForkBeforeTurnRequest { - workspace_path: workspace_path.clone(), - source_session_id: request.session_id, - source_turn_id, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - } - None => { - self.runtime - .fork_session(AgentSessionForkRequest { - workspace_path: workspace_path.clone(), - source_session_id: request.session_id, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - } - } - .map_err(runtime_ipc_error)?; - let restored = self - .runtime - .restore_session(AgentSessionRestoreRequest { - workspace_path, - session_id: forked.session_id.clone(), - include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(runtime_ipc_error)?; - let transcript = self - .runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: forked.session_id, - turn_id: None, - }) - .await - .map_err(runtime_ipc_error)?; - let workspace_binding = self - .session_workspace_binding(&restored.session.session_id) - .await?; - Ok(RuntimeIpcOperationResult::SessionForked { - session: restored.session, - workspace_binding, - transcript, - }) - } - RuntimeIpcOperation::DeleteSession { session_id } => { - delete_owned_session(&self.runtime, &self.workspace, session_id).await?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::UpdateSessionMode { request } => { - self.runtime - .update_session_mode(request) - .await - .map_err(runtime_ipc_error)?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::UpdateSessionModel { request } => { - self.runtime - .update_session_model(request) - .await - .map_err(runtime_ipc_error)?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::RenameSession { request } => { - rename_owned_session(&self.runtime, &self.workspace, request).await?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::ReloadSessionContext { request } => { - self.compatibility - .reload_session_context(request) - .await - .map_err(core_ipc_error)?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::CompactSession { request } => self - .runtime - .start_session_compaction(request) - .await - .map(|result| RuntimeIpcOperationResult::TurnAccepted { - session_id: result.session_id, - turn_id: result.turn_id, - }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::UndoSession { request } => self - .runtime - .undo_session(request) - .await - .map(|revert| RuntimeIpcOperationResult::SessionReverted { revert }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::RedoSession { request } => self - .runtime - .redo_session(request) - .await - .map(|revert| RuntimeIpcOperationResult::SessionReverted { revert }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::SessionUsage { request } => self - .runtime - .generate_session_usage(request) - .await - .map(|usage| RuntimeIpcOperationResult::SessionUsage { usage }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::WaitForSettlement { request } => { - self.runtime - .wait_for_turn_settlement(request) - .await - .map_err(runtime_ipc_error)?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::SearchWorkspaceReferences { request } => self - .runtime - .search_workspace_references(request) - .await - .map(|search| RuntimeIpcOperationResult::WorkspaceReferenceSearch { search }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::WorkspaceReferencesForMessage { request } => self - .runtime - .workspace_references_for_message(request) - .await - .map(|references| RuntimeIpcOperationResult::WorkspaceReferences { references }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::GetSessionLineage { request } => self - .runtime - .get_session_lineage(request) - .await - .map(|snapshot| RuntimeIpcOperationResult::SessionLineage { snapshot }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::InspectLineageSession { request } => self - .runtime - .read_lineage_session_transcript(request) - .await - .map( - |inspection| RuntimeIpcOperationResult::LineageSessionInspection { inspection }, - ) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::CancelLineageSession { request } => self - .runtime - .cancel_lineage_session(request) - .await - .map(|cancellation| RuntimeIpcOperationResult::TurnCancelled { cancellation }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::WorkspaceDiff => self - .runtime - .workspace_diff() - .await - .map(|snapshot| RuntimeIpcOperationResult::WorkspaceDiff { snapshot }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::SubmitTurn { request } => { - let outcome = self - .runtime - .submit_dialog_turn(request) - .await - .map_err(runtime_ipc_error)?; - let (session_id, turn_id) = match outcome { - DialogSubmitOutcome::Started { - session_id, - turn_id, - } - | DialogSubmitOutcome::Queued { - session_id, - turn_id, - } => (session_id, turn_id), - }; - Ok(RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - }) - } - RuntimeIpcOperation::SteerTurn { request } => self - .runtime - .steer_dialog_turn(request) - .await - .map(|outcome| match outcome { - bitfun_agent_runtime::sdk::DialogSteerOutcome::Buffered { - session_id, - turn_id, - steering_id, - } => RuntimeIpcOperationResult::TurnSteered { - session_id, - turn_id, - steering_id, - }, - }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::RunUserShellCommand { request } => self - .runtime - .run_user_shell_command(request) - .await - .map(|result| RuntimeIpcOperationResult::TurnAccepted { - session_id: result.session_id, - turn_id: result.turn_id, - }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::CancelTurn { request } => self - .runtime - .cancel_turn(request) - .await - .map(|cancellation| RuntimeIpcOperationResult::TurnCancelled { cancellation }) - .map_err(runtime_ipc_error), - RuntimeIpcOperation::PendingPermissions { session_id } => { - let requests = self - .runtime - .pending_permission_requests() - .map_err(runtime_ipc_error)? - .into_iter() - .filter(|request| { - permission_targets_session(request, &session_id, &self.subagent_routes) - }) - .collect(); - Ok(RuntimeIpcOperationResult::PendingPermissions { requests }) - } - RuntimeIpcOperation::RespondPermission { - session_id, - request_id, - reply, - } => { - let permitted = self - .runtime - .pending_permission_requests() - .map_err(runtime_ipc_error)? - .iter() - .any(|request| { - request.request_id == request_id - && permission_targets_session( - request, - &session_id, - &self.subagent_routes, - ) - }); - if !permitted { - return Err(RuntimeIpcError { - code: RuntimeIpcErrorCode::SessionMismatch, - message: "permission request does not belong to the controlled session" - .to_string(), - }); - } - self.runtime - .respond_permission(&request_id, reply) - .await - .map_err(runtime_ipc_error)?; - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::SubmitUserAnswers { request } => { - let permitted = self - .question_sessions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(&request.tool_id) - .is_some_and(|session_id| session_id == &request.session_id); - if !permitted { - return Err(RuntimeIpcError { - code: RuntimeIpcErrorCode::SessionMismatch, - message: "user-input request does not belong to the controlled session" - .to_string(), - }); - } - self.runtime - .submit_user_answers(AgentUserAnswersRequest { - tool_id: request.tool_id.clone(), - answers: request.answers, - }) - .await - .map_err(runtime_ipc_error)?; - self.question_sessions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(&request.tool_id); - Ok(RuntimeIpcOperationResult::Unit) - } - RuntimeIpcOperation::RecordLocalCommandTurn { request } => self - .runtime - .record_completed_local_command_turn(request) - .await - .map(|record| RuntimeIpcOperationResult::LocalCommandTurnRecorded { record }) - .map_err(runtime_ipc_error), - } - } - - fn subscribe_events( - &self, - session_id: &str, - ) -> std::result::Result, RuntimeIpcError> { - subscribe_session_events(&self.events, &self.event_stream_available, session_id) - } -} - -fn runtime_session_state(state: bitfun_agent_runtime::sdk::SessionState) -> RuntimeSessionState { - use bitfun_agent_runtime::sdk::{ProcessingPhase, SessionState}; - - match state { - SessionState::Idle => RuntimeSessionState::Idle, - SessionState::Processing { - current_turn_id, - phase, - } => RuntimeSessionState::Processing { - current_turn_id, - phase: match phase { - ProcessingPhase::Starting => RuntimeSessionProcessingPhase::Starting, - ProcessingPhase::Compacting => RuntimeSessionProcessingPhase::Compacting, - ProcessingPhase::Thinking => RuntimeSessionProcessingPhase::Thinking, - ProcessingPhase::Streaming => RuntimeSessionProcessingPhase::Streaming, - ProcessingPhase::ToolCalling => RuntimeSessionProcessingPhase::ToolCalling, - ProcessingPhase::ToolConfirming => RuntimeSessionProcessingPhase::ToolConfirming, - }, - }, - SessionState::Error { error, recoverable } => { - RuntimeSessionState::Error { error, recoverable } - } - } -} - -fn owned_session_rename_request( - workspace: &Path, - request: RuntimeSessionRenameRequest, -) -> AgentSessionRenameRequest { - AgentSessionRenameRequest { - workspace_path: workspace.to_string_lossy().to_string(), - session_id: request.session_id, - session_name: request.session_name, - remote_connection_id: None, - remote_ssh_host: None, - } -} - -async fn rename_owned_session( - runtime: &AgentRuntime, - workspace: &Path, - request: RuntimeSessionRenameRequest, -) -> std::result::Result<(), RuntimeIpcError> { - runtime - .rename_session(owned_session_rename_request(workspace, request)) - .await - .map_err(runtime_ipc_error) -} - -async fn delete_owned_session( - runtime: &AgentRuntime, - workspace: &Path, - session_id: String, -) -> std::result::Result<(), RuntimeIpcError> { - runtime - .delete_session(AgentSessionDeleteRequest { - workspace_path: workspace.to_string_lossy().to_string(), - session_id, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(runtime_ipc_error) -} - -fn subscribe_session_events( - events: &SessionEventSenders, - available: &watch::Sender, - session_id: &str, -) -> std::result::Result, RuntimeIpcError> { - let mut events = events - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !*available.borrow() { - return Err(event_stream_unavailable_error()); - } - events.retain(|_, sender| sender.receiver_count() > 0); - Ok(events - .entry(session_id.to_string()) - .or_insert_with(|| broadcast::channel(EVENT_BUFFER).0) - .subscribe()) -} - -fn event_stream_unavailable_error() -> RuntimeIpcError { - RuntimeIpcError { - code: RuntimeIpcErrorCode::Unavailable, - message: "Shared Runtime event stream is unavailable; restart Shared TUI".to_string(), - } -} - -fn publish_event(events: &SessionEventSenders, session_id: &str, event: RuntimeIpcEvent) { - if let Some(sender) = events - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(session_id) - { - let _ = sender.send(event); - } -} - -fn invalidate_event_stream( - available: &watch::Sender, - events: &SessionEventSenders, - reason: RuntimeIpcStreamInvalidationReason, -) { - if available.send_replace(false) { - for sender in events - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .values() - { - let _ = sender.send(RuntimeIpcEvent::StreamInvalidated { reason }); - } - } -} - -async fn await_permission_route( - request: &PermissionRequest, - routes: &SubagentRoutes, - updates: &Notify, -) -> bool { - if request.delegation.is_none() { - return true; - } - let deadline = tokio::time::Instant::now() + SUBAGENT_ROUTE_TIMEOUT; - loop { - let updated = updates.notified(); - if routes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains_key(&request.session_id) - { - return true; - } - if tokio::time::timeout_at(deadline, updated).await.is_err() { - return false; - } - } -} - -impl SharedRuntimeHandler { - async fn session_workspace_binding( - &self, - session_id: &str, - ) -> std::result::Result { - self.runtime - .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { - session_id: session_id.to_string(), - }) - .await - .map_err(runtime_ipc_error)? - .ok_or_else(workspace_mismatch_error) - } - - fn validate_workspace( - &self, - operation: &RuntimeIpcOperation, - ) -> std::result::Result<(), RuntimeIpcError> { - let requested = match operation { - RuntimeIpcOperation::ListSessions { request } => Some(request.workspace_path.as_str()), - RuntimeIpcOperation::CreateSession { request } => Some( - request - .workspace_path - .as_deref() - .ok_or_else(workspace_mismatch_error)?, - ), - RuntimeIpcOperation::RestoreSession { request } => { - Some(request.workspace_path.as_str()) - } - RuntimeIpcOperation::SubmitTurn { request } => Some( - request - .workspace_path - .as_deref() - .ok_or_else(workspace_mismatch_error)?, - ), - RuntimeIpcOperation::UndoSession { request } - | RuntimeIpcOperation::RedoSession { request } => Some(request.workspace_path.as_str()), - RuntimeIpcOperation::GetSessionLineage { request } => { - reject_remote_lineage_scope( - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - )?; - Some(request.workspace_path.as_str()) - } - RuntimeIpcOperation::InspectLineageSession { request } => { - reject_remote_lineage_scope( - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - )?; - Some(request.workspace_path.as_str()) - } - RuntimeIpcOperation::CancelLineageSession { request } => { - reject_remote_lineage_scope( - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - )?; - Some(request.workspace_path.as_str()) - } - _ => None, - }; - let Some(requested) = requested else { - return Ok(()); - }; - let matches = dunce::canonicalize(Path::new(requested)) - .is_ok_and(|requested| requested == self.workspace); - if matches { - Ok(()) - } else { - Err(workspace_mismatch_error()) - } - } -} - -fn reject_remote_lineage_scope( - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, -) -> std::result::Result<(), RuntimeIpcError> { - if remote_connection_id.is_some() || remote_ssh_host.is_some() { - return Err(workspace_mismatch_error()); - } - Ok(()) -} - -fn workspace_mismatch_error() -> RuntimeIpcError { - RuntimeIpcError { - code: RuntimeIpcErrorCode::SessionMismatch, - message: "Shared TUI operation targets a different workspace".to_string(), - } -} - -fn route_agent_event( - event: &AgenticEvent, - source_session_id: &str, - routes: &SubagentRoutes, -) -> (String, Option, Option) { - let mut routes = routes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let AgenticEvent::SubagentSessionLinked { - session_id, - subagent_dialog_turn_id, - parent_session_id, - parent_dialog_turn_id, - parent_tool_call_id, - .. - } = event - { - let (root_session_id, root_turn_id, root_tool_call_id) = routes - .get(parent_session_id) - .map(|route| { - ( - route.root_session_id.clone(), - route.root_turn_id.clone(), - route.root_tool_call_id.clone(), - ) - }) - .unwrap_or_else(|| { - ( - parent_session_id.clone(), - parent_dialog_turn_id.clone(), - parent_tool_call_id.clone(), - ) - }); - routes.insert( - session_id.clone(), - SubagentRoute { - root_session_id, - root_turn_id, - root_tool_call_id, - source_turn_id: subagent_dialog_turn_id.clone(), - }, - ); - } - let routed = routes - .get(source_session_id) - .cloned() - .map(|route| { - ( - route.root_session_id, - Some(route.root_turn_id), - Some(route.root_tool_call_id), - ) - }) - .unwrap_or_else(|| (source_session_id.to_string(), None, None)); - if let AgenticEvent::DialogTurnCompleted { - session_id, - turn_id, - .. - } - | AgenticEvent::DialogTurnCancelled { - session_id, - turn_id, - } - | AgenticEvent::DialogTurnFailed { - session_id, - turn_id, - .. - } = event - { - // A root Turn may finish while a background descendant is still active. - // Remove only the route for the exact descendant Turn that settled; a - // stale terminal event must not erase a newer Turn's route. - if routes - .get(session_id) - .is_some_and(|route| route.source_turn_id == *turn_id) - { - routes.remove(session_id); - } - } - routed -} - -fn project_subagent_link_route( - event: &mut AgenticEvent, - routed_session_id: &str, - routed_turn_id: Option<&str>, - routed_tool_call_id: Option<&str>, -) { - let AgenticEvent::SubagentSessionLinked { - parent_session_id, - parent_dialog_turn_id, - parent_tool_call_id, - .. - } = event - else { - return; - }; - *parent_session_id = routed_session_id.to_string(); - if let Some(routed_turn_id) = routed_turn_id { - *parent_dialog_turn_id = routed_turn_id.to_string(); - } - if let Some(routed_tool_call_id) = routed_tool_call_id { - *parent_tool_call_id = routed_tool_call_id.to_string(); - } -} - -fn project_user_question_route( - event: &mut AgenticEvent, - routed_session_id: &str, - routed_turn_id: Option<&str>, -) { - let AgenticEvent::ToolEvent { - session_id, - turn_id, - tool_event, - .. - } = event - else { - return; - }; - if tool_event.effective_tool_name() == "AskUserQuestion" { - *session_id = routed_session_id.to_string(); - if let Some(routed_turn_id) = routed_turn_id { - *turn_id = routed_turn_id.to_string(); - } - } -} - -fn index_user_question( - event: &AgenticEvent, - routed_session_id: &str, - questions: &Mutex>, -) { - let AgenticEvent::ToolEvent { tool_event, .. } = event else { - return; - }; - let mut questions = questions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - match tool_event { - ToolEventData::Started { .. } if tool_event.effective_tool_name() == "AskUserQuestion" => { - questions.insert( - tool_event.tool_id().to_string(), - routed_session_id.to_string(), - ); - } - ToolEventData::Completed { .. } - | ToolEventData::Failed { .. } - | ToolEventData::Cancelled { .. } => { - questions.remove(tool_event.tool_id()); - } - _ => {} - } -} - -pub(crate) async fn run_service(workspace: PathBuf, expected_identity: String) -> Result<()> { - bitfun_services_core::process_manager::contain_current_process_tree() - .context("contain Shared Runtime process tree")?; - prepare_client_environment().await?; - let identity = instance_identity(&workspace)?; - if identity.as_str() != expected_identity { - return Err(anyhow!( - "Shared Runtime identity does not match its workspace" - )); - } - let runtime = crate::initialize_core_services_for_deployment( - &workspace, - crate::runtime::approval::CliApprovalPolicy::Ask, - crate::BootstrapProfile::Interactive, - RuntimeDeployment::Shared, - ) - .await?; - let handler = Arc::new(SharedRuntimeHandler::build( - runtime.agent_runtime().clone(), - runtime.compatibility().clone(), - &workspace, - )?); - let server = RuntimeIpcServer::bind_with_handler( - &ipc_root()?, - identity, - RuntimeIpcServerConfig { - server_version: env!("CARGO_PKG_VERSION").to_string(), - idle_timeout: IDLE_TIMEOUT, - handshake_timeout: CONNECT_TIMEOUT, - request_timeout: SERVER_OPERATION_TIMEOUT, - max_connections: 64, - }, - handler, - ) - .await - .context("bind Shared Runtime IPC")?; - let result = server.serve().await.context("serve Shared Runtime IPC"); - crate::shutdown_mcp_servers().await; - result -} - -pub(crate) async fn connect_or_start(workspace: &Path) -> Result { - prepare_client_environment().await?; - let identity = instance_identity(workspace)?; - let runtime_root = ipc_root()?; - let store = DiscoveryStore::new(&runtime_root, identity.clone()); - let client_id = uuid::Uuid::new_v4().to_string(); - let mut last_connect_error = None; - match connect_existing(&store, &runtime_root, &client_id).await { - Ok(Some(client)) => return require_interactive_tui(client), - Ok(None) => {} - Err(error) => last_connect_error = Some(error), - } - - let mut child = StartupChild::spawn(workspace, identity.as_str())?; - let mut started = Instant::now(); - let mut respawned = false; - loop { - match connect_existing(&store, &runtime_root, &client_id).await { - Ok(Some(client)) => { - let client = require_interactive_tui(client)?; - child.disarm(); - return Ok(client); - } - Ok(None) => {} - Err(error) => last_connect_error = Some(error), - } - if let Some(status) = child.try_wait().context("poll Shared Runtime startup")? { - if embedded_runtime_owner_present(workspace)? { - return Err(anyhow!( - "Agent Runtime ownership failed (runtime_ownership_unavailable): an Embedded Runtime owns this workspace; close it before starting Shared TUI ({status})" - )); - } - if runtime_owner_present(workspace)? { - // Another Shared child may still be initializing and has not - // published discovery yet. Keep connecting until the normal - // bounded startup timeout instead of mislabeling it Embedded. - } else { - if respawned { - return Err(anyhow!( - "Shared Runtime exited before becoming ready ({status})" - )); - } - child = StartupChild::spawn(workspace, identity.as_str())?; - respawned = true; - started = Instant::now(); - } - } - if started.elapsed() >= STARTUP_TIMEOUT { - let owner_guidance = if runtime_owner_present(workspace)? { - "; Agent Runtime ownership failed (runtime_ownership_unavailable): another local Runtime still owns this workspace, so close its clients and wait up to 30 seconds" - } else { - "" - }; - let connection_detail = last_connect_error - .as_ref() - .map(|error| format!("; last connection error: {error}")) - .unwrap_or_default(); - return Err(anyhow!( - "Shared Runtime did not become ready within {} seconds{owner_guidance}{connection_detail}", - STARTUP_TIMEOUT.as_secs() - )); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } -} - -fn runtime_owner_present(workspace: &Path) -> Result { - CoreRuntimeOwnership::runtime_owner_present(path_manager()?.as_ref(), workspace) - .map_err(anyhow::Error::from) -} - -fn embedded_runtime_owner_present(workspace: &Path) -> Result { - CoreRuntimeOwnership::embedded_runtime_owner_present(path_manager()?.as_ref(), workspace) - .map_err(anyhow::Error::from) -} - -fn require_interactive_tui(client: RuntimeIpcClient) -> Result { - if client.capabilities().interactive_tui { - Ok(client) - } else { - Err(anyhow!( - "local Runtime does not support Shared TUI operations" - )) - } -} - -async fn prepare_client_environment() -> Result<()> { - crate::agent::agentic_system::select_agentic_system_profile( - bitfun_core::product_assembly::DeliveryProfile::Cli, - )?; - bitfun_core::service::config::initialize_global_config() - .await - .map_err(|error| anyhow!("Failed to initialize Shared TUI configuration: {error}")) -} - -async fn connect_existing( - store: &DiscoveryStore, - runtime_root: &Path, - client_id: &str, -) -> Result> { - let Some(discovery) = store.read().context("read Shared Runtime discovery")? else { - return Ok(None); - }; - RuntimeIpcClient::connect( - runtime_root, - &discovery, - client_id, - env!("CARGO_PKG_VERSION"), - CONNECT_TIMEOUT, - CLIENT_REQUEST_TIMEOUT, - ) - .await - .context("connect existing Shared Runtime") - .map(Some) -} - -struct StartupChild { - child: Option, -} - -impl StartupChild { - fn spawn(workspace: &Path, identity: &str) -> Result { - let executable = std::env::current_exe().context("resolve BitFun executable")?; - let mut command = bitfun_services_core::process_manager::create_command(executable); - command - .arg("__shared-runtime") - .arg("--workspace") - .arg(workspace) - .arg("--instance-identity") - .arg(identity) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - configure_detached_process(&mut command); - let child = command.spawn().context("start Shared Runtime process")?; - Ok(Self { child: Some(child) }) - } - - fn try_wait(&mut self) -> std::io::Result> { - self.child - .as_mut() - .expect("startup child is armed") - .try_wait() - } - - fn disarm(mut self) { - self.child.take(); - } -} - -impl Drop for StartupChild { - fn drop(&mut self) { - let Some(child) = self.child.as_mut() else { - return; - }; - #[cfg(unix)] - if let Ok(process_id) = i32::try_from(child.id()) { - // SAFETY: the child calls setsid before exec, so its PID is the - // process-group ID owned by this startup attempt. - let _ = unsafe { libc::kill(-process_id, libc::SIGKILL) }; - } - let _ = child.kill(); - let _ = child.wait(); - } -} - -fn configure_detached_process(command: &mut Command) { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - command.pre_exec(|| { - if libc::setsid() == -1 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } - }); - } - } - #[cfg(windows)] - let _ = command; -} - -fn instance_identity(workspace: &Path) -> Result { - let user_root = path_manager()?.user_data_dir(); - RuntimeInstanceIdentity::for_workspace( - workspace, - CoreRuntimeOwnership::distribution_identity(), - RELEASE_CHANNEL, - &user_root.to_string_lossy(), - PROTOCOL_VERSION, - ) - .context("resolve Shared Runtime identity") -} - -fn ipc_root() -> Result { - Ok(path_manager()? - .user_data_dir() - .join("agent-runtime") - .join(format!("ipc-v{PROTOCOL_VERSION}"))) -} - -fn path_manager() -> Result> { - bitfun_core::infrastructure::try_get_path_manager_arc() - .map_err(|error| anyhow!(error.to_string())) -} - -fn permission_targets_session( - request: &PermissionRequest, - session_id: &str, - routes: &SubagentRoutes, -) -> bool { - permission_request_session(request, routes) == session_id -} - -fn permission_event_session( - event: &PermissionRequestEvent, - index: &Mutex>, - routes: &SubagentRoutes, -) -> Option { - let mut index = index - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - match event { - PermissionRequestEvent::Asked { request } => { - let session_id = permission_request_session(request, routes); - index.insert(request.request_id.clone(), session_id.clone()); - Some(session_id) - } - PermissionRequestEvent::Replied { request_id, .. } - | PermissionRequestEvent::Cancelled { request_id, .. } => index.remove(request_id), - } -} - -fn permission_request_session(request: &PermissionRequest, routes: &SubagentRoutes) -> String { - let routes = routes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - routes - .get(&request.session_id) - .or_else(|| { - request - .delegation - .as_ref() - .and_then(|delegation| routes.get(&delegation.parent_session_id)) - }) - .map(|route| route.root_session_id.clone()) - .or_else(|| { - request - .delegation - .as_ref() - .map(|delegation| delegation.parent_session_id.clone()) - }) - .unwrap_or_else(|| request.session_id.clone()) -} - -fn runtime_error_message(error: RuntimeError) -> anyhow::Error { - anyhow!(error.into_message()) -} - -fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError { - let code = match &error { - RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::SessionInUse => { - RuntimeIpcErrorCode::SessionInUse - } - RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::InvalidRequest => { - RuntimeIpcErrorCode::InvalidRequest - } - RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::OutcomeUnknown => { - RuntimeIpcErrorCode::OutcomeUnknown - } - RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::NotFound => { - RuntimeIpcErrorCode::NotFound - } - _ => RuntimeIpcErrorCode::Unavailable, - }; - RuntimeIpcError { - code, - message: error.into_message(), - } -} - -fn core_ipc_error(error: bitfun_core::util::errors::BitFunError) -> RuntimeIpcError { - let code = match &error { - bitfun_core::util::errors::BitFunError::Validation(_) - | bitfun_core::util::errors::BitFunError::NotFound(_) => { - RuntimeIpcErrorCode::InvalidRequest - } - bitfun_core::util::errors::BitFunError::SessionInUse { .. } => { - RuntimeIpcErrorCode::SessionInUse - } - _ => RuntimeIpcErrorCode::Unavailable, - }; - RuntimeIpcError { - code, - message: error.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::{ - await_permission_route, connect_existing, delete_owned_session, index_user_question, - invalidate_event_stream, owned_session_rename_request, permission_event_session, - permission_targets_session, project_subagent_link_route, project_user_question_route, - publish_event, reject_remote_lineage_scope, rename_owned_session, route_agent_event, - runtime_ipc_error, subscribe_session_events, SessionEventSenders, SubagentRoute, - SubagentRoutes, EVENT_BUFFER, - }; - use bitfun_agent_runtime::sdk::{ - AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionManagementPort, - AgentSessionRenameRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, - AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, - AgentSubmissionResult, PermissionDelegationContext, PermissionReplySource, - PermissionRequest, PermissionRequestEvent, PermissionRequestSource, - PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, RuntimeError, - }; - use bitfun_agent_runtime_ipc::{RuntimeIpcErrorCode, RuntimeSessionRenameRequest}; - 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 shared_lineage_scope_rejects_remote_identity() { - assert!(reject_remote_lineage_scope(None, None).is_ok()); - let error = reject_remote_lineage_scope(Some("connection-1"), None) - .expect_err("Shared Runtime must stay workspace-local"); - assert_eq!(error.code, RuntimeIpcErrorCode::SessionMismatch); - let error = reject_remote_lineage_scope(None, Some("host-1")) - .expect_err("Shared Runtime must stay workspace-local"); - assert_eq!(error.code, RuntimeIpcErrorCode::SessionMismatch); - } - - #[test] - fn shared_handler_steering_delegates_to_the_runtime_sdk() { - let source = include_str!("shared_runtime.rs").replace("\r\n", "\n"); - let execute = source - .split_once("impl RuntimeIpcRequestHandler for SharedRuntimeHandler") - .expect("shared handler") - .1 - .split_once("fn subscribe_events(") - .expect("shared handler boundary") - .0; - - assert!(execute.contains("RuntimeIpcOperation::SteerTurn { request }")); - assert!(execute.contains(".steer_dialog_turn(request)")); - assert!(execute.contains("RuntimeIpcOperationResult::TurnSteered")); - assert!(!execute.contains("submit_steering")); - } - - #[derive(Default)] - struct RecordingSessionPort { - delete_requests: Mutex>, - rename_requests: Mutex>, - } - - #[async_trait::async_trait] - impl AgentSubmissionPort for RecordingSessionPort { - async fn create_session( - &self, - request: AgentSessionCreateRequest, - ) -> PortResult { - Ok(AgentSessionCreateResult::new( - "session-1", - request.session_name, - request.agent_type, - )) - } - - async fn submit_message( - &self, - request: AgentSubmissionRequest, - ) -> PortResult { - Ok(AgentSubmissionResult { - turn_id: request.turn_id.unwrap_or_else(|| "turn-1".to_string()), - accepted: true, - }) - } - - async fn resolve_session_agent_type( - &self, - _session_id: &str, - ) -> PortResult> { - Ok(Some("agentic".to_string())) - } - } - - #[async_trait::async_trait] - impl AgentSessionManagementPort for RecordingSessionPort { - async fn list_sessions( - &self, - _request: AgentSessionListRequest, - ) -> PortResult> { - Ok(Vec::new()) - } - - async fn delete_session(&self, request: AgentSessionDeleteRequest) -> PortResult<()> { - self.delete_requests.lock().unwrap().push(request); - Ok(()) - } - - async fn rename_session(&self, request: AgentSessionRenameRequest) -> PortResult<()> { - self.rename_requests.lock().unwrap().push(request); - Ok(()) - } - - async fn resolve_session_workspace_binding( - &self, - _request: AgentSessionWorkspaceRequest, - ) -> PortResult> { - Ok(None) - } - } - - #[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 - ); - } - - #[test] - fn invalid_runtime_requests_keep_their_ipc_error_category() { - let error = runtime_ipc_error(RuntimeError::Port(PortError::new( - PortErrorKind::InvalidRequest, - "Unknown agent mode: missing", - ))); - - assert_eq!( - error.code, - bitfun_agent_runtime_ipc::RuntimeIpcErrorCode::InvalidRequest - ); - } - - #[test] - fn unknown_runtime_outcomes_keep_their_ipc_error_category() { - let error = runtime_ipc_error(RuntimeError::Port(PortError::new( - PortErrorKind::OutcomeUnknown, - "inspect authoritative state", - ))); - - assert_eq!(error.code, RuntimeIpcErrorCode::OutcomeUnknown); - } - - #[test] - fn missing_runtime_sessions_keep_their_ipc_error_category() { - let error = runtime_ipc_error(RuntimeError::Port(PortError::new( - PortErrorKind::NotFound, - "Session not found: session-1", - ))); - - assert_eq!(error.code, RuntimeIpcErrorCode::NotFound); - } - - #[test] - fn shared_rename_uses_the_server_workspace_and_no_remote_identity() { - let request = owned_session_rename_request( - std::path::Path::new("D:/workspace/project"), - RuntimeSessionRenameRequest { - session_id: "session-1".to_string(), - session_name: "Auth refactor".to_string(), - }, - ); - - assert_eq!(request.workspace_path, "D:/workspace/project"); - assert_eq!(request.session_id, "session-1"); - assert_eq!(request.session_name, "Auth refactor"); - assert!(request.remote_connection_id.is_none()); - assert!(request.remote_ssh_host.is_none()); - } - - #[tokio::test] - async fn embedded_and_shared_rename_reach_the_same_runtime_owner() { - let workspace = tempfile::tempdir().expect("workspace"); - let canonical_workspace = dunce::canonicalize(workspace.path()).expect("workspace path"); - let workspace_path = canonical_workspace.to_string_lossy().to_string(); - let port = Arc::new(RecordingSessionPort::default()); - let runtime = AgentRuntimeBuilder::new() - .with_submission_port(port.clone()) - .with_session_management_port(port.clone()) - .build() - .expect("runtime"); - let expected = AgentSessionRenameRequest { - workspace_path: workspace_path.clone(), - session_id: "session-1".to_string(), - session_name: "Auth refactor".to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }; - - runtime - .rename_session(expected.clone()) - .await - .expect("embedded rename"); - - rename_owned_session( - &runtime, - &canonical_workspace, - RuntimeSessionRenameRequest { - session_id: "session-1".to_string(), - session_name: "Auth refactor".to_string(), - }, - ) - .await - .expect("shared rename"); - - assert_eq!( - port.rename_requests.lock().unwrap().as_slice(), - &[expected.clone(), expected] - ); - } - - #[tokio::test] - async fn embedded_and_shared_delete_reach_the_same_runtime_owner() { - let workspace = tempfile::tempdir().expect("workspace"); - let canonical_workspace = dunce::canonicalize(workspace.path()).expect("workspace path"); - let workspace_path = canonical_workspace.to_string_lossy().to_string(); - let port = Arc::new(RecordingSessionPort::default()); - let runtime = AgentRuntimeBuilder::new() - .with_submission_port(port.clone()) - .with_session_management_port(port.clone()) - .build() - .expect("runtime"); - let expected = AgentSessionDeleteRequest { - workspace_path, - session_id: "session-2".to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }; - - runtime - .delete_session(expected.clone()) - .await - .expect("embedded delete"); - - delete_owned_session(&runtime, &canonical_workspace, "session-2".to_string()) - .await - .expect("shared delete"); - - assert_eq!( - port.delete_requests.lock().unwrap().as_slice(), - &[expected.clone(), expected] - ); - } - - #[tokio::test] - async fn existing_runtime_connection_errors_are_not_hidden_as_absence() { - let root = tempfile::tempdir().unwrap(); - let identity = bitfun_agent_runtime_ipc::RuntimeInstanceIdentity::for_workspace( - root.path(), - "bitfun", - "stable", - "fixture-user", - bitfun_agent_runtime_ipc::PROTOCOL_VERSION, - ) - .unwrap(); - let store = bitfun_agent_runtime_ipc::DiscoveryStore::new(root.path(), identity.clone()); - store - .write(&bitfun_agent_runtime_ipc::DiscoveryRecord::new( - identity, - "invalid-endpoint".to_string(), - 1, - "token".to_string(), - "owner".to_string(), - )) - .unwrap(); - assert!(connect_existing(&store, root.path(), "client") - .await - .is_err()); - } - - #[test] - fn exited_shared_child_reports_embedded_owner_without_waiting_for_timeout() { - let source = include_str!("shared_runtime.rs"); - let exited_child = source - .split_once("if let Some(status) = child.try_wait()") - .expect("Shared Runtime child exit branch") - .1 - .split_once("if started.elapsed() >= STARTUP_TIMEOUT") - .expect("startup timeout boundary") - .0; - - assert!(exited_child.contains("embedded_runtime_owner_present")); - assert!(exited_child.contains("runtime_ownership_unavailable")); - assert!(exited_child.contains("Embedded Runtime owns this workspace")); - assert!(exited_child.contains("return Err")); - } - - fn delegated_permission(session_id: &str, parent_session_id: &str) -> PermissionRequest { - PermissionRequest { - request_id: "permission-1".to_string(), - round_id: "round-1".to_string(), - order: 0, - tool_call_id: Some("tool-1".to_string()), - project_path: None, - project_id: "project-1".to_string(), - session_id: session_id.to_string(), - agent_id: "agentic".to_string(), - action: "run command".to_string(), - resources: Vec::new(), - save_resources: Vec::new(), - source: PermissionRequestSource { - kind: PermissionRequestSourceKind::ToolCall, - identity: "shell".to_string(), - }, - delegation: Some(PermissionDelegationContext { - parent_session_id: parent_session_id.to_string(), - parent_dialog_turn_id: Some("parent-turn".to_string()), - parent_tool_call_id: "task-1".to_string(), - subagent_type: "general".to_string(), - }), - display_metadata: serde_json::Map::new(), - } - } - - #[test] - fn unrelated_session_events_do_not_consume_a_clients_lag_budget() { - let events = SessionEventSenders::new(HashMap::new()); - let (available, _) = watch::channel(true); - let _noisy = subscribe_session_events(&events, &available, "noisy").unwrap(); - let mut quiet = subscribe_session_events(&events, &available, "quiet").unwrap(); - for _ in 0..=EVENT_BUFFER { - publish_event( - &events, - "noisy", - bitfun_agent_runtime_ipc::RuntimeIpcEvent::StreamInvalidated { - reason: bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason::Lagged, - }, - ); - } - assert!(matches!( - quiet.try_recv(), - Err(tokio::sync::broadcast::error::TryRecvError::Empty) - )); - invalidate_event_stream( - &available, - &events, - bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason::Lagged, - ); - assert!(quiet.try_recv().is_ok()); - assert!(subscribe_session_events(&events, &available, "late").is_err()); - } - - #[test] - fn descendant_routes_outlive_the_root_turn_and_end_with_each_descendant() { - let routes = Mutex::new(HashMap::new()); - let root_route = ( - "parent-session".to_string(), - Some("parent-turn".to_string()), - Some("delegate-tool".to_string()), - ); - let linked = AgenticEvent::SubagentSessionLinked { - session_id: "child-session".to_string(), - subagent_dialog_turn_id: "child-turn".to_string(), - parent_session_id: "parent-session".to_string(), - parent_dialog_turn_id: "parent-turn".to_string(), - parent_tool_call_id: "delegate-tool".to_string(), - agent_type: None, - model_id: None, - focused_review_display_label: None, - }; - assert_eq!( - route_agent_event(&linked, "child-session", &routes), - root_route - ); - - let mut nested = AgenticEvent::SubagentSessionLinked { - session_id: "grandchild-session".to_string(), - subagent_dialog_turn_id: "grandchild-turn".to_string(), - parent_session_id: "child-session".to_string(), - parent_dialog_turn_id: "child-turn".to_string(), - parent_tool_call_id: "nested-tool".to_string(), - agent_type: None, - model_id: None, - focused_review_display_label: None, - }; - let nested_route = route_agent_event(&nested, "grandchild-session", &routes); - assert_eq!(nested_route, root_route); - project_subagent_link_route( - &mut nested, - &nested_route.0, - nested_route.1.as_deref(), - nested_route.2.as_deref(), - ); - assert!(matches!( - nested, - AgenticEvent::SubagentSessionLinked { - parent_session_id, - parent_dialog_turn_id, - parent_tool_call_id, - .. - } if parent_session_id == "parent-session" - && parent_dialog_turn_id == "parent-turn" - && parent_tool_call_id == "delegate-tool" - )); - let grandchild_output = AgenticEvent::TextChunk { - session_id: "grandchild-session".to_string(), - turn_id: "grandchild-turn".to_string(), - round_id: "round-2".to_string(), - attempt_id: None, - attempt_index: None, - text: "nested output".to_string(), - }; - assert_eq!( - route_agent_event(&grandchild_output, "grandchild-session", &routes), - root_route - ); - - let completed = AgenticEvent::DialogTurnCompleted { - session_id: "parent-session".to_string(), - turn_id: "parent-turn".to_string(), - total_rounds: 1, - total_tools: 1, - duration_ms: 1, - partial_recovery_reason: None, - success: Some(true), - finish_reason: None, - has_final_response: Some(true), - }; - route_agent_event(&completed, "parent-session", &routes); - assert_eq!(routes.lock().expect("routes").len(), 2); - assert_eq!( - route_agent_event(&grandchild_output, "grandchild-session", &routes), - root_route - ); - - let child_completed = AgenticEvent::DialogTurnCancelled { - session_id: "child-session".to_string(), - turn_id: "child-turn".to_string(), - }; - route_agent_event(&child_completed, "child-session", &routes); - assert_eq!(routes.lock().expect("routes").len(), 1); - - let grandchild_completed = AgenticEvent::DialogTurnCancelled { - session_id: "grandchild-session".to_string(), - turn_id: "grandchild-turn".to_string(), - }; - route_agent_event(&grandchild_completed, "grandchild-session", &routes); - assert!(routes.lock().expect("routes").is_empty()); - } - - #[test] - fn stale_descendant_terminal_does_not_remove_a_new_turn_route() { - let routes = SubagentRoutes::new(HashMap::new()); - for turn_id in ["child-turn-old", "child-turn-new"] { - let linked = AgenticEvent::SubagentSessionLinked { - session_id: "child-session".to_string(), - subagent_dialog_turn_id: turn_id.to_string(), - parent_session_id: "root-session".to_string(), - parent_dialog_turn_id: "root-turn".to_string(), - parent_tool_call_id: "root-tool".to_string(), - agent_type: None, - model_id: None, - focused_review_display_label: None, - }; - route_agent_event(&linked, "child-session", &routes); - } - - let stale = AgenticEvent::DialogTurnCancelled { - session_id: "child-session".to_string(), - turn_id: "child-turn-old".to_string(), - }; - route_agent_event(&stale, "child-session", &routes); - - assert_eq!( - routes - .lock() - .expect("routes") - .get("child-session") - .map(|route| route.source_turn_id.as_str()), - Some("child-turn-new") - ); - } - - #[test] - fn nested_subagent_permissions_route_to_the_root_controller() { - let root_route = ( - "root-session".to_string(), - "root-turn".to_string(), - "root-tool".to_string(), - ); - let routes = SubagentRoutes::new(HashMap::from([ - ( - "child-session".to_string(), - SubagentRoute { - root_session_id: root_route.0.clone(), - root_turn_id: root_route.1.clone(), - root_tool_call_id: root_route.2.clone(), - source_turn_id: "child-turn".to_string(), - }, - ), - ( - "nested-session".to_string(), - SubagentRoute { - root_session_id: root_route.0, - root_turn_id: root_route.1, - root_tool_call_id: root_route.2, - source_turn_id: "nested-turn".to_string(), - }, - ), - ])); - let index = Mutex::new(HashMap::new()); - let request = delegated_permission("nested-session", "child-session"); - - assert!(permission_targets_session( - &request, - "root-session", - &routes - )); - let events = [ - PermissionRequestEvent::Asked { - request: request.clone(), - }, - PermissionRequestEvent::Replied { - request_id: request.request_id, - reply: bitfun_agent_runtime::sdk::PermissionReply::Once, - source: PermissionReplySource::User, - }, - ]; - for event in events { - assert_eq!( - permission_event_session(&event, &index, &routes).as_deref(), - Some("root-session") - ); - } - } - - #[tokio::test] - async fn delegated_permission_waits_for_its_authoritative_subagent_route() { - let routes = Arc::new(SubagentRoutes::new(HashMap::new())); - let updates = Arc::new(Notify::new()); - let request = delegated_permission("child-session", "root-session"); - let waiting_routes = routes.clone(); - let waiting_updates = updates.clone(); - let waiting_request = request.clone(); - let waiting = tokio::spawn(async move { - await_permission_route(&waiting_request, &waiting_routes, &waiting_updates).await - }); - - tokio::time::sleep(Duration::from_millis(10)).await; - routes.lock().expect("routes").insert( - "child-session".to_string(), - SubagentRoute { - root_session_id: "root-session".to_string(), - root_turn_id: "root-turn".to_string(), - root_tool_call_id: "root-tool".to_string(), - source_turn_id: "child-turn".to_string(), - }, - ); - updates.notify_waiters(); - - assert!(waiting.await.expect("route waiter")); - assert!(permission_targets_session( - &request, - "root-session", - &routes - )); - } - - #[test] - fn user_question_answers_remain_scoped_to_the_routed_parent_session() { - let questions = Mutex::new(HashMap::new()); - let mut started = AgenticEvent::ToolEvent { - session_id: "child-session".to_string(), - turn_id: "child-turn".to_string(), - round_id: "round-1".to_string(), - attempt_id: None, - attempt_index: None, - tool_event: ToolEventData::Started { - identity: ToolEventIdentity::direct("question-1", "AskUserQuestion"), - params: serde_json::json!({}), - timeout_seconds: None, - }, - }; - project_user_question_route(&mut started, "parent-session", Some("parent-turn")); - assert!(matches!( - &started, - AgenticEvent::ToolEvent { session_id, turn_id, .. } - if session_id == "parent-session" && turn_id == "parent-turn" - )); - index_user_question(&started, "parent-session", &questions); - assert_eq!( - questions - .lock() - .expect("question index") - .get("question-1") - .map(String::as_str), - Some("parent-session") - ); - } -} diff --git a/src/apps/cli/src/shared_tui_backend.rs b/src/apps/cli/src/shared_tui_backend.rs deleted file mode 100644 index fc7061cf86..0000000000 --- a/src/apps/cli/src/shared_tui_backend.rs +++ /dev/null @@ -1,1608 +0,0 @@ -//! CLI Host compatibility adapter from the private Shared Runtime IPC to TUI v2. - -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use crate::tui_backend::{TuiBackend, TuiBackendError, TuiBackendErrorKind}; -use async_trait::async_trait; -use bitfun_agent_runtime_ipc::{ - RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, - RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcStreamInvalidationReason, RuntimeSessionForkRequest, RuntimeSessionProcessingPhase, - RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, RuntimeSessionState, - RuntimeUserAnswersRequest, -}; -use bitfun_app_server::management::{ - ACCOUNT_CAPABILITY, EXTERNAL_HOOKS_CAPABILITY, EXTERNAL_SOURCES_CAPABILITY, MODES_CAPABILITY, - NATIVE_HOOKS_CAPABILITY, SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, -}; -use bitfun_app_server::{ - AppManagementCapabilities, AppManagementError, AppManagementErrorKind, AppManagementService, -}; -use bitfun_app_server_client::AppServerEvent; -use bitfun_app_server_protocol::account::*; -use bitfun_app_server_protocol::agent::*; -use bitfun_app_server_protocol::app::{ - CapabilityAvailability, CapabilityDescriptor, HealthResponse, HealthStatus, InitializeRequest, - InitializeResponse, ServerInfo, TransportLimits, -}; -use bitfun_app_server_protocol::event::{ - AgentEventNotification, EventCursor, EventStream, EventStreamState, - EventStreamStateNotification, PermissionEventNotification, ResyncDirective, -}; -use bitfun_app_server_protocol::external_source::*; -use bitfun_app_server_protocol::hook::*; -use bitfun_app_server_protocol::mcp::*; -use bitfun_app_server_protocol::model::*; -use bitfun_app_server_protocol::session::*; -use bitfun_app_server_protocol::skill::*; -use bitfun_app_server_protocol::subagent::*; -use bitfun_app_server_protocol::workspace::*; -use bitfun_app_server_protocol::worktree::*; -use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; -use bitfun_runtime_ports::{ - AgentSessionCompactionResult, AgentSessionForkResult, AgentSessionWorkspaceBinding, - AgentUserShellCommandResult, -}; -use tokio::sync::broadcast; - -const EVENT_BUFFER: usize = 256; - -pub(crate) struct SharedTuiBackend { - client: RuntimeIpcClient, - management: Arc, - local_management_scope: Arc, - current_session_id: Arc>>, - events: broadcast::Sender, -} - -impl SharedTuiBackend { - pub(crate) fn new(client: RuntimeIpcClient, management: Arc) -> Self { - let (events, _) = broadcast::channel(EVENT_BUFFER); - let connection_id = format!("shared-runtime-{}", uuid::Uuid::new_v4()); - spawn_event_bridge( - client.subscribe_events(), - events.clone(), - connection_id.clone(), - ); - spawn_external_source_event_bridge( - management.subscribe_external_source_updates(), - events.clone(), - connection_id, - ); - Self { - client, - management, - local_management_scope: Arc::new(AtomicBool::new(true)), - current_session_id: Arc::new(Mutex::new(None)), - events, - } - } - - fn set_current_session(&self, session_id: impl Into) { - *self - .current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(session_id.into()); - } - - fn set_management_scope_from_binding(&self, binding: &AgentSessionWorkspaceBinding) { - self.local_management_scope.store( - binding.remote_connection_id.is_none() && binding.remote_ssh_host.is_none(), - Ordering::Relaxed, - ); - } - - fn current_session(&self) -> Result { - self.current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - .ok_or_else(|| backend_error("Shared TUI has no attached Session", false)) - } - - async fn request( - &self, - operation: RuntimeIpcOperation, - ) -> Result { - self.client - .request(operation) - .await - .map_err(map_client_error) - } - - fn management_service( - &self, - capability: &str, - ) -> Result<&AppManagementService, TuiBackendError> { - require_local_management_scope( - self.local_management_scope.load(Ordering::Relaxed), - capability, - )?; - match self.management.capabilities().availability(capability) { - Some(CapabilityAvailability::Available) => Ok(self.management.as_ref()), - Some(CapabilityAvailability::Unavailable { reason }) => Err(TuiBackendError { - message: reason.clone(), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - }), - None => Err(TuiBackendError { - message: format!( - "{capability} is not declared by the App Server management service" - ), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - }), - } - } -} - -fn require_local_management_scope(local: bool, capability: &str) -> Result<(), TuiBackendError> { - if local { - return Ok(()); - } - Err(TuiBackendError { - message: format!( - "{capability} is unavailable for a Remote workspace; the Shared CLI adapter does not fall back to its local management service" - ), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - }) -} - -#[async_trait] -impl TuiBackend for SharedTuiBackend { - async fn initialize( - &self, - request: InitializeRequest, - ) -> Result { - if request.protocol_version < MIN_PROTOCOL_VERSION - || request.protocol_version > PROTOCOL_VERSION - { - return Err(backend_error( - format!( - "Unsupported TUI protocol {}, expected {}", - request.protocol_version, PROTOCOL_VERSION - ), - false, - )); - } - if !self.client.capabilities().interactive_tui { - return Err(backend_error( - "Shared Runtime does not advertise interactive TUI support", - false, - )); - } - Ok(InitializeResponse::new( - ServerInfo { - name: "bitfun-shared-runtime-host-adapter".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - tui_capabilities(&self.management.capabilities()), - TransportLimits { - max_frame_bytes: 16 * 1024 * 1024, - event_buffer_capacity: EVENT_BUFFER as u32, - }, - )) - } - - async fn model_catalog(&self) -> Result { - load_model_catalog().await - } - - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_snapshot(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_login(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_finalize_login(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_logout(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_start(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_snapshot(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_cancel(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_local_changed(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn worktree_repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> Result { - self.management_service(WORKTREES_CAPABILITY)? - .worktree_repository_status(request) - .await - .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) - } - - async fn worktree_bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> Result { - self.management_service(WORKTREES_CAPABILITY)? - .worktree_bind_session(request) - .await - .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) - } - - async fn worktree_release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> Result { - self.management_service(WORKTREES_CAPABILITY)? - .worktree_release_session(request) - .await - .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) - } - - async fn health(&self) -> Result { - self.client.health().await.map_err(map_client_error)?; - Ok(HealthResponse { - status: HealthStatus::Ready, - protocol_version: PROTOCOL_VERSION, - }) - } - - fn subscribe_events(&self) -> broadcast::Receiver { - self.events.subscribe() - } - - async fn list_sessions( - &self, - request: ListSessionsRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::ListSessions { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::Sessions { sessions } => { - Ok(ListSessionsResponse { sessions }) - } - other => Err(unexpected("list_sessions", other)), - } - } - - async fn sync_session( - &self, - request: SyncSessionRequest, - ) -> Result { - let requested_session_id = request.session_id.clone(); - match self - .request(RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: request.workspace_path, - session_id: requested_session_id.clone(), - }, - }) - .await? - { - RuntimeIpcOperationResult::SessionRestored { - session, - state, - workspace_binding, - transcript, - pending_permissions, - } => { - self.set_current_session(requested_session_id); - self.set_management_scope_from_binding(&workspace_binding); - Ok(SyncSessionResponse { - session, - state: map_session_state(state), - transcript, - workspace_binding, - pending_permissions, - }) - } - other => Err(unexpected("sync_session", other)), - } - } - - async fn create_session( - &self, - request: CreateSessionRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CreateSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::SessionCreated { session } => { - self.set_current_session(session.session_id.clone()); - self.local_management_scope.store(true, Ordering::Relaxed); - Ok(CreateSessionResponse(session)) - } - other => Err(unexpected("create_session", other)), - } - } - - async fn delete_session( - &self, - request: DeleteSessionRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::DeleteSession { - session_id: request.0.session_id, - }) - .await?, - "delete_session", - )?; - Ok(DeleteSessionResponse {}) - } - - async fn rename_session( - &self, - request: RenameSessionRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: request.0.session_id, - session_name: request.0.session_name, - }, - }) - .await?, - "rename_session", - )?; - Ok(RenameSessionResponse {}) - } - - async fn submit_dialog_turn( - &self, - request: SubmitDialogTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SubmitTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - } => Ok(SubmitDialogTurnResponse::Started { - session_id, - turn_id, - }), - other => Err(unexpected("submit_dialog_turn", other)), - } - } - - async fn cancel_turn( - &self, - request: CancelTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CancelTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnCancelled { cancellation } => { - Ok(CancelTurnResponse(cancellation)) - } - other => Err(unexpected("cancel_turn", other)), - } - } - - async fn steer_turn( - &self, - request: SteerTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SteerTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnSteered { steering_id, .. } => { - Ok(SteerTurnResponse { steering_id }) - } - other => Err(unexpected("steer_turn", other)), - } - } - - async fn run_user_shell_command( - &self, - request: RunUserShellCommandRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::RunUserShellCommand { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - } => Ok(RunUserShellCommandResponse(AgentUserShellCommandResult { - session_id, - turn_id, - })), - other => Err(unexpected("run_user_shell_command", other)), - } - } - - async fn submit_user_answers( - &self, - request: SubmitUserAnswersRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::SubmitUserAnswers { - request: RuntimeUserAnswersRequest { - session_id: self.current_session()?, - tool_id: request.tool_id, - answers: request.answers, - }, - }) - .await?, - "submit_user_answers", - )?; - Ok(SubmitUserAnswersResponse {}) - } - - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::RecordLocalCommandTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::LocalCommandTurnRecorded { record } => { - Ok(RecordLocalCommandTurnResponse(record)) - } - other => Err(unexpected("record_local_command_turn", other)), - } - } - - async fn respond_permission( - &self, - request: RespondPermissionRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::RespondPermission { - session_id: self.current_session()?, - request_id: request.request_id, - reply: request.reply, - }) - .await?, - "respond_permission", - )?; - Ok(RespondPermissionResponse {}) - } - - async fn pending_permissions(&self) -> Result { - let Some(session_id) = self - .current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - else { - return Ok(PendingPermissionsResponse { - requests: Vec::new(), - }); - }; - match self - .request(RuntimeIpcOperation::PendingPermissions { session_id }) - .await? - { - RuntimeIpcOperationResult::PendingPermissions { requests } => { - Ok(PendingPermissionsResponse { requests }) - } - other => Err(unexpected("pending_permissions", other)), - } - } - - async fn compact_session( - &self, - request: CompactSessionRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CompactSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - } => Ok(CompactSessionResponse(AgentSessionCompactionResult { - session_id, - turn_id, - })), - other => Err(unexpected("compact_session", other)), - } - } - - async fn undo_session( - &self, - request: UndoSessionRequest, - ) -> Result { - map_revert( - self.request(RuntimeIpcOperation::UndoSession { request: request.0 }) - .await?, - "undo_session", - ) - } - - async fn redo_session( - &self, - request: RedoSessionRequest, - ) -> Result { - map_revert( - self.request(RuntimeIpcOperation::RedoSession { request: request.0 }) - .await?, - "redo_session", - ) - } - - async fn reload_context( - &self, - request: ReloadContextRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::ReloadSessionContext { request: request.0 }) - .await?, - "reload_context", - )?; - Ok(ReloadContextResponse {}) - } - - async fn session_usage( - &self, - request: SessionUsageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SessionUsage { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::SessionUsage { usage } => Ok(SessionUsageResponse(usage)), - other => Err(unexpected("session_usage", other)), - } - } - - async fn wait_for_settlement( - &self, - request: WaitForSettlementRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::WaitForSettlement { request: request.0 }) - .await?, - "wait_for_settlement", - )?; - Ok(WaitForSettlementResponse {}) - } - - async fn workspace_diff(&self) -> Result { - match self.request(RuntimeIpcOperation::WorkspaceDiff).await? { - RuntimeIpcOperationResult::WorkspaceDiff { snapshot } => { - Ok(WorkspaceDiffResponse(snapshot)) - } - other => Err(unexpected("workspace_diff", other)), - } - } - - async fn search_workspace_references( - &self, - request: SearchWorkspaceReferencesRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SearchWorkspaceReferences { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::WorkspaceReferenceSearch { search } => { - Ok(SearchWorkspaceReferencesResponse(search)) - } - other => Err(unexpected("search_workspace_references", other)), - } - } - - async fn message_references( - &self, - request: MessageReferencesRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::WorkspaceReferencesForMessage { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::WorkspaceReferences { references } => { - Ok(MessageReferencesResponse(references)) - } - other => Err(unexpected("message_references", other)), - } - } - - async fn session_lineage( - &self, - request: SessionLineageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::GetSessionLineage { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::SessionLineage { snapshot } => { - Ok(SessionLineageResponse(snapshot)) - } - other => Err(unexpected("session_lineage", other)), - } - } - - async fn inspect_lineage( - &self, - request: InspectLineageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::InspectLineageSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::LineageSessionInspection { inspection } => { - Ok(InspectLineageResponse(inspection)) - } - other => Err(unexpected("inspect_lineage", other)), - } - } - - async fn cancel_lineage( - &self, - request: CancelLineageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CancelLineageSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnCancelled { cancellation } => { - Ok(CancelLineageResponse(cancellation)) - } - other => Err(unexpected("cancel_lineage", other)), - } - } - - async fn fork_session( - &self, - request: ForkSessionRequest, - ) -> Result { - self.fork(request.0.source_session_id, None).await - } - - async fn fork_session_before_turn( - &self, - request: ForkSessionBeforeTurnRequest, - ) -> Result { - self.fork(request.0.source_session_id, Some(request.0.source_turn_id)) - .await - } - - async fn update_session_model( - &self, - request: UpdateSessionModelRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::UpdateSessionModel { request: request.0 }) - .await?, - "update_session_model", - )?; - Ok(UpdateSessionModelResponse {}) - } - - async fn update_session_mode( - &self, - request: UpdateSessionModeRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::UpdateSessionMode { request: request.0 }) - .await?, - "update_session_mode", - )?; - Ok(UpdateSessionModeResponse {}) - } - - async fn list_agent_modes( - &self, - _request: ListAgentModesRequest, - ) -> Result { - let session_id = self - .current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - match self - .request(RuntimeIpcOperation::ListAgentModes { session_id }) - .await? - { - RuntimeIpcOperationResult::AgentModes { modes } => Ok(ListAgentModesResponse { - modes: modes - .into_iter() - .map(|mode| AgentModeSummary { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect(), - }), - other => Err(unexpected("list_agent_modes", other)), - } - } - - async fn list_models(&self) -> Result { - self.management_service("tui.models")? - .list_models(ListModelsRequest {}) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn get_model( - &self, - request: GetModelRequest, - ) -> Result { - self.management_service("tui.models")? - .get_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn add_model( - &self, - request: AddModelRequest, - ) -> Result { - self.management_service("tui.models")? - .add_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn update_model( - &self, - request: UpdateModelRequest, - ) -> Result { - self.management_service("tui.models")? - .update_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn delete_model( - &self, - request: DeleteModelRequest, - ) -> Result { - self.management_service("tui.models")? - .delete_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn set_model_default( - &self, - request: SetModelDefaultRequest, - ) -> Result { - self.management_service("tui.models")? - .set_model_default(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn list_skills( - &self, - request: ListSkillsRequest, - ) -> Result { - self.management_service("tui.skills")? - .list_skills(request) - .await - .map_err(|error| map_management_error("tui.skills", error)) - } - - async fn set_skill_enabled( - &self, - request: SetSkillEnabledRequest, - ) -> Result { - self.management_service("tui.skills")? - .set_skill_enabled(request) - .await - .map_err(|error| map_management_error("tui.skills", error)) - } - - async fn list_subagents( - &self, - request: ListSubagentsRequest, - ) -> Result { - self.management_service("tui.subagents")? - .list_subagents(request) - .await - .map_err(|error| map_management_error("tui.subagents", error)) - } - - async fn set_subagent_enabled( - &self, - request: SetSubagentEnabledRequest, - ) -> Result { - self.management_service("tui.subagents")? - .set_subagent_enabled(request) - .await - .map_err(|error| map_management_error("tui.subagents", error)) - } - - async fn list_mcp_servers( - &self, - request: ListMcpServersRequest, - ) -> Result { - self.management_service("tui.mcp")? - .list_mcp_servers(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn toggle_mcp_server( - &self, - request: ToggleMcpServerRequest, - ) -> Result { - self.management_service("tui.mcp")? - .toggle_mcp_server(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn add_mcp_server( - &self, - request: AddMcpServerRequest, - ) -> Result { - self.management_service("tui.mcp")? - .add_mcp_server(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn delete_mcp_server( - &self, - request: DeleteMcpServerRequest, - ) -> Result { - self.management_service("tui.mcp")? - .delete_mcp_server(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn external_mcp_decision( - &self, - request: ExternalMcpDecisionRequest, - ) -> Result { - self.management_service("tui.mcp")? - .external_mcp_decision(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn mcp_conflict_choice( - &self, - request: McpConflictChoiceRequest, - ) -> Result { - self.management_service("tui.mcp")? - .mcp_conflict_choice(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn external_source_snapshot( - &self, - request: ExternalSourceSnapshotRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .external_source_snapshot(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn external_source_control( - &self, - request: ExternalSourceControlRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .external_source_control(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn external_source_review( - &self, - request: ExternalSourceReviewRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .external_source_review(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn set_native_command_choice( - &self, - request: SetNativeCommandChoiceRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .set_native_command_choice(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn expand_external_command( - &self, - request: ExpandExternalCommandRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .expand_external_command(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn native_hook_overview( - &self, - request: NativeHookOverviewRequest, - ) -> Result { - self.management_service(NATIVE_HOOKS_CAPABILITY)? - .native_hook_overview(request) - .await - .map_err(|error| map_management_error(NATIVE_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_snapshot( - &self, - request: ExternalHookSnapshotRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_snapshot(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_plan( - &self, - request: ExternalHookPlanRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_plan(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_apply( - &self, - request: ExternalHookApplyRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_apply(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_mutate( - &self, - request: ExternalHookMutationRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_mutate(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } -} - -impl SharedTuiBackend { - async fn fork( - &self, - source_session_id: String, - before_turn_id: Option, - ) -> Result { - match self - .request(RuntimeIpcOperation::ForkSession { - request: RuntimeSessionForkRequest { - session_id: source_session_id, - before_turn_id, - }, - }) - .await? - { - RuntimeIpcOperationResult::SessionForked { - session, - workspace_binding, - .. - } => { - self.set_current_session(session.session_id.clone()); - self.set_management_scope_from_binding(&workspace_binding); - Ok(ForkSessionResponse(AgentSessionForkResult { - session_id: session.session_id, - session_name: session.session_name, - agent_type: session.agent_type, - })) - } - other => Err(unexpected("fork_session", other)), - } - } -} - -async fn load_model_catalog() -> Result { - let catalog = bitfun_core::get_ai_model_catalog() - .await - .map_err(|message| backend_error(message, false))?; - let reasoning_presets_by_model = catalog - .models - .into_iter() - .filter_map(|model| { - model.reasoning.map(|reasoning| { - ( - model.id, - reasoning - .presets - .into_iter() - .map(|preset| preset.id) - .collect(), - ) - }) - }) - .collect(); - Ok(TuiModelCatalogResponse { - provider_catalog: catalog.provider_catalog, - reasoning_presets_by_model, - }) -} - -fn map_revert( - result: RuntimeIpcOperationResult, - operation: &str, -) -> Result { - match result { - RuntimeIpcOperationResult::SessionReverted { revert } => Ok(RevertSessionResponse(revert)), - other => Err(unexpected(operation, other)), - } -} - -fn expect_unit(result: RuntimeIpcOperationResult, operation: &str) -> Result<(), TuiBackendError> { - match result { - RuntimeIpcOperationResult::Unit => Ok(()), - other => Err(unexpected(operation, other)), - } -} - -fn unexpected(operation: &str, result: RuntimeIpcOperationResult) -> TuiBackendError { - backend_error( - format!("Shared Runtime returned an unexpected result for {operation}: {result:?}"), - true, - ) -} - -fn map_client_error(error: RuntimeIpcClientError) -> TuiBackendError { - let outcome_unknown = matches!( - &error, - RuntimeIpcClientError::Remote(remote) - if remote.code == RuntimeIpcErrorCode::OutcomeUnknown - ) || matches!( - error, - RuntimeIpcClientError::Timeout - | RuntimeIpcClientError::Disconnected - | RuntimeIpcClientError::UnexpectedResponse - | RuntimeIpcClientError::Io(_) - ); - backend_error(error.to_string(), outcome_unknown) -} - -fn backend_error(message: impl Into, outcome_unknown: bool) -> TuiBackendError { - TuiBackendError { - message: message.into(), - outcome_unknown, - kind: TuiBackendErrorKind::Backend, - } -} - -fn map_management_error(capability: &str, error: AppManagementError) -> TuiBackendError { - let kind = match error.kind { - AppManagementErrorKind::Unsupported => TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - AppManagementErrorKind::InvalidRequest - | AppManagementErrorKind::NotFound - | AppManagementErrorKind::Internal => TuiBackendErrorKind::Backend, - }; - TuiBackendError { - message: error.message, - outcome_unknown: false, - kind, - } -} - -fn map_session_state(state: RuntimeSessionState) -> SessionRuntimeState { - match state { - RuntimeSessionState::Idle => SessionRuntimeState::Idle, - RuntimeSessionState::Processing { - current_turn_id, - phase, - } => SessionRuntimeState::Processing { - current_turn_id, - phase: match phase { - RuntimeSessionProcessingPhase::Starting => SessionProcessingPhase::Starting, - RuntimeSessionProcessingPhase::Compacting => SessionProcessingPhase::Compacting, - RuntimeSessionProcessingPhase::Thinking => SessionProcessingPhase::Thinking, - RuntimeSessionProcessingPhase::Streaming => SessionProcessingPhase::Streaming, - RuntimeSessionProcessingPhase::ToolCalling => SessionProcessingPhase::ToolCalling, - RuntimeSessionProcessingPhase::ToolConfirming => { - SessionProcessingPhase::ToolConfirming - } - }, - }, - RuntimeSessionState::Error { error, recoverable } => { - SessionRuntimeState::Error { error, recoverable } - } - } -} - -fn tui_capabilities(management: &AppManagementCapabilities) -> Vec { - let mut capabilities = [ - ( - "agent", - vec![ - "agent/createSession", - "agent/listSessions", - "agent/deleteSession", - "agent/submitDialogTurn", - "agent/steerTurn", - "agent/runUserShellCommand", - "agent/submitUserAnswers", - "agent/cancelTurn", - "agent/event", - ], - ), - ( - "session", - vec![ - "session/sync", - "session/recordLocalCommandTurn", - "session/rename", - "session/updateModel", - "session/updateMode", - "session/fork", - "session/forkBeforeTurn", - "session/compact", - "session/undo", - "session/redo", - "session/reloadContext", - "session/usage", - "session/waitForSettlement", - "session/lineage", - "session/inspectLineage", - "session/cancelLineage", - ], - ), - ( - "permission", - vec![ - "agent/permissionEvent", - "agent/respondPermission", - "agent/listPendingPermissionRequests", - ], - ), - ( - "workspace", - vec![ - "workspace/diff", - "workspace/searchReferences", - "workspace/messageReferences", - ], - ), - ] - .into_iter() - .map(|(id, methods)| CapabilityDescriptor { - id: id.to_string(), - availability: CapabilityAvailability::Available, - methods: methods.into_iter().map(str::to_string).collect(), - }) - .collect::>(); - capabilities.push(CapabilityDescriptor { - id: "tui.modes".to_string(), - availability: CapabilityAvailability::Available, - methods: vec!["agent/listModes".to_string()], - }); - capabilities.extend( - management - .descriptors() - .into_iter() - .filter(|descriptor| descriptor.id != MODES_CAPABILITY), - ); - capabilities -} - -fn spawn_event_bridge( - mut source: broadcast::Receiver, - output: broadcast::Sender, - connection_id: String, -) { - tokio::spawn(async move { - let agent_sequence = AtomicU64::new(0); - let permission_sequence = AtomicU64::new(0); - loop { - match source.recv().await { - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { envelope, .. })) => { - let _ = output.send(AppServerEvent::Agent(AgentEventNotification { - cursor: next_cursor(&connection_id, EventStream::Agent, &agent_sequence), - event: envelope, - })); - } - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Permission { - event, .. - })) => { - let _ = output.send(AppServerEvent::Permission(PermissionEventNotification { - cursor: next_cursor( - &connection_id, - EventStream::Permission, - &permission_sequence, - ), - event, - })); - } - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::StreamInvalidated { - reason, - })) => { - let _ = output.send(stream_state_event( - &connection_id, - &agent_sequence, - EventStreamState::Invalidated, - Some(invalidation_reason(reason)), - )); - break; - } - Ok(RuntimeIpcClientEvent::Disconnected) - | Err(broadcast::error::RecvError::Closed) => { - let _ = output.send(AppServerEvent::ConnectionClosed); - break; - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - let mut event = stream_state_event( - &connection_id, - &agent_sequence, - EventStreamState::Lagged, - Some("Shared Runtime client event receiver lagged".to_string()), - ); - if let AppServerEvent::StreamState(notification) = &mut event { - notification.missed = Some(missed); - } - let _ = output.send(event); - break; - } - } - } - }); -} - -fn spawn_external_source_event_bridge( - mut source: broadcast::Receiver<( - String, - bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, - )>, - output: broadcast::Sender, - connection_id: String, -) { - tokio::spawn(async move { - let sequence = AtomicU64::new(0); - loop { - match source.recv().await { - Ok((workspace_path, snapshot)) => { - let _ = output.send(AppServerEvent::ExternalSource( - ExternalSourceEventNotification { - cursor: next_cursor( - &connection_id, - EventStream::ExternalSource, - &sequence, - ), - workspace_path, - snapshot, - }, - )); - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - let _ = - output.send(AppServerEvent::StreamState(EventStreamStateNotification { - cursor: next_cursor( - &connection_id, - EventStream::ExternalSource, - &sequence, - ), - stream: EventStream::ExternalSource, - state: EventStreamState::Lagged, - missed: Some(missed), - resync: ResyncDirective { - method: "externalSource/snapshot".to_string(), - snapshot_available: true, - reason: Some( - "Shared external source event receiver lagged".to_string(), - ), - }, - })); - } - Err(broadcast::error::RecvError::Closed) => break, - } - } - }); -} - -fn next_cursor(connection_id: &str, stream: EventStream, sequence: &AtomicU64) -> EventCursor { - EventCursor { - connection_id: connection_id.to_string(), - stream, - sequence: sequence.fetch_add(1, Ordering::Relaxed) + 1, - } -} - -fn stream_state_event( - connection_id: &str, - sequence: &AtomicU64, - state: EventStreamState, - reason: Option, -) -> AppServerEvent { - AppServerEvent::StreamState(EventStreamStateNotification { - cursor: next_cursor(connection_id, EventStream::Agent, sequence), - stream: EventStream::Agent, - state, - missed: None, - resync: ResyncDirective { - method: "session/sync".to_string(), - snapshot_available: true, - reason, - }, - }) -} - -fn invalidation_reason(reason: RuntimeIpcStreamInvalidationReason) -> String { - match reason { - RuntimeIpcStreamInvalidationReason::Lagged => "Shared Runtime event stream lagged", - RuntimeIpcStreamInvalidationReason::Closed => "Shared Runtime event stream closed", - RuntimeIpcStreamInvalidationReason::FrameTooLarge => { - "Shared Runtime event exceeded the transport frame limit" - } - } - .to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; - - #[test] - fn shared_management_capabilities_follow_the_local_management_service() { - let capabilities = tui_capabilities(&AppManagementCapabilities::available()); - for id in [ - "tui.models", - "tui.skills", - "tui.subagents", - "tui.mcp", - "tui.externalSources", - "tui.nativeHooks", - "tui.externalHooks", - ACCOUNT_CAPABILITY, - SETTINGS_SYNC_CAPABILITY, - WORKTREES_CAPABILITY, - ] { - let capability = capabilities - .iter() - .find(|capability| capability.id == id) - .expect("management capability should be declared"); - assert_eq!(capability.availability, CapabilityAvailability::Available); - assert!(!capability.methods.is_empty()); - } - } - - #[test] - fn shared_management_preserves_availability_and_error_kind() { - let mut management = AppManagementCapabilities::available(); - management.mcp = CapabilityAvailability::Unavailable { - reason: "local MCP compatibility owner unavailable".to_string(), - }; - let capabilities = tui_capabilities(&management); - let mcp = capabilities - .iter() - .find(|capability| capability.id == "tui.mcp") - .expect("MCP capability"); - assert!(matches!( - mcp.availability, - CapabilityAvailability::Unavailable { .. } - )); - - let error = map_management_error( - "tui.mcp", - AppManagementError::unsupported("MCP compatibility owner unavailable"), - ); - assert_eq!( - error.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.mcp".to_string() - } - ); - assert!(!error.outcome_unknown); - - let error = map_management_error( - "tui.models", - AppManagementError::invalid_request("invalid model mutation"), - ); - assert_eq!(error.kind, TuiBackendErrorKind::Backend); - assert!(!error.outcome_unknown); - } - - #[test] - fn remote_workspace_cannot_use_the_local_management_service() { - let remote_error = require_local_management_scope(false, "tui.models") - .expect_err("Remote workspace must not use the local service"); - assert_eq!( - remote_error.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.models".to_string() - } - ); - assert!(remote_error.message.contains("Remote workspace")); - assert!(remote_error.message.contains("does not fall back")); - - let external_error = require_local_management_scope(false, EXTERNAL_SOURCES_CAPABILITY) - .expect_err("Remote external sources must not use the local service"); - assert_eq!( - external_error.kind, - TuiBackendErrorKind::Unsupported { - capability: EXTERNAL_SOURCES_CAPABILITY.to_string() - } - ); - - for capability in [NATIVE_HOOKS_CAPABILITY, EXTERNAL_HOOKS_CAPABILITY] { - let hook_error = require_local_management_scope(false, capability) - .expect_err("Remote Hook management must not use the local service"); - assert_eq!( - hook_error.kind, - TuiBackendErrorKind::Unsupported { - capability: capability.to_string() - } - ); - assert!(hook_error.message.contains("does not fall back")); - } - - for capability in [ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY] { - let account_error = require_local_management_scope(false, capability) - .expect_err("Remote account management must not use the controller account"); - assert_eq!( - account_error.kind, - TuiBackendErrorKind::Unsupported { - capability: capability.to_string() - } - ); - assert!(account_error.message.contains("does not fall back")); - } - - let worktree_error = require_local_management_scope(false, WORKTREES_CAPABILITY) - .expect_err("Remote worktree management must not use the controller worktree owner"); - assert_eq!( - worktree_error.kind, - TuiBackendErrorKind::Unsupported { - capability: WORKTREES_CAPABILITY.to_string() - } - ); - assert!(worktree_error.message.contains("does not fall back")); - } - - fn agent_event(text: &str) -> RuntimeIpcClientEvent { - RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { - session_id: "session-1".to_string(), - envelope: AgenticEventEnvelope::new( - AgenticEvent::TextChunk { - session_id: "session-1".to_string(), - turn_id: "turn-1".to_string(), - round_id: "round-1".to_string(), - attempt_id: None, - attempt_index: None, - text: text.to_string(), - }, - AgenticEventPriority::Normal, - ), - }) - } - - #[tokio::test] - async fn event_bridge_preserves_monotonic_cursors_and_projects_invalidation() { - let (source, source_rx) = broadcast::channel(8); - let (output, mut output_rx) = broadcast::channel(8); - spawn_event_bridge(source_rx, output, "connection-1".to_string()); - - source.send(agent_event("one")).expect("first event"); - source.send(agent_event("two")).expect("second event"); - source - .send(RuntimeIpcClientEvent::Runtime( - RuntimeIpcEvent::StreamInvalidated { - reason: RuntimeIpcStreamInvalidationReason::Lagged, - }, - )) - .expect("invalidation"); - - for expected in [1, 2] { - let AppServerEvent::Agent(notification) = output_rx.recv().await.expect("agent event") - else { - panic!("expected agent event"); - }; - assert_eq!(notification.cursor.connection_id, "connection-1"); - assert_eq!(notification.cursor.stream, EventStream::Agent); - assert_eq!(notification.cursor.sequence, expected); - } - let AppServerEvent::StreamState(notification) = - output_rx.recv().await.expect("stream invalidation") - else { - panic!("expected stream state"); - }; - assert_eq!(notification.cursor.sequence, 3); - assert_eq!(notification.state, EventStreamState::Invalidated); - assert_eq!(notification.resync.method, "session/sync"); - assert!(notification.resync.snapshot_available); - } - - #[tokio::test] - async fn event_bridge_projects_disconnect_as_connection_closed() { - let (source, source_rx) = broadcast::channel(2); - let (output, mut output_rx) = broadcast::channel(2); - spawn_event_bridge(source_rx, output, "connection-2".to_string()); - - source - .send(RuntimeIpcClientEvent::Disconnected) - .expect("disconnect event"); - - assert!(matches!( - output_rx.recv().await.expect("connection closed"), - AppServerEvent::ConnectionClosed - )); - } -} diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index 2f453a9c95..7ee07780c9 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -291,6 +291,7 @@ fn interactive_tui_session_client_uses_only_the_app_server_boundary() { const AGENT_MODULE: &str = include_str!("../../src/agent/mod.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); + const CLI_CARGO: &str = include_str!("../../Cargo.toml"); assert!( !AGENT_MODULE.contains("trait Agent"), @@ -311,6 +312,10 @@ fn interactive_tui_session_client_uses_only_the_app_server_boundary() { && TUI_CLIENT.contains("use crate::tui_backend::{TuiBackend, TuiBackendError"), "TuiBackend must remain CLI-local and depend only on App Server client contracts" ); + assert!( + !CLI_CARGO.contains("bitfun-agent-runtime-ipc"), + "the removed private Runtime IPC must not return to the CLI dependency graph" + ); for backend_operation in [ ".sync_session(", ".submit_dialog_turn(", @@ -375,9 +380,9 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { const CHAT_INPUT: &str = include_str!("../../src/modes/chat/input.rs"); const CHAT_SELECTION: &str = include_str!("../../src/modes/chat/selection.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); - const SHARED_TUI_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); + const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); - const SHARED_RUNTIME: &str = include_str!("../../src/shared_runtime.rs"); + const SHARED_APP_SERVER: &str = include_str!("../../src/shared_app_server.rs"); const CLI_MAIN: &str = include_str!("../../src/main.rs"); const CLI_CARGO: &str = include_str!("../../Cargo.toml"); @@ -404,29 +409,25 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { "interactive chat and startup must use the backend-neutral TUI session client" ); assert!( - !CLI_CARGO.contains("bitfun-sdk-host") && CLI_CARGO.contains("bitfun-agent-runtime-ipc"), - "Shared TUI must use the private Runtime IPC adapter without making CLI depend on SDK Host" + !CLI_CARGO.contains("bitfun-sdk-host") + && !CLI_CARGO.contains("bitfun-agent-runtime-ipc") + && !CLI_MAIN.contains("shared_tui_backend") + && !CLI_MAIN.contains("shared_runtime"), + "Shared TUI must use App Server without retaining the removed Runtime IPC adapter" ); assert!( - SHARED_TUI_BACKEND.contains("RuntimeIpcClient") - && !TUI_CLIENT.contains("RuntimeIpcClient") - && !STARTUP_PAGE.contains("RuntimeIpcClient") - && !CHAT_MODE.contains("RuntimeIpcClient"), - "Shared IPC must remain in the CLI Host adapter instead of leaking into TUI clients or controllers" - ); - assert!( - SHARED_TUI_BACKEND - .contains("RuntimeIpcOperation::UpdateSessionMode { request: request.0 }") - && SHARED_RUNTIME.contains("RuntimeIpcOperation::UpdateSessionMode { request }") - && SHARED_RUNTIME.contains(".update_session_mode(request)"), - "Shared Agent mode updates must reuse the Runtime port through the private IPC adapter" + CLI_MAIN.contains("shared_app_server::connect_or_start") + && CLI_MAIN.contains("AppServerTuiBackend::new(client)") + && TUI_BACKEND.contains("AppServerClient") + && !TUI_CLIENT.contains("RuntimeIpc") + && !STARTUP_PAGE.contains("RuntimeIpc") + && !CHAT_MODE.contains("RuntimeIpc"), + "Embedded and Shared TUI must use the same App Server backend boundary" ); assert!( - SHARED_TUI_BACKEND - .contains("RuntimeIpcOperation::UpdateSessionModel { request: request.0 }") - && SHARED_RUNTIME.contains("RuntimeIpcOperation::UpdateSessionModel { request }") - && SHARED_RUNTIME.contains(".update_session_model(request)"), - "Shared model updates must reuse the Runtime port through the private IPC adapter" + TUI_BACKEND.contains("self.client.update_session_mode(request).await") + && TUI_BACKEND.contains("self.client.update_session_model(request).await"), + "Shared mode and model updates must use typed App Server methods" ); assert!( TUI_CLIENT.contains(".external_source_snapshot(ExternalSourceSnapshotRequest") @@ -439,8 +440,8 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { assert!( CHAT_COMMANDS.matches("if self.agent.is_shared()").count() >= 3 && EMBEDDED_APP_SERVER.contains("AppServerTuiBackend::new(client)") - && SHARED_RUNTIME.contains("RuntimeDeployment::Shared") - && SHARED_RUNTIME.contains("process_manager::contain_current_process_tree"), + && SHARED_APP_SERVER.contains("RuntimeDeployment::Shared") + && SHARED_APP_SERVER.contains("process_manager::contain_current_process_tree"), "Shared controls must stay terminal-safe while preserving Embedded recovery and one process Job owner" ); assert!( @@ -454,7 +455,8 @@ fn interactive_tui_hook_management_stays_behind_the_typed_backend() { const CHAT_HOOKS: &str = include_str!("../../src/modes/chat/external_hooks.rs"); const CHAT_NATIVE_HOOKS: &str = include_str!("../../src/modes/chat/native_hooks.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); - const SHARED_TUI_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); + const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); + const SHARED_APP_SERVER: &str = include_str!("../../src/shared_app_server.rs"); for operation in [ "external_hook_snapshot", @@ -481,10 +483,11 @@ fn interactive_tui_hook_management_stays_behind_the_typed_backend() { } assert!( CHAT_HOOKS.contains("expected_revision") - && SHARED_TUI_BACKEND.contains("NATIVE_HOOKS_CAPABILITY") - && SHARED_TUI_BACKEND.contains("EXTERNAL_HOOKS_CAPABILITY") - && SHARED_TUI_BACKEND.contains("does not fall back"), - "Hook mutations must preserve stale-revision fencing and remote fail-closed routing" + && TUI_BACKEND.contains("self.client.native_hook_overview(request).await") + && TUI_BACKEND.contains("self.client.external_hook_snapshot(request).await") + && TUI_BACKEND.contains("self.client.external_hook_mutate(request).await") + && SHARED_APP_SERVER.contains("AppManagementService::load_for_local_host"), + "Hook mutations must preserve stale-revision fencing and use Host-owned App Server management" ); assert!( !CHAT_HOOKS.contains("post_call_hooks") @@ -499,11 +502,10 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { const WORKTREE_CONTROLLER: &str = include_str!("../../src/modes/chat/worktree.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); - const SHARED_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); const WORKTREE_MANAGEMENT: &str = include_str!("../../../../crates/interfaces/app-server/src/management/worktree.rs"); const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); - const CLI_MAIN: &str = include_str!("../../src/main.rs"); + const SHARED_APP_SERVER: &str = include_str!("../../src/shared_app_server.rs"); for direct_owner in [ "GitService", @@ -525,8 +527,7 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { assert!( WORKTREE_CONTROLLER.contains(operation) && TUI_CLIENT.contains(operation) - && TUI_BACKEND.contains(operation) - && SHARED_BACKEND.contains(operation), + && TUI_BACKEND.contains(operation), "Worktree operation {operation} must stay behind the typed TUI backend" ); } @@ -538,10 +539,11 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { "the Embedded Host must enable the App Server's built-in local Worktree management" ); assert!( - SHARED_BACKEND.contains("WORKTREES_CAPABILITY") - && SHARED_BACKEND.contains("does not fall back") - && CLI_MAIN.contains("AppManagementService::load().await?"), - "Shared Worktree management must fail closed" + SHARED_APP_SERVER.contains("AppManagementService::load_for_local_host") + && TUI_BACKEND.contains("self.client.worktree_repository_status(request).await") + && TUI_BACKEND.contains("self.client.worktree_bind_session(request).await") + && TUI_BACKEND.contains("self.client.worktree_release_session(request).await"), + "Shared Worktree management must use the App Server Host and typed client boundary" ); } @@ -606,31 +608,23 @@ fn phase4_tui_management_boundaries_have_zero_legacy_owner_budget() { #[test] fn runtime_ownership_policy_is_assembled_once_in_core() { - const SHARED_RUNTIME: &str = include_str!("../../src/shared_runtime.rs"); + const SHARED_APP_SERVER: &str = include_str!("../../src/shared_app_server.rs"); const CLI_RUNTIME: &str = include_str!("../../src/runtime/mod.rs"); const CLI_MAIN: &str = include_str!("../../src/main.rs"); const AGENTIC_SYSTEM: &str = include_str!("../../src/agent/agentic_system.rs"); - for private_policy in [ - "RuntimeOwnershipKey::for_workspace", - "WorkspaceRuntimeOwnership::try_acquire", - "fn ownership_root", - "fn product_identity", - "pub(crate) fn acquire_ownership", - ] { - assert!( - !SHARED_RUNTIME.contains(private_policy), - "CLI must not duplicate Core ownership policy: {private_policy}" - ); - } assert!( !CLI_RUNTIME.contains("WorkspaceRuntimeOwnership") && !CLI_RUNTIME.contains("_runtime_ownership"), "Coordinator must retain the Core owner; CliRuntimeContext must not keep a second guard" ); assert!( - CLI_MAIN.contains("CoreRuntimeOwnership") + SHARED_APP_SERVER.contains("RuntimeOwnershipKey::for_workspace") + && SHARED_APP_SERVER.contains("initialize_core_services_for_deployment") + && SHARED_APP_SERVER.contains("RuntimeDeployment::Shared") + && !SHARED_APP_SERVER.contains("WorkspaceRuntimeOwnership::try_acquire") + && CLI_MAIN.contains("CoreRuntimeOwnership") && AGENTIC_SYSTEM.contains("init_agentic_system_for_profile_with_runtime_ownership"), - "CLI must select a deployment and inject the single Core owner" + "Shared discovery may derive the canonical identity, while Core retains the single Runtime owner" ); } diff --git a/src/apps/server/src/app_server.rs b/src/apps/server/src/app_server.rs index 6fc25d0ca9..19cc84a793 100644 --- a/src/apps/server/src/app_server.rs +++ b/src/apps/server/src/app_server.rs @@ -14,8 +14,11 @@ //! (cheap `Clone` via the inner `Arc`); `serve` runs once per WS connection. use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime}; +use bitfun_app_server::schema::TransportLimits; use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer}; +pub(crate) const MAX_WEBSOCKET_FRAME_BYTES: usize = 256 * 1024; + /// Build the in-process `BitfunAppServer` for the Server Host. /// /// Constructs a [`BitfunAppRuntime`] from the product-assembled `runtime` and @@ -28,5 +31,10 @@ use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer}; /// [`BitfunAppServer`] is in use. pub(crate) fn build(runtime: AgentRuntime, event_source: AgentEventSource) -> BitfunAppServer { let app_runtime = BitfunAppRuntime::new(runtime, event_source); - BitfunAppServer::new(app_runtime) + BitfunAppServer::new(app_runtime).with_transport_limits(TransportLimits { + max_request_bytes: MAX_WEBSOCKET_FRAME_BYTES as u64, + max_response_bytes: MAX_WEBSOCKET_FRAME_BYTES as u64, + max_frame_bytes: MAX_WEBSOCKET_FRAME_BYTES as u64, + event_buffer_capacity: 1024, + }) } diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index e4c2629348..c34df1c537 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -33,11 +33,9 @@ use axum::{ use bitfun_app_server::BitfunAppServer; +use crate::app_server::MAX_WEBSOCKET_FRAME_BYTES; use crate::AppState; -/// Maximum accepted WS frame size (256 KiB), matching the prior envelope handler. -const MAX_WS_TEXT_BYTES: usize = 256 * 1024; - /// WebSocket connection handler. /// /// Validates the browser origin, then upgrades the connection and runs one @@ -54,8 +52,8 @@ pub(crate) async fn websocket_handler( return StatusCode::FORBIDDEN.into_response(); } tracing::info!("New WebSocket connection"); - ws.max_message_size(MAX_WS_TEXT_BYTES) - .max_frame_size(MAX_WS_TEXT_BYTES) + ws.max_message_size(MAX_WEBSOCKET_FRAME_BYTES) + .max_frame_size(MAX_WEBSOCKET_FRAME_BYTES) .on_upgrade(move |socket| handle_socket(socket, bitfun_app_server)) } diff --git a/src/apps/server/src/routes/ws_transport.rs b/src/apps/server/src/routes/ws_transport.rs index 81573a4a05..58478d33e8 100644 --- a/src/apps/server/src/routes/ws_transport.rs +++ b/src/apps/server/src/routes/ws_transport.rs @@ -23,6 +23,8 @@ use futures::{Sink, Stream}; use futures_util::stream::{SplitSink, SplitStream}; use futures_util::{SinkExt, StreamExt}; +use crate::app_server::MAX_WEBSOCKET_FRAME_BYTES; + /// Bridge an axum WebSocket into an ACP `Lines` transport for /// `BitfunAppServer::serve(lines)`. /// @@ -57,6 +59,12 @@ impl Sink for WSSink { fn start_send(self: Pin<&mut Self>, item: String) -> Result<(), Self::Error> { let this = self.get_mut(); + if item.len() > MAX_WEBSOCKET_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ws outgoing frame exceeds the Host transport limit", + )); + } this.sink .start_send_unpin(Message::Text(item.into())) .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ws send failed")) diff --git a/src/crates/adapters/AGENTS-CN.md b/src/crates/adapters/AGENTS-CN.md index 29deb4ee85..d506f7ad2e 100644 --- a/src/crates/adapters/AGENTS-CN.md +++ b/src/crates/adapters/AGENTS-CN.md @@ -8,7 +8,6 @@ | Crate | 职责 | 本地文档 | |---|---|---| -| `agent-runtime-ipc` | 不发布的私有本机 IPC adapter,为可选的第一方 Shared TUI Runtime 提供封闭交互操作 | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider 请求/响应 adapter 与 stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode Command、standalone Tool 和 Subagent 实时 provider 的生态语义;受管包静态预览 | [AGENTS.md](opencode-adapter/AGENTS.md) | | `transport` | Event transport emitter 与宿主 transport adapter | [AGENTS.md](transport/AGENTS.md) | diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index 42ba50528a..869235aff1 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -11,7 +11,6 @@ services. | Crate | Responsibility | Local doc | |---|---|---| -| `agent-runtime-ipc` | Non-published private local IPC adapter for the opt-in first-party Shared TUI Runtime; closed interactive operations only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode source semantics for user Instructions plus the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | | `claude-code-adapter` | Runtime-free Claude Code user Instructions, Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md deleted file mode 100644 index c100aad26e..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ /dev/null @@ -1,29 +0,0 @@ -**中文** | [English](AGENTS.md) - -# Agent Runtime IPC - -范围:`src/crates/adapters/agent-runtime-ipc`。 - -该 crate 不发布,是第一方 Shared TUI adapter 使用的私有本机协议。它提供 discovery、单实例锁、有界 framing、认证初始化、封闭的交互操作集、Session controller lease、事件传递、连接上限和 cleanup;它不是公开 SDK、远程协议、service layer 或 Runtime owner。 - -## 预集成约束 - -- 唯一 consumer 是 `src/apps/cli` 中的第一方交互式 TUI adapter;不自动包含 GUI、Remote、Peer、ACP、Headless CLI 或 SDK Host。 -- 稳定测试合同包括本机 endpoint、严格 initialize-first、分离的握手/请求 deadline、128 KiB 请求与 8 MiB 响应/事件上限、有界连接、每个 Session 一个 controller、断线取消、30 秒空闲退出和 owner-checked discovery cleanup。 -- consumer 必须复用既有 Agent Runtime owners,并证明 Embedded/Shared 行为等价,不能依赖 SDK Host。 - -## 边界 - -- 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、只读且 workspace-scoped 的 main Agent 摘要、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取,以及不取得 Session lease 的 Runtime 绑定工作区只读 diff;此外还包括声明式上下文 reload、Turn submit/steer、用户显式 Shell 执行/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。用户显式 Shell 执行同样要求当前 controller、Session 空闲和调用方提供的 Turn ID;它只委托给窄 Runtime port,并复用正常 ToolPipeline、权限、工作区路由、持久化与取消 owner,不是通用 Tool 或进程执行 wire。steer 要求当前 controller、活动 Turn 以及调用方提供的精确 Session/Turn ID;它只委托给共享 Runtime owner,拒绝过期投影,不创建第二个 Turn 或 queue owner。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录、默认值和完整 Agent/Subagent 管理仍是 wire 之外的产品配置;main Agent 摘要只服务 host-owned selector:启动页查询 Runtime 绑定工作区且不取得 Session lease,Session 级查询则要求该 Session 的当前 controller 并由 Runtime owner 解析执行工作区。摘要中的 external-source 分类保持生态无关,且不携带安装、变更、激活或 runtime lifecycle API。禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 -- 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 -- 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 -- 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 -- Embedded 调用方必须继续以强类型直接调用 Agent Runtime,不能初始化本 transport。Shared 的 request、response 和 event frame 在写出前只编码一次;不能为了吞吐量削弱严格解码、未知字段拒绝、frame 上限、有界队列和背压。 - -## 验证 - -```bash -cargo test -p bitfun-agent-runtime-ipc -node scripts/check-core-boundaries.mjs -``` diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md deleted file mode 100644 index 13c7e4c775..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ /dev/null @@ -1,55 +0,0 @@ -[中文](AGENTS-CN.md) | **English** - -# Agent Runtime IPC - -Scope: `src/crates/adapters/agent-runtime-ipc`. - -This non-published crate is the private local protocol used by the first-party Shared TUI adapter. -It provides discovery, one-instance locking, bounded framing, authenticated initialization, a closed interactive operation set, -session controller leases, event delivery, connection bounds, and cleanup. It is not a public SDK, remote protocol, service layer, or Runtime owner. - -## Pre-integration contract - -- Only consumer: the first-party interactive TUI adapter in `src/apps/cli`. - GUI, Remote, Peer, ACP, Headless CLI, and SDK Host are not implied consumers. -- Stable test contract: platform-local endpoint, strict initialize-first handshake, separate handshake/request deadlines, - 128 KiB request and 8 MiB response/event limits, bounded connections, one controller per Session, one active Turn per connection, - disconnect cancellation, sticky event-stream invalidation, 30-second idle exit, and owner-checked discovery cleanup. -- Integration check: the consumer must reuse existing Agent Runtime owners and - prove Embedded/Shared behavior equivalence without depending on SDK Host. - -## Boundaries - -- Export only the exact workspace-private API needed by the CLI adapter. Do not - publish this crate or expose its wire as an SDK contract. -- The closed operation budget is Health, a read-only workspace-scoped main-Agent mode summary, Session list/create/restore/delete/fork (including transcript on restore/fork), current-Session rename, Agent mode/model update, manual context compaction, Session undo/redo, current-controller read-only workspace-reference search/persisted-reference lookup, root-scoped Session-lineage query/descendant transcript read/targeted descendant cancellation, and a read-only diff of the Runtime-bound workspace that does not acquire a Session lease, - declarative context reload, Turn submit/steer/user-authored Shell execution/cancel, pending/respond Permission, and UserInput answers. Delete is limited to an idle Session not controlled by any client. - Fork is a current-controller, idle-only operation. It either copies through the latest persisted Turn or stops immediately before an explicitly selected Turn. The encoded success result carries the authoritative new Session and transcript; only then may the server atomically switch the connection lease from the source Session to the fork. - Manual compaction is a current-controller, idle-only Turn operation. The client supplies its exact Turn ID before admission so timeout or disconnect cleanup can cancel the same owned task; once Core begins the atomic context commit, a late cancellation does not expose a false idle state. - User-authored Shell execution is a current-controller, idle-only Turn operation with a caller-supplied Turn ID. It delegates to the narrow Runtime port and normal ToolPipeline, permission, workspace-routing, persistence, and cancellation owners; it is not a generic Tool or process-execution wire. - Steering is a current-controller, active-Turn-only operation with caller-supplied Session and Turn IDs. It delegates to the shared Runtime owner, rejects stale projections, and does not create a second Turn or queue owner. - Lineage operations require the current root controller. Query and transcript inspection remain read-only during an active Turn; targeted cancellation validates descendant membership in the Runtime owner and follows the existing Session-abort semantics for the selected descendant's active execution subtree. They do not add observer, detach, paging, or controller-transfer semantics. - Context reload may run during an active Turn, does not rewrite that Turn, and guards the cache so the next message reads invalidated instructions. - Undo/redo is a current-controller operation that may enter during an active Turn because Core owns cancel-and-drain before mutation. Its success response carries the authoritative transcript and clears the connection's active-Turn projection. It is local-workspace only and does not expose a generic checkpoint protocol. - Disconnect cleanup is internal lifecycle, not a detach operation. - Model catalogs, defaults, and full Agent/Subagent management remain product configuration outside this wire. The main-Agent summary is only the minimal host-owned selector projection: startup reads the Runtime-bound workspace without a Session lease, while a Session-scoped query requires that Session's current controller and lets the Runtime owner resolve its execution workspace. Its external-source classification is ecosystem-neutral, and it carries no installation, mutation, activation, or runtime lifecycle API. Do not add archive, replay, observer, - general controller transfer, Tool/MCP/Hook management, or other product configuration incidentally. -- Stable Event, Product Domain, and Runtime Port DTOs may be reused. Do not - depend on `bitfun-core`, Agent Runtime implementations, SDK Host, services, - Tauri, terminal, tool runtime, or remote transports. -- Use only Windows Named Pipes or Unix Domain Sockets. Do not add TCP, HTTP, - WebSocket, browser access, or remote fallback. -- Treat this as same-user local isolation, not a sandbox. Product composition - must supply a user-private runtime directory. -- Embedded callers must continue to invoke the typed Agent Runtime directly and - must not initialize this transport. Shared outgoing request, response, and - event frames are encoded once before write; strict decoding, unknown-field - rejection, frame limits, bounded queues, and backpressure must not be weakened - for throughput. - -## Verification - -```bash -cargo test -p bitfun-agent-runtime-ipc -node scripts/check-core-boundaries.mjs -``` diff --git a/src/crates/adapters/agent-runtime-ipc/Cargo.toml b/src/crates/adapters/agent-runtime-ipc/Cargo.toml deleted file mode 100644 index e1d2209288..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "bitfun-agent-runtime-ipc" -version.workspace = true -authors.workspace = true -edition.workspace = true -publish = false -description = "Private pre-integration IPC seam for a shared BitFun Agent Runtime" - -[lib] -name = "bitfun_agent_runtime_ipc" -crate-type = ["rlib"] - -[dependencies] -async-trait = { workspace = true } -bitfun-events = { path = "../../contracts/events" } -bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false } -bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } -dunce = { workspace = true } -fs2 = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -sha2 = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "sync", "time"] } -uuid = { workspace = true } -tempfile = { workspace = true } - -[target.'cfg(windows)'.dependencies] -windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } - -[lints] -workspace = true diff --git a/src/crates/adapters/agent-runtime-ipc/src/client.rs b/src/crates/adapters/agent-runtime-ipc/src/client.rs deleted file mode 100644 index 835f2ee41c..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/client.rs +++ /dev/null @@ -1,579 +0,0 @@ -use crate::{ - read_frame_strict_with_limit, serialize_frame_with_limit, write_frame, - write_serialized_frame_with_limit, DiscoveryRecord, HealthResult, InitializeRequest, - LocalIpcEndpoint, RuntimeIpcCapabilities, RuntimeIpcError, RuntimeIpcFrame, - RuntimeIpcFrameReader, RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcTransportError, MAX_REQUEST_FRAME_BYTES, MAX_RESPONSE_FRAME_BYTES, PROTOCOL_VERSION, -}; -use std::fmt; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{broadcast, mpsc, oneshot, watch, Mutex, OwnedMutexGuard}; - -const CLIENT_EVENT_BUFFER: usize = 256; -const CLIENT_COMMAND_BUFFER: usize = 64; - -#[derive(Debug, Clone, PartialEq)] -pub enum RuntimeIpcClientEvent { - Runtime(crate::RuntimeIpcEvent), - Disconnected, -} - -#[derive(Clone)] -pub struct RuntimeIpcClient { - commands: mpsc::Sender, - instance_identity: String, - request_timeout: Duration, - request_gate: Arc>, - next_request_id: Arc, - events: broadcast::Sender, - capabilities: RuntimeIpcCapabilities, - disconnect: watch::Sender, -} - -struct ClientCommand { - request_id: u64, - frame_bytes: Vec, - response: oneshot::Sender, - deadline: tokio::time::Instant, - release_gate_after_write: bool, - _request_gate: Option>, -} - -struct PendingRequest { - response: oneshot::Sender, - request_gate: Option>, - release_gate_after_write: bool, -} - -struct ClientWrite { - request_id: u64, - frame_bytes: Vec, - deadline: tokio::time::Instant, -} - -enum ClientWriteOutcome { - Complete { - request_id: u64, - }, - Timeout { - request_id: u64, - }, - FrameTooLarge { - request_id: u64, - error: RuntimeIpcIoError, - }, - Disconnected { - request_id: u64, - }, -} - -enum PendingResponse { - Result(RuntimeIpcOperationResult), - Remote(RuntimeIpcError), - Timeout, - Io(RuntimeIpcIoError), - Disconnected, -} - -impl fmt::Debug for RuntimeIpcClient { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("RuntimeIpcClient") - .field("instance_identity", &self.instance_identity) - .field("request_timeout", &self.request_timeout) - .field("capabilities", &self.capabilities) - .finish_non_exhaustive() - } -} - -impl RuntimeIpcClient { - pub async fn connect( - runtime_root: &Path, - discovery: &DiscoveryRecord, - client_id: &str, - client_version: &str, - connect_timeout: Duration, - request_timeout: Duration, - ) -> Result { - if connect_timeout.is_zero() || request_timeout.is_zero() { - return Err(RuntimeIpcClientError::InvalidTimeout); - } - if discovery.protocol_version != PROTOCOL_VERSION { - return Err(RuntimeIpcClientError::IncompatibleProtocol { - expected: PROTOCOL_VERSION, - observed: discovery.protocol_version, - }); - } - validate_client_fact(client_id)?; - validate_client_fact(client_version)?; - let endpoint = LocalIpcEndpoint::parse_for_root( - &discovery.endpoint, - runtime_root, - &discovery.instance_identity, - )?; - let mut stream = endpoint.connect(connect_timeout).await?; - let request_id = 1; - timeout_io( - connect_timeout, - write_frame( - &mut stream, - &RuntimeIpcFrame::Initialize { - request_id, - request: InitializeRequest { - protocol_version: PROTOCOL_VERSION, - instance_identity: discovery.instance_identity.as_str().to_string(), - token: discovery.token.clone(), - client_id: client_id.to_string(), - client_version: client_version.to_string(), - }, - }, - ), - ) - .await?; - let response = timeout_io( - connect_timeout, - read_frame_strict_with_limit(&mut stream, MAX_RESPONSE_FRAME_BYTES), - ) - .await?; - let capabilities = match response { - RuntimeIpcFrame::Initialized { - request_id: response_id, - result, - } if response_id == request_id - && result.protocol_version == PROTOCOL_VERSION - && result.instance_identity == discovery.instance_identity.as_str() - && result.capabilities.health => - { - result.capabilities - } - RuntimeIpcFrame::Error { - request_id: Some(response_id), - error, - } if response_id == request_id => return Err(RuntimeIpcClientError::Remote(error)), - _ => return Err(RuntimeIpcClientError::UnexpectedResponse), - }; - - let (events, _) = broadcast::channel(CLIENT_EVENT_BUFFER); - let (commands, command_rx) = mpsc::channel(CLIENT_COMMAND_BUFFER); - let (disconnect, disconnect_rx) = watch::channel(false); - tokio::spawn(run_connection( - stream, - command_rx, - events.clone(), - disconnect_rx, - )); - - Ok(Self { - commands, - instance_identity: discovery.instance_identity.as_str().to_string(), - request_timeout, - request_gate: Arc::new(Mutex::new(())), - next_request_id: Arc::new(AtomicU64::new(2)), - events, - capabilities, - disconnect, - }) - } - - pub fn capabilities(&self) -> &RuntimeIpcCapabilities { - &self.capabilities - } - - pub fn subscribe_events(&self) -> broadcast::Receiver { - self.events.subscribe() - } - - pub async fn request( - &self, - operation: RuntimeIpcOperation, - ) -> Result { - let request_gate = self.request_gate.clone().lock_owned().await; - let release_gate_after_write = operation.is_interruptible_lineage_read(); - let request_id = self - .next_request_id - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { - value.checked_add(1) - }) - .map_err(|_| RuntimeIpcClientError::RequestIdExhausted)?; - let frame_bytes = serialize_frame_with_limit( - &RuntimeIpcFrame::Request { - request_id, - operation, - }, - MAX_REQUEST_FRAME_BYTES, - ) - .map_err(RuntimeIpcClientError::RequestEncoding)?; - let deadline = tokio::time::Instant::now() + self.request_timeout; - let (sender, receiver) = oneshot::channel(); - match tokio::time::timeout_at( - deadline, - self.commands.send(ClientCommand { - request_id, - frame_bytes, - response: sender, - deadline, - release_gate_after_write, - _request_gate: Some(request_gate), - }), - ) - .await - { - Err(_) => { - let _ = self.disconnect.send(true); - return Err(RuntimeIpcClientError::Timeout); - } - Ok(Err(_)) => return Err(RuntimeIpcClientError::Disconnected), - Ok(Ok(())) => {} - } - - let response = match tokio::time::timeout_at(deadline, receiver).await { - Err(_) => { - let _ = self.disconnect.send(true); - return Err(RuntimeIpcClientError::Timeout); - } - Ok(Err(_)) => return Err(RuntimeIpcClientError::Disconnected), - Ok(Ok(response)) => response, - }; - match response { - PendingResponse::Result(result) => Ok(result), - PendingResponse::Remote(error) => Err(RuntimeIpcClientError::Remote(error)), - PendingResponse::Timeout => Err(RuntimeIpcClientError::Timeout), - PendingResponse::Io(error) => Err(RuntimeIpcClientError::Io(error)), - PendingResponse::Disconnected => Err(RuntimeIpcClientError::Disconnected), - } - } - - pub async fn health(&self) -> Result { - match self.request(RuntimeIpcOperation::Health).await? { - RuntimeIpcOperationResult::Health { - instance_identity, - process_id, - } if instance_identity == self.instance_identity => Ok(HealthResult { - instance_identity, - process_id, - }), - _ => Err(RuntimeIpcClientError::UnexpectedResponse), - } - } -} - -async fn run_connection( - stream: S, - mut commands: mpsc::Receiver, - events: broadcast::Sender, - mut disconnect: watch::Receiver, -) where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, -{ - let (mut reader, writer) = tokio::io::split(stream); - let (writes, write_rx) = mpsc::channel(1); - let (write_outcomes, mut write_outcome_rx) = mpsc::unbounded_channel(); - let writer = tokio::spawn(run_writer(writer, write_rx, write_outcomes)); - let mut pending = std::collections::HashMap::new(); - let mut frames = RuntimeIpcFrameReader::new(MAX_RESPONSE_FRAME_BYTES); - loop { - tokio::select! { - biased; - changed = disconnect.changed() => { - if changed.is_err() || *disconnect.borrow() { - break; - } - } - command = commands.recv() => { - let Some(command) = command else { - break; - }; - let request_id = command.request_id; - if tokio::time::Instant::now() >= command.deadline { - let _ = command.response.send(PendingResponse::Timeout); - continue; - } - pending.insert(request_id, PendingRequest { - response: command.response, - request_gate: command._request_gate, - release_gate_after_write: command.release_gate_after_write, - }); - if writes.try_send(ClientWrite { - request_id, - frame_bytes: command.frame_bytes, - deadline: command.deadline, - }).is_err() { - if let Some(request) = pending.remove(&request_id) { - let _ = request.response.send(PendingResponse::Disconnected); - } - break; - } - }, - outcome = write_outcome_rx.recv() => match outcome { - Some(ClientWriteOutcome::Complete { request_id }) => { - if let Some(request) = pending.get_mut(&request_id) { - if request.release_gate_after_write { - request.request_gate.take(); - } - } - } - Some(ClientWriteOutcome::FrameTooLarge { request_id, error }) => { - if let Some(request) = pending.remove(&request_id) { - let _ = request.response.send(PendingResponse::Io(error)); - } - } - Some(ClientWriteOutcome::Timeout { request_id }) => { - if let Some(request) = pending.remove(&request_id) { - let _ = request.response.send(PendingResponse::Timeout); - } - break; - } - Some(ClientWriteOutcome::Disconnected { request_id }) => { - if let Some(request) = pending.remove(&request_id) { - let _ = request.response.send(PendingResponse::Disconnected); - } - break; - } - None => break, - }, - frame = frames.read_strict(&mut reader) => match frame { - Ok(RuntimeIpcFrame::Response { request_id, result }) => { - if let Some(request) = pending.remove(&request_id) { - let _ = request.response.send(PendingResponse::Result(result)); - } else { - break; - } - } - Ok(RuntimeIpcFrame::Error { - request_id: Some(request_id), - error, - }) => { - if let Some(request) = pending.remove(&request_id) { - let _ = request.response.send(PendingResponse::Remote(error)); - } else { - break; - } - } - Ok(RuntimeIpcFrame::Event { event }) => { - let _ = events.send(RuntimeIpcClientEvent::Runtime(event)); - } - Ok(_) | Err(_) => break, - } - } - } - - drop(writes); - writer.abort(); - let _ = writer.await; - for (_, request) in pending.drain() { - let _ = request.response.send(PendingResponse::Disconnected); - } - let _ = events.send(RuntimeIpcClientEvent::Disconnected); -} - -async fn run_writer( - mut writer: W, - mut writes: mpsc::Receiver, - outcomes: mpsc::UnboundedSender, -) where - W: tokio::io::AsyncWrite + Unpin, -{ - while let Some(write) = writes.recv().await { - let request_id = write.request_id; - let outcome = match tokio::time::timeout_at( - write.deadline, - write_serialized_frame_with_limit( - &mut writer, - &write.frame_bytes, - MAX_REQUEST_FRAME_BYTES, - ), - ) - .await - { - Err(_) => ClientWriteOutcome::Timeout { request_id }, - Ok(Err(error @ RuntimeIpcIoError::FrameTooLarge { .. })) => { - ClientWriteOutcome::FrameTooLarge { request_id, error } - } - Ok(Err(_)) => ClientWriteOutcome::Disconnected { request_id }, - Ok(Ok(())) => ClientWriteOutcome::Complete { request_id }, - }; - let terminal = matches!( - outcome, - ClientWriteOutcome::Timeout { .. } | ClientWriteOutcome::Disconnected { .. } - ); - if outcomes.send(outcome).is_err() || terminal { - break; - } - } -} - -async fn timeout_io( - timeout: Duration, - future: impl std::future::Future>, -) -> Result { - tokio::time::timeout(timeout, future) - .await - .map_err(|_| RuntimeIpcClientError::Timeout)? - .map_err(RuntimeIpcClientError::Io) -} - -fn validate_client_fact(value: &str) -> Result<(), RuntimeIpcClientError> { - if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { - return Err(RuntimeIpcClientError::InvalidClientIdentity); - } - Ok(()) -} - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeIpcClientError { - #[error("runtime IPC protocol mismatch: expected {expected}, observed {observed}")] - IncompatibleProtocol { expected: u32, observed: u32 }, - #[error("runtime IPC client identity is invalid")] - InvalidClientIdentity, - #[error("runtime IPC request timed out")] - Timeout, - #[error("runtime IPC connection closed")] - Disconnected, - #[error("runtime IPC returned an unexpected response")] - UnexpectedResponse, - #[error("runtime IPC request identifiers are exhausted")] - RequestIdExhausted, - #[error("runtime IPC request could not be encoded")] - RequestEncoding(#[source] RuntimeIpcIoError), - #[error("runtime IPC timeouts must be greater than zero")] - InvalidTimeout, - #[error("runtime IPC request was rejected: {0:?}")] - Remote(RuntimeIpcError), - #[error(transparent)] - Transport(#[from] RuntimeIpcTransportError), - #[error(transparent)] - Io(#[from] RuntimeIpcIoError), -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{write_frame_with_limit, RuntimeSessionRenameRequest}; - - #[tokio::test] - async fn connection_reads_a_large_response_while_a_large_request_is_still_writing() { - let (client_stream, mut server_stream) = tokio::io::duplex(64); - let (commands, command_rx) = mpsc::channel(CLIENT_COMMAND_BUFFER); - let (events, _) = broadcast::channel(CLIENT_EVENT_BUFFER); - let (disconnect, disconnect_rx) = watch::channel(false); - let connection = tokio::spawn(run_connection( - client_stream, - command_rx, - events, - disconnect_rx, - )); - - let server = tokio::spawn(async move { - let mut frames = RuntimeIpcFrameReader::new(MAX_REQUEST_FRAME_BYTES); - let first = frames.read_strict(&mut server_stream).await.unwrap(); - assert!(matches!( - first, - RuntimeIpcFrame::Request { request_id: 2, .. } - )); - - // Observe the second write without draining it. With a single - // read/write task this fills both directions and deadlocks. - frames - .wait_for_frame_start(&mut server_stream) - .await - .unwrap(); - write_frame_with_limit( - &mut server_stream, - &RuntimeIpcFrame::Response { - request_id: 2, - result: RuntimeIpcOperationResult::Health { - instance_identity: "response".repeat(4_096), - process_id: 7, - }, - }, - MAX_RESPONSE_FRAME_BYTES, - ) - .await - .unwrap(); - - let second = frames.read_strict(&mut server_stream).await.unwrap(); - assert!(matches!( - second, - RuntimeIpcFrame::Request { request_id: 3, .. } - )); - write_frame_with_limit( - &mut server_stream, - &RuntimeIpcFrame::Response { - request_id: 3, - result: RuntimeIpcOperationResult::Unit, - }, - MAX_RESPONSE_FRAME_BYTES, - ) - .await - .unwrap(); - }); - - let deadline = tokio::time::Instant::now() + Duration::from_secs(2); - let request_gate = Arc::new(Mutex::new(())); - let (first_sender, first_response) = oneshot::channel(); - commands - .send(ClientCommand { - request_id: 2, - frame_bytes: serialize_frame_with_limit( - &RuntimeIpcFrame::Request { - request_id: 2, - operation: RuntimeIpcOperation::Health, - }, - MAX_REQUEST_FRAME_BYTES, - ) - .unwrap(), - response: first_sender, - deadline, - release_gate_after_write: true, - _request_gate: Some(request_gate.clone().lock_owned().await), - }) - .await - .unwrap(); - - let (second_sender, second_response) = oneshot::channel(); - let second_request_gate = request_gate.lock_owned().await; - commands - .send(ClientCommand { - request_id: 3, - frame_bytes: serialize_frame_with_limit( - &RuntimeIpcFrame::Request { - request_id: 3, - operation: RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: "session".to_string(), - session_name: "request".repeat(8_192), - }, - }, - }, - MAX_REQUEST_FRAME_BYTES, - ) - .unwrap(), - response: second_sender, - deadline, - release_gate_after_write: false, - _request_gate: Some(second_request_gate), - }) - .await - .unwrap(); - - tokio::time::timeout(Duration::from_millis(500), async { - assert!(matches!( - first_response.await.unwrap(), - PendingResponse::Result(RuntimeIpcOperationResult::Health { .. }) - )); - assert!(matches!( - second_response.await.unwrap(), - PendingResponse::Result(RuntimeIpcOperationResult::Unit) - )); - server.await.unwrap(); - }) - .await - .expect("full-duplex IPC must continue reading while a request write is backpressured"); - - let _ = disconnect.send(true); - connection.await.unwrap(); - } -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/discovery.rs b/src/crates/adapters/agent-runtime-ipc/src/discovery.rs deleted file mode 100644 index 8046b6ba9a..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/discovery.rs +++ /dev/null @@ -1,525 +0,0 @@ -use fs2::FileExt; -use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; -use sha2::{Digest, Sha256}; -use std::fmt::{self, Write as _}; -use std::fs::{File, OpenOptions}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use crate::PROTOCOL_VERSION; - -const MAX_DISCOVERY_BYTES: u64 = 16 * 1024; -const DISCOVERY_REPLACE_ATTEMPTS: usize = 6; -const DISCOVERY_READ_ATTEMPTS: usize = 4; - -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct RuntimeInstanceIdentity(String); - -impl RuntimeInstanceIdentity { - pub fn for_workspace( - workspace_root: &Path, - product_identity: &str, - release_channel: &str, - user_identity: &str, - protocol_version: u32, - ) -> Result { - validate_identity_part(product_identity)?; - validate_identity_part(release_channel)?; - validate_identity_part(user_identity)?; - let canonical_workspace = dunce::canonicalize(workspace_root).map_err(|source| { - RuntimeIpcDiscoveryError::CanonicalizeWorkspace { - path: workspace_root.to_path_buf(), - source, - } - })?; - - let mut hasher = Sha256::new(); - hasher.update(b"bitfun-agent-runtime-instance-v2\0"); - for part in [product_identity, release_channel, user_identity] { - hasher.update(part.as_bytes()); - hasher.update(b"\0"); - } - hasher.update(protocol_version.to_le_bytes()); - hasher.update(b"\0"); - hash_canonical_path(&mut hasher, &canonical_workspace); - let digest = hasher.finalize(); - let mut encoded = String::with_capacity(digest.len() * 2); - for byte in digest { - write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); - } - Ok(Self(encoded)) - } - - pub fn parse(value: impl Into) -> Result { - let value = value.into(); - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(RuntimeIpcDiscoveryError::InvalidInstanceIdentity); - } - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for RuntimeInstanceIdentity { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_tuple("RuntimeInstanceIdentity") - .field(&self.0) - .finish() - } -} - -impl Serialize for RuntimeInstanceIdentity { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } -} - -impl<'de> Deserialize<'de> for RuntimeInstanceIdentity { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(value).map_err(D::Error::custom) - } -} - -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DiscoveryRecord { - pub protocol_version: u32, - pub instance_identity: RuntimeInstanceIdentity, - pub endpoint: String, - pub process_id: u32, - pub token: String, - pub owner_id: String, -} - -impl DiscoveryRecord { - pub fn new( - instance_identity: RuntimeInstanceIdentity, - endpoint: String, - process_id: u32, - token: String, - owner_id: String, - ) -> Self { - Self { - protocol_version: PROTOCOL_VERSION, - instance_identity, - endpoint, - process_id, - token, - owner_id, - } - } -} - -impl fmt::Debug for DiscoveryRecord { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("DiscoveryRecord") - .field("protocol_version", &self.protocol_version) - .field("instance_identity", &self.instance_identity) - .field("endpoint", &self.endpoint) - .field("process_id", &self.process_id) - .field("token", &"[REDACTED]") - .field("owner_id", &self.owner_id) - .finish() - } -} - -pub struct DiscoveryStore { - root: PathBuf, - identity: RuntimeInstanceIdentity, -} - -impl DiscoveryStore { - pub fn new(root: &Path, identity: RuntimeInstanceIdentity) -> Self { - Self { - root: root.to_path_buf(), - identity, - } - } - - pub fn identity(&self) -> &RuntimeInstanceIdentity { - &self.identity - } - - pub fn read(&self) -> Result, RuntimeIpcDiscoveryError> { - let path = self.record_path(); - let Some(mut file) = open_discovery_with_retry(&path)? else { - return Ok(None); - }; - let size = file - .metadata() - .map_err(|source| RuntimeIpcDiscoveryError::ReadDiscovery { - path: path.clone(), - source, - })? - .len(); - if size > MAX_DISCOVERY_BYTES { - return Err(RuntimeIpcDiscoveryError::DiscoveryTooLarge { size }); - } - let mut bytes = Vec::with_capacity(size as usize); - file.read_to_end(&mut bytes) - .map_err(|source| RuntimeIpcDiscoveryError::ReadDiscovery { - path: path.clone(), - source, - })?; - let record = serde_json::from_slice::(&bytes) - .map_err(RuntimeIpcDiscoveryError::InvalidDiscovery)?; - if record.instance_identity != self.identity { - return Err(RuntimeIpcDiscoveryError::WrongInstanceIdentity); - } - Ok(Some(record)) - } - - pub fn write(&self, record: &DiscoveryRecord) -> Result<(), RuntimeIpcDiscoveryError> { - if record.instance_identity != self.identity { - return Err(RuntimeIpcDiscoveryError::WrongInstanceIdentity); - } - ensure_private_directory(&self.root)?; - let bytes = - serde_json::to_vec(record).map_err(RuntimeIpcDiscoveryError::SerializeDiscovery)?; - if bytes.len() as u64 > MAX_DISCOVERY_BYTES { - return Err(RuntimeIpcDiscoveryError::DiscoveryTooLarge { - size: bytes.len() as u64, - }); - } - let path = self.record_path(); - let mut temporary = tempfile::NamedTempFile::new_in(&self.root).map_err(|source| { - RuntimeIpcDiscoveryError::WriteDiscovery { - path: path.clone(), - source, - } - })?; - temporary - .write_all(&bytes) - .and_then(|_| temporary.as_file().sync_all()) - .map_err(|source| RuntimeIpcDiscoveryError::WriteDiscovery { - path: path.clone(), - source, - })?; - let temporary_path = temporary.into_temp_path(); - replace_discovery_atomically(temporary_path.as_ref(), &path) - .map_err(|source| RuntimeIpcDiscoveryError::WriteDiscovery { path, source }) - } - - pub fn remove_if_owned( - &self, - expected: &DiscoveryRecord, - ) -> Result { - if self.read()?.as_ref() != Some(expected) { - return Ok(false); - } - let path = self.record_path(); - match std::fs::remove_file(&path) { - Ok(()) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(source) => Err(RuntimeIpcDiscoveryError::RemoveDiscovery { path, source }), - } - } - - fn record_path(&self) -> PathBuf { - self.root.join(format!("{}.json", self.identity.as_str())) - } -} - -pub struct RuntimeInstanceLock { - file: File, -} - -impl RuntimeInstanceLock { - pub fn try_acquire( - root: &Path, - identity: &RuntimeInstanceIdentity, - ) -> Result { - ensure_private_directory(root)?; - let path = root.join(format!("{}.instance.lock", identity.as_str())); - let mut options = OpenOptions::new(); - options.create(true).truncate(false).read(true).write(true); - configure_private_file(&mut options); - let file = - options - .open(&path) - .map_err(|source| RuntimeIpcDiscoveryError::OpenInstanceLock { - path: path.clone(), - source, - })?; - FileExt::try_lock_exclusive(&file) - .map_err(|source| RuntimeIpcDiscoveryError::InstanceAlreadyOwned { source })?; - Ok(Self { file }) - } -} - -impl Drop for RuntimeInstanceLock { - fn drop(&mut self) { - let _ = FileExt::unlock(&self.file); - } -} - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeIpcDiscoveryError { - #[error( - "runtime identity parts must be non-empty, bounded, and contain no control characters" - )] - InvalidIdentityPart, - #[error("runtime instance identity must be a lowercase SHA-256 digest")] - InvalidInstanceIdentity, - #[error("failed to canonicalize runtime workspace {path}")] - CanonicalizeWorkspace { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to create private runtime directory {path}")] - CreateRuntimeDirectory { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to read runtime discovery record {path}")] - ReadDiscovery { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to write runtime discovery record {path}")] - WriteDiscovery { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to remove runtime discovery record {path}")] - RemoveDiscovery { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("runtime discovery record exceeds {MAX_DISCOVERY_BYTES} bytes: {size}")] - DiscoveryTooLarge { size: u64 }, - #[error("runtime discovery record is invalid")] - InvalidDiscovery(#[source] serde_json::Error), - #[error("failed to serialize runtime discovery record")] - SerializeDiscovery(#[source] serde_json::Error), - #[error("runtime discovery record belongs to another instance")] - WrongInstanceIdentity, - #[error("failed to open runtime instance lock {path}")] - OpenInstanceLock { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("runtime instance is already owned")] - InstanceAlreadyOwned { - #[source] - source: std::io::Error, - }, -} - -fn validate_identity_part(value: &str) -> Result<(), RuntimeIpcDiscoveryError> { - if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { - return Err(RuntimeIpcDiscoveryError::InvalidIdentityPart); - } - Ok(()) -} - -fn hash_canonical_path(hasher: &mut Sha256, path: &Path) { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - hasher.update(path.as_os_str().as_bytes()); - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - for unit in path.as_os_str().encode_wide() { - hasher.update(unit.to_le_bytes()); - } - } -} - -fn ensure_private_directory(path: &Path) -> Result<(), RuntimeIpcDiscoveryError> { - std::fs::create_dir_all(path).map_err(|source| { - RuntimeIpcDiscoveryError::CreateRuntimeDirectory { - path: path.to_path_buf(), - source, - } - })?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err( - |source| RuntimeIpcDiscoveryError::CreateRuntimeDirectory { - path: path.to_path_buf(), - source, - }, - )?; - } - Ok(()) -} - -fn configure_private_file(options: &mut OpenOptions) { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - #[cfg(not(unix))] - let _ = options; -} - -fn open_discovery_for_read(path: &Path) -> std::io::Result { - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - use windows::Win32::Storage::FileSystem::{ - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - }; - options.share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0); - } - options.open(path) -} - -fn open_discovery_with_retry(path: &Path) -> Result, RuntimeIpcDiscoveryError> { - let mut last_transient_error = None; - for attempt in 0..DISCOVERY_READ_ATTEMPTS { - match open_discovery_for_read(path) { - Ok(file) => return Ok(Some(file)), - Err(error) if is_transient_discovery_read_error(&error) => { - last_transient_error = Some(error); - if attempt + 1 < DISCOVERY_READ_ATTEMPTS { - std::thread::sleep(Duration::from_millis(1)); - } - } - Err(source) => { - return Err(RuntimeIpcDiscoveryError::ReadDiscovery { - path: path.to_path_buf(), - source, - }); - } - } - } - match last_transient_error { - Some(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Some(source) => Err(RuntimeIpcDiscoveryError::ReadDiscovery { - path: path.to_path_buf(), - source, - }), - None => unreachable!("a non-empty read retry loop records a terminal result"), - } -} - -fn is_transient_discovery_read_error(error: &std::io::Error) -> bool { - error.kind() == std::io::ErrorKind::NotFound - || cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)) -} - -fn replace_discovery_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { - let mut last_error = None; - for attempt in 0..DISCOVERY_REPLACE_ATTEMPTS { - match replace_discovery_once(temporary, target) { - Ok(()) => { - sync_discovery_parent(target)?; - return Ok(()); - } - Err(error) if is_retryable_replace_error(&error) => { - last_error = Some(error); - if attempt + 1 < DISCOVERY_REPLACE_ATTEMPTS { - std::thread::sleep(Duration::from_millis(2 * (attempt as u64 + 1))); - } - } - Err(error) => return Err(error), - } - } - Err(last_error.expect("a bounded replace loop always records its final error")) -} - -#[cfg(windows)] -fn replace_discovery_once(temporary: &Path, target: &Path) -> std::io::Result<()> { - use std::os::windows::ffi::OsStrExt; - use windows::core::PCWSTR; - use windows::Win32::Storage::FileSystem::{ - MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, REPLACEFILE_WRITE_THROUGH, - }; - - let target_exists = target.exists(); - let temporary = temporary - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let target = target - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // SAFETY: both buffers are owned, NUL-terminated UTF-16 paths that remain - // alive for the call. The temporary file is created in the target's - // directory, so replacement cannot cross volumes. - unsafe { - if target_exists { - ReplaceFileW( - PCWSTR(target.as_ptr()), - PCWSTR(temporary.as_ptr()), - PCWSTR::null(), - REPLACEFILE_WRITE_THROUGH, - None, - None, - ) - } else { - MoveFileExW( - PCWSTR(temporary.as_ptr()), - PCWSTR(target.as_ptr()), - MOVEFILE_WRITE_THROUGH, - ) - } - } - .map_err(|error| std::io::Error::other(error.to_string())) -} - -#[cfg(not(windows))] -fn replace_discovery_once(temporary: &Path, target: &Path) -> std::io::Result<()> { - std::fs::rename(temporary, target) -} - -#[cfg(unix)] -fn sync_discovery_parent(target: &Path) -> std::io::Result<()> { - File::open( - target - .parent() - .ok_or_else(|| std::io::Error::other("discovery path has no parent"))?, - )? - .sync_all() -} - -#[cfg(not(unix))] -fn sync_discovery_parent(_target: &Path) -> std::io::Result<()> { - Ok(()) -} - -fn is_retryable_replace_error(error: &std::io::Error) -> bool { - matches!( - error.kind(), - std::io::ErrorKind::PermissionDenied - | std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::Interrupted - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::AlreadyExists - | std::io::ErrorKind::Other - ) -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/framing.rs b/src/crates/adapters/agent-runtime-ipc/src/framing.rs deleted file mode 100644 index 18bc3568e7..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/framing.rs +++ /dev/null @@ -1,248 +0,0 @@ -use crate::RuntimeIpcFrame; -use std::io::Write; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; - -pub(crate) const MAX_REQUEST_FRAME_BYTES: usize = 128 * 1024; -pub(crate) const MAX_RESPONSE_FRAME_BYTES: usize = 8 * 1024 * 1024; - -pub(crate) async fn write_frame( - writer: &mut W, - frame: &RuntimeIpcFrame, -) -> Result<(), RuntimeIpcIoError> -where - W: AsyncWrite + Unpin, -{ - write_frame_with_limit(writer, frame, MAX_REQUEST_FRAME_BYTES).await -} - -pub(crate) async fn write_frame_with_limit( - writer: &mut W, - frame: &RuntimeIpcFrame, - max_bytes: usize, -) -> Result<(), RuntimeIpcIoError> -where - W: AsyncWrite + Unpin, -{ - let bytes = serialize_frame_with_limit(frame, max_bytes)?; - write_serialized_frame_with_limit(writer, &bytes, max_bytes).await -} - -pub(crate) async fn write_serialized_frame_with_limit( - writer: &mut W, - bytes: &[u8], - max_bytes: usize, -) -> Result<(), RuntimeIpcIoError> -where - W: AsyncWrite + Unpin, -{ - if bytes.len() > max_bytes { - return Err(RuntimeIpcIoError::FrameTooLarge { - size: bytes.len(), - max_bytes, - }); - } - writer - .write_u32(bytes.len() as u32) - .await - .map_err(RuntimeIpcIoError::Io)?; - writer - .write_all(bytes) - .await - .map_err(RuntimeIpcIoError::Io)?; - writer.flush().await.map_err(RuntimeIpcIoError::Io) -} - -pub(crate) async fn read_frame(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - read_frame_strict_with_limit(reader, MAX_REQUEST_FRAME_BYTES).await -} - -pub(crate) async fn read_frame_strict_with_limit( - reader: &mut R, - max_bytes: usize, -) -> Result -where - R: AsyncRead + Unpin, -{ - RuntimeIpcFrameReader::new(max_bytes) - .read_strict(reader) - .await -} - -pub(crate) struct RuntimeIpcFrameReader { - max_bytes: usize, - buffer: Vec, -} - -impl RuntimeIpcFrameReader { - pub(crate) fn new(max_bytes: usize) -> Self { - Self { - max_bytes, - buffer: Vec::new(), - } - } - - pub(crate) async fn read_strict( - &mut self, - reader: &mut R, - ) -> Result - where - R: AsyncRead + Unpin, - { - self.fill(reader, 4).await?; - let size = - u32::from_be_bytes(self.buffer[..4].try_into().expect("four-byte header")) as usize; - if size > self.max_bytes { - return Err(RuntimeIpcIoError::FrameTooLarge { - size, - max_bytes: self.max_bytes, - }); - } - self.fill(reader, size + 4).await?; - let payload = self.buffer.split_off(4); - self.buffer.clear(); - parse_strict_frame(&payload) - } - - pub(crate) fn frame_started(&self) -> bool { - !self.buffer.is_empty() - } - - pub(crate) async fn wait_for_frame_start( - &mut self, - reader: &mut R, - ) -> Result<(), RuntimeIpcIoError> - where - R: AsyncRead + Unpin, - { - self.fill(reader, 1).await - } - - async fn fill(&mut self, reader: &mut R, target: usize) -> Result<(), RuntimeIpcIoError> - where - R: AsyncRead + Unpin, - { - let mut chunk = [0; 8 * 1024]; - while self.buffer.len() < target { - let remaining = (target - self.buffer.len()).min(chunk.len()); - match reader - .read(&mut chunk[..remaining]) - .await - .map_err(RuntimeIpcIoError::Io)? - { - 0 => { - return Err(RuntimeIpcIoError::Io( - std::io::ErrorKind::UnexpectedEof.into(), - )) - } - read => self.buffer.extend_from_slice(&chunk[..read]), - } - } - Ok(()) - } -} - -fn parse_strict_frame(bytes: &[u8]) -> Result { - let original = serde_json::from_slice::(bytes) - .map_err(RuntimeIpcIoError::Deserialize)?; - let frame = serde_json::from_value(original.clone()).map_err(RuntimeIpcIoError::Deserialize)?; - let canonical = serde_json::to_value(&frame).map_err(RuntimeIpcIoError::Serialize)?; - if let Some(path) = first_unknown_field(&original, &canonical, "$".to_string()) { - return Err(RuntimeIpcIoError::UnknownField { path }); - } - Ok(frame) -} - -pub(crate) fn serialize_frame_with_limit( - frame: &RuntimeIpcFrame, - max_bytes: usize, -) -> Result, RuntimeIpcIoError> { - let mut writer = CappedWriter::new(max_bytes); - let result = serde_json::to_writer(&mut writer, frame); - if writer.overflowed { - return Err(RuntimeIpcIoError::FrameTooLarge { - size: max_bytes.saturating_add(1), - max_bytes, - }); - } - result.map_err(RuntimeIpcIoError::Serialize)?; - Ok(writer.bytes) -} - -struct CappedWriter { - bytes: Vec, - max_bytes: usize, - overflowed: bool, -} - -impl CappedWriter { - fn new(max_bytes: usize) -> Self { - Self { - bytes: Vec::with_capacity(max_bytes.min(16 * 1024)), - max_bytes, - overflowed: false, - } - } -} - -impl Write for CappedWriter { - fn write(&mut self, input: &[u8]) -> std::io::Result { - let remaining = self.max_bytes.saturating_sub(self.bytes.len()); - if input.len() > remaining { - self.bytes.extend_from_slice(&input[..remaining]); - self.overflowed = true; - return Err(std::io::Error::other("runtime IPC frame is too large")); - } - self.bytes.extend_from_slice(input); - Ok(input.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -fn first_unknown_field( - original: &serde_json::Value, - canonical: &serde_json::Value, - path: String, -) -> Option { - match (original, canonical) { - (serde_json::Value::Object(original), serde_json::Value::Object(canonical)) => { - for (key, value) in original { - let next_path = format!("{path}.{key}"); - let Some(canonical_value) = canonical.get(key) else { - return Some(next_path); - }; - if let Some(unknown) = first_unknown_field(value, canonical_value, next_path) { - return Some(unknown); - } - } - None - } - (serde_json::Value::Array(original), serde_json::Value::Array(canonical)) => original - .iter() - .zip(canonical) - .enumerate() - .find_map(|(index, (original, canonical))| { - first_unknown_field(original, canonical, format!("{path}[{index}]")) - }), - _ => None, - } -} - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeIpcIoError { - #[error("runtime IPC frame exceeds {max_bytes} bytes: {size}")] - FrameTooLarge { size: usize, max_bytes: usize }, - #[error("runtime IPC transport failed")] - Io(#[source] std::io::Error), - #[error("failed to serialize runtime IPC frame")] - Serialize(#[source] serde_json::Error), - #[error("runtime IPC frame is invalid")] - Deserialize(#[source] serde_json::Error), - #[error("runtime IPC frame contains an unknown field at {path}")] - UnknownField { path: String }, -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/handler.rs b/src/crates/adapters/agent-runtime-ipc/src/handler.rs deleted file mode 100644 index 3b2c9fde7f..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/handler.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::{RuntimeIpcError, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult}; -use async_trait::async_trait; -use tokio::sync::{broadcast, watch}; - -#[async_trait] -pub trait RuntimeIpcRequestHandler: Send + Sync { - /// Rejects clients once authoritative event delivery is permanently lost. - fn ensure_available(&self) -> Result<(), RuntimeIpcError> { - Ok(()) - } - - /// Sticky process-level availability for authenticated connections that - /// have not attached to a Session yet. - fn subscribe_availability(&self) -> Option> { - None - } - - async fn execute( - &self, - operation: RuntimeIpcOperation, - ) -> Result; - - fn subscribe_events( - &self, - session_id: &str, - ) -> Result, RuntimeIpcError>; -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/ipc.rs b/src/crates/adapters/agent-runtime-ipc/src/ipc.rs deleted file mode 100644 index 9bc108e256..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/ipc.rs +++ /dev/null @@ -1,316 +0,0 @@ -use crate::RuntimeInstanceIdentity; -use std::path::Path; -#[cfg(unix)] -use std::path::PathBuf; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Duration; -#[cfg(windows)] -use std::time::Instant; -use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; - -#[cfg(unix)] -const MAX_PORTABLE_UDS_PATH_BYTES: usize = 103; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct LocalIpcEndpoint { - discovery_value: String, - #[cfg(unix)] - path: PathBuf, -} - -impl LocalIpcEndpoint { - pub(crate) fn for_instance( - runtime_root: &Path, - identity: &RuntimeInstanceIdentity, - ) -> Result { - let suffix = &identity.as_str()[..16]; - #[cfg(windows)] - { - let _ = runtime_root; - Ok(Self { - discovery_value: format!(r"\\.\pipe\bitfun-agent-runtime-{suffix}"), - }) - } - #[cfg(unix)] - { - let root = dunce::canonicalize(runtime_root) - .map_err(RuntimeIpcTransportError::CanonicalizeRuntimeRoot)?; - let discovery_value = format!("bf-ar-{suffix}.sock"); - let path = root.join(&discovery_value); - validate_uds_path_length(&path)?; - Ok(Self { - discovery_value, - path, - }) - } - } - - pub(crate) fn parse_for_root( - value: &str, - runtime_root: &Path, - identity: &RuntimeInstanceIdentity, - ) -> Result { - let expected = Self::for_instance(runtime_root, identity)?; - if value != expected.discovery_value { - return Err(RuntimeIpcTransportError::InvalidEndpoint); - } - Ok(expected) - } - - pub(crate) fn discovery_value(&self) -> &str { - &self.discovery_value - } - - #[cfg(unix)] - pub(crate) fn as_path(&self) -> &Path { - &self.path - } - - #[cfg(windows)] - fn as_pipe_name(&self) -> &str { - &self.discovery_value - } - - pub(crate) async fn connect( - &self, - deadline: Duration, - ) -> Result { - if deadline.is_zero() { - return Err(RuntimeIpcTransportError::InvalidDeadline); - } - #[cfg(windows)] - { - use tokio::net::windows::named_pipe::ClientOptions; - let expires_at = Instant::now() + deadline; - loop { - match ClientOptions::new().open(self.as_pipe_name()) { - Ok(client) => return Ok(LocalIpcStream::WindowsClient(client)), - Err(error) if Instant::now() < expires_at => { - let remaining = expires_at.saturating_duration_since(Instant::now()); - tokio::time::sleep(remaining.min(Duration::from_millis(10))).await; - if error.kind() != std::io::ErrorKind::NotFound - && error.raw_os_error() != Some(231) - { - return Err(RuntimeIpcTransportError::Connect(error)); - } - } - Err(error) => return Err(RuntimeIpcTransportError::Connect(error)), - } - } - } - #[cfg(unix)] - { - let stream = - tokio::time::timeout(deadline, tokio::net::UnixStream::connect(self.as_path())) - .await - .map_err(|_| RuntimeIpcTransportError::ConnectTimeout)? - .map_err(RuntimeIpcTransportError::Connect)?; - Ok(LocalIpcStream::Unix(stream)) - } - } -} - -#[cfg(unix)] -fn validate_uds_path_length(path: &Path) -> Result<(), RuntimeIpcTransportError> { - use std::os::unix::ffi::OsStrExt; - let observed = path.as_os_str().as_bytes().len(); - if observed > MAX_PORTABLE_UDS_PATH_BYTES { - return Err(RuntimeIpcTransportError::EndpointTooLong { - observed, - maximum: MAX_PORTABLE_UDS_PATH_BYTES, - }); - } - Ok(()) -} - -pub(crate) struct LocalIpcListener { - endpoint: LocalIpcEndpoint, - #[cfg(windows)] - server: Option, - #[cfg(unix)] - listener: tokio::net::UnixListener, -} - -impl LocalIpcListener { - pub(crate) async fn bind(endpoint: LocalIpcEndpoint) -> Result { - #[cfg(windows)] - { - let server = create_windows_server(&endpoint, true)?; - Ok(Self { - endpoint, - server: Some(server), - }) - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let path = endpoint.as_path(); - remove_stale_unix_socket(path)?; - let listener = - tokio::net::UnixListener::bind(path).map_err(RuntimeIpcTransportError::Bind)?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .map_err(RuntimeIpcTransportError::Bind)?; - Ok(Self { endpoint, listener }) - } - } - - pub(crate) async fn accept(&mut self) -> Result { - #[cfg(windows)] - { - self.server - .as_mut() - .expect("Windows listener always owns an accept instance") - .connect() - .await - .map_err(RuntimeIpcTransportError::Accept)?; - let server = self - .server - .take() - .expect("connected Windows listener instance must remain owned"); - self.server = Some(create_windows_server(&self.endpoint, false)?); - Ok(LocalIpcStream::WindowsServer(server)) - } - #[cfg(unix)] - { - let (stream, _) = self - .listener - .accept() - .await - .map_err(RuntimeIpcTransportError::Accept)?; - Ok(LocalIpcStream::Unix(stream)) - } - } -} - -#[cfg(windows)] -fn create_windows_server( - endpoint: &LocalIpcEndpoint, - first: bool, -) -> Result { - use tokio::net::windows::named_pipe::ServerOptions; - ServerOptions::new() - .first_pipe_instance(first) - .reject_remote_clients(true) - .create(endpoint.as_pipe_name()) - .map_err(RuntimeIpcTransportError::Bind) -} - -#[cfg(unix)] -fn remove_stale_unix_socket(path: &Path) -> Result<(), RuntimeIpcTransportError> { - use std::os::unix::fs::FileTypeExt; - match std::fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_socket() => { - std::fs::remove_file(path).map_err(RuntimeIpcTransportError::Bind) - } - Ok(_) => Err(RuntimeIpcTransportError::EndpointOccupied), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(RuntimeIpcTransportError::Bind(error)), - } -} - -impl Drop for LocalIpcListener { - fn drop(&mut self) { - #[cfg(unix)] - { - let _ = remove_stale_unix_socket(self.endpoint.as_path()); - } - } -} - -pub(crate) enum LocalIpcStream { - #[cfg(windows)] - WindowsClient(tokio::net::windows::named_pipe::NamedPipeClient), - #[cfg(windows)] - WindowsServer(tokio::net::windows::named_pipe::NamedPipeServer), - #[cfg(unix)] - Unix(tokio::net::UnixStream), -} - -impl AsyncRead for LocalIpcStream { - fn poll_read( - self: Pin<&mut Self>, - context: &mut Context<'_>, - buffer: &mut ReadBuf<'_>, - ) -> Poll> { - match self.get_mut() { - #[cfg(windows)] - Self::WindowsClient(stream) => Pin::new(stream).poll_read(context, buffer), - #[cfg(windows)] - Self::WindowsServer(stream) => Pin::new(stream).poll_read(context, buffer), - #[cfg(unix)] - Self::Unix(stream) => Pin::new(stream).poll_read(context, buffer), - } - } -} - -impl AsyncWrite for LocalIpcStream { - fn poll_write( - self: Pin<&mut Self>, - context: &mut Context<'_>, - buffer: &[u8], - ) -> Poll> { - match self.get_mut() { - #[cfg(windows)] - Self::WindowsClient(stream) => Pin::new(stream).poll_write(context, buffer), - #[cfg(windows)] - Self::WindowsServer(stream) => Pin::new(stream).poll_write(context, buffer), - #[cfg(unix)] - Self::Unix(stream) => Pin::new(stream).poll_write(context, buffer), - } - } - - fn poll_flush( - self: Pin<&mut Self>, - context: &mut Context<'_>, - ) -> Poll> { - match self.get_mut() { - #[cfg(windows)] - Self::WindowsClient(stream) => Pin::new(stream).poll_flush(context), - #[cfg(windows)] - Self::WindowsServer(stream) => Pin::new(stream).poll_flush(context), - #[cfg(unix)] - Self::Unix(stream) => Pin::new(stream).poll_flush(context), - } - } - - fn poll_shutdown( - self: Pin<&mut Self>, - context: &mut Context<'_>, - ) -> Poll> { - match self.get_mut() { - #[cfg(windows)] - Self::WindowsClient(stream) => Pin::new(stream).poll_shutdown(context), - #[cfg(windows)] - Self::WindowsServer(stream) => Pin::new(stream).poll_shutdown(context), - #[cfg(unix)] - Self::Unix(stream) => Pin::new(stream).poll_shutdown(context), - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeIpcTransportError { - #[error("runtime IPC endpoint is invalid")] - InvalidEndpoint, - #[cfg(unix)] - #[error("runtime IPC endpoint path is too long: {observed} bytes exceeds {maximum}")] - EndpointTooLong { observed: usize, maximum: usize }, - #[cfg(unix)] - #[error("runtime IPC endpoint path is occupied by a non-socket entry")] - EndpointOccupied, - #[error("runtime IPC deadline must be greater than zero")] - InvalidDeadline, - #[cfg(unix)] - #[error("failed to canonicalize runtime IPC directory")] - CanonicalizeRuntimeRoot(#[source] std::io::Error), - #[error("failed to bind runtime IPC endpoint")] - Bind(#[source] std::io::Error), - #[error("failed to accept runtime IPC connection")] - Accept(#[source] std::io::Error), - #[error("failed to connect to runtime IPC endpoint")] - Connect(#[source] std::io::Error), - #[cfg(unix)] - #[error("timed out connecting to runtime IPC endpoint")] - ConnectTimeout, -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/lib.rs b/src/crates/adapters/agent-runtime-ipc/src/lib.rs deleted file mode 100644 index 233d3d5979..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Private Shared TUI IPC for discovery, framing, and leases; not a public API or Runtime owner. - -mod client; -mod discovery; -mod framing; -mod handler; -mod ipc; -mod operation; -mod protocol; -mod server; -mod session_lease; - -pub use client::{RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent}; -pub use discovery::{ - DiscoveryRecord, DiscoveryStore, RuntimeInstanceIdentity, RuntimeInstanceLock, - RuntimeIpcDiscoveryError, -}; -pub use framing::RuntimeIpcIoError; -pub(crate) use framing::{ - read_frame, read_frame_strict_with_limit, serialize_frame_with_limit, write_frame, - write_frame_with_limit, write_serialized_frame_with_limit, RuntimeIpcFrameReader, - MAX_REQUEST_FRAME_BYTES, MAX_RESPONSE_FRAME_BYTES, -}; -pub use handler::RuntimeIpcRequestHandler; -pub use ipc::RuntimeIpcTransportError; -pub(crate) use ipc::{LocalIpcEndpoint, LocalIpcListener, LocalIpcStream}; -pub use operation::{ - RuntimeAgentModeSummary, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeSessionForkRequest, RuntimeSessionProcessingPhase, RuntimeSessionRenameRequest, - RuntimeSessionRestoreRequest, RuntimeSessionState, RuntimeUserAnswersRequest, -}; -pub use protocol::{ - HealthResult, InitializeRequest, InitializeResult, RuntimeIpcCapabilities, RuntimeIpcError, - RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcFrame, RuntimeIpcStreamInvalidationReason, - PROTOCOL_VERSION, -}; -pub use server::{RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcServerError}; -pub(crate) use session_lease::{LeaseTransition, RuntimeSessionLeases}; - -#[cfg(test)] -#[path = "tests/discovery_and_framing.rs"] -mod discovery_and_framing_tests; -#[cfg(test)] -#[path = "tests/local_health.rs"] -mod local_health_tests; -#[cfg(test)] -#[path = "tests/protocol_contracts.rs"] -mod protocol_contract_tests; -#[cfg(test)] -#[path = "tests/shared_controller.rs"] -mod shared_controller_tests; diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs deleted file mode 100644 index 15558630cd..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ /dev/null @@ -1,563 +0,0 @@ -use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; -use bitfun_runtime_ports::{ - AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnRequest, - AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, - AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, - AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionLineageCancellationRequest, - AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, - AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentSessionUsageRequest, AgentSessionWorkspaceBinding, - AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementRequest, - AgentUserShellCommandRequest, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, SessionTranscript, SessionUsageReport, - WorkspaceDiffSnapshot, -}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeSessionRestoreRequest { - pub workspace_path: String, - pub session_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeSessionRenameRequest { - pub session_id: String, - pub session_name: String, -} - -/// Forks the controlled Session at its latest persisted turn, or immediately -/// before `before_turn_id` when the TUI selected a historical user prompt. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeSessionForkRequest { - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub before_turn_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeUserAnswersRequest { - pub session_id: String, - pub tool_id: String, - pub answers: serde_json::Value, -} - -/// Minimal host-owned main-agent catalog consumed by Shared TUI selectors. -/// Runtime generation keys and provider-specific source state never cross IPC. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeAgentModeSummary { - pub id: String, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_id: Option, - #[serde(default)] - pub is_external: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde( - tag = "kind", - rename_all = "snake_case", - rename_all_fields = "camelCase" -)] -pub enum RuntimeSessionState { - Idle, - Processing { - current_turn_id: String, - phase: RuntimeSessionProcessingPhase, - }, - Error { - error: String, - recoverable: bool, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeSessionProcessingPhase { - Starting, - Compacting, - Thinking, - Streaming, - ToolCalling, - ToolConfirming, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "operation", - rename_all = "snake_case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum RuntimeIpcOperation { - Health, - ListAgentModes { - #[serde(default, skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - ListSessions { - request: AgentSessionListRequest, - }, - CreateSession { - request: AgentSessionCreateRequest, - }, - RestoreSession { - request: RuntimeSessionRestoreRequest, - }, - DeleteSession { - session_id: String, - }, - UpdateSessionMode { - request: AgentSessionModeUpdateRequest, - }, - UpdateSessionModel { - request: AgentSessionModelUpdateRequest, - }, - RenameSession { - request: RuntimeSessionRenameRequest, - }, - ForkSession { - request: RuntimeSessionForkRequest, - }, - ReloadSessionContext { - request: AgentContextReloadRequest, - }, - CompactSession { - request: AgentSessionCompactionRequest, - }, - UndoSession { - request: AgentSessionRevertRequest, - }, - RedoSession { - request: AgentSessionRevertRequest, - }, - SessionUsage { - request: AgentSessionUsageRequest, - }, - WaitForSettlement { - request: AgentTurnSettlementRequest, - }, - SearchWorkspaceReferences { - request: AgentWorkspaceReferenceSearchRequest, - }, - WorkspaceReferencesForMessage { - request: AgentMessageWorkspaceReferencesRequest, - }, - GetSessionLineage { - request: AgentSessionLineageRequest, - }, - InspectLineageSession { - request: AgentSessionLineageTranscriptRequest, - }, - CancelLineageSession { - request: AgentSessionLineageCancellationRequest, - }, - WorkspaceDiff, - SubmitTurn { - request: AgentDialogTurnRequest, - }, - SteerTurn { - request: AgentDialogSteerRequest, - }, - RunUserShellCommand { - request: AgentUserShellCommandRequest, - }, - CancelTurn { - request: AgentTurnCancellationRequest, - }, - PendingPermissions { - session_id: String, - }, - RespondPermission { - session_id: String, - request_id: String, - reply: PermissionReply, - }, - SubmitUserAnswers { - request: RuntimeUserAnswersRequest, - }, - RecordLocalCommandTurn { - request: AgentLocalCommandTurnRecordRequest, - }, -} - -impl RuntimeIpcOperation { - /// These speculative reads may be superseded by a newer request from the - /// same TUI connection. The server keeps their execution outside the - /// connection's serial control path so cancellation and Session changes - /// are never queued behind transcript I/O. - pub(crate) fn is_interruptible_lineage_read(&self) -> bool { - matches!( - self, - Self::GetSessionLineage { .. } | Self::InspectLineageSession { .. } - ) - } - - pub fn session_id(&self) -> Option<&str> { - match self { - Self::ListAgentModes { - session_id: Some(session_id), - } => Some(session_id), - Self::RestoreSession { request } => Some(&request.session_id), - Self::DeleteSession { session_id } => Some(session_id), - Self::UpdateSessionMode { request } => Some(&request.session_id), - Self::UpdateSessionModel { request } => Some(&request.session_id), - Self::RenameSession { request } => Some(&request.session_id), - Self::ForkSession { request } => Some(&request.session_id), - Self::ReloadSessionContext { request } => Some(&request.session_id), - Self::CompactSession { request } => Some(&request.session_id), - Self::UndoSession { request } => Some(&request.session_id), - Self::RedoSession { request } => Some(&request.session_id), - Self::SessionUsage { request } => Some(&request.session_id), - Self::WaitForSettlement { request } => Some(&request.session_id), - Self::SearchWorkspaceReferences { request } => Some(&request.session_id), - Self::WorkspaceReferencesForMessage { request } => Some(&request.session_id), - Self::GetSessionLineage { request } => Some(&request.anchor_session_id), - Self::InspectLineageSession { request } => Some(&request.root_session_id), - Self::CancelLineageSession { request } => Some(&request.root_session_id), - Self::SubmitTurn { request } => Some(&request.session_id), - Self::SteerTurn { request } => Some(&request.session_id), - Self::RunUserShellCommand { request } => Some(&request.session_id), - Self::CancelTurn { request } => Some(&request.session_id), - Self::PendingPermissions { session_id } - | Self::RespondPermission { session_id, .. } => Some(session_id), - Self::SubmitUserAnswers { request } => Some(&request.session_id), - Self::RecordLocalCommandTurn { request } => Some(&request.session_id), - Self::Health - | Self::ListAgentModes { session_id: None } - | Self::ListSessions { .. } - | Self::CreateSession { .. } - | Self::WorkspaceDiff => None, - } - } - - pub(crate) fn rules(&self) -> RuntimeIpcOperationRules { - use RuntimeIpcSessionRequirement::{ - AttachExisting, CurrentController, None, UncontrolledTarget, - }; - - match self { - Self::Health - | Self::ListAgentModes { - session_id: std::option::Option::None, - } - | Self::ListSessions { .. } => RuntimeIpcOperationRules::new(None, false, false, false), - Self::ListAgentModes { - session_id: Some(_), - } => RuntimeIpcOperationRules::new(CurrentController, false, false, false), - Self::WorkspaceDiff => RuntimeIpcOperationRules::new(None, true, false, false), - Self::CreateSession { .. } => RuntimeIpcOperationRules::new(None, true, true, true), - Self::RestoreSession { .. } => { - RuntimeIpcOperationRules::new(AttachExisting, true, true, true) - } - Self::DeleteSession { .. } => { - RuntimeIpcOperationRules::new(UncontrolledTarget, true, true, true) - } - Self::UpdateSessionMode { .. } - | Self::UpdateSessionModel { .. } - | Self::RenameSession { .. } - | Self::CompactSession { .. } - | Self::SubmitTurn { .. } - | Self::RunUserShellCommand { .. } => { - RuntimeIpcOperationRules::new(CurrentController, true, false, true) - } - Self::ForkSession { .. } => { - RuntimeIpcOperationRules::new(CurrentController, true, true, true) - } - Self::ReloadSessionContext { .. } - | Self::UndoSession { .. } - | Self::RedoSession { .. } - | Self::SteerTurn { .. } - | Self::CancelTurn { .. } - | Self::RespondPermission { .. } - | Self::SubmitUserAnswers { .. } => { - RuntimeIpcOperationRules::new(CurrentController, false, false, true) - } - Self::RecordLocalCommandTurn { .. } => { - RuntimeIpcOperationRules::new(CurrentController, true, false, true) - } - Self::PendingPermissions { .. } => { - RuntimeIpcOperationRules::new(CurrentController, false, false, false) - } - Self::SessionUsage { .. } | Self::WaitForSettlement { .. } => { - RuntimeIpcOperationRules::new(CurrentController, false, false, false) - } - Self::SearchWorkspaceReferences { .. } | Self::WorkspaceReferencesForMessage { .. } => { - RuntimeIpcOperationRules::new(CurrentController, false, false, false) - } - Self::GetSessionLineage { .. } | Self::InspectLineageSession { .. } => { - RuntimeIpcOperationRules::new(CurrentController, false, false, false) - } - Self::CancelLineageSession { .. } => { - RuntimeIpcOperationRules::new(CurrentController, false, false, true) - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RuntimeIpcSessionRequirement { - None, - CurrentController, - AttachExisting, - UncontrolledTarget, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct RuntimeIpcOperationRules { - pub(crate) session_requirement: RuntimeIpcSessionRequirement, - pub(crate) requires_idle: bool, - pub(crate) serializes_session_selection: bool, - pub(crate) side_effecting: bool, -} - -impl RuntimeIpcOperationRules { - const fn new( - session_requirement: RuntimeIpcSessionRequirement, - requires_idle: bool, - serializes_session_selection: bool, - side_effecting: bool, - ) -> Self { - Self { - session_requirement, - requires_idle, - serializes_session_selection, - side_effecting, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "result", - rename_all = "snake_case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum RuntimeIpcOperationResult { - Health { - instance_identity: String, - process_id: u32, - }, - Unit, - AgentModes { - modes: Vec, - }, - Sessions { - sessions: Vec, - }, - SessionCreated { - session: AgentSessionCreateResult, - }, - SessionRestored { - session: AgentSessionSummary, - state: RuntimeSessionState, - workspace_binding: AgentSessionWorkspaceBinding, - transcript: SessionTranscript, - pending_permissions: Vec, - }, - SessionForked { - session: AgentSessionSummary, - workspace_binding: AgentSessionWorkspaceBinding, - transcript: SessionTranscript, - }, - SessionReverted { - revert: AgentSessionRevertResult, - }, - SessionUsage { - usage: SessionUsageReport, - }, - SessionLineage { - snapshot: Option, - }, - LineageSessionInspection { - inspection: AgentSessionLineageInspection, - }, - TurnAccepted { - session_id: String, - turn_id: String, - }, - TurnSteered { - session_id: String, - turn_id: String, - steering_id: String, - }, - TurnCancelled { - cancellation: AgentTurnCancellationResult, - }, - PendingPermissions { - requests: Vec, - }, - WorkspaceReferenceSearch { - search: AgentWorkspaceReferenceSearchResult, - }, - WorkspaceReferences { - references: Vec, - }, - WorkspaceDiff { - snapshot: WorkspaceDiffSnapshot, - }, - LocalCommandTurnRecorded { - record: AgentLocalCommandTurnRecordResult, - }, -} - -#[cfg(test)] -mod tests { - use super::{RuntimeIpcOperation, RuntimeIpcSessionRequirement, RuntimeSessionRestoreRequest}; - use bitfun_runtime_ports::{ - AgentContextReloadRequest, AgentContextReloadTarget, AgentDialogSteerRequest, - AgentSessionLineageCancellationRequest, AgentSessionLineageRequest, - AgentSessionLineageTranscriptRequest, - }; - - #[test] - fn delete_rules_are_fail_closed_for_shared_session_selection() { - let rules = RuntimeIpcOperation::DeleteSession { - session_id: "session-2".to_string(), - } - .rules(); - - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::UncontrolledTarget - ); - assert!(rules.requires_idle); - assert!(rules.serializes_session_selection); - assert!(rules.side_effecting); - } - - #[test] - fn reload_rules_preserve_active_turn_semantics_after_rule_consolidation() { - let rules = RuntimeIpcOperation::ReloadSessionContext { - request: AgentContextReloadRequest { - session_id: "session-1".to_string(), - target: AgentContextReloadTarget::All, - }, - } - .rules(); - - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(rules.side_effecting); - } - - #[test] - fn steer_rules_require_the_current_controller_but_allow_an_active_turn() { - let operation = RuntimeIpcOperation::SteerTurn { - request: AgentDialogSteerRequest { - session_id: "session-1".to_string(), - turn_id: "turn-1".to_string(), - content: "check tests".to_string(), - display_content: None, - }, - }; - let rules = operation.rules(); - - assert_eq!(operation.session_id(), Some("session-1")); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(rules.side_effecting); - } - - #[test] - fn restore_and_pending_permission_rules_preserve_existing_behavior() { - let restore = RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: "D:/workspace/project".to_string(), - session_id: "session-2".to_string(), - }, - } - .rules(); - assert_eq!( - restore.session_requirement, - RuntimeIpcSessionRequirement::AttachExisting - ); - assert!(restore.requires_idle); - assert!(restore.serializes_session_selection); - assert!(restore.side_effecting); - - let pending = RuntimeIpcOperation::PendingPermissions { - session_id: "session-1".to_string(), - } - .rules(); - assert_eq!( - pending.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!pending.requires_idle); - assert!(!pending.serializes_session_selection); - assert!(!pending.side_effecting); - } - - #[test] - fn lineage_rules_keep_root_controller_and_allow_active_read_only_inspection() { - let query = RuntimeIpcOperation::GetSessionLineage { - request: AgentSessionLineageRequest { - workspace_path: "D:/workspace/project".to_string(), - anchor_session_id: "root-1".to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }, - }; - let inspect = RuntimeIpcOperation::InspectLineageSession { - request: AgentSessionLineageTranscriptRequest { - workspace_path: "D:/workspace/project".to_string(), - root_session_id: "root-1".to_string(), - session_id: "child-1".to_string(), - required_settled_turn_ids: Vec::new(), - remote_connection_id: None, - remote_ssh_host: None, - }, - }; - let cancel = RuntimeIpcOperation::CancelLineageSession { - request: AgentSessionLineageCancellationRequest { - workspace_path: "D:/workspace/project".to_string(), - root_session_id: "root-1".to_string(), - session_id: "child-1".to_string(), - expected_active_turn_id: Some("turn-child".to_string()), - source: None, - reason: None, - wait_timeout_ms: None, - remote_connection_id: None, - remote_ssh_host: None, - }, - }; - - for operation in [&query, &inspect] { - let rules = operation.rules(); - assert_eq!(operation.session_id(), Some("root-1")); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(!rules.side_effecting); - } - let cancel_rules = cancel.rules(); - assert_eq!(cancel.session_id(), Some("root-1")); - assert_eq!( - cancel_rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!cancel_rules.requires_idle); - assert!(!cancel_rules.serializes_session_selection); - assert!(cancel_rules.side_effecting); - } -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs deleted file mode 100644 index 628ad2f908..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ /dev/null @@ -1,147 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; - -use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; -use bitfun_events::AgenticEventEnvelope; -use bitfun_product_domains::tool_permissions::PermissionRequestEvent; - -pub const PROTOCOL_VERSION: u32 = 17; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum RuntimeIpcFrame { - Initialize { - request_id: u64, - request: InitializeRequest, - }, - Initialized { - request_id: u64, - result: InitializeResult, - }, - Request { - request_id: u64, - operation: RuntimeIpcOperation, - }, - Response { - request_id: u64, - result: RuntimeIpcOperationResult, - }, - Error { - request_id: Option, - error: RuntimeIpcError, - }, - Event { - event: RuntimeIpcEvent, - }, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "kind", - rename_all = "snake_case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum RuntimeIpcEvent { - Agent { - session_id: String, - envelope: AgenticEventEnvelope, - }, - Permission { - session_id: String, - event: PermissionRequestEvent, - }, - StreamInvalidated { - reason: RuntimeIpcStreamInvalidationReason, - }, -} - -impl RuntimeIpcEvent { - pub fn session_id(&self) -> Option<&str> { - match self { - Self::Agent { session_id, .. } | Self::Permission { session_id, .. } => { - Some(session_id) - } - Self::StreamInvalidated { .. } => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeIpcStreamInvalidationReason { - Lagged, - Closed, - FrameTooLarge, -} - -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InitializeRequest { - pub protocol_version: u32, - pub instance_identity: String, - pub token: String, - pub client_id: String, - pub client_version: String, -} - -impl fmt::Debug for InitializeRequest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("InitializeRequest") - .field("protocol_version", &self.protocol_version) - .field("instance_identity", &self.instance_identity) - .field("token", &"[REDACTED]") - .field("client_id", &self.client_id) - .field("client_version", &self.client_version) - .finish() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InitializeResult { - pub protocol_version: u32, - pub instance_identity: String, - pub server_version: String, - pub capabilities: RuntimeIpcCapabilities, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RuntimeIpcCapabilities { - pub health: bool, - #[serde(default)] - pub interactive_tui: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HealthResult { - pub instance_identity: String, - pub process_id: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeIpcErrorCode { - InvalidRequest, - Unauthorized, - IncompatibleProtocol, - WrongInstance, - FrameTooLarge, - NotFound, - SessionInUse, - ControllerRequired, - SessionMismatch, - OperationUnsupported, - OutcomeUnknown, - Unavailable, - Internal, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RuntimeIpcError { - pub code: RuntimeIpcErrorCode, - pub message: String, -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs deleted file mode 100644 index 998d266755..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ /dev/null @@ -1,1147 +0,0 @@ -use crate::operation::RuntimeIpcSessionRequirement; -use crate::{ - read_frame, serialize_frame_with_limit, write_frame_with_limit, - write_serialized_frame_with_limit, DiscoveryRecord, DiscoveryStore, InitializeResult, - LeaseTransition, LocalIpcEndpoint, LocalIpcListener, LocalIpcStream, RuntimeInstanceIdentity, - RuntimeInstanceLock, RuntimeIpcCapabilities, RuntimeIpcDiscoveryError, RuntimeIpcError, - RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcFrame, RuntimeIpcFrameReader, - RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, - RuntimeIpcTransportError, RuntimeSessionLeases, MAX_REQUEST_FRAME_BYTES, - MAX_RESPONSE_FRAME_BYTES, PROTOCOL_VERSION, -}; -use bitfun_events::AgenticEvent; -use bitfun_runtime_ports::{AgentSubmissionSource, AgentTurnCancellationRequest}; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{broadcast, watch}; -use tokio::task::JoinSet; -use uuid::Uuid; - -const MAX_CONNECTION_LIMIT: usize = 1024; - -#[derive(Debug, Clone)] -pub struct RuntimeIpcServerConfig { - pub server_version: String, - pub idle_timeout: Duration, - pub handshake_timeout: Duration, - pub request_timeout: Duration, - pub max_connections: usize, -} - -pub struct RuntimeIpcServer { - listener: LocalIpcListener, - #[cfg(test)] - endpoint: LocalIpcEndpoint, - discovery_store: DiscoveryStore, - discovery_record: DiscoveryRecord, - _instance_lock: RuntimeInstanceLock, - connection: Arc, - idle_timeout: Duration, - max_connections: usize, -} - -impl RuntimeIpcServer { - pub async fn bind( - runtime_root: &Path, - identity: RuntimeInstanceIdentity, - config: RuntimeIpcServerConfig, - ) -> Result { - Self::bind_inner(runtime_root, identity, config, None).await - } - - pub async fn bind_with_handler( - runtime_root: &Path, - identity: RuntimeInstanceIdentity, - config: RuntimeIpcServerConfig, - handler: Arc, - ) -> Result { - Self::bind_inner(runtime_root, identity, config, Some(handler)).await - } - - async fn bind_inner( - runtime_root: &Path, - identity: RuntimeInstanceIdentity, - config: RuntimeIpcServerConfig, - handler: Option>, - ) -> Result { - validate_server_config(&config)?; - let instance_lock = RuntimeInstanceLock::try_acquire(runtime_root, &identity)?; - let owner_id = Uuid::new_v4().simple().to_string(); - let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let endpoint = LocalIpcEndpoint::for_instance(runtime_root, &identity)?; - let listener = LocalIpcListener::bind(endpoint.clone()).await?; - let discovery_store = DiscoveryStore::new(runtime_root, identity.clone()); - let discovery_record = DiscoveryRecord::new( - identity.clone(), - endpoint.discovery_value().to_string(), - std::process::id(), - token.clone(), - owner_id, - ); - discovery_store.write(&discovery_record)?; - - Ok(Self { - listener, - #[cfg(test)] - endpoint, - discovery_store, - discovery_record, - _instance_lock: instance_lock, - connection: Arc::new(ConnectionConfig { - instance_identity: identity.as_str().to_string(), - token, - server_version: config.server_version, - handshake_timeout: config.handshake_timeout, - request_timeout: config.request_timeout, - handler, - leases: Arc::new(RuntimeSessionLeases::default()), - attachment_gate: Arc::new(tokio::sync::Mutex::new(())), - }), - idle_timeout: config.idle_timeout, - max_connections: config.max_connections, - }) - } - - pub fn discovery_record(&self) -> &DiscoveryRecord { - &self.discovery_record - } - - #[cfg(test)] - pub(crate) fn endpoint(&self) -> &LocalIpcEndpoint { - &self.endpoint - } - - pub async fn serve(mut self) -> Result<(), RuntimeIpcServerError> { - let result = self.serve_until_idle().await; - let cleanup = self - .discovery_store - .remove_if_owned(&self.discovery_record) - .map(|_| ()) - .map_err(RuntimeIpcServerError::Discovery); - result.and(cleanup) - } - - async fn serve_until_idle(&mut self) -> Result<(), RuntimeIpcServerError> { - let mut clients = JoinSet::new(); - loop { - if clients.is_empty() { - tokio::select! { - accepted = self.listener.accept() => { - let stream = accepted?; - spawn_connection(&mut clients, stream, self.connection.clone()); - } - _ = tokio::time::sleep(self.idle_timeout) => break, - } - } else if clients.len() >= self.max_connections { - observe_connection(clients.join_next().await)?; - } else { - tokio::select! { - accepted = self.listener.accept() => { - let stream = accepted?; - spawn_connection(&mut clients, stream, self.connection.clone()); - } - completed = clients.join_next() => { - observe_connection(completed)?; - } - } - } - } - Ok(()) - } -} - -impl Drop for RuntimeIpcServer { - fn drop(&mut self) { - let _ = self.discovery_store.remove_if_owned(&self.discovery_record); - } -} - -fn spawn_connection( - clients: &mut JoinSet>, - stream: LocalIpcStream, - config: Arc, -) { - clients.spawn(async move { handle_connection(stream, &config).await }); -} - -fn observe_connection( - completed: Option, tokio::task::JoinError>>, -) -> Result<(), RuntimeIpcServerError> { - match completed { - Some(Ok(Ok(()))) | Some(Ok(Err(_))) => Ok(()), - Some(Err(error)) => Err(RuntimeIpcServerError::ConnectionTask(error)), - None => Ok(()), - } -} - -struct ConnectionConfig { - instance_identity: String, - token: String, - server_version: String, - handshake_timeout: Duration, - request_timeout: Duration, - handler: Option>, - leases: Arc, - attachment_gate: Arc>, -} - -async fn handle_connection( - mut stream: LocalIpcStream, - config: &ConnectionConfig, -) -> Result<(), RuntimeIpcServerError> { - let connection_id = Uuid::new_v4().simple().to_string(); - let first = match timeout_read(config.handshake_timeout, &mut stream).await { - Ok(frame) => frame, - Err(RuntimeIpcServerError::Disconnected) => return Ok(()), - Err(error) => return Err(error), - }; - let (request_id, request) = match first { - RuntimeIpcFrame::Initialize { - request_id, - request, - } => (request_id, request), - frame => { - send_error( - &mut stream, - config.handshake_timeout, - request_id_of(&frame), - RuntimeIpcErrorCode::InvalidRequest, - "initialize must be the first frame", - ) - .await?; - return Ok(()); - } - }; - - if !constant_time_eq(request.token.as_bytes(), config.token.as_bytes()) { - send_error( - &mut stream, - config.handshake_timeout, - Some(request_id), - RuntimeIpcErrorCode::Unauthorized, - "runtime IPC authentication failed", - ) - .await?; - return Ok(()); - } - if request.protocol_version != PROTOCOL_VERSION { - send_error( - &mut stream, - config.handshake_timeout, - Some(request_id), - RuntimeIpcErrorCode::IncompatibleProtocol, - "runtime IPC protocol version is incompatible", - ) - .await?; - return Ok(()); - } - if request.instance_identity != config.instance_identity { - send_error( - &mut stream, - config.handshake_timeout, - Some(request_id), - RuntimeIpcErrorCode::WrongInstance, - "runtime IPC endpoint belongs to another instance", - ) - .await?; - return Ok(()); - } - if !valid_client_fact(&request.client_id) || !valid_client_fact(&request.client_version) { - send_error( - &mut stream, - config.handshake_timeout, - Some(request_id), - RuntimeIpcErrorCode::InvalidRequest, - "runtime IPC client identity is invalid", - ) - .await?; - return Ok(()); - } - - let interactive_tui = config - .handler - .as_ref() - .is_some_and(|handler| handler.ensure_available().is_ok()); - timeout_write( - config.handshake_timeout, - &mut stream, - &RuntimeIpcFrame::Initialized { - request_id, - result: InitializeResult { - protocol_version: PROTOCOL_VERSION, - instance_identity: config.instance_identity.clone(), - server_version: config.server_version.clone(), - capabilities: RuntimeIpcCapabilities { - health: true, - interactive_tui, - }, - }, - }, - ) - .await?; - - let mut events = None; - let mut availability = config - .handler - .as_ref() - .and_then(|handler| handler.subscribe_availability()); - let mut active_turn_id = None; - let result = run_initialized_connection( - &mut stream, - config, - &connection_id, - &mut events, - &mut availability, - &mut active_turn_id, - ) - .await; - cleanup_connection(config, &connection_id, active_turn_id, events.as_mut()).await; - match result { - Err(RuntimeIpcServerError::Disconnected) => Ok(()), - other => other, - } -} - -async fn run_initialized_connection( - stream: &mut LocalIpcStream, - config: &ConnectionConfig, - connection_id: &str, - events: &mut Option>, - availability: &mut Option>, - active_turn_id: &mut Option, -) -> Result<(), RuntimeIpcServerError> { - let mut frames = RuntimeIpcFrameReader::new(MAX_REQUEST_FRAME_BYTES); - let mut frame_deadline = None; - let mut interruptible_lineage_reads = JoinSet::new(); - let mut interruptible_lineage_request_id = None; - loop { - match next_connection_input( - config.request_timeout, - stream, - &mut frames, - &mut frame_deadline, - events.as_mut(), - availability.as_mut(), - &mut interruptible_lineage_reads, - ) - .await? - { - ConnectionInput::InterruptibleLineageReadCompleted(completed) => { - let Some(request_id) = interruptible_lineage_request_id.take() else { - continue; - }; - match completed { - Ok(InterruptibleLineageReadResult::Completed(result)) => match result { - Ok(result) => { - send_operation_result( - config.request_timeout, - stream, - request_id, - result, - ) - .await?; - } - Err(error) => { - send_runtime_error( - stream, - config.request_timeout, - Some(request_id), - error, - ) - .await?; - } - }, - Ok(InterruptibleLineageReadResult::Deadline) => { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::Unavailable, - "runtime lineage read exceeded its deadline", - ) - .await?; - } - Err(error) => { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::Internal, - &format!("runtime lineage read task stopped: {error}"), - ) - .await?; - } - } - } - ConnectionInput::Event(event) => { - if matches!(event, RuntimeIpcEvent::StreamInvalidated { .. }) { - timeout_write( - config.request_timeout, - stream, - &RuntimeIpcFrame::Event { event }, - ) - .await?; - return Err(RuntimeIpcServerError::EventStreamUnavailable); - } - let Some(attached) = config.leases.attached_session(connection_id) else { - continue; - }; - if event.session_id() != Some(attached.as_str()) { - continue; - } - if event_finishes_turn(&event, active_turn_id.as_deref()) { - *active_turn_id = None; - } - let frame = RuntimeIpcFrame::Event { event }; - let frame_bytes = match serialize_frame_with_limit(&frame, MAX_RESPONSE_FRAME_BYTES) - { - Ok(bytes) => bytes, - Err(RuntimeIpcIoError::FrameTooLarge { .. }) => { - timeout_write( - config.request_timeout, - stream, - &RuntimeIpcFrame::Event { - event: RuntimeIpcEvent::StreamInvalidated { - reason: - crate::RuntimeIpcStreamInvalidationReason::FrameTooLarge, - }, - }, - ) - .await?; - return Err(RuntimeIpcServerError::EventStreamUnavailable); - } - Err(error) => return Err(RuntimeIpcServerError::Io(error)), - }; - timeout_write_serialized(config.request_timeout, stream, &frame_bytes).await?; - } - ConnectionInput::EventLagged => { - return Err(RuntimeIpcServerError::EventStreamUnavailable) - } - ConnectionInput::EventClosed => { - return Err(RuntimeIpcServerError::EventStreamUnavailable) - } - ConnectionInput::RuntimeUnavailable => { - return Err(RuntimeIpcServerError::EventStreamUnavailable) - } - ConnectionInput::Frame(RuntimeIpcFrame::Request { - request_id, - operation, - }) => { - if let Some(superseded_request_id) = interruptible_lineage_request_id.take() { - interruptible_lineage_reads.abort_all(); - while interruptible_lineage_reads.join_next().await.is_some() {} - send_error( - stream, - config.request_timeout, - Some(superseded_request_id), - RuntimeIpcErrorCode::Unavailable, - "runtime lineage read was superseded by a newer request", - ) - .await?; - } - if matches!(operation, RuntimeIpcOperation::Health) { - send_operation_result( - config.request_timeout, - stream, - request_id, - RuntimeIpcOperationResult::Health { - instance_identity: config.instance_identity.clone(), - process_id: std::process::id(), - }, - ) - .await?; - continue; - } - let Some(handler) = config.handler.as_ref() else { - send_runtime_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcError { - code: RuntimeIpcErrorCode::OperationUnsupported, - message: - "runtime IPC server does not provide interactive TUI operations" - .to_string(), - }, - ) - .await?; - continue; - }; - if let Err(error) = handler.ensure_available() { - send_runtime_error(stream, config.request_timeout, Some(request_id), error) - .await?; - continue; - } - - let rules = operation.rules(); - if active_turn_id.is_some() && rules.requires_idle { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::SessionInUse, - "finish or cancel the active turn before starting this session operation", - ) - .await?; - continue; - } - if let RuntimeIpcOperation::SteerTurn { request } = &operation { - if active_turn_id.as_deref() != Some(request.turn_id.as_str()) { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::SessionInUse, - "steering requires the connection's exact active turn", - ) - .await?; - continue; - } - } - - // Serialize attachment so a newly visible Session cannot be claimed - // before its generated ID returns to the creating connection, or - // deleted while another connection is attaching it. - let _attachment_guard = if rules.serializes_session_selection { - Some(config.attachment_gate.lock().await) - } else { - None - }; - let mut lease_transition = - match prepare_operation(config, connection_id, &operation) { - Ok(transition) => transition, - Err(error) => { - send_runtime_error( - stream, - config.request_timeout, - Some(request_id), - error, - ) - .await?; - continue; - } - }; - if operation.is_interruptible_lineage_read() { - let handler = handler.clone(); - let request_timeout = config.request_timeout; - interruptible_lineage_reads.spawn(async move { - match tokio::time::timeout(request_timeout, handler.execute(operation)) - .await - { - Ok(result) => InterruptibleLineageReadResult::Completed(result), - Err(_) => InterruptibleLineageReadResult::Deadline, - } - }); - interruptible_lineage_request_id = Some(request_id); - continue; - } - let provisional_turn_id = match &operation { - RuntimeIpcOperation::SubmitTurn { request } => { - let Some(turn_id) = request.turn_id.clone() else { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::InvalidRequest, - "Shared TUI submit requires a stable turn id", - ) - .await?; - continue; - }; - *active_turn_id = Some(turn_id.clone()); - Some(turn_id) - } - RuntimeIpcOperation::RunUserShellCommand { request } => { - let turn_id = request.turn_id.clone(); - *active_turn_id = Some(turn_id.clone()); - Some(turn_id) - } - RuntimeIpcOperation::CompactSession { request } => { - let turn_id = request.turn_id.clone(); - *active_turn_id = Some(turn_id.clone()); - Some(turn_id) - } - _ => None, - }; - let steering_target = match &operation { - RuntimeIpcOperation::SteerTurn { request } => { - Some((request.session_id.clone(), request.turn_id.clone())) - } - _ => None, - }; - let side_effecting = rules.side_effecting; - let result = - tokio::time::timeout(config.request_timeout, handler.execute(operation)).await; - let result = match result { - Ok(Ok(result)) => result, - Ok(Err(error)) => { - if provisional_turn_id.is_some() { - *active_turn_id = None; - } - config - .leases - .rollback(connection_id, lease_transition.clone()); - send_runtime_error(stream, config.request_timeout, Some(request_id), error) - .await?; - continue; - } - Err(_) if provisional_turn_id.is_some() => { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::OutcomeUnknown, - "turn submission outcome is unknown; the connection will close and cancel the submitted turn id", - ) - .await?; - return Err(RuntimeIpcServerError::Disconnected); - } - Err(_) if side_effecting => { - config - .leases - .rollback(connection_id, lease_transition.clone()); - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::OutcomeUnknown, - "runtime operation outcome is unknown; inspect authoritative state before retrying", - ) - .await?; - return Err(RuntimeIpcServerError::Disconnected); - } - Err(_) => { - config - .leases - .rollback(connection_id, lease_transition.clone()); - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::Unavailable, - "runtime IPC operation exceeded its deadline", - ) - .await?; - continue; - } - }; - if matches!(result, RuntimeIpcOperationResult::SessionReverted { .. }) { - // Core has cancelled and drained the owned turn before applying - // the revert, so the connection must drop its provisional view. - *active_turn_id = None; - } - let response = RuntimeIpcFrame::Response { request_id, result }; - let revert_response = matches!( - &response, - RuntimeIpcFrame::Response { - result: RuntimeIpcOperationResult::SessionReverted { .. }, - .. - } - ); - let response_bytes = match serialize_frame_with_limit( - &response, - MAX_RESPONSE_FRAME_BYTES, - ) { - Err(RuntimeIpcIoError::FrameTooLarge { .. }) if revert_response => { - config.leases.rollback(connection_id, lease_transition); - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::OutcomeUnknown, - "session revert completed but its authoritative transcript exceeds the Shared TUI response limit; reconnect and restore the Session", - ) - .await?; - return Err(RuntimeIpcServerError::Disconnected); - } - Err(RuntimeIpcIoError::FrameTooLarge { .. }) => { - config.leases.rollback(connection_id, lease_transition); - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::FrameTooLarge, - "runtime IPC response exceeds the supported frame size", - ) - .await?; - continue; - } - Err(error) => return Err(RuntimeIpcServerError::Io(error)), - Ok(bytes) => bytes, - }; - - let RuntimeIpcFrame::Response { result, .. } = &response else { - unreachable!("response frame was just constructed") - }; - let created_session_id = match result { - RuntimeIpcOperationResult::SessionCreated { session } => { - Some(session.session_id.as_str()) - } - RuntimeIpcOperationResult::SessionForked { session, .. } => { - Some(session.session_id.as_str()) - } - _ => None, - }; - if let Some(created_session_id) = created_session_id { - lease_transition = match config.leases.switch(connection_id, created_session_id) - { - Ok(transition) => transition, - Err(error) => { - send_runtime_error( - stream, - config.request_timeout, - Some(request_id), - error, - ) - .await?; - continue; - } - }; - } - let mut event_stream_unavailable = false; - if let Some(session_id) = match result { - RuntimeIpcOperationResult::SessionCreated { session } => { - Some(session.session_id.as_str()) - } - RuntimeIpcOperationResult::SessionRestored { session, .. } => { - Some(session.session_id.as_str()) - } - RuntimeIpcOperationResult::SessionForked { session, .. } => { - Some(session.session_id.as_str()) - } - _ => None, - } { - match handler.subscribe_events(session_id) { - Ok(receiver) => *events = Some(receiver), - Err(_) => event_stream_unavailable = true, - } - } - if let RuntimeIpcOperationResult::TurnAccepted { turn_id, .. } = result { - if provisional_turn_id.as_deref() != Some(turn_id.as_str()) { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::Internal, - "runtime returned a different turn id than the accepted turn operation", - ) - .await?; - return Err(RuntimeIpcServerError::Disconnected); - } - } - if let Some((expected_session_id, expected_turn_id)) = steering_target.as_ref() { - let identity_matches = matches!( - result, - RuntimeIpcOperationResult::TurnSteered { - session_id, - turn_id, - .. - } if session_id == expected_session_id && turn_id == expected_turn_id - ); - if !identity_matches { - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::Internal, - "runtime returned an invalid result for the steering operation", - ) - .await?; - return Err(RuntimeIpcServerError::Disconnected); - } - } - if let Err(error) = - timeout_write_serialized(config.request_timeout, stream, &response_bytes).await - { - config.leases.rollback(connection_id, lease_transition); - return Err(error); - } - if event_stream_unavailable { - return Err(RuntimeIpcServerError::EventStreamUnavailable); - } - } - ConnectionInput::Frame(frame) => { - send_error( - stream, - config.request_timeout, - request_id_of(&frame), - RuntimeIpcErrorCode::InvalidRequest, - "runtime IPC frame is not valid after initialization", - ) - .await?; - return Ok(()); - } - } - } -} - -enum ConnectionInput { - Frame(RuntimeIpcFrame), - Event(RuntimeIpcEvent), - EventLagged, - EventClosed, - RuntimeUnavailable, - InterruptibleLineageReadCompleted( - Result, - ), -} - -enum InterruptibleLineageReadResult { - Completed(Result), - Deadline, -} - -async fn next_connection_input( - timeout: Duration, - stream: &mut LocalIpcStream, - frames: &mut RuntimeIpcFrameReader, - frame_deadline: &mut Option, - events: Option<&mut broadcast::Receiver>, - availability: Option<&mut watch::Receiver>, - interruptible_lineage_reads: &mut JoinSet, -) -> Result { - tokio::select! { - frame = read_connected(timeout, stream, frames, frame_deadline) => frame.map(ConnectionInput::Frame), - completed = receive_interruptible_lineage_read(interruptible_lineage_reads) => { - Ok(ConnectionInput::InterruptibleLineageReadCompleted(completed)) - }, - event = receive_event(events) => match event { - None => std::future::pending().await, - Some(Ok(event)) => Ok(ConnectionInput::Event(event)), - Some(Err(broadcast::error::RecvError::Lagged(_))) => Ok(ConnectionInput::EventLagged), - Some(Err(broadcast::error::RecvError::Closed)) => Ok(ConnectionInput::EventClosed), - }, - () = wait_until_unavailable(availability) => Ok(ConnectionInput::RuntimeUnavailable), - } -} - -async fn receive_interruptible_lineage_read( - reads: &mut JoinSet, -) -> Result { - if reads.is_empty() { - return std::future::pending().await; - } - reads - .join_next() - .await - .expect("a non-empty lineage read set should yield a task") -} - -async fn receive_event( - events: Option<&mut broadcast::Receiver>, -) -> Option> { - match events { - Some(events) => Some(events.recv().await), - None => std::future::pending().await, - } -} - -async fn wait_until_unavailable(availability: Option<&mut watch::Receiver>) { - let Some(availability) = availability else { - return std::future::pending().await; - }; - while *availability.borrow() { - if availability.changed().await.is_err() { - break; - } - } -} - -fn prepare_operation( - config: &ConnectionConfig, - connection_id: &str, - operation: &RuntimeIpcOperation, -) -> Result { - let session_id = operation.session_id(); - match operation.rules().session_requirement { - RuntimeIpcSessionRequirement::None => {} - RuntimeIpcSessionRequirement::CurrentController => config.leases.validate( - connection_id, - session_id.expect("controller operations are session scoped"), - )?, - RuntimeIpcSessionRequirement::AttachExisting => { - return config.leases.switch( - connection_id, - session_id.expect("attachment operations are session scoped"), - ); - } - RuntimeIpcSessionRequirement::UncontrolledTarget => config.leases.validate_uncontrolled( - session_id.expect("uncontrolled-target operations are session scoped"), - )?, - } - Ok(LeaseTransition::Unchanged) -} - -async fn cleanup_connection( - config: &ConnectionConfig, - connection_id: &str, - active_turn_id: Option, - events: Option<&mut broadcast::Receiver>, -) { - let session_id = config.leases.attached_session(connection_id); - let mut release_lease = true; - if let (Some(handler), Some(session_id), Some(turn_id)) = - (config.handler.as_ref(), session_id.as_ref(), active_turn_id) - { - let cancellation = handler.execute(RuntimeIpcOperation::CancelTurn { - request: AgentTurnCancellationRequest { - session_id: session_id.clone(), - turn_id: Some(turn_id.clone()), - source: Some(AgentSubmissionSource::Cli), - requester_session_id: None, - reason: Some("shared_tui_disconnected".to_string()), - wait_timeout_ms: None, - cancel_descendants: true, - }, - }); - let cancelled = matches!( - tokio::time::timeout(config.request_timeout, cancellation).await, - Ok(Ok(RuntimeIpcOperationResult::TurnCancelled { .. })) - ); - release_lease = - cancelled && wait_for_turn_terminal(config.request_timeout, events, &turn_id).await; - } - if release_lease { - config.leases.release_connection(connection_id); - } -} - -async fn wait_for_turn_terminal( - timeout: Duration, - events: Option<&mut broadcast::Receiver>, - turn_id: &str, -) -> bool { - let Some(events) = events else { return false }; - tokio::time::timeout(timeout, async { - loop { - match events.recv().await { - Ok(RuntimeIpcEvent::StreamInvalidated { .. }) => return false, - Ok(event) if event_finishes_turn(&event, Some(turn_id)) => return true, - Ok(_) => {} - Err(_) => return false, - } - } - }) - .await - .unwrap_or(false) -} - -fn event_finishes_turn(event: &RuntimeIpcEvent, active_turn_id: Option<&str>) -> bool { - let (RuntimeIpcEvent::Agent { envelope, .. }, Some(active_turn_id)) = (event, active_turn_id) - else { - return false; - }; - matches!( - &envelope.event, - AgenticEvent::DialogTurnCompleted { turn_id, .. } - | AgenticEvent::DialogTurnCancelled { turn_id, .. } - | AgenticEvent::DialogTurnFailed { turn_id, .. } - if turn_id == active_turn_id - ) -} - -async fn read_connected( - timeout: Duration, - stream: &mut LocalIpcStream, - frames: &mut RuntimeIpcFrameReader, - frame_deadline: &mut Option, -) -> Result { - if !frames.frame_started() { - map_connected_read(frames.wait_for_frame_start(stream).await)?; - *frame_deadline = Some(tokio::time::Instant::now() + timeout); - } - let deadline = *frame_deadline.get_or_insert_with(|| tokio::time::Instant::now() + timeout); - match tokio::time::timeout_at(deadline, frames.read_strict(stream)).await { - Err(_) => Err(RuntimeIpcServerError::IoTimeout), - Ok(result) => { - *frame_deadline = None; - map_connected_read(result) - } - } -} - -fn map_connected_read(result: Result) -> Result { - match result { - Err(RuntimeIpcIoError::Io(error)) - if matches!( - error.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::ConnectionReset - ) => - { - Err(RuntimeIpcServerError::Disconnected) - } - Err(error) => Err(RuntimeIpcServerError::Io(error)), - Ok(frame) => Ok(frame), - } -} - -async fn timeout_read( - timeout: Duration, - stream: &mut LocalIpcStream, -) -> Result { - match tokio::time::timeout(timeout, read_frame(stream)).await { - Err(_) => Err(RuntimeIpcServerError::IoTimeout), - Ok(Err(RuntimeIpcIoError::Io(error))) - if matches!( - error.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::ConnectionReset - ) => - { - Err(RuntimeIpcServerError::Disconnected) - } - Ok(Err(error)) => Err(RuntimeIpcServerError::Io(error)), - Ok(Ok(frame)) => Ok(frame), - } -} - -async fn timeout_write( - timeout: Duration, - stream: &mut LocalIpcStream, - frame: &RuntimeIpcFrame, -) -> Result<(), RuntimeIpcServerError> { - tokio::time::timeout( - timeout, - write_frame_with_limit(stream, frame, MAX_RESPONSE_FRAME_BYTES), - ) - .await - .map_err(|_| RuntimeIpcServerError::IoTimeout)? - .map_err(RuntimeIpcServerError::Io) -} - -async fn timeout_write_serialized( - timeout: Duration, - stream: &mut LocalIpcStream, - bytes: &[u8], -) -> Result<(), RuntimeIpcServerError> { - tokio::time::timeout( - timeout, - write_serialized_frame_with_limit(stream, bytes, MAX_RESPONSE_FRAME_BYTES), - ) - .await - .map_err(|_| RuntimeIpcServerError::IoTimeout)? - .map_err(RuntimeIpcServerError::Io) -} - -async fn send_error( - stream: &mut LocalIpcStream, - timeout: Duration, - request_id: Option, - code: RuntimeIpcErrorCode, - message: &str, -) -> Result<(), RuntimeIpcServerError> { - timeout_write( - timeout, - stream, - &RuntimeIpcFrame::Error { - request_id, - error: RuntimeIpcError { - code, - message: message.to_string(), - }, - }, - ) - .await -} - -async fn send_operation_result( - timeout: Duration, - stream: &mut LocalIpcStream, - request_id: u64, - result: RuntimeIpcOperationResult, -) -> Result<(), RuntimeIpcServerError> { - let frame = RuntimeIpcFrame::Response { request_id, result }; - match timeout_write(timeout, stream, &frame).await { - Err(RuntimeIpcServerError::Io(RuntimeIpcIoError::FrameTooLarge { .. })) => { - send_error( - stream, - timeout, - Some(request_id), - RuntimeIpcErrorCode::FrameTooLarge, - "runtime IPC response exceeds the supported frame size", - ) - .await - } - result => result, - } -} - -fn request_id_of(frame: &RuntimeIpcFrame) -> Option { - match frame { - RuntimeIpcFrame::Initialize { request_id, .. } - | RuntimeIpcFrame::Initialized { request_id, .. } - | RuntimeIpcFrame::Request { request_id, .. } - | RuntimeIpcFrame::Response { request_id, .. } => Some(*request_id), - RuntimeIpcFrame::Error { request_id, .. } => *request_id, - RuntimeIpcFrame::Event { .. } => None, - } -} - -async fn send_runtime_error( - stream: &mut LocalIpcStream, - timeout: Duration, - request_id: Option, - error: RuntimeIpcError, -) -> Result<(), RuntimeIpcServerError> { - timeout_write( - timeout, - stream, - &RuntimeIpcFrame::Error { request_id, error }, - ) - .await -} - -fn valid_client_fact(value: &str) -> bool { - !value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) -} - -fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { - let length = left.len().max(right.len()); - let mut difference = left.len() ^ right.len(); - for index in 0..length { - difference |= usize::from( - left.get(index).copied().unwrap_or_default() - ^ right.get(index).copied().unwrap_or_default(), - ); - } - difference == 0 -} - -fn validate_server_config(config: &RuntimeIpcServerConfig) -> Result<(), RuntimeIpcServerError> { - if config.server_version.is_empty() - || config.server_version.len() > 128 - || config.server_version.chars().any(char::is_control) - || config.idle_timeout.is_zero() - || config.handshake_timeout.is_zero() - || config.request_timeout.is_zero() - || config.max_connections == 0 - || config.max_connections > MAX_CONNECTION_LIMIT - { - return Err(RuntimeIpcServerError::InvalidConfig); - } - Ok(()) -} - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeIpcServerError { - #[error("runtime IPC server configuration is invalid")] - InvalidConfig, - #[error("runtime IPC connection timed out")] - IoTimeout, - #[error("runtime IPC client disconnected")] - Disconnected, - #[error("runtime IPC event stream is unavailable")] - EventStreamUnavailable, - #[error("runtime IPC connection task failed")] - ConnectionTask(#[source] tokio::task::JoinError), - #[error(transparent)] - Discovery(#[from] RuntimeIpcDiscoveryError), - #[error(transparent)] - Transport(#[from] RuntimeIpcTransportError), - #[error(transparent)] - Io(#[from] RuntimeIpcIoError), -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/session_lease.rs b/src/crates/adapters/agent-runtime-ipc/src/session_lease.rs deleted file mode 100644 index 5b07663477..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/session_lease.rs +++ /dev/null @@ -1,154 +0,0 @@ -use crate::{RuntimeIpcError, RuntimeIpcErrorCode}; -use std::collections::HashMap; -use std::sync::Mutex; - -/// Connection-to-Session controller leases; the Runtime remains Session owner. -#[derive(Default)] -pub(crate) struct RuntimeSessionLeases { - state: Mutex, -} - -#[derive(Default)] -struct LeaseState { - by_session: HashMap, - by_connection: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum LeaseTransition { - Unchanged, - Claimed, - Switched { previous_session_id: String }, -} - -impl RuntimeSessionLeases { - pub(crate) fn switch( - &self, - connection_id: &str, - session_id: &str, - ) -> Result { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = state.by_connection.get(connection_id).cloned(); - if previous.as_deref() == Some(session_id) { - return Ok(LeaseTransition::Unchanged); - } - if state.by_session.contains_key(session_id) { - return Err(error( - RuntimeIpcErrorCode::SessionInUse, - "session already has an active Shared TUI controller", - )); - } - - if let Some(previous_session_id) = previous { - state.by_session.remove(&previous_session_id); - state - .by_session - .insert(session_id.to_string(), connection_id.to_string()); - state - .by_connection - .insert(connection_id.to_string(), session_id.to_string()); - Ok(LeaseTransition::Switched { - previous_session_id, - }) - } else { - state - .by_session - .insert(session_id.to_string(), connection_id.to_string()); - state - .by_connection - .insert(connection_id.to_string(), session_id.to_string()); - Ok(LeaseTransition::Claimed) - } - } - - pub(crate) fn rollback(&self, connection_id: &str, transition: LeaseTransition) { - match transition { - LeaseTransition::Unchanged => {} - LeaseTransition::Claimed => { - self.release_connection(connection_id); - } - LeaseTransition::Switched { - previous_session_id, - } => { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(current_session_id) = state.by_connection.get(connection_id).cloned() { - state.by_session.remove(¤t_session_id); - } - state - .by_session - .insert(previous_session_id.clone(), connection_id.to_string()); - state - .by_connection - .insert(connection_id.to_string(), previous_session_id); - } - } - } - - pub(crate) fn validate( - &self, - connection_id: &str, - session_id: &str, - ) -> Result<(), RuntimeIpcError> { - let state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - match state.by_connection.get(connection_id) { - None => Err(error( - RuntimeIpcErrorCode::ControllerRequired, - "operation requires an attached Shared TUI session", - )), - Some(attached) if attached == session_id => Ok(()), - Some(_) => Err(error( - RuntimeIpcErrorCode::SessionMismatch, - "operation targets a different session than this connection controls", - )), - } - } - - pub(crate) fn validate_uncontrolled(&self, session_id: &str) -> Result<(), RuntimeIpcError> { - let state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if state.by_session.contains_key(session_id) { - return Err(error( - RuntimeIpcErrorCode::SessionInUse, - "session already has an active Shared TUI controller", - )); - } - Ok(()) - } - - pub(crate) fn release_connection(&self, connection_id: &str) -> Option { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let session_id = state.by_connection.remove(connection_id)?; - state.by_session.remove(&session_id); - Some(session_id) - } - - pub(crate) fn attached_session(&self, connection_id: &str) -> Option { - self.state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .by_connection - .get(connection_id) - .cloned() - } -} - -fn error(code: RuntimeIpcErrorCode, message: &str) -> RuntimeIpcError { - RuntimeIpcError { - code, - message: message.to_string(), - } -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs deleted file mode 100644 index 8469c84309..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs +++ /dev/null @@ -1,270 +0,0 @@ -use crate::{ - read_frame, write_frame, DiscoveryRecord, DiscoveryStore, RuntimeInstanceIdentity, - RuntimeInstanceLock, RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, - MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, -}; -use tempfile::tempdir; -use tokio::io::AsyncWriteExt; - -#[test] -fn discovery_replacement_never_exposes_partial_json() { - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = RuntimeInstanceIdentity::for_workspace( - workspace.path(), - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("identity"); - let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); - let record = |owner: usize| { - DiscoveryRecord::new( - identity.clone(), - format!("endpoint-{owner}-{}", "x".repeat(8 * 1024)), - 42, - format!("token-{owner}"), - format!("owner-{owner}"), - ) - }; - store.write(&record(0)).expect("initial discovery"); - - let running = Arc::new(AtomicBool::new(true)); - let reader_running = running.clone(); - let reader_root = runtime_root.path().to_path_buf(); - let reader_identity = identity.clone(); - let reader = std::thread::spawn(move || { - let reader_store = DiscoveryStore::new(&reader_root, reader_identity); - while reader_running.load(Ordering::Acquire) { - let observation = reader_store.read(); - #[cfg(windows)] - match &observation { - // ReplaceFileW keeps the target contents atomic but may make - // the path briefly unavailable while a replacement commits. - Ok(None) => continue, - Err(crate::RuntimeIpcDiscoveryError::ReadDiscovery { source, .. }) - if matches!(source.raw_os_error(), Some(32 | 33)) => - { - continue; - } - _ => {} - } - assert!(observation.expect("discovery remains valid").is_some()); - } - }); - - for owner in 1..=500 { - store.write(&record(owner)).expect("replace discovery"); - } - running.store(false, Ordering::Release); - reader.join().expect("reader remains healthy"); - assert_eq!(store.read().expect("final discovery"), Some(record(500))); -} - -#[cfg(windows)] -#[test] -fn sharing_violation_is_never_reported_as_missing_discovery() { - use std::os::windows::fs::OpenOptionsExt; - - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = RuntimeInstanceIdentity::for_workspace( - workspace.path(), - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("identity"); - let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); - let record = DiscoveryRecord::new( - identity.clone(), - "local-endpoint".to_string(), - 42, - "secret-token".to_string(), - "owner".to_string(), - ); - store.write(&record).expect("write discovery"); - let path = runtime_root - .path() - .join(format!("{}.json", identity.as_str())); - let _exclusive_reader = std::fs::OpenOptions::new() - .read(true) - .share_mode(0) - .open(path) - .expect("hold discovery without sharing"); - - assert!(matches!( - store.read(), - Err(crate::RuntimeIpcDiscoveryError::ReadDiscovery { source, .. }) - if matches!(source.raw_os_error(), Some(32 | 33)) - )); -} - -#[test] -fn instance_identity_is_stable_and_scoped_to_runtime_facts() { - let first_workspace = tempdir().expect("first workspace"); - let second_workspace = tempdir().expect("second workspace"); - - let first = RuntimeInstanceIdentity::for_workspace( - first_workspace.path(), - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("first identity"); - let same = RuntimeInstanceIdentity::for_workspace( - &first_workspace.path().join("."), - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("same identity"); - let other_workspace = RuntimeInstanceIdentity::for_workspace( - second_workspace.path(), - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("other workspace identity"); - let other_user = RuntimeInstanceIdentity::for_workspace( - first_workspace.path(), - "bitfun", - "stable", - "user-b", - PROTOCOL_VERSION, - ) - .expect("other user identity"); - - assert_eq!(first, same); - assert_ne!(first, other_workspace); - assert_ne!(first, other_user); - assert_eq!(first.as_str().len(), 64); -} - -#[cfg(unix)] -#[test] -fn instance_identity_preserves_non_utf8_workspace_bytes() { - use std::ffi::OsString; - use std::os::unix::ffi::OsStringExt; - - let root = tempdir().expect("workspace root"); - let first_path = root.path().join(OsString::from_vec(vec![b'w', 0x80])); - let second_path = root.path().join(OsString::from_vec(vec![b'w', 0x81])); - std::fs::create_dir(&first_path).expect("first non-UTF-8 workspace"); - std::fs::create_dir(&second_path).expect("second non-UTF-8 workspace"); - - let identity = |path| { - RuntimeInstanceIdentity::for_workspace(path, "bitfun", "stable", "user-a", PROTOCOL_VERSION) - .expect("workspace identity") - }; - - assert_ne!(identity(&first_path), identity(&second_path)); -} - -#[test] -fn discovery_is_owner_checked_and_instance_lock_is_exclusive() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = RuntimeInstanceIdentity::for_workspace( - workspace.path(), - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("identity"); - let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); - let record = DiscoveryRecord::new( - identity.clone(), - "local-endpoint".to_string(), - 42, - "secret-token".to_string(), - "owner-one".to_string(), - ); - - let instance_lock = RuntimeInstanceLock::try_acquire(runtime_root.path(), &identity) - .expect("first instance lock"); - assert!(RuntimeInstanceLock::try_acquire(runtime_root.path(), &identity).is_err()); - - store.write(&record).expect("write discovery"); - assert_eq!(store.read().expect("read discovery"), Some(record.clone())); - assert!(!format!("{record:?}").contains("secret-token")); - - let another_owner = DiscoveryRecord::new( - identity, - "local-endpoint".to_string(), - 42, - "other-token".to_string(), - "owner-two".to_string(), - ); - assert!(!store - .remove_if_owned(&another_owner) - .expect("do not remove another owner's record")); - assert!(store.remove_if_owned(&record).expect("remove owned record")); - assert_eq!(store.read().expect("read removed discovery"), None); - - drop(instance_lock); - RuntimeInstanceLock::try_acquire(runtime_root.path(), store.identity()) - .expect("lock released by RAII drop"); -} - -#[tokio::test] -async fn framing_round_trips_health_and_rejects_oversized_lengths() { - let (mut writer, mut reader) = tokio::io::duplex(MAX_REQUEST_FRAME_BYTES + 16); - let expected = RuntimeIpcFrame::Request { - request_id: 9, - operation: RuntimeIpcOperation::Health, - }; - write_frame(&mut writer, &expected) - .await - .expect("write bounded frame"); - assert_eq!( - read_frame(&mut reader).await.expect("read bounded frame"), - expected - ); - - let (mut writer, mut reader) = tokio::io::duplex(8); - writer - .write_u32((MAX_REQUEST_FRAME_BYTES + 1) as u32) - .await - .expect("write oversized length prefix"); - let error = read_frame(&mut reader) - .await - .expect_err("reject oversized frame"); - assert!(matches!(error, RuntimeIpcIoError::FrameTooLarge { .. })); -} - -#[tokio::test] -async fn request_framing_rejects_unknown_fields_inside_nested_dtos() { - let value = serde_json::json!({ - "type": "request", - "request_id": 1, - "operation": { - "operation": "list_sessions", - "request": { - "workspacePath": "workspace", - "future_field": true - } - } - }); - let bytes = serde_json::to_vec(&value).expect("serialize fixture"); - let (mut writer, mut reader) = tokio::io::duplex(bytes.len() + 4); - writer - .write_u32(bytes.len() as u32) - .await - .expect("write length"); - writer.write_all(&bytes).await.expect("write fixture"); - - assert!(matches!( - read_frame(&mut reader).await, - Err(RuntimeIpcIoError::UnknownField { path }) if path.ends_with("future_field") - )); -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs deleted file mode 100644 index 5e15c0b048..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs +++ /dev/null @@ -1,466 +0,0 @@ -use crate::{ - read_frame, write_frame, DiscoveryStore, InitializeRequest, RuntimeInstanceIdentity, - RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcErrorCode, RuntimeIpcFrame, - RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcServer, RuntimeIpcServerConfig, - RuntimeIpcTransportError, RuntimeSessionRenameRequest, MAX_REQUEST_FRAME_BYTES, - PROTOCOL_VERSION, -}; -use std::time::Duration; -use tempfile::tempdir; -use tokio::io::AsyncWriteExt; - -fn runtime_identity(workspace: &std::path::Path) -> RuntimeInstanceIdentity { - RuntimeInstanceIdentity::for_workspace( - workspace, - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("runtime identity") -} - -fn server_config() -> RuntimeIpcServerConfig { - RuntimeIpcServerConfig { - server_version: "0.2.14-test".to_string(), - idle_timeout: Duration::from_millis(80), - handshake_timeout: Duration::from_secs(2), - request_timeout: Duration::from_secs(2), - max_connections: 8, - } -} - -#[tokio::test] -async fn authenticated_client_can_read_health_and_idle_server_cleans_discovery() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let server = RuntimeIpcServer::bind(runtime_root.path(), identity.clone(), server_config()) - .await - .expect("bind server"); - let discovery = server.discovery_record().clone(); - let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); - assert_eq!( - store.read().expect("read discovery"), - Some(discovery.clone()) - ); - - let server_task = tokio::spawn(server.serve()); - let client = RuntimeIpcClient::connect( - runtime_root.path(), - &discovery, - "foundation-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("initialize client"); - let health = client.health().await.expect("read health"); - assert_eq!(health.instance_identity, identity.as_str()); - assert_eq!(health.process_id, std::process::id()); - drop(client); - - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits after idle timeout") - .expect("server task joins") - .expect("server exits cleanly"); - assert_eq!(store.read().expect("read cleaned discovery"), None); -} - -#[tokio::test] -async fn oversized_request_is_rejected_before_send_without_closing_the_connection() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) - .await - .expect("bind server"); - let discovery = server.discovery_record().clone(); - let server_task = tokio::spawn(server.serve()); - let client = RuntimeIpcClient::connect( - runtime_root.path(), - &discovery, - "oversized-request-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("initialize client"); - - let error = client - .request(RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: "session-1".to_string(), - session_name: "x".repeat(MAX_REQUEST_FRAME_BYTES), - }, - }) - .await - .expect_err("oversized request must fail before transport write"); - assert!(matches!( - error, - RuntimeIpcClientError::RequestEncoding(RuntimeIpcIoError::FrameTooLarge { .. }) - )); - client - .health() - .await - .expect("pre-send rejection must leave the connection usable"); - - drop(client); - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits after idle timeout") - .expect("server task joins") - .expect("server exits cleanly"); -} - -#[tokio::test] -async fn cancelling_the_server_task_cleans_its_discovery_record() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let server = RuntimeIpcServer::bind(runtime_root.path(), identity.clone(), server_config()) - .await - .expect("bind server"); - let store = DiscoveryStore::new(runtime_root.path(), identity); - let server_task = tokio::spawn(server.serve()); - - server_task.abort(); - assert!(server_task - .await - .expect_err("server task is cancelled") - .is_cancelled()); - - assert_eq!(store.read().expect("read cleaned discovery"), None); -} - -#[tokio::test] -async fn handshake_rejects_bad_token_wrong_instance_and_protocol_mismatch() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let other_workspace = tempdir().expect("other workspace"); - let identity = runtime_identity(workspace.path()); - let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) - .await - .expect("bind server"); - let endpoint = server.endpoint().clone(); - let discovery = server.discovery_record().clone(); - let server_task = tokio::spawn(server.serve()); - - let mut bad_token = discovery.clone(); - bad_token.token = "wrong-token".to_string(); - let error = RuntimeIpcClient::connect( - runtime_root.path(), - &bad_token, - "foundation-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect_err("bad token must fail"); - assert!(matches!( - error, - RuntimeIpcClientError::Remote(ref remote) - if remote.code == RuntimeIpcErrorCode::Unauthorized - )); - - let wrong_identity = runtime_identity(other_workspace.path()); - let mut raw = endpoint - .connect(Duration::from_secs(2)) - .await - .expect("connect raw local stream"); - write_frame( - &mut raw, - &RuntimeIpcFrame::Initialize { - request_id: 41, - request: InitializeRequest { - protocol_version: PROTOCOL_VERSION, - instance_identity: wrong_identity.as_str().to_string(), - token: discovery.token.clone(), - client_id: "foundation-test".to_string(), - client_version: "0.1.0".to_string(), - }, - }, - ) - .await - .expect("write wrong-instance initialize"); - assert!(matches!( - read_frame(&mut raw).await.expect("read wrong-instance rejection"), - RuntimeIpcFrame::Error { request_id: Some(41), error } - if error.code == RuntimeIpcErrorCode::WrongInstance - )); - drop(raw); - - let mut mismatched_discovery = discovery.clone(); - mismatched_discovery.instance_identity = wrong_identity; - assert!(matches!( - RuntimeIpcClient::connect( - runtime_root.path(), - &mismatched_discovery, - "foundation-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await, - Err(RuntimeIpcClientError::Transport( - RuntimeIpcTransportError::InvalidEndpoint - )) - )); - - let mut wrong_protocol = discovery; - wrong_protocol.protocol_version = PROTOCOL_VERSION + 1; - assert!(matches!( - RuntimeIpcClient::connect( - runtime_root.path(), - &wrong_protocol, - "foundation-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await, - Err(RuntimeIpcClientError::IncompatibleProtocol { .. }) - )); - - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits") - .expect("server task joins") - .expect("server exits cleanly"); -} - -#[tokio::test] -async fn first_frame_must_be_initialize() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) - .await - .expect("bind server"); - let endpoint = server.endpoint().clone(); - let server_task = tokio::spawn(server.serve()); - - let mut stream = endpoint - .connect(Duration::from_secs(2)) - .await - .expect("connect raw local stream"); - write_frame( - &mut stream, - &RuntimeIpcFrame::Request { - request_id: 99, - operation: RuntimeIpcOperation::Health, - }, - ) - .await - .expect("write pre-initialize request"); - let response = read_frame(&mut stream).await.expect("read rejection"); - assert!(matches!( - response, - RuntimeIpcFrame::Error { - request_id: Some(99), - error - } if error.code == RuntimeIpcErrorCode::InvalidRequest - )); - drop(stream); - - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits") - .expect("server task joins") - .expect("server exits cleanly"); -} - -#[tokio::test] -async fn malformed_client_is_isolated_from_later_health_clients() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let mut config = server_config(); - config.idle_timeout = Duration::from_millis(250); - let server = RuntimeIpcServer::bind(runtime_root.path(), identity, config) - .await - .expect("bind server"); - let endpoint = server.endpoint().clone(); - let discovery = server.discovery_record().clone(); - let server_task = tokio::spawn(server.serve()); - - let mut malformed = endpoint - .connect(Duration::from_secs(2)) - .await - .expect("connect malformed client"); - malformed - .write_u32((MAX_REQUEST_FRAME_BYTES + 1) as u32) - .await - .expect("write oversized frame prefix"); - drop(malformed); - tokio::time::sleep(Duration::from_millis(20)).await; - - let healthy = RuntimeIpcClient::connect( - runtime_root.path(), - &discovery, - "foundation-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("server remains available after malformed client"); - healthy.health().await.expect("Health remains available"); - drop(healthy); - - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits") - .expect("server task joins") - .expect("server exits cleanly"); -} - -#[tokio::test] -async fn connection_limit_applies_before_authentication() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let mut config = server_config(); - config.idle_timeout = Duration::from_millis(250); - config.max_connections = 1; - let server = RuntimeIpcServer::bind(runtime_root.path(), identity, config) - .await - .expect("bind server"); - let endpoint = server.endpoint().clone(); - let discovery = server.discovery_record().clone(); - let server_task = tokio::spawn(server.serve()); - - let blocker = endpoint - .connect(Duration::from_secs(2)) - .await - .expect("connect unauthenticated blocker"); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!(matches!( - RuntimeIpcClient::connect( - runtime_root.path(), - &discovery, - "bounded-client", - "0.1.0", - Duration::from_millis(50), - Duration::from_secs(2), - ) - .await, - Err(RuntimeIpcClientError::Timeout) - )); - - drop(blocker); - let client = RuntimeIpcClient::connect( - runtime_root.path(), - &discovery, - "bounded-client", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("capacity recovers after blocker disconnects"); - client - .health() - .await - .expect("Health after capacity recovery"); - drop(client); - - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits") - .expect("server task joins") - .expect("server exits cleanly"); -} - -#[cfg(unix)] -#[tokio::test] -async fn non_utf8_runtime_root_supports_discovery_bind_and_health() { - use std::ffi::OsString; - use std::os::unix::ffi::OsStringExt; - - let parent = tempdir().expect("runtime parent"); - let runtime_root = parent - .path() - .join(OsString::from_vec(b"runtime-\x80".to_vec())); - std::fs::create_dir(&runtime_root).expect("create non-UTF-8 runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let server = RuntimeIpcServer::bind(&runtime_root, identity.clone(), server_config()) - .await - .expect("bind server under non-UTF-8 runtime root"); - let discovery = server.discovery_record().clone(); - let server_task = tokio::spawn(server.serve()); - - let client = RuntimeIpcClient::connect( - &runtime_root, - &discovery, - "non-utf8-test", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("initialize through lossless Unix endpoint"); - let health = client.health().await.expect("read Health"); - assert_eq!(health.instance_identity, identity.as_str()); - drop(client); - - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("server exits") - .expect("server task joins") - .expect("server exits cleanly"); -} - -#[cfg(unix)] -#[tokio::test] -async fn unix_endpoint_rejects_a_runtime_root_that_exceeds_the_portable_uds_limit() { - let parent = tempdir().expect("runtime parent"); - let runtime_root = parent.path().join("r".repeat(120)); - std::fs::create_dir(&runtime_root).expect("create long runtime root"); - let workspace = tempdir().expect("workspace"); - - let error = match RuntimeIpcServer::bind( - &runtime_root, - runtime_identity(workspace.path()), - server_config(), - ) - .await - { - Ok(_) => panic!("overlong UDS path must fail before bind"), - Err(error) => error, - }; - - assert!(matches!( - error, - crate::server::RuntimeIpcServerError::Transport( - RuntimeIpcTransportError::EndpointTooLong { .. } - ) - )); -} - -#[cfg(unix)] -#[tokio::test] -async fn stale_unix_socket_is_replaced_by_the_next_locked_owner() { - use std::os::unix::net::UnixListener; - - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = runtime_identity(workspace.path()); - let endpoint = crate::LocalIpcEndpoint::for_instance(runtime_root.path(), &identity) - .expect("stable endpoint"); - let stale = UnixListener::bind(endpoint.as_path()).expect("create stale socket"); - drop(stale); - assert!(endpoint.as_path().exists()); - - let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) - .await - .expect("next owner replaces stale socket"); - assert_eq!(server.endpoint(), &endpoint); - drop(server); - assert!(!endpoint.as_path().exists()); -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs deleted file mode 100644 index 753fa5fb3a..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ /dev/null @@ -1,605 +0,0 @@ -use crate::operation::RuntimeIpcSessionRequirement; -use crate::{ - serialize_frame_with_limit, InitializeRequest, RuntimeAgentModeSummary, RuntimeIpcError, - RuntimeIpcErrorCode, RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeSessionForkRequest, RuntimeSessionRenameRequest, RuntimeUserAnswersRequest, - MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, -}; - -use bitfun_product_domains::tool_permissions::PermissionReply; -use bitfun_runtime_ports::{ - AgentContextReloadRequest, AgentContextReloadTarget, AgentDialogSteerRequest, - AgentDialogTurnRequest, AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, - AgentSessionLineageCancellationRequest, AgentSessionLineageRequest, - AgentSessionLineageTranscriptRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSubmissionSource, - AgentWorkspaceReferenceSearchRequest, DialogSubmissionPolicy, WorkspaceDiffContent, - WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, -}; -use serde_json::{json, Map}; - -#[test] -fn shared_runtime_protocol_stays_at_version_17() { - assert_eq!(PROTOCOL_VERSION, 17); -} - -#[test] -fn protocol_rejects_unknown_fields_and_operations() { - let unknown_field = - r#"{"type":"request","request_id":1,"operation":{"operation":"health"},"metadata":{}}"# - .to_string(); - assert!(serde_json::from_str::(&unknown_field).is_err()); - - let unknown_operation = - r#"{"type":"request","request_id":1,"operation":{"operation":"list_sessions"}}"#; - assert!(serde_json::from_str::(unknown_operation).is_err()); -} - -#[test] -fn initialize_debug_redacts_the_bearer_token() { - let request = InitializeRequest { - protocol_version: PROTOCOL_VERSION, - instance_identity: "a".repeat(64), - token: "top-secret-token".to_string(), - client_id: "foundation-test".to_string(), - client_version: "0.1.0".to_string(), - }; - - let debug = format!("{request:?}"); - assert!(!debug.contains("top-secret-token")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn protocol_round_trips_reviewed_permission_and_user_input_operations() { - let operations = vec![ - RuntimeIpcOperation::PendingPermissions { - session_id: "session-1".to_string(), - }, - RuntimeIpcOperation::RespondPermission { - session_id: "session-1".to_string(), - request_id: "permission-1".to_string(), - reply: PermissionReply::Once, - }, - RuntimeIpcOperation::SubmitUserAnswers { - request: RuntimeUserAnswersRequest { - session_id: "session-1".to_string(), - tool_id: "question-1".to_string(), - answers: json!({"choice": "yes"}), - }, - }, - ]; - - for operation in operations { - let encoded = serde_json::to_value(&operation).expect("serialize operation"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize operation"); - assert_eq!(decoded, operation); - } -} - -#[test] -fn protocol_round_trips_read_only_main_agent_catalog() { - assert_eq!(PROTOCOL_VERSION, 17); - let operation = RuntimeIpcOperation::ListAgentModes { - session_id: Some("session-1".to_string()), - }; - let result = RuntimeIpcOperationResult::AgentModes { - modes: vec![RuntimeAgentModeSummary { - id: "review".to_string(), - description: "Review the current workspace".to_string(), - model_id: Some("provider/model".to_string()), - is_external: true, - }], - }; - - let operation_json = - serde_json::to_value(&operation).expect("serialize mode catalog operation"); - assert_eq!( - operation_json, - json!({"operation": "list_agent_modes", "sessionId": "session-1"}) - ); - let decoded_operation: RuntimeIpcOperation = - serde_json::from_value(operation_json).expect("deserialize mode catalog operation"); - assert_eq!(decoded_operation, operation); - assert_eq!(decoded_operation.session_id(), Some("session-1")); - let rules = decoded_operation.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(!rules.side_effecting); - - let result_json = serde_json::to_value(&result).expect("serialize mode catalog result"); - assert_eq!(result_json["result"], "agent_modes"); - assert_eq!(result_json["modes"][0]["id"], "review"); - assert_eq!(result_json["modes"][0]["modelId"], "provider/model"); - assert_eq!(result_json["modes"][0]["isExternal"], true); - let decoded_result: RuntimeIpcOperationResult = - serde_json::from_value(result_json).expect("deserialize mode catalog result"); - assert_eq!(decoded_result, result); - - let startup_operation = RuntimeIpcOperation::ListAgentModes { session_id: None }; - assert_eq!(startup_operation.session_id(), None); - assert_eq!( - startup_operation.rules().session_requirement, - RuntimeIpcSessionRequirement::None - ); - assert_eq!( - serde_json::to_value(startup_operation).expect("serialize startup mode catalog"), - json!({"operation": "list_agent_modes"}) - ); -} - -#[test] -fn protocol_round_trips_exact_turn_steering_without_replacing_turn_admission() { - assert_eq!(PROTOCOL_VERSION, 17); - let operation = RuntimeIpcOperation::SteerTurn { - request: AgentDialogSteerRequest { - session_id: "session-1".to_string(), - turn_id: "turn-1".to_string(), - content: "check tests".to_string(), - display_content: Some("Check tests".to_string()), - }, - }; - let result = RuntimeIpcOperationResult::TurnSteered { - session_id: "session-1".to_string(), - turn_id: "turn-1".to_string(), - steering_id: "steer-1".to_string(), - }; - - let operation_json = serde_json::to_value(&operation).expect("serialize steer operation"); - let result_json = serde_json::to_value(&result).expect("serialize steer result"); - - assert_eq!(operation_json["operation"], "steer_turn"); - assert_eq!(operation_json["request"]["turnId"], "turn-1"); - assert_eq!(result_json["result"], "turn_steered"); - assert_eq!(result_json["steeringId"], "steer-1"); - assert_eq!( - serde_json::from_value::(operation_json) - .expect("deserialize steer operation"), - operation - ); - assert_eq!( - serde_json::from_value::(result_json) - .expect("deserialize steer result"), - result - ); -} - -#[test] -fn protocol_round_trips_read_only_workspace_reference_operations() { - let operations = vec![ - RuntimeIpcOperation::SearchWorkspaceReferences { - request: AgentWorkspaceReferenceSearchRequest { - session_id: "session-1".to_string(), - query: "src/ma".to_string(), - limit: 20, - }, - }, - RuntimeIpcOperation::WorkspaceReferencesForMessage { - request: AgentMessageWorkspaceReferencesRequest { - session_id: "session-1".to_string(), - message_id: "message-1".to_string(), - }, - }, - ]; - - for operation in operations { - let encoded = serde_json::to_value(&operation).expect("serialize workspace operation"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize workspace operation"); - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(!rules.side_effecting); - } -} - -#[test] -fn protocol_round_trips_root_scoped_lineage_operations() { - let operations = vec![ - RuntimeIpcOperation::GetSessionLineage { - request: AgentSessionLineageRequest { - workspace_path: "D:/workspace/project".to_string(), - anchor_session_id: "root-1".to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }, - }, - RuntimeIpcOperation::InspectLineageSession { - request: AgentSessionLineageTranscriptRequest { - workspace_path: "D:/workspace/project".to_string(), - root_session_id: "root-1".to_string(), - session_id: "child-1".to_string(), - required_settled_turn_ids: vec!["turn-terminal".to_string()], - remote_connection_id: None, - remote_ssh_host: None, - }, - }, - RuntimeIpcOperation::CancelLineageSession { - request: AgentSessionLineageCancellationRequest { - workspace_path: "D:/workspace/project".to_string(), - root_session_id: "root-1".to_string(), - session_id: "child-1".to_string(), - expected_active_turn_id: Some("turn-child".to_string()), - source: Some(AgentSubmissionSource::Cli), - reason: Some("user_cancelled".to_string()), - wait_timeout_ms: Some(5_000), - remote_connection_id: None, - remote_ssh_host: None, - }, - }, - ]; - - for operation in operations { - let encoded = serde_json::to_value(&operation).expect("serialize lineage operation"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize lineage operation"); - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("root-1")); - } -} - -#[test] -fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { - assert_eq!(PROTOCOL_VERSION, 17); - - let operation = RuntimeIpcOperation::WorkspaceDiff; - let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); - assert_eq!(encoded, json!({"operation": "workspace_diff"})); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize workspace diff operation"); - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), None); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::None - ); - assert!(rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(!rules.side_effecting); - - let result = RuntimeIpcOperationResult::WorkspaceDiff { - snapshot: WorkspaceDiffSnapshot { - files: vec![WorkspaceDiffFile { - path: "src/main.rs".to_string(), - old_path: None, - status: WorkspaceDiffFileStatus::Modified, - staged: false, - unstaged: true, - untracked: false, - additions: 1, - deletions: 1, - content: WorkspaceDiffContent::Text { - patch: "@@ -1 +1 @@\n-old\n+new\n".to_string(), - }, - }], - truncated: false, - }, - }; - let encoded = serde_json::to_value(&result).expect("serialize workspace diff result"); - let decoded: RuntimeIpcOperationResult = - serde_json::from_value(encoded).expect("deserialize workspace diff result"); - assert_eq!(decoded, result); -} - -#[test] -fn protocol_round_trips_user_shell_as_an_idle_controller_turn() { - let operation = RuntimeIpcOperation::RunUserShellCommand { - request: bitfun_runtime_ports::AgentUserShellCommandRequest { - session_id: "session-1".to_string(), - turn_id: "turn-shell".to_string(), - command: "git status --short".to_string(), - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize user shell operation"); - assert_eq!(encoded["operation"], "run_user_shell_command"); - assert_eq!(encoded["request"]["sessionId"], "session-1"); - assert_eq!(encoded["request"]["turnId"], "turn-shell"); - assert_eq!(encoded["request"]["command"], "git status --short"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize user shell operation"); - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(rules.side_effecting); -} - -#[test] -fn protocol_round_trips_the_reviewed_session_mode_operation() { - let operation = RuntimeIpcOperation::UpdateSessionMode { - request: AgentSessionModeUpdateRequest { - session_id: "session-1".to_string(), - mode_id: "ask".to_string(), - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize mode update"); - assert_eq!(encoded["operation"], "update_session_mode"); - assert_eq!(encoded["request"]["sessionId"], "session-1"); - assert_eq!(encoded["request"]["modeId"], "ask"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize mode update"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - assert_eq!( - decoded.rules().session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); -} - -#[test] -fn protocol_round_trips_the_reviewed_session_model_operation() { - let operation = RuntimeIpcOperation::UpdateSessionModel { - request: AgentSessionModelUpdateRequest { - session_id: "session-1".to_string(), - model_id: "provider/model".to_string(), - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize model update"); - assert_eq!(encoded["operation"], "update_session_model"); - assert_eq!(encoded["request"]["sessionId"], "session-1"); - assert_eq!(encoded["request"]["modelId"], "provider/model"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize model update"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - assert_eq!( - decoded.rules().session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); -} - -#[test] -fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 17); - - let operation = RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: "session-1".to_string(), - session_name: "Auth refactor".to_string(), - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize session rename"); - assert_eq!( - encoded, - json!({ - "operation": "rename_session", - "request": { - "sessionId": "session-1", - "sessionName": "Auth refactor" - } - }) - ); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize session rename"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - assert_eq!( - decoded.rules().session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); -} - -#[test] -fn protocol_round_trips_fork_as_an_atomic_idle_controller_transition() { - let operation = RuntimeIpcOperation::ForkSession { - request: RuntimeSessionForkRequest { - session_id: "session-1".to_string(), - before_turn_id: Some("turn-2".to_string()), - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize session fork"); - assert_eq!( - encoded, - json!({ - "operation": "fork_session", - "request": { - "sessionId": "session-1", - "beforeTurnId": "turn-2" - } - }) - ); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize session fork"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(rules.requires_idle); - assert!(rules.serializes_session_selection); - assert!(rules.side_effecting); -} - -#[test] -fn protocol_round_trips_manual_compaction_as_an_idle_controller_turn() { - let operation = RuntimeIpcOperation::CompactSession { - request: AgentSessionCompactionRequest { - session_id: "session-1".to_string(), - turn_id: "turn-compact-1".to_string(), - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize compaction"); - assert_eq!( - encoded, - json!({ - "operation": "compact_session", - "request": { - "sessionId": "session-1", - "turnId": "turn-compact-1" - } - }) - ); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize compaction"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(rules.side_effecting); -} - -#[test] -fn protocol_round_trips_undo_as_an_active_controller_operation() { - let operation = RuntimeIpcOperation::UndoSession { - request: AgentSessionRevertRequest { - workspace_path: "D:/workspace/project".to_string(), - session_id: "session-1".to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize session undo"); - assert_eq!(encoded["operation"], "undo_session"); - assert_eq!(encoded["request"]["sessionId"], "session-1"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize session undo"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(rules.side_effecting); -} - -#[test] -fn protocol_round_trips_session_delete_and_not_found() { - let operation = RuntimeIpcOperation::DeleteSession { - session_id: "session-2".to_string(), - }; - let encoded = serde_json::to_value(&operation).expect("serialize session delete"); - assert_eq!( - encoded, - json!({ - "operation": "delete_session", - "sessionId": "session-2" - }) - ); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize session delete"); - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-2")); - - let frame = RuntimeIpcFrame::Error { - request_id: Some(7), - error: RuntimeIpcError { - code: RuntimeIpcErrorCode::NotFound, - message: "session not found".to_string(), - }, - }; - let encoded = serde_json::to_value(&frame).expect("serialize not-found error"); - assert_eq!(encoded["error"]["code"], "not_found"); - let decoded: RuntimeIpcFrame = - serde_json::from_value(encoded).expect("deserialize not-found error"); - assert_eq!(decoded, frame); -} - -#[test] -fn protocol_round_trips_context_reload_as_a_controller_operation() { - let operation = RuntimeIpcOperation::ReloadSessionContext { - request: AgentContextReloadRequest { - session_id: "session-1".to_string(), - target: AgentContextReloadTarget::Instructions, - }, - }; - - let encoded = serde_json::to_value(&operation).expect("serialize context reload"); - assert_eq!(encoded["operation"], "reload_session_context"); - assert_eq!(encoded["request"]["sessionId"], "session-1"); - assert_eq!(encoded["request"]["target"], "instructions"); - let decoded: RuntimeIpcOperation = - serde_json::from_value(encoded).expect("deserialize context reload"); - - assert_eq!(decoded, operation); - assert_eq!(decoded.session_id(), Some("session-1")); - let rules = decoded.rules(); - assert_eq!( - rules.session_requirement, - RuntimeIpcSessionRequirement::CurrentController - ); - assert!(!rules.requires_idle); - assert!(!rules.serializes_session_selection); - assert!(rules.side_effecting); -} - -#[test] -fn session_mode_operation_rejects_unknown_envelope_fields() { - let unknown_field = json!({ - "operation": "update_session_mode", - "request": { - "sessionId": "session-1", - "modeId": "ask" - }, - "metadata": {} - }); - - assert!(serde_json::from_value::(unknown_field).is_err()); -} - -#[test] -fn submit_turn_accepts_the_existing_64_kib_tui_paste_contract() { - let frame = RuntimeIpcFrame::Request { - request_id: 1, - operation: RuntimeIpcOperation::SubmitTurn { - request: AgentDialogTurnRequest { - session_id: "session-1".to_string(), - message: "x".repeat(64 * 1024), - original_message: None, - turn_id: Some("turn-1".to_string()), - execution: Default::default(), - agent_type: "agentic".to_string(), - workspace_path: Some("D:/workspace/project".to_string()), - remote_connection_id: None, - remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), - reply_route: None, - prepended_reminders: Vec::new(), - attachments: Vec::new(), - metadata: Map::new(), - }, - }, - }; - - serialize_frame_with_limit(&frame, MAX_REQUEST_FRAME_BYTES) - .expect("64 KiB TUI input plus its typed envelope must fit the request frame"); -} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs deleted file mode 100644 index b36b557ad4..0000000000 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ /dev/null @@ -1,1778 +0,0 @@ -use crate::{ - read_frame, write_frame, InitializeRequest, LocalIpcStream, RuntimeInstanceIdentity, - RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, - RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, - RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeSessionForkRequest, - RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, PROTOCOL_VERSION, -}; -use async_trait::async_trait; -use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; -use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionCompactionRequest, AgentSessionComposerUpdate, - AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionLineageCancellationRequest, - AgentSessionLineageTranscriptRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSubmissionSource, - AgentUserShellCommandRequest, DialogSubmissionPolicy, SessionExecutionTarget, - SessionTranscript, -}; -use serde_json::Map; -use std::path::Path; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, -}; -use std::time::Duration; -use tempfile::{tempdir, TempDir}; -use tokio::io::AsyncWriteExt; -use tokio::sync::{broadcast, watch, Notify}; -type EventSubscription = Result, RuntimeIpcError>; - -fn test_agent_event(session_id: &str, turn_id: &str) -> RuntimeIpcEvent { - RuntimeIpcEvent::Agent { - session_id: session_id.to_string(), - envelope: AgenticEventEnvelope::new( - AgenticEvent::DialogTurnCancelled { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - }, - AgenticEventPriority::Critical, - ), - } -} - -struct TestServer { - runtime_root: TempDir, - workspace: TempDir, - endpoint: crate::LocalIpcEndpoint, - discovery: crate::DiscoveryRecord, - task: tokio::task::JoinHandle>, -} - -impl TestServer { - async fn start( - config: RuntimeIpcServerConfig, - handler: Arc, - ) -> Self { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let server = RuntimeIpcServer::bind_with_handler( - runtime_root.path(), - test_identity(workspace.path()), - config, - handler, - ) - .await - .expect("bind shared server"); - let endpoint = server.endpoint().clone(); - let discovery = server.discovery_record().clone(); - Self { - runtime_root, - workspace, - endpoint, - discovery, - task: tokio::spawn(server.serve()), - } - } - - async fn connect(&self, client_id: &str) -> LocalIpcStream { - initialize(&self.endpoint, &self.discovery, client_id).await - } - - async fn finish(self) { - self.task.await.unwrap().unwrap(); - } -} - -struct FakeHandler { - calls: Mutex>, - delay: Option, - mode_delay: Option, - model_delay: Option, - rename_delay: Option, - delete_delay: Option, - submit_delay: Option, - lineage_read_delay: Option, - invalid_steer_result: bool, - settle_cancel: bool, - events: broadcast::Sender, - available: watch::Sender, -} - -struct CreateRaceHandler { - create_started: Arc, - allow_create: Arc, - available: Arc, - events: broadcast::Sender, -} - -impl Default for FakeHandler { - fn default() -> Self { - let (events, _) = broadcast::channel(16); - let (available, _) = watch::channel(true); - Self { - calls: Mutex::new(Vec::new()), - delay: None, - mode_delay: None, - model_delay: None, - rename_delay: None, - delete_delay: None, - submit_delay: None, - lineage_read_delay: None, - invalid_steer_result: false, - settle_cancel: true, - events, - available, - } - } -} - -#[async_trait] -impl RuntimeIpcRequestHandler for CreateRaceHandler { - fn ensure_available(&self) -> Result<(), RuntimeIpcError> { - self.available - .load(Ordering::SeqCst) - .then_some(()) - .ok_or(RuntimeIpcError { - code: RuntimeIpcErrorCode::Unavailable, - message: "fixture event stream unavailable".to_string(), - }) - } - - async fn execute( - &self, - operation: RuntimeIpcOperation, - ) -> Result { - match operation { - RuntimeIpcOperation::CreateSession { request } => { - self.create_started.notify_one(); - self.allow_create.notified().await; - let mut session = - AgentSessionCreateResult::new("session-a", "Created session", "agentic"); - session.workspace_path = request.workspace_path; - session.workspace_id = Some("workspace-fixture".to_string()); - Ok(RuntimeIpcOperationResult::SessionCreated { session }) - } - RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), - _ => Ok(RuntimeIpcOperationResult::Unit), - } - } - - fn subscribe_events(&self, _session_id: &str) -> EventSubscription { - self.ensure_available().map(|()| self.events.subscribe()) - } -} - -#[async_trait] -impl RuntimeIpcRequestHandler for FakeHandler { - fn ensure_available(&self) -> Result<(), RuntimeIpcError> { - (*self.available.borrow()) - .then_some(()) - .ok_or(RuntimeIpcError { - code: RuntimeIpcErrorCode::Unavailable, - message: "fixture event stream unavailable".to_string(), - }) - } - - fn subscribe_availability(&self) -> Option> { - Some(self.available.subscribe()) - } - - async fn execute( - &self, - operation: RuntimeIpcOperation, - ) -> Result { - self.calls - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(operation.clone()); - if let Some(delay) = self.delay { - tokio::time::sleep(delay).await; - } - if matches!(operation, RuntimeIpcOperation::UpdateSessionMode { .. }) { - if let Some(delay) = self.mode_delay { - tokio::time::sleep(delay).await; - } - } - if matches!(operation, RuntimeIpcOperation::UpdateSessionModel { .. }) { - if let Some(delay) = self.model_delay { - tokio::time::sleep(delay).await; - } - } - if matches!(operation, RuntimeIpcOperation::RenameSession { .. }) { - if let Some(delay) = self.rename_delay { - tokio::time::sleep(delay).await; - } - } - if matches!(operation, RuntimeIpcOperation::DeleteSession { .. }) { - if let Some(delay) = self.delete_delay { - tokio::time::sleep(delay).await; - } - } - if matches!( - operation, - RuntimeIpcOperation::GetSessionLineage { .. } - | RuntimeIpcOperation::InspectLineageSession { .. } - ) { - if let Some(delay) = self.lineage_read_delay { - tokio::time::sleep(delay).await; - } - } - match operation { - RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), - RuntimeIpcOperation::ForkSession { .. } => { - Ok(RuntimeIpcOperationResult::SessionForked { - session: summary("session-fork"), - transcript: SessionTranscript { - session_id: "session-fork".to_string(), - messages: Vec::new(), - }, - workspace_binding: workspace_binding(), - }) - } - RuntimeIpcOperation::SubmitTurn { request } => { - if let Some(delay) = self.submit_delay { - tokio::time::sleep(delay).await; - } - Ok(RuntimeIpcOperationResult::TurnAccepted { - session_id: request.session_id, - turn_id: request.turn_id.expect("test turn id"), - }) - } - RuntimeIpcOperation::SteerTurn { request } => { - if self.invalid_steer_result { - return Ok(RuntimeIpcOperationResult::Unit); - } - Ok(RuntimeIpcOperationResult::TurnSteered { - session_id: request.session_id, - turn_id: request.turn_id, - steering_id: "steer-fixture".to_string(), - }) - } - RuntimeIpcOperation::RunUserShellCommand { request } => { - if let Some(delay) = self.submit_delay { - tokio::time::sleep(delay).await; - } - Ok(RuntimeIpcOperationResult::TurnAccepted { - session_id: request.session_id, - turn_id: request.turn_id, - }) - } - RuntimeIpcOperation::CompactSession { request } => { - Ok(RuntimeIpcOperationResult::TurnAccepted { - session_id: request.session_id, - turn_id: request.turn_id, - }) - } - RuntimeIpcOperation::InspectLineageSession { request } => { - Ok(RuntimeIpcOperationResult::LineageSessionInspection { - inspection: bitfun_runtime_ports::AgentSessionLineageInspection { - transcript: SessionTranscript { - session_id: request.session_id, - messages: Vec::new(), - }, - active_turn_id: None, - }, - }) - } - RuntimeIpcOperation::CancelLineageSession { request } => { - Ok(RuntimeIpcOperationResult::TurnCancelled { - cancellation: bitfun_runtime_ports::AgentTurnCancellationResult { - session_id: request.session_id, - turn_id: None, - requested: true, - }, - }) - } - RuntimeIpcOperation::UndoSession { request } - | RuntimeIpcOperation::RedoSession { request } => { - Ok(RuntimeIpcOperationResult::SessionReverted { - revert: AgentSessionRevertResult { - transcript: SessionTranscript { - session_id: request.session_id.clone(), - messages: Vec::new(), - }, - session_id: request.session_id, - composer: AgentSessionComposerUpdate::Preserve, - retired_turn_ids: Vec::new(), - changed: true, - hidden_turn_count: 1, - }, - }) - } - RuntimeIpcOperation::CancelTurn { request } => { - if self.settle_cancel { - let _ = self.events.send(test_agent_event( - &request.session_id, - request.turn_id.as_deref().expect("cancel turn id"), - )); - } - Ok(RuntimeIpcOperationResult::TurnCancelled { - cancellation: bitfun_runtime_ports::AgentTurnCancellationResult { - session_id: request.session_id, - turn_id: request.turn_id, - requested: true, - }, - }) - } - _ => Ok(RuntimeIpcOperationResult::Unit), - } - } - - fn subscribe_events(&self, _session_id: &str) -> EventSubscription { - Ok(self.events.subscribe()) - } -} - -#[tokio::test] -async fn authenticated_partial_frame_is_closed_at_the_request_deadline() { - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(config, handler.clone()).await; - let mut client = server.connect("partial-frame").await; - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - client.write_u32(10).await.unwrap(); - client.write_all(b"{").await.unwrap(); - let events = handler.events.clone(); - let emitter = tokio::spawn(async move { - loop { - let _ = events.send(test_agent_event("session-a", "turn-a")); - tokio::time::sleep(Duration::from_millis(5)).await; - } - }); - assert!(tokio::time::timeout(Duration::from_millis(200), async { - while read_frame(&mut client).await.is_ok() {} - }) - .await - .is_ok()); - emitter.abort(); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn startup_connection_closes_when_runtime_becomes_unavailable() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("startup-page").await; - handler.available.send_replace(false); - assert!( - tokio::time::timeout(Duration::from_millis(200), read_frame(&mut client)) - .await - .expect("startup connection closes") - .is_err() - ); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn oversized_event_reports_typed_invalidation_before_disconnect() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("oversized-event").await; - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - handler - .events - .send(RuntimeIpcEvent::Agent { - session_id: "session-a".to_string(), - envelope: AgenticEventEnvelope::new( - AgenticEvent::TextChunk { - session_id: "session-a".to_string(), - turn_id: "turn-a".to_string(), - round_id: "round-a".to_string(), - attempt_id: None, - attempt_index: None, - text: "x".repeat(9 * 1024 * 1024), - }, - AgenticEventPriority::Critical, - ), - }) - .unwrap(); - assert!(matches!( - read_frame(&mut client).await.unwrap(), - RuntimeIpcFrame::Event { - event: RuntimeIpcEvent::StreamInvalidated { - reason: crate::RuntimeIpcStreamInvalidationReason::FrameTooLarge, - } - } - )); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn first_party_timeout_reports_unknown_outcome_and_releases_the_lease() { - let mut config = server_config(); - config.request_timeout = Duration::from_millis(100); - let handler = Arc::new(FakeHandler { - delay: Some(Duration::from_millis(250)), - ..FakeHandler::default() - }); - let server = TestServer::start(config, handler.clone()).await; - for client_id in ["first-timeout", "second-timeout"] { - let client = RuntimeIpcClient::connect( - server.runtime_root.path(), - &server.discovery, - client_id, - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("connect first-party client"); - let restore = client - .request(restore_operation(server.workspace.path(), "session-a")) - .await; - assert!( - matches!( - restore, - Err(RuntimeIpcClientError::Remote(RuntimeIpcError { - code: RuntimeIpcErrorCode::OutcomeUnknown, - .. - })) - ), - "unexpected restore result: {restore:?}" - ); - } - server.finish().await; -} - -#[tokio::test] -async fn cancellation_supersedes_a_slow_lineage_read_on_the_same_client() { - let handler = Arc::new(FakeHandler { - lineage_read_delay: Some(Duration::from_secs(2)), - ..FakeHandler::default() - }); - let server = TestServer::start(server_config(), handler.clone()).await; - let client = RuntimeIpcClient::connect( - server.runtime_root.path(), - &server.discovery, - "lineage-controller", - "0.1.0", - Duration::from_secs(2), - Duration::from_secs(3), - ) - .await - .expect("connect first-party client"); - client - .request(restore_operation(server.workspace.path(), "session-a")) - .await - .expect("restore root session"); - - let inspect_client = client.clone(); - let workspace_path = server.workspace.path().to_string_lossy().to_string(); - let inspect_workspace_path = workspace_path.clone(); - let inspect = tokio::spawn(async move { - inspect_client - .request(RuntimeIpcOperation::InspectLineageSession { - request: AgentSessionLineageTranscriptRequest { - workspace_path: inspect_workspace_path, - root_session_id: "session-a".to_string(), - session_id: "session-child".to_string(), - required_settled_turn_ids: Vec::new(), - remote_connection_id: None, - remote_ssh_host: None, - }, - }) - .await - }); - wait_for_calls(&handler, |calls| { - calls - .iter() - .any(|call| matches!(call, RuntimeIpcOperation::InspectLineageSession { .. })) - }) - .await; - - let cancellation = tokio::time::timeout( - Duration::from_millis(300), - client.request(RuntimeIpcOperation::CancelLineageSession { - request: AgentSessionLineageCancellationRequest { - workspace_path, - root_session_id: "session-a".to_string(), - session_id: "session-child".to_string(), - expected_active_turn_id: Some("turn-child".to_string()), - source: None, - reason: None, - wait_timeout_ms: None, - remote_connection_id: None, - remote_ssh_host: None, - }, - }), - ) - .await - .expect("lineage cancellation must not wait for transcript I/O") - .expect("cancel response"); - assert!(matches!( - cancellation, - RuntimeIpcOperationResult::TurnCancelled { cancellation } - if cancellation.requested && cancellation.session_id == "session-child" - )); - assert!(matches!( - inspect.await.expect("inspect task"), - Err(RuntimeIpcClientError::Remote(RuntimeIpcError { - code: RuntimeIpcErrorCode::Unavailable, - .. - })) - )); - - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn generated_session_is_claimed_before_another_connection_can_restore_it() { - let create_started = Arc::new(Notify::new()); - let allow_create = Arc::new(Notify::new()); - let server = TestServer::start( - server_config(), - Arc::new(CreateRaceHandler { - create_started: create_started.clone(), - allow_create: allow_create.clone(), - available: Arc::new(AtomicBool::new(true)), - events: broadcast::channel(16).0, - }), - ) - .await; - let mut creator = server.connect("creator").await; - let mut restorer = server.connect("restorer").await; - let workspace_path = server.workspace.path().to_string_lossy().to_string(); - let expected_workspace_path = workspace_path.clone(); - - let create_task = tokio::spawn(async move { - request( - &mut creator, - 2, - create_operation(Path::new(&workspace_path), "Created session"), - ) - .await - }); - create_started.notified().await; - let restore_task = tokio::spawn(async move { - request( - &mut restorer, - 2, - RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: "fixture-workspace".to_string(), - session_id: "session-a".to_string(), - }, - }, - ) - .await - }); - - tokio::time::sleep(Duration::from_millis(20)).await; - assert!( - !restore_task.is_finished(), - "restore must wait until create has claimed its generated Session" - ); - allow_create.notify_one(); - match create_task.await.expect("create task") { - RuntimeIpcFrame::Response { - result: RuntimeIpcOperationResult::SessionCreated { session }, - .. - } => { - assert_eq!( - session.workspace_path.as_deref(), - Some(expected_workspace_path.as_str()) - ); - assert_eq!(session.workspace_id.as_deref(), Some("workspace-fixture")); - } - other => panic!("unexpected create response: {other:?}"), - } - assert!(matches!( - restore_task.await.expect("restore task"), - RuntimeIpcFrame::Error { error, .. } - if error.code == RuntimeIpcErrorCode::SessionInUse - )); - - server.finish().await; -} - -fn summary(session_id: &str) -> AgentSessionSummary { - AgentSessionSummary { - session_id: session_id.to_string(), - session_name: "Shared session".to_string(), - agent_type: "agentic".to_string(), - model_id: None, - reasoning_preset: None, - last_user_dialog_agent_type: None, - last_submitted_agent_type: None, - turn_count: 0, - created_at_ms: 1, - last_active_at_ms: 1, - } -} - -fn restored(session_id: &str) -> RuntimeIpcOperationResult { - RuntimeIpcOperationResult::SessionRestored { - session: summary(session_id), - state: crate::RuntimeSessionState::Idle, - transcript: SessionTranscript { - session_id: session_id.to_string(), - messages: Vec::new(), - }, - pending_permissions: Vec::new(), - workspace_binding: workspace_binding(), - } -} - -fn workspace_binding() -> AgentSessionWorkspaceBinding { - AgentSessionWorkspaceBinding { - workspace_id: Some("workspace-fixture".to_string()), - workspace_path: "/workspace".to_string(), - project_workspace_path: Some("/workspace".to_string()), - execution_target: Some(SessionExecutionTarget::local("/workspace")), - remote_connection_id: None, - remote_ssh_host: None, - } -} - -fn test_identity(workspace: &Path) -> RuntimeInstanceIdentity { - RuntimeInstanceIdentity::for_workspace( - workspace, - "bitfun", - "stable", - "user-a", - PROTOCOL_VERSION, - ) - .expect("runtime identity") -} - -fn restore_operation(workspace: &Path, session_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: workspace.to_string_lossy().to_string(), - session_id: session_id.to_string(), - }, - } -} - -fn create_operation(workspace: &Path, name: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::CreateSession { - request: AgentSessionCreateRequest { - session_name: name.to_string(), - agent_type: "agentic".to_string(), - workspace_path: Some(workspace.to_string_lossy().to_string()), - project_workspace_path: None, - execution_target: None, - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id: None, - metadata: Map::new(), - }, - } -} - -fn submit_operation(workspace: &Path, session_id: &str, turn_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::SubmitTurn { - request: AgentDialogTurnRequest { - session_id: session_id.to_string(), - message: "hello".to_string(), - original_message: None, - turn_id: Some(turn_id.to_string()), - execution: Default::default(), - agent_type: "agentic".to_string(), - workspace_path: Some(workspace.to_string_lossy().to_string()), - remote_connection_id: None, - remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), - reply_route: None, - prepended_reminders: Vec::new(), - attachments: Vec::new(), - metadata: Map::new(), - }, - } -} - -fn steer_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::SteerTurn { - request: bitfun_runtime_ports::AgentDialogSteerRequest { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - content: "check tests".to_string(), - display_content: None, - }, - } -} - -fn shell_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::RunUserShellCommand { - request: AgentUserShellCommandRequest { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - command: "git status --short".to_string(), - }, - } -} - -fn compact_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::CompactSession { - request: AgentSessionCompactionRequest { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - }, - } -} - -fn undo_operation(workspace: &Path, session_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::UndoSession { - request: AgentSessionRevertRequest { - workspace_path: workspace.to_string_lossy().to_string(), - session_id: session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }, - } -} - -fn update_mode_operation(session_id: &str, mode_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::UpdateSessionMode { - request: AgentSessionModeUpdateRequest { - session_id: session_id.to_string(), - mode_id: mode_id.to_string(), - }, - } -} - -fn update_model_operation(session_id: &str, model_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::UpdateSessionModel { - request: AgentSessionModelUpdateRequest { - session_id: session_id.to_string(), - model_id: model_id.to_string(), - }, - } -} - -fn rename_operation(session_id: &str, session_name: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: session_id.to_string(), - session_name: session_name.to_string(), - }, - } -} - -fn fork_operation(session_id: &str, before_turn_id: Option<&str>) -> RuntimeIpcOperation { - RuntimeIpcOperation::ForkSession { - request: RuntimeSessionForkRequest { - session_id: session_id.to_string(), - before_turn_id: before_turn_id.map(str::to_string), - }, - } -} - -fn delete_operation(session_id: &str) -> RuntimeIpcOperation { - RuntimeIpcOperation::DeleteSession { - session_id: session_id.to_string(), - } -} - -fn server_config() -> RuntimeIpcServerConfig { - RuntimeIpcServerConfig { - server_version: "shared-controller-test".to_string(), - idle_timeout: Duration::from_millis(100), - handshake_timeout: Duration::from_secs(2), - request_timeout: Duration::from_secs(2), - max_connections: 4, - } -} - -async fn initialize( - endpoint: &crate::LocalIpcEndpoint, - discovery: &crate::DiscoveryRecord, - client_id: &str, -) -> LocalIpcStream { - let mut stream = endpoint - .connect(Duration::from_secs(2)) - .await - .expect("connect local stream"); - write_frame( - &mut stream, - &RuntimeIpcFrame::Initialize { - request_id: 1, - request: InitializeRequest { - protocol_version: PROTOCOL_VERSION, - instance_identity: discovery.instance_identity.as_str().to_string(), - token: discovery.token.clone(), - client_id: client_id.to_string(), - client_version: "0.1.0".to_string(), - }, - }, - ) - .await - .expect("initialize request"); - assert!(matches!( - read_frame(&mut stream).await.expect("initialize response"), - RuntimeIpcFrame::Initialized { result, .. } if result.capabilities.interactive_tui - )); - stream -} - -async fn request( - stream: &mut LocalIpcStream, - request_id: u64, - operation: RuntimeIpcOperation, -) -> RuntimeIpcFrame { - write_frame( - stream, - &RuntimeIpcFrame::Request { - request_id, - operation, - }, - ) - .await - .expect("write operation"); - read_frame(stream).await.expect("read operation response") -} - -async fn expect_response( - stream: &mut LocalIpcStream, - request_id: u64, - operation: RuntimeIpcOperation, -) { - assert!(matches!( - request(stream, request_id, operation).await, - RuntimeIpcFrame::Response { .. } - )); -} - -async fn expect_error( - stream: &mut LocalIpcStream, - request_id: u64, - operation: RuntimeIpcOperation, - expected: RuntimeIpcErrorCode, -) { - assert!(matches!( - request(stream, request_id, operation).await, - RuntimeIpcFrame::Error { error, .. } if error.code == expected - )); -} - -async fn wait_for_calls(handler: &FakeHandler, ready: impl Fn(&[RuntimeIpcOperation]) -> bool) { - tokio::time::timeout(Duration::from_secs(2), async { - loop { - let found = { - let calls = handler.calls.lock().expect("calls"); - ready(&calls) - }; - if found { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("expected runtime operation"); -} - -#[tokio::test] -async fn session_switching_is_exclusive_and_disconnect_releases_control() { - let server = TestServer::start(server_config(), Arc::new(FakeHandler::default())).await; - let mut first = server.connect("first-controller").await; - let mut second = server.connect("second-controller").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_response( - &mut second, - 3, - restore_operation(server.workspace.path(), "session-b"), - ) - .await; - expect_error( - &mut first, - 3, - restore_operation(server.workspace.path(), "session-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_response( - &mut first, - 4, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - - drop(first); - tokio::time::sleep(Duration::from_millis(30)).await; - expect_response( - &mut second, - 4, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn session_scoped_agent_catalog_requires_the_current_controller() { - let server = TestServer::start(server_config(), Arc::new(FakeHandler::default())).await; - let mut client = server.connect("catalog-controller").await; - - expect_response( - &mut client, - 2, - RuntimeIpcOperation::ListAgentModes { session_id: None }, - ) - .await; - expect_error( - &mut client, - 3, - RuntimeIpcOperation::ListAgentModes { - session_id: Some("session-a".to_string()), - }, - RuntimeIpcErrorCode::ControllerRequired, - ) - .await; - expect_response( - &mut client, - 4, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut client, - 5, - RuntimeIpcOperation::ListAgentModes { - session_id: Some("session-a".to_string()), - }, - ) - .await; - - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn successful_fork_atomically_transfers_control_and_releases_the_source() { - let server = TestServer::start(server_config(), Arc::new(FakeHandler::default())).await; - let mut forker = server.connect("fork-controller").await; - let mut observer = server.connect("source-controller").await; - - expect_response( - &mut forker, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - assert!(matches!( - request(&mut forker, 3, fork_operation("session-a", Some("turn-2"))).await, - RuntimeIpcFrame::Response { - result: RuntimeIpcOperationResult::SessionForked { session, .. }, - .. - } if session.session_id == "session-fork" - )); - - expect_response( - &mut observer, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut forker, - 4, - update_mode_operation("session-a", "ask"), - RuntimeIpcErrorCode::SessionMismatch, - ) - .await; - expect_response(&mut forker, 5, update_mode_operation("session-fork", "ask")).await; - expect_error( - &mut observer, - 3, - restore_operation(server.workspace.path(), "session-fork"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - drop(forker); - drop(observer); - server.finish().await; -} - -#[tokio::test] -async fn one_connection_rejects_a_second_turn_until_the_first_finishes() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("controller").await; - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut client, - 3, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_error( - &mut client, - 4, - submit_operation(server.workspace.path(), "session-a", "turn-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_error( - &mut client, - 5, - restore_operation(server.workspace.path(), "session-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_error( - &mut client, - 6, - fork_operation("session-a", None), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - drop(client); - wait_for_calls(&handler, |calls| { - let submitted = calls - .iter() - .filter(|call| matches!(call, RuntimeIpcOperation::SubmitTurn { .. })) - .count(); - let cancelled_first = calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CancelTurn { request } - if request.session_id == "session-a" - && request.turn_id.as_deref() == Some("turn-a") - ) - }); - let forked = calls - .iter() - .any(|call| matches!(call, RuntimeIpcOperation::ForkSession { .. })); - submitted == 1 && !forked && cancelled_first - }) - .await; - server.finish().await; -} - -#[tokio::test] -async fn steering_requires_and_preserves_the_connections_exact_active_turn() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("steering-controller").await; - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut client, - 3, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_response(&mut client, 4, steer_operation("session-a", "turn-a")).await; - expect_error( - &mut client, - 5, - steer_operation("session-a", "turn-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_error( - &mut client, - 6, - submit_operation(server.workspace.path(), "session-a", "turn-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - let calls = handler.calls.lock().unwrap().clone(); - assert_eq!( - calls - .iter() - .filter(|operation| matches!(operation, RuntimeIpcOperation::SteerTurn { .. })) - .count(), - 1, - "a mismatched steer must be rejected before reaching the runtime" - ); - - drop(client); - wait_for_calls(&handler, |calls| { - calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CancelTurn { request } - if request.session_id == "session-a" - && request.turn_id.as_deref() == Some("turn-a") - ) - }) - }) - .await; - server.finish().await; -} - -#[tokio::test] -async fn steering_rejects_an_invalid_runtime_result_and_closes_the_connection() { - let handler = Arc::new(FakeHandler { - invalid_steer_result: true, - ..FakeHandler::default() - }); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("invalid-steering-result").await; - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut client, - 3, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_error( - &mut client, - 4, - steer_operation("session-a", "turn-a"), - RuntimeIpcErrorCode::Internal, - ) - .await; - - assert!(read_frame(&mut client).await.is_err()); - wait_for_calls(&handler, |calls| { - calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CancelTurn { request } - if request.session_id == "session-a" - && request.turn_id.as_deref() == Some("turn-a") - ) - }) - }) - .await; - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn manual_compaction_owns_the_supplied_turn_until_disconnect_cancels_it() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("compact-controller").await; - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut client, - 3, - compact_operation("session-a", "turn-compact-a"), - ) - .await; - expect_error( - &mut client, - 4, - compact_operation("session-a", "turn-compact-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - drop(client); - wait_for_calls(&handler, |calls| { - let compacted = calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CompactSession { request } - if request.session_id == "session-a" - && request.turn_id == "turn-compact-a" - ) - }); - let cancelled = calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CancelTurn { request } - if request.session_id == "session-a" - && request.turn_id.as_deref() == Some("turn-compact-a") - ) - }); - compacted && cancelled - }) - .await; - server.finish().await; -} - -#[tokio::test] -async fn mode_update_requires_the_controlled_idle_session() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("mode-controller").await; - - expect_error( - &mut client, - 2, - update_mode_operation("session-a", "ask"), - RuntimeIpcErrorCode::ControllerRequired, - ) - .await; - expect_response( - &mut client, - 3, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut client, - 4, - update_mode_operation("session-b", "ask"), - RuntimeIpcErrorCode::SessionMismatch, - ) - .await; - expect_response(&mut client, 5, update_mode_operation("session-a", "ask")).await; - expect_response( - &mut client, - 6, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_error( - &mut client, - 7, - update_mode_operation("session-a", "agentic"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - // Scoped so the guard is provably released before the awaits below. - let updates = { - let calls = handler.calls.lock().expect("calls"); - calls - .iter() - .filter(|operation| matches!(operation, RuntimeIpcOperation::UpdateSessionMode { .. })) - .count() - }; - assert_eq!( - updates, 1, - "only the controlled idle-session update reaches the Runtime handler" - ); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn timed_out_mode_update_reports_unknown_outcome_and_closes_the_connection() { - let handler = Arc::new(FakeHandler { - mode_delay: Some(Duration::from_millis(100)), - ..FakeHandler::default() - }); - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let server = TestServer::start(config, handler).await; - let mut first = server.connect("mode-timeout").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut first, - 3, - update_mode_operation("session-a", "ask"), - RuntimeIpcErrorCode::OutcomeUnknown, - ) - .await; - - assert!(read_frame(&mut first).await.is_err()); - let mut second = server.connect("mode-timeout-successor").await; - expect_response( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - drop(first); - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn model_update_requires_the_controlled_idle_session() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("model-controller").await; - - expect_error( - &mut client, - 2, - update_model_operation("session-a", "provider/model-a"), - RuntimeIpcErrorCode::ControllerRequired, - ) - .await; - expect_response( - &mut client, - 3, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut client, - 4, - update_model_operation("session-b", "provider/model-a"), - RuntimeIpcErrorCode::SessionMismatch, - ) - .await; - expect_response( - &mut client, - 5, - update_model_operation("session-a", "provider/model-a"), - ) - .await; - expect_response( - &mut client, - 6, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_error( - &mut client, - 7, - update_model_operation("session-a", "provider/model-b"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - // Scoped so the guard is provably released before the awaits below. - let updates = { - let calls = handler.calls.lock().expect("calls"); - calls - .iter() - .filter(|operation| matches!(operation, RuntimeIpcOperation::UpdateSessionModel { .. })) - .count() - }; - assert_eq!( - updates, 1, - "only the controlled idle-session update reaches the Runtime handler" - ); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn timed_out_model_update_reports_unknown_outcome_and_closes_the_connection() { - let handler = Arc::new(FakeHandler { - model_delay: Some(Duration::from_millis(100)), - ..FakeHandler::default() - }); - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let server = TestServer::start(config, handler).await; - let mut first = server.connect("model-timeout").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut first, - 3, - update_model_operation("session-a", "provider/model-a"), - RuntimeIpcErrorCode::OutcomeUnknown, - ) - .await; - - assert!(read_frame(&mut first).await.is_err()); - let mut second = server.connect("model-timeout-successor").await; - expect_response( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - drop(first); - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn rename_requires_the_controlled_idle_session() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("rename-controller").await; - - expect_error( - &mut client, - 2, - rename_operation("session-a", "Auth refactor"), - RuntimeIpcErrorCode::ControllerRequired, - ) - .await; - expect_response( - &mut client, - 3, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut client, - 4, - rename_operation("session-b", "Other work"), - RuntimeIpcErrorCode::SessionMismatch, - ) - .await; - expect_response( - &mut client, - 5, - rename_operation("session-a", "Auth refactor"), - ) - .await; - expect_response( - &mut client, - 6, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_error( - &mut client, - 7, - rename_operation("session-a", "Blocked during turn"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - let calls = handler.calls.lock().expect("calls"); - assert_eq!( - calls - .iter() - .filter(|operation| matches!(operation, RuntimeIpcOperation::RenameSession { .. })) - .count(), - 1, - "only the controlled idle-session rename reaches the Runtime handler" - ); - drop(calls); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn undo_can_cancel_the_controlled_active_turn_and_clears_its_projection() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut client = server.connect("undo-controller").await; - - expect_response( - &mut client, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut client, - 3, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_response( - &mut client, - 4, - undo_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response(&mut client, 5, rename_operation("session-a", "After undo")).await; - - let calls = handler.calls.lock().expect("calls"); - assert!(calls - .iter() - .any(|operation| matches!(operation, RuntimeIpcOperation::UndoSession { .. }))); - drop(calls); - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn delete_requires_an_uncontrolled_target_and_an_idle_connection() { - let handler = Arc::new(FakeHandler::default()); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut first = server.connect("delete-controller").await; - let mut second = server.connect("delete-other").await; - - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut first, - 3, - delete_operation("session-a"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_error( - &mut second, - 2, - delete_operation("session-a"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - expect_response(&mut second, 3, delete_operation("session-b")).await; - - expect_response( - &mut first, - 4, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - expect_error( - &mut first, - 5, - delete_operation("session-c"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - let deletes = handler - .calls - .lock() - .expect("calls") - .iter() - .filter(|operation| matches!(operation, RuntimeIpcOperation::DeleteSession { .. })) - .count(); - assert_eq!( - deletes, 1, - "only the uncontrolled idle delete reaches Runtime" - ); - - drop(first); - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn timed_out_delete_reports_unknown_outcome_and_closes_the_connection() { - let handler = Arc::new(FakeHandler { - delete_delay: Some(Duration::from_millis(100)), - ..FakeHandler::default() - }); - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let server = TestServer::start(config, handler).await; - let mut client = server.connect("delete-timeout").await; - - expect_error( - &mut client, - 2, - delete_operation("session-b"), - RuntimeIpcErrorCode::OutcomeUnknown, - ) - .await; - assert!(read_frame(&mut client).await.is_err()); - - drop(client); - server.finish().await; -} - -#[tokio::test] -async fn timed_out_rename_reports_unknown_outcome_and_closes_the_connection() { - let handler = Arc::new(FakeHandler { - rename_delay: Some(Duration::from_millis(100)), - ..FakeHandler::default() - }); - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let server = TestServer::start(config, handler).await; - let mut first = server.connect("rename-timeout").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut first, - 3, - rename_operation("session-a", "Outcome unknown"), - RuntimeIpcErrorCode::OutcomeUnknown, - ) - .await; - - assert!(read_frame(&mut first).await.is_err()); - let mut second = server.connect("rename-timeout-successor").await; - expect_response( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - drop(first); - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn timed_out_submit_closes_and_cancels_its_provisional_turn() { - let handler = Arc::new(FakeHandler { - submit_delay: Some(Duration::from_millis(100)), - ..FakeHandler::default() - }); - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let server = TestServer::start(config, handler.clone()).await; - let mut first = server.connect("first-controller").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut first, - 3, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - RuntimeIpcErrorCode::OutcomeUnknown, - ) - .await; - - wait_for_calls(&handler, |calls| { - calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CancelTurn { request } - if request.session_id == "session-a" - && request.turn_id.as_deref() == Some("turn-a") - ) - }) - }) - .await; - - let mut second = server.connect("second-controller").await; - expect_response( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - drop(first); - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn timed_out_shell_command_closes_and_cancels_its_provisional_turn() { - let handler = Arc::new(FakeHandler { - submit_delay: Some(Duration::from_millis(100)), - ..FakeHandler::default() - }); - let mut config = server_config(); - config.request_timeout = Duration::from_millis(20); - let server = TestServer::start(config, handler.clone()).await; - let mut first = server.connect("shell-controller").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_error( - &mut first, - 3, - shell_operation("session-a", "turn-shell-a"), - RuntimeIpcErrorCode::OutcomeUnknown, - ) - .await; - - wait_for_calls(&handler, |calls| { - calls.iter().any(|call| { - matches!( - call, - RuntimeIpcOperation::CancelTurn { request } - if request.session_id == "session-a" - && request.turn_id.as_deref() == Some("turn-shell-a") - ) - }) - }) - .await; - - let mut second = server.connect("shell-successor").await; - expect_response( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - drop(first); - drop(second); - server.finish().await; -} - -#[tokio::test] -async fn unsettled_disconnect_cancellation_quarantines_the_session_lease() { - let handler = Arc::new(FakeHandler { - settle_cancel: false, - ..FakeHandler::default() - }); - let server = TestServer::start(server_config(), handler.clone()).await; - let mut first = server.connect("first-controller").await; - expect_response( - &mut first, - 2, - restore_operation(server.workspace.path(), "session-a"), - ) - .await; - expect_response( - &mut first, - 3, - submit_operation(server.workspace.path(), "session-a", "turn-a"), - ) - .await; - drop(first); - - tokio::time::sleep(Duration::from_millis(30)).await; - let mut second = server.connect("second-controller").await; - expect_error( - &mut second, - 2, - restore_operation(server.workspace.path(), "session-a"), - RuntimeIpcErrorCode::SessionInUse, - ) - .await; - - handler - .events - .send(RuntimeIpcEvent::StreamInvalidated { - reason: crate::RuntimeIpcStreamInvalidationReason::Closed, - }) - .unwrap(); - drop(second); - tokio::time::timeout(Duration::from_millis(300), server.finish()) - .await - .expect("stream invalidation starts the normal idle shutdown window"); -} diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 74d6c4255f..f079c99c20 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -433,6 +433,13 @@ fn runtime_tool_restrictions_for_session_lifetime( restrictions } +fn session_history_needs_restore( + context_message_count: usize, + persisted_turn_count: usize, +) -> bool { + persisted_turn_count > 0 && context_message_count <= 1 +} + /// Subagent execution result /// /// Contains the text response after subagent execution @@ -5369,24 +5376,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .get_context_messages(&session_id) .await?; - // Check if restore is needed: - // - Empty context needs restore - // - Only 1 message (likely just system prompt) with existing turns needs restore - // - Sessions with multiple turns should have > 1 messages (at least system + user + assistant) - let needs_restore = if context_messages.is_empty() { + // A missing or system-only context needs restore only when the Session + // already has persisted turns. A newly created Session legitimately has + // no context before its first turn. + let needs_restore = + session_history_needs_restore(context_messages.len(), session.dialog_turn_ids.len()); + if needs_restore && context_messages.is_empty() { debug!( "Session {} context is empty, restoring from persistence", session_id ); - true - } else if context_messages.len() == 1 && !session.dialog_turn_ids.is_empty() { + } else if needs_restore { debug!( "Session {} has {} turns but only {} messages, restoring history", session_id, session.dialog_turn_ids.len(), context_messages.len() ); - true } else { debug!( "Session {} context exists ({} messages, {} turns), no restore needed", @@ -5394,8 +5400,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context_messages.len(), session.dialog_turn_ids.len() ); - false - }; + } if needs_restore { debug!( @@ -9294,11 +9299,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .get_context_messages(session_id) .await?; - let needs_restore = if context_messages.is_empty() { - !session.dialog_turn_ids.is_empty() - } else { - context_messages.len() == 1 && !session.dialog_turn_ids.is_empty() - }; + let needs_restore = + session_history_needs_restore(context_messages.len(), session.dialog_turn_ids.len()); if needs_restore { let restore_path = self.restore_path_for_existing_session(session_id).await?; @@ -12791,12 +12793,12 @@ mod tests { resolve_agent_submission_turn_id, resolve_subagent_model_selection, resolve_submission_permission_mode, runtime_port_error_preserving_message, runtime_session_summary, runtime_tool_restrictions_for_session_lifetime, - runtime_transcript_messages_from_turns, session_storage_workspace_locator, - turn_review_manifest_for_agent, validate_required_lineage_turns_settled, - ActiveSubagentExecution, BackgroundSubagentWaitMode, ContextCompactionOutcome, - ConversationCoordinator, ManualCompactionCommitGate, SessionMemoryMode, - SessionReferenceLocator, SessionRelationshipKind, SubagentExecutionRequest, - TEST_AGENT_MODEL_DEFAULTS, + runtime_transcript_messages_from_turns, session_history_needs_restore, + session_storage_workspace_locator, turn_review_manifest_for_agent, + validate_required_lineage_turns_settled, ActiveSubagentExecution, + BackgroundSubagentWaitMode, ContextCompactionOutcome, ConversationCoordinator, + ManualCompactionCommitGate, SessionMemoryMode, SessionReferenceLocator, + SessionRelationshipKind, SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::agents::ExternalSubagentModelBinding; use crate::agentic::coordination::coordination_store::{ @@ -12875,6 +12877,15 @@ mod tests { } } + #[test] + fn session_history_restore_requires_a_persisted_turn() { + assert!(!session_history_needs_restore(0, 0)); + assert!(!session_history_needs_restore(1, 0)); + assert!(session_history_needs_restore(0, 1)); + assert!(session_history_needs_restore(1, 1)); + assert!(!session_history_needs_restore(2, 1)); + } + #[test] fn external_command_delegation_uses_the_resolved_primary_binding() { let source = include_str!("coordinator.rs").replace("\r\n", "\n"); diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index 155e327fe7..656470c566 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -208,6 +208,30 @@ impl AppServerClient { self.rpc(|cx| Ok(cx.send_request(request))).await } + pub async fn subscribe_session( + &self, + session_id: impl Into, + ) -> agent_client_protocol::Result { + self.rpc(|cx| { + Ok(cx.send_request(SubscribeSessionRequest { + session_id: session_id.into(), + })) + }) + .await + } + + pub async fn unsubscribe_session( + &self, + session_id: impl Into, + ) -> agent_client_protocol::Result { + self.rpc(|cx| { + Ok(cx.send_request(UnsubscribeSessionRequest { + session_id: session_id.into(), + })) + }) + .await + } + pub async fn external_source_snapshot( &self, request: ExternalSourceSnapshotRequest, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/app.rs b/src/crates/interfaces/app-server-protocol/src/schemas/app.rs index 6338409797..91a2326feb 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/app.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/app.rs @@ -22,6 +22,14 @@ pub struct ServerInfo { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TransportLimits { + /// The maximum JSON-RPC request frame accepted by the Host transport. + #[serde(default)] + pub max_request_bytes: u64, + /// The maximum JSON-RPC response or notification frame emitted by the Host transport. + #[serde(default)] + pub max_response_bytes: u64, + /// Deprecated aggregate limit retained for older clients. New Hosts should + /// populate the directional limits above. pub max_frame_bytes: u64, pub event_buffer_capacity: u32, } @@ -96,7 +104,7 @@ pub enum HealthStatus { #[cfg(test)] mod tests { use super::super::method::INITIALIZE; - use super::{ClientInfo, InitializeRequest}; + use super::{ClientInfo, InitializeRequest, TransportLimits}; #[test] fn initialize_method_is_stable() { @@ -110,4 +118,27 @@ mod tests { }; assert_eq!(request.protocol_version, 1); } + + #[test] + fn transport_limits_keep_the_legacy_aggregate_field_compatible() { + let legacy: TransportLimits = serde_json::from_value(serde_json::json!({ + "maxFrameBytes": 131_072, + "eventBufferCapacity": 1024 + })) + .expect("legacy transport limits should deserialize"); + assert_eq!(legacy.max_request_bytes, 0); + assert_eq!(legacy.max_response_bytes, 0); + assert_eq!(legacy.max_frame_bytes, 131_072); + + let value = serde_json::to_value(TransportLimits { + max_request_bytes: 131_072, + max_response_bytes: 8 * 1024 * 1024, + max_frame_bytes: 131_072, + event_buffer_capacity: 1024, + }) + .expect("directional transport limits should serialize"); + assert_eq!(value["maxRequestBytes"], 131_072); + assert_eq!(value["maxResponseBytes"], 8 * 1024 * 1024); + assert_eq!(value["maxFrameBytes"], 131_072); + } } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs index f6eb86d78f..d645e0fe37 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs @@ -105,6 +105,24 @@ pub struct RenameSessionRequest(pub AgentSessionRenameRequest); unit_response!(RenameSessionResponse); +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/subscribe", response = SubscribeSessionResponse)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SubscribeSessionRequest { + pub session_id: String, +} + +unit_response!(SubscribeSessionResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/unsubscribe", response = UnsubscribeSessionResponse)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnsubscribeSessionRequest { + pub session_id: String, +} + +unit_response!(UnsubscribeSessionResponse); + #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] #[request(method = "session/compact", response = CompactSessionResponse)] pub struct CompactSessionRequest(pub AgentSessionCompactionRequest); @@ -185,3 +203,38 @@ unit_response!(UpdateSessionModelResponse); pub struct UpdateSessionModeRequest(pub AgentSessionModeUpdateRequest); unit_response!(UpdateSessionModeResponse); + +#[cfg(test)] +mod tests { + use agent_client_protocol::JsonRpcMessage; + + use super::{SubscribeSessionRequest, UnsubscribeSessionRequest}; + + #[test] + fn subscription_requests_use_stable_session_methods() { + assert_eq!( + SubscribeSessionRequest { + session_id: "session-1".to_string(), + } + .method(), + "session/subscribe" + ); + assert_eq!( + UnsubscribeSessionRequest { + session_id: "session-1".to_string(), + } + .method(), + "session/unsubscribe" + ); + } + + #[test] + fn subscription_requests_reject_unknown_fields() { + let extra = serde_json::json!({ + "sessionId": "session-1", + "unexpected": true, + }); + assert!(serde_json::from_value::(extra.clone()).is_err()); + assert!(serde_json::from_value::(extra).is_err()); + } +} diff --git a/src/crates/interfaces/app-server/src/agent.rs b/src/crates/interfaces/app-server/src/agent.rs index 519918a6f9..8d9d433f65 100644 --- a/src/crates/interfaces/app-server/src/agent.rs +++ b/src/crates/interfaces/app-server/src/agent.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use agent_client_protocol::{Error, Result}; use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime, PortErrorKind, RuntimeError}; +use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_core::service::git::GitError; use bitfun_runtime_ports::AgentContextReloadPort; @@ -80,6 +81,9 @@ impl BitfunAppRuntime { RuntimeError::Port(error) => match error.kind { PortErrorKind::InvalidRequest => Error::invalid_params().data(error.message), PortErrorKind::NotFound => Error::resource_not_found(None), + PortErrorKind::SessionInUse => { + app_server_error(AppServerErrorKind::SessionInUse, error.message) + } _ => Self::internal_error(error.message), }, other => Self::internal_error(other.into_message()), @@ -102,6 +106,19 @@ impl BitfunAppRuntime { } } +fn app_server_error(kind: AppServerErrorKind, message: impl Into) -> Error { + Error::new(kind.json_rpc_code() as i32, message.into()).data( + serde_json::to_value(AppServerErrorData { + kind, + retryable: false, + outcome_unknown: false, + capability: Some("agent.turn_control".to_string()), + request_id: None, + }) + .unwrap_or(serde_json::Value::Null), + ) +} + /// Convenience for wrapping a fallible runtime call into a JSON-RPC `Result`. pub fn runtime_call(result: std::result::Result) -> Result { result.map_err(BitfunAppRuntime::runtime_error) diff --git a/src/crates/interfaces/app-server/src/management/service.rs b/src/crates/interfaces/app-server/src/management/service.rs index 3a87bd2f52..44ccda4e14 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -25,7 +25,7 @@ use super::{ SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, }; -/// App Server adapter shared by Embedded and local Shared compatibility Hosts. +/// App Server adapter shared by Embedded and local Shared Hosts. /// /// The service delegates to the existing config, registry, MCP, and external /// source owners. Local-only capabilities must be enabled through the local diff --git a/src/crates/interfaces/app-server/src/server.rs b/src/crates/interfaces/app-server/src/server.rs index 4dfeac8806..889397649a 100644 --- a/src/crates/interfaces/app-server/src/server.rs +++ b/src/crates/interfaces/app-server/src/server.rs @@ -8,10 +8,13 @@ mod event_forwarder; mod fallback; mod handlers; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +use std::{collections::HashSet, sync::RwLock}; use agent_client_protocol::{ConnectTo, ConnectionTo, Result}; +use bitfun_product_domains::tool_permissions::PermissionRequest; +use bitfun_runtime_ports::AgentSessionWorkspaceBinding; use crate::agent::BitfunAppRuntime; use crate::management::AppManagementService; @@ -21,6 +24,10 @@ static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); pub(super) struct ConnectionEventState { id: String, + require_session_subscription: bool, + local_management_scope: AtomicBool, + session_subscriptions: RwLock>, + permission_subscriptions: RwLock>, agent_sequence: AtomicU64, permission_sequence: AtomicU64, config_sequence: AtomicU64, @@ -28,12 +35,16 @@ pub(super) struct ConnectionEventState { } impl ConnectionEventState { - fn new() -> Self { + fn new(require_session_subscription: bool) -> Self { Self { id: format!( "app-server-{}", NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed) ), + require_session_subscription, + local_management_scope: AtomicBool::new(true), + session_subscriptions: RwLock::new(HashSet::new()), + permission_subscriptions: RwLock::new(HashSet::new()), agent_sequence: AtomicU64::new(0), permission_sequence: AtomicU64::new(0), config_sequence: AtomicU64::new(0), @@ -41,6 +52,121 @@ impl ConnectionEventState { } } + pub(super) fn subscribe_session(&self, session_id: impl Into) { + self.session_subscriptions + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(session_id.into()); + } + + pub(super) fn set_management_scope_from_binding(&self, binding: &AgentSessionWorkspaceBinding) { + self.local_management_scope.store( + binding.remote_connection_id.is_none() && binding.remote_ssh_host.is_none(), + Ordering::Release, + ); + } + + pub(super) fn set_local_management_scope(&self, allowed: bool) { + self.local_management_scope + .store(allowed, Ordering::Release); + } + + pub(super) fn allows_local_management(&self) -> bool { + self.local_management_scope.load(Ordering::Acquire) + } + + pub(super) fn unsubscribe_session(&self, session_id: &str) { + self.session_subscriptions + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id); + } + + pub(super) fn accepts_session(&self, session_id: &str) -> bool { + !self.require_session_subscription + || self + .session_subscriptions + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(session_id) + } + + pub(super) fn subscribe_descendant(&self, parent_session_id: &str, session_id: &str) { + if self.accepts_session(parent_session_id) { + self.subscribe_session(session_id.to_string()); + } + } + + pub(super) fn remember_permission(&self, request_id: impl Into) { + self.permission_subscriptions + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(request_id.into()); + } + + pub(super) fn accepts_permission(&self, request: &PermissionRequest) -> bool { + self.accepts_session(&request.session_id) + || request + .delegation + .as_ref() + .is_some_and(|delegation| self.accepts_session(&delegation.parent_session_id)) + } + + pub(super) fn filter_pending_permissions( + &self, + requests: Vec, + ) -> Vec { + requests + .into_iter() + .filter(|request| { + let accepted = self.accepts_permission(request); + if accepted { + self.remember_permission(request.request_id.clone()); + } + accepted + }) + .collect() + } + + pub(super) fn filter_session_permissions( + &self, + requests: Vec, + session_id: &str, + ) -> Vec { + requests + .into_iter() + .filter(|request| { + let accepted = request.session_id == session_id + || request + .delegation + .as_ref() + .is_some_and(|delegation| delegation.parent_session_id == session_id); + if accepted { + self.remember_permission(request.request_id.clone()); + } + accepted + }) + .collect() + } + + pub(super) fn can_respond_permission(&self, request_id: &str) -> bool { + !self.require_session_subscription + || self + .permission_subscriptions + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(request_id) + } + + pub(super) fn take_permission(&self, request_id: &str) -> bool { + let removed = self + .permission_subscriptions + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(request_id); + !self.require_session_subscription || removed + } + pub(super) fn cursor( &self, stream: bitfun_app_server_protocol::event::EventStream, @@ -85,6 +211,8 @@ impl ConnectionEventState { pub struct BitfunAppServer { runtime: Arc, management: Option>, + require_session_subscription: bool, + transport_limits: bitfun_app_server_protocol::app::TransportLimits, } impl BitfunAppServer { @@ -92,6 +220,13 @@ impl BitfunAppServer { Self { runtime: Arc::new(runtime), management: None, + require_session_subscription: false, + transport_limits: bitfun_app_server_protocol::app::TransportLimits { + max_request_bytes: 16 * 1024 * 1024, + max_response_bytes: 16 * 1024 * 1024, + max_frame_bytes: 16 * 1024 * 1024, + event_buffer_capacity: 1024, + }, } } @@ -100,6 +235,23 @@ impl BitfunAppServer { self } + /// Restrict Session and Permission notifications to Sessions explicitly + /// subscribed by this connection. Shared multi-client hosts enable this; + /// private Embedded hosts retain the existing all-events behavior. + pub fn require_session_subscriptions(mut self, required: bool) -> Self { + self.require_session_subscription = required; + self + } + + /// Set the limits owned by the concrete Host transport. + pub fn with_transport_limits( + mut self, + limits: bitfun_app_server_protocol::app::TransportLimits, + ) -> Self { + self.transport_limits = limits; + self + } + /// Return the shared runtime used by this server. pub fn runtime(&self) -> &BitfunAppRuntime { &self.runtime @@ -109,7 +261,8 @@ impl BitfunAppServer { pub async fn serve(self, transport: impl ConnectTo + 'static) -> Result<()> { let runtime = self.runtime; let management = self.management; - let event_state = Arc::new(ConnectionEventState::new()); + let transport_limits = self.transport_limits; + let event_state = Arc::new(ConnectionEventState::new(self.require_session_subscription)); AppServer .builder() @@ -118,22 +271,54 @@ impl BitfunAppServer { runtime.clone(), event_state.clone(), management.clone(), + transport_limits, )) .with_connection_builder(handlers::agent::builder( runtime.clone(), management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::account::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::session::builder( + runtime.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::permission::builder( + runtime.clone(), + event_state.clone(), )) - .with_connection_builder(handlers::account::builder(management.clone())) - .with_connection_builder(handlers::session::builder(runtime.clone())) - .with_connection_builder(handlers::permission::builder(runtime.clone())) .with_connection_builder(handlers::workspace::builder(runtime.clone())) - .with_connection_builder(handlers::worktree::builder(management.clone())) - .with_connection_builder(handlers::model::builder(management.clone())) - .with_connection_builder(handlers::skill::builder(management.clone())) - .with_connection_builder(handlers::subagent::builder(management.clone())) - .with_connection_builder(handlers::mcp::builder(management.clone())) - .with_connection_builder(handlers::external_source::builder(management.clone())) - .with_connection_builder(handlers::hook::builder(management.clone())) + .with_connection_builder(handlers::worktree::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::model::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::skill::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::subagent::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::mcp::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::external_source::builder( + management.clone(), + event_state.clone(), + )) + .with_connection_builder(handlers::hook::builder( + management.clone(), + event_state.clone(), + )) .with_connection_builder(handlers::git::builder()) .with_connection_builder(handlers::config::builder()) .with_connection_builder(handlers::i18n::builder()) diff --git a/src/crates/interfaces/app-server/src/server/event_forwarder.rs b/src/crates/interfaces/app-server/src/server/event_forwarder.rs index 1afbbe5f22..a7cc9a5f5f 100644 --- a/src/crates/interfaces/app-server/src/server/event_forwarder.rs +++ b/src/crates/interfaces/app-server/src/server/event_forwarder.rs @@ -71,6 +71,19 @@ pub(super) async fn run( tokio::select! { recv = rx.recv() => match recv { Ok(envelope) => { + if let bitfun_events::AgenticEvent::SubagentSessionLinked { + session_id, + parent_session_id, + .. + } = &envelope.event + { + event_state.subscribe_descendant(parent_session_id, session_id); + } + if let Some(session_id) = envelope.event.session_id() { + if !event_state.accepts_session(session_id) { + continue; + } + } let notification = SessionEventNotification { cursor: event_state.next_cursor(EventStream::Agent), event: envelope, @@ -90,6 +103,22 @@ pub(super) async fn run( }, recv = permission_recv => match recv { Some(Ok(event)) => { + let accepted = match &event { + PermissionRequestEvent::Asked { request } => { + let accepted = event_state.accepts_permission(request); + if accepted { + event_state.remember_permission(request.request_id.clone()); + } + accepted + } + PermissionRequestEvent::Replied { request_id, .. } + | PermissionRequestEvent::Cancelled { request_id, .. } => { + event_state.take_permission(request_id) + } + }; + if !accepted { + continue; + } let notification = PermissionEventNotification { cursor: event_state.next_cursor(EventStream::Permission), event, @@ -127,6 +156,9 @@ pub(super) async fn run( }, recv = external_source_recv => match recv { Some(Ok((workspace_path, snapshot))) => { + if !event_state.allows_local_management() { + continue; + } if let Err(error) = cx.send_notification(ExternalSourceEventNotification { cursor: event_state.next_cursor(EventStream::ExternalSource), workspace_path, diff --git a/src/crates/interfaces/app-server/src/server/handlers/account.rs b/src/crates/interfaces/app-server/src/server/handlers/account.rs index 1aa634aed8..e5e2873b62 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/account.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/account.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, ACCOUNT_CAPABILITY, AccountSnapshotRequest, account_snapshot @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, ACCOUNT_CAPABILITY, AccountLoginRequest, account_login @@ -34,6 +37,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, ACCOUNT_CAPABILITY, AccountFinalizeLoginRequest, account_finalize_login @@ -43,6 +47,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, ACCOUNT_CAPABILITY, AccountLogoutRequest, account_logout @@ -52,6 +57,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SETTINGS_SYNC_CAPABILITY, SettingsSyncStartRequest, settings_sync_start @@ -61,6 +67,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SETTINGS_SYNC_CAPABILITY, SettingsSyncSnapshotRequest, settings_sync_snapshot @@ -70,6 +77,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SETTINGS_SYNC_CAPABILITY, SettingsSyncCancelRequest, settings_sync_cancel @@ -79,6 +87,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SETTINGS_SYNC_CAPABILITY, SettingsSyncLocalChangedRequest, settings_sync_local_changed diff --git a/src/crates/interfaces/app-server/src/server/handlers/agent.rs b/src/crates/interfaces/app-server/src/server/handlers/agent.rs index 5abffa5a5d..8b5ae2bc3c 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/agent.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/agent.rs @@ -16,6 +16,7 @@ use crate::schema::*; pub(in crate::server) fn builder( runtime: Arc, management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -23,6 +24,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MODES_CAPABILITY, ListAgentModesRequest, list_agent_modes @@ -32,14 +34,33 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: CreateSessionMessage, responder, _cx| { - responder.respond_with_result(runtime_call( + event_state.set_local_management_scope(false); + let result = runtime_call( runtime .runtime() .create_session(request.0) .await .map(CreateSessionResponse), - )) + ); + if let Ok(response) = &result { + let session_id = response.0.session_id.clone(); + event_state.subscribe_session(session_id.clone()); + match runtime + .runtime() + .resolve_session_workspace_binding( + bitfun_runtime_ports::AgentSessionWorkspaceRequest { session_id }, + ) + .await + { + Ok(Some(binding)) => { + event_state.set_management_scope_from_binding(&binding) + } + Ok(None) | Err(_) => event_state.set_local_management_scope(false), + } + } + responder.respond_with_result(result) } }, agent_client_protocol::on_receive_request!(), @@ -67,8 +88,10 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: SubmitTurnMessage, responder, _cx| { let session_id = request.0.session_id.clone(); + event_state.subscribe_session(session_id.clone()); responder.respond_with_result( runtime .runtime() @@ -86,8 +109,10 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: SubmitDialogTurnMessage, responder, _cx| { let session_id = request.0.session_id.clone(); + event_state.subscribe_session(session_id.clone()); responder.respond_with_result( runtime .runtime() @@ -105,9 +130,14 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: RunMessage, responder, _cx| { + if let RunSessionSpec::Existing { session_id } = &request.session { + event_state.subscribe_session(session_id.clone()); + } let handle = runtime_call(runtime.runtime().run(request.to_run_request()).await)?; + event_state.subscribe_session(handle.session_id.clone()); responder.respond(RunResponse::from_handle(handle)) } }, @@ -116,14 +146,16 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: CancelTurnMessage, responder, _cx| { - responder.respond_with_result(runtime_call( - runtime - .runtime() - .cancel_turn(request.0) - .await - .map(CancelTurnResponse), - )) + event_state.subscribe_session(request.0.session_id.clone()); + let result = runtime + .runtime() + .cancel_turn(request.0) + .await + .map(CancelTurnResponse) + .map_err(BitfunAppRuntime::runtime_error); + responder.respond_with_result(result) } }, agent_client_protocol::on_receive_request!(), @@ -131,8 +163,10 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: SteerTurnRequest, responder, _cx| { let session_id = request.0.session_id.clone(); + event_state.subscribe_session(session_id.clone()); responder.respond_with_result( runtime .runtime() @@ -154,8 +188,10 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: RunUserShellCommandRequest, responder, _cx| { let session_id = request.0.session_id.clone(); + event_state.subscribe_session(session_id.clone()); responder.respond_with_result( runtime .runtime() diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index 4d59cc6bdf..8653f75a40 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -1,7 +1,7 @@ use agent_client_protocol::{Builder, Error, HandleDispatchFrom}; use bitfun_app_server_protocol::app::{ CapabilityAvailability, CapabilityDescriptor, HealthRequest, HealthResponse, HealthStatus, - InitializeRequest, InitializeResponse, ServerInfo, TransportLimits, + InitializeRequest, InitializeResponse, ServerInfo, }; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_app_server_protocol::event::{SyncEventsRequest, SyncEventsResponse}; @@ -10,13 +10,11 @@ use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; use crate::management::EXTERNAL_SOURCES_CAPABILITY; use crate::role::{AppClient, AppServer}; -const MAX_FRAME_BYTES: u64 = 16 * 1024 * 1024; -const EVENT_BUFFER_CAPACITY: u32 = 1024; - pub(in crate::server) fn builder( runtime: std::sync::Arc, event_state: std::sync::Arc, management: Option>, + transport_limits: bitfun_app_server_protocol::app::TransportLimits, ) -> Builder> { let capabilities = registered_capabilities(management.as_deref()); let external_source_snapshot_available = capabilities.iter().any(|capability| { @@ -48,10 +46,7 @@ pub(in crate::server) fn builder( version: env!("CARGO_PKG_VERSION").to_string(), }, capabilities.clone(), - TransportLimits { - max_frame_bytes: MAX_FRAME_BYTES, - event_buffer_capacity: EVENT_BUFFER_CAPACITY, - }, + transport_limits.clone(), ))) }, agent_client_protocol::on_receive_request!(), @@ -70,6 +65,7 @@ pub(in crate::server) fn builder( let pending_permissions = runtime .runtime() .pending_permission_requests() + .map(|requests| event_state.filter_pending_permissions(requests)) .unwrap_or_default(); responder.respond(SyncEventsResponse { cursors: request @@ -80,7 +76,8 @@ pub(in crate::server) fn builder( pending_permissions, agent_snapshot_available: false, config_snapshot_available: false, - external_source_snapshot_available, + external_source_snapshot_available: external_source_snapshot_available + && event_state.allows_local_management(), }) }, agent_client_protocol::on_receive_request!(), @@ -111,6 +108,8 @@ fn registered_capabilities( "session", vec![ "session/sync", + "session/subscribe", + "session/unsubscribe", "session/readTranscript", "session/resolveWorkspace", "session/recordLocalCommandTurn", diff --git a/src/crates/interfaces/app-server/src/server/handlers/capability.rs b/src/crates/interfaces/app-server/src/server/handlers/capability.rs index 8d6052d2c6..da67d1dfba 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/capability.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/capability.rs @@ -12,11 +12,13 @@ use bitfun_product_domains::external_sources::{ use crate::management::{AppManagementError, AppManagementErrorKind, AppManagementService}; macro_rules! management_handler { - ($management:ident, $capability:expr, $request:ty, $method:ident) => {{ + ($management:ident, $event_state:ident, $capability:expr, $request:ty, $method:ident) => {{ let management = $management.clone(); + let event_state = $event_state.clone(); async move |request: $request, responder, _cx| { let management = crate::server::handlers::capability::require_management( management.as_deref(), + event_state.as_ref(), $capability, )?; responder.respond_with_result(management.$method(request).await.map_err(|error| { @@ -29,8 +31,10 @@ pub(super) use management_handler; pub(super) fn require_management<'a>( management: Option<&'a AppManagementService>, + event_state: &crate::server::ConnectionEventState, capability: &str, ) -> Result<&'a AppManagementService, Error> { + require_workspace_local_scope(event_state, capability)?; let management = management.ok_or_else(|| unsupported(capability))?; match management.capabilities().availability(capability) { Some(CapabilityAvailability::Available) => Ok(management), @@ -38,6 +42,36 @@ pub(super) fn require_management<'a>( } } +pub(super) fn require_workspace_local_scope( + event_state: &crate::server::ConnectionEventState, + capability: &str, +) -> Result<(), Error> { + if !is_workspace_bound_capability(capability) || event_state.allows_local_management() { + Ok(()) + } else { + Err(error_with_data( + AppServerErrorKind::Unsupported, + capability, + "This workspace-scoped capability is unavailable for a Remote workspace", + )) + } +} + +/// Only capabilities that are bound to the local workspace / execution target +/// are unavailable for a Remote workspace. Worktree binding and per-workspace +/// external-source catalogs operate on the local filesystem path, which is not +/// the authoritative location when the workspace files live on a remote host. +/// Host-runtime configuration (model, mode, skill, subagent, MCP, hooks, and +/// account/settings-sync) is owned and consumed by the local agent runtime that +/// executes the session, so it stays manageable regardless of where the +/// workspace files live. +fn is_workspace_bound_capability(capability: &str) -> bool { + matches!( + capability, + crate::management::WORKTREES_CAPABILITY | crate::management::EXTERNAL_SOURCES_CAPABILITY + ) +} + fn unsupported(capability: &str) -> Error { error_with_data( AppServerErrorKind::Unsupported, @@ -132,11 +166,15 @@ fn error_with_data( #[cfg(test)] mod tests { use super::*; - use crate::management::{EXTERNAL_SOURCES_CAPABILITY, MODELS_CAPABILITY, WORKTREES_CAPABILITY}; + use crate::management::{ + ACCOUNT_CAPABILITY, EXTERNAL_SOURCES_CAPABILITY, MODELS_CAPABILITY, + SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, + }; #[test] fn missing_management_service_returns_structured_unsupported_without_fallback() { - let error = match require_management(None, MODELS_CAPABILITY) { + let event_state = crate::server::ConnectionEventState::new(true); + let error = match require_management(None, &event_state, MODELS_CAPABILITY) { Ok(_) => panic!("management service should be required"), Err(error) => error, }; @@ -153,6 +191,65 @@ mod tests { assert!(!data.outcome_unknown); } + #[test] + fn remote_workspace_rejects_workspace_bound_capabilities_before_owner_access() { + let event_state = crate::server::ConnectionEventState::new(true); + event_state.set_management_scope_from_binding( + &bitfun_runtime_ports::AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: "/remote/workspace".to_string(), + project_workspace_path: None, + execution_target: None, + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: None, + }, + ); + // Only workspace-bound capabilities (worktrees, per-workspace external + // sources) are unavailable for a remote workspace. They must fail + // closed before any local owner access. + for capability in [WORKTREES_CAPABILITY, EXTERNAL_SOURCES_CAPABILITY] { + let error = match require_management(None, &event_state, capability) { + Ok(_) => panic!("remote workspace-bound management must fail closed before local owner access"), + Err(error) => error, + }; + let data: AppServerErrorData = serde_json::from_value( + error + .data + .expect("unsupported error should carry structured data"), + ) + .expect("parse app server error data"); + assert_eq!(data.kind, AppServerErrorKind::Unsupported); + assert_eq!(data.capability.as_deref(), Some(capability)); + assert!(error.message.contains("Remote workspace")); + } + } + + #[test] + fn remote_workspace_allows_host_runtime_capabilities() { + let event_state = crate::server::ConnectionEventState::new(true); + event_state.set_management_scope_from_binding( + &bitfun_runtime_ports::AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: "/remote/workspace".to_string(), + project_workspace_path: None, + execution_target: None, + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("ssh.example.test".to_string()), + }, + ); + // Host-runtime config (model catalog, account, settings-sync) is owned + // by the local agent runtime that executes the session, so it must pass + // the remote scope gate regardless of where the workspace files live. + for capability in [ + MODELS_CAPABILITY, + ACCOUNT_CAPABILITY, + SETTINGS_SYNC_CAPABILITY, + ] { + require_workspace_local_scope(&event_state, capability) + .expect("host-runtime capability must pass the remote scope gate"); + } + } + #[test] fn external_stale_error_preserves_domain_recovery_contract() { let domain = ExternalSourceOperationError::new( diff --git a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs index 70437c6b82..e97d51e921 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_SOURCES_CAPABILITY, ExternalSourceSnapshotRequest, external_source_snapshot @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_SOURCES_CAPABILITY, ExternalSourceControlRequest, external_source_control @@ -34,6 +37,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_SOURCES_CAPABILITY, ExternalSourceReviewRequest, external_source_review @@ -43,6 +47,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_SOURCES_CAPABILITY, SetNativeCommandChoiceRequest, set_native_command_choice @@ -52,6 +57,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_SOURCES_CAPABILITY, ExpandExternalCommandRequest, expand_external_command diff --git a/src/crates/interfaces/app-server/src/server/handlers/hook.rs b/src/crates/interfaces/app-server/src/server/handlers/hook.rs index 143a783264..e5d643f9e2 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/hook.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/hook.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, NATIVE_HOOKS_CAPABILITY, NativeHookOverviewRequest, native_hook_overview @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_HOOKS_CAPABILITY, ExternalHookSnapshotRequest, external_hook_snapshot @@ -34,6 +37,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_HOOKS_CAPABILITY, ExternalHookPlanRequest, external_hook_plan @@ -43,6 +47,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_HOOKS_CAPABILITY, ExternalHookApplyRequest, external_hook_apply @@ -52,6 +57,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, EXTERNAL_HOOKS_CAPABILITY, ExternalHookMutationRequest, external_hook_mutate diff --git a/src/crates/interfaces/app-server/src/server/handlers/mcp.rs b/src/crates/interfaces/app-server/src/server/handlers/mcp.rs index 86abf39a46..f242e0a822 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/mcp.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/mcp.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MCP_CAPABILITY, ListMcpServersRequest, list_mcp_servers @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MCP_CAPABILITY, ToggleMcpServerRequest, toggle_mcp_server @@ -34,6 +37,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MCP_CAPABILITY, AddMcpServerRequest, add_mcp_server @@ -43,6 +47,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MCP_CAPABILITY, DeleteMcpServerRequest, delete_mcp_server @@ -52,6 +57,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MCP_CAPABILITY, ExternalMcpDecisionRequest, external_mcp_decision @@ -61,6 +67,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MCP_CAPABILITY, McpConflictChoiceRequest, mcp_conflict_choice diff --git a/src/crates/interfaces/app-server/src/server/handlers/model.rs b/src/crates/interfaces/app-server/src/server/handlers/model.rs index d485dc3e92..7198516513 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/model.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/model.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MODELS_CAPABILITY, TuiModelCatalogRequest, tui_model_catalog @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MODELS_CAPABILITY, ListModelsRequest, list_models @@ -32,16 +35,29 @@ pub(in crate::server) fn builder( agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!(management, MODELS_CAPABILITY, GetModelRequest, get_model), + management_handler!( + management, + event_state, + MODELS_CAPABILITY, + GetModelRequest, + get_model + ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!(management, MODELS_CAPABILITY, AddModelRequest, add_model), + management_handler!( + management, + event_state, + MODELS_CAPABILITY, + AddModelRequest, + add_model + ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( management_handler!( management, + event_state, MODELS_CAPABILITY, UpdateModelRequest, update_model @@ -51,6 +67,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MODELS_CAPABILITY, DeleteModelRequest, delete_model @@ -60,6 +77,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, MODELS_CAPABILITY, SetModelDefaultRequest, set_model_default diff --git a/src/crates/interfaces/app-server/src/server/handlers/permission.rs b/src/crates/interfaces/app-server/src/server/handlers/permission.rs index 874358fc0b..eeab2029b5 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/permission.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/permission.rs @@ -1,11 +1,13 @@ use crate::agent::{runtime_call, BitfunAppRuntime}; use crate::role::{AppClient, AppServer}; use crate::schema::*; -use agent_client_protocol::{Builder, HandleDispatchFrom}; +use agent_client_protocol::{Builder, Error, HandleDispatchFrom}; +use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use std::sync::Arc; pub(in crate::server) fn builder( runtime: Arc, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -13,7 +15,11 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |r: RespondPermissionMessage, p, _| { + if !event_state.can_respond_permission(&r.request_id) { + return p.respond_with_result(Err(permission_scope_error(&r.request_id))); + } runtime_call( runtime .runtime() @@ -28,7 +34,11 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |r: RespondPermissionBatchMessage, p, _| { + if !event_state.can_respond_permission(&r.request_id) { + return p.respond_with_result(Err(permission_scope_error(&r.request_id))); + } let request_ids = runtime_call( runtime .runtime() @@ -43,8 +53,11 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |_: ListPendingPermissionRequestsMessage, p, _| { - let requests = runtime_call(runtime.runtime().pending_permission_requests())?; + let requests = event_state.filter_pending_permissions(runtime_call( + runtime.runtime().pending_permission_requests(), + )?); p.respond(ListPendingPermissionRequestsResponse { requests }) } }, @@ -106,3 +119,16 @@ pub(in crate::server) fn builder( agent_client_protocol::on_receive_request!(), ) } + +fn permission_scope_error(request_id: &str) -> Error { + Error::invalid_params().data( + serde_json::to_value(AppServerErrorData { + kind: AppServerErrorKind::InvalidRequest, + retryable: false, + outcome_unknown: false, + capability: Some("agent.respond_permission".to_string()), + request_id: Some(request_id.to_string()), + }) + .unwrap_or(serde_json::Value::Null), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/session.rs b/src/crates/interfaces/app-server/src/server/handlers/session.rs index 59ce5cb9b4..68b645356d 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/session.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/session.rs @@ -8,8 +8,10 @@ use bitfun_app_server_protocol::session::{ RecordLocalCommandTurnRequest, RecordLocalCommandTurnResponse, RedoSessionRequest, ReloadContextRequest, ReloadContextResponse, ResolveWorkspaceRequest, ResolveWorkspaceResponse, RevertSessionResponse, SessionLineageRequest, SessionLineageResponse, SessionProcessingPhase, - SessionRuntimeState, SessionUsageRequest, SessionUsageResponse, SyncSessionRequest, - SyncSessionResponse, UndoSessionRequest, WaitForSettlementRequest, WaitForSettlementResponse, + SessionRuntimeState, SessionUsageRequest, SessionUsageResponse, SubscribeSessionRequest, + SubscribeSessionResponse, SyncSessionRequest, SyncSessionResponse, UndoSessionRequest, + UnsubscribeSessionRequest, UnsubscribeSessionResponse, WaitForSettlementRequest, + WaitForSettlementResponse, }; use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; @@ -19,10 +21,31 @@ use crate::schema::*; pub(in crate::server) fn builder( runtime: Arc, + event_state: Arc, ) -> Builder> { AppServer .builder() .name("session handlers") + .on_receive_request( + { + let event_state = event_state.clone(); + async move |request: SubscribeSessionRequest, responder, _cx| { + event_state.subscribe_session(request.session_id); + responder.respond(SubscribeSessionResponse {}) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let event_state = event_state.clone(); + async move |request: UnsubscribeSessionRequest, responder, _cx| { + event_state.unsubscribe_session(&request.session_id); + responder.respond(UnsubscribeSessionResponse {}) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_request( { let runtime = runtime.clone(); @@ -102,14 +125,19 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: ForkSessionMessage, responder, _cx| { - responder.respond_with_result(runtime_call( + let result = runtime_call( runtime .runtime() .fork_session(request.0) .await .map(ForkSessionResponse), - )) + ); + if let Ok(response) = &result { + event_state.subscribe_session(response.0.session_id.clone()); + } + responder.respond_with_result(result) } }, agent_client_protocol::on_receive_request!(), @@ -117,14 +145,19 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: ForkSessionAtTurnMessage, responder, _cx| { - responder.respond_with_result(runtime_call( + let result = runtime_call( runtime .runtime() .fork_session_at_turn(request.0) .await .map(ForkSessionResponse), - )) + ); + if let Ok(response) = &result { + event_state.subscribe_session(response.0.session_id.clone()); + } + responder.respond_with_result(result) } }, agent_client_protocol::on_receive_request!(), @@ -132,14 +165,19 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: ForkSessionBeforeTurnMessage, responder, _cx| { - responder.respond_with_result(runtime_call( + let result = runtime_call( runtime .runtime() .fork_session_before_turn(request.0) .await .map(ForkSessionResponse), - )) + ); + if let Ok(response) = &result { + event_state.subscribe_session(response.0.session_id.clone()); + } + responder.respond_with_result(result) } }, agent_client_protocol::on_receive_request!(), @@ -147,18 +185,36 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: RestoreSessionMessage, responder, _cx| { let session_id = request.session_id.clone(); - responder.respond_with_result( - runtime + event_state.set_local_management_scope(false); + event_state.subscribe_session(session_id.clone()); + let result = runtime + .runtime() + .restore_session(request.into()) + .await + .map(RestoreSessionResponse::from) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }); + if result.is_ok() { + match runtime .runtime() - .restore_session(request.into()) + .resolve_session_workspace_binding( + bitfun_runtime_ports::AgentSessionWorkspaceRequest { + session_id: session_id.clone(), + }, + ) .await - .map(RestoreSessionResponse::from) - .map_err(|error| { - BitfunAppRuntime::session_runtime_error(&session_id, error) - }), - ) + { + Ok(Some(binding)) => { + event_state.set_management_scope_from_binding(&binding) + } + Ok(None) | Err(_) => event_state.set_local_management_scope(false), + } + } + responder.respond_with_result(result) } }, agent_client_protocol::on_receive_request!(), @@ -166,9 +222,14 @@ pub(in crate::server) fn builder( .on_receive_request( { let runtime = runtime.clone(); + let event_state = event_state.clone(); async move |request: SyncSessionRequest, responder, _cx| { let session_id = request.session_id.clone(); let workspace_path = request.workspace_path.clone(); + let remote_connection_id = request.remote_connection_id.clone(); + let remote_ssh_host = request.remote_ssh_host.clone(); + event_state.set_local_management_scope(false); + event_state.subscribe_session(session_id.clone()); let restored = runtime .runtime() .restore_session(AgentSessionRestoreRequest { @@ -193,7 +254,7 @@ pub(in crate::server) fn builder( ) .await, )?; - let workspace_binding = runtime_call( + let authoritative_binding = match runtime_call( runtime .runtime() .resolve_session_workspace_binding( @@ -202,15 +263,32 @@ pub(in crate::server) fn builder( }, ) .await, - )? - .unwrap_or_else(|| fallback_workspace_binding(workspace_path)); - let pending_permissions = runtime - .runtime() - .pending_permission_requests() - .unwrap_or_default() - .into_iter() - .filter(|permission| permission.session_id == session_id) - .collect(); + ) { + Ok(binding) => binding, + Err(error) => { + event_state.set_local_management_scope(false); + return Err(error); + } + }; + if let Some(binding) = &authoritative_binding { + event_state.set_management_scope_from_binding(binding); + } else { + event_state.set_local_management_scope(false); + } + let workspace_binding = authoritative_binding.unwrap_or_else(|| { + fallback_workspace_binding( + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + }); + let pending_permissions = event_state.filter_session_permissions( + runtime + .runtime() + .pending_permission_requests() + .unwrap_or_default(), + &session_id, + ); responder.respond(SyncSessionResponse { session: restored.session, @@ -426,14 +504,23 @@ pub(in crate::server) fn builder( ) } -fn fallback_workspace_binding(workspace_path: String) -> AgentSessionWorkspaceBinding { +fn fallback_workspace_binding( + workspace_path: String, + remote_connection_id: Option, + remote_ssh_host: Option, +) -> AgentSessionWorkspaceBinding { + let execution_target = if remote_connection_id.is_none() && remote_ssh_host.is_none() { + Some(SessionExecutionTarget::local(workspace_path.clone())) + } else { + None + }; AgentSessionWorkspaceBinding { workspace_id: None, workspace_path: workspace_path.clone(), project_workspace_path: Some(workspace_path.clone()), - execution_target: Some(SessionExecutionTarget::local(workspace_path)), - remote_connection_id: None, - remote_ssh_host: None, + execution_target, + remote_connection_id, + remote_ssh_host, } } diff --git a/src/crates/interfaces/app-server/src/server/handlers/skill.rs b/src/crates/interfaces/app-server/src/server/handlers/skill.rs index 88522da6ea..29604fc5b5 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/skill.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/skill.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SKILLS_CAPABILITY, ListSkillsRequest, list_skills @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SKILLS_CAPABILITY, SetSkillEnabledRequest, set_skill_enabled diff --git a/src/crates/interfaces/app-server/src/server/handlers/subagent.rs b/src/crates/interfaces/app-server/src/server/handlers/subagent.rs index 8900215767..6c52038a09 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/subagent.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/subagent.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SUBAGENTS_CAPABILITY, ListSubagentsRequest, list_subagents @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, SUBAGENTS_CAPABILITY, SetSubagentEnabledRequest, set_subagent_enabled diff --git a/src/crates/interfaces/app-server/src/server/handlers/worktree.rs b/src/crates/interfaces/app-server/src/server/handlers/worktree.rs index 5e78bb75a5..1155e00095 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/worktree.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/worktree.rs @@ -9,6 +9,7 @@ use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( management: Option>, + event_state: Arc, ) -> Builder> { AppServer .builder() @@ -16,6 +17,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, WORKTREES_CAPABILITY, WorktreeRepositoryStatusRequest, worktree_repository_status @@ -25,6 +27,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, WORKTREES_CAPABILITY, WorktreeBindSessionRequest, worktree_bind_session @@ -34,6 +37,7 @@ pub(in crate::server) fn builder( .on_receive_request( management_handler!( management, + event_state, WORKTREES_CAPABILITY, WorktreeReleaseSessionRequest, worktree_release_session diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index 9e63e0b6f0..4ac92008e6 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -45,13 +45,20 @@ use bitfun_app_server::schema::{ }; use bitfun_app_server::{transport, AppClient, AppServer, BitfunAppRuntime, BitfunAppServer}; use bitfun_app_server_protocol::agent as protocol_agent; -use bitfun_app_server_protocol::app::{ClientInfo, HealthStatus, InitializeRequest}; +use bitfun_app_server_protocol::app::{ + ClientInfo, HealthStatus, InitializeRequest, TransportLimits, +}; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_app_server_protocol::event::{AgentEventNotification, EventStream, SyncEventsRequest}; use bitfun_app_server_protocol::session as protocol_session; use bitfun_app_server_protocol::workspace as protocol_workspace; use bitfun_app_server_protocol::PROTOCOL_VERSION; + +use bitfun_app_server_protocol::account::{ + AccountSnapshotRequest, SettingsSyncSnapshotRequest, +}; use bitfun_runtime_ports as ports; + use tokio::task::LocalSet; /// Minimal `AgentSubmissionPort` mock modeled on `sdk_minimal.rs`. @@ -354,8 +361,18 @@ impl ports::AgentSessionManagementPort for Phase2Provider { async fn resolve_session_workspace_binding( &self, - _request: ports::AgentSessionWorkspaceRequest, + request: ports::AgentSessionWorkspaceRequest, ) -> PortResult> { + if request.session_id == "remote-session" { + return Ok(Some(ports::AgentSessionWorkspaceBinding { + workspace_id: Some("remote-workspace-1".to_string()), + workspace_path: "/remote/workspace".to_string(), + project_workspace_path: Some("/remote/workspace".to_string()), + execution_target: None, + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("ssh.example.test".to_string()), + })); + } Ok(Some(ports::AgentSessionWorkspaceBinding { workspace_id: Some("workspace-1".to_string()), workspace_path: "/authoritative/workspace".to_string(), @@ -814,6 +831,112 @@ async fn phase2_sync_aggregates_authoritative_session_state() { .await; } +#[tokio::test(flavor = "current_thread")] +async fn remote_session_does_not_block_host_config() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, _provider) = build_phase2_app_runtime(); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + let response = client + .sync_session(protocol_session::SyncSessionRequest { + workspace_path: "/requested/remote".to_string(), + session_id: "remote-session".to_string(), + include_internal: false, + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("ssh.example.test".to_string()), + }) + .await + .expect("sync remote session"); + assert_eq!( + response.workspace_binding.remote_connection_id.as_deref(), + Some("remote-1") + ); + + // Host config is host-runtime state owned by the local agent + // runtime that executes the session, so it must NOT be rejected by + // the remote workspace scope gate. The harness has no global config + // service loaded, so the call fails later for that reason -- but + // crucially not with the remote-scope rejection. + let error = client + .validate_config() + .await + .expect_err("validate_config needs a global config service in the harness"); + assert!( + !error.message.contains("Remote workspace"), + "host config must not hit the remote workspace scope gate: {}", + error.message + ); + client.shutdown().await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn remote_session_allows_account_scoped_capabilities() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, _provider) = build_phase2_app_runtime(); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + let _response = client + .sync_session(protocol_session::SyncSessionRequest { + workspace_path: "/requested/remote".to_string(), + session_id: "remote-session".to_string(), + include_internal: false, + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("ssh.example.test".to_string()), + }) + .await + .expect("sync remote session"); + + // Account and settings-sync are account-level capabilities. The + // scope gate must let them through even for a remote workspace. + // The management service is not loaded in this test harness, so + // the call fails later with a generic "Host does not provide" + // error 鈥?but crucially NOT with the remote-scope rejection. + for error in [ + client + .account_snapshot(AccountSnapshotRequest { + workspace_path: "/requested/remote".to_string(), + }) + .await + .expect_err("account snapshot needs a management service"), + client + .settings_sync_snapshot(SettingsSyncSnapshotRequest { + workspace_path: "/requested/remote".to_string(), + }) + .await + .expect_err("settings sync needs a management service"), + ] { + let data: AppServerErrorData = serde_json::from_value( + error + .data + .expect("account error should carry stable data"), + ) + .expect("account error data should match the wire contract"); + assert_eq!(data.kind, AppServerErrorKind::Unsupported); + assert!( + !error.message.contains("Remote workspace"), + "account-scoped capability must not hit the remote scope gate: {}", + error.message + ); + } + client.shutdown().await; + }) + .await; +} + #[tokio::test(flavor = "current_thread")] async fn phase2_mutations_route_through_runtime_owner_ports() { let local = LocalSet::new(); @@ -1058,6 +1181,31 @@ fn spawn_server( }); } +fn spawn_server_with_transport_limits( + runtime: BitfunAppRuntime, + transport: impl agent_client_protocol::ConnectTo + 'static, + limits: TransportLimits, +) { + tokio::task::spawn_local(async move { + let _ = BitfunAppServer::new(runtime) + .with_transport_limits(limits) + .serve(transport) + .await; + }); +} + +fn spawn_shared_server( + runtime: BitfunAppRuntime, + transport: impl agent_client_protocol::ConnectTo + 'static, +) { + tokio::task::spawn_local(async move { + let _ = BitfunAppServer::new(runtime) + .require_session_subscriptions(true) + .serve(transport) + .await; + }); +} + #[tokio::test(flavor = "current_thread")] async fn lightweight_client_negotiates_with_the_production_server() { let local = LocalSet::new(); @@ -1092,6 +1240,8 @@ async fn lightweight_client_negotiates_with_the_production_server() { .collect::>(); for method in [ "session/sync", + "session/subscribe", + "session/unsubscribe", "agent/steerTurn", "agent/runUserShellCommand", "agent/submitUserAnswers", @@ -1159,6 +1309,43 @@ async fn lightweight_client_negotiates_with_the_production_server() { .await; } +#[tokio::test(flavor = "current_thread")] +async fn initialize_advertises_the_host_supplied_transport_limits() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let limits = TransportLimits { + max_request_bytes: 128 * 1024, + max_response_bytes: 8 * 1024 * 1024, + max_frame_bytes: 128 * 1024, + event_buffer_capacity: 1024, + }; + spawn_server_with_transport_limits( + build_app_runtime(), + server_transport, + limits.clone(), + ); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + let initialized = client + .initialize(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "transport-limit-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }) + .await + .expect("initialize should return Host limits"); + assert_eq!(initialized.limits, limits); + client.shutdown().await; + }) + .await; +} + #[tokio::test(flavor = "current_thread")] async fn session_control_methods_forward_exact_owner_dtos() { let local = LocalSet::new(); @@ -1642,6 +1829,97 @@ async fn runtime_events_are_forwarded_as_agent_event_notifications() { .await; } +#[tokio::test(flavor = "current_thread")] +async fn shared_connections_receive_only_their_subscribed_session_events() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_a, client_a_transport) = transport::in_memory_channel_pair(); + let (server_b, client_b_transport) = transport::in_memory_channel_pair(); + let (runtime, event_queue) = build_app_runtime_with_queue(); + spawn_shared_server(runtime.clone(), server_a); + spawn_shared_server(runtime, server_b); + + let client_a = bitfun_app_server_client::connect(client_a_transport) + .await + .expect("connect first shared client"); + let client_b = bitfun_app_server_client::connect(client_b_transport) + .await + .expect("connect second shared client"); + let mut events_a = client_a.subscribe_events(); + let mut events_b = client_b.subscribe_events(); + + client_a + .subscribe_session("session-1") + .await + .expect("subscribe first client to session-1"); + client_b + .subscribe_session("session-2") + .await + .expect("subscribe second client to session-2"); + + enqueue_session_state(&event_queue, "session-1", "first").await; + assert_eq!(next_agent_session(&mut events_a).await, "session-1"); + assert!( + tokio::time::timeout(Duration::from_millis(150), events_b.recv()) + .await + .is_err() + ); + + client_b + .subscribe_session("session-1") + .await + .expect("subscribe second client to session-1"); + enqueue_session_state(&event_queue, "session-1", "second").await; + assert_eq!(next_agent_session(&mut events_a).await, "session-1"); + assert_eq!(next_agent_session(&mut events_b).await, "session-1"); + + client_a + .unsubscribe_session("session-1") + .await + .expect("unsubscribe first client from session-1"); + enqueue_session_state(&event_queue, "session-1", "third").await; + assert_eq!(next_agent_session(&mut events_b).await, "session-1"); + assert!( + tokio::time::timeout(Duration::from_millis(150), events_a.recv()) + .await + .is_err() + ); + }) + .await; +} + +async fn enqueue_session_state(event_queue: &EventQueue, session_id: &str, state: &str) { + event_queue + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: session_id.to_string(), + new_state: state.to_string(), + }, + None, + ) + .await + .expect("session event should enqueue"); +} + +async fn next_agent_session( + events: &mut tokio::sync::broadcast::Receiver, +) -> String { + let event = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .expect("shared client should receive an event") + .expect("shared client event stream should stay open"); + let bitfun_app_server_client::AppServerEvent::Agent(notification) = event else { + panic!("expected Agent event"); + }; + notification + .event + .event + .session_id() + .expect("test Agent event should carry a Session ID") + .to_string() +} + #[derive( Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcRequest, )]