diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md
index d7fb2c3eef..2c2c47ed4e 100644
--- a/docs/architecture/agent-runtime-deployment-design.md
+++ b/docs/architecture/agent-runtime-deployment-design.md
@@ -39,8 +39,9 @@ flowchart TB
| Embedded TUI/Headless CLI/Peer Host | Session、Turn、Permission 和事件订阅统一通过同一个 Rust Runtime SDK(当前 preview);CLI crate 只保留第一方 adapter 和各形态自己的展示/断流策略 |
| ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 |
| Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 |
+| Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 |
| 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 |
-| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制租约、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter |
+| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter |
| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded |
| Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 |
@@ -106,7 +107,7 @@ ownership 分成“产品决策”和“文件锁原语”两层;入口不再
```mermaid
flowchart TB
Entrypoints["Desktop · CLI · ACP · SDK Host · Server bootstrap"]
- Entrypoints --> Core["CoreRuntimeOwnership
deployment · product identity · process leases"]
+ Entrypoints --> Core["CoreRuntimeOwnership
deployment · product identity · process-held lock"]
Core --> Primitive["services-core::runtime_ownership
canonical key · RAII file lock"]
Primitive --> E["Embedded · shared lock"]
Primitive --> S["Shared · exclusive lock"]
@@ -119,21 +120,58 @@ flowchart TD
Read -->|"no · attach/mutate/turn"| Remote{"structured remote facts?"}
Remote -->|"yes"| RemoteHost["由目标 execution host 负责"]
Remote -->|"no"| Gate["Coordinator → CoreRuntimeOwnership"]
- Gate --> Lease["按 canonical workspace 保留进程期 lease"]
+ Gate --> Lock["按 canonical workspace 持有文件锁"]
```
| 场景 | 行为 | 原因 |
|---|---|---|
| 多个 Embedded 进程访问同一 workspace | 共享锁允许并存 | 保持单实例、CI 和隔离测试的既有成本模型 |
| Shared 与任一 Embedded 访问同一 workspace | 后启动者返回稳定错误码和启动建议 | 防止同一 workspace 同时存在两种 Runtime deployment |
-| Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并保留 lease | 不把窗口数、Session 数等同于 Runtime 进程数 |
+| Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并持有文件锁 | 不把窗口数、Session 数等同于 Runtime 进程数 |
| 只读 list/view | 不加锁 | ownership 只管理 Runtime deployment,不扩大成读取权限 |
| 已解析且带有效 `connection_id` 的 remote workspace | 本机不加锁 | 与 Session storage 的远端判据一致;`host` 提示本身不能绕过本地锁 |
| 当前只读 HTTP Server | 不创建 Core owner | 没有 Agent Runtime 就没有 ownership 可声明 |
-`CoreRuntimeOwnership` 只选择 deployment、产品 identity 并保留进程期 lease;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。
+`CoreRuntimeOwnership` 只选择 deployment、产品 identity 并在进程存活期间持有锁;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。
-### 4.2 私有本机 IPC
+### 4.2 Session 单写
+
+workspace 可以被多个 Embedded 进程同时打开,但持久化 Session 不能被多个进程同时写入。保护粒度是“实际 Session 存储位置 + Session ID”,不是窗口、TUI 实例或 workspace。
+
+```mermaid
+flowchart LR
+ subgraph W["同一 workspace"]
+ A["Session A"]
+ B["Session B"]
+ end
+
+ GUI["GUI 进程"] -->|"写入"| A
+ TUI["TUI 进程"] -->|"写入"| B
+ CLI["另一个 CLI 进程"] -.->|"写入 A:session_in_use"| A
+ View["任意入口的 list / view"] -.->|"只读"| A
+ View -.->|"只读"| B
+```
+
+BitFun Runtime Session 只有 `SessionManager` 决定何时开始和结束写入;底层持久化方法复用同一文件锁,不再实现第二套判断。Agent SDK、BitFun ACP adapter 和 Shared TUI 保留结构化的 `session_in_use` 分类;SDK Host 将其映射为可重试并建议 retry 的结构化 `action_required`。GUI、Embedded TUI 和 Headless CLI 当前只显示明确的冲突消息,尚未承诺结构化错误字段,自动化调用不能依赖该文案。Desktop 作为 ACP client 管理的外部 agent Session 不经过该 Runtime owner,不在本节的 Session 单写范围内。
+
+| 场景 | 行为 |
+|---|---|
+| 同一进程重复 restore 同一 Session | 返回已加载的 Session,不重复取得或释放写入权 |
+| 另一个进程打开同一存储位置中的同一 Session | 立即返回 `session_in_use`;不等待、不自动抢占 |
+| 多个进程打开同一 workspace 中的不同 Session | 允许,各 Session 独立写入 |
+| 多个进程更新同一 Session 列表索引 | 按存储位置串行更新共享索引,不影响不同 Session 文件并行写入 |
+| `.`、`..`、符号链接或 Windows 路径大小写指向同一存储位置 | 视为同一个 Session 存储位置 |
+| 相同 Session ID 位于不同存储位置 | 文件锁相互独立;同一 `SessionManager` 仍按 Session ID 保持唯一绑定,不能同时加载 |
+| Session 存储路径无法解析或错误地指向文件系统根目录 | 在发布内存状态前返回错误,不创建可写 Session |
+| create/restore 在发布到内存前失败、取消或超时 | 临时文件锁随操作释放;后续进程可以重试 |
+| save、cleanup 或 unload 失败 | 已加载 Session 继续持有写入权,避免另一个进程接手不完整状态 |
+| unload 或 delete 成功 | 释放写入权 |
+| 进程崩溃或被强制结束 | 操作系统释放文件锁;残留锁文件本身不代表 Session 仍在使用 |
+| Remote workspace | 在实际 Session 存储所在机器执行同一检查;控制端不得用本机路径替代 |
+
+该机制不增加后台进程、轮询、连接或常驻线程,也不改变 Shared TUI 的连接控制规则。临时 Session 不写入磁盘,因此不参与此检查。
+
+### 4.3 私有本机 IPC
```mermaid
sequenceDiagram
@@ -147,7 +185,7 @@ sequenceDiagram
alt valid
S-->>C: initialized(health + interactive_tui)
C->>S: create or restore Session
- S-->>C: controller lease + Session facts
+ S-->>C: Session control + Session facts
C->>S: submit/cancel Turn or answer Permission/UserInput
S-->>C: Session-filtered authoritative events
else invalid
@@ -177,7 +215,7 @@ sequenceDiagram
- 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task;
- 未知字段、未知 operation、错误身份和不兼容版本 fail closed;
- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session。
-- Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 租约;无法确认时租约保持隔离,直到 Runtime 进程退出。
+- Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。
- Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。
- Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。
- 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。
@@ -223,9 +261,9 @@ flowchart TB
| canonical workspace + product | 防止 Embedded 与 Shared 同时拥有同一工作区 Runtime |
| workspace + product + release channel + user + protocol | 定位兼容的本机 Shared instance |
| stable local endpoint + bearer token + owner id | endpoint 定位同一 instance;随机 token 认证本轮 server;owner id 防止旧实例误删新 discovery |
-| Session identity | 未来 Runtime 内的持久化和写入隔离;不由 IPC foundation 定义 |
+| 实际 Session 存储位置 + Session ID | 限制持久化 Session 的跨进程并发写入;不由 IPC 协议定义 |
-当前 Shared TUI 只有 controller,没有 observer 或 detached Query:一个 Client 关闭不会删除 Session;它会取消仍拥有的活动 Turn,只有取消得到确认才释放 Session 控制租约,否则该租约隔离到 Runtime 退出。最后一个 Client 关闭后,Runtime 进入 30 秒空闲期;期间重连可继续使用,超时后 Runtime 正常关闭。若未来增加后台任务、observer 或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。
+当前 Shared TUI 只有 controller,没有 observer 或 detached Query:一个 Client 关闭不会删除 Session;它会取消仍拥有的活动 Turn,只有取消得到确认才释放 Session 控制权,否则继续隔离该 Session,直到 Runtime 退出。最后一个 Client 关闭后,Runtime 进入 30 秒空闲期;期间重连可继续使用,超时后 Runtime 正常关闭。若未来增加后台任务、observer 或 Remote 引用,必须先扩展 Runtime-aware drain,不能把这些引用塞进当前简单连接计数。
对普通单实例用户,未显式启用 Shared deployment 时不增加后台进程、连接、发现扫描或常驻内存。
diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs
index fba5fcbf99..4c3ed98c11 100644
--- a/src/apps/cli/src/shared_runtime.rs
+++ b/src/apps/cli/src/shared_runtime.rs
@@ -2,7 +2,8 @@ use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use bitfun_agent_runtime::sdk::{
AgentRuntime, AgentSessionRestoreRequest, AgentUserAnswersRequest, DialogSubmitOutcome,
- PermissionRequest, PermissionRequestEvent, RuntimeError, SessionTranscriptRequest,
+ PermissionRequest, PermissionRequestEvent, PortErrorKind, RuntimeError,
+ SessionTranscriptRequest,
};
use bitfun_agent_runtime_ipc::{
DiscoveryStore, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcError,
@@ -914,8 +915,14 @@ fn runtime_error_message(error: RuntimeError) -> anyhow::Error {
}
fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError {
+ let code = match &error {
+ RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::SessionInUse => {
+ RuntimeIpcErrorCode::SessionInUse
+ }
+ _ => RuntimeIpcErrorCode::Unavailable,
+ };
RuntimeIpcError {
- code: RuntimeIpcErrorCode::Unavailable,
+ code,
message: error.into_message(),
}
}
@@ -925,12 +932,13 @@ mod tests {
use super::{
await_permission_route, connect_existing, index_user_question, invalidate_event_stream,
permission_event_session, permission_targets_session, project_subagent_link_route,
- project_user_question_route, publish_event, route_agent_event, subscribe_session_events,
- SessionEventSenders, EVENT_BUFFER,
+ project_user_question_route, publish_event, route_agent_event, runtime_ipc_error,
+ subscribe_session_events, SessionEventSenders, EVENT_BUFFER,
};
use bitfun_agent_runtime::sdk::{
PermissionDelegationContext, PermissionReplySource, PermissionRequest,
- PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind,
+ PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PortError,
+ PortErrorKind, RuntimeError,
};
use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity};
use std::collections::HashMap;
@@ -938,6 +946,19 @@ mod tests {
use std::time::Duration;
use tokio::sync::{watch, Notify};
+ #[test]
+ fn session_writer_conflict_reuses_the_existing_ipc_error() {
+ let error = runtime_ipc_error(RuntimeError::Port(PortError::new(
+ PortErrorKind::SessionInUse,
+ "Session is already open for writing: session-1",
+ )));
+
+ assert_eq!(
+ error.code,
+ bitfun_agent_runtime_ipc::RuntimeIpcErrorCode::SessionInUse
+ );
+ }
+
#[tokio::test]
async fn existing_runtime_connection_errors_are_not_hidden_as_absence() {
let root = tempfile::tempdir().unwrap();
diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs
index 30a0c11701..2b7d086270 100644
--- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs
+++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs
@@ -8930,6 +8930,10 @@ fn runtime_port_error_from_bitfun(error: BitFunError) -> bitfun_runtime_ports::P
(bitfun_runtime_ports::PortErrorKind::Cancelled, message)
}
BitFunError::Timeout(message) => (bitfun_runtime_ports::PortErrorKind::Timeout, message),
+ BitFunError::SessionInUse { session_id } => (
+ bitfun_runtime_ports::PortErrorKind::SessionInUse,
+ format!("Session is already open for writing: {session_id}"),
+ ),
BitFunError::NotImplemented(message) => {
(bitfun_runtime_ports::PortErrorKind::NotAvailable, message)
}
@@ -11778,7 +11782,7 @@ mod tests {
.session_storage_dir();
assert!(!session_manager
.persistence_manager()
- .session_storage_exists(&storage_path, &transient.session_id)
+ .session_storage_exists(&workspace_path, &transient.session_id)
.expect("persistence probe should succeed"));
let transient_child = session_manager
diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs
index 2ca87e7591..3adc9c5f79 100644
--- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs
+++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs
@@ -134,6 +134,10 @@ impl SchedulerSubmitError {
Self::Core(BitFunError::Timeout(message)) => {
PortError::new(PortErrorKind::Timeout, message)
}
+ Self::Core(BitFunError::SessionInUse { session_id }) => PortError::new(
+ PortErrorKind::SessionInUse,
+ format!("Session is already open for writing: {session_id}"),
+ ),
Self::Core(BitFunError::NotImplemented(message)) => {
PortError::new(PortErrorKind::NotAvailable, message)
}
@@ -2390,6 +2394,16 @@ mod tests {
use bitfun_runtime_ports::{AgentDialogPrependedReminder, AgentInputAttachment, PortErrorKind};
use tokio::sync::RwLock as TokioRwLock;
+ #[test]
+ fn scheduler_preserves_session_writer_conflicts() {
+ let error = SchedulerSubmitError::Core(BitFunError::SessionInUse {
+ session_id: "session-1".to_string(),
+ })
+ .into_port_error();
+
+ assert_eq!(error.kind, PortErrorKind::SessionInUse);
+ }
+
fn test_scheduler() -> (
Arc,
Arc,
diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs
index 1073da085e..2d79c6cb73 100644
--- a/src/crates/assembly/core/src/agentic/persistence/manager.rs
+++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs
@@ -36,7 +36,7 @@ use bitfun_services_core::{
build_session_metadata as build_persisted_session_metadata, empty_session_metadata_page,
refresh_session_metadata_from_turns, try_refresh_session_metadata_for_saved_turn,
SessionMemoryMode, SessionMetadataBuildFacts, SessionMetadataStore,
- SessionMetadataStoreError, SessionStorageLayout,
+ SessionMetadataStoreError, SessionStorageLayout, SessionWriteLock, SessionWriteLockError,
},
};
use futures::{stream, StreamExt};
@@ -66,6 +66,39 @@ static SESSION_PERSISTENCE_LOCKS: OnceLock
static SESSION_BRANCH_ALLOCATION_LOCKS: OnceLock>>>> =
OnceLock::new();
+struct PendingSessionDirectory {
+ path: PathBuf,
+ committed: bool,
+}
+
+impl PendingSessionDirectory {
+ fn new(path: PathBuf) -> Self {
+ Self {
+ path,
+ committed: false,
+ }
+ }
+
+ fn commit(mut self) {
+ self.committed = true;
+ }
+}
+
+impl Drop for PendingSessionDirectory {
+ fn drop(&mut self) {
+ if self.committed || !self.path.exists() {
+ return;
+ }
+ if let Err(error) = std::fs::remove_dir_all(&self.path) {
+ warn!(
+ "Failed to remove an unfinished Session directory: path={}, error={}",
+ self.path.display(),
+ error
+ );
+ }
+ }
+}
+
async fn memory_pollution_guard_enabled() -> bool {
match get_global_config_service().await {
Ok(service) => {
@@ -373,6 +406,40 @@ impl PersistenceManager {
self.path_manager.project_sessions_dir(workspace_path)
}
+ /// Hold this across a multi-step Session write that is not already owned by
+ /// a loaded Session runtime.
+ pub(crate) fn lock_session_writes(
+ &self,
+ workspace_path: &Path,
+ session_id: &str,
+ ) -> BitFunResult {
+ let sessions_dir = self.project_sessions_dir(workspace_path);
+ SessionWriteLock::try_acquire(&sessions_dir, session_id)
+ .map_err(|error| Self::session_write_lock_error(session_id, error))
+ }
+
+ pub(super) fn lock_session_write_operation(
+ &self,
+ workspace_path: &Path,
+ session_id: &str,
+ ) -> BitFunResult {
+ let sessions_dir = self.project_sessions_dir(workspace_path);
+ SessionWriteLock::try_acquire_for_operation(&sessions_dir, session_id)
+ .map_err(|error| Self::session_write_lock_error(session_id, error))
+ }
+
+ fn session_write_lock_error(session_id: &str, error: SessionWriteLockError) -> BitFunError {
+ match error {
+ SessionWriteLockError::InUse => BitFunError::SessionInUse {
+ session_id: session_id.to_string(),
+ },
+ other => BitFunError::Session(format!(
+ "Failed to protect Session writes: session_id={session_id}, code={}, error={other}",
+ other.code()
+ )),
+ }
+ }
+
pub(crate) fn is_resolved_sessions_dir(&self, path: &Path) -> bool {
CoreSessionStorePort::resolved_sessions_dir_kind(self.path_manager.as_ref(), path).is_some()
}
@@ -1002,6 +1069,8 @@ impl PersistenceManager {
workspace_path: &Path,
metadata: &SessionMetadata,
) -> BitFunResult<()> {
+ let _session_write =
+ self.lock_session_write_operation(workspace_path, &metadata.session_id)?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, &metadata.session_id)
.await;
@@ -1040,6 +1109,8 @@ impl PersistenceManager {
metadata: &SessionMetadata,
) -> BitFunResult {
Self::validate_session_id(&metadata.session_id)?;
+ let _session_write =
+ self.lock_session_write_operation(workspace_path, &metadata.session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, &metadata.session_id)
@@ -1086,6 +1157,7 @@ impl PersistenceManager {
update: impl FnOnce(&mut SessionMetadata) -> BitFunResult<()>,
) -> BitFunResult {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, session_id)
@@ -1120,6 +1192,7 @@ impl PersistenceManager {
mode: SessionMemoryMode,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, session_id)
.await;
@@ -1141,6 +1214,7 @@ impl PersistenceManager {
session_id: &str,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, session_id)
.await;
@@ -1235,6 +1309,7 @@ impl PersistenceManager {
cache: &SessionPromptCache,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
self.ensure_session_dir(workspace_path, session_id).await?;
@@ -1254,6 +1329,7 @@ impl PersistenceManager {
session_id: &str,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
match fs::remove_file(self.prompt_cache_path(workspace_path, session_id)).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
@@ -1285,6 +1361,7 @@ impl PersistenceManager {
anchors: &[TokenAnchor],
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
self.ensure_session_dir(workspace_path, session_id).await?;
@@ -1305,6 +1382,7 @@ impl PersistenceManager {
session_id: &str,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
match fs::remove_file(self.token_anchors_path(workspace_path, session_id)).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
@@ -1325,6 +1403,7 @@ impl PersistenceManager {
messages: &[Message],
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
self.ensure_snapshots_dir(workspace_path, session_id)
.await?;
@@ -1445,6 +1524,7 @@ impl PersistenceManager {
snapshot: &TurnSkillAgentSnapshot,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
self.ensure_snapshots_dir(workspace_path, session_id)
.await?;
@@ -1483,6 +1563,7 @@ impl PersistenceManager {
turn_index: usize,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
let dir = self.snapshots_dir(workspace_path, session_id);
if !dir.exists() {
return Ok(());
@@ -1524,6 +1605,7 @@ impl PersistenceManager {
snapshot: &TurnSkillAgentSnapshot,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
self.ensure_snapshots_dir(workspace_path, session_id)
.await?;
@@ -1560,6 +1642,7 @@ impl PersistenceManager {
turn_index: usize,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
let dir = self.snapshots_dir(workspace_path, session_id);
if !dir.exists() {
return Ok(());
@@ -1611,6 +1694,8 @@ impl PersistenceManager {
session: &Session,
) -> BitFunResult<()> {
Self::validate_session_id(&session.session_id)?;
+ let _session_write =
+ self.lock_session_write_operation(workspace_path, &session.session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
let sessions_dir = self.project_sessions_dir(workspace_path);
@@ -1644,6 +1729,10 @@ impl PersistenceManager {
)));
}
}
+ // Dropping an interrupted future removes the directory while the
+ // cross-process writer is still held. A stale index entry is repaired
+ // by the existing index validation on the next read.
+ let pending_session_dir = PendingSessionDirectory::new(session_dir);
if let Err(error) = self
.save_session_files_locked(workspace_path, session)
@@ -1664,15 +1753,19 @@ impl PersistenceManager {
cleanup_error: cleanup_error.to_string(),
});
}
+ pending_session_dir.commit();
return Err(error);
}
+ pending_session_dir.commit();
Ok(())
}
/// Save session
pub async fn save_session(&self, workspace_path: &Path, session: &Session) -> BitFunResult<()> {
Self::validate_session_id(&session.session_id)?;
+ let _session_write =
+ self.lock_session_write_operation(workspace_path, &session.session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, &session.session_id)
@@ -2041,6 +2134,7 @@ impl PersistenceManager {
state: &SessionState,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
self.ensure_runtime_for_write(workspace_path).await?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, session_id)
@@ -2074,6 +2168,7 @@ impl PersistenceManager {
session_id: &str,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, session_id)
.await;
@@ -2123,6 +2218,7 @@ impl PersistenceManager {
turn: &DialogTurnData,
) -> BitFunResult<()> {
Self::validate_session_id(&turn.session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, &turn.session_id)?;
let save_started_at = Instant::now();
self.ensure_runtime_for_write(workspace_path).await?;
let persistence_lock = self
@@ -2464,6 +2560,7 @@ impl PersistenceManager {
turn_index: usize,
) -> BitFunResult<()> {
Self::validate_session_id(session_id)?;
+ let _session_write = self.lock_session_write_operation(workspace_path, session_id)?;
let persistence_lock = self
.get_session_persistence_lock(workspace_path, session_id)
.await;
@@ -2541,6 +2638,7 @@ impl PersistenceManager {
trigger: &str,
) -> BitFunResult