From 47c76a2b15045edecb6c425e5c447dcf86f72918 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 27 Jul 2026 17:26:28 +0800 Subject: [PATCH] feat(cli): add opt-in shared TUI runtime --- .../agent-runtime-deployment-design.md | 64 +- docs/architecture/cli-product-line-design.md | 22 +- scripts/core-boundaries/rules/crate-rules.mjs | 18 +- .../rules/source/forbidden-rules.mjs | 18 +- scripts/core-boundaries/self-test.mjs | 30 +- src/apps/cli/Cargo.toml | 2 + src/apps/cli/src/actions.rs | 119 ++ src/apps/cli/src/agent/runtime_client.rs | 758 ++++++++-- src/apps/cli/src/chat_state.rs | 41 +- src/apps/cli/src/main.rs | 234 +++- src/apps/cli/src/modes/chat.rs | 10 +- src/apps/cli/src/modes/chat/account.rs | 13 +- src/apps/cli/src/modes/chat/commands.rs | 67 +- src/apps/cli/src/modes/chat/input.rs | 29 +- src/apps/cli/src/modes/chat/run.rs | 165 ++- src/apps/cli/src/modes/chat/sessions.rs | 34 +- src/apps/cli/src/runtime/mod.rs | 4 + src/apps/cli/src/shared_runtime.rs | 1226 +++++++++++++++++ src/apps/cli/src/ui/chat/popups.rs | 4 +- src/apps/cli/src/ui/session_selector.rs | 17 +- src/apps/cli/src/ui/startup.rs | 110 +- src/apps/cli/tests/exec_cli_contracts.rs | 15 + src/apps/cli/tests/product_assembly_cli.rs | 33 +- src/crates/adapters/AGENTS-CN.md | 2 +- src/crates/adapters/AGENTS.md | 2 +- .../adapters/agent-runtime-ipc/AGENTS-CN.md | 13 +- .../adapters/agent-runtime-ipc/AGENTS.md | 34 +- .../adapters/agent-runtime-ipc/Cargo.toml | 4 + .../adapters/agent-runtime-ipc/src/client.rs | 303 +++- .../adapters/agent-runtime-ipc/src/framing.rs | 213 ++- .../adapters/agent-runtime-ipc/src/handler.rs | 27 + .../adapters/agent-runtime-ipc/src/ipc.rs | 16 +- .../adapters/agent-runtime-ipc/src/lib.rs | 45 +- .../agent-runtime-ipc/src/operation.rs | 112 +- .../agent-runtime-ipc/src/protocol.rs | 56 +- .../adapters/agent-runtime-ipc/src/server.rs | 653 ++++++++- .../agent-runtime-ipc/src/session_lease.rs | 140 ++ .../src/tests/discovery_and_framing.rs | 35 +- .../src/tests/local_health.rs | 37 +- .../src/tests/protocol_contracts.rs | 68 +- .../src/tests/shared_controller.rs | 771 +++++++++++ .../services-core/src/process_manager.rs | 18 +- 42 files changed, 4974 insertions(+), 608 deletions(-) create mode 100644 src/apps/cli/src/shared_runtime.rs create mode 100644 src/crates/adapters/agent-runtime-ipc/src/handler.rs create mode 100644 src/crates/adapters/agent-runtime-ipc/src/session_lease.rs create mode 100644 src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index a877218b19..7db5e322d7 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -33,12 +33,12 @@ flowchart LR | Embedded Desktop GUI | 继续使用现有 Desktop 事件投影和 Tauri adapter;本设计没有改变其依赖或生命周期 | | Embedded TUI/Headless CLI/Peer Host | Session、Turn、Permission 和事件订阅统一通过同一个 Rust Runtime SDK(当前 preview);CLI crate 只保留第一方 adapter 和各形态自己的展示/断流策略 | | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | -| Runtime ownership | 已有可选的 Embedded 共享锁 / Shared 独占锁原语;尚未接入产品入口 | -| Shared local IPC | 已有未发布、仅 crate 内可见的 discovery、实例锁、严格握手、Health 和 cleanup 基础;尚无生产 consumer | -| Shared Session/Turn/Tool/Permission | 尚未设计为稳定 wire,也没有产品 consumer | -| Shared GUI/TUI/Remote | 尚未交付,没有 `--shared` 或隐藏 Host 命令 | +| Runtime ownership | CLI 的 Embedded deployment 取得共享锁;Shared TUI deployment 取得独占锁,二者在同一 workspace 互斥;其他产品入口尚未接入该锁 | +| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制租约、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 | -因此当前完成的是 Embedded 入口的调用边界收敛,不是用户可用的 Shared Runtime 产品。具体 `EventQueue` 仍由 Core 产品装配,Runtime SDK 只提供同进程订阅入口;没有 Shared event wire、事件重放或 Shared consumer。 +因此当前交付的是一条窄的、显式启用的 Shared TUI deployment,不是通用本机 Server。具体 `EventQueue` 仍由 Core 产品装配;IPC 只把当前 TUI 必需的强类型操作和事件映射到同一个 Runtime owner,没有事件重放或公开协议承诺。 ## 2. 最少名词 @@ -64,7 +64,7 @@ flowchart TB end Embedded["Embedded adapter"] --> API - Shared["Shared local IPC adapter · future"] -.-> API + Shared["Shared local IPC adapter · opt-in TUI"] --> API SDK["SDK Host adapter"] --> API Remote["Remote adapter"] --> API ``` @@ -105,31 +105,42 @@ flowchart LR ``` - 多个 Embedded 进程可继续并存。 -- Shared 与任何 Embedded owner 互斥,避免同一工作区出现两个 Runtime owner。 -- 当前没有入口调用该原语,所以现有产品行为不变。 +- 在当前 CLI 边界内,Shared TUI 与 Embedded CLI Runtime 互斥;多个 Embedded CLI 进程仍可并存。 +- CLI 每次初始化 Runtime 时都调用该原语;Desktop、SDK Host、Server 等入口尚未接入,也不会被误报为已共享或已互斥。 - 该锁不选择 workspace、不启动 Runtime、不缓存实例,也不替代 Session 写入权或文件冲突控制。 ### 4.2 私有本机 IPC ```mermaid sequenceDiagram - participant C as Foundation client + participant C as Shared TUI client participant D as User-private discovery - participant S as Foundation server + participant S as Shared Runtime process C->>D: read endpoint + token + identity + protocol C->>S: connect via Named Pipe / UDS C->>S: initialize(identity, protocol, token) alt valid - S-->>C: initialized(capabilities = health) - C->>S: health - S-->>C: instance identity + PID + S-->>C: initialized(health + interactive_tui) + C->>S: create or restore Session + S-->>C: controller lease + Session facts + C->>S: submit/cancel Turn or answer Permission/UserInput + S-->>C: Session-filtered authoritative events else invalid S-->>C: typed error and close end ``` -当前协议刻意只有 Health。它验证以下地基,而不提前冻结业务 wire: +当前协议只覆盖第一个 TUI 纵向切片: + +| 已支持 | 明确不支持 | +|---|---| +| Health、Session list/create、原子 restore(含 transcript 与 pending Permission) | Session delete/fork、跨 workspace attach、transcript 分页 | +| Turn submit/cancel | replay、cursor、resume event stream | +| pending/respond Permission、submit UserInput answers | 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; @@ -137,10 +148,15 @@ sequenceDiagram - discovery 所在目录必须由未来 composition 选择为当前用户私有目录; - discovery 通过同目录临时文件原子替换;Unix endpoint 保留原生路径字节,路径过长时在 bind 前返回明确错误; - 第一帧必须完成 token、instance identity 和 protocol version 校验; -- JSON frame 使用 4-byte 长度前缀,并在分配前执行 64 KiB 硬上限; +- 未认证握手预算为 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; - 未知字段、未知 operation、错误身份和不兼容版本 fail closed; -- 无连接后按调用方配置的 idle timeout 退出,并只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 +- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session。 +- Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 租约;无法确认时租约保持隔离,直到 Runtime 进程退出。 +- Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 +- Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 +- 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 这是一条本机同用户边界,不是沙箱、远程协议或公开兼容承诺。 @@ -180,7 +196,7 @@ flowchart LR | stable local endpoint + bearer token + owner id | endpoint 定位同一 instance;随机 token 认证本轮 server;owner id 防止旧实例误删新 discovery | | Session identity | 未来 Runtime 内的持久化和写入隔离;不由 IPC foundation 定义 | -一个 Client 关闭不应推导 Session 或 Runtime 必须退出;真正的 Shared lifecycle 需要综合 Client、活动 Query、后台任务和 Remote 引用。当前 Health-only server 没有这些业务引用,因此只实现“无连接后 idle 退出”。后续接入 Runtime 时必须替换为 Runtime-aware drain,不能直接复用 Health server 的简单空闲条件。 +当前 Shared TUI 只有 controller,没有 observer 或 detached Query:一个 Client 关闭不会删除 Session;它会取消仍拥有的活动 Turn,只有取消得到确认才释放 Session 控制租约,否则该租约隔离到 Runtime 退出。最后一个 Client 关闭后,Runtime 进入 30 秒空闲期;期间重连可继续使用,超时后 Runtime 正常关闭。若未来增加后台任务、observer 或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。 对普通单实例用户,未显式启用 Shared deployment 时不增加后台进程、连接、发现扫描或常驻内存。 @@ -200,12 +216,12 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 | 约束 | 当前决定 | |---|---| -| 首个候选 consumer | 仅限另行评审的第一方交互式 TUI attach adapter;不自动包含 GUI、Headless CLI、Remote 或 SDK Host | -| 稳定测试合同 | 本机 endpoint、initialize-first、64 KiB frame、Health、连接上限、owner-checked cleanup | -| 接入门槛 | 必须复用既有 Runtime owners,并用同一 fixture 证明 Embedded/Shared 行为等价 | -| 删除条件 | 若首个 consumer 选择其他 transport,或 Shared 在产品接入前取消,则直接删除该 crate,不保留“未来可能使用”的 API | +| 当前 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/Turn/transcript/Permission/UserInput 的 TUI 必需子集;任何新增操作都需要真实 consumer 和 owner 等价测试 | +| 协议地位 | crate 保持 `publish = false`;这是 workspace 内私有协议,不是 Agent SDK 或远程兼容承诺 | -在首个 consumer 通过评审前,crate 保持 `publish = false`,所有 Rust API 保持 crate 内可见;架构守卫禁止增加 Runtime、SDK Host、services、CLI/TUI、远程网络依赖及 Health 之外的 operation。 +架构守卫只允许 CLI 消费该 crate;IPC 可以复用稳定的 Event、Product Domain 与 Runtime Port DTO,但禁止依赖 Runtime 实现、SDK Host、services、Tauri 或远程网络 transport。 ## 8. 与竞品的取舍 @@ -222,7 +238,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 私有 IPC 不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。 -- 默认 GUI/TUI/Headless CLI 在 Shared 产品能力正式交付前保持现有 Embedded 行为。 +- 默认 GUI/TUI/Headless CLI 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared,当前互斥范围也只覆盖 CLI deployment。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 -- 未经真实 consumer 验证的接口不进入 wire;当前唯一 operation 是 Health。 +- 未经真实 consumer 验证的接口不进入 wire;当前 wire 只包含表中列出的 Shared TUI 操作。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 28ec3dc4c3..ffe3b65196 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -252,6 +252,17 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付 - 两者的能力对照、共同 fixture 和等价门槛以 [Agent SDK 产品与宿主架构第 9 节](agent-sdk-product-architecture.md#9-headless-cli-与-agent-sdk)为唯一事实源。 +交互式 TUI 另有一个显式部署选项:`bitfun --shared` 或 `bitfun chat --shared`。它通过 CLI 私有本机 IPC adapter 连接同一 Agent Runtime,不经过 SDK Host,也不改变 Headless CLI 或公开 SDK 的协议。当前范围如下: + +| 形态 | 默认部署 | 当前 Shared 范围 | +|---|---|---| +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、Turn submit/cancel、Permission 和 UserInput | +| `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 | +| ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 | + +Shared TUI 首版不提供 Session delete/fork、模式/模型、MCP/扩展、账号同步、用量、observer、replay 或 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 +Shared 模式的命令面板、快捷键帮助和底部提示使用同一能力投影:不支持的管理动作不显示为可执行入口。Session 切换失败保留原控制权,单个连接已有活动 Turn 时拒绝重复提交;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 + #### 管理与诊断 CLI-P1 应统一以下命令的文本和结构化只读视图: @@ -301,12 +312,17 @@ TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降 flowchart LR Exec["bitfun exec"] --> Choice{"Session"} Choice -->|"new / free"| Embedded["Embedded"] - Choice -->|"already owned"| Attach["Attach Host or reject"] + Choice -->|"already owned"| Reject["typed occupied error"] + + TUI["bitfun chat"] --> Deploy{"deployment"} + Deploy -->|"default"| EmbeddedTui["Embedded"] + Deploy -->|"--shared"| SharedTui["private local IPC"] + SharedTui --> Runtime["one Shared Runtime owner"] ``` Embedded 只意味着 Runtime 与 CLI 同进程,不意味着绕过持久化单写规则。新 Session 取得自己的写入权;恢复既有 Session 时, -CLI 必须先取得该 Session 的写入权。如果 Shared Agent Runtime 或另一个 `exec` 已持有,CLI 连接现有 Runtime 或返回明确的 -“Session 已占用”,不能并发写入同一 Session。 +CLI 必须先取得该 Session 的写入权。如果 Shared Agent Runtime 或另一个 `exec` 已持有,Headless CLI 返回明确的 +“Session 已占用”;它不会自动切换部署。只有用户显式选择 `--shared` 的交互式 TUI 才连接 Shared Runtime,且同一 Session 同时只有一个 controller。 CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Rust Runtime SDK 的类型化端口完成;TUI 只把 `SessionTranscript` 转换为本地渲染状态,不再消费 Core `Message`。Peer Host 的对话提交、精确取消、基础会话控制、thread-goal 查询、会话模型更新和 diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 2ee728f323..0cde3b3d49 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -10,7 +10,6 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-codex-adapter', 'bitfun-core', 'bitfun-core-types', - 'bitfun-events', 'bitfun-external-sources', 'bitfun-harness', 'bitfun-opencode-adapter', @@ -18,7 +17,6 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-plugin-runtime-client', 'bitfun-product-capabilities', 'bitfun-relay-service', - 'bitfun-runtime-ports', 'bitfun-runtime-services', 'bitfun-sdk-host', 'bitfun-services-core', @@ -26,7 +24,6 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-static-hook-support', 'bitfun-tool-call-jsonrepair', 'bitfun-tool-packs', - 'bitfun-product-domains', 'bitfun-transport', 'bitfun-webdriver', 'terminal-core', @@ -72,6 +69,15 @@ export const noCoreDependencyCrates = [ ]; export const forbiddenManifestDependencyRules = [ + { + 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'], @@ -130,7 +136,7 @@ export const lightweightBoundaryRules = [ { crateName: 'agent-runtime-ipc', reason: - 'agent-runtime-ipc is a non-published Health-only local transport seam, not a Runtime, SDK Host, service, or product surface', + '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, }, { @@ -389,9 +395,9 @@ export const lightweightBoundaryRules = [ export const dependencyProfileRules = [ { crateName: 'agent-runtime-ipc', - profileName: 'private Health-only local IPC profile', + profileName: 'private Shared TUI local IPC profile', reason: - 'agent-runtime-ipc must not acquire Runtime, SDK Host, service, remote transport, or product dependencies before its first reviewed consumer', + '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, }, { diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 50401f0055..ab8c854a6b 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,26 +1,14 @@ // Boundary rules for source ownership, facades, and required owner content. export const forbiddenContentRules = [ - { - path: 'src/crates/adapters/agent-runtime-ipc/src/lib.rs', - reason: - 'agent-runtime-ipc must remain crate-internal until its first reviewed production consumer', - patterns: [ - { - regex: /^\s*pub\s+(?!\(crate\))/, - message: - 'agent-runtime-ipc must not expose any externally public Rust item before consumer review', - }, - ], - }, { path: 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', - reason: 'agent-runtime-ipc operation scope is frozen at Health', + reason: 'agent-runtime-ipc operation scope is frozen to the first Shared TUI slice', patterns: [ { - regex: /^\s+(?!Health\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|Self|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, message: - 'agent-runtime-ipc may not add operations or results beyond Health in this foundation', + 'agent-runtime-ipc may not add replay, observer, controller-transfer, deletion, fork, or other operations beyond the reviewed Shared TUI slice', }, ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 209ee9297a..074f1c20b1 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4815,7 +4815,6 @@ export function runManifestParserSelfTest({ 'bitfun-sdk-host', 'bitfun-services-core', 'bitfun-services-integrations', - 'bitfun-product-domains', 'bitfun-transport', 'terminal-core', 'tool-runtime', @@ -4828,6 +4827,15 @@ export function runManifestParserSelfTest({ 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', ); @@ -4836,27 +4844,19 @@ export function runManifestParserSelfTest({ throw new Error(`agent-runtime-ipc dependency profile must forbid ${dependency}`); } } - const runtimeIpcLibRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src/lib.rs', - ); - const runtimeIpcPublicPattern = runtimeIpcLibRule?.patterns[0]?.regex; - if ( - !runtimeIpcPublicPattern || - !runtimeIpcPublicPattern.test('pub fn leaked_api() {}') || - runtimeIpcPublicPattern.test('pub(crate) fn internal_api() {}') - ) { - throw new Error('agent-runtime-ipc public surface guard must allow only crate visibility'); - } const runtimeIpcOperationRule = forbiddenContentRules.find( (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', ); const runtimeIpcOperationPattern = runtimeIpcOperationRule?.patterns[0]?.regex; if ( !runtimeIpcOperationPattern || - !runtimeIpcOperationPattern.test(' Execute,') || - runtimeIpcOperationPattern.test(' Health,') + !['ReplayEvents', 'ReadTranscript', 'DetachSession'].every((name) => + runtimeIpcOperationPattern.test(` ${name},`), + ) || + runtimeIpcOperationPattern.test(' Health,') || + runtimeIpcOperationPattern.test(' SubmitTurn {') ) { - throw new Error('agent-runtime-ipc operation guard must allow only Health by structure'); + 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', diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 558339ce57..c16aa6bee2 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -21,8 +21,10 @@ bitfun-core = { path = "../../crates/assembly/core", default-features = false, f bitfun-events = { path = "../../crates/contracts/events" } 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 = ["runtime-ownership"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["external-sources"] } diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 5852d2c8ae..13008fd9dd 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -21,6 +21,7 @@ pub(crate) struct ActionState { pub context: ActionContext, pub is_processing: bool, pub popup_open: bool, + shared_tui: bool, } impl ActionState { @@ -29,6 +30,7 @@ impl ActionState { context: ActionContext::Startup, is_processing: false, popup_open, + shared_tui: false, } } @@ -37,8 +39,19 @@ impl ActionState { context: ActionContext::Chat, is_processing, popup_open, + shared_tui: false, } } + + pub(crate) const fn with_shared_tui(mut self, shared_tui: bool) -> Self { + self.shared_tui = shared_tui; + self + } + + #[cfg(test)] + pub(crate) const fn for_shared_tui(self) -> Self { + self.with_shared_tui(true) + } } const STARTUP_ACTION_STATES: &[ActionState] = @@ -96,6 +109,47 @@ pub(crate) enum ActionHandler { ScrollDown, } +pub(crate) const SHARED_TUI_EMBEDDED_HANDOFF: &str = + "Exit all Shared TUI clients, wait up to 30 seconds for their Runtime to stop, then use default Embedded `bitfun chat`"; +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. Session/turn interaction is available; model, agent, MCP, extension, 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`."; + +impl ActionHandler { + pub(crate) const fn available_in_shared_tui_preview(self) -> bool { + matches!( + self, + Self::Help + | Self::ClearConversation + | Self::SelectTheme + | Self::NewSession + | Self::Sessions + | Self::AcpHelp + | Self::Init + | Self::History + | Self::ToggleAutoApprove + | Self::Exit + | Self::OpenPalette + | Self::SubmitInput + | Self::Interrupt + | Self::ClosePopups + | Self::NavigateBack + | Self::InsertNewline + | Self::Paste + | Self::ToggleFocusedTool + | Self::PreviousTool + | Self::NextTool + | Self::HistoryPrevious + | Self::HistoryNext + | Self::JumpTop + | Self::JumpBottom + | Self::ClearInput + | Self::ToggleBrowse + | Self::ScrollUp + | Self::ScrollDown + ) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ShortcutField { SendMessage, @@ -802,6 +856,9 @@ impl ActionSpec { if !self.supports_context(state.context) { return false; } + if state.shared_tui && !self.handler.available_in_shared_tui_preview() { + return false; + } match self.availability { ActionAvailability::Always => true, ActionAvailability::Idle => !state.is_processing, @@ -811,6 +868,12 @@ impl ActionSpec { } pub(crate) fn unavailable_message(&self, state: ActionState) -> String { + if state.shared_tui && !self.handler.available_in_shared_tui_preview() { + return format!( + "{} is unavailable in Shared TUI preview. {}", + self.name, SHARED_TUI_EMBEDDED_HANDOFF + ); + } match self.availability { ActionAvailability::Idle if state.is_processing => format!( "{} is unavailable while a turn is processing. Use the interrupt shortcut first.", @@ -1089,6 +1152,9 @@ impl ResolvedBinding { if !self.spec.supports_context(state.context) { return false; } + if state.shared_tui && !self.spec.handler.available_in_shared_tui_preview() { + return false; + } if state.popup_open && !above_modals && self.policy.availability != ActionAvailability::Popup @@ -1671,6 +1737,59 @@ mod tests { use super::*; + #[test] + fn shared_tui_preview_keeps_management_outside_the_first_slice() { + assert!(ActionHandler::Sessions.available_in_shared_tui_preview()); + assert!(ActionHandler::Interrupt.available_in_shared_tui_preview()); + for action in [ + ActionHandler::SelectModel, + ActionHandler::OpenAgentSelector, + ActionHandler::McpServers, + ActionHandler::Tools, + ActionHandler::Extensions, + ActionHandler::Hooks, + ActionHandler::Login, + ActionHandler::Usage, + ] { + assert!(!action.available_in_shared_tui_preview(), "{action:?}"); + } + assert!(SHARED_TUI_HELP_NOTE.contains("bitfun chat --shared")); + assert!(SHARED_TUI_HELP_NOTE.contains("one Session")); + assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); + } + + #[test] + fn shared_tui_projections_hide_embedded_management_actions() { + let state = ActionState::chat(false, false).for_shared_tui(); + let slash_ids = slash_actions(state) + .into_iter() + .map(|action| action.id) + .collect::>(); + let palette_ids = palette_actions(state) + .into_iter() + .map(|action| action.id) + .collect::>(); + + for unavailable in [ + "switch_agent", + "select_model", + "skills", + "mcp_servers", + "extensions", + "hooks", + "usage", + ] { + assert!(!slash_ids.contains(&unavailable), "{unavailable}"); + assert!(!palette_ids.contains(&unavailable), "{unavailable}"); + } + for available in ["new_session", "sessions", "theme", "help", "exit"] { + assert!(palette_ids.contains(&available), "{available}"); + } + + let help = ResolvedKeymap::new(&ShortcutsConfig::default()).help_text(state); + assert!(!help.contains("Switch Agent")); + } + fn resolve_id( keymap: &ResolvedKeymap, key: KeyEvent, diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 8d9fa0e1c9..97f9a32031 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -5,9 +5,10 @@ //! 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::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use tokio::sync::Mutex; +use tokio::sync::{broadcast, Mutex}; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentEventReceiver, AgentLocalCommandTurnRecordRequest, AgentRuntime, @@ -15,16 +16,34 @@ use bitfun_agent_runtime::sdk::{ AgentSessionForkResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, AgentSessionUsageRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, - PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortErrorKind, + PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport, AUTO_APPROVE_ASK_CONTEXT_KEY, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use bitfun_agent_runtime_ipc::{ + RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, + RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeIpcStreamInvalidationReason, RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, +}; +use bitfun_events::{AgenticEvent, AgenticEventEnvelope}; use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy}; +use crate::actions::SHARED_TUI_EMBEDDED_HANDOFF; use crate::runtime::approval::CliApprovalPolicy; use crate::runtime::CliRuntimeContext; +fn shared_restore_error(error: RuntimeIpcClientError) -> anyhow::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 { + error.into() + } +} + fn validated_session_summary( sessions: &[AgentSessionSummary], session_id: &str, @@ -97,40 +116,115 @@ fn session_mode_migration_notice( /// CLI-owned client for the portable Agent Runtime SDK. /// Stateless regarding agent_type; callers pass it per call. pub(crate) struct CliAgentRuntimeClient { - runtime: AgentRuntime, + backend: CliAgentRuntimeBackend, approval_policy: Arc>, workspace_path: 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), +} + +type SharedBroadcast = Arc>>>; + impl CliAgentRuntimeClient { pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { Self { - runtime: runtime.agent_runtime().clone(), + backend: CliAgentRuntimeBackend::Embedded(runtime.agent_runtime().clone()), approval_policy: Arc::new(RwLock::new(runtime.approval_policy())), workspace_path: Arc::new(RwLock::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_path: Arc::new(RwLock::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(_)) + } + + 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 { - self.runtime.subscribe_events() + 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 { - self.runtime.subscribe_permission_requests() + 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> { - self.runtime.pending_permission_requests() + 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( @@ -138,17 +232,32 @@ impl CliAgentRuntimeClient { request_id: &str, reply: PermissionReply, ) -> Result<()> { - self.runtime - .respond_permission(request_id, reply) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) + 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.runtime + self.embedded_runtime("recording local command turns")? .record_completed_local_command_turn(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())) @@ -189,14 +298,24 @@ impl CliAgentRuntimeClient { &self, workspace_path: &Path, ) -> Result> { - self.runtime - .list_sessions(AgentSessionListRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) + 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> { @@ -211,6 +330,7 @@ impl CliAgentRuntimeClient { AgentSessionSummary, PathBuf, Option, + SessionTranscript, )> { tracing::info!("Restoring session: {}", session_id); @@ -221,17 +341,55 @@ impl CliAgentRuntimeClient { let previous_summary = validated_session_summary(&sessions, session_id, &effective_workspace)?; - let restored = self - .runtime - .restore_session(AgentSessionRestoreRequest { - workspace_path: effective_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(|error| anyhow::anyhow!(error.into_message()))?; + let (restored, transcript, shared_pending) = match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => { + let restored = runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: effective_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(|error| anyhow::anyhow!(error.into_message()))?; + 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) + } + CliAgentRuntimeBackend::Shared(client) => match client + .request(RuntimeIpcOperation::RestoreSession { + request: RuntimeSessionRestoreRequest { + workspace_path: effective_workspace.to_string_lossy().to_string(), + session_id: session_id.to_string(), + }, + }) + .await + .map_err(shared_restore_error)? + { + RuntimeIpcOperationResult::SessionRestored { + session, + transcript, + pending_permissions, + } => (session, transcript, Some(pending_permissions)), + _ => return Err(unexpected_shared_result("restore_session")), + }, + }; let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; @@ -242,13 +400,28 @@ impl CliAgentRuntimeClient { *workspace_guard = Some(effective_workspace.clone()); *session_id_guard = Some(session_id.to_string()); *turn_id_guard = None; + drop(workspace_guard); + 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_notice = session_mode_migration_notice(&previous_summary, &restored.session); - Ok((restored.session, effective_workspace, migration_notice)) + let migration_notice = session_mode_migration_notice(&previous_summary, &restored); + Ok((restored, effective_workspace, migration_notice, transcript)) } pub(crate) async fn delete_session(&self, session_id: &str) -> Result<()> { - self.runtime + self.embedded_runtime("deleting sessions")? .delete_session(AgentSessionDeleteRequest { workspace_path: self.workspace_path_string(), session_id: session_id.to_string(), @@ -259,22 +432,12 @@ impl CliAgentRuntimeClient { .map_err(|error| anyhow::anyhow!(error.into_message())) } - pub(crate) async fn get_transcript(&self, session_id: &str) -> Result { - self.runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: session_id.to_string(), - turn_id: 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 + self.embedded_runtime("changing the session model")? .update_session_model(AgentSessionModelUpdateRequest { session_id: session_id.to_string(), model_id: model_id.to_string(), @@ -284,7 +447,7 @@ impl CliAgentRuntimeClient { } pub(crate) async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> Result<()> { - self.runtime + self.embedded_runtime("changing the session mode")? .update_session_mode(AgentSessionModeUpdateRequest { session_id: session_id.to_string(), mode_id: mode_id.to_string(), @@ -297,7 +460,7 @@ impl CliAgentRuntimeClient { &self, source_session_id: &str, ) -> Result { - self.runtime + self.embedded_runtime("forking sessions")? .fork_session(AgentSessionForkRequest { workspace_path: self.workspace_path_string(), source_session_id: source_session_id.to_string(), @@ -312,7 +475,7 @@ impl CliAgentRuntimeClient { &self, request: AgentSessionUsageRequest, ) -> Result { - self.runtime + self.embedded_runtime("generating session usage")? .generate_session_usage(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())) @@ -324,13 +487,21 @@ impl CliAgentRuntimeClient { 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 + 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 { @@ -348,12 +519,12 @@ impl CliAgentRuntimeClient { } 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(); - if let Ok(sessions) = self - .runtime + if let Ok(sessions) = runtime .list_sessions(AgentSessionListRequest { workspace_path: workspace.to_string_lossy().to_string(), remote_connection_id: None, @@ -367,7 +538,7 @@ impl CliAgentRuntimeClient { } } - self.runtime + runtime .create_session_with_id( session_id.to_string(), AgentSessionCreateRequest { @@ -391,9 +562,9 @@ impl CliAgentRuntimeClient { } async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { + let runtime = self.embedded_runtime("recovering Embedded sessions")?; let workspace = self.workspace_path_buf(); - match self - .runtime + match runtime .restore_session(AgentSessionRestoreRequest { workspace_path: workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), @@ -428,10 +599,10 @@ impl CliAgentRuntimeClient { 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 session = self - .runtime + let session = runtime .create_session_with_id( session_id, AgentSessionCreateRequest { @@ -466,26 +637,37 @@ impl CliAgentRuntimeClient { 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 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: 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(); *session_id_guard = Some(id.clone()); + drop(session_id_guard); + self.refresh_shared_pending_permissions().await?; tracing::info!("Created core session: {}", id); Ok(id) @@ -521,29 +703,51 @@ impl CliAgentRuntimeClient { attachments: Vec::new(), metadata, }; - let start_result = self.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?; - self.runtime - .submit_dialog_turn(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - } else { - return Err(anyhow::anyhow!(error_message)); + 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")), + }, } } - - Ok(turn_id) + .await; + if submission.is_err() { + *self.current_turn_id.lock().await = None; + } + submission } pub(crate) async fn cancel_current_turn(&self) -> Result<()> { @@ -552,17 +756,29 @@ impl CliAgentRuntimeClient { if let (Some(session_id), Some(turn_id)) = (session_id, turn_id) { tracing::info!("Cancelling turn: session={}, turn={}", 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, - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + 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, + }; + 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()) { @@ -574,28 +790,40 @@ impl CliAgentRuntimeClient { } pub(crate) async fn create_new_session(&self, agent_type: &str) -> Result { - let mut session_id_guard = self.session_id.lock().await; - - 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 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: 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(); - *session_id_guard = Some(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) @@ -613,16 +841,197 @@ impl CliAgentRuntimeClient { answers: serde_json::Value, ) -> Result<()> { tracing::info!("Submitting user answers for tool: {}", tool_id); - self.runtime - .submit_user_answers(AgentUserAnswersRequest { - tool_id: tool_id.to_string(), - answers, - }) + 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 - .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e.into_message())) + .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(); + } +} + +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}; @@ -651,12 +1060,39 @@ mod tests { use bitfun_runtime_ports::AgentSessionSummary; - use bitfun_agent_runtime::sdk::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::sdk::{ + PermissionDelegationContext, PermissionRequest, PermissionRequestEvent, + PermissionRequestSource, PermissionRequestSourceKind, AUTO_APPROVE_ASK_CONTEXT_KEY, + }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; use crate::runtime::approval::CliApprovalPolicy; - use super::{cli_approval_metadata, session_mode_migration_notice, validated_session_summary}; + use super::{ + cli_approval_metadata, project_routed_permission_event, session_mode_migration_notice, + shared_disconnect_message, shared_restore_error, validated_session_summary, + }; + 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 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 cli_approval_metadata_keeps_auto_invocation_scoped() { @@ -679,7 +1115,11 @@ mod tests { #[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 runtime_update = ["self.runtime", "\n .update_session_model"].concat(); + let runtime_update = [ + "self.embedded_runtime(\"changing the session model\")?", + "\n .update_session_model", + ] + .concat(); let compatibility_update = ["self.compatibility", "\n .update_session_model"].concat(); @@ -690,7 +1130,11 @@ mod tests { #[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 runtime_update = ["self.runtime", "\n .update_session_mode"].concat(); + let runtime_update = [ + "self.embedded_runtime(\"changing the session mode\")?", + "\n .update_session_mode", + ] + .concat(); let compatibility_update = [ "self.compatibility", "\n .update_session_agent_type", @@ -704,7 +1148,7 @@ mod tests { #[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 = ["self.runtime", ".subscribe_events()"].concat(); + 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(); @@ -778,4 +1222,38 @@ mod tests { assert!(session_mode_migration_notice(&summary, &summary).is_none()); } + + #[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/chat_state.rs b/src/apps/cli/src/chat_state.rs index 86b2f81329..a5c3f2eebb 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; /// Chat state module /// /// Pure UI rendering state for the chat interface. @@ -447,6 +447,35 @@ impl ChatState { true } + pub(crate) fn reconcile_permission_requests( + &mut self, + requests: Vec, + ) -> bool { + let expected = requests + .iter() + .map(|request| request.request_id.clone()) + .collect::>(); + let stale = self + .permission_prompt + .iter() + .map(|prompt| prompt.request.request_id.clone()) + .chain( + self.permission_queue + .iter() + .map(|request| request.request_id.clone()), + ) + .filter(|request_id| !expected.contains(request_id)) + .collect::>(); + let mut changed = false; + for request_id in stale { + changed |= self.resolve_permission_request(&request_id); + } + for request in requests { + changed |= self.enqueue_permission_request(request); + } + changed + } + /// Load historical messages from the portable runtime transcript. /// /// Tool results (ToolResult messages) are merged back into the corresponding @@ -1298,6 +1327,16 @@ mod tests { assert!(!state.resolve_permission_request("unrelated")); assert!(state.resolve_permission_request("request-m")); assert!(state.permission_prompt.is_none()); + + assert!(state.enqueue_permission_request(first)); + assert!(state.reconcile_permission_requests(vec![third])); + assert_eq!( + state + .permission_prompt + .as_ref() + .map(|prompt| prompt.request.request_id.as_str()), + Some("request-m") + ); } #[test] diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index e2afdd060d..68fde2f3bf 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -30,6 +30,7 @@ mod prompts; mod root_handlers; mod runtime; mod self_update; +mod shared_runtime; mod ui; use anyhow::{anyhow, Result}; @@ -92,6 +93,21 @@ struct Cli { /// Enable verbose logging #[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. + /// Automation, desktop, and remote modes remain unchanged. + #[arg(long, verbatim_doc_comment)] + shared: bool, +} + +fn shared_tui_requested(shared: bool, command: &Option) -> Result { + if shared && !matches!(command, None | Some(Commands::Chat { .. })) { + return Err(anyhow!( + "--shared is available only for the interactive TUI (`bitfun --shared` or `bitfun chat --shared`); other commands and applications are unchanged" + )); + } + Ok(shared || matches!(command, Some(Commands::Chat { shared: true, .. }))) } #[derive(Subcommand)] @@ -101,6 +117,18 @@ enum Commands { /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, + + /// Use the opt-in Shared Runtime for this interactive TUI + #[arg(long)] + shared: bool, + }, + + #[command(name = "__shared-runtime", hide = true)] + SharedRuntime { + #[arg(long)] + workspace: std::path::PathBuf, + #[arg(long)] + instance_identity: String, }, /// Execute single command @@ -417,8 +445,15 @@ pub(crate) enum BootstrapProfile { } impl BootstrapProfile { - const fn starts_peer_host(self) -> bool { + const fn starts_peer_host( + self, + deployment: bitfun_services_core::runtime_ownership::RuntimeDeployment, + ) -> bool { matches!(self, Self::Interactive) + && matches!( + deployment, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded + ) } const fn starts_mcp(self) -> bool { @@ -592,6 +627,21 @@ async fn initialize_core_services( workspace_root: &std::path::Path, approval_policy: runtime::approval::CliApprovalPolicy, bootstrap_profile: BootstrapProfile, +) -> Result> { + initialize_core_services_for_deployment( + workspace_root, + approval_policy, + bootstrap_profile, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, + ) + .await +} + +async fn initialize_core_services_for_deployment( + workspace_root: &std::path::Path, + approval_policy: runtime::approval::CliApprovalPolicy, + bootstrap_profile: BootstrapProfile, + deployment: bitfun_services_core::runtime_ownership::RuntimeDeployment, ) -> Result> { use bitfun_core::infrastructure::ai::AIClientFactory; @@ -602,6 +652,7 @@ async fn initialize_core_services( .await .map_err(|error| anyhow!("Failed to initialize global config service: {error}"))?; tracing::info!("Global config service initialized"); + let runtime_ownership = shared_runtime::acquire_ownership(workspace_root, deployment)?; let config_service = bitfun_core::service::config::get_global_config_service() .await @@ -625,6 +676,7 @@ async fn initialize_core_services( agentic_system, workspace_root, approval_policy, + runtime_ownership, )?); debug_assert!(runtime .product() @@ -643,7 +695,7 @@ async fn initialize_core_services( runtime.product().plugin_runtime(), ); - if bootstrap_profile.starts_peer_host() { + if bootstrap_profile.starts_peer_host(deployment) { if let Err(e) = peer_host::ensure_peer_host_ready(runtime.as_ref()).await { tracing::warn!("Failed to initialize CLI peer host services: {e}"); } else { @@ -707,6 +759,7 @@ async fn run_interactive( config: CliConfig, default_agent: String, _workspace_str: String, + shared: bool, ) -> Result<()> { use ui::startup::{StartupPage, StartupResult}; @@ -722,52 +775,71 @@ async fn run_interactive( .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from(".")); - // 3. Initialize core services - let runtime = initialize_core_services( - &workspace_path, - runtime::approval::CliApprovalPolicy::Ask, - BootstrapProfile::Interactive, - ) - .await?; - let agent = Arc::new(CliAgentRuntimeClient::new( - runtime.as_ref(), - Some(workspace_path.clone()), - )); + let runtime = if shared { + None + } else { + Some( + initialize_core_services( + &workspace_path, + runtime::approval::CliApprovalPolicy::Ask, + BootstrapProfile::Interactive, + ) + .await?, + ) + }; + let agent = if let Some(runtime) = &runtime { + Arc::new(CliAgentRuntimeClient::new( + runtime.as_ref(), + Some(workspace_path.clone()), + )) + } else { + let client = shared_runtime::connect_or_start(&workspace_path).await?; + Arc::new(CliAgentRuntimeClient::new_shared( + client, + Some(workspace_path.clone()), + )) + }; + let compatibility = runtime + .as_ref() + .map(|runtime| runtime.compatibility().clone()); // 3.5 Restore persisted account session (if any) - if let Some(user_id) = account::try_restore_session().await { - tracing::info!("Restored account session for user {user_id}"); - // Re-establish device routing so the CLI becomes RPC-controllable. - // The daemon owns device routing when it is running: same-machine - // processes share one device_id and last AuthConnect wins. - if daemon::is_daemon_running() { - tracing::info!( - "CLI daemon is running; skipping in-process device routing (daemon owns it)" - ); - } else { - let device = DeviceIdentity::from_current_machine() - .map_err(|e| anyhow!("detect device: {e}"))?; - if let Err(e) = account::restore_device_routing(&device.device_name).await { - tracing::warn!("Failed to restore device routing: {e}"); + if !shared { + if let Some(user_id) = account::try_restore_session().await { + tracing::info!("Restored account session for user {user_id}"); + if daemon::is_daemon_running() { + tracing::info!( + "CLI daemon is running; skipping in-process device routing (daemon owns it)" + ); + } else { + let device = DeviceIdentity::from_current_machine() + .map_err(|e| anyhow!("detect device: {e}"))?; + if let Err(e) = account::restore_device_routing(&device.device_name).await { + tracing::warn!("Failed to restore device routing: {e}"); + } } } } // 3.6 Continuous account settings sync (30s pull + debounced push). // Safe to start before login: cycles skip while logged out. - account_sync::start_settings_sync_loop(); + if !shared { + account_sync::start_settings_sync_loop(); + } // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( config, Arc::clone(&agent), - runtime.compatibility().clone(), + compatibility.clone(), default_agent, workspace.clone(), ); let startup_result = startup_page.run(&mut terminal)?; if let StartupResult::Exit = startup_result { - shutdown_mcp_servers().await; + if !shared { + shutdown_mcp_servers().await; + } ui::restore_terminal(terminal)?; println!("Goodbye!"); return Ok(()); @@ -784,13 +856,7 @@ async fn run_interactive( // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); - let mut chat_mode = ChatMode::new( - config, - agent_type, - workspace, - agent, - runtime.compatibility().clone(), - ); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent, compatibility); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } @@ -800,7 +866,9 @@ async fn run_interactive( let chat_result = chat_mode.run(Some(terminal)); // 6. Cleanup, including fatal event-stream exits. - shutdown_mcp_servers().await; + if !shared { + shutdown_mcp_servers().await; + } let _exit_reason = chat_result?; println!("Goodbye!"); @@ -856,6 +924,15 @@ 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) { + Ok(shared) => shared, + Err(error) if exec_requests_json_output(&raw_args) => { + modes::exec::emit_preflight_json_error(ExecOutputFormat::Json, &error)?; + return Err(anyhow::Error::new(ReportedCliError { exit_code: 2 })); + } + Err(error) => return Err(error), + }; let is_exec_mode = matches!(cli.command, Some(Commands::Exec { .. })); let is_daemon_run = matches!( cli.command, @@ -870,7 +947,11 @@ async fn run_cli() -> Result<()> { tracing::Level::ERROR }; - if is_tui_mode || is_exec_mode || is_daemon_run { + if is_shared_service { + let service_log_dir = + logging::resolve_logs_root().join(format!("shared-runtime-{}", 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 { logging::init_file_logging(file_log_level); } else { tracing_subscriber::fmt() @@ -893,11 +974,16 @@ async fn run_cli() -> Result<()> { } match cli.command { - Some(Commands::Chat { agent }) => { + Some(Commands::Chat { agent, .. }) => { // Interactive mode with startup page, scoped to the current directory. - run_interactive(config, agent, ".".to_string()).await?; + run_interactive(config, agent, ".".to_string(), use_shared_runtime).await?; } + Some(Commands::SharedRuntime { + workspace, + instance_identity, + }) => shared_runtime::run_service(workspace, instance_identity).await?, + Some(Commands::Exec { message, agent, @@ -1122,7 +1208,7 @@ async fn run_cli() -> Result<()> { let workspace_str = ".".to_string(); let default_agent = config.behavior.default_agent.clone(); - run_interactive(config, default_agent, workspace_str).await?; + run_interactive(config, default_agent, workspace_str, use_shared_runtime).await?; } } @@ -1177,7 +1263,7 @@ async fn run_interactive_with_session( agent_type, workspace, agent, - runtime.compatibility().clone(), + Some(runtime.compatibility().clone()), ) .with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); @@ -1399,7 +1485,12 @@ mod bootstrap_profile_tests { ]; for (profile, starts_peer_host, starts_mcp) in cases { - assert_eq!(profile.starts_peer_host(), starts_peer_host); + assert_eq!( + profile.starts_peer_host( + bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, + ), + starts_peer_host + ); assert_eq!(profile.starts_mcp(), starts_mcp); } } @@ -1500,3 +1591,58 @@ mod sdk_host_command_tests { assert!(!include_str!("../Cargo.toml").contains("bitfun-sdk-host")); } } + +#[cfg(test)] +mod shared_tui_command_tests { + use super::{shared_tui_requested, BootstrapProfile, Cli, Commands}; + use bitfun_services_core::runtime_ownership::RuntimeDeployment; + use clap::{CommandFactory, Parser, Subcommand}; + + #[test] + fn shared_is_an_opt_in_interactive_tui_flag() { + let default_tui = Cli::try_parse_from(["bitfun", "--shared"]).expect("parse default TUI"); + assert!(default_tui.shared); + + let chat = + Cli::try_parse_from(["bitfun", "chat", "--shared"]).expect("parse explicit chat TUI"); + assert!(matches!( + chat.command, + Some(Commands::Chat { shared: true, .. }) + )); + let root_chat = Cli::try_parse_from(["bitfun", "--shared", "chat"]) + .expect("parse root Shared choice before chat"); + assert!(shared_tui_requested(root_chat.shared, &root_chat.command).unwrap()); + } + + #[test] + fn shared_rejects_non_interactive_surfaces_without_fallback() { + assert!(Cli::try_parse_from(["bitfun", "exec", "hello", "--shared"]).is_err()); + let exec = Cli::try_parse_from(["bitfun", "--shared", "exec", "hello"]) + .expect("parse root deployment choice"); + let error = shared_tui_requested(exec.shared, &exec.command) + .expect_err("headless exec must remain Embedded"); + assert!(error.to_string().contains("interactive TUI")); + } + + #[test] + fn shared_runtime_does_not_start_the_peer_host() { + assert!(BootstrapProfile::Interactive.starts_peer_host(RuntimeDeployment::Embedded)); + assert!(!BootstrapProfile::Interactive.starts_peer_host(RuntimeDeployment::Shared)); + } + + #[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("Automation, desktop, and remote modes")); + assert!(!help.contains("__shared-runtime")); + let exec_help = Commands::augment_subcommands(Cli::command()) + .find_subcommand("exec") + .expect("exec command") + .clone() + .render_long_help() + .to_string(); + assert!(!exec_help.contains("--shared")); + } +} diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index fac6f25cf3..7b4c94cd93 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -26,7 +26,7 @@ use resize::ResizeRedrawState; use crate::actions::{ action_by_id, action_conflict_behavior_version, action_for_alias, removed_management_command_hint, slash_actions, ActionContext, ActionHandler, ActionSpec, - ActionState, ResolvedKeymap, + ActionState, ResolvedKeymap, SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; use crate::agent::runtime_client::CliAgentRuntimeClient; use crate::chat_state::ChatState; @@ -190,7 +190,7 @@ pub(crate) struct ChatMode { agent_type: String, workspace: Option, agent: Arc, - compatibility: CoreAgentRuntimeCompatibility, + compatibility: Option, /// User-level default resolved from shared config for this TUI run. auto_approve_ask_default: bool, /// Temporary override for the current session only. @@ -241,7 +241,7 @@ impl ChatMode { agent_type: String, workspace: Option, agent: Arc, - compatibility: CoreAgentRuntimeCompatibility, + compatibility: Option, ) -> Self { let keymap = ResolvedKeymap::new(&config.shortcuts); Self { @@ -284,6 +284,10 @@ impl ChatMode { self.initial_prompt = Some(prompt); self } + + fn action_state(&self, is_processing: bool, popup_open: bool) -> ActionState { + ActionState::chat(is_processing, popup_open).with_shared_tui(self.agent.is_shared()) + } } include!("chat/account.rs"); diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 108a45d069..72df1c65f1 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -81,12 +81,15 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { + let Some(compatibility) = self.compatibility.clone() else { + self.open_account_panel(chat_view, rt_handle); + chat_state.add_system_message(format!( + "Account settings sync is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}" + )); + return; + }; let workspace = self.workspace_path_for_sync(chat_state); - crate::account_sync::start_auto_sync_background( - self.compatibility.clone(), - is_first_login, - workspace, - ); + crate::account_sync::start_auto_sync_background(compatibility, is_first_login, workspace); self.open_account_panel(chat_view, rt_handle); chat_state.add_system_message(if is_first_login { "Sync started (use local / upload settings).".to_string() diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 1139967bfb..620b26d023 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -55,7 +55,7 @@ impl ChatMode { if action_id == "toggle_auto_approve" || action_id.starts_with("toggle_auto_approve:") { let action = action_by_id("toggle_auto_approve", ActionContext::Chat) .expect("Auto mode action must remain registered"); - let state = ActionState::chat(chat_state.is_processing, false); + let state = self.action_state(chat_state.is_processing, false); if !action.available(state) { chat_view.set_status(Some(action.unavailable_message(state))); return Ok(None); @@ -147,7 +147,7 @@ impl ChatMode { } self.dispatch_action( action, - ActionState::chat(chat_state.is_processing, false), + self.action_state(chat_state.is_processing, false), chat_view, chat_state, rt_handle, @@ -198,6 +198,21 @@ impl ChatMode { } let builtin_alias = format!("/{command_name}"); let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); + if self.agent.is_shared() { + if let Some(action) = builtin_action { + return self.dispatch_action( + action, + self.action_state(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ); + } + chat_state.add_system_message(format!( + "External prompt command /{command_name} is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}." + )); + return Ok(None); + } let mut external = self.external_command_projection(command_name); let authoritative_preferences = tokio::task::block_in_place(|| { rt_handle @@ -291,7 +306,7 @@ impl ChatMode { let action = builtin_action.expect("route requires an available built-in action"); self.dispatch_action( action, - ActionState::chat(chat_state.is_processing, false), + self.action_state(chat_state.is_processing, false), chat_view, chat_state, rt_handle, @@ -606,14 +621,21 @@ impl ChatMode { chat_view.set_status(Some(action.unavailable_message(state))); return Ok(None); } - match action.handler { ActionHandler::Help => { - chat_view.show_info_popup(self.keymap.help_text(state)); + let mut help = self.keymap.help_text(state); + if self.agent.is_shared() { + help.push_str("\n\n"); + help.push_str(SHARED_TUI_HELP_NOTE); + } + chat_view.show_info_popup(help); } ActionHandler::ClearConversation => { if chat_state.is_processing { self.cancel_active_turn(chat_view, rt_handle); + if self.agent.is_shared() { + return Ok(None); + } } chat_state.clear_messages(); chat_view.clear_screen(); @@ -695,6 +717,9 @@ impl ChatMode { ActionHandler::Exit => { if chat_state.is_processing { self.cancel_active_turn(chat_view, rt_handle); + if self.agent.is_shared() { + return Ok(None); + } } return Ok(Some(ChatExitReason::Quit)); } @@ -707,7 +732,9 @@ impl ChatMode { ActionHandler::SubmitInput => { return self.submit_input(chat_view, chat_state, rt_handle); } - ActionHandler::Interrupt => self.cancel_active_turn(chat_view, rt_handle), + ActionHandler::Interrupt => { + self.cancel_active_turn(chat_view, rt_handle); + } ActionHandler::ClosePopups => self.close_all_popups(chat_view), ActionHandler::NavigateBack => self.navigate_back(chat_view), ActionHandler::InsertNewline => chat_view.handle_newline(), @@ -815,17 +842,29 @@ impl ChatMode { Ok(None) } - fn cancel_active_turn(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { + fn cancel_active_turn( + &self, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) -> bool { tracing::info!("User requested cancellation"); let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(error) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", error); - } - }) + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async move { agent.cancel_current_turn().await }) }); - chat_view.set_status(Some("Cancelling...".to_string())); + match result { + Ok(()) => { + chat_view.set_status(Some( + "Cancelling... Wait for the turn to stop before retrying.".to_string(), + )); + true + } + Err(error) => { + tracing::error!("Failed to cancel turn: {}", error); + chat_view.set_status(Some(format!("Cancellation failed: {error}"))); + false + } + } } fn paste_clipboard(&self, chat_view: &mut ChatView) { diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index f2f3a112b9..9153eedbdb 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -11,7 +11,7 @@ impl ChatMode { } let modal_state = - ActionState::chat(chat_state.is_processing, self.any_popup_visible(chat_view)); + self.action_state(chat_state.is_processing, self.any_popup_visible(chat_view)); if let Some(action) = self.keymap.resolve_modal_safe(key, modal_state) { return self.dispatch_action(action, modal_state, chat_view, chat_state, rt_handle); } @@ -47,16 +47,19 @@ impl ChatMode { QuestionAction::Submit(answers) => { let tool_id = prompt.tool_id.clone(); let agent = self.agent.clone(); - chat_state.question_prompt = None; tracing::info!("User submitted answers for tool: {}", tool_id); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.submit_user_answers(&tool_id, answers).await { - tracing::error!("Failed to submit answers: {}", e); - } - }) - }); - chat_view.set_status(Some("Answers submitted".to_string())); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.submit_user_answers(&tool_id, answers)) + }) { + Ok(()) => { + chat_state.question_prompt = None; + chat_view.set_status(Some("Answers submitted".to_string())); + } + Err(error) => { + tracing::error!("Failed to submit answers: {error}"); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } } QuestionAction::Reject => { let tool_id = prompt.tool_id.clone(); @@ -75,7 +78,7 @@ impl ChatMode { // Host recovery keys win over configured actions while a popup is open. if self.any_popup_visible(chat_view) { - let state = ActionState::chat(chat_state.is_processing, true); + let state = self.action_state(chat_state.is_processing, true); if let Some(action) = self.keymap.resolve_reserved(key, state) { return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); } @@ -331,11 +334,11 @@ impl ChatMode { if let Some(action) = self .keymap - .resolve(key, ActionState::chat(chat_state.is_processing, false)) + .resolve(key, self.action_state(chat_state.is_processing, false)) { return self.dispatch_action( action, - ActionState::chat(chat_state.is_processing, false), + self.action_state(chat_state.is_processing, false), chat_view, chat_state, rt_handle, diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 1ed667ecdc..148ccbda0d 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -24,7 +24,7 @@ impl ChatMode { (false, EffectiveColorScheme::Truecolor) => Theme::dark(), }; let theme = self.resolve_configured_theme(base, appearance, scheme); - let shortcut_hints = self.keymap.compact_hints(ActionState::chat(false, false)); + let shortcut_hints = self.keymap.compact_hints(self.action_state(false, false)); let mut chat_view = ChatView::new(theme, shortcut_hints); // Create or restore core session @@ -53,19 +53,11 @@ impl ChatMode { tokio::task::block_in_place(|| { rt_handle.block_on(async { // Restore session in core (loads metadata, messages, managers) - let (summary, effective_workspace_path, migration_notice) = + let (summary, effective_workspace_path, migration_notice, transcript) = agent.restore_session_in_current_workspace(&rid).await?; let effective_workspace = Some(effective_workspace_path.to_string_lossy().to_string()); - // Load historical messages for UI display - let transcript = agent.get_transcript(&rid).await.unwrap_or_else(|_| { - bitfun_agent_runtime::sdk::SessionTranscript { - session_id: rid.clone(), - messages: Vec::new(), - } - }); - let state = ChatState::from_session_transcript( rid.clone(), summary.session_name, @@ -107,53 +99,64 @@ impl ChatMode { self.agent_type = chat_state.agent_type.clone(); self.workspace = chat_state.workspace.clone(); - let external_workspace = self.agent.workspace_path_buf(); - let (initial_external_sources, mut external_source_rx, conflict_preferences) = - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let updates = - subscribe_external_source_updates(Some(&external_workspace)).await; - let snapshot = external_source_snapshot(Some(&external_workspace), false).await; - let preferences = external_source_conflict_choices().await.map(Into::into); - (snapshot, updates.ok(), preferences) - }) - }); - match conflict_preferences { - Ok(preferences) => self.replace_external_conflict_preferences(preferences), - Err(error) => tracing::warn!("External source preferences are unavailable: {}", error), - } - match initial_external_sources { - Ok(snapshot) => { - let (available, restricted) = external_command_counts(&snapshot); - let pending_conflicts = snapshot - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .count(); - let tool_notice = self.take_external_tool_notice(&snapshot); - let agent_notice = self.take_external_agent_notice(&snapshot); - self.update_external_source_view(&mut chat_view, &snapshot); - self.external_source_snapshot = Some(snapshot.clone()); - if snapshot.discovery_pending { - chat_view.set_status(Some( - "Checking compatible content from external AI applications".to_string(), - )); - } else if tool_notice.is_some() || agent_notice.is_some() { - chat_view.set_status(Some( - [tool_notice, agent_notice] - .into_iter() - .flatten() - .collect::>() - .join("; "), - )); - } else if available + restricted > 0 || pending_conflicts > 0 { - chat_view.set_status(Some(format!( - "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" - ))); + let mut external_source_rx = None; + if self.agent.is_shared() { + chat_view.set_status(Some(format!( + "Shared TUI preview: this view controls sessions and turns; local extension, MCP, account-sync, model, and mode management remain Embedded. {SHARED_TUI_EMBEDDED_HANDOFF}" + ))); + } else { + let external_workspace = self.agent.workspace_path_buf(); + let (initial_external_sources, updates, conflict_preferences) = + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let updates = + subscribe_external_source_updates(Some(&external_workspace)).await; + let snapshot = + external_source_snapshot(Some(&external_workspace), false).await; + let preferences = external_source_conflict_choices().await.map(Into::into); + (snapshot, updates.ok(), preferences) + }) + }); + external_source_rx = updates; + match conflict_preferences { + Ok(preferences) => self.replace_external_conflict_preferences(preferences), + Err(error) => { + tracing::warn!("External source preferences are unavailable: {}", error) } } - Err(error) => { - tracing::warn!("External source discovery is unavailable: {}", error); + match initial_external_sources { + Ok(snapshot) => { + let (available, restricted) = external_command_counts(&snapshot); + let pending_conflicts = snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let tool_notice = self.take_external_tool_notice(&snapshot); + let agent_notice = self.take_external_agent_notice(&snapshot); + self.update_external_source_view(&mut chat_view, &snapshot); + self.external_source_snapshot = Some(snapshot.clone()); + if snapshot.discovery_pending { + chat_view.set_status(Some( + "Checking compatible content from external AI applications".to_string(), + )); + } else if tool_notice.is_some() || agent_notice.is_some() { + chat_view.set_status(Some( + [tool_notice, agent_notice] + .into_iter() + .flatten() + .collect::>() + .join("; "), + )); + } else if available + restricted > 0 || pending_conflicts > 0 { + chat_view.set_status(Some(format!( + "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" + ))); + } + } + Err(error) => { + tracing::warn!("External source discovery is unavailable: {}", error); + } } } @@ -236,7 +239,7 @@ impl ChatMode { while !should_quit { chat_view.set_action_state( - ActionState::chat(chat_state.is_processing, false), + self.action_state(chat_state.is_processing, false), &self.keymap, ); @@ -306,9 +309,55 @@ impl ChatMode { } } Err(TryRecvError::Empty) => break, - Err(TryRecvError::Lagged(_)) => continue, + Err(TryRecvError::Lagged(_)) => { + match self.agent.pending_permission_requests() { + Ok(requests) => { + let requests = requests + .into_iter() + .filter(|request| { + crate::runtime::approval::permission_request_targets_session( + request, + &session_id, + ) + }) + .collect(); + if chat_state.reconcile_permission_requests(requests) { + needs_redraw = true; + } + } + Err(error) => { + let mut failure = format!( + "Shared Runtime permission state could not be resynchronized: {}", + error.into_message() + ); + let agent = self.agent.clone(); + if let Err(error) = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.cancel_current_turn()) + }) { + failure = format!( + "{failure}; failed to cancel the active turn: {error}" + ); + } + mark_active_turn_failed(&mut chat_state, &failure); + chat_view.set_status(Some(format!("Error: {failure}"))); + fatal_event_stream_error = Some(failure); + } + } + break; + } Err(TryRecvError::Closed) => { - permission_rx = None; + let mut failure = + "Shared Runtime 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()) + }) { + failure = + format!("{failure}; failed to cancel the active turn: {error}"); + } + mark_active_turn_failed(&mut chat_state, &failure); + chat_view.set_status(Some(format!("Error: {failure}"))); + fatal_event_stream_error = Some(failure); break; } Ok(_) => {} diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index d394cc171c..e0c920e861 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -14,20 +14,12 @@ impl ChatMode { let (new_state, restored_agent_type, migration_notice) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let (session_summary, effective_workspace_path, migration_notice) = + let (session_summary, effective_workspace_path, migration_notice, transcript) = agent.restore_session_in_current_workspace(&sid).await?; let restored_agent_type = session_summary.agent_type.clone(); let effective_workspace = Some(effective_workspace_path.to_string_lossy().to_string()); - // Load historical messages through the runtime transcript contract. - let transcript = agent.get_transcript(&sid).await.unwrap_or_else(|_| { - bitfun_agent_runtime::sdk::SessionTranscript { - session_id: sid.clone(), - messages: Vec::new(), - } - }); - let state = ChatState::from_session_transcript( sid.clone(), session_summary.session_name, @@ -158,9 +150,15 @@ impl ChatMode { let agent = self.agent.clone(); let current_session_id = chat_state.core_session_id.clone(); - let sessions = tokio::task::block_in_place(|| { - rt_handle.block_on(async { agent.list_sessions().await.unwrap_or_default() }) - }); + let sessions = tokio::task::block_in_place(|| rt_handle.block_on(agent.list_sessions())); + let sessions = match sessions { + Ok(sessions) => sessions, + Err(error) => { + tracing::error!("Failed to list sessions: {error}"); + chat_view.set_status(Some(format!("Failed to load sessions: {error}"))); + return; + } + }; if sessions.is_empty() { chat_state.add_system_message("No sessions found.".to_string()); @@ -193,7 +191,11 @@ impl ChatMode { }) .collect(); - chat_view.show_session_selector(session_items, Some(current_session_id)); + chat_view.show_session_selector( + session_items, + Some(current_session_id), + !self.agent.is_shared(), + ); } /// Handle session deletion from the session selector @@ -204,6 +206,12 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { + if self.agent.is_shared() { + chat_view.set_status(Some(format!( + "Session deletion is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}; then run `bitfun sessions delete`" + ))); + return; + } // Prevent deleting the currently active session if item.session_id == chat_state.core_session_id { chat_view.set_status(Some("Cannot delete the active session".to_string())); diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 2ceec6f876..45693f9b60 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -12,6 +12,7 @@ use bitfun_core::product_runtime::{ use bitfun_core::runtime_ports::PluginRuntimeAvailability; use bitfun_runtime_ports::LocalWorkspaceSnapshotPort; use bitfun_runtime_services::RuntimeServices; +use bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership; use crate::product_assembly::{assemble_acp_runtime_parts, assemble_cli_runtime_parts}; @@ -57,6 +58,7 @@ pub(crate) struct CliRuntimeContext { services: RuntimeServices, product: CliProductRuntimeState, approval_policy: CliApprovalPolicy, + _runtime_ownership: Arc, } impl CliRuntimeContext { @@ -64,6 +66,7 @@ impl CliRuntimeContext { agentic_system: AgenticSystem, workspace_root: impl AsRef, approval_policy: CliApprovalPolicy, + runtime_ownership: WorkspaceRuntimeOwnership, ) -> Result { let scheduler = ensure_product_dialog_scheduler(&agentic_system); let (workspace_root, services) = @@ -117,6 +120,7 @@ impl CliRuntimeContext { services, product, approval_policy, + _runtime_ownership: Arc::new(runtime_ownership), }) } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs new file mode 100644 index 0000000000..41e79160c9 --- /dev/null +++ b/src/apps/cli/src/shared_runtime.rs @@ -0,0 +1,1226 @@ +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use bitfun_agent_runtime::sdk::{ + AgentRuntime, AgentSessionRestoreRequest, AgentUserAnswersRequest, DialogSubmitOutcome, + PermissionRequest, PermissionRequestEvent, RuntimeError, SessionTranscriptRequest, +}; +use bitfun_agent_runtime_ipc::{ + DiscoveryStore, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcError, + RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, + RuntimeIpcStreamInvalidationReason, PROTOCOL_VERSION, +}; +use bitfun_events::{AgenticEvent, ToolEventData}; +use bitfun_services_core::runtime_ownership::{ + RuntimeDeployment, RuntimeOwnershipError, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, +}; +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>>; + +pub(crate) struct SharedRuntimeHandler { + runtime: AgentRuntime, + workspace: PathBuf, + events: Arc, + question_sessions: Arc>>, + subagent_routes: Arc>>, + event_stream_available: watch::Sender, +} + +impl SharedRuntimeHandler { + pub(crate) fn build(runtime: AgentRuntime, 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(Mutex::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, + 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::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(); + Ok(RuntimeIpcOperationResult::SessionRestored { + session: restored.session, + transcript, + pending_permissions, + }) + } + 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::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) + } + } + } + + fn subscribe_events( + &self, + session_id: &str, + ) -> std::result::Result, RuntimeIpcError> { + subscribe_session_events(&self.events, &self.event_stream_available, session_id) + } +} + +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: &Mutex>, + 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 { + 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)?, + ), + _ => 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 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: &Mutex>, +) -> (String, Option, Option) { + let mut routes = routes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let AgenticEvent::SubagentSessionLinked { + session_id, + parent_session_id, + parent_dialog_turn_id, + parent_tool_call_id, + .. + } = event + { + let root_route = routes.get(parent_session_id).cloned().unwrap_or_else(|| { + ( + parent_session_id.clone(), + parent_dialog_turn_id.clone(), + parent_tool_call_id.clone(), + ) + }); + routes.insert(session_id.clone(), root_route); + } + let routed = routes + .get(source_session_id) + .cloned() + .map(|(session_id, turn_id, tool_call_id)| (session_id, Some(turn_id), Some(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 + { + routes.retain(|_, (parent_session_id, parent_turn_id, _)| { + parent_session_id != session_id || parent_turn_id != turn_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(), + &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 !runtime_owner_present(workspace)? { + 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)? { + "; 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 { + let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?; + match WorkspaceRuntimeOwnership::try_acquire( + &ownership_root()?, + &key, + RuntimeDeployment::Shared, + ) { + Ok(_) => Ok(false), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(true), + Err(error) => Err(error.into()), + } +} + +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}")) +} + +pub(crate) fn acquire_ownership( + workspace: &Path, + deployment: RuntimeDeployment, +) -> Result { + let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity()) + .context("resolve Runtime ownership key")?; + WorkspaceRuntimeOwnership::try_acquire(&ownership_root()?, &key, deployment).map_err(|error| { + let guidance = match deployment { + RuntimeDeployment::Embedded => { + "A Shared TUI Runtime owns this CLI workspace; use `bitfun chat --shared`, or close its clients and wait up to 30 seconds" + } + RuntimeDeployment::Shared => { + "An Embedded CLI process owns this workspace; close it before using `--shared`" + } + }; + anyhow!("{guidance}: {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, + product_identity(), + RELEASE_CHANNEL, + &user_root.to_string_lossy(), + PROTOCOL_VERSION, + ) + .context("resolve Shared Runtime identity") +} + +fn product_identity() -> &'static str { + option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or("bitfun") +} + +fn ipc_root() -> Result { + Ok(path_manager()? + .user_data_dir() + .join("agent-runtime") + .join(format!("ipc-v{PROTOCOL_VERSION}"))) +} + +fn ownership_root() -> Result { + Ok(path_manager()? + .user_data_dir() + .join("agent-runtime") + .join("ownership")) +} + +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: &Mutex>, +) -> bool { + permission_request_session(request, routes) == session_id +} + +fn permission_event_session( + event: &PermissionRequestEvent, + index: &Mutex>, + routes: &Mutex>, +) -> 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: &Mutex>, +) -> 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(|(root_session_id, _, _)| 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 { + RuntimeIpcError { + code: RuntimeIpcErrorCode::Unavailable, + message: error.into_message(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + await_permission_route, connect_existing, index_user_question, invalidate_event_stream, + permission_event_session, permission_targets_session, project_subagent_link_route, + project_user_question_route, publish_event, route_agent_event, subscribe_session_events, + SessionEventSenders, EVENT_BUFFER, + }; + use bitfun_agent_runtime::sdk::{ + PermissionDelegationContext, PermissionReplySource, PermissionRequest, + PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, + }; + use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + use tokio::sync::{watch, Notify}; + + #[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()); + } + + 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 subagent_events_route_to_the_parent_until_its_turn_finishes() { + 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!(routes.lock().expect("routes").is_empty()); + } + + #[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 = Mutex::new(HashMap::from([ + ("child-session".to_string(), root_route.clone()), + ("nested-session".to_string(), root_route), + ])); + 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(Mutex::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(), + ( + "root-session".to_string(), + "root-turn".to_string(), + "root-tool".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/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 1106e0150e..b778b0e8d0 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -380,8 +380,10 @@ impl ChatView { &mut self, sessions: Vec, current_session_id: Option, + can_delete: bool, ) { - self.session_selector.show(sessions, current_session_id); + self.session_selector + .show(sessions, current_session_id, can_delete); self.popup_stack.push(PopupType::SessionSelector); } diff --git a/src/apps/cli/src/ui/session_selector.rs b/src/apps/cli/src/ui/session_selector.rs index 0267c19105..435eab6d7a 100644 --- a/src/apps/cli/src/ui/session_selector.rs +++ b/src/apps/cli/src/ui/session_selector.rs @@ -43,6 +43,7 @@ pub(super) struct SessionSelectorState { visible: bool, /// Currently active session ID (for highlighting) current_session_id: Option, + can_delete: bool, last_area: Option, /// Inline rename state rename_editing: bool, @@ -57,6 +58,7 @@ impl SessionSelectorState { list_state: ListState::default(), visible: false, current_session_id: None, + can_delete: false, last_area: None, rename_editing: false, rename_buffer: String::new(), @@ -65,7 +67,12 @@ impl SessionSelectorState { } /// Show the session selector with given session list - pub(super) fn show(&mut self, sessions: Vec, current_session_id: Option) { + pub(super) fn show( + &mut self, + sessions: Vec, + current_session_id: Option, + can_delete: bool, + ) { if sessions.is_empty() { return; } @@ -77,6 +84,7 @@ impl SessionSelectorState { self.items = sessions; self.current_session_id = current_session_id; + self.can_delete = can_delete; self.list_state.select(Some(initial_idx)); self.visible = true; self.rename_editing = false; @@ -154,6 +162,9 @@ impl SessionSelectorState { } // Ctrl+D: delete selected session (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + if !self.can_delete { + return SessionAction::None; + } if let Some(item) = self.selected_item().cloned() { SessionAction::Delete(item) } else { @@ -347,8 +358,10 @@ impl SessionSelectorState { }; let hint_text = if self.rename_editing { " Enter: Save Esc: Cancel " - } else { + } else if self.can_delete { " Up/Down: Navigate Enter: Switch Ctrl+D: Delete Esc: Close " + } else { + " Up/Down: Navigate Enter: Switch Esc: Close " }; let hint = Paragraph::new(Line::from(Span::styled( hint_text, diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index cf62207b3d..9f58660527 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -16,7 +16,7 @@ use super::theme::{ use super::theme_selector::{ThemeItem, ThemeSelectorState}; use crate::actions::{ action_by_id, action_for_alias, removed_management_command_hint, ActionContext, ActionHandler, - ActionSpec, ActionState, ResolvedKeymap, + ActionSpec, ActionState, ResolvedKeymap, SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; use crate::config::CliConfig; /// Startup page module @@ -128,6 +128,15 @@ const TIPS: &[&str] = &[ "Use /new to start a fresh conversation session", ]; +const SHARED_TUI_TIPS: &[&str] = &[ + "Type /help to see the Shared TUI command scope", + "Use /sessions to list and continue previous conversations", + "Use /new to start a fresh conversation session", + "Press Ctrl+E to toggle browse mode for scrolling history", + "Press Ctrl+O to expand or collapse tool output", + "Use /theme to switch the CLI theme", +]; + const FANCY_LOGO: [&str; 6] = [ " ██████╗ ██╗████████╗███████╗██╗ ██╗███╗ ██╗", " ██╔══██╗██║╚══██╔══╝██╔════╝██║ ██║████╗ ██║", @@ -193,7 +202,7 @@ pub(crate) struct StartupPage { // ── System context ── agent: Arc, - compatibility: CoreAgentRuntimeCompatibility, + compatibility: Option, // ── State ── /// Selected agent type (can be changed via /agent or Tab) @@ -215,7 +224,7 @@ impl StartupPage { pub(crate) fn new( config: CliConfig, agent: Arc, - compatibility: CoreAgentRuntimeCompatibility, + compatibility: Option, default_agent: String, workspace: Option, ) -> Self { @@ -244,20 +253,26 @@ impl StartupPage { } }; + let tips = if agent.is_shared() { + SHARED_TUI_TIPS + } else { + TIPS + }; let tip_index = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as usize - % TIPS.len(); + % tips.len(); let keymap = ResolvedKeymap::new(&config.shortcuts); + let action_state = ActionState::startup(false).with_shared_tui(agent.is_shared()); let mut page = Self { text_input: TextInput::new(), theme, config, keymap, - tip: TIPS[tip_index], - command_menu: CommandMenuState::new(ActionState::startup(false)), + tip: tips[tip_index], + command_menu: CommandMenuState::new(action_state), command_palette: CommandPaletteState::new(), model_selector: ModelSelectorState::new(), agent_selector: AgentSelectorState::new(), @@ -304,6 +319,10 @@ impl StartupPage { } } + fn action_state(&self, popup_open: bool) -> ActionState { + ActionState::startup(popup_open).with_shared_tui(self.agent.is_shared()) + } + /// Get the current CLI config after startup-page edits. pub(crate) fn config(&self) -> &CliConfig { &self.config @@ -622,15 +641,18 @@ impl StartupPage { fn render_bottom_bar(&self, frame: &mut Frame, area: Rect) { let version = format!("v{}", env!("CARGO_PKG_VERSION")); - let mcp_status = crate::get_mcp_status_text(); - - // Determine MCP status color - let mcp_color = if mcp_status.contains("Ready") { - self.theme.success - } else if mcp_status.contains("Failed") { - self.theme.error + let (runtime_status, runtime_color) = if self.agent.is_shared() { + ("Runtime: Shared".to_string(), self.theme.success) } else { - self.theme.warning + let mcp_status = crate::get_mcp_status_text(); + let color = if mcp_status.contains("Ready") { + self.theme.success + } else if mcp_status.contains("Failed") { + self.theme.error + } else { + self.theme.warning + }; + (mcp_status, color) }; // Left: workspace path @@ -640,9 +662,9 @@ impl StartupPage { ))); frame.render_widget(left, area); - // Right: MCP status | version + // Right: deployment/MCP status | version let right = Paragraph::new(Line::from(vec![ - Span::styled(&mcp_status, Style::default().fg(mcp_color)), + Span::styled(&runtime_status, Style::default().fg(runtime_color)), Span::styled( format!(" | {} ", version), Style::default().fg(self.theme.muted), @@ -708,8 +730,7 @@ impl StartupPage { // Clear transient status on any key press self.status = None; - let modal_state = - ActionState::startup(self.info_popup.is_some() || self.any_popup_visible()); + let modal_state = self.action_state(self.info_popup.is_some() || self.any_popup_visible()); if let Some(action) = self.keymap.resolve_modal_safe(key, modal_state) { return self.dispatch_action(action, modal_state); } @@ -722,7 +743,7 @@ impl StartupPage { // Host recovery keys win over configured actions while a popup is open. if self.any_popup_visible() { - let state = ActionState::startup(true); + let state = self.action_state(true); if let Some(action) = self.keymap.resolve_reserved(key, state) { return self.dispatch_action(action, state); } @@ -908,8 +929,8 @@ impl StartupPage { // ── Normal key handling ── - if let Some(action) = self.keymap.resolve(key, ActionState::startup(false)) { - return self.dispatch_action(action, ActionState::startup(false)); + if let Some(action) = self.keymap.resolve(key, self.action_state(false)) { + return self.dispatch_action(action, self.action_state(false)); } match (key.code, key.modifiers) { @@ -967,7 +988,7 @@ impl StartupPage { self.status = Some(format!("Unknown palette action: {action_id}")); return None; }; - self.dispatch_action(action, ActionState::startup(false)) + self.dispatch_action(action, self.action_state(false)) } fn dispatch_action( @@ -979,10 +1000,14 @@ impl StartupPage { self.status = Some(action.unavailable_message(state)); return None; } - match action.handler { ActionHandler::Help => { - self.info_popup = Some(self.keymap.help_text(ActionState::startup(false))); + let mut help = self.keymap.help_text(self.action_state(false)); + if self.agent.is_shared() { + help.push_str("\n\n"); + help.push_str(SHARED_TUI_HELP_NOTE); + } + self.info_popup = Some(help); } ActionHandler::Exit => return Some(StartupResult::Exit), ActionHandler::NewSession => { @@ -1024,7 +1049,7 @@ impl StartupPage { }, ActionHandler::OpenPalette => { self.push_current_popup_to_stack(); - self.command_palette.show(ActionState::startup(false)); + self.command_palette.show(self.action_state(false)); } ActionHandler::SubmitInput => return self.submit_input(), ActionHandler::InsertNewline => { @@ -1123,7 +1148,7 @@ impl StartupPage { ); return None; }; - self.dispatch_action(action, ActionState::startup(false)) + self.dispatch_action(action, self.action_state(false)) } // ======================== Selectors ======================== @@ -1221,12 +1246,15 @@ impl StartupPage { } fn start_sync_and_show_account(&mut self, is_first_login: bool) { + let Some(compatibility) = self.compatibility.clone() else { + self.open_account_panel(); + self.status = Some(format!( + "Account settings sync is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}" + )); + return; + }; let workspace = self.workspace_path_for_sync(); - crate::account_sync::start_auto_sync_background( - self.compatibility.clone(), - is_first_login, - workspace, - ); + crate::account_sync::start_auto_sync_background(compatibility, is_first_login, workspace); self.open_account_panel(); self.status = Some(if is_first_login { "Sync started (use local / upload settings).".to_string() @@ -1324,9 +1352,16 @@ impl StartupPage { self.push_current_popup_to_stack(); let agent = Arc::clone(&self.agent); let sessions = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(async { agent.list_sessions().await.unwrap_or_default() }) + tokio::runtime::Handle::current().block_on(agent.list_sessions()) }); + let sessions = match sessions { + Ok(sessions) => sessions, + Err(error) => { + tracing::error!("Failed to list sessions: {error}"); + self.status = Some(format!("Failed to load sessions: {error}")); + return; + } + }; if sessions.is_empty() { self.status = Some("No sessions found.".to_string()); @@ -1359,10 +1394,17 @@ impl StartupPage { }) .collect(); - self.session_selector.show(session_items, None); + self.session_selector + .show(session_items, None, !self.agent.is_shared()); } fn handle_session_delete(&mut self, item: &SessionItem) { + if self.agent.is_shared() { + self.status = Some(format!( + "Session deletion is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}; then run `bitfun sessions delete`" + )); + return; + } let agent = Arc::clone(&self.agent); let sid = item.session_id.clone(); diff --git a/src/apps/cli/tests/exec_cli_contracts.rs b/src/apps/cli/tests/exec_cli_contracts.rs index b1fac87ff1..4fcc6ce56d 100644 --- a/src/apps/cli/tests/exec_cli_contracts.rs +++ b/src/apps/cli/tests/exec_cli_contracts.rs @@ -173,6 +173,21 @@ fn exec_json_preflight_failure_is_one_result_document() { .is_some_and(|message| message.contains("--session-id"))); } +#[test] +fn root_shared_flag_keeps_exec_json_error_contract() { + let output = run_cli(&["--shared", "exec", "task", "--output-format", "json"]); + let stdout = stdout(&output); + + assert!(!output.status.success(), "{stdout}"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("one JSON result object"); + assert_eq!(value["type"], "result"); + assert_eq!(value["subtype"], "error"); + assert!(value["result"] + .as_str() + .is_some_and(|message| message.contains("interactive TUI"))); + assert!(stderr(&output).is_empty(), "{}", stderr(&output)); +} + #[test] fn exec_json_rejects_continue_with_an_explicit_resume() { let output = run_cli(&[ diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index 86ed42db21..6ddba35906 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -305,8 +305,11 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { const STARTUP_PAGE: &str = include_str!("../src/ui/startup.rs"); const CHAT_MODE: &str = include_str!("../src/modes/chat.rs"); const CHAT_RUN: &str = include_str!("../src/modes/chat/run.rs"); + const CHAT_COMMANDS: &str = include_str!("../src/modes/chat/commands.rs"); const CHAT_INPUT: &str = include_str!("../src/modes/chat/input.rs"); const CHAT_SELECTION: &str = include_str!("../src/modes/chat/selection.rs"); + const RUNTIME_CLIENT: &str = include_str!("../src/agent/runtime_client.rs"); + const SHARED_RUNTIME: &str = include_str!("../src/shared_runtime.rs"); const CLI_MAIN: &str = include_str!("../src/main.rs"); const CLI_CARGO: &str = include_str!("../Cargo.toml"); @@ -333,8 +336,34 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { "interactive chat must retain the existing app-private runtime client facade" ); assert!( - !CLI_CARGO.contains("bitfun-sdk-host") && !CLI_CARGO.contains("bitfun-agent-runtime-ipc"), - "the CLI must not gain SDK Host or Shared IPC dependencies in this refactor" + !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" + ); + assert!( + RUNTIME_CLIENT.contains("RuntimeIpcClient") + && !STARTUP_PAGE.contains("RuntimeIpcClient") + && !CHAT_MODE.contains("RuntimeIpcClient"), + "Shared IPC must remain behind CliAgentRuntimeClient instead of leaking into TUI controllers" + ); + let shared_command_path = CHAT_COMMANDS + .split_once("fn handle_command(") + .expect("handle_command") + .1; + assert!( + shared_command_path + .find("if self.agent.is_shared()") + .unwrap_or(usize::MAX) + < shared_command_path + .find("external_source_conflict_choices") + .expect("external source call"), + "Shared slash commands must branch before initializing Embedded external-source owners" + ); + assert!( + CHAT_COMMANDS.matches("if self.agent.is_shared()").count() >= 3 + && RUNTIME_CLIENT.contains("Failed to read Embedded session transcript") + && SHARED_RUNTIME.contains("RuntimeDeployment::Shared") + && SHARED_RUNTIME.contains("process_manager::contain_current_process_tree"), + "Shared controls must stay terminal-safe while preserving Embedded recovery and one process Job owner" ); assert!( CLI_MAIN.contains("Cli::command()") && CLI_MAIN.contains("McpAction::Import"), diff --git a/src/crates/adapters/AGENTS-CN.md b/src/crates/adapters/AGENTS-CN.md index 03ce7152eb..29deb4ee85 100644 --- a/src/crates/adapters/AGENTS-CN.md +++ b/src/crates/adapters/AGENTS-CN.md @@ -8,7 +8,7 @@ | Crate | 职责 | 本地文档 | |---|---|---| -| `agent-runtime-ipc` | 不发布且仅 crate 内可见的本机 IPC 预集成边界,为未来第一方 Shared Agent Runtime adapter 提供当前仅 Health 的基础 | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | +| `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 d4937ab324..8e9f175333 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -11,7 +11,7 @@ services. | Crate | Responsibility | Local doc | |---|---|---| -| `agent-runtime-ipc` | Non-published, crate-internal local IPC pre-integration seam for a future first-party Shared Agent Runtime adapter; currently Health-only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | +| `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 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 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 index 54e8d57e38..c0da541955 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -4,20 +4,19 @@ 范围:`src/crates/adapters/agent-runtime-ipc`。 -该 crate 不发布,是未来第一方 Shared Agent Runtime adapter 的私有预集成边界。当前只验证 discovery、单实例锁、有界 framing、认证初始化、Health、连接上限和 cleanup;它不是公开 SDK 或 Runtime owner,也没有生产 consumer。 +该 crate 不发布,是第一方 Shared TUI adapter 使用的私有本机协议。它提供 discovery、单实例锁、有界 framing、认证初始化、封闭的交互操作集、Session controller lease、事件传递、连接上限和 cleanup;它不是公开 SDK、远程协议、service layer 或 Runtime owner。 ## 预集成约束 -- 首个候选 consumer 仅为另行评审的第一方交互式 TUI attach adapter;不自动包含 GUI、Remote、Headless CLI 或 SDK Host。 -- 稳定测试合同只有本机 endpoint、initialize-first、64 KiB frame、Health、连接上限和 owner-checked discovery cleanup。 +- 唯一 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。 -- 若首个 consumer 选择其他 transport,或 Shared 在产品接入前取消,删除本 crate。 ## 边界 -- 首个生产 consumer 证明准确 API 前,所有 Rust item 保持 crate 内可见,且 crate 不得发布。 -- Health 是唯一 operation。禁止增加 Session、Turn、Tool、MCP、Permission、UserInput、Hook、event replay、controller lease 或产品配置。 -- 禁止依赖 `bitfun-core`、Agent Runtime、SDK Host、services、CLI/TUI、Tauri、product domains、terminal、tool runtime 或远程 transport。 +- 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 +- 封闭 operation 范围为 Health、Session list/create/restore(restore 结果包含 transcript)、Turn submit/cancel、pending/respond Permission 和 UserInput answers。断连 cleanup 属于内部生命周期,不是 detach operation。禁止顺带加入 delete、fork、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 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index ee77434b61..4f685b8bf0 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -4,32 +4,30 @@ Scope: `src/crates/adapters/agent-runtime-ipc`. -This non-published crate is a private pre-integration seam for a future -first-party Shared Agent Runtime adapter. It currently proves discovery, -one-instance locking, bounded framing, authenticated initialization, Health, -connection bounds, and cleanup. It is not a public SDK or Runtime owner, and it -has no production consumer yet. +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 -- First consumer: the separately reviewed first-party interactive TUI attach - adapter. GUI, Remote, Headless CLI, and SDK Host are not implied consumers. -- Stable test contract: platform-local endpoint, strict initialize-first - handshake, 64 KiB frame limit, Health, bounded connections, and owner-checked - discovery cleanup. +- 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. -- Removal condition: delete this seam if the first consumer chooses another - transport or Shared deployment is abandoned before product activation. ## Boundaries -- Keep all Rust items crate-internal until the first production consumer proves - the exact API it needs. Do not publish this crate. -- Health is the only operation. Do not add Session, Turn, Tool, MCP, Permission, - UserInput, Hook, event replay, controller lease, or product configuration. -- Do not depend on `bitfun-core`, Agent Runtime, SDK Host, services, CLI/TUI, - Tauri, product domains, terminal, tool runtime, or remote transports. +- 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, Session list/create/restore (including transcript), Turn submit/cancel, pending/respond Permission, + and UserInput answers. Disconnect cleanup is internal lifecycle, not a detach operation. Do not add delete, fork, replay, observer, + controller transfer, Tool/MCP/Hook management, or 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 diff --git a/src/crates/adapters/agent-runtime-ipc/Cargo.toml b/src/crates/adapters/agent-runtime-ipc/Cargo.toml index aa91c937e2..60bca6c02a 100644 --- a/src/crates/adapters/agent-runtime-ipc/Cargo.toml +++ b/src/crates/adapters/agent-runtime-ipc/Cargo.toml @@ -11,6 +11,10 @@ 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 } diff --git a/src/crates/adapters/agent-runtime-ipc/src/client.rs b/src/crates/adapters/agent-runtime-ipc/src/client.rs index cd3b11ef15..ee3462c53a 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/client.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/client.rs @@ -1,17 +1,50 @@ use crate::{ - read_frame, write_frame, DiscoveryRecord, HealthResult, InitializeRequest, LocalIpcEndpoint, - LocalIpcStream, RuntimeIpcError, RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, - RuntimeIpcOperationResult, RuntimeIpcTransportError, PROTOCOL_VERSION, + read_frame_strict_with_limit, serialize_frame_with_limit, write_frame, 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::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 { - stream: LocalIpcStream, + commands: mpsc::Sender, instance_identity: String, request_timeout: Duration, - next_request_id: u64, + request_gate: Arc>, + events: broadcast::Sender, + capabilities: RuntimeIpcCapabilities, + disconnect: watch::Sender, +} + +struct ClientCommand { + operation: RuntimeIpcOperation, + response: oneshot::Sender, + deadline: tokio::time::Instant, + _request_gate: OwnedMutexGuard<()>, +} + +enum PendingResponse { + Result(RuntimeIpcOperationResult), + Remote(RuntimeIpcError), + RequestIdExhausted, + Timeout, + Io(RuntimeIpcIoError), + Disconnected, } impl fmt::Debug for RuntimeIpcClient { @@ -20,7 +53,7 @@ impl fmt::Debug for RuntimeIpcClient { .debug_struct("RuntimeIpcClient") .field("instance_identity", &self.instance_identity) .field("request_timeout", &self.request_timeout) - .field("next_request_id", &self.next_request_id) + .field("capabilities", &self.capabilities) .finish_non_exhaustive() } } @@ -31,8 +64,12 @@ impl RuntimeIpcClient { discovery: &DiscoveryRecord, client_id: &str, client_version: &str, - timeout: Duration, + 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, @@ -46,84 +83,226 @@ impl RuntimeIpcClient { runtime_root, &discovery.instance_identity, )?; - let mut stream = endpoint.connect(timeout).await?; + let mut stream = endpoint.connect(connect_timeout).await?; let request_id = 1; - let frame = 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(), - }, - }; - timeout_io(timeout, write_frame(&mut stream, &frame)).await?; - let response = timeout_io(timeout, read_frame(&mut stream)).await?; - match response { + 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.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), - } + }; - Ok(Self { + 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: timeout, - next_request_id: 2, + request_timeout, + request_gate: Arc::new(Mutex::new(())), + events, + capabilities, + disconnect, }) } - pub async fn health(&mut self) -> Result { - let request_id = self.next_request_id; - self.next_request_id = self - .next_request_id - .checked_add(1) - .ok_or(RuntimeIpcClientError::RequestIdExhausted)?; - timeout_io( - self.request_timeout, - write_frame( - &mut self.stream, - &RuntimeIpcFrame::Request { - request_id, - operation: RuntimeIpcOperation::Health, - }, - ), + 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; + serialize_frame_with_limit( + &RuntimeIpcFrame::Request { + request_id: u64::MAX, + operation: operation.clone(), + }, + MAX_REQUEST_FRAME_BYTES, + )?; + let deadline = tokio::time::Instant::now() + self.request_timeout; + let (sender, receiver) = oneshot::channel(); + match tokio::time::timeout_at( + deadline, + self.commands.send(ClientCommand { + operation, + response: sender, + deadline, + _request_gate: request_gate, + }), ) - .await?; - let response = timeout_io(self.request_timeout, read_frame(&mut self.stream)).await?; - match response { - RuntimeIpcFrame::Response { - request_id: response_id, - result: - RuntimeIpcOperationResult::Health { - instance_identity, - process_id, - }, - } if response_id == request_id && instance_identity == self.instance_identity => { - Ok(HealthResult { - instance_identity, - process_id, - }) + .await + { + Err(_) => { + let _ = self.disconnect.send(true); + return Err(RuntimeIpcClientError::Timeout); } - RuntimeIpcFrame::Error { - request_id: Some(response_id), - error, - } if response_id == request_id => Err(RuntimeIpcClientError::Remote(error)), + 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::RequestIdExhausted => Err(RuntimeIpcClientError::RequestIdExhausted), + 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( + mut stream: crate::LocalIpcStream, + mut commands: mpsc::Receiver, + events: broadcast::Sender, + mut disconnect: watch::Receiver, +) { + let mut next_request_id = 2u64; + 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 Some(incremented) = next_request_id.checked_add(1) else { + let _ = command.response.send(PendingResponse::RequestIdExhausted); + break; + }; + let request_id = next_request_id; + next_request_id = incremented; + if tokio::time::Instant::now() >= command.deadline { + let _ = command.response.send(PendingResponse::Timeout); + continue; + } + let frame = RuntimeIpcFrame::Request { + request_id, + operation: command.operation, + }; + match tokio::time::timeout_at(command.deadline, write_frame(&mut stream, &frame)).await { + Err(_) => { + let _ = command.response.send(PendingResponse::Timeout); + break; + } + Ok(Err(error @ RuntimeIpcIoError::FrameTooLarge { .. })) => { + let _ = command.response.send(PendingResponse::Io(error)); + continue; + } + Ok(Err(_)) => { + let _ = command.response.send(PendingResponse::Disconnected); + break; + } + Ok(Ok(())) => {} + } + pending.insert(request_id, (command.response, command._request_gate)); + }, + frame = frames.read_strict(&mut stream) => match frame { + Ok(RuntimeIpcFrame::Response { request_id, result }) => { + if let Some((sender, _request_gate)) = pending.remove(&request_id) { + let _ = sender.send(PendingResponse::Result(result)); + } else { + break; + } + } + Ok(RuntimeIpcFrame::Error { + request_id: Some(request_id), + error, + }) => { + if let Some((sender, _request_gate)) = pending.remove(&request_id) { + let _ = sender.send(PendingResponse::Remote(error)); + } else { + break; + } + } + Ok(RuntimeIpcFrame::Event { event }) => { + let _ = events.send(RuntimeIpcClientEvent::Runtime(event)); + } + Ok(_) | Err(_) => break, + } + } + } + + for (_, (sender, _request_gate)) in pending.drain() { + let _ = sender.send(PendingResponse::Disconnected); + } + let _ = events.send(RuntimeIpcClientEvent::Disconnected); +} + async fn timeout_io( timeout: Duration, future: impl std::future::Future>, @@ -149,10 +328,14 @@ pub enum RuntimeIpcClientError { 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 timeouts must be greater than zero")] + InvalidTimeout, #[error("runtime IPC request was rejected: {0:?}")] Remote(RuntimeIpcError), #[error(transparent)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/framing.rs b/src/crates/adapters/agent-runtime-ipc/src/framing.rs index 07a9407012..a762fed630 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/framing.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/framing.rs @@ -1,19 +1,29 @@ use crate::RuntimeIpcFrame; +use std::io::Write; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -pub const MAX_FRAME_BYTES: usize = 64 * 1024; +pub(crate) const MAX_REQUEST_FRAME_BYTES: usize = 128 * 1024; +pub(crate) const MAX_RESPONSE_FRAME_BYTES: usize = 8 * 1024 * 1024; -pub async fn write_frame( +pub(crate) async fn write_frame( writer: &mut W, frame: &RuntimeIpcFrame, ) -> Result<(), RuntimeIpcIoError> where W: AsyncWrite + Unpin, { - let bytes = serde_json::to_vec(frame).map_err(RuntimeIpcIoError::Serialize)?; - if bytes.len() > MAX_FRAME_BYTES { - return Err(RuntimeIpcIoError::FrameTooLarge { size: bytes.len() }); - } + 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)?; writer .write_u32(bytes.len() as u32) .await @@ -25,30 +35,197 @@ where writer.flush().await.map_err(RuntimeIpcIoError::Io) } -pub async fn read_frame(reader: &mut R) -> Result +pub(crate) async fn read_frame(reader: &mut R) -> Result where R: AsyncRead + Unpin, { - let size = reader.read_u32().await.map_err(RuntimeIpcIoError::Io)? as usize; - if size > MAX_FRAME_BYTES { - return Err(RuntimeIpcIoError::FrameTooLarge { size }); - } - let mut bytes = vec![0; size]; - reader - .read_exact(&mut bytes) + 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 - .map_err(RuntimeIpcIoError::Io)?; - serde_json::from_slice(&bytes).map_err(RuntimeIpcIoError::Deserialize) +} + +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_FRAME_BYTES} bytes: {size}")] - FrameTooLarge { size: usize }, + #[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 new file mode 100644 index 0000000000..3b2c9fde7f --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/handler.rs @@ -0,0 +1,27 @@ +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 index 047543bed0..9bc108e256 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/ipc.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/ipc.rs @@ -13,7 +13,7 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; const MAX_PORTABLE_UDS_PATH_BYTES: usize = 103; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct LocalIpcEndpoint { +pub(crate) struct LocalIpcEndpoint { discovery_value: String, #[cfg(unix)] path: PathBuf, @@ -58,12 +58,12 @@ impl LocalIpcEndpoint { Ok(expected) } - pub fn discovery_value(&self) -> &str { + pub(crate) fn discovery_value(&self) -> &str { &self.discovery_value } #[cfg(unix)] - pub fn as_path(&self) -> &Path { + pub(crate) fn as_path(&self) -> &Path { &self.path } @@ -72,7 +72,7 @@ impl LocalIpcEndpoint { &self.discovery_value } - pub async fn connect( + pub(crate) async fn connect( &self, deadline: Duration, ) -> Result { @@ -124,7 +124,7 @@ fn validate_uds_path_length(path: &Path) -> Result<(), RuntimeIpcTransportError> Ok(()) } -pub struct LocalIpcListener { +pub(crate) struct LocalIpcListener { endpoint: LocalIpcEndpoint, #[cfg(windows)] server: Option, @@ -218,7 +218,7 @@ impl Drop for LocalIpcListener { } } -pub enum LocalIpcStream { +pub(crate) enum LocalIpcStream { #[cfg(windows)] WindowsClient(tokio::net::windows::named_pipe::NamedPipeClient), #[cfg(windows)] @@ -293,12 +293,15 @@ impl AsyncWrite for LocalIpcStream { 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")] @@ -307,6 +310,7 @@ pub enum RuntimeIpcTransportError { 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 index 72a61c5f74..5fd345146d 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/lib.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/lib.rs @@ -1,38 +1,40 @@ -//! Private local IPC foundation for a future shared BitFun Agent Runtime. -//! -//! The crate implements only discovery, authentication, protocol framing, and -//! Health. All items remain crate-internal until a reviewed first-party adapter -//! becomes a production consumer. This is not a public SDK or Runtime owner. - -#![allow(dead_code, unreachable_pub)] +//! 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; -#[cfg(test)] -pub(crate) use client::{RuntimeIpcClient, RuntimeIpcClientError}; -pub(crate) use discovery::{ +pub use client::{RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent}; +pub use discovery::{ DiscoveryRecord, DiscoveryStore, RuntimeInstanceIdentity, RuntimeInstanceLock, RuntimeIpcDiscoveryError, }; -#[cfg(test)] -pub(crate) use framing::MAX_FRAME_BYTES; -pub(crate) use framing::{read_frame, write_frame, RuntimeIpcIoError}; -pub(crate) use ipc::{ - LocalIpcEndpoint, LocalIpcListener, LocalIpcStream, RuntimeIpcTransportError, +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, RuntimeIpcFrameReader, MAX_REQUEST_FRAME_BYTES, + MAX_RESPONSE_FRAME_BYTES, }; -pub(crate) use operation::{RuntimeIpcOperation, RuntimeIpcOperationResult}; -pub(crate) use protocol::{ +pub use handler::RuntimeIpcRequestHandler; +pub use ipc::RuntimeIpcTransportError; +pub(crate) use ipc::{LocalIpcEndpoint, LocalIpcListener, LocalIpcStream}; +pub use operation::{ + RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeSessionRestoreRequest, + RuntimeUserAnswersRequest, +}; +pub use protocol::{ HealthResult, InitializeRequest, InitializeResult, RuntimeIpcCapabilities, RuntimeIpcError, - RuntimeIpcErrorCode, RuntimeIpcFrame, PROTOCOL_VERSION, + RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcFrame, RuntimeIpcStreamInvalidationReason, + PROTOCOL_VERSION, }; -#[cfg(test)] -pub(crate) use server::{RuntimeIpcServer, RuntimeIpcServerConfig}; +pub use server::{RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcServerError}; +pub(crate) use session_lease::{LeaseTransition, RuntimeSessionLeases}; #[cfg(test)] #[path = "tests/discovery_and_framing.rs"] @@ -43,3 +45,6 @@ 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 index a5927e9e98..eeb151b8a7 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,16 +1,120 @@ +use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; +use bitfun_runtime_ports::{ + AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionListRequest, AgentSessionSummary, AgentTurnCancellationRequest, + AgentTurnCancellationResult, SessionTranscript, +}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +#[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, 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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] pub enum RuntimeIpcOperation { Health, + ListSessions { + request: AgentSessionListRequest, + }, + CreateSession { + request: AgentSessionCreateRequest, + }, + RestoreSession { + request: RuntimeSessionRestoreRequest, + }, + SubmitTurn { + request: AgentDialogTurnRequest, + }, + CancelTurn { + request: AgentTurnCancellationRequest, + }, + PendingPermissions { + session_id: String, + }, + RespondPermission { + session_id: String, + request_id: String, + reply: PermissionReply, + }, + SubmitUserAnswers { + request: RuntimeUserAnswersRequest, + }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)] +impl RuntimeIpcOperation { + pub fn session_id(&self) -> Option<&str> { + match self { + Self::RestoreSession { request } => Some(&request.session_id), + Self::SubmitTurn { 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::Health | Self::ListSessions { .. } | Self::CreateSession { .. } => None, + } + } + + pub fn requires_controller(&self) -> bool { + matches!( + self, + Self::SubmitTurn { .. } + | Self::CancelTurn { .. } + | Self::PendingPermissions { .. } + | Self::RespondPermission { .. } + | Self::SubmitUserAnswers { .. } + ) + } +} + +#[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, + Sessions { + sessions: Vec, + }, + SessionCreated { + session: AgentSessionCreateResult, + }, + SessionRestored { + session: AgentSessionSummary, + transcript: SessionTranscript, + pending_permissions: Vec, + }, + TurnAccepted { + session_id: String, + turn_id: String, + }, + TurnCancelled { + cancellation: AgentTurnCancellationResult, + }, + PendingPermissions { + requests: Vec, + }, } diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 754461859f..05f6dcdafc 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -2,10 +2,12 @@ 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 = 1; +pub const PROTOCOL_VERSION: u32 = 2; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum RuntimeIpcFrame { Initialize { @@ -28,6 +30,49 @@ pub enum RuntimeIpcFrame { 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)] @@ -66,6 +111,8 @@ pub struct InitializeResult { #[serde(deny_unknown_fields)] pub struct RuntimeIpcCapabilities { pub health: bool, + #[serde(default)] + pub interactive_tui: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -82,6 +129,11 @@ pub enum RuntimeIpcErrorCode { IncompatibleProtocol, WrongInstance, FrameTooLarge, + SessionInUse, + ControllerRequired, + SessionMismatch, + OperationUnsupported, + OutcomeUnknown, Unavailable, Internal, } diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index dcca12f7d4..4d04761b79 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -1,13 +1,18 @@ use crate::{ - read_frame, write_frame, DiscoveryRecord, DiscoveryStore, InitializeResult, LocalIpcEndpoint, - LocalIpcListener, LocalIpcStream, RuntimeInstanceIdentity, RuntimeInstanceLock, - RuntimeIpcCapabilities, RuntimeIpcDiscoveryError, RuntimeIpcError, RuntimeIpcErrorCode, - RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcTransportError, PROTOCOL_VERSION, + read_frame, serialize_frame_with_limit, write_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; @@ -17,12 +22,14 @@ const MAX_CONNECTION_LIMIT: usize = 1024; pub struct RuntimeIpcServerConfig { pub server_version: String, pub idle_timeout: Duration, - pub io_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, @@ -37,6 +44,24 @@ impl RuntimeIpcServer { 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)?; @@ -56,6 +81,7 @@ impl RuntimeIpcServer { Ok(Self { listener, + #[cfg(test)] endpoint, discovery_store, discovery_record, @@ -64,7 +90,11 @@ impl RuntimeIpcServer { instance_identity: identity.as_str().to_string(), token, server_version: config.server_version, - io_timeout: config.io_timeout, + 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, @@ -75,7 +105,8 @@ impl RuntimeIpcServer { &self.discovery_record } - pub fn endpoint(&self) -> &LocalIpcEndpoint { + #[cfg(test)] + pub(crate) fn endpoint(&self) -> &LocalIpcEndpoint { &self.endpoint } @@ -146,14 +177,19 @@ struct ConnectionConfig { instance_identity: String, token: String, server_version: String, - io_timeout: Duration, + 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 first = match timeout_read(config.io_timeout, &mut stream).await { + 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), @@ -166,7 +202,7 @@ async fn handle_connection( frame => { send_error( &mut stream, - config.io_timeout, + config.handshake_timeout, request_id_of(&frame), RuntimeIpcErrorCode::InvalidRequest, "initialize must be the first frame", @@ -179,7 +215,7 @@ async fn handle_connection( if !constant_time_eq(request.token.as_bytes(), config.token.as_bytes()) { send_error( &mut stream, - config.io_timeout, + config.handshake_timeout, Some(request_id), RuntimeIpcErrorCode::Unauthorized, "runtime IPC authentication failed", @@ -190,7 +226,7 @@ async fn handle_connection( if request.protocol_version != PROTOCOL_VERSION { send_error( &mut stream, - config.io_timeout, + config.handshake_timeout, Some(request_id), RuntimeIpcErrorCode::IncompatibleProtocol, "runtime IPC protocol version is incompatible", @@ -201,7 +237,7 @@ async fn handle_connection( if request.instance_identity != config.instance_identity { send_error( &mut stream, - config.io_timeout, + config.handshake_timeout, Some(request_id), RuntimeIpcErrorCode::WrongInstance, "runtime IPC endpoint belongs to another instance", @@ -212,7 +248,7 @@ async fn handle_connection( if !valid_client_fact(&request.client_id) || !valid_client_fact(&request.client_version) { send_error( &mut stream, - config.io_timeout, + config.handshake_timeout, Some(request_id), RuntimeIpcErrorCode::InvalidRequest, "runtime IPC client identity is invalid", @@ -221,8 +257,12 @@ async fn handle_connection( return Ok(()); } + let interactive_tui = config + .handler + .as_ref() + .is_some_and(|handler| handler.ensure_available().is_ok()); timeout_write( - config.io_timeout, + config.handshake_timeout, &mut stream, &RuntimeIpcFrame::Initialized { request_id, @@ -230,40 +270,340 @@ async fn handle_connection( protocol_version: PROTOCOL_VERSION, instance_identity: config.instance_identity.clone(), server_version: config.server_version.clone(), - capabilities: RuntimeIpcCapabilities { health: true }, + 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; loop { - let frame = match timeout_read(config.io_timeout, &mut stream).await { - Ok(frame) => frame, - Err(RuntimeIpcServerError::Disconnected) => return Ok(()), - Err(error) => return Err(error), - }; - match frame { - RuntimeIpcFrame::Request { + match next_connection_input( + config.request_timeout, + stream, + &mut frames, + &mut frame_deadline, + events.as_mut(), + availability.as_mut(), + ) + .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 }; + if matches!( + serialize_frame_with_limit(&frame, MAX_RESPONSE_FRAME_BYTES), + Err(RuntimeIpcIoError::FrameTooLarge { .. }) + ) { + timeout_write( + config.request_timeout, + stream, + &RuntimeIpcFrame::Event { + event: RuntimeIpcEvent::StreamInvalidated { + reason: crate::RuntimeIpcStreamInvalidationReason::FrameTooLarge, + }, + }, + ) + .await?; + return Err(RuntimeIpcServerError::EventStreamUnavailable); + } + timeout_write(config.request_timeout, stream, &frame).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: RuntimeIpcOperation::Health, - } => { - timeout_write( - config.io_timeout, - &mut stream, - &RuntimeIpcFrame::Response { + operation, + }) => { + if matches!(operation, RuntimeIpcOperation::Health) { + send_operation_result( + config.request_timeout, + stream, request_id, - result: RuntimeIpcOperationResult::Health { + 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; + } + + if active_turn_id.is_some() + && matches!( + operation, + RuntimeIpcOperation::SubmitTurn { .. } + | RuntimeIpcOperation::RestoreSession { .. } + | RuntimeIpcOperation::CreateSession { .. } + ) + { + send_error( + stream, + config.request_timeout, + Some(request_id), + RuntimeIpcErrorCode::SessionInUse, + "finish or cancel the active turn before changing the controlled session", + ) + .await?; + continue; + } + + // Serialize attachment so a newly visible Session cannot be claimed + // before its generated ID returns to the creating connection. + let _attachment_guard = if matches!( + operation, + RuntimeIpcOperation::CreateSession { .. } + | RuntimeIpcOperation::RestoreSession { .. } + ) { + 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; + } + }; + 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) + } + _ => None, + }; + let result = tokio::time::timeout( + config.request_timeout, + handler.execute(operation.clone()), ) - .await?; + .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 operation_has_side_effects(&operation) => { + 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; + } + }; + let response = RuntimeIpcFrame::Response { request_id, result }; + match serialize_frame_with_limit(&response, MAX_RESPONSE_FRAME_BYTES) { + 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(_) => {} + } + + let RuntimeIpcFrame::Response { result, .. } = &response else { + unreachable!("response frame was just constructed") + }; + if let RuntimeIpcOperationResult::SessionCreated { session } = result { + lease_transition = + match config.leases.switch(connection_id, &session.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()) + } + _ => 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 submitted operation", + ) + .await?; + return Err(RuntimeIpcServerError::Disconnected); + } + } + if let Err(error) = timeout_write(config.request_timeout, stream, &response).await { + config.leases.rollback(connection_id, lease_transition); + return Err(error); + } + if event_stream_unavailable { + return Err(RuntimeIpcServerError::EventStreamUnavailable); + } } - frame => { + ConnectionInput::Frame(frame) => { send_error( - &mut stream, - config.io_timeout, + stream, + config.request_timeout, request_id_of(&frame), RuntimeIpcErrorCode::InvalidRequest, "runtime IPC frame is not valid after initialization", @@ -275,6 +615,194 @@ async fn handle_connection( } } +enum ConnectionInput { + Frame(RuntimeIpcFrame), + Event(RuntimeIpcEvent), + EventLagged, + EventClosed, + RuntimeUnavailable, +} + +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>, +) -> Result { + tokio::select! { + frame = read_connected(timeout, stream, frames, frame_deadline) => frame.map(ConnectionInput::Frame), + 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_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 operation_has_side_effects(operation: &RuntimeIpcOperation) -> bool { + matches!( + operation, + RuntimeIpcOperation::CreateSession { .. } + | RuntimeIpcOperation::RestoreSession { .. } + | RuntimeIpcOperation::SubmitTurn { .. } + | RuntimeIpcOperation::CancelTurn { .. } + | RuntimeIpcOperation::RespondPermission { .. } + | RuntimeIpcOperation::SubmitUserAnswers { .. } + ) +} + +fn prepare_operation( + config: &ConnectionConfig, + connection_id: &str, + operation: &RuntimeIpcOperation, +) -> Result { + if matches!(operation, RuntimeIpcOperation::CreateSession { .. }) { + return Ok(LeaseTransition::Unchanged); + } + + if let RuntimeIpcOperation::RestoreSession { request } = operation { + return config.leases.switch(connection_id, &request.session_id); + } + + if operation.requires_controller() { + config.leases.validate( + connection_id, + operation + .session_id() + .expect("controller 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, + }, + }); + 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, @@ -301,10 +829,13 @@ async fn timeout_write( stream: &mut LocalIpcStream, frame: &RuntimeIpcFrame, ) -> Result<(), RuntimeIpcServerError> { - tokio::time::timeout(timeout, write_frame(stream, frame)) - .await - .map_err(|_| RuntimeIpcServerError::IoTimeout)? - .map_err(RuntimeIpcServerError::Io) + 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 send_error( @@ -328,6 +859,28 @@ async fn send_error( .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, .. } @@ -335,9 +888,24 @@ fn request_id_of(frame: &RuntimeIpcFrame) -> Option { | 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) } @@ -359,7 +927,8 @@ fn validate_server_config(config: &RuntimeIpcServerConfig) -> Result<(), Runtime || config.server_version.len() > 128 || config.server_version.chars().any(char::is_control) || config.idle_timeout.is_zero() - || config.io_timeout.is_zero() + || config.handshake_timeout.is_zero() + || config.request_timeout.is_zero() || config.max_connections == 0 || config.max_connections > MAX_CONNECTION_LIMIT { @@ -376,6 +945,8 @@ pub enum RuntimeIpcServerError { 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)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/session_lease.rs b/src/crates/adapters/agent-runtime-ipc/src/session_lease.rs new file mode 100644 index 0000000000..f01067a050 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/session_lease.rs @@ -0,0 +1,140 @@ +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 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 index 2c47c6ad01..31d38d2df8 100644 --- 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 @@ -1,7 +1,7 @@ use crate::{ read_frame, write_frame, DiscoveryRecord, DiscoveryStore, RuntimeInstanceIdentity, - RuntimeInstanceLock, RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, MAX_FRAME_BYTES, - PROTOCOL_VERSION, + RuntimeInstanceLock, RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, + MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, }; use tempfile::tempdir; use tokio::io::AsyncWriteExt; @@ -208,7 +208,7 @@ fn discovery_is_owner_checked_and_instance_lock_is_exclusive() { #[tokio::test] async fn framing_round_trips_health_and_rejects_oversized_lengths() { - let (mut writer, mut reader) = tokio::io::duplex(MAX_FRAME_BYTES + 16); + let (mut writer, mut reader) = tokio::io::duplex(MAX_REQUEST_FRAME_BYTES + 16); let expected = RuntimeIpcFrame::Request { request_id: 9, operation: RuntimeIpcOperation::Health, @@ -223,7 +223,7 @@ async fn framing_round_trips_health_and_rejects_oversized_lengths() { let (mut writer, mut reader) = tokio::io::duplex(8); writer - .write_u32((MAX_FRAME_BYTES + 1) as u32) + .write_u32((MAX_REQUEST_FRAME_BYTES + 1) as u32) .await .expect("write oversized length prefix"); let error = read_frame(&mut reader) @@ -231,3 +231,30 @@ async fn framing_round_trips_health_and_rejects_oversized_lengths() { .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 index 6cae625bc6..524125b300 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs @@ -2,7 +2,7 @@ use crate::{ read_frame, write_frame, DiscoveryStore, InitializeRequest, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcErrorCode, RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcTransportError, - MAX_FRAME_BYTES, PROTOCOL_VERSION, + MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, }; use std::time::Duration; use tempfile::tempdir; @@ -23,7 +23,8 @@ fn server_config() -> RuntimeIpcServerConfig { RuntimeIpcServerConfig { server_version: "0.2.14-test".to_string(), idle_timeout: Duration::from_millis(80), - io_timeout: Duration::from_secs(2), + handshake_timeout: Duration::from_secs(2), + request_timeout: Duration::from_secs(2), max_connections: 8, } } @@ -44,12 +45,13 @@ async fn authenticated_client_can_read_health_and_idle_server_cleans_discovery() ); let server_task = tokio::spawn(server.serve()); - let mut client = RuntimeIpcClient::connect( + 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"); @@ -66,22 +68,6 @@ async fn authenticated_client_can_read_health_and_idle_server_cleans_discovery() assert_eq!(store.read().expect("read cleaned discovery"), None); } -#[tokio::test] -async fn dropping_a_bound_server_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); - assert!(store.read().expect("read discovery").is_some()); - - drop(server); - - assert_eq!(store.read().expect("read cleaned discovery"), None); -} - #[tokio::test] async fn cancelling_the_server_task_cleans_its_discovery_record() { let runtime_root = tempdir().expect("runtime root"); @@ -123,6 +109,7 @@ async fn handshake_rejects_bad_token_wrong_instance_and_protocol_mismatch() { "foundation-test", "0.1.0", Duration::from_secs(2), + Duration::from_secs(2), ) .await .expect_err("bad token must fail"); @@ -168,6 +155,7 @@ async fn handshake_rejects_bad_token_wrong_instance_and_protocol_mismatch() { "foundation-test", "0.1.0", Duration::from_secs(2), + Duration::from_secs(2), ) .await, Err(RuntimeIpcClientError::Transport( @@ -184,6 +172,7 @@ async fn handshake_rejects_bad_token_wrong_instance_and_protocol_mismatch() { "foundation-test", "0.1.0", Duration::from_secs(2), + Duration::from_secs(2), ) .await, Err(RuntimeIpcClientError::IncompatibleProtocol { .. }) @@ -256,18 +245,19 @@ async fn malformed_client_is_isolated_from_later_health_clients() { .await .expect("connect malformed client"); malformed - .write_u32((MAX_FRAME_BYTES + 1) as u32) + .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 mut healthy = RuntimeIpcClient::connect( + 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"); @@ -308,18 +298,20 @@ async fn connection_limit_applies_before_authentication() { "bounded-client", "0.1.0", Duration::from_millis(50), + Duration::from_secs(2), ) .await, Err(RuntimeIpcClientError::Timeout) )); drop(blocker); - let mut client = RuntimeIpcClient::connect( + 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"); @@ -361,6 +353,7 @@ async fn non_utf8_runtime_root_supports_discovery_bind_and_health() { "non-utf8-test", "0.1.0", Duration::from_secs(2), + Duration::from_secs(2), ) .await .expect("initialize through lossless Unix endpoint"); 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 index 79a423a2e4..241c312f38 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -1,4 +1,11 @@ -use crate::{InitializeRequest, RuntimeIpcFrame, RuntimeIpcOperation, PROTOCOL_VERSION}; +use crate::{ + serialize_frame_with_limit, InitializeRequest, RuntimeIpcFrame, RuntimeIpcOperation, + RuntimeUserAnswersRequest, MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, +}; + +use bitfun_product_domains::tool_permissions::PermissionReply; +use bitfun_runtime_ports::{AgentDialogTurnRequest, AgentSubmissionSource, DialogSubmissionPolicy}; +use serde_json::{json, Map}; #[test] fn protocol_rejects_unknown_fields_and_operations() { @@ -25,14 +32,59 @@ fn initialize_debug_redacts_the_bearer_token() { 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 submit_turn_accepts_the_existing_64_kib_tui_paste_contract() { let frame = RuntimeIpcFrame::Request { - request_id: 7, - operation: RuntimeIpcOperation::Health, + 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()), + 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(), + }, + }, }; - let json = serde_json::to_string(&frame).expect("serialize Health frame"); - assert_eq!( - json, - r#"{"type":"request","request_id":7,"operation":{"operation":"health"}}"# - ); + + 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 new file mode 100644 index 0000000000..8dbba23c91 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -0,0 +1,771 @@ +use crate::{ + read_frame, write_frame, InitializeRequest, LocalIpcStream, RuntimeInstanceIdentity, + RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, + RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, + RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeSessionRestoreRequest, PROTOCOL_VERSION, +}; +use async_trait::async_trait; +use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; +use bitfun_runtime_ports::{ + AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy, 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, + submit_delay: Option, + 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, + submit_delay: None, + 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; + Ok(RuntimeIpcOperationResult::SessionCreated { + session: AgentSessionCreateResult { + session_id: "session-a".to_string(), + session_name: "Created session".to_string(), + agent_type: "agentic".to_string(), + }, + }) + } + 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; + } + match operation { + RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), + 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::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_millis(150), + ) + .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 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 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(); + assert!(matches!( + create_task.await.expect("create task"), + RuntimeIpcFrame::Response { + result: RuntimeIpcOperationResult::SessionCreated { .. }, + .. + } + )); + 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, + 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), + transcript: SessionTranscript { + session_id: session_id.to_string(), + messages: Vec::new(), + }, + pending_permissions: Vec::new(), + } +} + +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()), + 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 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 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; + + 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") + ) + }); + submitted == 1 && cancelled_first + }) + .await; + 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 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/services/services-core/src/process_manager.rs b/src/crates/services/services-core/src/process_manager.rs index 5f8d7d6ce4..b6b4207bad 100644 --- a/src/crates/services/services-core/src/process_manager.rs +++ b/src/crates/services/services-core/src/process_manager.rs @@ -57,9 +57,7 @@ impl ProcessManager { job.set_extended_limit_info(&info)?; // Assign current process to Job so child processes inherit automatically - if let Err(e) = job.assign_current_process() { - warn!("Failed to assign current process to job: {}", e); - } + job.assign_current_process()?; let mut job_guard = self.job.lock().map_err(|e| { std::io::Error::other(format!("Failed to lock process manager job mutex: {}", e)) @@ -165,3 +163,17 @@ fn build_macos_path_env() -> Option { pub fn cleanup_all_processes() { GLOBAL_PROCESS_MANAGER.cleanup_all(); } + +/// Keep descendants of a long-lived service in the process-wide Job. +pub fn contain_current_process_tree() -> std::io::Result<()> { + #[cfg(windows)] + if GLOBAL_PROCESS_MANAGER + .job + .lock() + .map_err(|error| std::io::Error::other(error.to_string()))? + .is_none() + { + return Err(std::io::Error::other("Windows process Job is unavailable")); + } + Ok(()) +}