From 6d1dbc9504558ea961c1667d64cbc9ea9ecfa39f Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Sun, 27 Sep 2026 15:29:24 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(mcp):=20System=20MCP=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=87=86=E5=85=A5=E4=B8=8E=20MCP=20over=20ACP=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=BA=A7=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v4 MCP 适配第一部分:MCP 从「按需连接的插件能力」变成受生命周期管辖的一等 资源接入面。 - System MCP 启动准入:新增启动闸门 hook `before_react_start`,transport / initialize / 协商 / 真实 tools/list 未完成,或必需工具缺失、超时、取消时 阻止本次 loop 进入后续阶段(该 Err 不降级);配置层三字段与纯校验在 serde 与项目/全局/插件四条来源都拒绝非法组合;消除 initialize 路径 4 处 `unwrap_or_default`,tools/list 失败不再伪装成 Connected + 空工具列表。 - 一等工具注入:`system_mcp_tools` 按所属 server 的原始工具名精确匹配后 all-or-nothing 提升 direct,经目录原子提交后进入首个 Reason 的 LLM tools; `[]` 只要求 ready 不注入;普通 MCP 工具仍走 ToolSearch deferred 路径。 - MCP over ACP:client 可声明 acp 型 server,由宿主在会话级接入(与 plugin / process 并列的第三种 transport),带 host-policy 与 isolation 契约测试。 - 测试:host seam 用真实子进程 + 真实 rmcp stdio + counting model 断言首个 LLM 请求入参、失败时模型调用 0 次。 - 文档:配置参考、代码索引、架构契约与测试标准同步,part-1 规划/子计划/ 验收记录入库。 顺带清理三处已失效的 `#[allow(dead_code)]`(`prepare_system_tools`、 `with_direct`、`original_tool_name` 均已有生产调用方)。part-2 规划文档不进 本分支,保留在 `feat/mcp-adaptation-v4-part-2`。 Co-Authored-By: deepseek-v4-flash --- docs/code-index/peri-acp-types.md | 6 +- docs/code-index/peri-acp.md | 6 +- docs/code-index/peri-agent.md | 5 +- docs/code-index/peri-middlewares.md | 22 +- docs/design/README.md | 1 + docs/design/mcp-adaptation-v4-part-1.md | 243 +++++ docs/reference/mcp-ecosystem.md | 56 +- docs/standards/architecture-contracts.md | 11 +- docs/standards/testing.md | 18 + peri-acp-types/src/acp_mcp.rs | 76 ++ peri-acp-types/src/lib.rs | 1 + peri-acp-types/src/plugin.rs | 482 +++++++++- peri-acp-types/src/ports.rs | 50 + peri-acp/CLAUDE.md | 2 + peri-acp/Cargo.toml | 5 +- peri-acp/src/dispatch/init.rs | 13 +- peri-acp/src/host/assemble.rs | 9 + peri-acp/src/host/executor_flow_test.rs | 22 +- peri-acp/src/host/mcp_v4_startup_test.rs | 819 ++++++++++++++++ peri-acp/src/host/mod.rs | 10 + peri-acp/src/host/prompt_test.rs | 34 + peri-acp/src/host/requests.rs | 1 + peri-acp/src/host/requests/acp_mcp.rs | 225 +++++ .../src/host/requests/acp_mcp_loop_test.rs | 266 ++++++ peri-acp/src/host/requests/acp_mcp_test.rs | 243 +++++ .../src/host/requests/session_lifecycle.rs | 1 + peri-acp/src/host/requests_test.rs | 4 + peri-acp/src/host/server_loop.rs | 117 ++- .../host/stdio/run_server_integration_test.rs | 1 + peri-acp/src/host/task_scope.rs | 2 + peri-acp/src/host/workspace.rs | 7 + peri-agent/CLAUDE.md | 1 + .../src/agent/stages/middleware_runner.rs | 80 ++ .../agent/stages/middleware_runner_test.rs | 401 ++++++++ peri-agent/src/agent/stages/mod.rs | 17 + peri-agent/src/agent/stages/stages_test.rs | 483 ++++++++++ peri-agent/src/middleware/capabilities.rs | 34 +- peri-agent/src/middleware/chain.rs | 15 + peri-agent/src/middleware/trait.rs | 16 + .../src/session/exec/stage_builder/tools.rs | 43 +- .../session/exec/stage_builder/tools_test.rs | 144 +++ peri-agent/src/session/tool_catalog.rs | 261 +++++- peri-agent/src/session/tool_catalog_test.rs | 486 ++++++++++ peri-middlewares/CLAUDE.md | 5 + peri-middlewares/src/assembly/mcp.rs | 1 + peri-middlewares/src/assembly/preparation.rs | 26 +- peri-middlewares/src/mcp/acp/mod.rs | 19 + peri-middlewares/src/mcp/acp/session.rs | 546 +++++++++++ peri-middlewares/src/mcp/acp/session_test.rs | 480 ++++++++++ peri-middlewares/src/mcp/acp/transport.rs | 442 +++++++++ peri-middlewares/src/mcp/client.rs | 50 + peri-middlewares/src/mcp/client/lifecycle.rs | 108 ++- peri-middlewares/src/mcp/client/readiness.rs | 629 +++++++++++++ .../src/mcp/client/readiness_test.rs | 756 +++++++++++++++ peri-middlewares/src/mcp/client/status.rs | 24 + peri-middlewares/src/mcp/client_oauth.rs | 33 +- peri-middlewares/src/mcp/client_test.rs | 6 + peri-middlewares/src/mcp/config.rs | 208 ++++- peri-middlewares/src/mcp/config_test.rs | 597 +++++++++++- peri-middlewares/src/mcp/discover_tool.rs | 30 +- peri-middlewares/src/mcp/dynamic/registry.rs | 65 +- .../src/mcp/dynamic/registry/capability.rs | 1 + .../src/mcp/dynamic/registry_test.rs | 155 +++- peri-middlewares/src/mcp/dynamic/tool_test.rs | 81 +- peri-middlewares/src/mcp/initialize.rs | 393 ++++---- peri-middlewares/src/mcp/initialize_test.rs | 632 +++++++++++++ peri-middlewares/src/mcp/mcp_v4_seam_test.rs | 652 +++++++++++++ peri-middlewares/src/mcp/middleware.rs | 351 ++++++- peri-middlewares/src/mcp/middleware_test.rs | 795 +++++++++++++++- peri-middlewares/src/mcp/mod.rs | 9 + peri-middlewares/src/mcp/reconnect.rs | 49 +- .../src/mcp/resource_cache_test.rs | 6 + peri-middlewares/src/mcp/resource_tool.rs | 11 +- peri-middlewares/src/mcp/system_tools.rs | 386 ++++++++ peri-middlewares/src/mcp/system_tools_test.rs | 670 ++++++++++++++ peri-middlewares/src/mcp/task_scope.rs | 7 + peri-middlewares/src/mcp/tool_bridge.rs | 166 +++- peri-middlewares/src/mcp/transport.rs | 7 + peri-middlewares/src/mcp/transport_test.rs | 35 + peri-middlewares/src/plugin/loader.rs | 290 ++++-- peri-middlewares/src/plugin/loader_test.rs | 268 +++++- .../tests/mcp_host_policy_contract.rs | 874 ++++++++++++++++++ .../tests/mcp_isolation_contract.rs | 541 +++++++++++ ...-25-mcp-adaptation-v4-part-1-acceptance.md | 152 +++ ...026-09-25-mcp-adaptation-v4-part-1-plan.md | 356 +++++++ ...-adaptation-v4-part-1-sub-plan-a-config.md | 263 ++++++ ...aptation-v4-part-1-sub-plan-b-readiness.md | 398 ++++++++ ...aptation-v4-part-1-sub-plan-c-injection.md | 248 +++++ ...ation-v4-part-1-sub-plan-d-verification.md | 175 ++++ 89 files changed, 16240 insertions(+), 495 deletions(-) create mode 100644 docs/design/mcp-adaptation-v4-part-1.md create mode 100644 peri-acp-types/src/acp_mcp.rs create mode 100644 peri-acp/src/host/mcp_v4_startup_test.rs create mode 100644 peri-acp/src/host/requests/acp_mcp.rs create mode 100644 peri-acp/src/host/requests/acp_mcp_loop_test.rs create mode 100644 peri-acp/src/host/requests/acp_mcp_test.rs create mode 100644 peri-agent/src/session/exec/stage_builder/tools_test.rs create mode 100644 peri-middlewares/src/mcp/acp/mod.rs create mode 100644 peri-middlewares/src/mcp/acp/session.rs create mode 100644 peri-middlewares/src/mcp/acp/session_test.rs create mode 100644 peri-middlewares/src/mcp/acp/transport.rs create mode 100644 peri-middlewares/src/mcp/client/readiness.rs create mode 100644 peri-middlewares/src/mcp/client/readiness_test.rs create mode 100644 peri-middlewares/src/mcp/mcp_v4_seam_test.rs create mode 100644 peri-middlewares/src/mcp/system_tools.rs create mode 100644 peri-middlewares/src/mcp/system_tools_test.rs create mode 100644 peri-middlewares/tests/mcp_host_policy_contract.rs create mode 100644 peri-middlewares/tests/mcp_isolation_contract.rs create mode 100644 spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md create mode 100644 spec/issues/2026-09-25-mcp-adaptation-v4-part-1-plan.md create mode 100644 spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-a-config.md create mode 100644 spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-b-readiness.md create mode 100644 spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-c-injection.md create mode 100644 spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-d-verification.md diff --git a/docs/code-index/peri-acp-types.md b/docs/code-index/peri-acp-types.md index a1fc0e85e..aad6e8691 100644 --- a/docs/code-index/peri-acp-types.md +++ b/docs/code-index/peri-acp-types.md @@ -1,6 +1,6 @@ # peri-acp-types 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-12(模块职责拆分与 compact/历史恢复修复合并) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-26(MCP over ACP 契约(`acp_mcp.rs` + 两个端口);`McpServerConfig` 的 system_mcp 字段与三变体校验;模块职责拆分与 compact/历史恢复修复合并) > 依据:peri-acp-types/src/lib.rs、docs/standards/architecture-contracts.md、源码(本 crate 无 CLAUDE.md) ## 架构速览 @@ -110,9 +110,11 @@ | 功能 | 入口/关键点 | | --- | --- | +| MCP server 配置契约 | `plugin.rs`(`McpServerConfig` :46,`system_mcp` / `system_mcp_tools` / `system_mcp_timeout` 三字段与 camelCase 别名;`validate` :220;`McpServerConfigValidationError` :241;`DEFAULT_SYSTEM_MCP_TIMEOUT_MS` :209 / `MIN` :211 / `MAX` :213)——`Deserialize` 为手写实现(`McpServerConfigWire`),解析期即拒绝非法组合与显式 `null`;消费方(`peri-middlewares/src/mcp/config.rs` 的 direct/global/merged 入口、`plugin/loader.rs` 的 MCP 严格路径、`mcp/transport.rs`)各自复检 `validate`;`None` 与 `Some([])` 必须可区分并无损写回 | | 线程/存储 | `thread/types.rs`(`CancelPolicy` :17、`AgentStatus` :56、`ThreadMeta` :126);`store.rs`(ThreadStore/CompactionLifecycle/MessageFlags) | | 冻结数据 | `frozen.rs`(`FrozenData` :26、`ThreadPersistence` :39)——会话创建时冻结,SubAgent 复用 | -| 运行端口 | `runtime.rs`(`RuntimePort`,`cancel` :85);`ports.rs`(McpPoolPort/ToolSearchPort/WorkflowMiddlewarePort/SkillsPort) | +| 运行端口 | `runtime.rs`(`RuntimePort`,`cancel` :85);`ports.rs`(McpPoolPort/ToolSearchPort/WorkflowMiddlewarePort/SkillsPort/AcpMcpServerPort/AcpMcpGatewayPort) | +| MCP over ACP 契约 | `acp_mcp.rs`(`AcpMcpServerSpec`、`AcpMcpInbound`、`AcpMcpError`:`CODE_NOT_FOUND` / `CODE_UNAVAILABLE`);`ports.rs`(`AcpMcpGatewayPort` 出站切片、`AcpMcpServerPort`:`attach` / `request` / `notify` / `owns_connection` / `close_session`)——只描述协议载荷与运行时标识,不依赖 rmcp 或具体 transport;实现在 `peri-middlewares/src/mcp/acp/`,host 接线在 `peri-acp/src/host/requests/acp_mcp.rs`(契约 ARC-MCP-ACP-001) | | 其他 | `interaction.rs`(HITL)、`goal.rs`、`tasks.rs`、`cron.rs`、`workflow.rs`、`hooks.rs`、`plugin.rs`、`skills.rs`、`mcp.rs`/`mcp_skills.rs`、`lsp.rs`、`meta_harness.rs`、`peri_caps.rs`(`PeriCaps` re-export lib.rs:57)、`projection.rs`、`permission.rs`、`agents.rs`、`error.rs`(`AgentError`)、`summary.rs`/`event_data.rs`(TUI 消费 DTO) | ## 跨模块契约(指向 architecture-contracts.md,不复制正文) diff --git a/docs/code-index/peri-acp.md b/docs/code-index/peri-acp.md index 9b2369b7e..debedae92 100644 --- a/docs/code-index/peri-acp.md +++ b/docs/code-index/peri-acp.md @@ -1,6 +1,6 @@ # peri-acp 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-12(模块职责拆分与 compact/历史恢复修复合并) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-26(MCP over ACP 宿主接线:setup 声明受理、出站网关与入站路由;System MCP 启动准入 host seam 回归入口;模块职责拆分与 compact/历史恢复修复合并) > 依据:peri-acp/CLAUDE.md、docs/standards/architecture-contracts.md、docs/design/peri-acp-protocol.md、源码 ## 架构速览 @@ -113,11 +113,12 @@ | 功能 | 文件 | 入口/关键点 | | --- | --- | --- | | 宿主所有权与服务入口 | host/mod.rs | `run_acp_server` / `run_acp_server_inner` 持有 deployment owner;`SessionState` 保持 frozen/agent_pool/continuation/lease 状态 | -| 消息循环与请求分类 | host/server_loop.rs | `ServerLoop::run`;`spawn_prompt` / `spawn_mcp_apps_request` 经 task owner 准入;`dispatch_request` 保持 response → after_new_response | +| 消息循环与请求分类 | host/server_loop.rs | `ServerLoop::run`;`spawn_prompt` / `spawn_mcp_apps_request` / `spawn_acp_mcp_request` 经 task owner 准入;`dispatch_request` 保持 response → after_new_response → 会话 setup 声明受理(`session_setup` + `requests::acp_mcp::attach_session_servers`);`mcp/message` 通知在 `dispatch_notification` 内就地路由 | | Prompt 编排 / 预测 | host/prompt_dispatch.rs + host/prediction.rs | `dispatch_prompt_turn` 保留 host 根 re-export;`spawn_prediction` 在原 prompt lock 范围内准入 | | OAuth 事件投递 | host/oauth_delivery.rs | `spawn_oauth_consumer` / `deliver_oauth_event`;safe 与 legacy caps 分别裁决 | | EOF 收尾 | host/shutdown.rs | `shutdown_host` 借用唯一强 owner;先撤销准入,再取消并 drain 会话,最后关闭 LSP/MCP | | 方法注册面(mpsc) | host/requests.rs + host/requests/*.rs | `handle_request`(requests.rs:22,30 个方法分派到子模块;各 handle_* 均为 `pub(super)` 定义在对应子文件) | +| MCP over ACP(client 声明的 `type: "acp"` server) | host/requests/acp_mcp.rs + host/server_loop.rs + host/workspace.rs | `attach_session_servers`(setup 响应后受理 `mcpServers`);`AcpTransportGateway`(`AcpMcpGatewayPort` 实现:`mcp/connect` / `mcp/message` / `mcp/disconnect` 出站);`route_inbound`(按 `connectionId` 定位承载会话服务);`SessionEnvironment::shutdown` 调 `AcpMcpServerPort::close_session` | 会话 setup 各自解析、会话级服务持有连接;建连在后台(不阻塞会话建立,失败留在 MCP 池状态面),内层 MCP 错误码原样透传;契约 ARC-MCP-ACP-001 | | notification 处理 | host/notify.rs | `handle_notification`(:28)/`extract_session_id`(:153);`host/unify_wire_baseline_test.rs` 锁定发射面 payload 与 schema typed `SessionNotification` 的逐字段一致性;统一 host 入口见 ARC-STDIO-001 与 `docs/design/architecture.md` | | prompt 执行编排 | host/prompt.rs | `run_prompt` 借用既有 AcpServerConfig 与当轮参数;`take_recall_for_turn`;保留 session 快照、Controller 执行及 canonical 结果回写顺序 | | prompt 模型工厂 | host/prompt/models.rs | `build_model_factories`;闭包复用当轮 provider/config 快照与同一 session AgentPool,缓存按 provider fingerprint 校验 | @@ -153,3 +154,4 @@ - ARC-SECRET-001:日志/错误/遥测不得泄露 secret(provider api_key 仅在 LlmProvider 内部持有) - 部署关闭回归:`host/stdio/langfuse_shutdown_test.rs` 的真实尾部事件、waiter 取消、共享会话、MCP/session Incomplete 重试与 HTTP 失败终态;`transport/mpsc_test.rs::test_explicit_close_rejects_both_pending_directions_and_delivers_eof` 证明显式 close 结算双向 pending 并让两端 EOF。 +- System MCP 启动准入回归(B-07 宿主 seam,契约 2/3/4):`host/mcp_v4_startup_test.rs`(模块 `host::mcp_v4_startup_tests`,`host/mod.rs:52-54` 挂载;真实子进程 rmcp stdio + counting model + 临时 HOME,用例 `#[serial]` 且 `#[cfg(not(windows))]`)——命令 `cargo test -p peri-acp --lib -- host::mcp_v4_startup_tests`。它不覆盖 crate 内 seam(归 `peri-middlewares` 的 `mcp::mcp_v4_seam`)与工具调用策略(归 `mcp_host_policy_contract`)。 diff --git a/docs/code-index/peri-agent.md b/docs/code-index/peri-agent.md index a36540439..eac7f722d 100644 --- a/docs/code-index/peri-agent.md +++ b/docs/code-index/peri-agent.md @@ -1,6 +1,6 @@ # peri-agent 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-24(Bash 同步执行有界化,前台/后台超时解析分离) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-26(启动闸门 hook `before_react_start` 与 System MCP 工具 static base 提交;Bash 同步执行有界化) > 依据:peri-agent/CLAUDE.md、docs/standards/architecture-contracts.md、源码 ## 架构速览 @@ -31,7 +31,7 @@ | 改 Goal 自动接续与状态事件 | `peri-middlewares/src/{goal_middleware,completion_reminder}.rs` + `src/middleware/capabilities.rs` + `src/agent/agent_context.rs` + `src/session/exec/stage_builder.rs` | `CompletionReminder::admit/enqueue`;`BackgroundActivity::has_active_background_tasks`;`GoalController::increment_continuation`;`emit_goal_snapshot` | active Goal 经与 todo 共用的准入后才增长主动接续/紧迫感;已有 `block_continue` 优先,后台活动复用 Receive 的 `idle_should_wait → TaskManager::active_count`(Agent/Workflow/Shell 的 Running/Completing)。`build_stage_context` 在装配前统一补齐缺省 manager,工具、SubagentHost、probe 与 registry wake 必须同源;装配回归见 `peri-middlewares/src/assembly_test.rs::test_stage_completion_reminders_share_assembled_task_manager`。Workflow 在 `WorkflowTool::start_run` 注册,`session/workflow_completion.rs::apply_workflow_task_result` 先送完成 Defer 再提交 task 终态;终态后恢复提醒。Act 每轮结束(含错误返回)发 session Goal 快照,经 ACP 单路径投影;回归见两类 middleware 测试及 `middleware/capabilities_test.rs`、`session/workflow_completion_test.rs` | | 改循环退出 / keepgoing 判定 | `src/session/exec/executor.rs` + `src/agent/stages/mod.rs`(Receive 分支) | `executor.rs:130 is_keepgoing(&MessageContent)`;`run_session_loop`(executor.rs:221);`run_react_loop` 正常退出判断(stages/mod.rs:647 `consumed_count == 0 && !has_tool_calls`);判空底层 `peri-acp-types/src/messages/content.rs::is_empty`(:399) | 空字符串 / 空 blocks / 空 raw 内容须用 `MessageContent::is_empty()` 判空且禁止 trim 替代(纯空白字符串不算空);空历史 + 空内容 prompt 时短路 `push_done`;keepgoing 不注入 recall;cancel 与 stage error/interruption 可在 Receive 正常退出点之外终止;契约 ARC-KEEPGOING-001 | | 改 turn fatal failure 分类/传递 | `src/session/exec/executor_helpers/v2_execute.rs` + `executor_helpers.rs` + `executor_helpers/collect.rs`;契约 DTO 在 `peri-acp-types/src/session.rs` | `classify_loop_terminal`;`internal_failure_terminal`;`ExecutionFailure::from_agent_error`;`ExecOutcome.failure` → `PromptResult.failure` | transcript flush 后只采样一次 cancel;forwarder JoinError 保留到 Phase 9,在提取 transcript/recall/compaction 后覆盖为 Internal terminal;单一终态同时决定 Prompt stop reason、`TurnEnded`、fatal failure 与 cascade;LLM/provider failure 保留脱敏限长原意和可选 HTTP status,其他内部错误使用安全文案;Completed 为已提交成功,其他非成功结果中 cancel 优先;契约 ARC-EVENT-001 / ARC-CANCEL-001 | -| 加工具(direct/deferred) | trait 事实源 `peri-acp-types/src/tools.rs`;注册面 = middleware 的 `collect_tools()`;组装 `src/session/exec/stage_builder/tools.rs::build_session_tool_view` | `BaseTool::is_direct()`(默认 **false** = deferred);Reason publication 在 `src/agent/stages/reason.rs`,专用 hook runner 在 `middleware_runner.rs::run_before_reason_catalog`;Dynamic MCP projection holder 由 `StageBuildInput::dynamic_mcp_projection` 从 session owner 透传 | 每 turn 先应用 middleware disabled 与 agent allow/disallow filter 构造 session-local 视图;动态 refresh 后按 working map swap → `before_reason_catalog` → `before_model` → pin 发布,ToolSearch 在专用 hook 内重绑 Search index 与 Execute resolver;Discover/resource 的 projection lease 跨 stage build 复用并由 session close 释放;不得使用静态核心白名单或等待下一 turn;契约 ARC-TOOLS-001 | +| 加工具(direct/deferred) | trait 事实源 `peri-acp-types/src/tools.rs`;注册面 = middleware 的 `collect_tools()`;组装 `src/session/exec/stage_builder/tools.rs::build_session_tool_view` | `BaseTool::is_direct()`(默认 **false** = deferred);Reason publication 在 `src/agent/stages/reason.rs`,专用 hook runner 在 `middleware_runner.rs::run_before_reason_catalog`;Dynamic MCP projection holder 由 `StageBuildInput::dynamic_mcp_projection` 从 session owner 透传;启动期候选经 `middleware_runner.rs::run_before_react_start`(:84)→ `SessionToolCatalog::replace_static_mcp_tools`(`session/tool_catalog.rs:260`)替换 static base | 每 turn 先应用 middleware disabled 与 agent allow/disallow filter 构造 session-local 视图;动态 refresh 后按 working map swap → `before_reason_catalog` → `before_model` → pin 发布,ToolSearch 在专用 hook 内重绑 Search index 与 Execute resolver;Discover/resource 的 projection lease 跨 stage build 复用并由 session close 释放;startup 提交只更新 static base,不替代 Reason boundary、不混入 dynamic overlay;不得使用静态核心白名单或等待下一 turn;契约 ARC-TOOLS-001 | | 改 PTC effective-target dispatch | `src/agent/stages/tool_dispatch/{effective_dispatcher,execution}.rs` + `peri-acp-types/src/tools.rs` | `StageEffectiveToolDispatcher::dispatch` / `dispatch_output`;`collect_tool_results` | canonical `RunPtcCode` 是 deferred-only,经 `SearchExtraTools → ExecuteExtraTool` 进入执行;从当前 pinned catalog canonical resolve,policy/HITL/event/tool card 投影 effective target,并复用 timeout/cancel;typed execution evidence 经 canonical direct/deferred wrapper 透传;嵌套调用不写 transcript 或重复执行外层 batch hook/失败计数;模型 assistant raw wrapper call 仅保留协议配对;direct tools 不受影响;PTC JavaScript tools API 仍为 string projection;旧 `run_code` 仅作搜索迁移关键词,不可执行 | | 改 cancel 链路 | `src/agent/stages/mod.rs` + `src/session/exec/executor_helpers/v2_execute.rs` + `peri-acp-types/src/session.rs` | `run_stage`(stage-local `AgentError::Interrupted` 规范化);`build_and_execute_agent_v2` / `classify_loop_terminal`;`cancel_cascade_agents` / `cancel_all_agents`;`CancelRequest` 在 `peri-acp-types/src/identity.rs` | stage 仍成对发射 `StageEnded(Error)`,loop 终态统一为 Interrupted;按 (session_id, turn_id, attempt_id) 三元组定位;幂等判定与终态归 Agent 层;clear_queue 默认 false;契约 ARC-CANCEL-001 | | /compact 命令路径 | `src/session/exec/compact_pipeline.rs` | `run_compact(force=true)` → Full + re-inject | 编排:validate_inputs → resolve_auxiliary_model → run_v2_compact_with_cancel → assemble_compact_messages;取消返回 Cancelled | @@ -54,6 +54,7 @@ | 共享调用执行 | stages/tool_dispatch/execution.rs | `collect_tool_results`(:51);审批/并发/结算,参数复用 `tools::normalize_params` | | PTC 有效调用适配 | stages/tool_dispatch/effective_dispatcher.rs | `StageEffectiveToolDispatcher::dispatch`(:34);同一 pinned catalog,内层事件 ID 关联外层调用 | | 阶段中间件 runner | stages/middleware_runner.rs + agent_context.rs | `run_before_agent` 结束后(含 Err)drain recall;它与后续批次的 `run_before_input` 均将稳定 ID replacement reconcile;`run_before_model`/`run_after_model` 保留追加消息双写路径 | +| 启动闸门(首个 Reason 前的准入) | stages/mod.rs + stages/middleware_runner.rs | `run_before_react_start`(middleware_runner.rs:84;`run_react_loop` 首批 `before_agent`/`before_input` 之后、Compact 之前只执行一次);`StartupGateState` 只持本次候选,Err 不降级——`Interrupted` → `LoopResult::Interrupted`,其它 Err → `LoopResult::Error`;既有 `before_agent` 的 warn 软失败语义不变 | ### Compact v2(src/agent/compact_v2/) diff --git a/docs/code-index/peri-middlewares.md b/docs/code-index/peri-middlewares.md index 5d66a2855..0b5572f46 100644 --- a/docs/code-index/peri-middlewares.md +++ b/docs/code-index/peri-middlewares.md @@ -1,6 +1,6 @@ # peri-middlewares 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-25(Bash 同步期限覆盖 nohup 后代管道排空;multitask 下沉为内置技能) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-26(MCP over ACP 会话级连接与池归属过滤;System MCP 启动准入与一等工具注入;Bash 同步期限覆盖 nohup 后代管道排空;multitask 下沉为内置技能) > 依据:peri-middlewares/CLAUDE.md、docs/standards/architecture-contracts.md、docs/design/{mcp-multiplexing,middleware-system,workflow}.md、docs/reference/mcp-ecosystem.md、源码 ## 架构速览 @@ -23,7 +23,8 @@ | 改后台 Bash 清理提示 | `src/middleware/terminal.rs` + `descriptions/bash.md` | `background_cleanup_hint` | 显式后台、超时提升和残留后代共用平台提示:Linux/macOS 暴露 PGID 并停止整组,Windows 使用 taskkill /T;要求核验终态,不改变执行生命周期 | | 改 Bash 同步超时语义 | `src/middleware/terminal.rs` + `descriptions/bash.md` + `peri-agent/src/agent/async_tasks/shell.rs` | `parse_foreground_timeout` / `parse_background_timeout`、`FOREGROUND_DEFAULT_TIMEOUT_MS` / `FOREGROUND_MAX_TIMEOUT_MS` / `BACKGROUND_MAX_TIMEOUT_MS`、`BashTool::invoke_output` | 同步路径恒有界:默认 15000ms、硬上限 120000ms,`timeout: 0` 与超上限请求都界到上限(同步路径不存在禁用超时的表达);shell wait 与 stdout/stderr 排空共享期限,`nohup ... &` 后代继承管道时也不能无限等 EOF(回归 `terminal_test.rs::test_nohup_*`);到达上限不杀进程,提升(promotion)为后台任务并回传 task_id / pid / 实时日志路径;后台路径未传或 `timeout: 0` 表示不超时,显式正超时按平台下限(Unix 1ms / Windows 5000ms)与 600000ms 上限 clamp | | 改 Bash 超时提升后的输出交付 | `src/middleware/terminal.rs` + `peri-agent/src/agent/async_tasks/shell_output.rs` | `BashTool::invoke_output`、`ShellOutputCapture`、`tee_pipe_with_output` | 从原始 stdout/stderr 持续落盘,promotion 接管相同读任务、join 状态和文件;前台已回收 shell 但后代持有管道时,后台完成退出码保持未知;内存预览有界,最终通过 typed 文件引用交付;真实 CLI 回归见 `peri-tui/tests/print_background_exit.rs` | -| 改 hook 能力与上下文 | `peri-agent/src/middleware/{capabilities,trait}.rs` + 各 middleware `impl Middleware` | `BeforeAgentState` / `BeforeInputState` / `BeforeToolState` / `AfterToolState` / `AfterAgentState` / `BeforeModelState` / `CatalogState` / `StateView` | 输入替换只允许 before_agent / before_input;只读视图不泄漏 queue/catalog,MCP 通知/Goal/Stop/GitWatch 使用队列能力,ToolSearch 初始与 Reason 重绑使用 catalog/recall;能力适配不改变装配顺序或工具可见性 | +| 改 hook 能力与上下文 | `peri-agent/src/middleware/{capabilities,trait}.rs` + 各 middleware `impl Middleware` | `BeforeAgentState` / `BeforeInputState` / `BeforeToolState` / `AfterToolState` / `AfterAgentState` / `BeforeModelState` / `CatalogState` / `StartupState` / `StateView` | 输入替换只允许 before_agent / before_input;只读视图不泄漏 queue/catalog,MCP 通知/Goal/Stop/GitWatch 使用队列能力,ToolSearch 初始与 Reason 重绑使用 catalog/recall;`StartupState` 只提供候选工具的暂存/取出,不暴露可写 transcript/queue;能力适配不改变装配顺序或工具可见性 | +| 改 System MCP 启动准入与一等工具注入 | `src/mcp/{middleware,client/readiness,system_tools}.rs` + `src/mcp/tool_bridge.rs` + `peri-agent/src/{middleware/trait,agent/stages/middleware_runner}.rs` | `McpMiddleware::before_react_start`(middleware.rs:703)/ `await_system_ready`(:363);`await_system_connections`(client/readiness.rs:385);`prepare_system_tools`(system_tools.rs:62);`run_before_react_start`(middleware_runner.rs:84) | `system_mcp = true` 的 server 在首个 Reason 前必须完成 transport / initialize / 能力协商 / 真实 `tools/list`;失败或 timeout 返回类型化错误、不发布 ready、不进入 Compact/Reason/Act(取消仍映射 `Interrupted`);`system_mcp_tools` 按所属 server 原始工具名精确匹配后 all-or-nothing 提升 direct(缺省或 `[]` 只要求 ready),候选经 `StartupState` 提交、成功才原子替换 catalog static base;回归 `mcp::middleware`、`mcp::client::readiness`、`mcp::system_tools`、`mcp::mcp_v4_seam`、`tests/mcp_host_policy_contract.rs` | | 改中间件链序 | 蓝本 `peri-agent/src/session/factory.rs`(`ChainSlot` :27、`production_blueprint` :95、`build_middleware_chain` :153);装配 `peri-middlewares/src/assembly.rs` | `ProductionChainAssembler::assemble`(按 `blueprint: &[ChainSlot]` 唯一逐槽位 match,具体构造调用私有模块) | 顺序 = 行为契约禁止重排;增删/重排必须先以 `production_blueprint()` 的完整槽位序列与装配实现为准(ARC-MIDDLEWARE-001);`MiddlewareChainAssembler` trait 在 factory.rs:139 | | 改条件注册 / 关闭面 | `src/assembly.rs` + `src/assembly/{preparation,mcp,lsp,hooks}.rs` | 根 `disabled.contains(名)` 在构造前过滤;`build_parent_tools` 过滤继承工具;复杂槽位分别由 `add_mcp` / `add_lsp` / `add_hooks` 构造 | 蓝本只定槽位顺序;根逐槽位调用,Hook 每非空 group 展开一个实例;workflow agent 独立装配在 `assembly/workflow.rs` | | 加新工具(direct/deferred) | trait 事实源 `peri-acp-types/src/tools.rs`;注册面 = 各 middleware `collect_tools()`(例 `src/middleware/filesystem.rs:39`);LLM 可见面过滤在 Agent 层 `peri-agent/src/agent/stages/reason.rs:138` | `BaseTool::is_direct()`(默认 false = deferred);包装层 `src/tools/mod.rs`(ArcToolWrapper :44 / BoxToolWrapper :50) | `is_direct()=true` 直接进 LLM tools;false 经 `SearchExtraTools` 发现 + `ExecuteExtraTool` 代理执行;direct 集合同时是 tool_search 声明段数据源;包装层须透传 is_direct;契约 ARC-TOOLS-001、ARC-SERIAL-001 | @@ -34,6 +35,7 @@ | 改 ToolSearch 中间件注入 | `src/tool_search/middleware.rs` + `peri-agent/src/agent/stages/reason.rs` + `peri-agent/src/agent/model_bridge.rs` | `ToolSearchMiddleware::rebind_catalog` / `before_agent` / `before_reason_catalog` / `prompt_contribution`;`run_reason`;`AgentModelBridge::build_request` | 优先读 v2 每 turn 本地工具视图(`state.local_tools()`),无则回退 shared_tools(v1/测试路径);Reason refresh 后经专用 hook 幂等重绑 Search/Execute,随后 before_model、pin 与 ModelRequest 读取同一 contribution;不重跑全部 before_agent,fresh stage 不复用旧 middleware cache | | 改 artifacts 上传工具 | `src/artifact/` | `ArtifactMiddleware`(`mod.rs`);`ArtifactTool`(`tool.rs`);`ArtifactClient`(`client.rs`) | 独立 middleware 注册 direct `artifact` 工具;可由 MetaHarness 的 `ArtifactMiddleware: false` 单独关闭而不影响 ToolSearch;上传地址来自 env 或默认值;结果格式化统一经 `ArtifactClient::format_output` | | 改 MCP 连接 / 初始化 / 生命周期 | `src/mcp/client.rs`(pool 状态所有权与端口)+ `src/mcp/client/lifecycle.rs`(准入、连接提交与关闭)+ `src/mcp/task_scope.rs` + `src/mcp/initialize.rs` + `src/mcp/reconnect.rs` + `src/mcp/client/subscription.rs` + `src/mcp/client_oauth.rs`;owner contract `peri-acp-types/src/ports.rs` | `McpClientPool::{begin_shutdown,shutdown,spawn_background,try_commit_connection}`(lifecycle.rs:131/:179/:144/:163);`spawn_reconnect`(reconnect.rs);`McpTaskOwner` / `McpTaskOwnerPort`;`run_initialize`;`reconnect` | init/OAuth/reconnect/subscription 的 handle 只在 deployment-held non-Clone concrete owner,ACP 仅持 boxed owner port,pool 只持 weak spawner;pool lifecycle gate 线性化任务准入与 service/client commit。关闭顺序固定为 pool begin-close → owner abort/join → pool service close;service close 是 pool-held 单一 transaction,waiter 取消/并发/重试观察同一 report,超时保持 Closing。callback/notifier 使用 weak pool capture。连接超时 STDIO 10s / HTTP 30s / shutdown 5s;启动失败与连接超时同时写客户端状态与 WARN 诊断日志(server/transport/timeout_secs,错误文本经 `redact_mcp_error`),回归见 `initialize_test.rs`;契约 ARC-HOST-SHUTDOWN-001 | +| 改 MCP over ACP(client 声明的 `type: "acp"` server) | `src/mcp/acp/`(`session.rs` 会话状态机 + `transport.rs` 桥接 transport)+ `src/mcp/client.rs`(`acp_owners` 归属)+ `src/mcp/client/lifecycle.rs`(`commit_acp_connection` / `record_acp_failure` / `remove_acp_servers_for_session`)+ `src/mcp/mod.rs`(`pub mod acp`) | `AcpMcpService`(`AcpMcpServerPort` 实现:`attach` / `request` / `notify` / `owns_connection` / `close_session`);`create_bridge`;`McpClientPool::{is_visible_to_session,all_server_infos_visible_to,get_client_visible_to}`;`McpTaskKey::Acp` | 声明受理非阻塞:`attach` 只登记并后台建连(`mcp/connect` → `serve_client_auto` → `tools/list` → `commit_acp_connection`),失败留池状态面(`ConfigSource::Acp` 失败条目)不回抛;连接按会话归属过滤,工具桥接 / 发现面 / 状态面都不跨会话泄漏;内层 MCP 错误码原样透传;会话结束先断连再见池;契约 ARC-MCP-ACP-001,回归 `mcp::acp::session` | | 改 MCP 状态通知 / 缓冲 | `src/mcp/middleware.rs` + `src/mcp/client/status.rs`(`record_status_change` :257);缓冲所有权 `src/mcp/client.rs` 的 `pending_changes` | `McpMiddleware::first_turn_reminder`(middleware.rs:335,连接概览注入);`before_model`(:364,drain pending_changes 成 Info 消息);`push_status_changes`(:281) | 运行中变化进全局缓冲(任一会话消费一次即清空);初始化中(initialized=false)的状态写入不通知,避免与首 turn 概览重复 | | 改 MCP 缓存准入 / RPC 包装 / 失效 | `src/mcp/client/cache.rs`;磁盘缓存实现 `src/mcp/resource_cache.rs` | `persistent_cache_allowed_for`(:41);`read_resource_cached`(:72);`list_all_tools_cached`(:258) | dynamic connection 暂不读写持久化缓存;静态连接按认证策略和 server cache version 判定复用,资源内容验证成功后才持久化 | | 改 MCP 配置合并 | `src/mcp/config.rs` | `load_merged_config_full`(:223)→ `load_merged_config`(:349);`load_global_config`(:60);`load_from_path`(:45);`remove_server_from_config`(:385)/`set_server_disabled`(:473) | 三层合并:global `~/.peri/settings.json` → 插件(`plugin:{name}:{server}` 命名空间 + 与手动配置内容 hash 去重,:304-319)→ 项目 `{cwd}/.mcp.json`(后插覆盖);插件 env 按插件独立上下文在合并前展开(CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA) | @@ -41,7 +43,7 @@ | 改 Dynamic MCP load / unload / session close | `src/mcp/dynamic/registry.rs`(RegistryState 单一 owner、deployment port);`registry/{connector,load,unload,operations,lifecycle}.rs` | `load`(load.rs:12)/`run_load`(:139);`unload`(unload.rs:13);`close_session_impl`(lifecycle.rs:28);`notify_operation`(operations.rs:83) | load/unload 保留 scoped instance 与 operation identity;后台任务沿用 weak registry 和 start gate,session close 先撤销再 drain/close,超时保留未完成实例;`RegistrySessionClose::revoke_and_cleanup`(lifecycle.rs:128)串行等待,仅实际 Complete 才缓存完成,取消/Incomplete 可重试 | | 改 Dynamic MCP staged 连接与安全环境 | `src/mcp/dynamic/staged_connection.rs` + `staged_connection_test.rs` | `prepare_single_server`;`StagedMcpConnection::{commit,cleanup}`;`ActiveMcpConnection::close` | staging 同时持有 service/credential gate,commit 才移交 active;失败/Drop 清理归原 task owner;环境回归通过独立子进程隔离,测试模块路径保持不变 | | 改 Dynamic MCP capability / collision / projection | `src/mcp/dynamic/registry/capability.rs`;稳定导出路径 `registry::CheckedSessionMcpProjection` | `tools_collide`(:64);`publish_capability`(:102)/`revoke_capability`(:156);`CheckedSessionMcpProjection`(:194);catalog 入口 `registry.rs::register_catalog`(:112) | collision baseline 保持 first-write-wins;load commit、capability snapshot 与 projection 刷新在同一 RegistryState 锁内发布,撤销按精确 incarnation 判定;失效 lease 恢复静态 handles | -| 改 MCP 工具 / 资源注册 | `src/mcp/tool_bridge.rs` + `resource_tool.rs` + `discover_tool.rs` | `build_tool_bridges`(tool_bridge.rs:207,pool → Vec>);`McpToolBridge`(:31,`new` :57);`McpResourceTool`(resource_tool.rs:45);`DiscoverMCPTool`(discover_tool.rs:32) | 工具/资源仅在 pool 可用时注册(`assembly/mcp.rs::add_mcp` 条件分支);`McpMiddleware::collect_tools`(middleware.rs:311)提供 Discover + Resource;注册变更必须同时检查 pool、资源与 bridge 路径 | +| 改 MCP 工具 / 资源注册 | `src/mcp/tool_bridge.rs` + `resource_tool.rs` + `discover_tool.rs` | `build_tool_bridges`(tool_bridge.rs:430,pool → Vec>,内部经 typed 构建再装箱);`McpToolBridge`(:37,`new` :104);`build_typed_tool_bridges`(:412);`McpResourceTool`(resource_tool.rs:45);`DiscoverMCPTool`(discover_tool.rs:32) | 工具/资源仅在 pool 可用时注册(`assembly/mcp.rs::add_mcp` 条件分支);`McpMiddleware::collect_tools`(middleware.rs:613)提供 Discover + Resource;注册变更必须同时检查 pool、资源与 bridge 路径 | | 改 MCP skill 发现 | `src/mcp/skill_discovery.rs` + `skill_discovery/{skills_list,legacy_scan,verify}.rs` | `run_discovery`(skill_discovery.rs:97,规范 skills/list 与 legacy 扫描分流);`mcp_route_entries`(:346);`finish_command_source`(:166);`refresh_entry_and_content`(skills_list.rs:327);`is_skill_scheme`(legacy_scan.rs:18);`verify_and_build`(skills_list.rs:499) | 经 `McpSkillRegistry`(peri-acp-types)注册;`skill://` scheme 资源拉取 + digest 校验后入注册表;命令面 `McpSkillPlaceholder` 占位已由 `McpSkillReleaser`(skill_discovery.rs:264,放行跳板:交互式 Inject 原文 / RPC 直返全文)替代 | | 改 MCP Agent 发现 / 激活 | `src/mcp/agent_registry.rs` + `src/subagent/tool/mcp_activation.rs` + `src/assembly.rs` | `McpAgentRegistry::entries` / `activate`;`SubAgentTool::load_and_approve_mcp_agent` | 从 `resources/list` 快照只发现 `agent://.../agent.md` 元数据;`Agent(subagent_type="mcp____")` 激活时才 read/校验/digest/批准,远端危险本地字段默认忽略,执行复用父工具交集与现有 subagent runtime,不落盘、不覆盖本地定义 | | 改 MCP 多路复用 / Apps relay | `src/mcp/{channel_handler,apps,apps_relay}.rs` + ACP host 装配 | `ChannelHandler::new`;`PoolMcpAppsRelay`;Apps deployment profile 与 binding lease registry | Channel broker 只参与 Approval,AskUser 使用原始 broker;`PERI_MCP_APPS` 启用 stdio relay,App `tools/call` 必须经 connection-owned lease 和 canonical Permission/HITL dispatcher;现行契约见 `docs/design/mcp-multiplexing.md` | @@ -105,19 +107,21 @@ | 功能 | 入口/关键点 | | --- | --- | -| 连接 / pool / task owner | client.rs(McpClientPool 状态所有权与稳定 re-export);client/lifecycle.rs(begin_shutdown/shutdown、try_commit_connection);client/service.rs(McpServiceWrapper 与 capability 声明);client/types.rs(句柄、状态、connection key);task_scope.rs(McpTaskOwner / weak McpTaskSpawner / keyed completion);client/transport.rs(serve_client_auto、spawn_stdio_transport、build_http_transport);client/subscription.rs(资源订阅循环);initialize.rs(run_initialize);reconnect.rs(spawn_reconnect/reconnect) | +| 连接 / pool / task owner | client.rs(McpClientPool 状态所有权与稳定 re-export);client/lifecycle.rs(begin_shutdown/shutdown、try_commit_connection);client/service.rs(McpServiceWrapper 与 capability 声明);client/types.rs(句柄、状态、connection key);task_scope.rs(McpTaskOwner / weak McpTaskSpawner / keyed completion);client/transport.rs(serve_client_auto、spawn_stdio_transport、build_http_transport);client/subscription.rs(资源订阅循环);initialize.rs(run_initialize;commit_discovery_success :47 / commit_discovery_failure :61 提交本代 `DiscoveryEvidence`,`Err` 不产生 ready 证据);reconnect.rs(spawn_reconnect/reconnect) | +| System 启动准入 | client/readiness.rs(SystemReadinessTracker :239、DiscoveryEvidence :75、SystemReadinessError :159、await_system_connections :385);middleware.rs(await_system_ready :363、before_react_start :703、startup_tool_update :440、prepared_static_bridges :492) | +| System 必需工具注入 | system_tools.rs(prepare_system_tools :62、SystemToolError :24);tool_bridge.rs(build_typed_tool_bridges :412、with_direct :186、original_tool_name :200) | | Dynamic registry | dynamic/registry.rs(RegistryState 所有权、deployment port 与公开 re-export);registry/connector.rs(ProductionDynamicMcpConnector);registry/load.rs / unload.rs / lifecycle.rs(操作与关闭);registry/operations.rs(查询与通知);registry/capability.rs(collision、snapshot 与 projection lease) | | 状态 / 缓存 / OAuth 回调 | client/status.rs(server_infos、record_status_change、notify_initial_connections);client/cache.rs(persistent_cache_allowed_for、read_resource_cached、list_all_tools_cached);client/oauth.rs(reserve_oauth_flow_scoped、register_oauth_callback_scoped、release_oauth_flow_scoped) | -| 配置合并 | config.rs(load_merged_config_full :223 / load_merged_config :349 / remove_server_from_config :385 / set_server_disabled :473) | -| 工具 / 资源 / skill | tool_bridge.rs(build_tool_bridges :207);resource_tool.rs(McpResourceTool :45);discover_tool.rs(DiscoverMCPTool :32);skill_discovery.rs + skill_discovery/(run_discovery :97、verify_and_build skills_list.rs:499) | -| 中间件 | middleware.rs(McpMiddleware :24,collect_tools :311 / before_agent :354 / before_model :364 / first_turn_reminder :335 / ensure_discovery :80 / attach_connection_notifier :196) | +| 配置合并 / 校验 | config.rs(load_merged_config_full :301 / load_merged_config :442 / remove_server_from_config :478 / set_server_disabled :586;validate_config :99、server_config_hash :152、expand_server_config_with_context :250)——direct / global / merged 三入口在本文件内统一按 `McpServerConfig::validate` 复检,插件 MCP 走 `plugin/loader.rs` 的严格入口 | +| 工具 / 资源 / skill | tool_bridge.rs(build_tool_bridges :430);resource_tool.rs(McpResourceTool :45);discover_tool.rs(DiscoverMCPTool :32);skill_discovery.rs + skill_discovery/(run_discovery :97、verify_and_build skills_list.rs:499) | +| 中间件 | middleware.rs(McpMiddleware :122,collect_tools :613 / first_turn_reminder :639 / before_agent :688 / before_react_start :703 / before_model :722 / ensure_discovery :191 / attach_connection_notifier :317) | | OAuth / 凭证 / 信道 | oauth_flow.rs、client_oauth.rs、auth_store.rs(FileCredentialStore)、callback_server.rs(OAuthCallbackServer :26)、channel_handler.rs(ChannelHandler :17)、mcp_notify.rs | ### 插件(src/plugin/) | 功能 | 入口/关键点 | | --- | --- | -| 加载 | loader.rs(load_manifest :77 / load_plugins :491 / load_enabled_plugins_aggregated :632 / merge_plugin_mcp_servers :612 / parse_command_md :66) | +| 加载 | loader.rs(parse_command_md :73 / load_manifest :84 / load_plugins :589 / merge_plugin_mcp_servers :782 / load_enabled_plugins_aggregated :802;MCP 专用严格入口 load_enabled_plugins_for_mcp :755 + validate_mcp_server_config :456,非法 MCP 配置不降级为空配置) | | 配置 / 持久化 | config.rs(ClaudeSettings :11、installed_plugins 持久化 :175/:325、settings.json 启用名单 :428/:465) | | 安装 / 市场 | installer/(install.rs:12 / update_plugin install.rs:168 / uninstall.rs:15);marketplace/(MarketplaceManager :20);install_counts.rs | | 中间件 / 类型 | middleware.rs(PluginMiddleware :7);types.rs(仅 re-export,事实源 peri-acp-types/src/plugin.rs:269) | @@ -175,7 +179,7 @@ | 功能 | 入口/关键点 | | --- | --- | -| 工具 trait | `peri-acp-types/src/tools.rs`(BaseTool,is_direct 默认 false);注册面 = 各 middleware `collect_tools()`:filesystem.rs:39、mcp/middleware.rs:311、lsp/middleware.rs:54、subagent/mod.rs:546、hitl/mod.rs:90、cron/middleware.rs:29、tool_search/middleware.rs:52、goal_middleware.rs:76 等 | +| 工具 trait | `peri-acp-types/src/tools.rs`(BaseTool,is_direct 默认 false);注册面 = 各 middleware `collect_tools()`:filesystem.rs:39、mcp/middleware.rs:613、lsp/middleware.rs:54、subagent/mod.rs:546、hitl/mod.rs:90、cron/middleware.rs:29、tool_search/middleware.rs:52、goal_middleware.rs:76 等 | | 链序蓝本 | `peri-agent/src/session/factory.rs`(ChainSlot :27 / production_blueprint :89 / build_middleware_chain :144 / MiddlewareChainAssembler :130) | ## 跨模块契约(指向 architecture-contracts.md,不复制正文) diff --git a/docs/design/README.md b/docs/design/README.md index a902de245..5f0101772 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -35,6 +35,7 @@ draft、proposal、可行性探查、审计报告和未采纳方案不进入本 | 用户待发送队列 | [user-input-queue.md](user-input-queue.md) | Mailbox、单条/全部投递、取回与运行身份 | | Compact | [micro-compact.md](micro-compact.md) | 压缩计划与 LLM projection | | Dynamic MCP | [dynamic-mcp.md](dynamic-mcp.md) | session 动态加载、目录发布与关闭 | +| MCP 适配 v4-part-1 | [mcp-adaptation-v4-part-1.md](mcp-adaptation-v4-part-1.md) | System MCP 启动依赖、工具注入、MCP 隔离与 middleware 归属 | | MCP Apps relay | [mcp-multiplexing.md](mcp-multiplexing.md) | stdio Apps profile、binding lease 与多路数据隔离 | | Meta 数据访问 | [meta-control.md](meta-control.md) | 只读 session metadata CLI 与持久化边界 | | Workflow | [workflow.md](workflow.md) | Node RPC、runner、通知、kill 与 resume | diff --git a/docs/design/mcp-adaptation-v4-part-1.md b/docs/design/mcp-adaptation-v4-part-1.md new file mode 100644 index 000000000..fd9354e6e --- /dev/null +++ b/docs/design/mcp-adaptation-v4-part-1.md @@ -0,0 +1,243 @@ +# MCP 适配清单(v4-part-1) + +> 状态:已批准目标设计(v4-part-1) +> +> 目的:定义 MCP 适配的目标架构、System MCP 启动依赖、一等工具注入契约、隔离与复用边界,以及 middleware 拆分分类。本文件是 MCP 迁移范围与验收语义的唯一事实源;代码、协议和契约测试是当前实现行为的事实源,若与本文件冲突,必须先修正实现或明确记录本文件的变更。 +> +> 规范性用语:**必须**表示不可违反的契约,**不得**表示禁止行为,**可以**表示实现选择。本文描述目标契约,不把尚未完成的迁移写成已实现能力;“目标:完全下放”等状态表示 v4 目标归属,不表示当前代码已经完成迁移。 +> +> 版本范围:本文件只覆盖 v4 的 part-1。part-1 解决 MCP 实例划分、启动准入、工具注入和 middleware 归属;具体迁移批次、实现提交和现场验收不得回填到本文件,另以对应 issue 或验收记录为准。 +> +> 完整性口径:下表只统计具体 `impl Middleware for ...` 类型,不统计 `ChainSlot`、目录名、目标包分组或仅被 adaptor 接入的类型。当前清单中的 `WorkflowMiddleware` 本身由 `WorkflowMiddlewareAdaptor` 包装,因此不重复列为 middleware。 + +## 事实源与实现状态 + +本文件的设计结论以以下事实源为边界: + +- Agent 层链序:`peri-agent/src/session/factory.rs::ChainSlot` 与 `production_blueprint`。 +- MCP 对接与工具 bridge:`peri-middlewares/src/mcp/`,生产装配入口为 `peri-middlewares/src/assembly.rs`。 +- MCP 配置与协议行为:`peri-middlewares/src/mcp/config.rs`、`peri-middlewares/src/mcp/client/transport.rs` 及其契约测试。 +- 当前实现验证优先级:代码与契约测试 > `docs/standards/` > 本设计文档 > 对应 active issue。本文作为 v4 目标架构文档,不替代当前实现的事实记录。 + +## MCP 运行形态 + +v4 允许两种运行形态,但不改变 MCP 实例的隔离契约: + +- **Builtin MCP**:由 Peri 内部装配和调用;可以使用 `rmcp` 的内存 transport,使 client/server 在同一进程内通过内存通道通信,不启动外部 MCP 进程。 +- **External MCP**:通过现有配置接入外部 stdio 或 HTTP transport。是否提供独立的宿主 CLI 暴露入口不属于本 part-1 的已实现承诺,不能把未存在的命令写成当前用法。 + +当前客户端缺省配置使用 `rmcp` 的 `Auto` lifecycle;显式配置 `protocolVersion` 时使用对应的严格 discovery 路径。System MCP 不得绕过协议初始化、能力协商或 transport 生命周期;协议版本策略必须与 `peri-middlewares/src/mcp/client/transport.rs` 及测试保持一致,不得在本文中把所有 MCP 连接概括为固定的单一握手版本。 + +无论使用 builtin 还是 external transport,每个 MCP 实例都必须保持独立的 transport、状态、凭据和 capability root;复用 Rust library、schema、错误类型或测试 fixture 不构成运行时实例复用。 + +## System MCP 启动依赖 + +v4 MCP 配置定义 `system_mcp` 标识,用于声明该 MCP 是 react loop 的启动依赖,而不是普通的延迟可用工具源: + +- `system_mcp = true` 的 MCP 必须在 react loop 启动前完成 transport、initialize、能力协商和必要的健康检查。 +- `McpMiddleware` 在 **1R 阶段**负责等待所有 system MCP ready;等待期间阻塞后续 loop 启动。 +- 等待超过配置的 timeout 必须返回明确错误并终止本次启动,不得降级、跳过或把未知状态当作 ready。 +- `system_mcp = false` 或未标识的 MCP 可以按普通 MCP 生命周期建立,不阻塞 react loop 启动。 +- `system_mcp` 只表达启动依赖等级,不改变 MCP 的隔离边界;每个 MCP 仍拥有独立 transport、运行时实例、状态、凭据和 capability root。 + +### `system_mcp_tools` 一等工具注入 + +与 `system_mcp` 配套增加一等配置 key:`system_mcp_tools`,值为该 System MCP 必须提供的工具名数组。它描述的是启动期必需工具,不是普通工具搜索提示。 + +```json +{ + "mcpServers": { + "workspace": { + "system_mcp": true, + "system_mcp_tools": ["Read", "Write", "Edit", "Glob", "Grep"] + } + } +} +``` + +契约如下: + +- `system_mcp_tools` 只能与 `system_mcp = true` 配合使用;没有 `system_mcp` 时配置非法。 +- MCP 完成协议初始化、能力协商和 `tools/list` 后,`McpMiddleware` 必须逐项确认数组中的工具存在;具体协商版本遵循当前 transport 配置和客户端 lifecycle 契约。 +- 所有必需工具确认 ready 后,直接把对应 tool declaration/bridge 注入 RCRA loop 的工具列表;这些工具不是 deferred tool,不经过 `ToolSearchMiddleware`,也不需要模型先搜索。 +- 工具名应在所属 MCP 的命名空间内匹配;对 Agent 暴露时继续使用现有 MCP effective tool name,避免不同 MCP 的同名工具冲突。 +- 任一必需工具缺失、工具 schema 无法解析、initialize 失败或等待超时,都必须在 1R 返回明确错误并阻止 react loop 启动。 +- `system_mcp_tools` 为空数组表示该 System MCP 只要求连接 ready,不向 RCRA 直接注入工具。 + +## 最小 MCP 隔离设计 + +**最小合理数量:5 个相互隔离的 MCP 实例**,而不是让每个 middleware 各自实现一套 MCP 协议: + +1. **Workspace MCP**:复用现有 `side-projects/local-mcp-server`,提供本地 workspace/process 能力。 +2. **Artifact MCP**:单独提供 HTML/Markdown 内容发布、转换、TTL 和公开 URL 能力。 +3. **Web MCP**:将 `WebSearch` 与 `WebFetch` 合并,提供外部网页搜索和抓取能力。 +4. **Cron MCP**:提供定时任务注册、查询、删除和触发事件能力。 +5. **LSP MCP**:提供代码智能、诊断、符号、引用和调用关系能力。 + +MCP 是最小隔离单位:这 5 个 MCP 可以复用 Rust library、schema、错误类型和测试工具,但不能复用运行时实例、进程、状态、凭据、capability root 或 client pool。不同 MCP 之间不互相调用;如果需要传递内容、文件变更或触发事件,由 Agent/Runtime 分别调用 MCP,并通过宿主端口接收结果。 +```mermaid +flowchart LR + subgraph HOST[Agent / Runtime 宿主语义] + AGENT[peri-agent
Middleware hooks / prompt / session] + PORTS[peri-acp-types
State / event / lifecycle ports] + ASSEMBLY[ProductionChainAssembler
组合根] + RCRA[RCRA loop
direct tool list] + end + + subgraph ADAPTERS[仍保留在 Agent / Runtime 的 Middleware] + CONTEXT[DefaultSystemPrompt
Lang / AgentsMd / AgentDefine] + WORKSPACE[GitAttribution
宿主 hook / notification] + EXT[Plugin
Skills / SkillPreload context only] + MCP_MW[McpMiddleware
统一 MCP 对接核心
1R 等待 system_mcp ready] + SYSTEM_TOOLS[system_mcp_tools
required tools
direct injection / no ToolSearch] + end + + subgraph MCP[5 个彼此隔离的 MCP 实例] + WS[Workspace MCP
独立实例 / 独立状态] + WSCAP[Workspace MCP tools
Read / Write / Edit / Glob / Grep
folder_operations / Bash
Filesystem / Terminal / GitWatch / Skill tools
v4 目标能力集合] + + ART[Artifact MCP
独立实例 / 独立状态] + ARTCAP[Artifact MCP 目标能力
Markdown / HTML input
convert / upload / TTL / URL] + + WEB_MCP[Web MCP
独立实例 / 独立凭据] + WEBCAP[Web MCP 目标能力
WebSearch + WebFetch
search / extract / truncate] + + CRON[Cron MCP
独立实例 / 独立状态] + CRONCAP[Cron MCP 目标能力
register / list / remove
trigger events] + + LSP[LSP MCP
独立实例 / 独立状态] + LSPCAP[LSP MCP 目标能力
diagnostics / symbols / references
call hierarchy / implementations] + end + + subgraph LIBS[可复用代码,不是共享实例] + COMMON[Shared Rust libraries
schema / errors / test fixtures] + end + + subgraph DIRECT[不经 MCP 的宿主能力] + CONTROL[Permission / HITL / ToolSearch / PTC
独立 Middleware] + HOOK[HookMiddleware
Claude Plugin Hook 暂保留为独立 Middleware] + RUNTIME[SubAgent / Workflow / Goal
独立 Middleware / Runtime] + end + + AGENT --> ASSEMBLY + PORTS --> ASSEMBLY + ASSEMBLY --> CONTEXT + ASSEMBLY --> WORKSPACE + ASSEMBLY --> EXT + ASSEMBLY --> MCP_MW + + CONTEXT --> MCP_MW + WORKSPACE --> MCP_MW + EXT --> MCP_MW + + MCP_MW --> WS + MCP_MW --> ART + MCP_MW --> WEB_MCP + MCP_MW --> CRON + MCP_MW --> LSP + MCP_MW --> SYSTEM_TOOLS + SYSTEM_TOOLS --> RCRA + + WS --> WSCAP + ART --> ARTCAP + WEB_MCP --> WEBCAP + CRON --> CRONCAP + LSP --> LSPCAP + + WS -. "复用代码,不共享实例" .- COMMON + ART -. "复用代码,不共享实例" .- COMMON + WEB_MCP -. "复用代码,不共享实例" .- COMMON + CRON -. "复用代码,不共享实例" .- COMMON + LSP -. "复用代码,不共享实例" .- COMMON + + AGENT --> CONTROL + PORTS --> CONTROL + AGENT --> HOOK + PORTS --> RUNTIME +``` + +### 复用边界 + +| 能力 | 目标 MCP | 说明 | +| --- | --- | --- | +| 文件读取、目录扫描、文件写入 | Workspace MCP | `AgentsMd`、`AgentDefine`、Filesystem、Terminal、GitWatch 等文件/进程/工作区观察能力的 v4 目标归入 Workspace MCP;每个 MCP 实例拥有独立的 capability root 和状态。 | +| Skills 工具 | Workspace MCP | `SkillTool` / `DiscoverSkillsTool` 下放到 Workspace MCP 工具包;`SkillsMiddleware` / `SkillPreloadMiddleware` 只保留宿主侧 prompt/context、冻结摘要和预加载语义,不再直接提供 Skill 工具。 | +| 本地命令与 Git 查询 | Workspace MCP | `FilesystemMiddleware`、`TerminalMiddleware`、`GitWatchMiddleware` 的工具/工作区观察能力目标归入 Workspace MCP;`GitAttribution` 只复用 Workspace MCP 的查询能力,hook、notification 和归属注入仍由宿主持有。 | +| Default system prompt | 部分复用 Workspace MCP | 当前基础段通过 `include_str!` 在编译期嵌入,不能简单改成 MCP `Read`;只有运行时 persona、language、项目指引等文件读取适合调用 Workspace MCP,prompt 合并、优先级、冻结和缓存仍属于 middleware/Agent。 | +| Artifact 发布 | Artifact MCP | Agent/Runtime 显式准备内容后调用 Artifact MCP;Artifact MCP 只处理显式传入的内容,不能访问 Workspace MCP 的文件系统,也不能共享 Workspace MCP 的 capability root。 | +| Web 搜索与抓取 | Web MCP | `WebSearch` 与 `WebFetch` 可以共享代码和协议面,但运行时使用独立的 Web MCP 进程、网络策略和凭据。 | +| Cron 调度 | Cron MCP | `CronMiddleware` 可迁移为独立 Cron MCP;注册表和触发器留在 Cron MCP 内,Agent 只通过工具请求和宿主事件端口接入。 | +| LSP 代码智能 | LSP MCP | `LspMiddleware` 可迁移为独立 LSP MCP;LSP server pool 和诊断状态留在 LSP MCP 内,文件变更同步由 Agent/Runtime 显式发送。 | +| Plugin MCP/Skills 配置 | Workspace MCP + 宿主语义 | 可以调用 Workspace MCP 读取 manifest 和配置文件,但来源合并、命名空间、去重和插件生命周期仍属于 Plugin/MCP adapter。 | +| Approval、Question、Hook、SubAgent、Workflow、Goal | 不经这 5 个 MCP | 这些需要交互 broker、Agent state、外部 runtime 或宿主生命周期,强行映射为 MCP 会损失契约并扩大权限。 | + +因此“最小合理数量”是**5 个彼此隔离、互不调用的 MCP 实例**,不是 28 个 middleware 对应 28 个 MCP server;代码可以复用,MCP 运行时不能复用:Workspace MCP 负责本地能力,Artifact MCP 负责发布,Web MCP 负责外部信息读取,Cron MCP 负责调度,LSP MCP 负责代码智能,其余 middleware 通过 `peri-agent` / `peri-acp-types` 的宿主 seam 直接实现。 + +## 完整列表 + +状态标识:**目标:完全下放** = v4 目标是将该 middleware 的能力整体归入目标 MCP;**目标:部分下放** = 只有文件/工具等子能力进入 MCP,Agent hook、prompt、state 或生命周期仍保留;**宿主保留** = 继续由 Agent / Runtime 直接持有。这里的“目标”不表示当前实现已经完成迁移。 + +| 顺序 | Middleware 类型 | 当前源码 | 迁移状态 | 一句话说明 | +| ---: | --- | --- | --- | --- | +| 1 | `DefaultSystemPromptMiddleware` | `peri-middlewares/src/default_system_prompt/mod.rs` | 部分下放 | 文件载体可由 Workspace MCP 读取,但 prompt contribution、合并、优先级、冻结和缓存仍由 Agent 持有。 | +| 2 | `LangMiddleware` | `peri-middlewares/src/default_system_prompt/mod.rs` | 宿主保留 | 语言段落属于 Agent prompt 装配语义,不应下放为 Workspace MCP 工具。 | +| 3 | `ImageMiddleware` | `peri-middlewares/src/middleware/image/mod.rs` | 宿主保留 | 图片输入解析直接依赖 Agent message/content 类型,属于消息处理而非 Workspace MCP 能力。 | +| 4 | `AgentsMdMiddleware` | `peri-middlewares/src/agents_md/mod.rs` | 部分下放 | 文件读取可由 Workspace MCP 完成,但文档优先级、session 冻结和 prompt contribution 仍由 Agent 持有。 | +| 5 | `AgentDefineMiddleware` | `peri-middlewares/src/agent_define/mod.rs` | 部分下放 | 定义文件可由 Workspace MCP 读取,但 Agent 定义解析和 SubAgent/Plugin 语义仍由宿主持有。 | +| 6 | `AtMentionMiddleware` | `peri-middlewares/src/at_mention/mod.rs` | 宿主保留 | `@mention` 输入转换依赖 Agent 消息内容和工具上下文,不是 Workspace MCP 工具。 | +| 7 | `GitWatchMiddleware` | `peri-middlewares/src/git_watch/mod.rs` | 目标:完全下放 → Workspace MCP | Git 状态观察、分支变化检测、采样和工作区 watcher 统一进入 Workspace MCP,Agent 侧不再保留 GitWatch middleware。 | +| 8 | `GitAttributionMiddleware` | `peri-middlewares/src/attribution/mod.rs` | 部分下放 | Git/file 查询由 Workspace MCP 提供,但 before/after tool hook 和归属注入仍由 Agent 持有。 | +| 9 | `ArtifactMiddleware` | `peri-middlewares/src/artifact/mod.rs` | 目标:完全下放 → Artifact MCP | Artifact 的读取输入、格式转换、上传、TTL 和 URL 统一进入 Artifact MCP,Agent 侧只保留 MCP 对接。 | +| 10 | `WebMiddleware` | `peri-middlewares/src/middleware/web.rs` | 目标:完全下放 → Web MCP | `WebSearch` 与 `WebFetch` 统一进入 Web MCP,Agent 侧只保留 MCP 对接。 | +| 11 | `TodoMiddleware` | `peri-middlewares/src/middleware/todo.rs` | 部分下放 | Todo 文件/工具操作可由 Workspace MCP 执行,但 todo channel 与 session/UI 状态回写仍由宿主注入。 | +| 12 | `CronMiddleware` | `peri-middlewares/src/cron/middleware.rs` | 目标:完全下放 → Cron MCP | scheduler、后台 tick、取消、注册/查询/删除和触发事件统一进入 Cron MCP,Agent 侧只保留 MCP 对接和事件接收。 | +| 13 | `LspMiddleware` | `peri-middlewares/src/lsp/middleware.rs` | 目标:完全下放 → LSP MCP | LSP server pool、诊断状态、符号/引用查询和文件变更同步统一进入 LSP MCP,Agent 侧只保留 MCP 对接。 | +| 14 | `WorkflowMiddlewareAdaptor` | `peri-middlewares/src/workflow/mod.rs` | 独立 Middleware | Workflow executor、progress、通知、kill/resume 和 session 生命周期属于 Runtime,不下放到 MCP。 | +| 15 | `FilesystemMiddleware` | `peri-middlewares/src/middleware/filesystem.rs` | 目标:完全下放 → Workspace MCP | filesystem 工具、workspace path 解析、读写和目录操作统一进入 Workspace MCP,Agent 侧只保留 MCP 对接。 | +| 16 | `TerminalMiddleware` | `peri-middlewares/src/middleware/terminal.rs` | 目标:完全下放 → Workspace MCP | terminal/Bash 工具、进程执行和任务输出统一进入 Workspace MCP,Agent 侧只保留 MCP 对接。 | +| 17 | `PtcMiddleware` | `peri-middlewares/src/ptc/mod.rs` | 独立 Middleware | JS runtime、session-local tool bridge、权限和 effective tool dispatch 必须由 Agent/Runtime 持有,不下放到 MCP。 | +| 18 | `HumanInTheLoopMiddleware` | `peri-middlewares/src/hitl/mod.rs` | 独立 Middleware | Question broker、工具注册、取消和 ACP/TUI 交互通道属于宿主交互生命周期,不下放到 MCP。 | +| 19 | `PermissionMiddleware` | `peri-middlewares/src/permission/mod.rs` | 独立 Middleware | Permission mode、effective tool name、ToolSearch、Hook 和 broker 构成宿主安全边界,不下放到 MCP。 | +| 20 | `HookMiddleware` | `peri-middlewares/src/hooks/middleware.rs` | 独立 Middleware | Claude Plugin Hook 暂时继续作为 Middleware 执行,不定义为 MCP;hook loader、executor、Permission、Plugin、Agent event/state 和 command 执行端口属于宿主。 | +| 21 | `SkillsMiddleware` | `peri-middlewares/src/skills/mod.rs` | 部分下放 | Skill 文件发现和读取进入 Workspace MCP,但 skill roots、Plugin 来源、冻结摘要和 MCP registry 仍由宿主持有。 | +| 22 | `SkillPreloadMiddleware` | `peri-middlewares/src/subagent/skill_preload.rs` | 部分下放 | Skill 文件读取进入 Workspace MCP,但 SubAgent 输入、预加载顺序和取消生命周期仍由宿主持有。 | +| 23 | `PluginMiddleware` | `peri-middlewares/src/plugin/middleware.rs` | 部分下放 | Plugin manifest 文件可由 Workspace MCP 读取,但来源合并、命名空间、hooks、agents、commands 和生命周期仍由宿主持有。 | +| 24 | `ToolSearchMiddleware` | `peri-middlewares/src/tool_search/middleware.rs` | 独立 Middleware | deferred tool、MCP、Permission、PTC 和 SubAgent 的工具目录属于 Agent 工具编排,不下放到 MCP。 | +| 25 | `McpMiddleware` | `peri-middlewares/src/mcp/middleware.rs` | 独立 Middleware | 它是统一 MCP 对接核心,并在 1R 阶段等待 `system_mcp` 完成 ready;超时必须报错并阻止 react loop 启动。 | +| 26 | `DynamicMcpMiddleware` | `peri-middlewares/src/mcp/dynamic/tool.rs` | 独立 Middleware | session-scoped registry、动态工具目录、取消、权限和 projection lease 属于 MCP 对接宿主。 | +| 27 | `SubAgentMiddleware` | `peri-middlewares/src/subagent/mod.rs` | 独立 Middleware | parent/child session、fork/resume、取消、事件、frozen context、hooks、skills、tools 和 MCP activation 属于 Runtime,不下放到 MCP。 | +| 28 | `GoalMiddleware` | `peri-middlewares/src/goal_middleware.rs` | 独立 Middleware | controller、Goal tool、system prompt steering 和 session 生命周期属于 Agent/Runtime,不下放到 MCP。 | + +## 不属于实际 Middleware 实现、但迁移时会受影响的类型 + +以下类型没有自己的 `impl Middleware for ...`,因此不计入上表,但会影响对应 middleware 的拆包: + +- `WorkflowMiddleware`:`peri-middlewares/src/workflow/mod.rs`,由 `WorkflowMiddlewareAdaptor` 接入链。 +- `CronScheduler`:`peri-middlewares/src/cron/`,迁移后应成为 Cron MCP 内部状态,Agent 只通过 MCP 工具和宿主事件端口接入。 +- `LspServerPool`:由 `peri-resources` 提供,迁移后应成为 LSP MCP 内部状态,Agent/Runtime 通过 MCP 请求与文件变更同步端口接入。 +- `McpClientPool`、`McpTaskOwner`、`DynamicMcpRegistry`:`peri-middlewares/src/mcp/`,由 `McpMiddleware` 和 `DynamicMcpMiddleware` 使用。 +- `SkillTool`、`DiscoverSkillsTool`、`SubAgentTool`、各类 filesystem/web/terminal 工具:由对应 middleware 的 `collect_tools` 提供,不是单独的 middleware。 +- `ProductionChainAssembler`:`peri-middlewares/src/assembly.rs`,是所有 middleware 的组合根,不是 middleware;迁移时应保留为装配层。 + +## v4-part-1 验收契约 + +实现 v4-part-1 时,至少必须验证以下可观察结果;测试应在 MCP transport、宿主装配和 RCRA 工具视图的实际 seam 上断言,不以静态清单代替运行时验证: + +1. 配置解析拒绝没有 `system_mcp = true` 却声明 `system_mcp_tools` 的配置,并保留明确错误。 +2. System MCP 在 transport、协议初始化、能力协商和必需工具检查完成前,不得进入可启动的 react loop;任一失败或 timeout 都返回错误,不发布 ready。 +3. `system_mcp_tools` 的每个工具都经过所属 MCP namespace 解析,工具 schema 可构造为 bridge,并直接出现在 RCRA 工具列表;普通 deferred tool 仍走既有 `ToolSearchMiddleware` 路径。 +4. 必需工具为空数组时只验证 System MCP ready,不注入额外工具。 +5. 五个目标 MCP 的 transport、状态、凭据、capability root 和 client pool 不共享;MCP 之间不得通过隐式调用建立依赖。 +6. 对宿主保留的 Permission、HITL、Hook、SubAgent、Workflow、Goal 和 PTC 能力,迁移设计不得绕过既有 cancel、审批、事件、session 或 effective tool name 契约。 +7. v4 目标归属未完成迁移前,当前实现和文档必须能区分“目标归属”与“已落地能力”,不得以绿色的局部单测宣告整体迁移完成。 + +验证范围由对应实现 issue 记录;本文件只定义必须满足的行为契约,不保存某一次执行的勾选状态、耗时或提交号。 + +## 完整性依据 + +完整性依据为: + +- Agent 层链序事实源:`peri-agent/src/session/factory.rs::ChainSlot` 与 `production_blueprint`。 +- 实际实现搜索:`peri-middlewares/src/**/*.rs` 中的 `impl Middleware for ...`。 +- 生产装配入口:`peri-middlewares/src/assembly.rs`。 +- MCP 协议与配置事实源:`peri-middlewares/src/mcp/config.rs`、`peri-middlewares/src/mcp/client/transport.rs` 及其测试。 diff --git a/docs/reference/mcp-ecosystem.md b/docs/reference/mcp-ecosystem.md index a84799450..de3a0e213 100644 --- a/docs/reference/mcp-ecosystem.md +++ b/docs/reference/mcp-ecosystem.md @@ -2,7 +2,8 @@ > 本文件是生态背景与外部互通参考,不是仓库架构或协议的事实源。Perihelion > 的 MCP 设计以 `docs/design/dynamic-mcp.md`、 -> `docs/design/mcp-multiplexing.md` 和代码契约测试为准。 +> `docs/design/mcp-multiplexing.md`、`docs/design/mcp-adaptation-v4-part-1.md` +> (MCP 迁移范围与验收语义的目标设计)和代码契约测试为准。 > > 实验位置:`side-projects/mcp-apps/`(Node.js + `@modelcontextprotocol/ext-apps` + `@modelcontextprotocol/sdk`) > 文档定位:说明 MCP 在 perihelion 中的生态角色——**标准的 connector / 通用外部能力对接器**——以及外部开发者如何实现「外部 MCP server ↔ peri 内部」的互通。不搬运规范原文,官方出处见各章末尾链接。 @@ -128,6 +129,8 @@ tools 原语在两个协议版本中基本兼容。2026-07-28 的新增与调整 - **不占 LLM tools 参数**:N 个 MCP server 的工具全部注入会挤爆上下文与 API 参数面;延迟加载让工具 schema 只在被搜索命中时才出现。 - **工具面可控**:新接入的 server 不改变 core 工具的可见性,agent 的稳定工具面保持不变——这是「N 个 server 产生 N 个碎片化入口、工具面失控」问题的通用解法(skills 同理,见第 7 章)。 +**例外**:声明为启动依赖的 server 用 `system_mcp_tools` 指名必需工具(配置语义见 §9.2);这些工具的目标行为是不经 tool search、直接进入 RCRA 工具列表,契约见 `docs/design/mcp-adaptation-v4-part-1.md`。其余 MCP 工具仍全部落在 deferred 面。 + ### 3.3 桥接与命名 MCP 工具经 `McpToolBridge`(`peri-middlewares/src/mcp/tool_bridge.rs`)包装为 `BaseTool`: @@ -552,6 +555,7 @@ Peri 不实现上述 Web Host ↔ App 的 handshake;Peri 只承载下游选择 - **互通入口**:peri-agent 的 MCP client(stdio 或 Streamable HTTP 传输)。MCP 连接只存在于 agent 层;peri-acp 是 ACP 数据出口(stdio 出数据给 UI 界面);peri-tui / web UI 只是 view,与 MCP 无直接关系。 - **外部职责**:实现标准 MCP server(可复用官方 SDK:`@modelcontextprotocol/sdk` + 可选 `@modelcontextprotocol/ext-apps`),按第 3–7 章声明能力、暴露工具 / 资源 / 通知 / App / 技能。 - **内部职责**:peri-agent 侧 client 实现(连接、协商、工具执行 + HITL 权限、通知与 App 透传到事件链)。 +- **peri 侧配置**:server 在 `mcpServers` 中的条目(传输方式、协议版本、订阅、启动依赖)见 §9.2;外部实现者只需满足标准 MCP,不需要了解这些 key。 ### 8.2 外部实现 checklist @@ -574,7 +578,45 @@ Peri 不实现上述 Web Host ↔ App 的 handshake;Peri 只承载下游选择 - **订阅可靠性**:长流异常中断按 1s/2s/4s 指数退避重新 `listen`(最多 3 次,收到通知即重置计数);连接重连后按当前配置重建长流。2025-11-25 旧路径(`resources/subscribe` + 直推 list_changed)未实现;无订阅配置时维持 legacy 握手。 - **装配**:`McpSubscriptionPort`(`peri-acp-types/src/mcp.rs`)由 `McpClientPool` 实现——session 创建时注册 inbox、`close_session` 时注销。反向(client → server)支持经 `ChannelNotificationSender` 发送自定义 JSON-RPC 通知(`peri-middlewares/src/mcp/mcp_notify.rs`)。 -### 9.2 信道划分(已定稿) +### 9.2 System MCP 配置契约(`system_mcp` / `system_mcp_tools` / `system_mcp_timeout`) + +> 本节记录**配置层**的 key、取值与拒绝规则;启动准入、必需工具注入与目录发布的契约见 +> `docs/design/mcp-adaptation-v4-part-1.md`(「System MCP 启动依赖」「`system_mcp_tools` 一等工具注入」), +> 运行时行为以代码与契约测试为准。 + +三个 key 定义在 `McpServerConfig`(`peri-acp-types/src/plugin.rs`;`peri-middlewares/src/mcp/config.rs` 只 re-export): + +| canonical key | camelCase 别名 | 类型 | 未声明时 | 语义 | +| --- | --- | --- | --- | --- | +| `system_mcp` | `systemMcp` | bool | `None` | `true` 声明该 MCP 是 react loop 的**启动依赖**;消费判定固定为 `== Some(true)`,`false` 与未声明都是普通 MCP | +| `system_mcp_tools` | `systemMcpTools` | string[] | `None` | 该 server 必须提供的工具名,在**所属 server 的原始工具名**上精确匹配(不折叠大小写、不剥离 effective name 前缀、不跨 server 搜索) | +| `system_mcp_timeout` | `systemMcpTimeout` | 整数(毫秒) | `McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS`(30 000) | 启动等待超时;合法区间 `MIN_SYSTEM_MCP_TIMEOUT_MS..=MAX_SYSTEM_MCP_TIMEOUT_MS`(1..=600 000) | + +- **只按 canonical 写回**:序列化固定输出 snake_case 三 key,`false` / `None` 整 key 省略、写回不出现别名;输入两种拼法都接受,**同一字段两种拼法同时出现按 `duplicate field` 解析失败**(值相同也拒绝)。 +- **`None` 与 `Some([])` 不可互换**:未声明是「没有必需工具清单」;`[]` 是「只要求 ready,不向 RCRA 直接注入工具」。两者都可无损往返。 +- 工具名数组逐项保真:不 trim / 不排序 / 不去重 / 不展开 `${...}` / 不加前缀,合并与写回保持值与顺序。 +- System 三 key 显式 `null` 或类型不符一律解析失败,不降级成「未声明」。 + +非法组合在**解析期**由 `McpServerConfig::validate` 按 `McpServerConfigValidationError` 拒绝(固定规则正文,不携带配置内容): + +| 配置 | 错误 | +| --- | --- | +| `system_mcp_tools`(**含显式 `[]`**)而 `system_mcp != true` | `SystemMcpToolsRequiresSystemMcp` → `system_mcp_tools requires system_mcp = true` | +| `system_mcp_timeout` 而 `system_mcp != true` | `SystemMcpTimeoutRequiresSystemMcp` | +| `system_mcp_timeout` 越界 | `SystemMcpTimeoutOutOfRange` → `system_mcp_timeout must be within 1..=600000 milliseconds` | + +同一份 `validate` 是唯一规则来源,每个入口都要过它: + +- **直接 serde**:`McpServerConfig` 手写 `Deserialize`,解析即校验(开发者也应预期手工构造的 struct 在建传输时被再校验)。 +- **项目级 `{cwd}/.mcp.json`**:`mcp::config::load_from_path`(缺文件仍是空配置,解析失败是 `ParseError`)。 +- **全局 `~/.peri/settings.json`**:`mcp::config::load_global_config`;`config.mcpServers` 与顶层 `mcpServers` 两个候选 map 都先解析校验,选择仍按 nested > top-level。 +- **插件 MCP**:`plugin::loader::load_enabled_plugins_for_mcp`(MCP 专用严格入口),manifest 内联条目与被引用的配置文件都逐项校验;插件面板 / skills 聚合用的宽容 API 不作为启动输入。 +- **写入口**:`mcp::config::set_server_disabled`、`remove_server_from_config` 在改动前后校验,失败不写盘。 +- **建传输前**:`TransportConfig::try_from(&McpServerConfig)` 再校验一次——`McpServerConfig` 是公开 struct,可手工构造,`Deserialize` 不是唯一闸门。 + +非法配置一律向上传播,不降级为空配置、不当作「未安装」跳过;`disabled = true` 也不绕过校验。三层合并仍是 global < plugin < project,System MCP 不参与跨来源内容 hash 去重(namespace 归属不得因内容相同而消失)。三个 key 只表达启动依赖等级与必需工具清单,不改变 MCP 实例隔离(见「MCP 运行形态」与「最小 MCP 隔离设计」)。 + +### 9.3 信道划分(已定稿) 传输层为纯 JSON-RPC 2.0(Request / Notification / Response)。MCP Apps 数据到达下游的 contract 见 `docs/design/mcp-multiplexing.md`:外层 ACP envelope、Apps payload id 与 ACP id 分离、connection-owned App session、错误分层和 lifecycle。 @@ -583,7 +625,7 @@ Peri 不实现上述 Web Host ↔ App 的 handshake;Peri 只承载下游选择 3. `appSessionId` 必须由 ACP connection owner、server generation、resource/tool binding 共同约束。 4. 已知方法名冲突通过 `peri/mcp/` 命名空间隔离;下游未知 Apps 方法与字段按版本化 contract 保留。 -### 9.3 协议层目标最小增量(尚未实现) +### 9.4 协议层目标最小增量(尚未实现) 以下为 active spec 的目标,不是当前代码事实: @@ -591,18 +633,18 @@ Peri 不实现上述 Web Host ↔ App 的 handshake;Peri 只承载下游选择 - raw tool metadata、resource item 与 `CallToolResult` 经 relay 保留标准字段和未知 `_meta`;不得提前压成文本。 - 订阅场景:2026-07-28 `subscriptions/listen` 已落地(全链路见 9.1);2025-11-25 旧路径(`resources/subscribe` + 直推 list_changed)未实现。 -### 9.4 Agent / 工具系统目标(尚未实现) +### 9.5 Agent / 工具系统目标(尚未实现) - 经验证的 agent 初始 tool invocation 才能创建 App session;完整 tool input 恰好一次,随后 `tool-result XOR tool-cancelled` 恰好一个 terminal。 - App 发起的 `tools/call` 目标为复用 session-local effective view、canonical dispatch 与既有 Permission/HITL seam;在对应测试通过前不得视为已接通。 -### 9.5 下游 Web Host 边界 +### 9.6 下游 Web Host 边界 Web Host、iframe、sandbox、CSP、Permissions Policy、`postMessage` bridge 与 MCP Apps FE 均由下游实现,Peri 和 `peri-tui` 不实现这些能力。Peri 只提供 connection-scoped capability gate、MCP capability profile、resource/result DTO 和双向 ACP envelope。 下游实现可自行选择 Web、IDE webview 或其他渲染容器;这些选择不能反向改变 Peri 的安全与传输 contract。 -### 9.6 MCP 能力支持度矩阵(2026-08-14 核查) +### 9.7 MCP 能力支持度矩阵(2026-08-14 核查) peri 作为 MCP client,对照 2026-07-28 协议能力面的支持度与路线决策(已支持项细节见 §9.1,未支持项均无代码落点): @@ -617,7 +659,7 @@ peri 作为 MCP client,对照 2026-07-28 协议能力面的支持度与路线 | Sampling | ❌ 未实现(显式拒绝 `-32601`) | **不做(永远)** | server 请求 LLM 直接失败 | | Roots | ❌ 未实现 | **不做(永远)** | 不向 server 暴露工作目录 | | Tasks(正式扩展) | ❌ 未实现 | **不做(永远)** | rmcp 模型齐全,不接 | -| Elicitation(SEP-1036 正式扩展) | ❌ 未实现(默认 Decline) | **搁置(可能做)** | 与 ACP `elicitation/create` 撞名(§9.2) | +| Elicitation(SEP-1036 正式扩展) | ❌ 未实现(默认 Decline) | **搁置(可能做)** | 与 ACP `elicitation/create` 撞名(§9.3) | | MCP Apps(正式扩展) | ❌ 未实现 | **本次设计范围:Peri stdio relay;Web Host 不做** | 前置:ACP capability gate、条件 MCP capability、raw resource/result DTO、connection-owned session、HITL seam | | WebSocket 传输 | ❌ 未接 | **隔离**(不接入主链路) | rmcp 支持(`ws.rs`);peri 仅 stdio + HTTP,需要时走独立路径 | | Progress / Cancelled 通知 | ❌ 未处理 | 不做 | 长任务进度、优雅取消不可见 | diff --git a/docs/standards/architecture-contracts.md b/docs/standards/architecture-contracts.md index 7577fbf48..3ffc441e7 100644 --- a/docs/standards/architecture-contracts.md +++ b/docs/standards/architecture-contracts.md @@ -89,6 +89,13 @@ - **Boundary**:会话工作区环境的 host/MCP 任务、compact hooks 与 Agent `TaskManager` 排空共同约束执行 lease 释放(ARC-WORKSPACE-001);事件交付仍按 ARC-EVENT-001 验证。外部共享资源不因会话关闭而取得全局销毁权限。 - **Verify**:`cargo test -p peri-acp --lib -- host::task_scope`、`cargo test -p peri-acp --lib -- host::stdio::run_server_integration_tests`、`cargo test -p peri-middlewares --lib -- mcp::task_scope`、`cargo test -p peri-middlewares --lib -- mcp::client`、`cargo test -p peri-tui --lib -- app::mcp_lifecycle_tests`、`cargo test -p peri-tui --lib -- acp_client::deployment::tests`、`cargo test -p peri-middlewares --lib -- test_close_registration_`;ACP `host/stdio/langfuse_shutdown_test.rs` 覆盖实际尾部事件、共享会话与移交 session close owner 的 Incomplete 重试,`transport/mpsc_test.rs` 覆盖显式 close 的双向 pending 与 EOF。 +### ARC-MCP-ACP-001 + +- **Scope**:`peri-acp-types`(契约)、`peri-middlewares`(`mcp::acp` 实现与 MCP 池归属)、`peri-acp`(host 接线)。 +- **Rule**:client 在会话 setup(`session/new` / `load` / `resume` / `fork`)以 `McpServer::Acp { name, serverId }` 声明由 ACP 通道承载的 MCP server;agent 在 `initialize` 以 `mcpCapabilities.acp` 声明支持,链路为 `mcp/connect { serverId }` → `mcp/message { connectionId, method, params }`(双向)→ `mcp/disconnect { connectionId }`。声明解析只取 acp 型条目,单条畸形只丢该条、不牵连同批合法声明;受理在 setup 响应成功写入之后(`mcp/connect` 只带 client 自己声明的 `serverId`,客户端须先拿到会话结果)。建连不得阻塞会话建立:登记后由后台任务完成握手与 `tools/list`,工具经既有 deferred 发现面进入模型可见集;失败只留在 MCP 池状态面(归属该会话的失败条目 + `ConfigSource::Acp`)与 WARN 日志,不回抛给会话 setup。连接归属声明它的会话:工具桥接、发现面与状态面按池内归属过滤,连接与工具不得跨会话泄漏,池内名冲突时追加序号而非覆盖既有条目。`mcp/message` 只带 `connectionId`,宿主按各会话服务的 `owns_connection` 定位承载者,未知或已关闭的连接返回 `-32001`。内层 MCP 错误码必须原样透传:rmcp lifecycle 依赖 `-32601` 等方法级错误码判定回退,抹平成 `-32603` 会让「方法不存在」与「连接故障」不可区分。会话终结(close / delete / transport EOF)必须在 MCP 池关闭之前断开该会话的全部连接,`close_session` 幂等;`mcp/disconnect` 的等待有上界(`SHUTDOWN_TIMEOUT`),静默对端不得把会话关闭拖成无限等待。 +- **Boundary**:建连后的 service 准入、池生命周期与关闭顺序仍归 ARC-HOST-SHUTDOWN-001;这些工具在会话工具视图中的 direct/deferred 分类与发现面归 ARC-TOOLS-001。client 侧如何承载该 server(子进程、端点、宿主运行时)是 client 实现细节,不在本契约内。 +- **Verify**:`cargo test -p peri-middlewares --lib -- mcp::acp`、`cargo test -p peri-acp --lib -- acp_mcp`、`cargo test -p peri-middlewares --lib -- mcp::client`;人工检查 `peri-middlewares/src/mcp/acp/`(会话状态机与桥接 transport)、`peri-acp/src/host/requests/acp_mcp.rs`(声明解析 / 出站网关 / 入站路由)、`peri-acp/src/host/workspace.rs` 的 `SessionEnvironment::shutdown` 断开点与 `peri-acp/src/host/server_loop.rs` 的 setup 后受理顺序。 + ### ARC-WORKFLOW-RPC-001 - **Scope**:`peri-workflow` Node stdio JSON-RPC 与 agent 生命周期。 @@ -116,8 +123,8 @@ ### ARC-MIDDLEWARE-CAPABILITY-001 - **Scope**:`peri-agent` hook 接口、执行适配器与 `peri-middlewares` 消费方。 -- **Rule**:hook 只接收其阶段真实支持的能力组合,不得继承完整 `MiddlewareState` 或通过 no-op 写方法、不可回写快照模拟能力。`StateView` 只读消息与 turn 元数据,不得暴露可写 queue/catalog 句柄;队列与目录操作分别由 `QueueState`、`CatalogState` 显式提供;`AfterAgentState` 的 `BackgroundActivity` 只读能力复用 Receive 的 session 后台活动事实,不暴露任务管理写权限。输入替换仅在 `before_agent` / `before_input` 按已有稳定 MessageId 进行,不增删或重排;整链成功或 Err 后均须 reconcile,追加消息同步写入 transcript 与本次视图。首次 Receive 按中间件链序交错执行初始化与输入准备,保证后续初始化看见已转换的附件;后续非空用户批次仅运行 `before_input`,不得重复初始化或扫描历史附件。`before_model` 追加对后续 `after_model` 可见;工具审批通过 ToolCall 返回修改,after-tool/after-agent 反馈通过队列保留原唤醒语义。目录重绑仍遵守 ARC-TOOLS-001 的 Reason 发布顺序;不得持 transcript、queue 或 catalog guard 跨外部 await。 -- **Verify**:`cargo test -p peri-agent --doc`(不支持能力的 compile-fail);`cargo test -p peri-agent --lib middleware::capabilities`(model/目录/工具与队列的生产 runner);`cargo test -p peri-agent --lib before_agent_reconciles`(成功与 Err 回写);`cargo test -p peri-agent --lib test_before_input`(首批顺序、后续批次、空批次与错误回写);`cargo test -p peri-middlewares --lib middleware::image`;检查 `peri-agent/src/middleware/{capabilities,trait}.rs` 与 `peri-agent/src/agent/stages/middleware_runner.rs`。 +- **Rule**:hook 只接收其阶段真实支持的能力组合,不得继承完整 `MiddlewareState` 或通过 no-op 写方法、不可回写快照模拟能力。`StateView` 只读消息与 turn 元数据,不得暴露可写 queue/catalog 句柄;队列与目录操作分别由 `QueueState`、`CatalogState` 显式提供;`AfterAgentState` 的 `BackgroundActivity` 只读能力复用 Receive 的 session 后台活动事实,不暴露任务管理写权限。输入替换仅在 `before_agent` / `before_input` 按已有稳定 MessageId 进行,不增删或重排;整链成功或 Err 后均须 reconcile,追加消息同步写入 transcript 与本次视图。首次 Receive 按中间件链序交错执行初始化与输入准备,保证后续初始化看见已转换的附件;后续非空用户批次仅运行 `before_input`,不得重复初始化或扫描历史附件。`before_model` 追加对后续 `after_model` 可见;工具审批通过 ToolCall 返回修改,after-tool/after-agent 反馈通过队列保留原唤醒语义。目录重绑仍遵守 ARC-TOOLS-001 的 Reason 发布顺序;不得持 transcript、queue 或 catalog guard 跨外部 await。启动闸门 `before_react_start` 是独立阶段:在首批 `before_agent` / `before_input` 之后、Compact 之前只执行一次,其能力面 `StartupState` 只提供候选工具的暂存与取出(不暴露可写 transcript/queue),该阶段 Err 不降级(`Interrupted` 仍按中断分类),既有 `before_agent` 的软失败处理不变。 +- **Verify**:`cargo test -p peri-agent --doc`(不支持能力的 compile-fail,含 `StartupState`);`cargo test -p peri-agent --lib middleware::capabilities`(model/目录/工具与队列的生产 runner);`cargo test -p peri-agent --lib before_agent_reconciles`(成功与 Err 回写);`cargo test -p peri-agent --lib test_before_input`(首批顺序、后续批次、空批次与错误回写);`cargo test -p peri-agent --lib -- startup_gate`(候选的提交、丢弃与中断分类);`cargo test -p peri-middlewares --lib middleware::image`;检查 `peri-agent/src/middleware/{capabilities,trait}.rs` 与 `peri-agent/src/agent/stages/middleware_runner.rs`。 ### ARC-SECRET-001 diff --git a/docs/standards/testing.md b/docs/standards/testing.md index d71a1ddec..171ce4b30 100644 --- a/docs/standards/testing.md +++ b/docs/standards/testing.md @@ -26,6 +26,18 @@ src/foo/mod_test.rs # 单元测试(≥30行) src/foo/bar.rs # 子模块(末尾可含 #[cfg(test)] mod tests) ``` +### 测试模块挂载与命名 + +生产模块内的 `_test.rs` 不会被 cargo 自动发现:必须在实现文件里挂载,否则 `cargo test` 以 0 tests 退出(假绿)。 + +```rust +#[cfg(test)] +#[path = "foo_test.rs"] +mod tests; // 或 mod foo_tests;(见下) +``` + +模块名参与 `cargo test -- <过滤词>` 匹配,因此当某个测试文件是 canonical 命令的命中目标时,模块名必须让该过滤词成立:过滤 `mcp::mcp_v4_seam` 的文件要挂成 `mod mcp_v4_seam_tests;`,沿用 `mod tests;` 会让过滤词命中不到(`cargo test` 仍以 0 tests 退出 0)。 + --- ## 二、测试优先级分层 @@ -295,6 +307,12 @@ cargo test -p peri-theme # 单测过滤 cargo test -p --lib -- +# 集成测试目标(crate 根 tests/,只访问 crate 的 pub API) +cargo test -p --test + +# 改进程级环境变量或起真实子进程/真实 wire 的目标:目标内不保证互斥,需串行 +cargo test -p --test -- --test-threads=1 + # pre-commit(不含测试) lefthook run pre-commit # fmt + check + clippy + typos ``` diff --git a/peri-acp-types/src/acp_mcp.rs b/peri-acp-types/src/acp_mcp.rs new file mode 100644 index 000000000..7b47f192c --- /dev/null +++ b/peri-acp-types/src/acp_mcp.rs @@ -0,0 +1,76 @@ +//! MCP over ACP(UNSTABLE)契约类型。 +//! +//! client 在会话 setup(`session/new` / `load` / `resume` / `fork`)中以 +//! `McpServer::Acp { name, serverId }` 声明由 ACP 通道承载的 MCP server; +//! agent 侧经 `mcp/connect` 建立连接,用 `mcp/message` 双向转发内层 MCP +//! JSON-RPC 消息,`mcp/disconnect` 收尾。 +//! +//! 本模块只描述协议载荷与运行时标识,不依赖 rmcp 或具体 transport;连接 +//! 建立、消息路由与池提交归 `peri-middlewares`。 + +use serde_json::{Map, Value}; + +/// client 声明的一个 acp 型 MCP server(绑定到具体会话)。 +/// +/// `server_id` 是 client 生成的不透明标识,`mcp/connect` 依它把连接路由回 +/// 声明方;`name` 是人类可读名,用于工具命名与展示。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpMcpServerSpec { + pub session_id: String, + pub name: String, + pub server_id: String, +} + +/// client → agent 的 `mcp/message` 载荷。 +/// +/// `params` 为内层 MCP 消息参数;内层请求的配对由 ACP 请求 id 承载,载荷 +/// 本身不含 id(通知载荷无 ACP id,以 `mcp/message` 通知投递)。 +#[derive(Debug, Clone, PartialEq)] +pub struct AcpMcpInbound { + /// 该消息所属的 MCP-over-ACP 连接(`mcp/connect` 返回的 + /// `connectionId`)。 + pub connection_id: String, + /// 内层 MCP 方法名。 + pub method: String, + /// 内层 MCP 参数;缺省表示无参数。 + pub params: Option>, +} + +/// MCP over ACP 运行面错误(协议码 + 摘要,不含内层消息正文)。 +/// +/// 由实现方在连接失败、连接未知、内层 MCP 错误等场景返回;host 侧按其 +/// `code` 映射为 ACP JSON-RPC 错误码。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpMcpError { + pub code: i64, + pub message: String, +} + +impl AcpMcpError { + /// 资源不存在(未知/已关闭的 connectionId、未声明的 serverId)。 + pub const CODE_NOT_FOUND: i64 = -32001; + /// 服务不可用(会话已关闭、连接不可建立、池已关闭)。 + pub const CODE_UNAVAILABLE: i64 = -32002; + + pub fn not_found(message: impl Into) -> Self { + Self { + code: Self::CODE_NOT_FOUND, + message: message.into(), + } + } + + pub fn unavailable(message: impl Into) -> Self { + Self { + code: Self::CODE_UNAVAILABLE, + message: message.into(), + } + } +} + +impl std::fmt::Display for AcpMcpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MCP over ACP error [{}]: {}", self.code, self.message) + } +} + +impl std::error::Error for AcpMcpError {} diff --git a/peri-acp-types/src/lib.rs b/peri-acp-types/src/lib.rs index 6f4da6c94..85afb3e70 100644 --- a/peri-acp-types/src/lib.rs +++ b/peri-acp-types/src/lib.rs @@ -31,6 +31,7 @@ //! - `plugin` — 插件契约(PluginManifest/LoadedPlugin/PluginLoadResult/PluginManagerPort) //! - `ports` — 装配注入端口(McpPoolPort/ToolSearchPort/WorkflowMiddlewarePort/SkillsPort) +pub mod acp_mcp; pub mod agents; pub mod command; // 注册表顶层 re-export(Phase 2 消费方路径 `peri_acp_types::command_registry::*`, diff --git a/peri-acp-types/src/plugin.rs b/peri-acp-types/src/plugin.rs index 2993e9463..1c74e655f 100644 --- a/peri-acp-types/src/plugin.rs +++ b/peri-acp-types/src/plugin.rs @@ -28,6 +28,9 @@ pub enum ConfigSource { Global(PathBuf), /// 插件配置 Plugin, + /// 会话级声明:client 在 ACP 会话 setup 中以 `McpServer::Acp` 声明的 + /// MCP over ACP 服务器(无配置文件条目,归属绑定声明它的会话)。 + Acp, } /// 显式 MCP 协议版本。 @@ -39,7 +42,10 @@ pub enum McpProtocolVersion { } /// 单个 MCP 服务器配置 -#[derive(Debug, Clone, Serialize, Deserialize)] +/// +/// `Deserialize` 由手写实现提供(见 [`McpServerConfigWire`]):System 三个字段 +/// 拒绝显式 `null`,非法组合在解析期即按 [`McpServerConfigValidationError`] 拒绝。 +#[derive(Debug, Clone, Serialize)] pub struct McpServerConfig { /// stdio 传输的可执行命令(如 "npx") pub command: Option, @@ -71,11 +77,183 @@ pub struct McpServerConfig { /// subscriptions/listen 订阅配置(2026-07-28 协议;仅负责连接后建立订阅) #[serde(default, skip_serializing_if = "Option::is_none")] pub subscriptions: Option, + /// 启动依赖标识(System MCP):react loop 启动前必须完成 transport / initialize / + /// 能力协商。缺省 `None`,消费判定固定为 `== Some(true)`;`false`/`None` 是普通 MCP。 + /// 写回可省略 `false`/`None`。 + #[serde( + default, + rename = "system_mcp", + alias = "systemMcp", + skip_serializing_if = "is_false" + )] + pub system_mcp: Option, + /// System MCP 必须提供的工具名数组(在所属 server 的原始工具名上精确匹配): + /// 只与 `system_mcp = true` 配合使用;显式 `[]` 表示只要求 ready、不注入额外工具。 + /// `None` 与 `Some([])` 必须保持可区分并可无损写回。 + #[serde( + default, + rename = "system_mcp_tools", + alias = "systemMcpTools", + skip_serializing_if = "Option::is_none" + )] + pub system_mcp_tools: Option>, + /// System MCP 启动等待超时(毫秒);缺省 + /// [`McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS`],合法区间 + /// [`McpServerConfig::MIN_SYSTEM_MCP_TIMEOUT_MS`]`..=`[`McpServerConfig::MAX_SYSTEM_MCP_TIMEOUT_MS`]。 + /// 只与 `system_mcp = true` 配合使用。 + #[serde( + default, + rename = "system_mcp_timeout", + alias = "systemMcpTimeout", + skip_serializing_if = "Option::is_none" + )] + pub system_mcp_timeout: Option, /// 配置来源(运行时标记,不序列化) #[serde(skip)] pub source: Option, } +/// `McpServerConfig` 的私有 wire helper。 +/// +/// 字段与 serde 属性同 [`McpServerConfig`](`source` 仍不参与 wire),差别只在 +/// System 三个字段使用 `deserialize_with`:字段缺失才走 `default`(`None`), +/// 显式 `null` / 类型不符一律解析失败,不得降级为“未配置”。 +/// +/// 保留 derive 的 duplicate key 检测:不得先转 `serde_json::Value` +/// (对象去重会丢失重复 key,两种拼法同时出现就无法报错)。 +#[derive(Deserialize)] +struct McpServerConfigWire { + command: Option, + #[serde(default)] + args: Option>, + #[serde(default)] + env: Option>, + url: Option, + #[serde(default)] + headers: Option>, + #[serde(default)] + oauth: Option, + #[serde(default)] + disabled: Option, + #[serde(default, rename = "protocolVersion")] + protocol_version: Option, + #[serde(default)] + subscriptions: Option, + #[serde( + default, + rename = "system_mcp", + alias = "systemMcp", + deserialize_with = "deserialize_present_system_mcp" + )] + system_mcp: Option, + #[serde( + default, + rename = "system_mcp_tools", + alias = "systemMcpTools", + deserialize_with = "deserialize_present_system_mcp_tools" + )] + system_mcp_tools: Option>, + #[serde( + default, + rename = "system_mcp_timeout", + alias = "systemMcpTimeout", + deserialize_with = "deserialize_present_system_mcp_timeout" + )] + system_mcp_timeout: Option, +} + +/// `system_mcp` 的 wire 反序列化:显式 `null` 拒绝,缺失由 `default` 落到 `None`。 +fn deserialize_present_system_mcp<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + bool::deserialize(deserializer).map(Some) +} + +/// `system_mcp_tools` 的 wire 反序列化:显式 `null`、非数组、非字符串元素均拒绝。 +fn deserialize_present_system_mcp_tools<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Vec::::deserialize(deserializer).map(Some) +} + +/// `system_mcp_timeout` 的 wire 反序列化:显式 `null`、非整数均拒绝。 +fn deserialize_present_system_mcp_timeout<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + u64::deserialize(deserializer).map(Some) +} + +impl<'de> Deserialize<'de> for McpServerConfig { + fn deserialize>(deserializer: D) -> Result { + let wire = McpServerConfigWire::deserialize(deserializer)?; + let config = McpServerConfig { + command: wire.command, + args: wire.args, + env: wire.env, + url: wire.url, + headers: wire.headers, + oauth: wire.oauth, + disabled: wire.disabled, + protocol_version: wire.protocol_version, + subscriptions: wire.subscriptions, + system_mcp: wire.system_mcp, + system_mcp_tools: wire.system_mcp_tools, + system_mcp_timeout: wire.system_mcp_timeout, + source: None, + }; + // 非法组合在解析期拒绝,错误正文为固定契约文本。 + config.validate().map_err(serde::de::Error::custom)?; + Ok(config) + } +} + +impl McpServerConfig { + /// `system_mcp_timeout` 缺省值(毫秒):未配置时的有效启动等待超时。 + pub const DEFAULT_SYSTEM_MCP_TIMEOUT_MS: u64 = 30_000; + /// `system_mcp_timeout` 合法区间下界(毫秒)。 + pub const MIN_SYSTEM_MCP_TIMEOUT_MS: u64 = 1; + /// `system_mcp_timeout` 合法区间上界(毫秒,10 分钟)。 + pub const MAX_SYSTEM_MCP_TIMEOUT_MS: u64 = 600_000; + + /// 纯数据不变量校验:System key 与 `system_mcp = true` 的组合、timeout 区间。 + /// + /// 无副作用、无 namespace / transport / I/O 依赖;`disabled = true` 也照常校验。 + /// 确定性优先级:先组合错误(`system_mcp_tools` 先于 `system_mcp_timeout`), + /// 再 timeout 区间。 + pub fn validate(&self) -> Result<(), McpServerConfigValidationError> { + if self.system_mcp != Some(true) { + if self.system_mcp_tools.is_some() { + return Err(McpServerConfigValidationError::SystemMcpToolsRequiresSystemMcp); + } + if self.system_mcp_timeout.is_some() { + return Err(McpServerConfigValidationError::SystemMcpTimeoutRequiresSystemMcp); + } + } + if let Some(timeout) = self.system_mcp_timeout { + if !(Self::MIN_SYSTEM_MCP_TIMEOUT_MS..=Self::MAX_SYSTEM_MCP_TIMEOUT_MS) + .contains(&timeout) + { + return Err(McpServerConfigValidationError::SystemMcpTimeoutOutOfRange); + } + } + Ok(()) + } +} + +/// MCP 服务器配置的纯校验错误(固定规则文本,不携带配置内容)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum McpServerConfigValidationError { + /// 声明了 `system_mcp_tools` 却没有 `system_mcp = true`(含显式 `[]`)。 + #[error("system_mcp_tools requires system_mcp = true")] + SystemMcpToolsRequiresSystemMcp, + /// 声明了 `system_mcp_timeout` 却没有 `system_mcp = true`。 + #[error("system_mcp_timeout requires system_mcp = true")] + SystemMcpTimeoutRequiresSystemMcp, + /// `system_mcp_timeout` 超出合法区间。 + #[error("system_mcp_timeout must be within 1..=600000 milliseconds")] + SystemMcpTimeoutOutOfRange, +} + /// `subscriptions/listen` 订阅配置(2026-07-28 协议) /// /// 任一字段非空即启用订阅:连接后建立对应过滤器的 @@ -481,3 +659,305 @@ pub trait PluginManagerPort: Send + Sync { /// 聚合快照:已启用插件 × 已安装记录 → 协议快照条目(plugin-snapshot 事件)。 fn snapshot(&self, claude_dir: &Path) -> Vec; } + +#[cfg(test)] +mod tests { + use super::*; + + /// 仅含缺省字段的 typed 配置(`McpServerConfig` 没有 `Default`)。 + fn empty_config() -> McpServerConfig { + McpServerConfig { + command: None, + args: None, + env: None, + url: None, + headers: None, + oauth: None, + disabled: None, + protocol_version: None, + subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, + source: None, + } + } + + fn parse(json: &str) -> Result { + serde_json::from_str(json) + } + + /// 旧 JSON 兼容:新增 key 全部缺省为 None,输出不出现新 key,既有语义不变。 + #[test] + fn test_system_mcp_legacy_defaults() { + let cfg = parse(r#"{"command":"npx","protocolVersion":"2026-07-28"}"#) + .expect("旧 JSON 必须仍可解析"); + assert!(cfg.system_mcp.is_none(), "旧 JSON 不得推断出启动依赖"); + assert!(cfg.system_mcp_tools.is_none()); + assert!(cfg.system_mcp_timeout.is_none()); + assert!(cfg.validate().is_ok(), "缺省 System 字段必须合法"); + assert_eq!(cfg.protocol_version, Some(McpProtocolVersion::V2026_07_28)); + assert!(cfg.source.is_none(), "source 是运行时标记,不从 wire 读取"); + + let json = serde_json::to_value(&cfg).unwrap(); + for key in ["system_mcp", "system_mcp_tools", "system_mcp_timeout"] { + assert!(json.get(key).is_none(), "缺省不得序列化 {key}: {json}"); + } + assert_eq!(json["protocolVersion"], serde_json::json!("2026-07-28")); + assert!(json.get("source").is_none(), "source 不进入 wire: {json}"); + + let legacy = parse(r#"{"command":"npx","disabled":true,"args":["-y"]}"#).unwrap(); + assert_eq!(legacy.disabled, Some(true)); + assert_eq!(legacy.args, Some(vec!["-y".to_string()])); + } + + /// 契约 1:`system_mcp_tools` 没有 `system_mcp = true` 一律非法,含显式 `[]`。 + #[test] + fn test_system_mcp_tools_requires_true() { + for json in [ + r#"{"command":"npx","system_mcp_tools":[]}"#, + r#"{"command":"npx","system_mcp_tools":["Read"]}"#, + r#"{"command":"npx","system_mcp":false,"system_mcp_tools":[]}"#, + r#"{"command":"npx","system_mcp":false,"system_mcp_tools":["Read"]}"#, + r#"{"command":"npx","systemMcpTools":["Read"]}"#, + ] { + let err = parse(json).expect_err("非法组合必须解析失败"); + assert!( + err.to_string() + .contains("system_mcp_tools requires system_mcp = true"), + "固定规则正文必须保留: {json} -> {err}" + ); + } + + let typed = McpServerConfig { + system_mcp: Some(false), + system_mcp_tools: Some(vec![]), + ..empty_config() + }; + assert_eq!( + typed.validate().unwrap_err(), + McpServerConfigValidationError::SystemMcpToolsRequiresSystemMcp + ); + let missing = McpServerConfig { + system_mcp_tools: Some(vec!["Read".to_string()]), + ..empty_config() + }; + assert_eq!( + missing.validate().unwrap_err(), + McpServerConfigValidationError::SystemMcpToolsRequiresSystemMcp + ); + + for tools in ["[]", r#"["Read","Write"]"#] { + let json = + format!(r#"{{"command":"npx","system_mcp":true,"system_mcp_tools":{tools}}}"#); + let cfg = parse(&json).expect("true + 工具数组必须合法"); + assert_eq!(cfg.system_mcp, Some(true)); + assert!(cfg.validate().is_ok()); + } + } + + /// 契约 4 配置语义:`true + []` 与 `true + 缺省 tools` 必须保持可区分并无损写回。 + #[test] + fn test_system_mcp_empty_tools_roundtrip() { + let cfg = parse(r#"{"command":"npx","system_mcp":true,"system_mcp_tools":[]}"#).unwrap(); + assert_eq!(cfg.system_mcp, Some(true)); + assert_eq!(cfg.system_mcp_tools, Some(vec![])); + + let json = serde_json::to_value(&cfg).unwrap(); + assert_eq!(json["system_mcp"], serde_json::json!(true)); + assert_eq!( + json["system_mcp_tools"], + serde_json::json!([]), + "Some([]) 不得被省略: {json}" + ); + let back: McpServerConfig = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(back.system_mcp_tools, Some(vec![])); + assert_eq!(serde_json::to_value(&back).unwrap(), json, "往返必须无损"); + + let no_tools = parse(r#"{"command":"npx","system_mcp":true}"#).unwrap(); + assert!( + no_tools.system_mcp_tools.is_none(), + "未声明 tools 保持 None,不自动补空数组" + ); + let no_tools_json = serde_json::to_value(&no_tools).unwrap(); + assert!( + no_tools_json.get("system_mcp_tools").is_none(), + "None 不得序列化: {no_tools_json}" + ); + } + + /// snake_case 为 canonical;camelCase 别名输入等价;两种拼法同时出现报 duplicate field。 + #[test] + fn test_system_mcp_key_aliases() { + let snake = parse( + r#"{"command":"npx","system_mcp":true,"system_mcp_tools":["Read"],"system_mcp_timeout":1500}"#, + ) + .unwrap(); + let camel = parse( + r#"{"command":"npx","systemMcp":true,"systemMcpTools":["Read"],"systemMcpTimeout":1500}"#, + ) + .unwrap(); + assert_eq!(camel.system_mcp, snake.system_mcp); + assert_eq!(camel.system_mcp_tools, snake.system_mcp_tools); + assert_eq!(camel.system_mcp_timeout, snake.system_mcp_timeout); + + let json = serde_json::to_value(&camel).unwrap(); + assert_eq!(json["system_mcp"], serde_json::json!(true)); + assert_eq!(json["system_mcp_tools"], serde_json::json!(["Read"])); + assert_eq!(json["system_mcp_timeout"], serde_json::json!(1500)); + let text = serde_json::to_string(&camel).unwrap(); + assert!( + !text.contains("systemMcp"), + "canonical 输出只允许 snake_case: {text}" + ); + + for json in [ + r#"{"system_mcp":true,"systemMcp":true}"#, + r#"{"system_mcp":true,"system_mcp_tools":[],"systemMcpTools":[]}"#, + r#"{"system_mcp":true,"system_mcp_timeout":1000,"systemMcpTimeout":1000}"#, + ] { + let err = parse(json).expect_err("两种拼法同时出现必须失败"); + assert!( + err.to_string().contains("duplicate field"), + "必须报 duplicate field: {json} -> {err}" + ); + } + } + + /// 新增 key 的显式 null 与错误类型一律拒绝,不得降级为 None / 空数组。 + #[test] + fn test_system_mcp_rejects_null_and_wrong_types() { + for json in [ + r#"{"command":"npx","system_mcp":null}"#, + r#"{"command":"npx","system_mcp":"true"}"#, + r#"{"command":"npx","system_mcp":1}"#, + r#"{"command":"npx","system_mcp_tools":null}"#, + r#"{"command":"npx","system_mcp":true,"system_mcp_tools":"Read"}"#, + r#"{"command":"npx","system_mcp":true,"system_mcp_tools":[1]}"#, + r#"{"command":"npx","system_mcp":true,"system_mcp_tools":[null]}"#, + r#"{"command":"npx","system_mcp_timeout":null}"#, + r#"{"command":"npx","system_mcp":true,"system_mcp_timeout":"30000"}"#, + r#"{"command":"npx","system_mcp":true,"system_mcp_timeout":-1}"#, + ] { + assert!(parse(json).is_err(), "必须拒绝: {json}"); + } + + let err = parse(r#"{"system_mcp":true,"system_mcp_tools":null}"#).unwrap_err(); + assert!( + err.to_string().contains("invalid type: null"), + "显式 null 不得被当成未配置: {err}" + ); + + let ok = parse( + r#"{"command":"npx","system_mcp":true,"system_mcp_tools":[],"system_mcp_timeout":30000}"#, + ) + .expect("合法组合不得被误拒"); + assert!(ok.validate().is_ok()); + } + + /// 工具名数组逐项保真:不 trim / 不排序 / 不去重 / 不展开 `${...}` / 不加前缀。 + #[test] + fn test_system_mcp_tools_preserve_exact_values() { + let json = r#"{"command":"npx","system_mcp":true,"system_mcp_tools":["Read","read","READ","Read","","${VAR}"," spaced ","mcp__other__Tool"]}"#; + let cfg = parse(json).unwrap(); + let expected = serde_json::json!([ + "Read", + "read", + "READ", + "Read", + "", + "${VAR}", + " spaced ", + "mcp__other__Tool" + ]); + assert_eq!( + cfg.system_mcp_tools, + Some( + expected + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect::>() + ) + ); + let back = serde_json::to_value(&cfg).unwrap(); + assert_eq!(back["system_mcp_tools"], expected, "值与顺序必须原样写回"); + } + + /// `system_mcp_timeout` 只与 `system_mcp = true` 配合,缺省为常量 30_000ms。 + #[test] + fn test_system_mcp_timeout_requires_true() { + for json in [ + r#"{"command":"npx","system_mcp_timeout":1000}"#, + r#"{"command":"npx","system_mcp":false,"system_mcp_timeout":1000}"#, + r#"{"command":"npx","systemMcpTimeout":1000}"#, + ] { + let err = parse(json).expect_err("无 system_mcp = true 时 timeout 必须非法"); + assert!( + err.to_string() + .contains("system_mcp_timeout requires system_mcp = true"), + "固定规则正文必须保留: {json} -> {err}" + ); + } + + let typed = McpServerConfig { + system_mcp: Some(false), + system_mcp_timeout: Some(1000), + ..empty_config() + }; + assert_eq!( + typed.validate().unwrap_err(), + McpServerConfigValidationError::SystemMcpTimeoutRequiresSystemMcp + ); + assert_eq!(McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS, 30_000); + assert_eq!(McpServerConfig::MIN_SYSTEM_MCP_TIMEOUT_MS, 1); + assert_eq!(McpServerConfig::MAX_SYSTEM_MCP_TIMEOUT_MS, 600_000); + } + + /// `system_mcp_timeout` 区间 1..=600_000 毫秒;区间内往返保真,缺省不写回。 + #[test] + fn test_system_mcp_timeout_range_and_roundtrip() { + for ms in [1u64, 30_000, 600_000] { + let json = format!(r#"{{"system_mcp":true,"system_mcp_timeout":{ms}}}"#); + let cfg = parse(&json).unwrap(); + assert_eq!(cfg.system_mcp_timeout, Some(ms)); + assert_eq!( + serde_json::to_value(&cfg).unwrap()["system_mcp_timeout"], + serde_json::json!(ms) + ); + } + + for ms in [0u64, 600_001] { + let json = format!(r#"{{"system_mcp":true,"system_mcp_timeout":{ms}}}"#); + let err = parse(&json).expect_err("越界 timeout 必须解析失败"); + assert!( + err.to_string() + .contains("system_mcp_timeout must be within 1..=600000 milliseconds"), + "固定规则正文必须保留: {err}" + ); + let typed = McpServerConfig { + system_mcp: Some(true), + system_mcp_timeout: Some(ms), + ..empty_config() + }; + assert_eq!( + typed.validate().unwrap_err(), + McpServerConfigValidationError::SystemMcpTimeoutOutOfRange + ); + } + + let none = parse(r#"{"system_mcp":true}"#).unwrap(); + assert!(none.system_mcp_timeout.is_none()); + assert_eq!( + none.system_mcp_timeout + .unwrap_or(McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS), + 30_000, + "缺省有效值为 30_000ms" + ); + assert!(serde_json::to_value(&none) + .unwrap() + .get("system_mcp_timeout") + .is_none()); + } +} diff --git a/peri-acp-types/src/ports.rs b/peri-acp-types/src/ports.rs index bca7dff45..aba049d4d 100644 --- a/peri-acp-types/src/ports.rs +++ b/peri-acp-types/src/ports.rs @@ -14,6 +14,7 @@ use std::any::{Any, TypeId}; use std::path::PathBuf; use std::sync::Arc; +use crate::acp_mcp::{AcpMcpError, AcpMcpInbound, AcpMcpServerSpec}; use crate::agents::AgentCapability; use crate::dynamic_mcp::{ CanonicalDynamicMcpAction, DynamicMcpCatalogTool, DynamicMcpFailure, DynamicMcpInstanceKey, @@ -114,6 +115,55 @@ impl dyn McpPoolPort { } } +/// ACP 侧 agent→client 发送网关(MCP over ACP 的传输切片)。 +/// +/// 由 host 按 ACP 连接提供(`AcpTransport` 的请求/通知子集);middlewares 的 +/// 桥接 transport 经它把内层 MCP 消息投递为 `mcp/message`。实现不得在失败时 +/// 静默吞掉消息:投递失败必须返回错误,由桥接按连接失败处理。 +#[async_trait::async_trait] +pub trait AcpMcpGatewayPort: Send + Sync { + /// 发送 `mcp/message` 请求并等待内层 MCP 结果。 + async fn request( + &self, + method: &str, + params: serde_json::Value, + ) -> Result; + + /// 发送 `mcp/message` 通知(无响应)。 + async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), AcpMcpError>; +} + +/// 会话级 MCP over ACP 服务端口(`peri-middlewares` 实现)。 +/// +/// 一个端口服务一个 ACP 连接的多个会话:`attach` 注册 client 在会话 setup 中 +/// 声明的 server 并后台建连(连接就绪后工具经 MCP 池进入 deferred 发现); +/// `request` / `notify` 路由 client 反向下发的 `mcp/message`;`close_session` +/// 在会话结束(close / 删除 / transport 关闭 / 进程退出)时断开该会话全部连接。 +#[async_trait::async_trait] +pub trait AcpMcpServerPort: Send + Sync { + /// 注册并后台连接会话声明的 acp 型 server。 + /// + /// 同会话同 `server_id` 重复声明按幂等处理(已连接或正在连接的跳过)。 + /// 建连失败不影响调用方:失败在池状态面可见,不阻塞会话建立。 + fn attach(&self, gateway: Arc, servers: Vec); + + /// 处理 client 下发的 `mcp/message` 请求,返回内层 MCP 结果。 + async fn request(&self, inbound: AcpMcpInbound) -> Result; + + /// 处理 client 下发的 `mcp/message` 通知。 + async fn notify(&self, inbound: AcpMcpInbound) -> Result<(), AcpMcpError>; + + /// 该服务是否承载指定连接(入站 `mcp/message` 的定位依据)。 + /// + /// 入站消息只带 `connectionId`、不带 `sessionId`,而 MCP 池是会话级的 + /// (宿主装配每个会话各持一份连接事实),因此宿主需要在各会话服务间 + /// 定位承载者:只有 `true` 的那个才能路由该消息。 + fn owns_connection(&self, connection_id: &str) -> bool; + + /// 关闭会话的全部 ACP MCP 连接(发送 `mcp/disconnect`、移除池条目)。幂等。 + async fn close_session(&self, session_id: &str); +} + /// 工具检索索引端口(`peri-middlewares::tool_search::ToolSearchIndex` 实现)。 pub trait ToolSearchPort: Send + Sync { /// 还原具体实现(downcast 还原点,供 middlewares 装配面与装配面宿主使用)。 diff --git a/peri-acp/CLAUDE.md b/peri-acp/CLAUDE.md index 7fd26aae7..89151be6e 100644 --- a/peri-acp/CLAUDE.md +++ b/peri-acp/CLAUDE.md @@ -30,6 +30,7 @@ - Langfuse 事件只经 `peri-controller` 的 `LangfuseBridge` 统一映射进入 tracer(协议化前分支,不参与业务链路);日志、错误和遥测不得泄露 secret。 - stdio/MPSC transport 的 pending request 由 router 统一持有:response、caller cancellation 与 terminal close 至多结算一次;终止结算当前和后续请求,连接静默不引入隐式 timeout(ARC-TRANSPORT-001)。 - Host 后台任务由 non-Clone `HostTaskOwner` 持有,config/task 只持 weak `HostTaskSpawner`;MCP concrete owner 属 middlewares,ACP config 只能持 `peri-acp-types::ports::McpTaskOwnerPort`,禁止直接依赖 concrete type。transport EOF 关闭准入后取消并 drain local/manager 会话 ID 并集,再在锁外关闭 LSP/MCP。Host drain 或 MCP service-close report 超时必须报告 `Incomplete` 并保持 Closing,不得当作已经 join/Closed(ARC-HOST-SHUTDOWN-001)。 +- 会话 setup(`session/new` / `load` / `resume` / `fork`)里的 `mcpServers` acp 型声明在响应写入后由 `host/requests/acp_mcp.rs` 受理(`attach_session_servers`);`mcp/connect` 只带 client 声明的 `serverId`,因此受理顺序不能提前到响应之前。会话级服务持有连接(每个会话一个 MCP 池),入站 `mcp/message` 按 `connectionId` 定位承载会话、未知连接返回 `-32001`,内层 MCP 错误码原样透传;会话终结在 MCP 池关闭前调 `AcpMcpServerPort::close_session`(幂等)。建连是后台的:不阻塞会话建立,失败留在 MCP 池状态面(ARC-MCP-ACP-001)。 ## 目标命令 @@ -37,6 +38,7 @@ cargo check -p peri-acp cargo test -p peri-acp --lib cargo test -p peri-acp --lib -- host::task_scope +cargo test -p peri-acp --lib -- acp_mcp cargo test -p peri-acp --lib mapper cargo test -p peri-controller --test langfuse_e2e cargo test -p peri-acp --doc diff --git a/peri-acp/Cargo.toml b/peri-acp/Cargo.toml index cd15703cf..e561ec8ef 100644 --- a/peri-acp/Cargo.toml +++ b/peri-acp/Cargo.toml @@ -37,7 +37,10 @@ peri-model = { path = "../peri-model" } peri-middlewares = { path = "../peri-middlewares" } peri-resources = { path = "../peri-resources" } agent-client-protocol = { version = "2", features = ["unstable"] } -agent-client-protocol-schema = { version = "=1.5.0", features = ["unstable_elicitation"] } +# `unstable_mcp_over_acp`:MCP over ACP 的类型(`McpCapabilities` / `McpServer::Acp` +# / `ConnectMcpRequest`)与 ACP `mcp/*` 方法名。当前由 `agent-client-protocol` 的 +# `unstable` 特性归一化间接开启,此处显式声明,避免上游特性组合变动时静默丢失。 +agent-client-protocol-schema = { version = "=1.5.0", features = ["unstable_elicitation", "unstable_mcp_over_acp"] } tokio.workspace = true async-trait.workspace = true serde.workspace = true diff --git a/peri-acp/src/dispatch/init.rs b/peri-acp/src/dispatch/init.rs index 36decf4cb..e87555487 100644 --- a/peri-acp/src/dispatch/init.rs +++ b/peri-acp/src/dispatch/init.rs @@ -4,9 +4,9 @@ // 与 TUI 路径的 AcpServerConfig 对齐,否则 client 无法使用对应功能。 use agent_client_protocol_schema::v1::{ - AgentCapabilities, InitializeResponse, PromptCapabilities, SessionCapabilities, - SessionCloseCapabilities, SessionDeleteCapabilities, SessionForkCapabilities, - SessionListCapabilities, SessionResumeCapabilities, + AgentCapabilities, InitializeResponse, McpCapabilities, PromptCapabilities, + SessionCapabilities, SessionCloseCapabilities, SessionDeleteCapabilities, + SessionForkCapabilities, SessionListCapabilities, SessionResumeCapabilities, }; use agent_client_protocol_schema::ProtocolVersion; use peri_acp_types::PeriCaps; @@ -17,6 +17,10 @@ use peri_acp_types::PeriCaps; /// Echoes the client's declared peri caps back via `_meta` so the client /// can verify which extensions the agent will honor. /// +/// `mcpCapabilities.acp` 声明 agent 支持 MCP over ACP(client 在会话 setup 中 +/// 以 `type: "acp"` 声明 server,agent 经 `mcp/connect` 反向建连)。声明是硬 +/// 前置:未声明的 client 不得假定 agent 会处理 `mcp/message`。 +/// /// Used by both TUI (MpscTransport) and stdio transport implementations. pub fn build_initialize_response(peri_caps: &PeriCaps) -> InitializeResponse { let caps = AgentCapabilities::new() @@ -29,7 +33,8 @@ pub fn build_initialize_response(peri_caps: &PeriCaps) -> InitializeResponse { .resume(SessionResumeCapabilities::new()) .fork(SessionForkCapabilities::new()) .delete(SessionDeleteCapabilities::new()), - ); + ) + .mcp_capabilities(McpCapabilities::new().acp(true)); let caps = caps.meta(peri_caps.to_agent_meta()); InitializeResponse::new(ProtocolVersion::V1).agent_capabilities(caps) } diff --git a/peri-acp/src/host/assemble.rs b/peri-acp/src/host/assemble.rs index da0d585ed..152674596 100644 --- a/peri-acp/src/host/assemble.rs +++ b/peri-acp/src/host/assemble.rs @@ -488,6 +488,14 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( let mcp_subscription: Option> = mcp_pool_concrete .clone() .map(|p| p as Arc); + // MCP over ACP 服务:会话 setup 声明的 `type: "acp"` server 经它建连,连接 + // 进的就是**本装配的池**——会话级装配(`session_resources`)才有池,连接 + // 因此只能进声明它的会话的工具面。host 级装配无池即无此服务。 + let acp_mcp: Option> = + mcp_pool_concrete.clone().map(|pool| { + Arc::new(peri_middlewares::mcp::AcpMcpService::new(pool)) + as Arc + }); let mcp_apps_relay: Option> = if mcp_profile.apps_enabled() { mcp_pool_concrete.clone().map(|pool| { @@ -620,6 +628,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( cron_scheduler, mcp_pool, mcp_apps_relay, + acp_mcp, dynamic_mcp: Some(dynamic_mcp), oauth_event_tx: Some(oauth_event_tx), oauth_event_rx: Some(oauth_event_rx), diff --git a/peri-acp/src/host/executor_flow_test.rs b/peri-acp/src/host/executor_flow_test.rs index 1641b2125..aa2b481ce 100644 --- a/peri-acp/src/host/executor_flow_test.rs +++ b/peri-acp/src/host/executor_flow_test.rs @@ -83,7 +83,10 @@ impl Drop for HomeGuard { // ── Mock EventSink ───────────────────────────────────────────────────────── /// Mock EventSink,记录所有 push_done 调用(含 request_id)与事件流。 -struct MockEventSink { +/// +/// `pub(super)`:`host::mcp_v4_startup_tests`(B-07 的 MCP 启动准入用例)复用同一 +/// 观测面,避免测试各自维护一套事件顺序断言(字段仍私有,只经访问器暴露快照)。 +pub(super) struct MockEventSink { push_done_count: Mutex, push_done_stop_reasons: Mutex>, pushed_events: Mutex>, @@ -91,7 +94,7 @@ struct MockEventSink { } impl MockEventSink { - fn new() -> Self { + pub(super) fn new() -> Self { Self { push_done_count: Mutex::new(0), push_done_stop_reasons: Mutex::new(Vec::new()), @@ -100,9 +103,14 @@ impl MockEventSink { } } - fn push_done_count(&self) -> usize { + pub(super) fn push_done_count(&self) -> usize { *self.push_done_count.lock().unwrap() } + + /// 事件流快照(与 `push_done` 交错记录,用于断言 terminal 顺序)。 + pub(super) fn operations(&self) -> Vec { + self.operations.lock().unwrap().clone() + } } #[async_trait] @@ -267,7 +275,9 @@ impl UserInteractionBroker for NoopBroker { /// 构造最小 SessionContext(flow 测试走预取消中断路径;stage 装配桥经 /// 真实 ACP 桥注入——与生产 host/prompt.rs 同模式;LLM 工厂从测试 /// LlmProvider + AgentPool 烘焙,装配路径实际调用)。 -fn make_session_context(session_id: &str) -> SessionContext { +/// +/// `pub(super)`:`host::mcp_v4_startup_tests` 复用同一装配面后按需注入 MCP pool。 +pub(super) fn make_session_context(session_id: &str) -> SessionContext { // 事件广播宿主:发射端(EventPublisher 适配)与订阅端(subscribe 工厂) // 共享同一 Controller 实例,保持迁移前「publish/subscribe 同一广播」语义。 let controller = Arc::new(peri_controller::Controller::new( @@ -502,7 +512,7 @@ async fn make_session_context_with_manager( /// 构造 stage 装配桥(真实 ACP 桥,与生产 host/prompt.rs 同模式:ZST /// ProductionChainAssembler + build_compact_hooks(测试 ctx hook_groups 为空 /// → (None, None));测试无 Langfuse → bridge factory None)。 -fn make_stage_build(ctx: &SessionContext) -> StageBuildFn { +pub(super) fn make_stage_build(ctx: &SessionContext) -> StageBuildFn { let ctx_for_stage = ctx.clone(); Arc::new(move |sbr| { let (compact_pre_hook, compact_post_hook) = crate::host::prompt::build_compact_hooks( @@ -624,7 +634,7 @@ fn make_aborting_forwarder_launcher() -> ForwarderLauncherFn { }) } -fn make_turn_input( +pub(super) fn make_turn_input( event_sink: Arc, content: MessageContent, continuation: bool, diff --git a/peri-acp/src/host/mcp_v4_startup_test.rs b/peri-acp/src/host/mcp_v4_startup_test.rs new file mode 100644 index 000000000..de55ff997 --- /dev/null +++ b/peri-acp/src/host/mcp_v4_startup_test.rs @@ -0,0 +1,819 @@ +//! B-07 宿主 seam 终审:System MCP 启动准入(主 plan 契约 2 / 3 / 4)。 +//! +//! 断言层次(主 plan §5 R9 把跨层断言上移到本层;crate 内可观察层由 D-02 与 +//! `peri-middlewares/src/mcp/middleware_test.rs` 承担): +//! +//! - **真实装配**:`crate::host::stage_builder::build_stage_context` + +//! `ProductionChainAssembler`(与生产 host/prompt.rs 同一条链); +//! - **受控 transport**:真实子进程 + 真实 rmcp stdio 客户端。对端行为由 fixture +//! 脚本 mode 决定(不响应 / initialize 失败 / tools/list 失败 / 断链 / 正常列出), +//! 因此「未 ready」「失败」「timeout」「peer 断开」都由真实协议路径产生,不是手工 +//! 写状态;等待只轮询公开可见事实(`get_client().status`),不用 sleep 猜时序; +//! - **counting model**:`Model::stream` 调用计数就是「React loop 是否进入 Reason」 +//! 的事实——Reason 阶段必然调用模型,0 次即未进入。 +//! +//! 配置经真实项目级 `.mcp.json` 与真实 `run_initialize` 装载,HOME 重定向到临时 +//! 目录,测试不读取也不启动用户自己的 MCP 配置。 + +use std::{ + ffi::OsString, + path::Path, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, MutexGuard, OnceLock, + }, +}; + +use async_trait::async_trait; +use futures::stream; +use peri_acp_types::{ + event::{ExecutorEvent, TurnStatus}, + messages::{BaseMessage, MessageContent}, + ports::McpPoolPort, + session::ExecutionFailureKind, +}; +use peri_middlewares::mcp::{ClientStatus, McpClientPool, McpInitStatus, McpTaskOwner}; +use peri_model::{ + Model, ModelCapabilities, ModelMessage, ModelRequest, ModelResponse, ModelResult, ModelStream, + ModelStreamEvent, StopReason, +}; +use serial_test::serial; +use tokio_util::sync::CancellationToken as AgentCancellationToken; + +use super::executor_flow_tests::{ + make_session_context, make_stage_build, make_turn_input, MockEventSink, +}; +use crate::session::executor::{run_session_loop, PromptStopReason, SessionContext}; + +/// 受控 stdio MCP 对端。只实现启动准入涉及的方法: +/// `initialize` / `tools/list` / `resources/list` / `ping`;其余请求(含 +/// `server/discover`)回 `-32601`,驱动 rmcp Auto 生命周期回退 legacy initialize。 +/// +/// mode:`hang`(永不响应)/ `peer_exit`(收到请求即退出,模拟 peer 断开)/ +/// `init_error`(initialize 报错)/ `list_error`(tools/list 报错)/ +/// `hang_list`(initialize 成功但 `tools/list` 永不回应)/ `tools`(正常列出)。 +const FIXTURE_SCRIPT: &str = r#" +const mode = process.argv[2] || 'hang'; +const tools = (process.argv[3] || '').split(',').filter(Boolean); +const readline = require('node:readline').createInterface({ input: process.stdin }); +const reply = payload => process.stdout.write(JSON.stringify(payload) + '\n'); +readline.on('line', line => { + let request; + try { request = JSON.parse(line); } catch (error) { return; } + if (request.id === undefined) return; // 通知无 id,不回响应 + if (mode === 'hang') return; + if (mode === 'hang_list' && request.method === 'tools/list') return; + if (mode === 'peer_exit') process.exit(3); + if (request.method === 'initialize') { + if (mode === 'init_error') { + reply({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: 'fixture initialize failure' } }); + return; + } + reply({ jsonrpc: '2.0', id: request.id, result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'mcp-v4-fixture', version: '1' }, + }}); + return; + } + if (request.method === 'tools/list') { + if (mode === 'list_error') { + reply({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: 'fixture tools/list failure' } }); + return; + } + reply({ jsonrpc: '2.0', id: request.id, result: { + tools: tools.map(name => ({ + name, + description: 'fixture tool ' + name, + inputSchema: { type: 'object', properties: {} }, + })), + }}); + return; + } + if (request.method === 'resources/list') { + reply({ jsonrpc: '2.0', id: request.id, result: { resources: [] } }); + return; + } + if (request.method === 'ping') { + reply({ jsonrpc: '2.0', id: request.id, result: {} }); + return; + } + reply({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }); +}); +"#; + +/// HOME 重定向守卫:`load_merged_config_full` 读取 `~/.peri/settings.json`, +/// 测试必须走临时 HOME,避免启动用户自己的 MCP server。 +struct HomeRedirect { + _lock: MutexGuard<'static, ()>, + previous: Option, +} + +impl HomeRedirect { + fn set(home: &Path) -> Self { + static HOME_LOCK: OnceLock> = OnceLock::new(); + // 一个用例 panic 不得毒化 HOME 重定向,使后续用例连带失败(HOME 由 Drop + // 恢复,中毒锁不影响环境事实)。 + let lock = HOME_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let previous = std::env::var_os("HOME"); + std::env::set_var("HOME", home); + Self { + _lock: lock, + previous, + } + } +} + +impl Drop for HomeRedirect { + fn drop(&mut self) { + match self.previous.take() { + Some(home) => std::env::set_var("HOME", home), + None => std::env::remove_var("HOME"), + } + } +} + +/// counting model:`stream` 调用次数即 Reason 进入次数,同时保留请求快照 +/// (首个请求的 tools 入参是契约 3 的终审对象)。 +struct CountingModel { + calls: AtomicUsize, + requests: Mutex>, +} + +impl CountingModel { + fn new() -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + requests: Mutex::new(Vec::new()), + }) + } + + fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn request_count(&self) -> usize { + self.requests.lock().unwrap().len() + } + + /// 首个 LLM 请求的 tools 入参工具名(`ModelRequest.tools` 即模型真实收到的列表)。 + fn first_request_tool_names(&self) -> Vec { + self.requests + .lock() + .unwrap() + .first() + .map(|request| request.tools.iter().map(|tool| tool.name.clone()).collect()) + .unwrap_or_default() + } + + /// 首个 LLM 请求的系统消息文本(deferred 摘要的断言面)。 + fn first_request_system_text(&self) -> String { + self.requests + .lock() + .unwrap() + .first() + .and_then(|request| request.messages.first()) + .map(|message| message.text_content().unwrap_or_default()) + .unwrap_or_default() + } +} + +#[async_trait] +impl Model for CountingModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities { + supports_streaming: true, + ..ModelCapabilities::default() + } + } + + async fn stream( + &self, + request: ModelRequest, + cancellation: AgentCancellationToken, + ) -> ModelResult { + self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().unwrap().push(request); + let response = ModelResponse::new( + ModelMessage::assistant_text("done"), + StopReason::EndTurn, + None, + None, + )?; + Ok(ModelStream::with_parent_cancellation( + stream::iter(vec![Ok(ModelStreamEvent::Completed(response))]), + cancellation, + )) + } +} + +/// System MCP fixture 宿主:真实 `.mcp.json` + 真实 `run_initialize` + 真实 pool。 +struct McpStartupHarness { + _home: HomeRedirect, + _tmp: tempfile::TempDir, + pool: Arc, + _owner: McpTaskOwner, + init_task: Option>, +} + +impl McpStartupHarness { + /// 写入受控对端脚本与项目级配置;`servers` 是 `mcpServers` 的原始 JSON。 + fn new(servers: serde_json::Value) -> Self { + let tmp = tempfile::TempDir::new().expect("临时目录"); + let home = tmp.path().join("home"); + let workspace = tmp.path().join("workspace"); + let claude_home = tmp.path().join("claude"); + for dir in [&home, &workspace, &claude_home] { + std::fs::create_dir_all(dir).expect("创建临时目录"); + } + std::fs::write(workspace.join("mcp_fixture.js"), FIXTURE_SCRIPT) + .expect("写入 fixture 脚本"); + std::fs::write( + workspace.join(".mcp.json"), + serde_json::json!({ "mcpServers": servers }).to_string(), + ) + .expect("写入项目级 MCP 配置"); + + let home_guard = HomeRedirect::set(&home); + let (owner, spawner) = McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + let init_pool = Arc::clone(&pool); + let init_task = tokio::spawn(async move { + McpClientPool::run_initialize( + init_pool, + &workspace, + &claude_home, + status_tx, + None, + None, + ) + .await; + }); + + Self { + _home: home_guard, + _tmp: tmp, + pool, + _owner: owner, + init_task: Some(init_task), + } + } + + /// 等待真实初始化收口(所有 server 都已得出连接结论)。 + async fn initialized(servers: serde_json::Value) -> Self { + let mut harness = Self::new(servers); + if let Some(task) = harness.init_task.take() { + tokio::time::timeout(std::time::Duration::from_secs(30), task) + .await + .expect("真实 MCP 初始化不得挂起") + .expect("初始化任务不得 panic"); + } + harness + } + + /// 注入 fixture pool 的 session 装配面(真实 assembler 会据此构造 McpMiddleware)。 + fn session_context(&self, session_id: &str) -> SessionContext { + let mut ctx = make_session_context(session_id); + ctx.mcp_pool = Some(Arc::clone(&self.pool) as Arc); + ctx + } + + /// 有界轮询公开可见的句柄状态;到点仍不满足即失败(避免固定 sleep 猜时序)。 + async fn await_status(&self, server: &str, expect: &str, predicate: fn(&ClientStatus) -> bool) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + if let Some(handle) = self.pool.get_client(server) { + if predicate(&handle.status) { + return; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "{server} 的公开状态在 20s 内未变成 {expect}" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + async fn await_connected(&self, server: &str) { + self.await_status(server, "Connected", |status| { + matches!(status, ClientStatus::Connected) + }) + .await; + } + + async fn await_failed(&self, server: &str) { + self.await_status(server, "Failed", |status| { + matches!(status, ClientStatus::Failed(_)) + }) + .await; + } +} + +fn system_server( + mode: &str, + tools: &str, + required: serde_json::Value, + timeout_ms: u64, +) -> serde_json::Value { + serde_json::json!({ + "command": "node", + "args": ["mcp_fixture.js", mode, tools], + "system_mcp": true, + "system_mcp_tools": required, + "system_mcp_timeout": timeout_ms, + }) +} + +fn ordinary_server(mode: &str, tools: &str) -> serde_json::Value { + serde_json::json!({ + "command": "node", + "args": ["mcp_fixture.js", mode, tools], + }) +} + +/// 跑一次真实 prompt:装配面注入 counting model,其余(链装配、闸门、终态投影) +/// 全部走生产路径;结果与观测面由调用方持有的 `sink` / `model` 提供。 +async fn run_prompt( + ctx: SessionContext, + sink: &Arc, + model: &Arc, +) -> crate::session::executor::PromptResult { + let model = Arc::clone(model); + let mut ctx = ctx; + ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model) as Arc)); + let turn = make_turn_input( + Arc::clone(sink) as Arc, + MessageContent::text("system mcp startup probe"), + false, + vec![], + make_stage_build(&ctx), + ); + tokio::time::timeout( + std::time::Duration::from_secs(30), + run_session_loop(ctx, turn), + ) + .await + .expect("prompt 不得挂起") +} + +/// fatal 终态的统一断言:模型 0 次调用、failure=Internal、事件序列 +/// `AgentExecutionFailed → TurnEnded(Error) → done` 各一次,并核对真实失败经 +/// 标准 ACP 投影后的 wire 形态(-32000 / `data.kind = internal`,无 status / +/// diagnostic 附加字段)。 +fn assert_fatal_without_reason( + result: &crate::session::executor::PromptResult, + sink: &MockEventSink, + model: &CountingModel, + expected_fragment: &str, +) { + assert!(!result.ok, "启动准入失败必须使本次 prompt 失败"); + assert_eq!(model.call_count(), 0, "闸门失败时模型调用次数必须为 0"); + assert_eq!(model.request_count(), 0, "闸门失败时不得产生任何 LLM 请求"); + let failure = result.failure.as_ref().expect("fatal 必须产生 failure"); + assert_eq!(failure.kind, ExecutionFailureKind::Internal); + assert!( + failure.public_message.contains("McpMiddleware"), + "失败必须归属于 McpMiddleware: {}", + failure.public_message + ); + assert!( + failure.public_message.contains(expected_fragment), + "失败文案缺少准入事实「{expected_fragment}」: {}", + failure.public_message + ); + + // 真实闸门失败 → 标准 ACP fatal 投影(同一 `ExecutionFailure` 走生产转换函数)。 + let wire = crate::host::prompt::execution_failure_to_acp_error(failure); + assert_eq!( + wire.code, + crate::host::prompt::ACP_TURN_EXECUTION_FAILED_CODE + ); + assert_eq!( + wire.data, + Some(serde_json::json!({ "kind": "internal" })), + "MCP 准入失败只能投影为 internal 类别" + ); + assert!(wire.message.contains("McpMiddleware")); + assert!(wire.message.contains(expected_fragment)); + + let operations = sink.operations(); + let failure_index = operations + .iter() + .position(|operation| operation.contains("agent_execution_failed")) + .expect("必须发射 AgentExecutionFailed"); + let ended: Vec = operations + .iter() + .enumerate() + .filter(|(_, operation)| operation.contains("\"turn_ended\"")) + .map(|(index, _)| index) + .collect(); + assert_eq!(ended.len(), 1, "terminal 事件必须唯一"); + let done_index = operations + .iter() + .position(|operation| operation.starts_with("done:")) + .expect("必须 push_done"); + assert!(failure_index < ended[0] && ended[0] < done_index); + let turn_end: ExecutorEvent = serde_json::from_str(&operations[ended[0]]).unwrap(); + assert!( + matches!( + turn_end, + ExecutorEvent::TurnEnded { + status: TurnStatus::Error, + .. + } + ), + "准入失败必须是 Error 终态,不是 Interrupted: {turn_end:?}" + ); + assert_eq!(sink.push_done_count(), 1, "push_done 恰好一次"); +} + +// ── 契约 2:未 ready / 失败 / timeout 的首个 prompt 必须是 fatal,且不进入 Reason ── + +/// transport / initialize 失败(`System MCP` 已 Failed)→ 首个 prompt fatal, +/// 模型 0 次调用。文案只保留阶段类别,不含对端原文。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_transport_failure_fails_first_prompt_without_model_call() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("init_error", "", serde_json::json!([]), 5_000), + })) + .await; + harness.await_failed("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-init-failure"), + &sink, + &model, + ) + .await; + + assert_fatal_without_reason( + &result, + &sink, + &model, + "System MCP \"sys\" 启动失败:transport 或协议初始化失败", + ); + let failure = result.failure.expect("fatal failure"); + assert!( + !failure + .public_message + .contains("fixture initialize failure"), + "错误文案不得泄漏对端原文: {}", + failure.public_message + ); +} + +/// 连接与协商都好、但 live `tools/list` 未完成 → 仍不得 ready: +/// 这是「`Connected` + 无工具清单」被读成 ready 的原始风险面,只有真实成功的 +/// `tools/list` 才算发现证据(空数组是成功结果,未回应不是)。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_connected_without_tool_discovery_is_not_ready() { + let harness = McpStartupHarness::new(serde_json::json!({ + "sys": system_server("hang_list", "", serde_json::json!([]), 800), + })); + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-no-discovery"), + &sink, + &model, + ) + .await; + + assert_fatal_without_reason(&result, &sink, &model, "启动超时(800ms),未发布 ready"); +} + +/// `tools/list` 失败 → 不得被读成「空工具列表」:即使 `system_mcp_tools = []` +/// 也必须 fatal(契约 4 的两条分支之一)。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_tool_discovery_failure_is_not_an_empty_tool_list() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("list_error", "", serde_json::json!([]), 5_000), + })) + .await; + harness.await_failed("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-list-failure"), + &sink, + &model, + ) + .await; + + assert_fatal_without_reason( + &result, + &sink, + &model, + "System MCP \"sys\" 启动失败:tools/list 失败", + ); +} + +/// 对端不响应 → deadline 到期是**终态失败**,不是取消:模型 0 次调用、 +/// 不是 `Cancelled` stop reason、不是 `Interrupted` 终态。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_timeout_is_fatal_not_cancelled() { + let harness = McpStartupHarness::new(serde_json::json!({ + "sys": system_server("hang", "", serde_json::json!([]), 800), + })); + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt(harness.session_context("mcp-v4-timeout"), &sink, &model).await; + + assert_fatal_without_reason(&result, &sink, &model, "启动超时(800ms),未发布 ready"); + assert_ne!( + result.stop_reason, + PromptStopReason::Cancelled, + "timeout 不是取消" + ); + assert!( + !result + .failure + .as_ref() + .expect("fatal failure") + .public_message + .contains("已取消"), + "timeout 不得映射为取消文案" + ); +} + +/// peer 断开(子进程收到请求即退出)→ 同样在 Reason 之前 fatal。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_disconnected_peer_fails_first_prompt() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("peer_exit", "", serde_json::json!([]), 5_000), + })) + .await; + harness.await_failed("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt(harness.session_context("mcp-v4-peer-exit"), &sink, &model).await; + + assert_fatal_without_reason( + &result, + &sink, + &model, + "System MCP \"sys\" 启动失败:transport 或协议初始化失败", + ); +} + +/// 连接与 `tools/list` 都成功但缺必需工具 → 仍不得放行(all-or-nothing)。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_missing_required_tool_fails_before_reason() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("tools", "other", serde_json::json!(["echo"]), 5_000), + })) + .await; + harness.await_connected("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-missing-tool"), + &sink, + &model, + ) + .await; + + assert_fatal_without_reason(&result, &sink, &model, "未提供必需工具 \"echo\""); +} + +// ── 契约 3 / 4:ready 后首个 LLM 请求的 tools 入参 ────────────────────────────── + +/// 必需工具在第一个真实 LLM 请求中就直接可见(无需 ToolSearch),而普通 MCP 工具 +/// 仍是 deferred(只出现在 deferred 摘要里)。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_ready_exposes_required_tools_on_first_model_request() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("tools", "echo,glob", serde_json::json!(["echo"]), 5_000), + "ord": ordinary_server("tools", "ping"), + })) + .await; + harness.await_connected("sys").await; + harness.await_connected("ord").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt(harness.session_context("mcp-v4-ready-tools"), &sink, &model).await; + + assert!( + result.ok, + "准入成功后 prompt 必须正常结束: stop={:?}", + result.stop_reason + ); + assert!( + result.failure.is_none(), + "准入成功后不得有 failure: {:?}", + result.failure + ); + assert_eq!(model.call_count(), 1, "首个 prompt 恰好一次模型调用"); + + let tools = model.first_request_tool_names(); + assert!( + tools.iter().any(|name| name == "mcp__sys__echo"), + "必需工具必须直接出现在首个 LLM 请求: {tools:?}" + ); + assert!( + !tools.iter().any(|name| name == "mcp__sys__glob"), + "非必需的同 server 工具不得被提升为 direct: {tools:?}" + ); + assert!( + !tools.iter().any(|name| name == "mcp__ord__ping"), + "普通 MCP 工具必须保持 deferred: {tools:?}" + ); + + let system = model.first_request_system_text(); + assert!( + system.contains("## Deferred Tools") && system.contains("mcp__ord__ping"), + "所有 deferred 工具(含普通 MCP)仍必须经 ToolSearch 摘要可见" + ); +} + +/// 契约 4:`system_mcp_tools = []` 只要求 ready,不注入额外工具—— +/// 同一台 server 的工具全部保持 deferred。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_empty_required_tools_ready_without_injection() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("tools", "echo", serde_json::json!([]), 5_000), + })) + .await; + harness.await_connected("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-empty-required"), + &sink, + &model, + ) + .await; + + assert!( + result.ok, + "空必需工具数组必须放行: stop={:?}", + result.stop_reason + ); + assert_eq!(model.call_count(), 1, "ready 后正常进入 Reason"); + let tools = model.first_request_tool_names(); + assert!( + !tools.iter().any(|name| name == "mcp__sys__echo"), + "空数组不得注入任何 direct 工具: {tools:?}" + ); + let system = model.first_request_system_text(); + assert!( + system.contains("mcp__sys__echo"), + "未提升的工具仍应在 deferred 摘要中可见" + ); +} + +// ── 契约 2 例外:普通 MCP 永不阻塞启动 ───────────────────────────────────────── + +/// ordinary MCP 永久 pending(对端不响应)时,prompt 仍到达模型;system 依赖满足 +/// 即可放行。断言时 ordinary 仍**在连接中**,证明确实没有被等待。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn ordinary_mcp_pending_does_not_block_startup() { + let harness = McpStartupHarness::new(serde_json::json!({ + "sys": system_server("tools", "echo", serde_json::json!(["echo"]), 5_000), + "ord": ordinary_server("hang", ""), + })); + harness.await_connected("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-ordinary-pending"), + &sink, + &model, + ) + .await; + + assert!( + result.ok, + "普通 MCP pending 不得阻塞启动: stop={:?}", + result.stop_reason + ); + assert_eq!(model.call_count(), 1, "模型必须被调用"); + assert!( + model + .first_request_tool_names() + .iter() + .any(|name| name == "mcp__sys__echo"), + "system 依赖满足后必需工具仍须直接可见" + ); + assert!( + harness + .init_task + .as_ref() + .is_some_and(|task| !task.is_finished()), + "ordinary 此时仍应在连接中——启动准入没有等它" + ); +} + +/// ordinary MCP 直接初始化失败(未声明 `system_mcp`)同样不得阻塞启动:只有 +/// `system_mcp == Some(true)` 的 server 是启动依赖。与「同一 fixture 声明为 system +/// 即 fatal」形成对照,固定 fatal 由启动依赖判定产生,而非 fixture 失败本身。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn ordinary_mcp_failure_does_not_block_startup() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("tools", "echo", serde_json::json!(["echo"]), 5_000), + "ord": ordinary_server("init_error", ""), + })) + .await; + harness.await_connected("sys").await; + harness.await_failed("ord").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-ordinary-failure"), + &sink, + &model, + ) + .await; + + assert!( + result.ok, + "普通 MCP 失败不得阻碍启动: stop={:?} failure={:?}", + result.stop_reason, result.failure + ); + assert_eq!(model.call_count(), 1, "模型必须被调用"); +} + +/// 闸门位置固定:输入已被 Receive 接纳(transcript 出现该 human 消息),但既无 +/// assistant 输出也无线工具调用——即「Receive 之后、Compact / Reason 之前」。 +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn system_mcp_gate_runs_after_receive_and_before_reason() { + let harness = McpStartupHarness::initialized(serde_json::json!({ + "sys": system_server("init_error", "", serde_json::json!([]), 5_000), + })) + .await; + harness.await_failed("sys").await; + + let sink = Arc::new(MockEventSink::new()); + let model = CountingModel::new(); + let result = run_prompt( + harness.session_context("mcp-v4-receive-order"), + &sink, + &model, + ) + .await; + + assert!(!result.ok); + assert_eq!(model.call_count(), 0, "Compact / Reason 都不得发生"); + assert!( + result.messages.iter().any(|message| matches!( + message, + BaseMessage::Human { content, .. } if content.text_content().contains("system mcp startup probe") + )), + "首个 prompt 的输入必须已被 Receive 接纳: {:?}", + result.messages + ); + assert!( + !result + .messages + .iter() + .any(|message| matches!(message, BaseMessage::Ai { .. })), + "未进入 Reason 就不得有 assistant 消息: {:?}", + result.messages + ); + assert_eq!( + sink.operations() + .iter() + .filter(|operation| operation.contains("agent_execution_failed")) + .count(), + 1, + "失败事件恰好一次" + ); +} diff --git a/peri-acp/src/host/mod.rs b/peri-acp/src/host/mod.rs index cc6761a8e..41ec14400 100644 --- a/peri-acp/src/host/mod.rs +++ b/peri-acp/src/host/mod.rs @@ -49,6 +49,9 @@ pub mod controller_ports; mod executor_flow_tests; pub mod lease; mod mcp_apps; +#[cfg(test)] +#[path = "mcp_v4_startup_test.rs"] +mod mcp_v4_startup_tests; mod notify; mod oauth_delivery; mod prediction; @@ -142,6 +145,13 @@ pub struct AcpServerConfig { pub permission_mode: Arc, pub cron_scheduler: Option>, pub mcp_pool: Option>, + /// MCP over ACP 服务(会话 setup 声明的 `type: "acp"` server 的连接事实)。 + /// + /// 会话级字段:由会话工作区装配持有(每个会话各有一个 MCP 池),host 级 + /// 装配(`session_resources == false`)为 `None`——那时没有池可承载连接。 + /// 会话 setup 经 [`requests::acp_mcp`] 登记声明,入站 `mcp/message` 同样 + /// 经它在各会话间定位承载者。 + pub(crate) acp_mcp: Option>, /// Optional stdio-only MCP Apps backend. Absence keeps the capability fail closed. pub mcp_apps_relay: Option>, pub dynamic_mcp: Option>, diff --git a/peri-acp/src/host/prompt_test.rs b/peri-acp/src/host/prompt_test.rs index 8825bf3a8..1e426a68b 100644 --- a/peri-acp/src/host/prompt_test.rs +++ b/peri-acp/src/host/prompt_test.rs @@ -288,4 +288,38 @@ mod wire_projection { assert_eq!(value["stopReason"], "end_turn", "{value}"); assert!(value.get("error").is_none()); } + + /// System MCP 启动准入失败(`McpMiddleware`)在 ACP 边界的投影契约: + /// 类别固定 `Internal` → `-32000`、`data` 只有 `kind=internal`、不携带 + /// status / diagnostic,message 保留 `Middleware error: {middleware} - {reason}` + /// 形态——安全文案由 MCP 边界构造,ACP 不替它脱敏也不额外补内部 cause。 + /// + /// 真实失败文本由 `host::mcp_v4_startup_tests` 的端到端用例断言(真实 + /// gate 产生的 `ExecutionFailure` 走同一投影函数)。 + #[test] + fn system_mcp_middleware_error_projects_standard_acp_error() { + let failure = ExecutionFailure::from_agent_error(&AgentError::MiddlewareError { + middleware: "McpMiddleware".to_string(), + reason: "System MCP startup rejected".to_string(), + }); + assert_eq!(failure.kind, ExecutionFailureKind::Internal); + assert_eq!(failure.http_status, None); + assert!(failure.diagnostic.is_none(), "非模型失败不得携带模型诊断"); + + let err = prompt_wire_response( + Some(&failure), + crate::session::executor::PromptStopReason::EndTurn, + ) + .expect_err("启动准入失败必须映射为协议错误,不得返回成功 PromptResponse"); + assert_eq!(err.code, ACP_TURN_EXECUTION_FAILED_CODE); + assert_eq!( + err.message, + "Middleware error: McpMiddleware - System MCP startup rejected" + ); + assert_eq!(err.data, Some(serde_json::json!({"kind": "internal"}))); + + let wire = serde_json::to_value(&err).expect("AcpError 序列化不应失败"); + assert_eq!(wire["code"], ACP_TURN_EXECUTION_FAILED_CODE); + assert_eq!(wire["data"], serde_json::json!({"kind": "internal"})); + } } diff --git a/peri-acp/src/host/requests.rs b/peri-acp/src/host/requests.rs index 503e04a15..4de0394be 100644 --- a/peri-acp/src/host/requests.rs +++ b/peri-acp/src/host/requests.rs @@ -12,6 +12,7 @@ use peri_acp_types::PeriCaps; use super::{AcpServerConfig, SessionState}; +pub(crate) mod acp_mcp; pub(crate) mod config_options; mod mcp_oauth; mod plugin; diff --git a/peri-acp/src/host/requests/acp_mcp.rs b/peri-acp/src/host/requests/acp_mcp.rs new file mode 100644 index 000000000..c79957582 --- /dev/null +++ b/peri-acp/src/host/requests/acp_mcp.rs @@ -0,0 +1,225 @@ +//! MCP over ACP(client 声明的 `type: "acp"` server)的宿主接线。 +//! +//! 三个方向各自一段: +//! +//! - **声明解析**:会话 setup(`session/new|load|resume|fork`)的 `mcpServers` +//! 里筛出 acp 型条目,转成契约规格交给该会话的服务 +//! ([`attach_session_servers`],由 `ServerLoop` 在 setup 响应写入后调用); +//! - **入站消息**:client 经 `mcp/message` 反向下发的请求 / 通知按 +//! `connectionId` 定位承载它的会话服务([`route_inbound`]),请求走 +//! `ServerLoop` 的 spawn 路径、通知在通知分发里就地转发; +//! - **出站网关**:[`AcpTransportGateway`] 把服务的 `mcp/connect` / +//! `mcp/message` / `mcp/disconnect` 落成 ACP 请求 / 通知。 +//! +//! 会话服务是**会话级**的(每个会话各持一个 MCP 池,ACP 连接进的是本会话的 +//! 工具面),而 `mcp/message` 只带 `connectionId`:定位承载者是宿主职责,见 +//! [`AcpMcpServerPort::owns_connection`]。 + +use std::collections::HashMap; +use std::sync::Arc; + +use agent_client_protocol_schema::v1::McpServer; +use peri_acp_types::acp_mcp::{AcpMcpError, AcpMcpInbound, AcpMcpServerSpec}; +use peri_acp_types::ports::{AcpMcpGatewayPort, AcpMcpServerPort}; +use serde_json::Value; +use tracing::{debug, warn}; + +use super::super::{AcpServerConfig, SessionState}; +use crate::transport::{types::AcpError, AcpTransport}; + +/// agent → client 的 ACP 发送网关:MCP over ACP 的全部出站消息经它落成 +/// `mcp/connect` / `mcp/message` / `mcp/disconnect` 请求或通知。 +/// +/// 构造点在 setup 响应写入之后(`ServerLoop` 同时持有会话与 +/// `Arc` 的位置);`mcp/message` 的错误码按协议约定就是内层 +/// MCP 错误码,原样透传。 +pub(crate) struct AcpTransportGateway { + transport: Arc, +} + +impl AcpTransportGateway { + pub(crate) fn new(transport: Arc) -> Self { + Self { transport } + } +} + +#[async_trait::async_trait] +impl AcpMcpGatewayPort for AcpTransportGateway { + async fn request(&self, method: &str, params: Value) -> Result { + self.transport + .send_request(method, params) + .await + .map_err(transport_error) + } + + async fn notify(&self, method: &str, params: Value) -> Result<(), AcpMcpError> { + self.transport + .send_notification(method, params) + .await + .map_err(transport_error) + } +} + +fn transport_error(error: AcpError) -> AcpMcpError { + AcpMcpError { + code: error.code, + message: error.message, + } +} + +/// 从会话 setup 参数里筛出 acp 型 MCP 声明。 +/// +/// 其它传输形态(stdio / http / sse)不归本路径:它们是 agent 自行启动的外部 +/// 进程 / 端点,与 ACP 通道无关。 +/// +/// 逐条解析:单条畸形(未知传输形态、缺 `serverId`、无法反序列化)只丢该条并记 +/// 日志,不牵连同批合法的 server——声明是 client 的输入,一条坏声明不该让整批 +/// server 静默消失。 +fn parse_acp_servers(params: &Value, session_id: &str) -> Vec { + let Some(raw) = params.get("mcpServers") else { + return Vec::new(); + }; + if raw.is_null() { + return Vec::new(); + } + let Some(entries) = raw.as_array() else { + warn!( + session_id = %session_id, + "session setup 的 mcpServers 不是数组,按未声明处理" + ); + return Vec::new(); + }; + entries + .iter() + .filter_map(|entry| { + let server: McpServer = match serde_json::from_value(entry.clone()) { + Ok(server) => server, + Err(error) => { + warn!( + session_id = %session_id, + %error, + "mcpServers 条目无法解析,忽略该条" + ); + return None; + } + }; + let McpServer::Acp(acp) = server else { + return None; + }; + let server_id = acp.server_id.to_string(); + if server_id.is_empty() { + warn!(session_id = %session_id, "mcpServers 的 acp 声明缺少 serverId,忽略"); + return None; + } + Some(AcpMcpServerSpec { + session_id: session_id.to_string(), + name: acp.name, + server_id, + }) + }) + .collect() +} + +/// 登记本次会话 setup 声明的 acp 型 server(非阻塞:只登记并后台建连)。 +/// +/// 由 `ServerLoop` 在 setup 响应成功写入后调用:客户端此时已拿到会话结果, +/// `mcp/connect` 只带 client 自己声明的 `serverId`,顺序上先响应再建连,客户端 +/// 不必处理「会话还没告诉我就要求连 MCP」的交错。 +/// +/// 未装配 MCP over ACP 服务的会话(无工作区资源 / bare / print 模式)是无操作: +/// 这些会话没有 MCP 池可承载连接,声明无处落地。 +pub(crate) fn attach_session_servers( + cfg: &AcpServerConfig, + transport: &Arc, + params: &Value, + session_id: &str, +) { + let Some(port) = cfg.acp_mcp.as_ref() else { + return; + }; + let servers = parse_acp_servers(params, session_id); + if servers.is_empty() { + return; + } + debug!( + session_id = %session_id, + servers = servers.len(), + "MCP over ACP 声明已受理,后台建连" + ); + port.attach( + Arc::new(AcpTransportGateway::new(Arc::clone(transport))), + servers, + ); +} + +/// 定位承载 `connectionId` 的会话服务(连接与会话的事实都在会话级服务里)。 +/// +/// 只依 `owns_connection` 判定,不关心端口从哪来:会话集合的形态是宿主细节, +/// 判定规则是协议事实。 +fn locate_owner( + ports: impl Iterator>, + connection_id: &str, +) -> Option> { + ports + .into_iter() + .find(|port| port.owns_connection(connection_id)) +} + +/// 反序列化入站 `mcp/message` 载荷。 +fn inbound(params: &Value) -> Result { + let connection_id = params + .get("connectionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| AcpError::new(-32602, "missing connectionId"))?; + let method = params + .get("method") + .and_then(Value::as_str) + .filter(|method| !method.is_empty()) + .ok_or_else(|| AcpError::new(-32602, "missing method"))? + .to_string(); + let params = match params.get("params") { + None | Some(Value::Null) => None, + Some(Value::Object(map)) => Some(map.clone()), + Some(_) => return Err(AcpError::new(-32602, "params must be an object")), + }; + Ok(AcpMcpInbound { + connection_id: connection_id.to_string(), + method, + params, + }) +} + +/// 入站 `mcp/message` 的宿主路由:解析载荷 + 定位承载会话的服务。 +/// +/// 返回**已克隆的服务句柄**,调用方随即释放会话锁再 await——内层处理时长由 +/// 对端(client 宿主的 MCP server)决定,不得占着会话锁等待。 +pub(crate) fn route_inbound( + sessions: &HashMap, + params: &Value, +) -> Result<(Arc, AcpMcpInbound), AcpError> { + let inbound = inbound(params)?; + let ports = sessions.values().filter_map(|state| { + state + .environment + .as_ref()? + .cfg + .acp_mcp + .as_ref() + .map(Arc::clone) + }); + let Some(port) = locate_owner(ports, &inbound.connection_id) else { + return Err(AcpError::new( + AcpMcpError::CODE_NOT_FOUND, + format!( + "未知或已关闭的 MCP-over-ACP 连接: {}", + inbound.connection_id + ), + )); + }; + Ok((port, inbound)) +} + +#[cfg(test)] +#[path = "acp_mcp_test.rs"] +mod tests; diff --git a/peri-acp/src/host/requests/acp_mcp_loop_test.rs b/peri-acp/src/host/requests/acp_mcp_loop_test.rs new file mode 100644 index 000000000..413021998 --- /dev/null +++ b/peri-acp/src/host/requests/acp_mcp_loop_test.rs @@ -0,0 +1,266 @@ +//! MCP over ACP 宿主接线的主路径测试:client 在 `session/new` 声明 +//! `type: "acp"` 的 MCP server → 工具进入该会话的工具面 → 会话关闭时断开。 +//! +//! 假件只在**协议对端**(一个实现 `AcpTransport` 的 client 宿主:应答 +//! `mcp/connect`,把 `mcp/message` 交给它承载的那台 MCP server)。宿主侧走 +//! 真实路径:真实 `session/new`、真实宿主网关 `AcpTransportGateway`、真实桥接 +//! transport、真实 rmcp 握手与 `tools/list` 发现、真实 MCP 池提交与归属过滤。 +//! +//! 声明的解析与注入点取自生产调用链:`session/new` 的参数形状就是生产形状, +//! attach 调用点与 `ServerLoop` 在 setup 响应之后的调用同款(`mcp/connect` 只带 +//! `serverId`,必须在客户端拿到会话结果之后才发得出去)。 +//! +//! 池与服务的注入理由:非 bare 工作区装配面才会构造 MCP 池(`host/assemble.rs`), +//! 而拉起真实外部 server 不适合测试——这里注入同一份装配产物,装配面自身由 +//! `host/assemble.rs` 的既有测试覆盖。 + +use std::time::Duration; + +use async_trait::async_trait; +use peri_acp_types::ports::McpPoolPort; +use peri_middlewares::mcp::apps::McpCapabilityProfile; +use peri_middlewares::mcp::tool_bridge::build_tool_bridges_visible_to; +use peri_middlewares::mcp::{AcpMcpService, McpClientPool, McpTaskOwner}; + +use super::*; +use crate::transport::types::{IncomingMessage, RequestId}; + +/// client 宿主假件:真实部署里 client 就扮演这个角色。 +#[derive(Default)] +struct FakeClientHost { + /// 收到的 `mcp/connect` 的 `serverId`(按到达顺序)。 + connects: std::sync::Mutex>, + /// 收到的 `mcp/disconnect` 的 `connectionId`。 + disconnects: std::sync::Mutex>, + /// `mcp/message` 承载的内层 MCP 方法名。 + inner_methods: std::sync::Mutex>, +} + +impl FakeClientHost { + fn recorded(values: &std::sync::Mutex>) -> Vec { + values.lock().unwrap().clone() + } +} + +#[async_trait] +impl crate::transport::AcpTransport for FakeClientHost { + async fn send_request(&self, method: &str, params: Value) -> Result { + match method { + "mcp/connect" => { + self.connects.lock().unwrap().push( + params + .get("serverId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ); + Ok(json!({ "connectionId": "conn-1" })) + } + "mcp/disconnect" => { + self.disconnects.lock().unwrap().push( + params + .get("connectionId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ); + Ok(json!({})) + } + "mcp/message" => { + let inner = params + .get("method") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + self.inner_methods.lock().unwrap().push(inner.clone()); + match inner.as_str() { + "initialize" => Ok(json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "client-hosted-fixture", "version": "1" } + })), + "tools/list" => Ok(json!({ "tools": [{ + "name": "echo", + "description": "fixture echo tool", + "inputSchema": { "type": "object", "properties": {} } + }] })), + "ping" => Ok(json!({})), + // 未实现的方法按 JSON-RPC 回错:rmcp 的 lifecycle 协商依赖 + // -32601 判定 legacy initialize 回退,码值不得被抹平。 + other => Err(AcpError::new(-32601, format!("method not found: {other}"))), + } + } + other => Err(AcpError::new(-32601, format!("unexpected method: {other}"))), + } + } + + async fn send_notification(&self, _method: &str, _params: Value) -> Result<(), AcpError> { + Ok(()) + } + + async fn recv(&self) -> Option { + None + } + + async fn send_response( + &self, + _id: RequestId, + _result: Result, + ) -> Result<(), AcpError> { + Ok(()) + } +} + +/// 等待条件成立(建连与工具发现是异步的,没有固定时序)。 +async fn wait_until(what: &str, mut condition: impl FnMut() -> bool) { + for _ in 0..600 { + if condition() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("等待超时: {what}"); +} + +#[tokio::test] +async fn acp_declared_server_reaches_the_session_tool_face_and_disconnects_on_close() { + let tmp = tempfile::TempDir::new().unwrap(); + let config = + make_peri_config_with_provider(make_provider_config("test", "openai", "key", "model")); + let provider = LlmProvider::from_config(&config).unwrap(); + let mut cfg = make_server_config(config, provider, &tmp).await; + let cwd = tmp.path().canonicalize().unwrap(); + cfg.workspace_assembly = Some(crate::host::assemble::WorkspaceAssembly { + startup_cwd: cwd.to_str().unwrap().to_owned(), + bare: true, + mcp_profile: McpCapabilityProfile::disabled(), + }); + + let peer = Arc::new(FakeClientHost::default()); + let transport: Arc = Arc::clone(&peer) as Arc<_>; + let mut sessions = HashMap::new(); + let params = json!({ + "cwd": cwd, + "mcpServers": [{ "type": "acp", "name": "fixture", "serverId": "srv-1" }], + }); + let created = handle_request("session/new", ¶ms, &cfg, &mut sessions, &transport) + .await + .unwrap(); + let session_id = created["sessionId"].as_str().unwrap().to_owned(); + + // 会话级 MCP 池与 MCP over ACP 服务(非 bare 工作区装配面的产物)。 + let (_mcp_owner, mcp_spawner) = McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner_and_profile( + mcp_spawner, + McpCapabilityProfile::disabled(), + )); + { + let environment = Arc::get_mut( + sessions + .get_mut(&session_id) + .unwrap() + .environment + .as_mut() + .unwrap(), + ) + .expect("会话刚建立,工作区装配未被共享"); + environment.cfg.mcp_pool = Some(Arc::clone(&pool) as Arc); + environment.cfg.acp_mcp = Some(Arc::new(AcpMcpService::new(Arc::clone(&pool)))); + } + + // `ServerLoop` 在 setup 响应写入之后做的事。 + { + let environment_cfg = &sessions[&session_id].environment.as_ref().unwrap().cfg; + crate::host::requests::acp_mcp::attach_session_servers( + environment_cfg, + &transport, + ¶ms, + &session_id, + ); + } + + wait_until("声明的连接进入本会话的池", || { + pool.get_client_visible_to("fixture", Some(&session_id)) + .is_some() + }) + .await; + let handle = pool + .get_client_visible_to("fixture", Some(&session_id)) + .expect("归属会话应看到连接"); + assert!(matches!( + handle.status, + peri_middlewares::mcp::ClientStatus::Connected + )); + assert_eq!( + handle + .tools + .iter() + .map(|tool| tool.name.to_string()) + .collect::>(), + vec!["echo".to_string()] + ); + // 建连依的是客户端声明的 serverId;工具经内层 tools/list 发现。 + assert_eq!( + FakeClientHost::recorded(&peer.connects), + vec!["srv-1".to_string()] + ); + assert!(FakeClientHost::recorded(&peer.inner_methods) + .iter() + .any(|method| method == "tools/list")); + + // 工具进入该会话的工具面(deferred:`build_tool_bridges_visible_to` 是唯一 + // 会话级构造入口,名字带 server 前缀)。 + let tools = build_tool_bridges_visible_to(&pool, Some(&session_id)); + assert_eq!( + tools + .iter() + .map(|tool| tool.name().to_string()) + .collect::>(), + vec!["mcp__fixture__echo".to_string()] + ); + assert!(tools[0].mcp_server_name().is_some()); + assert!( + !tools[0].is_direct(), + "ACP 工具与其它 MCP 工具一致走 deferred 发现面" + ); + + // 入站:宿主按 connectionId 定位承载会话的服务,`ping` 由 rmcp 客户端 + // runtime 应答——证明「宿主路由 → 桥接 → rmcp → 响应回程」整条路径。 + let port = sessions[&session_id] + .environment + .as_ref() + .unwrap() + .cfg + .acp_mcp + .clone() + .unwrap(); + assert!(port.owns_connection("conn-1")); + assert!(!port.owns_connection("conn-2")); + let Ok((routed, inbound)) = crate::host::requests::acp_mcp::route_inbound( + &sessions, + &json!({ "connectionId": "conn-1", "method": "ping" }), + ) else { + panic!("已建立的连接必须能被宿主路由"); + }; + assert!(Arc::ptr_eq(&routed, &port)); + routed.request(inbound).await.expect("ping 应由 rmcp 应答"); + + // 会话关闭:连接事实随会话消失,且必须先于池关闭完成(否则没有出站通道)。 + handle_request( + "session/close", + &json!({"sessionId": session_id}), + &cfg, + &mut sessions, + &transport, + ) + .await + .unwrap(); + assert_eq!( + FakeClientHost::recorded(&peer.disconnects), + vec!["conn-1".to_string()] + ); + assert!(pool + .get_client_visible_to("fixture", Some(&session_id)) + .is_none()); + assert!(build_tool_bridges_visible_to(&pool, Some(&session_id)).is_empty()); +} diff --git a/peri-acp/src/host/requests/acp_mcp_test.rs b/peri-acp/src/host/requests/acp_mcp_test.rs new file mode 100644 index 000000000..bcdfdefb7 --- /dev/null +++ b/peri-acp/src/host/requests/acp_mcp_test.rs @@ -0,0 +1,243 @@ +//! MCP over ACP 宿主接线的契约测试。 +//! +//! 三条接线各自钉一个协议事实,都不经过真实网络: +//! +//! 1. **声明解析**:`mcpServers` 的 wire 形状取自 SDK 自身的序列化结果,acp 型 +//! 条目转成规格、其它传输与畸形条目被忽略且不牵连同批; +//! 2. **入站路由**:`mcp/message` 只带 `connectionId`,宿主必须在各会话服务间 +//! 定位承载者,错误码按协议约定(`-32601`/`-32602` 之外用 `CODE_NOT_FOUND`); +//! 3. **出站网关**:ACP 请求/通知原样透传,错误码不抹平——`mcp/message` 的码值 +//! 就是内层 MCP 错误码,抹平会让 rmcp 的 lifecycle 协商不可用。 + +use std::collections::HashMap; +use std::sync::Arc; + +use agent_client_protocol_schema::v1::{McpServer, McpServerAcp, McpServerAcpId, McpServerHttp}; +use async_trait::async_trait; +use parking_lot::Mutex; +use peri_acp_types::acp_mcp::{AcpMcpError, AcpMcpInbound, AcpMcpServerSpec}; +use peri_acp_types::ports::{AcpMcpGatewayPort, AcpMcpServerPort}; +use serde_json::{json, Value}; + +use super::*; +use crate::transport::types::{AcpError, IncomingMessage, RequestId}; + +/// 声明方的 wire 形状就是契约本身:这里由 SDK 的类型序列化产出,测试只断言 +/// 解析器认的是这个形状(形状一旦漂移,解析器必须跟着改)。 +fn acp_declaration(name: &str, server_id: &str) -> Value { + serde_json::to_value(McpServer::Acp(McpServerAcp::new( + name.to_string(), + McpServerAcpId::new(server_id.to_string()), + ))) + .unwrap() +} + +#[test] +fn acp_declaration_wire_shape_is_the_sdk_shape() { + assert_eq!( + acp_declaration("fixture", "srv-1"), + json!({"type": "acp", "name": "fixture", "serverId": "srv-1"}) + ); +} + +#[test] +fn acp_declarations_are_parsed_and_other_transports_ignored() { + let http = serde_json::to_value(McpServer::Http(McpServerHttp::new( + "remote", + "https://example.invalid/mcp", + ))) + .unwrap(); + let params = json!({ + "cwd": "/tmp", + "mcpServers": [http, acp_declaration("fixture", "srv-1")], + }); + + assert_eq!( + parse_acp_servers(¶ms, "s1"), + vec![AcpMcpServerSpec { + session_id: "s1".to_string(), + name: "fixture".to_string(), + server_id: "srv-1".to_string(), + }] + ); +} + +#[test] +fn malformed_declaration_drops_only_that_entry() { + let raw = + |name: &str, server_id: &str| json!({"type": "acp", "name": name, "serverId": server_id}); + let params = json!({ + "mcpServers": [ + // 未知传输形态:不属于本路径 + {"type": "carrier-pigeon", "name": "pigeon"}, + // 缺 serverId:无法路由回声明方,必须丢弃 + {"type": "acp", "name": "no-id"}, + {"type": "acp", "name": "empty-id", "serverId": ""}, + // 合法条目不受同批畸形影响 + raw("fixture", "srv-1"), + ], + }); + + assert_eq!( + parse_acp_servers(¶ms, "s1"), + vec![AcpMcpServerSpec { + session_id: "s1".to_string(), + name: "fixture".to_string(), + server_id: "srv-1".to_string(), + }] + ); +} + +#[test] +fn absent_or_non_array_declarations_are_no_declarations() { + assert!(parse_acp_servers(&json!({"cwd": "/tmp"}), "s1").is_empty()); + assert!(parse_acp_servers(&json!({"mcpServers": null}), "s1").is_empty()); + assert!(parse_acp_servers(&json!({"mcpServers": "oops"}), "s1").is_empty()); + assert!(parse_acp_servers(&json!({"mcpServers": []}), "s1").is_empty()); +} + +/// 只承载一条连接的最小服务假件:路由只依 `owns_connection` 判定。 +struct FakePort { + owned: Vec<&'static str>, +} + +impl FakePort { + fn new(owned: &[&'static str]) -> Self { + Self { + owned: owned.to_vec(), + } + } +} + +#[async_trait] +impl AcpMcpServerPort for FakePort { + fn attach(&self, _gateway: Arc, _servers: Vec) {} + + async fn request(&self, _inbound: AcpMcpInbound) -> Result { + Err(AcpMcpError::unavailable("测试假件不承载请求")) + } + + async fn notify(&self, _inbound: AcpMcpInbound) -> Result<(), AcpMcpError> { + Err(AcpMcpError::unavailable("测试假件不承载通知")) + } + + fn owns_connection(&self, connection_id: &str) -> bool { + self.owned.contains(&connection_id) + } + + async fn close_session(&self, _session_id: &str) {} +} + +#[test] +fn routing_picks_the_port_that_owns_the_connection() { + let first: Arc = Arc::new(FakePort::new(&["conn-a"])); + let second: Arc = Arc::new(FakePort::new(&["conn-b"])); + let ports = vec![Arc::clone(&first), Arc::clone(&second)]; + + let found = locate_owner(ports.iter().cloned(), "conn-b").expect("承载连接的服务"); + assert!(Arc::ptr_eq(&found, &second)); + assert!(locate_owner(ports.into_iter(), "conn-unknown").is_none()); +} + +#[test] +fn inbound_routing_rejects_malformed_and_unknown_connections() { + let sessions = HashMap::new(); + let reject = |params: &Value| match route_inbound(&sessions, params) { + Err(error) => error, + Ok(_) => panic!("非法的 mcp/message 载荷不得路由成功: {params}"), + }; + + let missing_connection = reject(&json!({"method": "tools/list"})); + assert_eq!(missing_connection.code, -32602); + + let missing_method = reject(&json!({"connectionId": "conn-1"})); + assert_eq!(missing_method.code, -32602); + + // 未知连接用「资源不存在」而非内部错误:对端据此区分「连接已结束」与 + // 「本节点故障」,与内层 MCP 的 -32601 语义也不混同。 + let unknown = reject(&json!({"connectionId": "conn-1", "method": "tools/list"})); + assert_eq!(unknown.code, AcpMcpError::CODE_NOT_FOUND); +} + +/// 记录出站调用并可回错的 transport 假件(client 侧的最小协议对端)。 +#[derive(Default)] +struct RecordingTransport { + requests: Mutex>, + notifications: Mutex>, + reply_error: Option, +} + +#[async_trait] +impl crate::transport::AcpTransport for RecordingTransport { + async fn send_request(&self, method: &str, params: Value) -> Result { + self.requests.lock().push((method.to_string(), params)); + match &self.reply_error { + Some(error) => Err(error.clone()), + None => Ok(json!({"connectionId": "conn-1"})), + } + } + + async fn send_notification(&self, method: &str, params: Value) -> Result<(), AcpError> { + self.notifications.lock().push((method.to_string(), params)); + Ok(()) + } + + async fn recv(&self) -> Option { + None + } + + async fn send_response( + &self, + _id: RequestId, + _result: Result, + ) -> Result<(), AcpError> { + Ok(()) + } +} + +#[tokio::test] +async fn gateway_forwards_acp_requests_and_notifications() { + let transport = Arc::new(RecordingTransport::default()); + let gateway = + AcpTransportGateway::new(Arc::clone(&transport) as Arc); + + let connected = gateway + .request("mcp/connect", json!({"serverId": "srv-1"})) + .await + .unwrap(); + assert_eq!(connected, json!({"connectionId": "conn-1"})); + assert_eq!( + transport.requests.lock().as_slice(), + &[("mcp/connect".to_string(), json!({"serverId": "srv-1"}))] + ); + + gateway + .notify("mcp/message", json!({"connectionId": "conn-1"})) + .await + .unwrap(); + assert_eq!(transport.notifications.lock().len(), 1); +} + +#[tokio::test] +async fn gateway_preserves_inner_mcp_error_codes() { + let transport = Arc::new(RecordingTransport { + // 内层 MCP 的方法级错误:码值必须原样到达桥接侧,rmcp 据此判断 + // legacy 回退而不是把连接判为故障。 + reply_error: Some(AcpError::new(-32601, "method not found: server/discover")), + ..RecordingTransport::default() + }); + let gateway = + AcpTransportGateway::new(Arc::clone(&transport) as Arc); + + let error = gateway + .request("mcp/message", json!({"connectionId": "conn-1"})) + .await + .unwrap_err(); + assert_eq!( + error, + AcpMcpError { + code: -32601, + message: "method not found: server/discover".to_string(), + } + ); +} diff --git a/peri-acp/src/host/requests/session_lifecycle.rs b/peri-acp/src/host/requests/session_lifecycle.rs index ea642603f..c7297ef1c 100644 --- a/peri-acp/src/host/requests/session_lifecycle.rs +++ b/peri-acp/src/host/requests/session_lifecycle.rs @@ -1296,6 +1296,7 @@ fn prewarm_session_mcp_discovery(cfg: &AcpServerConfig, session_id: &str) { &pool, ®istry, &command_registry, + session_id, &cancel, ); } diff --git a/peri-acp/src/host/requests_test.rs b/peri-acp/src/host/requests_test.rs index ac291d240..30cc262d3 100644 --- a/peri-acp/src/host/requests_test.rs +++ b/peri-acp/src/host/requests_test.rs @@ -34,6 +34,9 @@ mod legacy_tests; #[path = "requests_update_config_test.rs"] mod update_config_tests; +#[path = "requests/acp_mcp_loop_test.rs"] +mod acp_mcp_loop_tests; + // ── Mock AcpTransport ───────────────────────────────────────────────────────── /// 记录全部通知的 mock transport(`Mutex>`,Slice 6 @@ -143,6 +146,7 @@ async fn make_server_config( cron_scheduler: None, mcp_pool: None, mcp_apps_relay: None, + acp_mcp: None, dynamic_mcp: None, oauth_event_tx: None, oauth_event_rx: None, diff --git a/peri-acp/src/host/server_loop.rs b/peri-acp/src/host/server_loop.rs index e9b539a18..7bb6dae8b 100644 --- a/peri-acp/src/host/server_loop.rs +++ b/peri-acp/src/host/server_loop.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use serde_json::Value; use tokio_util::sync::CancellationToken; +use tracing::debug; use super::{ connection::ConnectionContext, dispatch_prompt_turn, extract_session_id, handle_notification, @@ -29,6 +30,7 @@ impl ServerLoop<'_> { match msg { IncomingMessage::Request { id, method, params } => match method.as_str() { "session/prompt" => self.spawn_prompt(id, params).await, + "mcp/message" => self.spawn_acp_mcp_request(id, params).await, "peri/mcp/open" | "peri/mcp/app" | "peri/mcp/resource" | "peri/mcp/invoke" => { self.spawn_mcp_apps_request(id, method, params).await; } @@ -277,6 +279,53 @@ impl ServerLoop<'_> { } } + /// `mcp/message`:client 宿主的 MCP server 反向下发的请求。 + /// + /// 与 `session/prompt` / `peri/mcp/*` 同列 spawn 路径:内层处理时长由对端 + /// 决定,不在请求循环里等。定位承载会话只需一次短锁(连接事实分散在各会话 + /// 的会话级 MCP 池,`mcp/message` 只带 `connectionId`)。 + async fn spawn_acp_mcp_request(&self, id: RequestId, params: Value) { + let transport = Arc::clone(self.transport); + let connection_cancellation = self.connection_cancellation.clone(); + let routed = { + let sessions = self.sessions.lock().await; + requests::acp_mcp::route_inbound(&sessions, ¶ms) + }; + let spawner = self.cfg.host_task_spawner.clone(); + let rejected_transport = Arc::clone(&transport); + let rejected_id = id.clone(); + let spawn_result = spawner.spawn( + task_scope::HostTaskOwnerKind::Session, + task_scope::HostTaskKind::McpOverAcp, + async move { + let result = match routed { + Ok((port, inbound)) => tokio::select! { + _ = connection_cancellation.cancelled() => Err( + crate::transport::types::AcpError::new(-32800, "request cancelled"), + ), + result = port.request(inbound) => result + .map_err(|error| crate::transport::types::AcpError::new(error.code, error.message)), + }, + Err(error) => Err(error), + }; + if let Err(error) = transport.send_response(id, result).await { + tracing::warn!(%error, "MCP over ACP response send failed"); + } + }, + ); + if spawn_result.is_err() { + let _ = rejected_transport + .send_response( + rejected_id, + Err(crate::transport::types::AcpError::new( + -32800, + "request cancelled", + )), + ) + .await; + } + } + async fn dispatch_request(&self, id: RequestId, method: String, params: Value) { let transport = self.transport; let cfg = self.cfg; @@ -377,27 +426,23 @@ impl ServerLoop<'_> { relay.close_session(session_id); } } - let new_session_id = (method == "session/new") - .then(|| { - result - .as_ref() - .ok()? - .get("sessionId")? - .as_str() - .map(str::to_owned) - }) - .flatten(); + let setup_session_id = session_setup(&method, ¶ms, result.as_ref().ok()); let response_sent = transport.send_response(id, result).await.is_ok(); if response_sent { - if let Some(session_id) = new_session_id { + if let Some(session_id) = setup_session_id { let environment = sessions .lock() .await .get(&session_id) .and_then(|state| state.environment.clone()); let local = environment.as_ref().map(|env| &env.cfg).unwrap_or(cfg); - requests::session_lifecycle::after_new_response(local, transport, &session_id) - .await; + if method == "session/new" { + requests::session_lifecycle::after_new_response(local, transport, &session_id) + .await; + } + // 会话 setup 声明的 acp 型 MCP server:响应之后才受理,客户端此时 + // 已拿到会话结果,`mcp/connect`(只带 serverId)才有确定的归属方。 + requests::acp_mcp::attach_session_servers(local, transport, ¶ms, &session_id); } } } @@ -406,6 +451,34 @@ impl ServerLoop<'_> { let cfg = self.cfg; let sessions = self.sessions; let cont_tx = self.cont_tx; + if method == "mcp/message" { + // client 宿主的 MCP server 反向下发的通知:按 connectionId 路由到承载 + // 会话的服务。就地 await(不 spawn)以保住与后续请求的相对次序,但只 + // 在短锁内定位承载者——投递等待由对端决定,不得占着会话锁。 + let routed = { + let sessions = sessions.lock().await; + requests::acp_mcp::route_inbound(&sessions, ¶ms) + }; + match routed { + Ok((port, inbound)) => { + if let Err(error) = port.notify(inbound).await { + debug!( + code = error.code, + error = %error.message, + "MCP over ACP 通知未能投递" + ); + } + } + Err(error) => { + debug!( + code = error.code, + error = %error.message, + "MCP over ACP 通知未能路由" + ); + } + } + return; + } if method == "session/cancel" { let session_id = extract_session_id(¶ms, ""); if !session_id.is_empty() { @@ -434,3 +507,21 @@ impl ServerLoop<'_> { } } } + +/// 会话 setup 方法涉及的会话 id:`session/new` | `session/fork` 取响应里的新 id, +/// `session/load` | `session/resume` 取请求里的 `sessionId`;非 setup 方法与失败的 +/// 调用返回 `None`(失败响应不做后置初始化)。 +fn session_setup(method: &str, params: &Value, response: Option<&Value>) -> Option { + let response = response?; + match method { + "session/new" | "session/fork" => response + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_owned), + "session/load" | "session/resume" => { + let session_id = extract_session_id(params, ""); + (!session_id.is_empty()).then(|| session_id.to_owned()) + } + _ => None, + } +} diff --git a/peri-acp/src/host/stdio/run_server_integration_test.rs b/peri-acp/src/host/stdio/run_server_integration_test.rs index 820c4db24..84c0caac7 100644 --- a/peri-acp/src/host/stdio/run_server_integration_test.rs +++ b/peri-acp/src/host/stdio/run_server_integration_test.rs @@ -165,6 +165,7 @@ async fn make_server_config_with( cron_scheduler: None, mcp_pool, mcp_apps_relay: None, + acp_mcp: None, dynamic_mcp: None, oauth_event_tx: None, oauth_event_rx: None, diff --git a/peri-acp/src/host/task_scope.rs b/peri-acp/src/host/task_scope.rs index a910c4cbb..856e55a11 100644 --- a/peri-acp/src/host/task_scope.rs +++ b/peri-acp/src/host/task_scope.rs @@ -30,6 +30,8 @@ pub(crate) enum HostTaskKind { Prediction, LegacyCancelHook, McpAppsRelay, + /// client 宿主的 MCP server 反向下发的 `mcp/message` 请求。 + McpOverAcp, UserInputEvents, CompactHook, } diff --git a/peri-acp/src/host/workspace.rs b/peri-acp/src/host/workspace.rs index 9fa99c918..633da7fc0 100644 --- a/peri-acp/src/host/workspace.rs +++ b/peri-acp/src/host/workspace.rs @@ -126,6 +126,13 @@ impl SessionEnvironment { } pub(crate) async fn shutdown(&self) -> bool { + // 会话终结即断开本会话声明的 MCP-over-ACP 连接:连接由 client 侧的 + // ACP 通道承载,会话不再存活后既没有归属也不会有入站消息;处置必须在 + // 池关闭之前完成,否则 `mcp/disconnect` 已无出站通道可用。实现幂等, + // 关闭重试重复调用是安全的。 + if let Some(port) = self.cfg.acp_mcp.as_ref() { + port.close_session(&self.session_id).await; + } if !self.finish_session_end().await { return false; } diff --git a/peri-agent/CLAUDE.md b/peri-agent/CLAUDE.md index dd599862a..2cb9ac22d 100644 --- a/peri-agent/CLAUDE.md +++ b/peri-agent/CLAUDE.md @@ -26,6 +26,7 @@ ## 稳定不变量 - `run_react_loop` 是阶段循环入口;退出判断保留在 Receive。 +- 启动闸门 `before_react_start` 在首批 `before_agent`/`before_input` 之后、Compact 之前只执行一次;它的 Err 不降级(`Interrupted` → `LoopResult::Interrupted`,其它 → `LoopResult::Error`),候选工具只活在本次 gate 的 `StartupState` 里、随 state 丢弃,而既有 `before_agent` 的软失败降级不受影响。 - `StageContext` 是阶段依赖边界;阶段间通过输入/输出和上下文传递,不绕过为全局状态。 - `FrozenContext` 的 prompt、指引、skills 与日期在会话内不可漂移;SubAgent 复用上游冻结数据。 - `BaseTool::is_direct()` 是工具可见性事实源;deferred 工具经搜索/执行代理访问。 diff --git a/peri-agent/src/agent/stages/middleware_runner.rs b/peri-agent/src/agent/stages/middleware_runner.rs index e8adc4af7..21cae1a7c 100644 --- a/peri-agent/src/agent/stages/middleware_runner.rs +++ b/peri-agent/src/agent/stages/middleware_runner.rs @@ -19,13 +19,93 @@ use crate::agent::agent_context::AgentContext; use crate::agent::stages::StageContext; +use crate::middleware::capabilities as hook_state; use crate::middleware::state::MiddlewareState; +use crate::session::tool_catalog::StartupToolUpdate; /// 从 StageContext 构造 AgentContext fn make_context_from_stage(ctx: &StageContext) -> AgentContext<'_> { AgentContext::from_stage(ctx) } +/// 启动闸门状态:暂存本次准入的候选工具更新。 +/// +/// 候选只存在于本次 `run_before_react_start` 的局部 state;middleware 失败、 +/// 取消或闸门结束即随 state 丢弃,不落 middleware 内部字段,也不跨 loop 复用。 +#[derive(Default)] +struct StartupGateState { + active_middleware: Option, + candidate: Option, + candidate_owner: Option, +} + +impl StartupGateState { + /// 候选登记者的名称;无登记时按链级归类。 + fn owner(&self) -> String { + self.candidate_owner + .clone() + .unwrap_or_else(|| "chain".to_string()) + } +} + +impl hook_state::StartupState for StartupGateState { + fn set_active_middleware(&mut self, middleware_name: &str) { + self.active_middleware = Some(middleware_name.to_string()); + } + + fn stage_startup_tools(&mut self, update: StartupToolUpdate) -> crate::error::AgentResult<()> { + let owner = self + .active_middleware + .clone() + .unwrap_or_else(|| "chain".to_string()); + if self.candidate.is_some() { + return Err(crate::error::AgentError::MiddlewareError { + middleware: owner, + reason: "System MCP 启动失败:启动闸门已登记候选工具,拒绝重复登记".to_string(), + }); + } + self.candidate = Some(update); + self.candidate_owner = Some(owner); + Ok(()) + } + + fn take_startup_tools(&mut self) -> Option { + self.candidate.take() + } +} + +/// 调用 middleware chain 的 `before_react_start` 钩子,并把候选原子提交到 +/// session tool catalog 的 static base。 +/// +/// 提交只更新 static base:Reason 边界仍完整走 ARC-TOOLS-001 的 +/// `refresh → working map swap → before_reason_catalog → before_model → pin`, +/// 本函数不改写 working map、不替代 Reason boundary。钩子返回 Err 时候选直接 +/// 丢弃,目录不变——调用方据此阻止进入 Compact。 +pub async fn run_before_react_start(ctx: &StageContext) -> crate::error::AgentResult<()> { + let mut gate = StartupGateState::default(); + ctx.runtime + .middleware_chain + .run_before_react_start(&mut gate) + .await?; + let Some(update) = hook_state::StartupState::take_startup_tools(&mut gate) else { + return Ok(()); + }; + let committed = ctx + .runtime + .tool_catalog + .replace_static_mcp_tools(update) + .map_err(|error| crate::error::AgentError::MiddlewareError { + middleware: gate.owner(), + reason: format!("System MCP 启动失败:工具目录发布被拒绝,未发布 ready({error})"), + })?; + tracing::debug!( + generation = committed.generation, + tools = committed.tools.len(), + "startup tool update committed to static base" + ); + Ok(()) +} + // ─── Async 调用辅助 ─────────────────────────────────────────────────────────── /// 调用 middleware chain 的 `before_compact` 钩子(只读,无 drain) diff --git a/peri-agent/src/agent/stages/middleware_runner_test.rs b/peri-agent/src/agent/stages/middleware_runner_test.rs index 5fa95ffa4..29a7689d5 100644 --- a/peri-agent/src/agent/stages/middleware_runner_test.rs +++ b/peri-agent/src/agent/stages/middleware_runner_test.rs @@ -306,3 +306,404 @@ async fn test_before_input_reconciles_replacement_after_error() { "出错前已完成的转换仍须回写" ); } + +// ─── 启动闸门(before_react_start)────────────────────────────────────────── + +/// 静态 MCP bridge 测试桩(名称与 `McpToolBridge` 同形)。 +struct StartupStubTool { + name: String, + server: Option, + direct: bool, +} + +#[async_trait::async_trait] +impl crate::tools::BaseTool for StartupStubTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.name + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + fn mcp_server_name(&self) -> Option<&str> { + self.server.as_deref() + } + + fn is_direct(&self) -> bool { + self.direct + } + + async fn invoke( + &self, + _input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + Ok(String::new()) + } +} + +fn startup_stub(name: &str, server: Option<&str>, direct: bool) -> Arc { + Arc::new(StartupStubTool { + name: name.to_string(), + server: server.map(str::to_owned), + direct, + }) +} + +/// 本次准入的候选:静态 bridge + 必需工具身份。 +fn startup_candidate(direct: bool) -> (Arc, StartupToolUpdate) { + let tool = startup_stub("mcp__system__lookup", Some("system"), direct); + let update = StartupToolUpdate { + tools: vec![Arc::clone(&tool)], + required: vec![crate::session::tool_catalog::StartupRequiredTool { + server_name: "system".to_string(), + original_tool_name: "lookup".to_string(), + effective_tool_name: "mcp__system__lookup".to_string(), + }], + }; + (tool, update) +} + +fn startup_catalog_with_core() -> Arc { + Arc::new(crate::session::tool_catalog::SessionToolCatalog::new( + std::collections::BTreeMap::from([("Read".to_string(), startup_stub("Read", None, true))]), + None, + )) +} + +fn working_tool_names(ctx: &StageContext) -> Vec { + ctx.runtime.tools.read().keys().cloned().collect() +} + +struct StageStartupTools(StartupToolUpdate); + +#[async_trait::async_trait] +impl crate::middleware::Middleware for StageStartupTools { + fn name(&self) -> &str { + "StartupStager" + } + + async fn before_react_start( + &self, + state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + state.stage_startup_tools(self.0.clone()) + } +} + +struct ObserveUncommittedCatalog( + Arc, + Arc, +); + +#[async_trait::async_trait] +impl crate::middleware::Middleware for ObserveUncommittedCatalog { + fn name(&self) -> &str { + "ObserveUncommittedCatalog" + } + + async fn before_react_start( + &self, + _state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + assert!( + !self.0.snapshot().tools.contains_key("mcp__system__lookup"), + "整条闸门链成功之前不得提交目录" + ); + self.1.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} + +struct SecondStartupStager(StartupToolUpdate); + +#[async_trait::async_trait] +impl crate::middleware::Middleware for SecondStartupStager { + fn name(&self) -> &str { + "SecondStartupStager" + } + + async fn before_react_start( + &self, + state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + state.stage_startup_tools(self.0.clone()) + } +} + +struct FailStartupGate; + +#[async_trait::async_trait] +impl crate::middleware::Middleware for FailStartupGate { + fn name(&self) -> &str { + "FailStartupGate" + } + + async fn before_react_start( + &self, + _state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + Err(crate::error::AgentError::MiddlewareError { + middleware: self.name().to_string(), + reason: "gate failed after staging".to_string(), + }) + } +} + +#[tokio::test] +async fn startup_gate_commits_staged_candidate_to_static_base_only() { + let mut ctx = make_context(); + let catalog = startup_catalog_with_core(); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let (_, update) = startup_candidate(true); + let observed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(StageStartupTools(update))); + chain.add(Box::new(ObserveUncommittedCatalog( + Arc::clone(&catalog), + Arc::clone(&observed), + ))); + ctx.runtime.middleware_chain = Arc::new(chain); + let working_before = working_tool_names(&ctx); + + run_before_react_start(&ctx).await.unwrap(); + + assert!( + observed.load(std::sync::atomic::Ordering::SeqCst), + "闸门必须按链序走完全部 middleware" + ); + let snapshot = catalog.snapshot(); + assert!( + snapshot + .direct_definitions + .iter() + .any(|definition| definition.name == "mcp__system__lookup"), + "准入候选必须直接出现在目录的模型可见工具中" + ); + assert!(snapshot.tools.contains_key("Read"), "非 MCP 工具不被覆盖"); + assert_eq!( + working_tool_names(&ctx), + working_before, + "startup 提交只更新 static base,working map 留给 Reason boundary 的 refresh/swap" + ); +} + +#[tokio::test] +async fn startup_gate_discards_candidate_when_a_later_middleware_fails() { + let mut ctx = make_context(); + let catalog = startup_catalog_with_core(); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let before = catalog.snapshot(); + let (_, update) = startup_candidate(true); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(StageStartupTools(update))); + chain.add(Box::new(FailStartupGate)); + ctx.runtime.middleware_chain = Arc::new(chain); + + let error = run_before_react_start(&ctx).await.unwrap_err(); + + assert!( + matches!(error, crate::error::AgentError::MiddlewareError { middleware, reason } + if middleware == "FailStartupGate" && reason == "gate failed after staging") + ); + assert!( + Arc::ptr_eq(&before, &catalog.snapshot()), + "闸门链失败时候选必须整体丢弃,不提交部分目录" + ); +} + +#[tokio::test] +async fn startup_gate_rejects_second_staging_with_owner_attribution() { + let mut ctx = make_context(); + let catalog = startup_catalog_with_core(); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let before = catalog.snapshot(); + let (_, first) = startup_candidate(true); + let (_, second) = startup_candidate(true); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(StageStartupTools(first))); + chain.add(Box::new(SecondStartupStager(second))); + ctx.runtime.middleware_chain = Arc::new(chain); + + let error = run_before_react_start(&ctx).await.unwrap_err(); + + assert!( + matches!(error, crate::error::AgentError::MiddlewareError { middleware, reason } + if middleware == "SecondStartupStager" && reason.contains("拒绝重复登记")), + "重复登记必须归属到实际登记的 middleware" + ); + assert!(Arc::ptr_eq(&before, &catalog.snapshot())); +} + +#[tokio::test] +async fn startup_gate_commit_failure_is_attributed_to_staging_middleware() { + let mut ctx = make_context(); + let catalog = Arc::new( + crate::session::tool_catalog::SessionToolCatalog::with_filter( + std::collections::BTreeMap::new(), + None, + Arc::new(|name| name != "mcp__system__lookup"), + ), + ); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let (_, update) = startup_candidate(true); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(StageStartupTools(update))); + ctx.runtime.middleware_chain = Arc::new(chain); + + let error = run_before_react_start(&ctx).await.unwrap_err(); + + match error { + crate::error::AgentError::MiddlewareError { middleware, reason } => { + assert_eq!(middleware, "StartupStager"); + assert!( + reason.contains("工具目录发布被拒绝") && reason.contains("mcp__system__lookup"), + "文案必须给出安全且可定位的失败类别,实际为:{reason}" + ); + } + other => panic!("expected MiddlewareError, got {other:?}"), + } + assert!(!catalog.snapshot().tools.contains_key("mcp__system__lookup")); +} + +#[tokio::test] +async fn startup_gate_without_candidate_leaves_catalog_and_working_map() { + let mut ctx = make_context(); + let catalog = startup_catalog_with_core(); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let before = catalog.snapshot(); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(crate::middleware::NoopMiddleware::new("noop"))); + ctx.runtime.middleware_chain = Arc::new(chain); + let working_before = working_tool_names(&ctx); + + run_before_react_start(&ctx).await.unwrap(); + + assert!(Arc::ptr_eq(&before, &catalog.snapshot())); + assert_eq!(working_tool_names(&ctx), working_before); +} + +/// 记录首次 Reason 实际入参工具名的 mock LLM(不复制 Reason 实现)。 +struct CapturingReasonLlm(Arc>>); + +#[async_trait::async_trait] +impl crate::agent::react::ReactLLM for CapturingReasonLlm { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + *self.0.lock() = tools.iter().map(|tool| tool.name().to_string()).collect(); + Ok(crate::agent::react::Reasoning::with_answer( + "thinking", "done", + )) + } + + fn model_name(&self) -> String { + "capturing-reason".to_string() + } +} + +#[tokio::test] +async fn startup_catalog_update_reaches_first_reason_tool_list() { + let mut ctx = make_context(); + let catalog = startup_catalog_with_core(); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let required = startup_stub("mcp__system__lookup", Some("system"), true); + let deferred = startup_stub("mcp__system__extra", Some("system"), false); + let update = StartupToolUpdate { + tools: vec![Arc::clone(&required), Arc::clone(&deferred)], + required: vec![crate::session::tool_catalog::StartupRequiredTool { + server_name: "system".to_string(), + original_tool_name: "lookup".to_string(), + effective_tool_name: "mcp__system__lookup".to_string(), + }], + }; + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(StageStartupTools(update))); + ctx.runtime.middleware_chain = Arc::new(chain); + ctx.session + .transcript + .write() + .append(BaseMessage::human(MessageContent::text("question"))); + run_before_react_start(&ctx).await.unwrap(); + assert!( + !ctx.runtime.tools.read().contains_key("mcp__system__lookup"), + "闸门提交阶段不先动 working map" + ); + + let seen = Arc::new(parking_lot::Mutex::new(Vec::new())); + ctx.runtime.llm = Arc::new(CapturingReasonLlm(Arc::clone(&seen))); + crate::agent::stages::reason::run_reason(crate::agent::stages::ReasonInput { + context: ctx.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + + let names = seen.lock().clone(); + assert!( + names.contains(&"mcp__system__lookup".to_string()), + "首个 Reason 的 LLM 入参必须含 required 工具,实际为:{names:?}" + ); + assert!( + !names.contains(&"mcp__system__extra".to_string()), + "普通 deferred 工具不得直接进入 LLM tools" + ); + assert!(names.contains(&"Read".to_string()), "core 工具不受影响"); + let snapshot = ctx.runtime.tool_catalog.snapshot(); + assert!( + snapshot.tools.contains_key("mcp__system__extra"), + "deferred bridge 仍在目录中,留给 ToolSearch 发现" + ); + assert!( + ctx.runtime.tools.read().contains_key("mcp__system__lookup"), + "Reason boundary 完成 refresh → working map swap" + ); +} + +struct InterruptStartupGate; + +#[async_trait::async_trait] +impl crate::middleware::Middleware for InterruptStartupGate { + fn name(&self) -> &str { + "InterruptStartupGate" + } + + async fn before_react_start( + &self, + _state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + Err(crate::error::AgentError::Interrupted) + } +} + +#[tokio::test] +async fn startup_gate_interrupted_is_propagated_without_commit() { + let mut ctx = make_context(); + let catalog = startup_catalog_with_core(); + ctx.runtime.tool_catalog = Arc::clone(&catalog); + let before = catalog.snapshot(); + let (_, update) = startup_candidate(true); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(StageStartupTools(update))); + chain.add(Box::new(InterruptStartupGate)); + ctx.runtime.middleware_chain = Arc::new(chain); + + let error = run_before_react_start(&ctx).await.unwrap_err(); + + assert!( + matches!(error, crate::error::AgentError::Interrupted), + "取消必须按 Interrupted 原样上报(timeout 等 fatal 不在此列),实际为 {error:?}" + ); + assert!( + Arc::ptr_eq(&before, &catalog.snapshot()), + "取消时不得发布 ready" + ); +} diff --git a/peri-agent/src/agent/stages/mod.rs b/peri-agent/src/agent/stages/mod.rs index 2b931c69b..ddfdd2183 100644 --- a/peri-agent/src/agent/stages/mod.rs +++ b/peri-agent/src/agent/stages/mod.rs @@ -564,6 +564,8 @@ struct LoopState { has_tool_calls: bool, /// before_agent hooks 是否已执行(首次 Receive 后执行一次) before_agent_has_run: bool, + /// 启动闸门 hook 是否已通过(首批 before_agent 后执行一次,成功才置位) + react_start_has_run: bool, /// 仅无完整工具调用的截断消耗恢复预算;完整工具结果可继续正常循环。 consecutive_truncations: usize, /// 本轮累计中断次数,不因工具进展重置。 @@ -893,6 +895,21 @@ pub async fn run_react_loop(context: StageContext, max_iterations: usize) -> Loo return LoopResult::Error(error); } + // ── 启动闸门 hook(首批输入准备完成后、Compact 前执行一次)── + // 只有声明启动依赖的 middleware(如 System MCP 准入)在此阻止 loop 启动: + // Err 不得降级,本次 loop 不进入 Compact / Reason / Act;Interrupted 仍按 + // 中断分类,不算 fatal。既有 before_agent 的软失败降级不受影响(见上)。 + if !loop_state.react_start_has_run { + match middleware_runner::run_before_react_start(&context).await { + Ok(()) => loop_state.react_start_has_run = true, + Err(crate::error::AgentError::Interrupted) => return LoopResult::Interrupted, + Err(error) => { + tracing::warn!(error = %error, "[v2] before_react_start hook failed"); + return LoopResult::Error(error); + } + } + } + // ── Compact ── // Compact 输出(compacted 标志)当前无调用方:compact 的副作用已直接 // 写入 transcript/flags 与事件流,此处仅保留阶段观测与错误传播。 diff --git a/peri-agent/src/agent/stages/stages_test.rs b/peri-agent/src/agent/stages/stages_test.rs index ffcd877b1..bc4930254 100644 --- a/peri-agent/src/agent/stages/stages_test.rs +++ b/peri-agent/src/agent/stages/stages_test.rs @@ -1310,6 +1310,489 @@ async fn test_p0_2_before_agent_runs_once_after_receive_and_skips_empty_or_cance assert_eq!(llm_calls.load(Ordering::SeqCst), 1); } +// ─── 启动闸门 hook(before_react_start)回归 ──────────────────────────────── +// +// 契约 2:System MCP 等启动依赖未完成前不得进入可启动 react loop。闸门在首批 +// before_agent 之后、Compact 之前调用一次;Err 终止本次 loop,Interrupted 仍按 +// 中断分类;既有 before_agent 的软失败降级不变。 + +/// 静态 MCP bridge 测试桩(名称与生产 `mcp__{server}__{tool}` 同形)。 +struct StartupGateStubTool { + name: String, + server: Option, + direct: bool, +} + +#[async_trait::async_trait] +impl crate::tools::BaseTool for StartupGateStubTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.name + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + fn mcp_server_name(&self) -> Option<&str> { + self.server.as_deref() + } + + fn is_direct(&self) -> bool { + self.direct + } + + async fn invoke( + &self, + _input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + Ok(String::new()) + } +} + +fn startup_gate_stub( + name: &str, + server: Option<&str>, + direct: bool, +) -> Arc { + Arc::new(StartupGateStubTool { + name: name.to_string(), + server: server.map(str::to_owned), + direct, + }) +} + +/// 本地目录 + working map:闸门提交的静态 MCP bridge 必须与既有条目共存。 +fn startup_gate_catalog() -> (SharedToolMap, Arc) { + let local = startup_gate_stub("startup_gate_local_tool", None, false); + let working: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "startup_gate_local_tool".to_string(), + Arc::clone(&local), + )]))); + let catalog = Arc::new(SessionToolCatalog::new( + BTreeMap::from([("startup_gate_local_tool".to_string(), local)]), + None, + )); + (working, catalog) +} + +/// 本次准入的候选:静态 bridge + 必需工具身份。 +fn startup_gate_candidate() -> crate::session::tool_catalog::StartupToolUpdate { + crate::session::tool_catalog::StartupToolUpdate { + tools: vec![startup_gate_stub( + "mcp__system__lookup", + Some("system"), + true, + )], + required: vec![crate::session::tool_catalog::StartupRequiredTool { + server_name: "system".to_string(), + original_tool_name: "lookup".to_string(), + effective_tool_name: "mcp__system__lookup".to_string(), + }], + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StartupGateOutcome { + /// 暂存候选后成功返回。 + Publish, + /// 暂存候选后返回 Err:候选必须整批丢弃。 + StageThenFail, + /// 返回 `AgentError::Interrupted`:按中断分类,不是 fatal。 + Interrupt, +} + +/// 同时实现 `before_agent` 与 `before_react_start`,用于断言调用次序。 +struct StartupGateProbe { + outcome: StartupGateOutcome, + before_agent_fails: bool, + order: Arc>>, + gate_calls: Arc, +} + +#[async_trait::async_trait] +impl crate::middleware::Middleware for StartupGateProbe { + fn name(&self) -> &str { + "StartupGateProbe" + } + + async fn before_agent( + &self, + _state: &mut dyn hook_state::BeforeAgentState, + ) -> crate::error::AgentResult<()> { + self.order.lock().unwrap().push("before_agent"); + if self.before_agent_fails { + return Err(crate::error::AgentError::MiddlewareError { + middleware: self.name().to_string(), + reason: "soft before_agent failure".to_string(), + }); + } + Ok(()) + } + + async fn before_react_start( + &self, + state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + self.order.lock().unwrap().push("before_react_start"); + self.gate_calls.fetch_add(1, Ordering::SeqCst); + state.stage_startup_tools(startup_gate_candidate())?; + match self.outcome { + StartupGateOutcome::Publish => Ok(()), + StartupGateOutcome::StageThenFail => Err(crate::error::AgentError::MiddlewareError { + middleware: self.name().to_string(), + reason: "startup gate failure".to_string(), + }), + StartupGateOutcome::Interrupt => Err(crate::error::AgentError::Interrupted), + } + } +} + +/// 记录每次模型调用看到的 direct 工具名;首轮触发一次本地工具调用以产生第二轮迭代。 +struct StartupGateLLM { + calls: Arc, + seen_tools: Arc>>>, +} + +#[async_trait::async_trait] +impl ReactLLM for StartupGateLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + self.seen_tools.lock().unwrap().push( + tools + .iter() + .map(|tool| tool.name().to_string()) + .collect::>(), + ); + match self.calls.fetch_add(1, Ordering::SeqCst) { + 0 => Ok(crate::agent::react::Reasoning::with_tools( + "use the local probe tool", + vec![crate::agent::react::ToolCall::new( + "startup-gate-tool-call", + "startup_gate_local_tool", + serde_json::json!({}), + )], + )), + _ => Ok(crate::agent::react::Reasoning::with_answer( + "thinking", "done", + )), + } + } + + fn model_name(&self) -> String { + "startup-gate-mock".to_string() + } +} + +#[tokio::test] +async fn test_react_start_gate_publishes_candidate_before_first_reason_and_runs_once() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Publish, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-publish"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + assert!(matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + )); + + assert_eq!( + *order.lock().unwrap(), + vec!["before_agent", "before_react_start"], + "闸门必须在首批 before_agent 之后执行" + ); + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 1, + "闸门每次 loop 只执行一次,第二轮迭代不得重复准入" + ); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 2, + "工具往返产生第二轮迭代,闸门成功不得阻止 Reason" + ); + let snapshot = catalog.snapshot(); + assert!( + snapshot + .direct_definitions + .iter() + .any(|definition| definition.name == "mcp__system__lookup"), + "闸门候选必须作为 direct 工具进入目录" + ); + assert!( + snapshot.tools.contains_key("startup_gate_local_tool"), + "闸门提交不得覆盖既有非 MCP 条目" + ); + let seen = seen_tools.lock().unwrap(); + assert_eq!(seen.len(), 2, "两轮 Reason 都必须真正调用模型"); + assert!( + seen[0].iter().any(|name| name == "mcp__system__lookup"), + "闸门提交的 required 工具必须出现在首个模型请求的 tools 中, got {:?}", + seen[0] + ); +} + +#[tokio::test] +async fn test_react_start_gate_error_stops_before_compact_without_publishing_candidate() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::StageThenFail, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-error"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .with_event_bus(Arc::new(bus)) + .build(); + let published_before = catalog.snapshot(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + let result = run_react_loop(context.clone(), 10).await; + + match result { + LoopResult::Error(crate::error::AgentError::MiddlewareError { middleware, reason }) => { + assert_eq!(middleware, "StartupGateProbe"); + assert_eq!(reason, "startup gate failure"); + } + other => panic!("闸门 Err 必须作为 loop fatal 返回, got {other:?}"), + } + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 1, + "闸门失败即终止,不得重试或重复准入" + ); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 0, + "闸门失败后不得进入 Reason 调用模型" + ); + assert_eq!( + drain_loop_observe_events(&mut handles).stage_lifecycle, + expected_stage_lifecycle(&[Stage::Receive]), + "闸门失败只允许完成 Receive,不得进入 Compact / Reason / Act" + ); + assert!( + Arc::ptr_eq(&published_before, &catalog.snapshot()), + "闸门失败必须丢弃 state 内候选,不发布部分目录" + ); +} + +#[tokio::test] +async fn test_react_start_gate_interrupted_maps_to_interrupted_not_fatal() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Interrupt, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-interrupted"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + let published_before = catalog.snapshot(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + let result = run_react_loop(context.clone(), 10).await; + + assert!( + matches!(result, LoopResult::Interrupted), + "闸门 Interrupted 必须按中断分类,不得升级为 fatal, got {result:?}" + ); + assert_eq!(gate_calls.load(Ordering::SeqCst), 1); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 0, + "中断后不得进入 Reason 调用模型" + ); + assert!( + Arc::ptr_eq(&published_before, &catalog.snapshot()), + "中断时暂存候选必须随 state 丢弃" + ); +} + +#[tokio::test] +async fn test_before_agent_soft_failure_still_reaches_reason_after_startup_gate() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Publish, + before_agent_fails: true, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-soft-before-agent"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + assert!( + matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + ), + "既有 before_agent 的软失败必须保持 warn 降级、不阻止 loop" + ); + assert_eq!( + *order.lock().unwrap(), + vec!["before_agent", "before_react_start"], + "before_agent 软失败不影响闸门按序执行" + ); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 2, + "软失败后 loop 仍必须走到 Reason 并完成工具往返" + ); + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 1, + "软失败不得让闸门重复执行" + ); +} + +#[tokio::test] +async fn test_react_start_gate_skipped_when_loop_exits_at_receive() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Publish, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-empty-queue"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + + assert!(matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + )); + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 0, + "Receive 直接退出时不得调用启动闸门" + ); + assert!(order.lock().unwrap().is_empty()); + assert_eq!(llm_calls.load(Ordering::SeqCst), 0); +} + struct UsageChurnReactLLM { usages: Vec, calls: Arc, diff --git a/peri-agent/src/middleware/capabilities.rs b/peri-agent/src/middleware/capabilities.rs index 32cc8cb8d..b4891b0d3 100644 --- a/peri-agent/src/middleware/capabilities.rs +++ b/peri-agent/src/middleware/capabilities.rs @@ -6,8 +6,9 @@ use super::state::MiddlewareState; use crate::{ agent::stages::SharedToolMap, + error::AgentResult, messages::{BaseMessage, MessageId}, - session::{MessageQueue, QueuedMessage}, + session::{tool_catalog::StartupToolUpdate, MessageQueue, QueuedMessage}, }; /// 只读消息与 turn 元数据,不暴露队列或工具目录。 @@ -123,6 +124,37 @@ impl BackgroundActivity for T { /// ``` pub trait BeforeModelState: StateView + MessageAppend + QueueState {} +/// 启动闸门(首批输入准备完成后、Compact 前)真实支持的能力:只暂存与取出本次 +/// 准入的候选工具更新,不暴露 transcript、队列或目录写权限。 +/// +/// 候选经本状态传递,失败或取消时由调用方直接丢弃 state,不需要 middleware +/// 内部的 commit/discard 状态协议。 +/// +/// ```compile_fail +/// use peri_agent::{messages::BaseMessage, middleware::capabilities::StartupState}; +/// fn cannot_append_history(state: &mut dyn StartupState, message: BaseMessage) { +/// state.add_message(message); +/// } +/// ``` +/// ```compile_fail +/// use peri_agent::middleware::capabilities::StartupState; +/// fn cannot_drain_queue(state: &mut dyn StartupState) { +/// state.v2_queue().drain_all(); +/// } +/// ``` +pub trait StartupState: Send + Sync { + /// 链序事实:记录当前正在执行闸门的 middleware,用于候选归属与安全错误文案。 + fn set_active_middleware(&mut self, middleware_name: &str); + + /// 暂存候选:整批静态工具(required 已提升 direct)与其必需工具身份。 + /// + /// 一次准入只允许一个候选;重复暂存返回 Err 而不是静默覆盖。 + fn stage_startup_tools(&mut self, update: StartupToolUpdate) -> AgentResult<()>; + + /// 取出候选;未暂存返回 `None`。 + fn take_startup_tools(&mut self) -> Option; +} + impl StateView for T { fn cwd(&self) -> &str { MiddlewareState::cwd(self) diff --git a/peri-agent/src/middleware/chain.rs b/peri-agent/src/middleware/chain.rs index 0dbd72691..47d2147f7 100644 --- a/peri-agent/src/middleware/chain.rs +++ b/peri-agent/src/middleware/chain.rs @@ -88,6 +88,21 @@ impl MiddlewareChain { Ok(()) } + /// 顺序执行启动闸门钩子(首批输入准备完成后、Compact 前)。 + /// + /// 遇错即停——后续 middleware 不执行,错误向上传播,调用方据此阻止进入 + /// Compact / Reason / Act。执行前先登记当前 middleware 名称,供候选归属。 + pub async fn run_before_react_start( + &self, + state: &mut dyn hook_state::StartupState, + ) -> AgentResult<()> { + for middleware in &self.middlewares { + state.set_active_middleware(middleware.name()); + middleware.before_react_start(state).await?; + } + Ok(()) + } + /// 顺序执行 Reason 工具目录刷新钩子。 pub async fn run_before_reason_catalog( &self, diff --git a/peri-agent/src/middleware/trait.rs b/peri-agent/src/middleware/trait.rs index 7ef38d966..03b650f58 100644 --- a/peri-agent/src/middleware/trait.rs +++ b/peri-agent/src/middleware/trait.rs @@ -25,6 +25,7 @@ use crate::{ /// ── Agent 生命周期级 ── /// 3. before_agent - Agent 开始执行前 /// before_input - 每批用户输入进入 Compact 前;首批与初始化按链序交错 +/// before_react_start - 首批输入准备完成后、Compact 前的启动闸门 /// /// ── 每轮 ReAct 迭代 ── /// 4. before_model - 每轮 LLM 调用前 @@ -75,6 +76,21 @@ pub trait Middleware: Send + Sync { Ok(()) } + /// 首次输入准备完成后、Compact 前的启动闸门。 + /// + /// 默认 no-op;只有声明启动依赖的 middleware 实现。调用点在首批 + /// `before_agent` / `before_input` 之后、Compact 之前:返回 Err 时本次 loop + /// 不进入 Compact / Reason / Act(`Interrupted` 仍按中断分类),已暂存的 + /// 候选随 state 丢弃。候选经 `StartupState` 传递,不落 middleware 内部字段。 + /// + /// 与既有 `before_agent` 的软失败语义互不影响:初始化钩子的 Err 处理不变。 + async fn before_react_start( + &self, + _state: &mut dyn hook_state::StartupState, + ) -> AgentResult<()> { + Ok(()) + } + /// 首轮用户 turn 的一次性受控通知(可选实现)。 /// /// Executor 仅在首个模型可见 turn(history 为空且非 continuation/keepgoing) diff --git a/peri-agent/src/session/exec/stage_builder/tools.rs b/peri-agent/src/session/exec/stage_builder/tools.rs index 08c5c9ee9..77901d5a8 100644 --- a/peri-agent/src/session/exec/stage_builder/tools.rs +++ b/peri-agent/src/session/exec/stage_builder/tools.rs @@ -1,9 +1,15 @@ //! Session-local 工具视图与动态目录注册;不写宿主共享表。 use super::{StageBuildError, StageBuildInput}; use crate::{ - agent::stages::SharedToolMap, session::tool_catalog::SessionToolCatalog, tools::BaseTool, + agent::stages::SharedToolMap, + session::tool_catalog::{CatalogRefreshError, SessionToolCatalog, StartupCatalogRegistration}, + tools::BaseTool, }; use parking_lot::RwLock; +use peri_acp_types::{ + dynamic_mcp::{DynamicMcpCatalogTool, DynamicMcpErrorCode}, + ports::DynamicMcpDeploymentPort, +}; use std::{ collections::{BTreeMap, HashSet}, sync::Arc, @@ -57,7 +63,42 @@ pub(super) fn register_tool_catalog( deployment .register_catalog(&input.session_id, tool_catalog.dynamic_catalog_tools()) .map_err(StageBuildError::DynamicMcp)?; + // 启动闸门提交晚到的静态 MCP 工具后,碰撞目录必须随新 base 重验:注册 + // 回调缺省不设置(子 agent 沿用自身 capability,不改父 session 目录)。 + tool_catalog.set_startup_catalog_registration(catalog_registration( + Arc::clone(deployment), + input.session_id.clone(), + )); } Ok(tool_catalog) } + +/// 启动提交的碰撞目录注册回调:捕获 deployment 与 session_id,把 registry 的 +/// 拒绝翻译为目录发布错误。 +/// +/// registry 在重名/别名冲突时以冲突工具名作为 `safe_summary` +/// (见 `mcp/dynamic/registry.rs::candidate_catalog_conflict`),因此可以无损 +/// 映射到 `StartupRegistrationRejected`;其余拒绝(会话/任务已关闭)不是工具 +/// 冲突,不作为「某个工具名与目录冲突」上报。 +fn catalog_registration( + deployment: Arc, + session_id: String, +) -> StartupCatalogRegistration { + Arc::new(move |tools: Vec| { + deployment + .register_catalog(&session_id, tools) + .map_err(|failure| match failure.code { + DynamicMcpErrorCode::ToolNameConflict => { + CatalogRefreshError::StartupRegistrationRejected { + tool: failure.safe_summary, + } + } + _ => CatalogRefreshError::InconsistentCapability, + }) + }) +} + +#[cfg(test)] +#[path = "tools_test.rs"] +mod tests; diff --git a/peri-agent/src/session/exec/stage_builder/tools_test.rs b/peri-agent/src/session/exec/stage_builder/tools_test.rs new file mode 100644 index 000000000..d47beddb1 --- /dev/null +++ b/peri-agent/src/session/exec/stage_builder/tools_test.rs @@ -0,0 +1,144 @@ +//! 启动提交的碰撞目录注册回调(`catalog_registration`)接线测试。 +//! +//! 只验证 middleware 侧 registry 拒绝到 `CatalogRefreshError` 的映射与透传: +//! registry 自己的拒绝语义由 `mcp::dynamic::registry` 的测试覆盖,目录提交的 +//! 原子性由 `session::tool_catalog` 的测试覆盖,本文件补的是两端之间的接线。 +use super::*; +use async_trait::async_trait; +use peri_acp_types::dynamic_mcp::{ + CanonicalDynamicMcpAction, DynamicMcpFailure, DynamicMcpOperationState, DynamicMcpResponse, + DynamicMcpShutdownReport, +}; +use peri_acp_types::ports::{SessionCloseRegistration, SessionMcpCapabilityPort}; +use std::sync::Mutex; + +/// 只控制 `register_catalog` 结果的 deployment stub;其余方法在测试里不可达。 +struct StubDeployment { + failure: DynamicMcpFailure, + seen: Mutex)>>, +} + +impl StubDeployment { + fn rejecting(code: DynamicMcpErrorCode, safe_summary: &str) -> Arc { + Arc::new(Self { + failure: DynamicMcpFailure::new(code, DynamicMcpOperationState::Failed, safe_summary), + seen: Mutex::new(Vec::new()), + }) + } +} + +#[async_trait] +impl DynamicMcpDeploymentPort for StubDeployment { + async fn execute( + &self, + _session_id: &str, + _action: CanonicalDynamicMcpAction, + ) -> Result { + unreachable!("本测试只驱动 register_catalog") + } + + fn register_catalog( + &self, + session_id: &str, + tools: Vec, + ) -> Result<(), DynamicMcpFailure> { + self.seen.lock().unwrap().push(( + session_id.to_string(), + tools.into_iter().map(|tool| tool.name).collect(), + )); + Err(self.failure.clone()) + } + + fn capability(&self, _session_id: &str) -> Arc { + struct Empty; + impl SessionMcpCapabilityPort for Empty { + fn snapshot(&self) -> Arc { + Arc::new(Default::default()) + } + } + Arc::new(Empty) + } + + fn close_registration(&self, _session_id: &str) -> Arc { + struct Noop; + #[async_trait] + impl SessionCloseRegistration for Noop { + async fn revoke_and_cleanup(&self) -> DynamicMcpShutdownReport { + DynamicMcpShutdownReport::Complete + } + } + Arc::new(Noop) + } + + fn begin_shutdown(&self) {} + + async fn close_session(&self, _session_id: &str) -> DynamicMcpShutdownReport { + DynamicMcpShutdownReport::Complete + } + + async fn shutdown(&self) -> DynamicMcpShutdownReport { + DynamicMcpShutdownReport::Complete + } +} + +fn catalog_tool(name: &str) -> DynamicMcpCatalogTool { + DynamicMcpCatalogTool { + name: name.to_string(), + aliases: Vec::new(), + static_mcp_server: None, + } +} + +/// registry 以 `ToolNameConflict` 拒绝时必须映射为 `StartupRegistrationRejected`, +/// 且冲突工具名无损保留(registry 侧固定用它作 `safe_summary`)。 +#[test] +fn startup_registration_maps_tool_conflict_to_rejection() { + let deployment = + StubDeployment::rejecting(DynamicMcpErrorCode::ToolNameConflict, "mcp__sys__echo"); + let register = catalog_registration(deployment, "session-a".to_string()); + + let error = register(vec![catalog_tool("mcp__sys__echo")]).unwrap_err(); + + assert_eq!( + error, + CatalogRefreshError::StartupRegistrationRejected { + tool: "mcp__sys__echo".to_string() + } + ); +} + +/// 非工具冲突的拒绝(会话/任务已关闭)不得被伪装成「某个工具名与目录冲突」。 +#[test] +fn startup_registration_maps_non_conflict_to_inconsistent_capability() { + let deployment = StubDeployment::rejecting( + DynamicMcpErrorCode::TaskOwnerClosed, + "Dynamic MCP task admission is closed", + ); + let register = catalog_registration(deployment, "session-a".to_string()); + + let error = register(vec![catalog_tool("mcp__sys__echo")]).unwrap_err(); + + assert_eq!(error, CatalogRefreshError::InconsistentCapability); +} + +/// 回调必须把本次 session 身份与整批候选工具原样交给 registry:session 传错会 +/// 让碰撞目录注册到别的会话,工具丢失会让晚到的静态工具不被重验。 +#[test] +fn startup_registration_forwards_session_and_candidate_tools() { + let deployment = StubDeployment::rejecting(DynamicMcpErrorCode::ToolNameConflict, "boom"); + let register = catalog_registration( + Arc::clone(&deployment) as Arc, + "session-z".to_string(), + ); + + let _ = register(vec![catalog_tool("Read"), catalog_tool("mcp__sys__echo")]); + + let seen = deployment.seen.lock().unwrap(); + assert_eq!(seen.len(), 1, "每次提交恰好注册一次"); + assert_eq!(seen[0].0, "session-z"); + assert_eq!( + seen[0].1, + vec!["Read".to_string(), "mcp__sys__echo".to_string()], + "候选工具必须整批透传且顺序不变" + ); +} diff --git a/peri-agent/src/session/tool_catalog.rs b/peri-agent/src/session/tool_catalog.rs index 58d0dd171..d5adff68c 100644 --- a/peri-agent/src/session/tool_catalog.rs +++ b/peri-agent/src/session/tool_catalog.rs @@ -1,7 +1,10 @@ use std::{collections::BTreeMap, sync::Arc}; use parking_lot::RwLock; -use peri_acp_types::{dynamic_mcp::SessionMcpCapabilitySnapshot, ports::SessionMcpCapabilityPort}; +use peri_acp_types::{ + dynamic_mcp::{DynamicMcpCatalogTool, SessionMcpCapabilitySnapshot}, + ports::SessionMcpCapabilityPort, +}; use crate::tools::{BaseTool, ToolDefinition}; @@ -11,8 +14,58 @@ pub enum CatalogRefreshError { InconsistentCapability, #[error("tool alias conflicts with another visible tool")] AliasConflict, + /// 启动候选工具不是静态 MCP 身份(名称非 `mcp__{server}__{tool}`)。 + #[error("startup tool `{tool}` is not a static MCP tool name")] + InvalidStartupSource { tool: String }, + /// 启动候选要覆盖 core/middleware 或其它 server 的静态条目。 + #[error("startup tool `{tool}` collides with a non-replaceable catalog entry")] + StartupRegistrationRejected { tool: String }, + /// 启动候选提交后必需工具仍不可直接使用(被策略过滤或动态遮蔽)。 + #[error("required startup tool `{tool}` of MCP server `{server}` is unavailable")] + RequiredToolUnavailable { server: String, tool: String }, } +/// 启动闸门提交的必需工具身份(跨层 seam:由 MCP middleware 按 server 归属上报)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartupRequiredTool { + pub server_name: String, + pub original_tool_name: String, + pub effective_tool_name: String, +} + +/// 启动闸门候选:整批静态 MCP 工具(required 已提升 direct)+ 必需工具身份。 +/// +/// 由 middleware 在 `before_react_start` 中经 `StartupState` 暂存,失败的整批 +/// 直接随 state 丢弃,不落 middleware 内部字段、不发布部分结果。 +#[derive(Clone)] +pub struct StartupToolUpdate { + pub tools: Vec>, + pub required: Vec, +} + +impl std::fmt::Debug for StartupToolUpdate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StartupToolUpdate") + .field( + "tools", + &self + .tools + .iter() + .map(|tool| tool.name()) + .collect::>(), + ) + .field("required", &self.required) + .finish() + } +} + +/// 动态碰撞目录的同步注册回调:静态 base 提交成功后按新目录重验动态注册。 +/// +/// 锁序为 catalog → registration → registry;回调**不得**重入 catalog。 +pub(crate) type StartupCatalogRegistration = + Arc) -> Result<(), CatalogRefreshError> + Send + Sync>; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ToolSource { CoreOrMiddleware, @@ -95,11 +148,18 @@ impl ToolFilterPolicy { } } -pub struct SessionToolCatalog { +/// 静态事实(base)与对外快照(published)在同一把锁内提交。 +struct CatalogState { base_tools: BTreeMap, - published: RwLock>, + published: Arc, +} + +pub struct SessionToolCatalog { + state: RwLock, capability: Option>, tool_filter: Arc bool + Send + Sync>, + /// 动态碰撞目录注册回调;缺省表示本次会话未接入碰撞目录(子 agent 等)。 + startup_registration: RwLock>, } impl SessionToolCatalog { @@ -135,66 +195,119 @@ impl SessionToolCatalog { let base_tools = base_tools .into_iter() .map(|(name, tool)| { - let source = tool - .mcp_server_name() - .map(str::to_owned) - .or_else(|| static_mcp_server(&name)) - .map(ToolSource::StaticMcp) - .unwrap_or(ToolSource::CoreOrMiddleware); + let source = base_entry_source(tool.as_ref(), &name); (name, CatalogToolEntry { tool, source }) }) .collect::>(); let initial = Arc::new(build_snapshot(0, &base_tools, None)?); Ok(Self { - base_tools, - published: RwLock::new(initial), + state: RwLock::new(CatalogState { + base_tools, + published: initial, + }), capability, tool_filter, + startup_registration: RwLock::new(None), }) } + /// 注入动态碰撞目录的同步注册回调(静态 base 提交成功后按新目录重验)。 + /// + /// 调用点归 `session/exec/stage_builder/tools.rs`(捕获 deployment 与 + /// session_id);未接线的目录(子 agent 沿用自身 capability)保持 None。 + pub(crate) fn set_startup_catalog_registration( + &self, + registration: StartupCatalogRegistration, + ) { + *self.startup_registration.write() = Some(registration); + } + pub fn dynamic_catalog_tools(&self) -> Vec { - self.base_tools - .iter() - .map( - |(name, entry)| peri_acp_types::dynamic_mcp::DynamicMcpCatalogTool { - name: name.clone(), - aliases: entry - .tool - .aliases() - .iter() - .map(|alias| (*alias).to_string()) - .collect(), - static_mcp_server: match &entry.source { - ToolSource::StaticMcp(server) => Some(server.clone()), - ToolSource::CoreOrMiddleware | ToolSource::DynamicMcp(_) => None, - }, - }, - ) - .collect() + dynamic_catalog_tools_of(&self.state.read().base_tools) } pub fn snapshot(&self) -> Arc { - Arc::clone(&self.published.read()) + Arc::clone(&self.state.read().published) } pub fn refresh(&self) -> Result, CatalogRefreshError> { - let capability = self - .capability - .as_ref() - .map(|port| port.snapshot()) - .unwrap_or_default(); - let current = self.snapshot(); - if current.generation == capability.generation { - return Ok(current); + let capability = self.capability_snapshot(); + let mut state = self.state.write(); + if state.published.generation == capability.generation { + return Ok(Arc::clone(&state.published)); } - let mut next_tools = build_tools(&self.base_tools, Some(&capability))?; - next_tools.retain(|name, _| (self.tool_filter)(name)); - let next = Arc::new(finalize(capability.generation, next_tools)?); - *self.published.write() = Arc::clone(&next); + let next = Arc::new(build_published( + capability.generation, + &state.base_tools, + Some(&capability), + self.tool_filter.as_ref(), + )?); + state.published = Arc::clone(&next); Ok(next) } + /// 把启动闸门准入的整批静态 MCP bridge 原子提交到 static base。 + /// + /// 只更新 base:Reason 边界仍完整走 ARC-TOOLS-001 的 + /// `refresh → working map swap → before_reason_catalog → before_model → pin`, + /// 本提交不替代 Reason boundary,也不混入 dynamic overlay(overlay 由 + /// capability 快照在每次发布时重新叠加)。 + /// + /// 同步、fallible、先验证后提交:来源、与既有 base 条目的身份冲突、别名 + /// 冲突、必需工具在发布快照中可直达(`is_direct && visible_to_model` 且未被 + /// `tool_filter`/动态遮蔽吞掉)全部通过后才替换 base 与 published。 + pub(crate) fn replace_static_mcp_tools( + &self, + update: StartupToolUpdate, + ) -> Result, CatalogRefreshError> { + let capability = self.capability_snapshot(); + let mut state = self.state.write(); + let mut next_base = state.base_tools.clone(); + for tool in update.tools { + let name = tool.name().to_string(); + let entry = startup_entry(&name, tool)?; + let collides = next_base + .get(&name) + .is_some_and(|existing| existing.source != entry.source); + if collides { + return Err(CatalogRefreshError::StartupRegistrationRejected { tool: name }); + } + next_base.insert(name, entry); + } + let next = Arc::new(build_published( + capability.generation, + &next_base, + Some(&capability), + self.tool_filter.as_ref(), + )?); + for required in &update.required { + let reachable = next + .direct_definitions + .iter() + .any(|definition| definition.name == required.effective_tool_name); + if !reachable { + return Err(CatalogRefreshError::RequiredToolUnavailable { + server: required.server_name.clone(), + tool: required.effective_tool_name.clone(), + }); + } + } + if let Some(registration) = self.startup_registration.read().clone() { + // 锁序 catalog → registration → registry;回调不得重入 catalog。 + registration(dynamic_catalog_tools_of(&next_base))?; + } + state.base_tools = next_base; + state.published = Arc::clone(&next); + Ok(next) + } + + fn capability_snapshot(&self) -> Arc { + self.capability + .as_ref() + .map(|port| port.snapshot()) + .unwrap_or_default() + } + /// Pin the exact request-local working tool objects after middleware has /// rebound meta tools. The returned snapshot belongs only to this Reason; /// request-local bindings must never replace the session publisher. @@ -232,6 +345,70 @@ fn build_snapshot( finalize(generation, build_tools(base, capability)?) } +/// 一次发布的完整构造:静态 base → dynamic overlay → session tool_filter。 +/// +/// 单一入口保证 initial / refresh / startup 提交三条路径的可见性语义一致。 +fn build_published( + generation: u64, + base: &BTreeMap, + capability: Option<&SessionMcpCapabilitySnapshot>, + tool_filter: &(dyn Fn(&str) -> bool + Send + Sync), +) -> Result { + let mut tools = build_tools(base, capability)?; + tools.retain(|name, _| tool_filter(name)); + finalize(generation, tools) +} + +/// 既有 base 条目的来源归属:工具自声明优先,其次按 `mcp__{server}__{tool}` 形态推导。 +fn base_entry_source(tool: &dyn BaseTool, name: &str) -> ToolSource { + tool.mcp_server_name() + .map(str::to_owned) + .or_else(|| static_mcp_server(name)) + .map(ToolSource::StaticMcp) + .unwrap_or(ToolSource::CoreOrMiddleware) +} + +/// 启动候选条目的来源归属:名称必须已是静态 MCP 有效名,凭此拒绝借启动闸门 +/// 注入 core/middleware 身份或覆盖其它 server 的条目。 +fn startup_entry( + name: &str, + tool: Arc, +) -> Result { + let Some(name_server) = static_mcp_server(name) else { + return Err(CatalogRefreshError::InvalidStartupSource { + tool: name.to_string(), + }); + }; + let server = tool + .mcp_server_name() + .map(str::to_owned) + .unwrap_or(name_server); + Ok(CatalogToolEntry { + tool, + source: ToolSource::StaticMcp(server), + }) +} + +fn dynamic_catalog_tools_of( + base: &BTreeMap, +) -> Vec { + base.iter() + .map(|(name, entry)| DynamicMcpCatalogTool { + name: name.clone(), + aliases: entry + .tool + .aliases() + .iter() + .map(|alias| (*alias).to_string()) + .collect(), + static_mcp_server: match &entry.source { + ToolSource::StaticMcp(server) => Some(server.clone()), + ToolSource::CoreOrMiddleware | ToolSource::DynamicMcp(_) => None, + }, + }) + .collect() +} + fn build_tools( base: &BTreeMap, capability: Option<&SessionMcpCapabilitySnapshot>, diff --git a/peri-agent/src/session/tool_catalog_test.rs b/peri-agent/src/session/tool_catalog_test.rs index ab21230d6..f3859dfae 100644 --- a/peri-agent/src/session/tool_catalog_test.rs +++ b/peri-agent/src/session/tool_catalog_test.rs @@ -231,3 +231,489 @@ fn filtered_catalog_reapplies_policy_across_load_and_unload() { assert_eq!(unloaded.generation, 2); assert!(!unloaded.tools.contains_key("mcp__example__lookup")); } + +// ─── 启动闸门静态提交(B-05)───────────────────────────────────────────────── + +/// 静态 MCP bridge 测试桩(名称与 `McpToolBridge` 同形)。 +struct BridgeTool { + name: String, + server: Option, + direct: bool, + aliases: &'static [&'static str], +} + +impl BridgeTool { + /// 名称与声明 server 可独立指定,用于构造"身份不一致"的越权候选。 + fn bridge(name: &str, server: &str) -> Self { + Self { + name: name.to_string(), + server: Some(server.to_string()), + direct: false, + aliases: &[], + } + } + + fn direct(mut self) -> Self { + self.direct = true; + self + } + + fn with_alias(mut self, alias: &'static str) -> Self { + self.aliases = Box::leak(vec![alias].into_boxed_slice()); + self + } +} + +#[async_trait] +impl BaseTool for BridgeTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.name + } + + fn parameters(&self) -> serde_json::Value { + json!({"type": "object"}) + } + + fn aliases(&self) -> &[&str] { + self.aliases + } + + fn mcp_server_name(&self) -> Option<&str> { + self.server.as_deref() + } + + fn is_direct(&self) -> bool { + self.direct + } + + async fn invoke( + &self, + _input: serde_json::Value, + _ctx: ToolContext<'_>, + ) -> Result> { + Ok(String::new()) + } +} + +fn startup_update( + tools: Vec>, + required: &[(&str, &str, &str)], +) -> StartupToolUpdate { + StartupToolUpdate { + tools, + required: required + .iter() + .map(|(server, original, effective)| StartupRequiredTool { + server_name: (*server).to_string(), + original_tool_name: (*original).to_string(), + effective_tool_name: (*effective).to_string(), + }) + .collect(), + } +} + +fn server_projection(server: &str) -> DynamicMcpServerProjection { + DynamicMcpServerProjection { + instance_key: DynamicMcpInstanceKey { + logical: DynamicMcpLogicalKey { + session_id: "session-a".to_string(), + server_name: server.to_string(), + }, + incarnation_id: Default::default(), + }, + name: server.to_string(), + config: DynamicMcpConfig { + command: Some(format!("{server}-mcp")), + ..Default::default() + } + .canonicalize() + .unwrap(), + tool_count: 1, + resource_count: 0, + } +} + +fn dynamic_capability( + generation: u64, + servers: &[&str], + tools: &[(&str, &str, Arc)], +) -> SessionMcpCapabilitySnapshot { + let mut server_map: BTreeMap = servers + .iter() + .map(|server| ((*server).to_string(), server_projection(server))) + .collect(); + let mut tool_map = BTreeMap::new(); + for (name, server, tool) in tools { + let projection = server_map + .entry((*server).to_string()) + .or_insert_with(|| server_projection(server)); + tool_map.insert( + (*name).to_string(), + peri_acp_types::dynamic_mcp::DynamicMcpToolCapability { + instance: projection.instance_key.clone(), + tool: Arc::clone(tool), + }, + ); + } + SessionMcpCapabilitySnapshot { + generation, + servers: server_map, + tools: tool_map, + } +} + +/// static base 的可观察投影(提交失败时用于断言 base 未被部分修改)。 +fn base_names(catalog: &SessionToolCatalog) -> Vec { + catalog + .dynamic_catalog_tools() + .into_iter() + .map(|tool| tool.name) + .collect() +} + +fn startup_catalog( + base: BTreeMap>, + capability: Option>, +) -> SessionToolCatalog { + SessionToolCatalog::new(base, capability) +} + +fn core_and_static_base() -> BTreeMap> { + BTreeMap::from([ + ( + "Read".to_string(), + Arc::new(NamedTool::new("Read", "core")) as Arc, + ), + ( + "mcp__system__lookup".to_string(), + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system")) as Arc, + ), + ( + "mcp__other__keep".to_string(), + Arc::new(BridgeTool::bridge("mcp__other__keep", "other")) as Arc, + ), + ]) +} + +#[test] +fn startup_commit_publishes_required_direct_tool_into_static_base() { + let catalog = startup_catalog( + core_and_static_base(), + Some(Arc::new(FixedCapability(Arc::new( + SessionMcpCapabilitySnapshot { + generation: 1, + ..Default::default() + }, + )))), + ); + assert!( + catalog + .snapshot() + .direct_definitions + .iter() + .all(|definition| definition.name != "mcp__system__lookup"), + "提交前 required 未提升 direct" + ); + + let prepared: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system").direct()); + let published = catalog + .replace_static_mcp_tools(startup_update( + vec![Arc::clone(&prepared)], + &[("system", "lookup", "mcp__system__lookup")], + )) + .unwrap(); + + assert!( + published + .direct_definitions + .iter() + .any(|definition| definition.name == "mcp__system__lookup"), + "required 工具必须直接可达模型" + ); + assert!( + Arc::ptr_eq(&published.tools["mcp__system__lookup"].tool, &prepared), + "整批替换必须用新 bridge 对象,而不是保留旧 deferred 对象" + ); + assert_eq!( + published.tools["mcp__system__lookup"].source, + ToolSource::StaticMcp("system".to_string()) + ); + assert!(published.tools.contains_key("Read"), "core 工具不受影响"); + assert_eq!( + published.tools["mcp__other__keep"].source, + ToolSource::StaticMcp("other".to_string()), + "未参与本次提交的 server 保持原条目" + ); + assert!( + catalog + .refresh() + .unwrap() + .direct_definitions + .iter() + .any(|definition| definition.name == "mcp__system__lookup"), + "Reason 边界 refresh 后 required 仍可达" + ); +} + +#[test] +fn startup_commit_survives_dynamic_generation_bump() { + let capability = Arc::new(MutableCapability(parking_lot::RwLock::new(Arc::new( + SessionMcpCapabilitySnapshot::default(), + )))); + let catalog = startup_catalog(core_and_static_base(), Some(capability.clone())); + let prepared: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system").direct()); + catalog + .replace_static_mcp_tools(startup_update( + vec![Arc::clone(&prepared)], + &[("system", "lookup", "mcp__system__lookup")], + )) + .unwrap(); + + capability.publish(SessionMcpCapabilitySnapshot { + generation: 2, + ..Default::default() + }); + let refreshed = catalog.refresh().unwrap(); + + assert_eq!(refreshed.generation, 2, "refresh 必须按新 generation 重建"); + assert!( + Arc::ptr_eq(&refreshed.tools["mcp__system__lookup"].tool, &prepared), + "dynamic overlay 重建必须以提交后的 static base 为事实源" + ); + assert!(refreshed.tools.contains_key("Read")); +} + +#[test] +fn dynamic_overlay_still_shadows_static_entries_after_startup_commit() { + let capability = Arc::new(MutableCapability(parking_lot::RwLock::new(Arc::new( + SessionMcpCapabilitySnapshot::default(), + )))); + let catalog = startup_catalog(core_and_static_base(), Some(capability.clone())); + let prepared: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system").direct()); + catalog + .replace_static_mcp_tools(startup_update( + vec![Arc::clone(&prepared)], + &[("system", "lookup", "mcp__system__lookup")], + )) + .unwrap(); + + let dynamic_tool: Arc = + Arc::new(NamedTool::new("mcp__system__direct", "dynamic")); + capability.publish(dynamic_capability( + 1, + &["system"], + &[("mcp__system__direct", "system", dynamic_tool)], + )); + let refreshed = catalog.refresh().unwrap(); + + assert!( + !refreshed.tools.contains_key("mcp__system__lookup"), + "启动提交不得绕过动态实例对同名 logical server 的遮蔽" + ); + assert!(refreshed.tools.contains_key("mcp__system__direct")); + assert!(refreshed.tools.contains_key("Read")); +} + +#[test] +fn startup_commit_rejects_required_tool_filtered_by_policy() { + let catalog = SessionToolCatalog::with_filter( + core_and_static_base(), + None, + Arc::new(|name| name != "mcp__system__lookup"), + ); + let before = catalog.snapshot(); + let prepared: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system").direct()); + let error = catalog + .replace_static_mcp_tools(startup_update( + vec![Arc::clone(&prepared)], + &[("system", "lookup", "mcp__system__lookup")], + )) + .unwrap_err(); + + assert_eq!( + error, + CatalogRefreshError::RequiredToolUnavailable { + server: "system".to_string(), + tool: "mcp__system__lookup".to_string(), + } + ); + assert!( + Arc::ptr_eq(&before, &catalog.snapshot()), + "整批失败不得有部分发布" + ); + assert_eq!( + base_names(&catalog), + vec![ + "Read".to_string(), + "mcp__other__keep".to_string(), + "mcp__system__lookup".to_string() + ] + ); +} + +#[test] +fn startup_commit_rejects_required_tool_shadowed_by_dynamic_instance() { + let catalog = startup_catalog( + core_and_static_base(), + Some(Arc::new(FixedCapability(Arc::new(dynamic_capability( + 1, + &["system"], + &[], + ))))), + ); + let before = catalog.snapshot(); + let prepared: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system").direct()); + let error = catalog + .replace_static_mcp_tools(startup_update( + vec![Arc::clone(&prepared)], + &[("system", "lookup", "mcp__system__lookup")], + )) + .unwrap_err(); + + assert!(matches!( + error, + CatalogRefreshError::RequiredToolUnavailable { ref tool, .. } if tool == "mcp__system__lookup" + )); + assert!( + Arc::ptr_eq(&before, &catalog.snapshot()), + "被动态遮蔽的必需项必须拒绝整批,不发布半成品" + ); +} + +#[test] +fn startup_commit_rejects_tool_without_static_mcp_identity() { + let catalog = startup_catalog(core_and_static_base(), None); + + let core_named: Arc = Arc::new(NamedTool::new("Read", "rogue")); + let error = catalog + .replace_static_mcp_tools(startup_update(vec![Arc::clone(&core_named)], &[])) + .unwrap_err(); + assert_eq!( + error, + CatalogRefreshError::InvalidStartupSource { + tool: "Read".to_string() + } + ); + + let declared_but_core_named: Arc = + Arc::new(BridgeTool::bridge("Read", "system").direct()); + let error = catalog + .replace_static_mcp_tools(startup_update(vec![declared_but_core_named], &[])) + .unwrap_err(); + assert_eq!( + error, + CatalogRefreshError::InvalidStartupSource { + tool: "Read".to_string() + } + ); + assert!( + !catalog.snapshot().tools["Read"].tool.is_direct(), + "启动提交不得把 core 工具身份换成 MCP bridge" + ); +} + +#[test] +fn startup_commit_rejects_cross_server_takeover() { + let catalog = startup_catalog(core_and_static_base(), None); + let foreign: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "other").direct()); + let error = catalog + .replace_static_mcp_tools(startup_update(vec![foreign], &[])) + .unwrap_err(); + + assert_eq!( + error, + CatalogRefreshError::StartupRegistrationRejected { + tool: "mcp__system__lookup".to_string() + } + ); + assert_eq!( + catalog.snapshot().tools["mcp__system__lookup"].source, + ToolSource::StaticMcp("system".to_string()), + "跨 server 覆盖必须被拒绝且不修改既有条目" + ); +} + +#[test] +fn startup_commit_rejects_alias_conflict_without_partial_publish() { + let base = BTreeMap::from([ + ( + "mcp__other__keep".to_string(), + Arc::new(BridgeTool::bridge("mcp__other__keep", "other").with_alias("shared")) + as Arc, + ), + ( + "Read".to_string(), + Arc::new(NamedTool::new("Read", "core")) as Arc, + ), + ]); + let catalog = startup_catalog(base, None); + let conflicting: Arc = + Arc::new(BridgeTool::bridge("mcp__system__tool", "system").with_alias("shared")); + let error = catalog + .replace_static_mcp_tools(startup_update(vec![conflicting], &[])) + .unwrap_err(); + + assert_eq!(error, CatalogRefreshError::AliasConflict); + assert!(!catalog.snapshot().tools.contains_key("mcp__system__tool")); + assert_eq!( + base_names(&catalog), + vec!["Read".to_string(), "mcp__other__keep".to_string()] + ); +} + +#[test] +fn startup_commit_registers_collision_directory_before_publishing() { + let catalog = startup_catalog(core_and_static_base(), None); + let seen: Arc>>> = + Arc::new(parking_lot::Mutex::new(None)); + let captured = Arc::clone(&seen); + catalog.set_startup_catalog_registration(Arc::new(move |tools| { + *captured.lock() = Some(tools.into_iter().map(|tool| tool.name).collect()); + Ok(()) + })); + + let prepared: Arc = + Arc::new(BridgeTool::bridge("mcp__system__lookup", "system").direct()); + catalog + .replace_static_mcp_tools(startup_update( + vec![Arc::clone(&prepared)], + &[("system", "lookup", "mcp__system__lookup")], + )) + .unwrap(); + + let registered = seen.lock().clone().expect("注册回调必须被调用"); + assert!(registered.contains(&"mcp__system__lookup".to_string())); + assert!(registered.contains(&"Read".to_string())); + + catalog.set_startup_catalog_registration(Arc::new(|_| { + Err(CatalogRefreshError::StartupRegistrationRejected { + tool: "mcp__system__lookup".to_string(), + }) + })); + let rejected: Arc = Arc::new(BridgeTool::bridge("mcp__system__lookup", "system")); + let error = catalog + .replace_static_mcp_tools(startup_update(vec![rejected], &[])) + .unwrap_err(); + + assert!(matches!( + error, + CatalogRefreshError::StartupRegistrationRejected { .. } + )); + assert!( + Arc::ptr_eq( + &catalog.snapshot().tools["mcp__system__lookup"].tool, + &prepared + ), + "碰撞目录拒绝后本地状态不得改变" + ); +} diff --git a/peri-middlewares/CLAUDE.md b/peri-middlewares/CLAUDE.md index c02ab9069..7aab67447 100644 --- a/peri-middlewares/CLAUDE.md +++ b/peri-middlewares/CLAUDE.md @@ -35,6 +35,9 @@ - **链顺序**:只能在 Agent 层 session 工厂的链序蓝本(`production_blueprint`)与 `src/assembly.rs` 槽位构造中判断与修改生产顺序;不得按名称或局部便利重排。 - **MCP**:保留三层合并、内容去重和插件命名空间;配置来源或工具注册变更必须同时检查 pool、资源与 bridge 路径。init/OAuth/reconnect/subscription 任务由 deployment-held non-Clone `McpTaskOwner` 持有,并实现契约层 `McpTaskOwnerPort` 供 ACP boxed 注入;pool 只持 weak spawner。正常关闭顺序固定为 pool begin-close → owner abort/join → pool service close。Pool service close 由 pool-held 单一 transaction 持有,waiter 取消/并发/重试必须观察同一 `McpPoolShutdownReport`;cleanup timeout 保持 `Closing`,不得发布 `Closed`(ARC-HOST-SHUTDOWN-001)。 +- **MCP over ACP**:client 在会话 setup 声明的 `type: "acp"` server 由 `src/mcp/acp/`(`AcpMcpService`)承载。`attach` 只登记并后台建连(会话建立不等连接),失败留在池状态面(`ConfigSource::Acp` 条目)而不回抛;连接按声明它的会话归属,工具桥接、发现面与状态面必须按 `is_visible_to_session` 过滤,不得跨会话泄漏;`mcp/message` 内层错误码原样透传(lifecycle 依赖方法级错误码);会话结束在池关闭前 `close_session`(幂等,`mcp/disconnect` 有上界)(ARC-MCP-ACP-001)。 +- **System MCP 启动准入**:`system_mcp = true` 的 server 必须在首个 Reason 前完成 transport / initialize / 能力协商 / 真实 `tools/list`(空数组是成功结果,`Err` 不是发现证据),由启动闸门 hook `before_react_start` 阻断未就绪的 loop;失败或 timeout 返回类型化错误、不发布 ready、不降级为 warn,取消仍按中断分类。`system_mcp_tools` 按所属 server 的原始工具名精确匹配后 all-or-nothing 提升 direct(`[]` 只要求 ready 不注入工具),只跳过 deferred 搜索,不绕过 Permission/HITL/事件/cancel。 +- **插件 MCP 配置严格路径**:`load_enabled_plugins_for_mcp` 对非法 MCP 配置直接失败、不降级为空配置;宽容展示 API(`load_enabled_plugins_aggregated` 等)行为保持不变。 - **Plugin manifest**:`commands` 条目兼容字符串路径与对象;字符串是相对插件根目录的路径。agents 未声明时仍保留约定目录回退。不要把路径条目当作名称。 - **Skills**:扫描必须保持根优先级、递归边界、符号链接防环、叶子语义和同名覆盖规则;插件 skill root 通过既有扩展点传入。 - **SubAgent**:同一会话的子 Agent 复用冻结的项目指引、skills 与 system prompt;同步子任务继承父取消,独立后台任务使用自身取消策略。`Agent(resume_thread_id, prompt)` 优先向当前会话的 live 后台执行投递 Info(非空 prompt),返回 `action: send / status: queued`;无 live 接收者且磁盘仍 active 时拒绝,非 active 才恢复并返回 `action: resume`。Info 不中断或唤醒模型,queued 不代表已读。事件必须按 `source_agent_id` 归属,新增事件同时检查父/子边界、完成和取消路径。 @@ -49,6 +52,7 @@ cargo build -p peri-middlewares cargo test -p peri-middlewares --lib cargo test -p peri-middlewares --lib -- mcp::task_scope +cargo test -p peri-middlewares --lib -- mcp::acp cargo test -p peri-acp --lib ``` @@ -56,5 +60,6 @@ cargo test -p peri-acp --lib - 链、工具注册与条件中间件:`../peri-agent/src/session/factory.rs` 与 `src/assembly.rs`;同时遵守 `../docs/standards/architecture-contracts.md` 的 `ARC-MIDDLEWARE-001`、`ARC-TOOLS-001`、`ARC-FROZEN-001`。 - Plugin/MCP 或 Skills 改动:阅读目标模块的实现与测试后运行对应 `cargo test -p peri-middlewares --lib <过滤词>`。 +- System MCP 准入 / 工具注入改动:`cargo test -p peri-middlewares --lib -- mcp::system_tools`、`-- mcp::client::readiness`、`-- mcp::middleware`;契约测试 `cargo test -p peri-middlewares --test mcp_host_policy_contract -- --test-threads=1` 与 `--test mcp_isolation_contract -- --test-threads=1`。 - SubAgent 或 HITL 改动:覆盖冻结数据、取消、事件归属及 effective tool name 的相关测试。 - 所有修改完成后运行 `git diff --check`;不得在日志、错误或测试 fixture 中写入密钥、token、密码或连接串。 diff --git a/peri-middlewares/src/assembly/mcp.rs b/peri-middlewares/src/assembly/mcp.rs index 4c47f3f1f..093b23e9c 100644 --- a/peri-middlewares/src/assembly/mcp.rs +++ b/peri-middlewares/src/assembly/mcp.rs @@ -66,6 +66,7 @@ pub(super) fn add_mcp( }; let mw = McpMiddleware::new(Arc::clone(&effective_pool)) .with_tool_pool(Arc::clone(pool)) + .with_session_id(session_id.clone()) .with_skill_discovery(ctx.mcp_skill_registry.clone(), ctx.cancel.clone()) .with_command_registry(command_registry.clone()); // 决策 B:装配后立即触发幂等发现(覆盖「装配时连接已 diff --git a/peri-middlewares/src/assembly/preparation.rs b/peri-middlewares/src/assembly/preparation.rs index a5f8fe254..95e20707c 100644 --- a/peri-middlewares/src/assembly/preparation.rs +++ b/peri-middlewares/src/assembly/preparation.rs @@ -2,7 +2,8 @@ use super::AssemblyContext; use crate::{ cron::{CronScheduler, CronSchedulerPortHandle}, - mcp::{build_tool_bridges, McpClientPool, McpResourceTool}, + mcp::tool_bridge::build_tool_bridges_visible_to, + mcp::{McpClientPool, McpResourceTool}, middleware::{FilesystemMiddleware, TerminalMiddleware, WebMiddleware}, permission::{AutoClassifier, LlmAutoClassifier}, tool_search::ToolSearchIndex, @@ -133,19 +134,24 @@ pub(super) fn build_parent_tools( } if !disabled.contains("McpMiddleware") { if let Some(ref pool) = mcp_pool_concrete { - let mcp_tools = build_tool_bridges(pool); + // 子 agent 继承父会话的工具面:按会话过滤,ACP 声明的 server 不会 + // 经父工具集泄漏到其他会话的子 agent。 + let mcp_tools = build_tool_bridges_visible_to(pool, Some(&ctx.session_id)); for tool in mcp_tools { parent_tools.push(tool); } if pool.has_resources() { - parent_tools.push(Box::new(McpResourceTool::new( - Arc::clone(pool), - // 未装配 session 注册表(print 模式)→ 空注册表 - //(无条目 = 不校验) - mcp_skill_registry - .clone() - .unwrap_or_else(|| Arc::new(McpSkillRegistry::new())), - ))); + parent_tools.push(Box::new( + McpResourceTool::new( + Arc::clone(pool), + // 未装配 session 注册表(print 模式)→ 空注册表 + //(无条目 = 不校验) + mcp_skill_registry + .clone() + .unwrap_or_else(|| Arc::new(McpSkillRegistry::new())), + ) + .with_session_id(ctx.session_id.clone()), + )); } } } diff --git a/peri-middlewares/src/mcp/acp/mod.rs b/peri-middlewares/src/mcp/acp/mod.rs new file mode 100644 index 000000000..299a08746 --- /dev/null +++ b/peri-middlewares/src/mcp/acp/mod.rs @@ -0,0 +1,19 @@ +//! MCP over ACP(UNSTABLE)接入。 +//! +//! client 在会话 setup 中以 `McpServer::Acp { name, serverId }` 声明由 ACP +//! 通道承载的 MCP server;本模块负责 agent 侧的全部运行时: +//! +//! - `session`:会话级连接管理(`mcp/connect` → rmcp 握手 → 工具发现 → +//! 提交 MCP 池 → 会话结束时 `mcp/disconnect`),实现 +//! [`peri_acp_types::ports::AcpMcpServerPort`]; +//! - `transport`:把 rmcp 的 JSON-RPC 消息经 ACP `mcp/message` 双向转发的 +//! [`rmcp::transport::Transport`] 实现。 +//! +//! 连接就绪是异步的:会话 setup 只登记声明并后台建连,工具经既有的 deferred +//! 发现面(`tool_search` 索引 / `SearchExtraTools`)进入模型可见集,因此建连 +//! 失败不阻塞会话建立,失败事实留在池状态面。 + +mod session; +mod transport; + +pub use session::AcpMcpService; diff --git a/peri-middlewares/src/mcp/acp/session.rs b/peri-middlewares/src/mcp/acp/session.rs new file mode 100644 index 000000000..42a60b96b --- /dev/null +++ b/peri-middlewares/src/mcp/acp/session.rs @@ -0,0 +1,546 @@ +//! 会话级 MCP over ACP 连接管理。 +//! +//! 一个 [`AcpMcpService`] 服务一条 ACP 连接上的全部会话:`attach` 登记 client +//! 在会话 setup 中声明的 acp 型 server 并后台建连;client 经 `mcp/message` +//! 反向下发的请求 / 通知按 `connectionId` 路由进对应 rmcp 连接; +//! `close_session` 断开该会话的全部连接。 +//! +//! 建连顺序(与配置型 MCP 的既有链路同构): +//! `mcp/connect` → 注册入站路由 → rmcp 握手(`serve_client_auto`)→ +//! 工具发现(`tools/list`)→ `retain_service` → `commit_acp_connection`。 +//! +//! 两条不变量: +//! 1. **不阻塞会话建立**:`attach` 只登记与派发任务,握手与发现在后台任务里 +//! 有界完成,失败留在池状态面(`ClientStatus::Failed`)而不回抛给调用方; +//! 2. **不跨会话泄漏**:连接在池中登记 `acp_owners` 归属,工具桥接、发现面与 +//! 状态面按归属会话过滤(`McpClientPool::is_visible_to_session`)。 + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use parking_lot::Mutex; +use peri_acp_types::acp_mcp::{AcpMcpError, AcpMcpInbound, AcpMcpServerSpec}; +use peri_acp_types::plugin::ConfigSource; +use peri_acp_types::ports::{AcpMcpGatewayPort, AcpMcpServerPort}; +use serde_json::{Map, Value}; + +use super::transport::{create_bridge, AcpBridgeHandle, MCP_CONNECT_METHOD, MCP_DISCONNECT_METHOD}; +use crate::mcp::client::{ + peer_declares_skills, redact_mcp_error, serve_client_auto, ClientStatus, McpClientHandle, + McpClientPool, OAuthStatus, HTTP_CONNECT_TIMEOUT, SHUTDOWN_TIMEOUT, +}; +use crate::mcp::task_scope::McpTaskKey; + +/// 会话级 MCP over ACP 服务(`AcpMcpServerPort` 实现)。 +/// +/// deployment 级单例:构造点持具体类型,host 侧只持 +/// `Arc`。内部状态(会话 → 声明 → 连接)与 MCP 池 +/// 分离,池只承担「已建立连接」的登记与工具面投影。 +pub struct AcpMcpService { + pool: Arc, + state: Arc>, +} + +/// 服务内部状态:会话声明与活跃连接索引。 +/// +/// `sessions` 用 `BTreeMap` 固定遍历顺序(建连派发顺序可复现);`connections` +/// 是 `connectionId` → 桥接句柄的反向索引,入站 `mcp/message` 依赖它路由。 +#[derive(Default)] +struct State { + sessions: BTreeMap, + connections: HashMap, +} + +struct SessionRecord { + gateway: Arc, + /// `serverId` → 建连状态(同一会话内 `serverId` 唯一)。 + servers: BTreeMap, +} + +/// 单个声明的建连状态。 +/// +/// 失败原因不在本状态机里留存:事实源是 MCP 池(`record_acp_failure`), +/// 本枚举只回答「是否需要再建一次连接」。 +enum ServerPhase { + Connecting, + Ready, + Failed, +} + +struct ConnectionRecord { + session_id: String, + server_id: String, + handle: Arc, +} + +impl AcpMcpService { + pub fn new(pool: Arc) -> Self { + Self { + pool, + state: Arc::new(Mutex::new(State::default())), + } + } + + /// 登记一条声明;返回 `None` 表示无需建连(已在建 / 已就绪的幂等重复)。 + fn dispatch( + &self, + gateway: Arc, + spec: AcpMcpServerSpec, + ) -> Option { + let mut state = self.state.lock(); + let record = state + .sessions + .entry(spec.session_id.clone()) + .or_insert_with(|| SessionRecord { + gateway: Arc::clone(&gateway), + servers: BTreeMap::new(), + }); + match record.servers.get(&spec.server_id) { + Some(ServerPhase::Connecting | ServerPhase::Ready) => None, + // 失败过的声明允许重试(client 可在后续 session setup 中重发)。 + Some(ServerPhase::Failed) | None => { + record + .servers + .insert(spec.server_id.clone(), ServerPhase::Connecting); + Some(spec) + } + } + } + + /// 建连任务的派发壳:任务本身失败不回抛,只记状态。 + fn spawn_connect( + pool: Arc, + state: Arc>, + gateway: Arc, + spec: AcpMcpServerSpec, + ) { + let key = McpTaskKey::Acp { + session_id: spec.session_id.clone(), + server_id: spec.server_id.clone(), + }; + let session_id = spec.session_id.clone(); + let server_id = spec.server_id.clone(); + let name = preferred_name(&spec); + let record_pool = Arc::clone(&pool); + let task_pool = Arc::clone(&pool); + let message = match pool.spawn_background( + key, + connect_server(task_pool, Arc::clone(&state), gateway, spec), + ) { + Ok(()) => return, + // 池已关闭 / 同键任务仍在跑:连接不可能建立,如实记为失败。 + Err(error) => error.to_string(), + }; + record_failure( + &record_pool, + &state, + &session_id, + &server_id, + &name, + &message, + ); + } + + /// `connectionId` → 桥接句柄。 + fn route(&self, connection_id: &str) -> Result, AcpMcpError> { + self.state + .lock() + .connections + .get(connection_id) + .map(|connection| Arc::clone(&connection.handle)) + .ok_or_else(|| { + AcpMcpError::not_found(format!("未知或已关闭的 MCP-over-ACP 连接: {connection_id}")) + }) + } +} + +#[async_trait::async_trait] +impl AcpMcpServerPort for AcpMcpService { + fn attach(&self, gateway: Arc, servers: Vec) { + for spec in servers { + let Some(spec) = self.dispatch(Arc::clone(&gateway), spec) else { + continue; + }; + Self::spawn_connect( + Arc::clone(&self.pool), + Arc::clone(&self.state), + Arc::clone(&gateway), + spec, + ); + } + } + + async fn request(&self, inbound: AcpMcpInbound) -> Result { + let handle = self.route(&inbound.connection_id)?; + handle + .request(&inbound.method, inbound.params.map(Value::Object)) + .await + .map_err(|error| { + // 内层 MCP 错误原样透传:请求方就是这台 server 的宿主机。 + AcpMcpError { + code: i64::from(error.code.0), + message: error.message.to_string(), + } + }) + } + + async fn notify(&self, inbound: AcpMcpInbound) -> Result<(), AcpMcpError> { + let handle = self.route(&inbound.connection_id)?; + handle + .notify(&inbound.method, inbound.params.map(Value::Object)) + .map_err(|error| AcpMcpError::unavailable(error.message.to_string())) + } + + fn owns_connection(&self, connection_id: &str) -> bool { + self.state.lock().connections.contains_key(connection_id) + } + + async fn close_session(&self, session_id: &str) { + // 顺序固定:先摘会话与连接索引(建连任务的存活检查据此判定),再终止 + // 在建任务,最后移除池条目——被 abort 的任务不会在此之后提交。 + let (gateway, server_ids, connections) = { + let mut state = self.state.lock(); + let Some(record) = state.sessions.remove(session_id) else { + return; + }; + let connections: Vec<(String, String, Arc)> = state + .connections + .iter() + .filter(|(_, connection)| connection.session_id == session_id) + .map(|(connection_id, connection)| { + ( + connection_id.clone(), + connection.server_id.clone(), + Arc::clone(&connection.handle), + ) + }) + .collect(); + for (connection_id, _, _) in &connections { + state.connections.remove(connection_id); + } + ( + record.gateway, + record.servers.into_keys().collect::>(), + connections, + ) + }; + + for server_id in server_ids { + self.pool + .stop_background(&McpTaskKey::Acp { + session_id: session_id.to_string(), + server_id, + }) + .await; + } + + for (connection_id, server_id, handle) in connections { + handle.close(); + disconnect(&gateway, &connection_id).await; + tracing::debug!( + session_id = %session_id, + server_id = %server_id, + "MCP over ACP 连接已断开" + ); + } + + let removed = self.pool.remove_acp_servers_for_session(session_id).await; + if !removed.is_empty() { + tracing::info!( + session_id = %session_id, + servers = removed.len(), + "MCP over ACP 会话连接已清理" + ); + } + } +} + +/// 建连任务主体:`mcp/connect` → 桥接 → rmcp 握手 → 工具发现 → 池提交。 +/// +/// 任何失败路径都必须成对收尾(注销入站路由 + `mcp/disconnect`),否则 client +/// 侧会留下一条无人使用的连接。 +async fn connect_server( + pool: Arc, + state: Arc>, + gateway: Arc, + spec: AcpMcpServerSpec, +) { + let connection_id = match connect(&gateway, &spec.server_id).await { + Ok(connection_id) => connection_id, + Err(error) => { + tracing::warn!( + session_id = %spec.session_id, + server = %spec.name, + %error, + "MCP over ACP 建连失败" + ); + record_failure( + &pool, + &state, + &spec.session_id, + &spec.server_id, + &preferred_name(&spec), + &error.message, + ); + return; + } + }; + + let (transport, handle, runner) = create_bridge(Arc::clone(&gateway), connection_id.clone()); + tokio::spawn(runner.run()); + // 握手期间 client 侧 server 就可能下发通知,入站路由必须早于握手注册。 + if !register_connection(&state, &spec, &connection_id, Arc::clone(&handle)) { + handle.close(); + disconnect(&gateway, &connection_id).await; + return; + } + + let served = serve_client_auto( + transport, + None, + None, + &pool.capability_profile, + HTTP_CONNECT_TIMEOUT, + ) + .await; + let service = match served { + Ok(Ok(service)) => service, + Ok(Err(error)) => { + let message = redact_mcp_error(&error.to_string()); + fail_connection(&pool, &state, &gateway, &spec, &connection_id, message).await; + return; + } + Err(_) => { + fail_connection( + &pool, + &state, + &gateway, + &spec, + &connection_id, + format!("MCP 握手超时({}s)", HTTP_CONNECT_TIMEOUT.as_secs()), + ) + .await; + return; + } + }; + + let mut service = pool.retain_service(service); + let peer = service.peer().clone(); + let tools = match peer.list_all_tools().await { + Ok(tools) => tools, + Err(error) => { + let message = redact_mcp_error(&error.to_string()); + let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + fail_connection(&pool, &state, &gateway, &spec, &connection_id, message).await; + return; + } + }; + if !connection_is_live(&state, &connection_id) { + // 会话在握手 / 发现期间关闭:连接不落池,立即断开。 + let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + disconnect(&gateway, &connection_id).await; + return; + } + + let client = McpClientHandle { + name: preferred_name(&spec), + version: peer.peer_info().and_then(|info| { + info.server_info + .as_ref() + .map(|server| server.version.clone()) + }), + // 会话级连接的来源是声明它的 client,不参与持久缓存。 + cache_version: None, + skills_capable: peer_declares_skills(&peer), + channel_capable: peer + .peer_info() + .and_then(|info| { + info.capabilities + .experimental + .as_ref() + .and_then(|experimental| experimental.get("claude/channel")) + .cloned() + }) + .is_some(), + peer: Some(peer), + resources: Vec::new(), + tools, + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: Some(ConfigSource::Acp), + url: None, + }; + match pool.commit_acp_connection(&spec.session_id, &spec.name, Arc::new(client), service) { + Ok(pool_name) => { + mark_ready(&state, &spec.session_id, &spec.server_id); + tracing::info!( + session_id = %spec.session_id, + server = %spec.name, + pool_name = %pool_name, + "MCP over ACP 连接就绪" + ); + } + Err(mut service) => { + // 池已关闭:不留任何可被读成成功的证据。 + let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + unregister_connection(&state, &connection_id); + disconnect(&gateway, &connection_id).await; + } + } +} + +/// 池内名:client 声明名为空时退回 `serverId`(工具命名要求非空前缀)。 +fn preferred_name(spec: &AcpMcpServerSpec) -> String { + let name = spec.name.trim(); + if name.is_empty() { + spec.server_id.clone() + } else { + name.to_string() + } +} + +/// `mcp/connect { serverId }` → `connectionId`。 +async fn connect( + gateway: &Arc, + server_id: &str, +) -> Result { + let mut params = Map::new(); + params.insert("serverId".to_string(), Value::String(server_id.to_string())); + let response = gateway + .request(MCP_CONNECT_METHOD, Value::Object(params)) + .await?; + response + .get("connectionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .ok_or_else(|| AcpMcpError::unavailable("mcp/connect 响应缺少 connectionId")) +} + +/// `mcp/disconnect { connectionId }`;失败只告警——连接已在本地拆除,对端延迟 +/// 回收不改变本侧终态。 +/// +/// 等待有上界:会话关闭路径会 await 这里,而 transport 层没有内建超时,静默的 +/// 对端不得把会话关闭拖成无限等待。 +async fn disconnect(gateway: &Arc, connection_id: &str) { + let mut params = Map::new(); + params.insert( + "connectionId".to_string(), + Value::String(connection_id.to_string()), + ); + let outcome = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + gateway.request(MCP_DISCONNECT_METHOD, Value::Object(params)), + ) + .await; + match outcome { + Ok(Ok(_)) => {} + Ok(Err(error)) => tracing::debug!( + connection_id = %connection_id, + %error, + "mcp/disconnect 未收到对端确认" + ), + Err(_) => tracing::debug!( + connection_id = %connection_id, + timeout_secs = SHUTDOWN_TIMEOUT.as_secs(), + "mcp/disconnect 等待对端确认超时" + ), + } +} + +/// 注册入站路由;返回 false 表示会话已关闭(调用方必须立刻断开)。 +fn register_connection( + state: &Mutex, + spec: &AcpMcpServerSpec, + connection_id: &str, + handle: Arc, +) -> bool { + let mut state = state.lock(); + if !state.sessions.contains_key(&spec.session_id) { + return false; + } + state.connections.insert( + connection_id.to_string(), + ConnectionRecord { + session_id: spec.session_id.clone(), + server_id: spec.server_id.clone(), + handle, + }, + ); + true +} + +fn unregister_connection(state: &Mutex, connection_id: &str) -> bool { + state.lock().connections.remove(connection_id).is_some() +} + +/// 连接是否仍属于存活会话(会话关闭后不得提交进池)。 +fn connection_is_live(state: &Mutex, connection_id: &str) -> bool { + state.lock().connections.contains_key(connection_id) +} + +/// 失败收口:注销路由、`mcp/disconnect`、池状态面记失败。 +async fn fail_connection( + pool: &Arc, + state: &Mutex, + gateway: &Arc, + spec: &AcpMcpServerSpec, + connection_id: &str, + message: String, +) { + tracing::warn!( + session_id = %spec.session_id, + server = %spec.name, + error = %message, + "MCP over ACP 连接失败" + ); + unregister_connection(state, connection_id); + disconnect(gateway, connection_id).await; + record_failure( + pool, + state, + &spec.session_id, + &spec.server_id, + &preferred_name(spec), + &message, + ); +} + +/// 失败收口(统一入口):池状态面留失败条目 + 状态机置 `Failed`。 +fn record_failure( + pool: &Arc, + state: &Mutex, + session_id: &str, + server_id: &str, + name: &str, + message: &str, +) { + pool.record_acp_failure(session_id, name, message); + mark_failed(state, session_id, server_id); +} + +fn mark_ready(state: &Mutex, session_id: &str, server_id: &str) { + if let Some(phase) = state + .lock() + .sessions + .get_mut(session_id) + .and_then(|session| session.servers.get_mut(server_id)) + { + *phase = ServerPhase::Ready; + } +} + +fn mark_failed(state: &Mutex, session_id: &str, server_id: &str) { + if let Some(phase) = state + .lock() + .sessions + .get_mut(session_id) + .and_then(|session| session.servers.get_mut(server_id)) + { + *phase = ServerPhase::Failed; + } +} + +#[cfg(test)] +#[path = "session_test.rs"] +mod tests; diff --git a/peri-middlewares/src/mcp/acp/session_test.rs b/peri-middlewares/src/mcp/acp/session_test.rs new file mode 100644 index 000000000..13d1473b4 --- /dev/null +++ b/peri-middlewares/src/mcp/acp/session_test.rs @@ -0,0 +1,480 @@ +//! [`AcpMcpService`] 的 crate 内契约测试。 +//! +//! 走真实链路:真实 `AcpMcpGatewayPort` 调用面、真实桥接 transport、真实 +//! `serve_client_auto` 握手与 `tools/list` 发现、真实 MCP 池提交。假件只在 +//! **协议对端**(`mcp/connect` / `mcp/message` 的应答方)——client 在真实部署 +//! 里就扮演这个角色。 +//! +//! 断言的行为契约(每条对应一处生产不变量): +//! 1. **不阻塞**:`attach` 立即返回,连接在后台完成(deferred 工具面); +//! 2. **幂等**:同会话同 `serverId` 重复声明只建一次连接; +//! 3. **不跨会话泄漏**:池内条目、工具桥接、状态面均按归属会话过滤; +//! 4. **失败可见**:建连失败留在本会话可见的池状态面(不 panic、不回抛); +//! 5. **关闭即断**:`close_session` 终止在建任务(不落池)、发 `mcp/disconnect`、 +//! 清理池条目,且不误伤其他会话的连接。 + +use std::sync::Arc; +use std::time::Duration; + +use peri_acp_types::acp_mcp::{AcpMcpError, AcpMcpInbound, AcpMcpServerSpec}; +use peri_acp_types::plugin::ConfigSource; +use peri_acp_types::ports::{AcpMcpGatewayPort, AcpMcpServerPort}; +use serde_json::{json, Map, Value}; + +use super::AcpMcpService; +use crate::mcp::apps::McpCapabilityProfile; +use crate::mcp::client::{ClientStatus, McpClientPool}; +use crate::mcp::task_scope::McpTaskOwner; +use crate::mcp::tool_bridge::build_tool_bridges_visible_to; + +/// 假件观测到的协议调用。 +#[derive(Default)] +struct FakeGateway { + /// 收到的 `mcp/connect` 的 `serverId`(按到达顺序)。 + connects: parking_lot::Mutex>, + /// 收到的 `mcp/disconnect` 的 `connectionId`。 + disconnects: parking_lot::Mutex>, + /// `mcp/message` 通知的 `method`(内层方法名)。 + notifications: parking_lot::Mutex>, + /// `mcp/message` 请求的内层方法名。 + inner_requests: parking_lot::Mutex>, + /// `mcp/connect` 前的延迟(模拟慢建连)。 + connect_delay: Option, + /// `mcp/connect` 直接失败(模拟 client 拒绝该 server)。 + fail_connect: bool, + /// 假 server 的 `tools/list` 载荷。 + tools: Vec, +} + +impl FakeGateway { + fn with_tools(tools: &[&str]) -> Self { + Self { + tools: tools + .iter() + .map(|name| { + json!({ + "name": name, + "description": format!("{name} tool"), + "inputSchema": { "type": "object", "properties": {} } + }) + }) + .collect(), + ..Self::default() + } + } + + fn connect_count(&self) -> usize { + self.connects.lock().len() + } +} + +#[async_trait::async_trait] +impl AcpMcpGatewayPort for FakeGateway { + async fn request(&self, method: &str, params: Value) -> Result { + match method { + "mcp/connect" => { + if let Some(delay) = self.connect_delay { + tokio::time::sleep(delay).await; + } + if self.fail_connect { + return Err(AcpMcpError::unavailable("client 拒绝建连")); + } + let server_id = params + .get("serverId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + self.connects.lock().push(server_id); + let connection_id = format!("conn-{}", self.connects.lock().len()); + Ok(json!({ "connectionId": connection_id })) + } + "mcp/message" => { + let inner = params + .get("method") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + self.inner_requests.lock().push(inner.clone()); + match inner.as_str() { + "initialize" => Ok(json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "acp-fixture", "version": "1" } + })), + "tools/list" => Ok(json!({ "tools": self.tools })), + // 未实现方法:按 JSON-RPC 码回错——rmcp 的 lifecycle 协商 + // 依赖 -32601 判定 legacy 回退,码值不得被抹平。 + other => Err(AcpMcpError { + code: -32601, + message: format!("method not found: {other}"), + }), + } + } + "mcp/disconnect" => { + let connection_id = params + .get("connectionId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + self.disconnects.lock().push(connection_id); + Ok(json!({})) + } + other => Err(AcpMcpError::not_found(format!("未知方法: {other}"))), + } + } + + async fn notify(&self, method: &str, params: Value) -> Result<(), AcpMcpError> { + if method == "mcp/message" { + let inner = params + .get("method") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + self.notifications.lock().push(inner); + } + Ok(()) + } +} + +struct Fixture { + pool: Arc, + service: Arc, + gateway: Arc, + _owner: McpTaskOwner, +} + +fn fixture(tools: &[&str]) -> Fixture { + let (owner, spawner) = McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner_and_profile( + spawner, + McpCapabilityProfile::disabled(), + )); + Fixture { + service: Arc::new(AcpMcpService::new(Arc::clone(&pool))), + pool, + gateway: Arc::new(FakeGateway::with_tools(tools)), + _owner: owner, + } +} + +fn spec(session_id: &str, name: &str, server_id: &str) -> AcpMcpServerSpec { + AcpMcpServerSpec { + session_id: session_id.to_string(), + name: name.to_string(), + server_id: server_id.to_string(), + } +} + +/// 轮询等待条件成立(连接握手 + 工具发现是异步的,无固定时序)。 +async fn wait_until(what: &str, mut condition: impl FnMut() -> bool) { + for _ in 0..600 { + if condition() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("等待超时: {what}"); +} + +/// 契约 1 + 3:`attach` 不阻塞、连接最终进池,且只对声明它的会话可见。 +#[tokio::test] +async fn attach_connects_in_background_and_stays_session_scoped() { + let fixture = fixture(&["echo"]); + let pool = Arc::clone(&fixture.pool); + fixture.service.attach( + Arc::clone(&fixture.gateway) as Arc, + vec![spec("s1", "acp-srv", "srv-1")], + ); + + // attach 本身是同步返回的:此处尚未有任何连接证据。 + assert!(pool.get_all_clients_visible_to(Some("s1")).is_empty()); + wait_until("acp 连接进池", || { + pool.get_client_visible_to("acp-srv", Some("s1")).is_some() + }) + .await; + + let handle = pool + .get_client_visible_to("acp-srv", Some("s1")) + .expect("归属会话应看到连接"); + assert!(matches!(handle.status, ClientStatus::Connected)); + assert_eq!(handle.source, Some(ConfigSource::Acp)); + assert_eq!( + handle + .tools + .iter() + .map(|t| t.name.to_string()) + .collect::>(), + vec!["echo".to_string()] + ); + // 内层 MCP 握手走的就是 `mcp/message` 请求;初始化完成的 + // `notifications/initialized` 走 `mcp/message` 通知。 + assert!(fixture + .gateway + .inner_requests + .lock() + .iter() + .any(|method| method == "tools/list")); + assert!(fixture + .gateway + .notifications + .lock() + .iter() + .any(|method| method == "notifications/initialized")); + + // 另一个会话:工具面、状态面、发现面都看不到这条连接。 + assert!(!pool.is_visible_to_session("acp-srv", "s2")); + assert!(pool.get_client_visible_to("acp-srv", Some("s2")).is_none()); + assert!(pool.get_all_clients_visible_to(Some("s2")).is_empty()); + assert!(build_tool_bridges_visible_to(&pool, Some("s2")).is_empty()); + assert!(pool + .all_server_infos_visible_to(Some("s2")) + .iter() + .all(|info| info.name != "acp-srv")); + // 部署面(`None`)不做归属过滤,仍能看到池内的真实连接。 + assert!(pool.get_client_visible_to("acp-srv", None).is_some()); + assert_eq!(build_tool_bridges_visible_to(&pool, Some("s1")).len(), 1); +} + +/// 契约 1 续:client 经 `mcp/message` 反向下发的请求打到内层 MCP 连接。 +#[tokio::test] +async fn inbound_request_reaches_the_inner_connection() { + let fixture = fixture(&["echo"]); + fixture.service.attach( + Arc::clone(&fixture.gateway) as Arc, + vec![spec("s1", "acp-srv", "srv-1")], + ); + let pool = Arc::clone(&fixture.pool); + wait_until("acp 连接进池", || { + pool.get_client_visible_to("acp-srv", Some("s1")).is_some() + }) + .await; + + // `ping` 由 rmcp 客户端 runtime 应答(默认 handler),因此这里证明的是 + // 「入站 mcp/message → 桥接 → rmcp → 响应回程」整条路径。 + let result = fixture + .service + .request(AcpMcpInbound { + connection_id: "conn-1".to_string(), + method: "ping".to_string(), + params: None, + }) + .await; + assert!(result.is_ok(), "入站请求应得到内层响应: {result:?}"); + + // 未知 connectionId:按契约码 -32001 拒绝,不静默成功。 + let missing = fixture + .service + .request(AcpMcpInbound { + connection_id: "conn-missing".to_string(), + method: "ping".to_string(), + params: None, + }) + .await + .expect_err("未知连接必须报错"); + assert_eq!(missing.code, AcpMcpError::CODE_NOT_FOUND); + + let mut params = Map::new(); + params.insert("anything".to_string(), json!(1)); + assert!(fixture + .service + .notify(AcpMcpInbound { + connection_id: "conn-1".to_string(), + method: "notifications/cancelled".to_string(), + params: Some(params), + }) + .await + .is_ok()); +} + +/// 契约 2:同会话同 `serverId` 重复声明幂等;不同会话同名各自建连且池内名不冲突。 +#[tokio::test] +async fn attach_is_idempotent_and_cross_session_names_do_not_collide() { + let fixture = fixture(&["echo"]); + let gateway = Arc::clone(&fixture.gateway) as Arc; + fixture + .service + .attach(Arc::clone(&gateway), vec![spec("s1", "acp-srv", "srv-1")]); + fixture + .service + .attach(Arc::clone(&gateway), vec![spec("s1", "acp-srv", "srv-1")]); + let pool = Arc::clone(&fixture.pool); + wait_until("首个连接进池", || { + pool.get_client_visible_to("acp-srv", Some("s1")).is_some() + }) + .await; + assert_eq!(fixture.gateway.connect_count(), 1, "重复声明不得重复建连"); + + // 另一会话声明同名 server:连接独立,池内名加后缀,互不覆盖。 + fixture + .service + .attach(Arc::clone(&gateway), vec![spec("s2", "acp-srv", "srv-2")]); + wait_until("第二会话连接进池", || { + pool.get_all_clients_visible_to(Some("s2")).len() == 1 + }) + .await; + let second = pool + .get_all_clients_visible_to(Some("s2")) + .pop() + .expect("第二会话应有自己的连接"); + assert_eq!(second.name, "acp-srv_2"); + assert!(matches!(second.status, ClientStatus::Connected)); + // 归属不变:各自只看得到自己那条。 + assert_eq!(build_tool_bridges_visible_to(&pool, Some("s1")).len(), 1); + assert_eq!(build_tool_bridges_visible_to(&pool, Some("s2")).len(), 1); + assert_eq!(fixture.gateway.connect_count(), 2); +} + +/// 契约 3 续:ACP 连接的上下线不得进入**部署级**通知面。 +/// +/// 状态变化缓冲与 notifier 都是部署级的(任一会话 drain 一次即清空;notifier +/// 推的是 TUI 通知面),而 ACP 连接只属于声明它的会话。失败条目的首次插入本 +/// 就不产生"变化",这里锁的是更隐蔽的一代:同一 `serverId` 重试失败时旧状态 +/// 已被取代,若不按归属跳过就会把别会话的 server 名与失败原因推进共享面。 +#[tokio::test] +async fn acp_status_changes_stay_out_of_the_deployment_wide_notification_buffer() { + let fixture = fixture(&[]); + let pool = Arc::clone(&fixture.pool); + pool.mark_initialized(); + + // 对照组:部署级 server 的状态变化照旧进缓冲(守卫不是整体关停通知)。 + McpClientPool::insert_failed(&pool, "cfg-srv", "配置连接失败".to_string()); + pool.record_status_change("cfg-srv", Some(&ClientStatus::Uninitialized)); + let changes = pool.drain_pending_changes(); + assert!( + changes.iter().any(|text| text.contains("cfg-srv")), + "部署级 server 的状态变化必须照旧进入缓冲: {changes:?}" + ); + + // 归属会话的 ACP 条目:首代失败(无"变化")与重试失败(有"变化")都不进缓冲。 + pool.record_acp_failure("s1", "acp-srv", "第一代失败"); + pool.record_acp_failure("s1", "acp-srv", "重试仍失败"); + assert!( + pool.drain_pending_changes().is_empty(), + "ACP 连接的状态变化不得进入部署级通知缓冲" + ); + // 事实仍在归属会话可见的池状态面(缓冲不是唯一出口)。 + let info = pool + .all_server_infos_visible_to(Some("s1")) + .into_iter() + .find(|info| info.name == "acp-srv") + .expect("失败条目应对归属会话可见"); + assert!(matches!(info.status, ClientStatus::Failed(_))); + assert!(pool + .all_server_infos_visible_to(Some("s2")) + .iter() + .all(|info| info.name != "acp-srv")); +} + +/// 契约 4:建连失败留在池状态面(本会话可见、他会话不可见),不 panic 不回抛。 +#[tokio::test] +async fn connect_failure_is_recorded_in_the_pool_state_face() { + let (owner, spawner) = McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner_and_profile( + spawner, + McpCapabilityProfile::disabled(), + )); + let service = AcpMcpService::new(Arc::clone(&pool)); + let gateway = Arc::new(FakeGateway { + fail_connect: true, + ..FakeGateway::default() + }); + service.attach( + Arc::clone(&gateway) as Arc, + vec![spec("s1", "acp-srv", "srv-1")], + ); + + wait_until("失败条目落池", || { + pool.all_server_infos_visible_to(Some("s1")) + .iter() + .any(|info| info.name == "acp-srv") + }) + .await; + let info = pool + .all_server_infos_visible_to(Some("s1")) + .into_iter() + .find(|info| info.name == "acp-srv") + .expect("失败条目应对归属会话可见"); + assert!(matches!(info.status, ClientStatus::Failed(_))); + assert!(info.error_summary.is_some(), "失败原因应可核对"); + // 他会话看不到这条失败证据(免得把别人的 server 读成自己的)。 + assert!(pool + .all_server_infos_visible_to(Some("s2")) + .iter() + .all(|info| info.name != "acp-srv")); + // 失败条目没有连接(peer / tools 为空),因此不进工具面。 + assert!(build_tool_bridges_visible_to(&pool, Some("s1")).is_empty()); + assert!(pool.get_client_visible_to("acp-srv", Some("s2")).is_none()); + drop(owner); +} + +/// 契约 5:`close_session` 断开该会话全部连接,其他会话不受影响。 +#[tokio::test] +async fn close_session_disconnects_only_that_session() { + let fixture = fixture(&["echo"]); + let gateway = Arc::clone(&fixture.gateway) as Arc; + let pool = Arc::clone(&fixture.pool); + fixture + .service + .attach(Arc::clone(&gateway), vec![spec("s1", "acp-srv", "srv-1")]); + fixture + .service + .attach(Arc::clone(&gateway), vec![spec("s2", "acp-srv", "srv-2")]); + wait_until("两条连接进池", || { + pool.get_all_clients_visible_to(Some("s1")).len() == 1 + && pool.get_all_clients_visible_to(Some("s2")).len() == 1 + }) + .await; + + fixture.service.close_session("s1").await; + + assert!(pool.get_client_visible_to("acp-srv", Some("s1")).is_none()); + assert!(pool.all_server_infos_visible_to(Some("s1")).is_empty()); + assert_eq!(fixture.gateway.disconnects.lock().len(), 1); + assert_eq!( + fixture + .gateway + .disconnects + .lock() + .first() + .map(String::as_str), + Some("conn-1") + ); + // s2 的连接仍在,且仍是自己的。 + assert_eq!(pool.get_all_clients_visible_to(Some("s2")).len(), 1); + // 幂等:重复关闭不产生额外协议调用。 + fixture.service.close_session("s1").await; + assert_eq!(fixture.gateway.disconnects.lock().len(), 1); + assert_eq!(fixture.gateway.connect_count(), 2); +} + +/// 契约 5 续:在建连接被会话关闭终止,握手完成后**不得**落池。 +#[tokio::test] +async fn close_session_aborts_inflight_connect_without_committing() { + let (owner, spawner) = McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner_and_profile( + spawner, + McpCapabilityProfile::disabled(), + )); + let service = AcpMcpService::new(Arc::clone(&pool)); + let gateway = Arc::new(FakeGateway { + connect_delay: Some(Duration::from_millis(150)), + ..FakeGateway::with_tools(&["echo"]) + }); + service.attach( + Arc::clone(&gateway) as Arc, + vec![spec("s1", "acp-srv", "srv-1")], + ); + // `mcp/connect` 尚未返回(延迟窗口内关闭会话)。 + service.close_session("s1").await; + tokio::time::sleep(Duration::from_millis(400)).await; + + assert!( + pool.get_client_visible_to("acp-srv", Some("s1")).is_none(), + "会话关闭后在建连接不得提交进池" + ); + assert!( + pool.all_server_infos_visible_to(Some("s1")).is_empty(), + "会话关闭后不得留下该会话的池条目" + ); + drop(owner); +} diff --git a/peri-middlewares/src/mcp/acp/transport.rs b/peri-middlewares/src/mcp/acp/transport.rs new file mode 100644 index 000000000..9e8a3633b --- /dev/null +++ b/peri-middlewares/src/mcp/acp/transport.rs @@ -0,0 +1,442 @@ +//! MCP over ACP 桥接 transport。 +//! +//! 把 rmcp 的 JSON-RPC 消息经 ACP `mcp/message` 双向转发: +//! +//! - rmcp 发出的请求 / 通知 → ACP 请求 / 通知(`connectionId` 定位连接); +//! - rmcp 对 client 请求的响应 → 结算挂起的 ACP 请求; +//! - client 经 `mcp/message` 反向下发的请求 / 通知 → 注入 rmcp 的接收队列。 +//! +//! 消息以 JSON 载荷中转(`mcp/message` 承载的本就是内层 MCP 消息),不复制 +//! rmcp 的类型层次;内层请求 id 由 rmcp 生成并在本模块内配对。 +//! +//! 关闭是单点信号([`BridgeClose`]):任一侧触发后,rmcp 的 `receive()` 与 +//! 出站转发任务同时收敛,不会留下悬挂的通道或任务。 + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; + +use parking_lot::Mutex; +use peri_acp_types::acp_mcp::AcpMcpError; +use peri_acp_types::ports::AcpMcpGatewayPort; +use rmcp::model::{ErrorCode, ErrorData, NumberOrString, RequestId}; +use rmcp::service::{RoleClient, RxJsonRpcMessage, TxJsonRpcMessage}; +use rmcp::transport::Transport; +use serde_json::{Map, Value}; +use tokio::sync::{mpsc, oneshot}; + +/// ACP `mcp/message` 的方法名(协议常量)。 +pub(crate) const MCP_MESSAGE_METHOD: &str = "mcp/message"; + +/// ACP `mcp/connect` 的方法名:agent → client,返回 `connectionId`。 +pub(crate) const MCP_CONNECT_METHOD: &str = "mcp/connect"; + +/// ACP `mcp/disconnect` 的方法名:agent → client,关闭一条连接。 +pub(crate) const MCP_DISCONNECT_METHOD: &str = "mcp/disconnect"; + +/// 桥接 transport 的错误类型(仅表达通道关闭)。 +#[derive(Debug, thiserror::Error)] +pub(crate) enum AcpBridgeError { + #[error("MCP over ACP 桥接通道已关闭")] + Closed, +} + +/// 单点关闭信号:`close()` 之后 rmcp 入站、出站转发与句柄注入同时失效。 +/// +/// 用「标志位 + `notify_waiters`」而非直接关通道:tokio 的 mpsc 关闭只能由 +/// 接收端发起,而关闭可能来自任一侧(rmcp 释放 transport、会话关闭、池关闭)。 +struct BridgeClose { + closed: std::sync::atomic::AtomicBool, + notify: tokio::sync::Notify, +} + +impl BridgeClose { + fn new() -> Arc { + Arc::new(Self { + closed: std::sync::atomic::AtomicBool::new(false), + notify: tokio::sync::Notify::new(), + }) + } + + fn close(&self) { + self.closed + .store(true, std::sync::atomic::Ordering::Release); + self.notify.notify_waiters(); + } + + fn is_closed(&self) -> bool { + self.closed.load(std::sync::atomic::Ordering::Acquire) + } + + /// 等待关闭;已关闭时立即返回(先登记再复查,避免错过 `notify_waiters`)。 + async fn wait(&self) { + while !self.is_closed() { + let notified = self.notify.notified(); + if self.is_closed() { + return; + } + notified.await; + } + } +} + +/// rmcp 侧的 transport 实现:出站经 channel 交给桥接任务,入站从 channel 读取。 +pub(crate) struct AcpBridgeTransport { + outbound_tx: mpsc::UnboundedSender>, + inbound_rx: mpsc::UnboundedReceiver>, + close: Arc, +} + +impl Transport for AcpBridgeTransport { + type Error = AcpBridgeError; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let tx = self.outbound_tx.clone(); + let close = Arc::clone(&self.close); + async move { + if close.is_closed() { + return Err(AcpBridgeError::Closed); + } + tx.send(item).map_err(|_| AcpBridgeError::Closed) + } + } + + fn receive(&mut self) -> impl Future>> + Send { + let rx = &mut self.inbound_rx; + let close = Arc::clone(&self.close); + async move { + tokio::select! { + biased; + // 关闭优先:会话结束时不再等待 client 的下一条消息。 + () = close.wait() => None, + message = rx.recv() => message, + } + } + } + + fn close(&mut self) -> impl Future> + Send { + // 关闭信号驱动:出站转发任务与入站接收同时收敛。 + self.close.close(); + async { Ok(()) } + } +} + +/// 桥接连接句柄:外部(会话管理器 / ACP 面)经它向 rmcp 注入反向下发的消息。 +pub(crate) struct AcpBridgeHandle { + inbound_tx: mpsc::UnboundedSender>, + pending: Arc>>>>, + next_id: std::sync::atomic::AtomicI64, + close: Arc, +} + +impl AcpBridgeHandle { + /// client 下发的 `mcp/message` 请求:注入 rmcp 并等待其响应。 + /// + /// 无内置超时:连接静默与调用方的取消语义由 ACP 层持有。 + pub(crate) async fn request( + &self, + method: &str, + params: Option, + ) -> Result { + if self.close.is_closed() { + return Err(connection_closed()); + } + let id = RequestId::Number( + self.next_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + ); + let message = build_request_message(&id, method, params)?; + let (tx, rx) = oneshot::channel(); + self.pending.lock().insert(id.clone(), tx); + if self.inbound_tx.send(message).is_err() { + self.pending.lock().remove(&id); + return Err(connection_closed()); + } + match rx.await { + Ok(result) => result, + Err(_) => Err(connection_closed()), + } + } + + /// client 下发的 `mcp/message` 通知:投递后即返回。 + pub(crate) fn notify(&self, method: &str, params: Option) -> Result<(), ErrorData> { + if self.close.is_closed() { + return Err(connection_closed()); + } + let message = build_notification_message(method, params)?; + self.inbound_tx + .send(message) + .map_err(|_| connection_closed()) + } + + /// 关闭桥接:rmcp 的 `receive()` 在排空入站队列后返回 `None`,出站转发任务退出。 + pub(crate) fn close(&self) { + self.close.close(); + } +} + +/// 建立一次桥接:返回 rmcp 侧 transport、外部句柄与出站转发任务。 +pub(crate) fn create_bridge( + gateway: Arc, + connection_id: String, +) -> (AcpBridgeTransport, Arc, BridgeRunner) { + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel(); + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel(); + let pending: Arc>>>> = + Arc::new(Mutex::new(HashMap::new())); + let close = BridgeClose::new(); + let transport = AcpBridgeTransport { + outbound_tx, + inbound_rx, + close: Arc::clone(&close), + }; + let handle = Arc::new(AcpBridgeHandle { + inbound_tx: inbound_tx.clone(), + pending: Arc::clone(&pending), + next_id: std::sync::atomic::AtomicI64::new(1), + close: Arc::clone(&close), + }); + let runner = BridgeRunner { + gateway, + connection_id, + outbound_rx, + inbound_tx, + pending, + close, + }; + (transport, handle, runner) +} + +/// 出站转发任务:rmcp 发出的消息 → ACP `mcp/message`。 +pub(crate) struct BridgeRunner { + gateway: Arc, + connection_id: String, + outbound_rx: mpsc::UnboundedReceiver>, + inbound_tx: mpsc::UnboundedSender>, + pending: Arc>>>>, + close: Arc, +} + +impl BridgeRunner { + /// 运行直到关闭信号触发或出站通道关闭(transport 被 rmcp 释放)。 + pub(crate) async fn run(self) { + let Self { + gateway, + connection_id, + mut outbound_rx, + inbound_tx, + pending, + close, + } = self; + loop { + let message = tokio::select! { + biased; + () = close.wait() => break, + message = outbound_rx.recv() => match message { + Some(message) => message, + None => break, + }, + }; + let Ok(value) = serde_json::to_value(&message) else { + tracing::warn!(connection_id = %connection_id, "MCP over ACP 消息序列化失败"); + continue; + }; + let Some(method) = value.get("method").and_then(Value::as_str) else { + // 响应 / 错误:结算挂起的 client 请求。 + settle_pending(&value, &pending); + continue; + }; + let method = method.to_owned(); + let params = value.get("params").cloned(); + match value.get("id").and_then(json_request_id) { + // 请求:转发为 ACP 请求,响应回填为 rmcp 的 Response / Error。 + Some(id) => { + let gateway = Arc::clone(&gateway); + let connection_id = connection_id.clone(); + let inbound_tx = inbound_tx.clone(); + tokio::spawn(async move { + let result = + forward_request(gateway.as_ref(), &connection_id, &method, params) + .await; + match build_response_message(&id, result) { + Some(message) => { + let _ = inbound_tx.send(message); + } + None => tracing::warn!( + connection_id = %connection_id, + "MCP over ACP 响应回填失败,请求将按取消结算" + ), + } + }); + } + // 通知:无 id,不等待响应。 + None => { + if let Err(error) = gateway + .notify( + MCP_MESSAGE_METHOD, + message_params(&connection_id, &method, params.map(json_object)), + ) + .await + { + tracing::warn!( + connection_id = %connection_id, + %error, + "MCP over ACP 通知转发失败" + ); + } + } + } + } + } +} + +/// 把 rmcp 请求经 ACP `mcp/message` 发出,返回内层 MCP 结果。 +async fn forward_request( + gateway: &dyn AcpMcpGatewayPort, + connection_id: &str, + method: &str, + params: Option, +) -> Result { + gateway + .request( + MCP_MESSAGE_METHOD, + message_params(connection_id, method, params.map(json_object)), + ) + .await + .map_err(inner_error) +} + +/// ACP 错误 → 内层 MCP 错误。 +/// +/// `mcp/message` 请求的错误码按协议约定就是内层 JSON-RPC 错误码 +/// (`AcpMcpServerPort::request` 反向同理),因此原样还原码值:rmcp 的 +/// lifecycle 协商依赖 `-32601` 等方法级错误码判定 legacy 回退,抹平成 +/// `-32603` 会让「MCP 方法不存在」与「连接故障」不可区分。码值超出 +/// rmcp 的 `i32` 表示时退回内部错误。 +fn inner_error(error: AcpMcpError) -> ErrorData { + let code = i32::try_from(error.code).unwrap_or(ErrorCode::INTERNAL_ERROR.0); + ErrorData::new(ErrorCode(code), error.message, None) +} + +/// `mcp/message` 载荷:`{ connectionId, method, params }`(params 缺省省略)。 +fn message_params(connection_id: &str, method: &str, params: Option>) -> Value { + let mut object = Map::new(); + object.insert( + "connectionId".to_string(), + Value::String(connection_id.to_string()), + ); + object.insert("method".to_string(), Value::String(method.to_string())); + if let Some(params) = params { + object.insert("params".to_string(), Value::Object(params)); + } + Value::Object(object) +} + +/// 非对象 params(如数组)按缺省处理:MCP 请求参数约定为对象或省略。 +fn json_object(params: Value) -> Map { + match params { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// 结算挂起的 client 请求:Response → 结果,Error → 错误。 +fn settle_pending( + value: &Value, + pending: &Mutex>>>, +) { + let Some(id) = value.get("id").and_then(json_request_id) else { + return; + }; + let Some(sender) = pending.lock().remove(&id) else { + return; + }; + let result = match value.get("error") { + Some(error) => Err( + serde_json::from_value::(error.clone()).unwrap_or_else(|_| { + ErrorData::internal_error("MCP 错误载荷无法解析".to_string(), None) + }), + ), + None => Ok(value.get("result").cloned().unwrap_or(Value::Null)), + }; + let _ = sender.send(result); +} + +/// JSON id → rmcp `RequestId`(数字或字符串)。 +fn json_request_id(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().map(NumberOrString::Number), + Value::String(text) => Some(NumberOrString::String(text.as_str().into())), + _ => None, + } +} + +fn connection_closed() -> ErrorData { + ErrorData::internal_error("MCP over ACP 连接已关闭".to_string(), None) +} + +fn request_id_value(id: &RequestId) -> Value { + serde_json::to_value(id).unwrap_or(Value::Null) +} + +/// 构造注入 rmcp 的请求消息。 +fn build_request_message( + id: &RequestId, + method: &str, + params: Option, +) -> Result, ErrorData> { + let mut object = Map::new(); + object.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); + object.insert("id".to_string(), request_id_value(id)); + object.insert("method".to_string(), Value::String(method.to_string())); + if let Some(params) = params { + object.insert("params".to_string(), params); + } + parse_inbound(Value::Object(object)) +} + +/// 构造注入 rmcp 的通知消息。 +fn build_notification_message( + method: &str, + params: Option, +) -> Result, ErrorData> { + let mut object = Map::new(); + object.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); + object.insert("method".to_string(), Value::String(method.to_string())); + if let Some(params) = params { + object.insert("params".to_string(), params); + } + parse_inbound(Value::Object(object)) +} + +/// 构造回填 rmcp 的响应 / 错误消息;解析失败返回 `None`(调用方丢弃并 warn)。 +fn build_response_message( + id: &RequestId, + result: Result, +) -> Option> { + let mut object = Map::new(); + object.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); + object.insert("id".to_string(), request_id_value(id)); + match result { + Ok(result) => { + object.insert("result".to_string(), result); + } + Err(error) => { + object.insert( + "error".to_string(), + serde_json::to_value(&error).unwrap_or(Value::Null), + ); + } + } + parse_inbound(Value::Object(object)).ok() +} + +/// JSON → rmcp 入站消息;解析失败返回描述性错误,由调用方决定降级方式。 +fn parse_inbound(value: Value) -> Result, ErrorData> { + serde_json::from_value(value).map_err(|error| { + ErrorData::invalid_request(format!("MCP over ACP 消息解析失败: {error}"), None) + }) +} diff --git a/peri-middlewares/src/mcp/client.rs b/peri-middlewares/src/mcp/client.rs index 9c4a6b1f4..efd3bf82e 100644 --- a/peri-middlewares/src/mcp/client.rs +++ b/peri-middlewares/src/mcp/client.rs @@ -5,6 +5,13 @@ mod cache; mod lifecycle; mod oauth; pub(crate) mod process; +// System MCP 启动准入 seam:清单发布与闸门已接线(`initialize` / `middleware`), +// 仍有三处冻结但尚无生产读取方的成员——`DiscoveryEvidence::is_complete`(只有测试在问)、 +// `NegotiatedSystemMcp::handle`(协商结果保留句柄,消费方只读 `requirement`/`generation`)、 +// `SystemReadinessError::CatalogPublicationFailed`(清单发布失败分支待接)。三者都由 +// crate 内测试覆盖,接线波次落地后连同本豁免一起删除。 +#[allow(dead_code)] +mod readiness; mod service; mod status; mod subscription; @@ -17,11 +24,21 @@ use oauth::{OAuthFlowKey, PendingOAuthCallback}; use peri_acp_types::{ mcp::McpSubscriptionPort, ports::McpPoolShutdownReport, session::InboxHandle, }; +use readiness::SystemReadinessTracker; use rmcp::model::{Resource, Tool}; use std::{any::Any, collections::HashMap, sync::Arc}; pub(crate) use cache::cache_scope_allows_persistence; pub use oauth::OAuthStartDisposition; +// System MCP 启动准入(IF-M3):证据、等待与类型化错误;子模块声明留在本文件, +// 不占 `mcp/mod.rs`(其 owner 为 C-INJ-02 / D-02)。消费方:B-02(证据提交 / +// 清单发布,已接线)、B-03(闸门与 C 接线,W4 消费 `NegotiatedSystemMcp` / +// `SystemMcpRequirement` / `SystemReadinessError`)。 +#[allow(unused_imports)] +pub(crate) use readiness::{ + DiscoveryEvidence, NegotiatedSystemMcp, SystemMcpManifest, SystemMcpRequirement, + SystemReadinessError, +}; #[cfg(test)] pub(crate) use service::ControlledMcpService; pub(crate) use service::{ @@ -97,6 +114,13 @@ pub struct McpClientPool { pub(crate) capability_profile: super::apps::McpCapabilityProfile, /// 初始模型 MCP tool invocation 签发、`peri/mcp/open` 单次消费的租约。 pub(crate) app_binding_leases: Arc, + /// System MCP 启动准入事实(IF-M3):配置清单状态 + 每台 server 的本代发现 + /// 证据 + 独立 watch revision。所有写入都必须经由成功/失败的生产路径。 + pub(crate) system_readiness: SystemReadinessTracker, + /// 会话级 ACP(MCP over ACP)连接归属:池内 server name → 所属 session id。 + /// 无条目的 server 对所有会话可见(配置来源与 dynamic 投影的既有语义); + /// 有条目的仅在归属会话内可见(工具桥接与状态面据此过滤)。 + pub(crate) acp_owners: parking_lot::RwLock>, } pub(crate) const STDIO_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -145,6 +169,8 @@ impl McpClientPool { resource_cache: super::resource_cache::McpResourceCache::new(), capability_profile, app_binding_leases: Arc::new(super::apps::McpAppBindingLeaseRegistry::default()), + system_readiness: SystemReadinessTracker::new(), + acp_owners: parking_lot::RwLock::new(HashMap::new()), } } @@ -195,6 +221,17 @@ impl McpClientPool { pub fn get_client(&self, name: &str) -> Option> { self.clients.read().get(name).cloned() } + /// 会话可见的连接句柄([`Self::get_client`] 的 ACP 归属过滤版)。 + pub fn get_client_visible_to( + &self, + name: &str, + session_id: Option<&str>, + ) -> Option> { + if session_id.is_some_and(|session_id| !self.is_visible_to_session(name, session_id)) { + return None; + } + self.get_client(name) + } pub fn get_all_clients(&self) -> Vec> { self.clients .read() @@ -203,6 +240,19 @@ impl McpClientPool { .cloned() .collect() } + /// 会话可见的连接句柄([`Self::get_all_clients`] 的 ACP 归属过滤版)。 + /// + /// `session_id` 为 `None` 表示不过滤(部署面视图:面板、命令面、池关闭)。 + pub fn get_all_clients_visible_to( + &self, + session_id: Option<&str>, + ) -> Vec> { + let mut clients = self.get_all_clients(); + if let Some(session_id) = session_id { + clients.retain(|client| self.is_visible_to_session(&client.name, session_id)); + } + clients + } pub fn has_resources(&self) -> bool { self.clients .read() diff --git a/peri-middlewares/src/mcp/client/lifecycle.rs b/peri-middlewares/src/mcp/client/lifecycle.rs index e9f66276a..f192f5ec3 100644 --- a/peri-middlewares/src/mcp/client/lifecycle.rs +++ b/peri-middlewares/src/mcp/client/lifecycle.rs @@ -102,7 +102,7 @@ impl McpClientPool { .unwrap_or(0) } - pub(super) fn advance_handle_generation(&self, handle: &Arc) -> u64 { + pub(crate) fn advance_handle_generation(&self, handle: &Arc) -> u64 { let generation = self .next_handle_generation .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -124,6 +124,108 @@ impl McpClientPool { let _ = svc.close_with_timeout(SHUTDOWN_TIMEOUT).await; } self.configs.write().remove(server_name); + // 句柄与配置同时消失:本代发现证据一律失效,等待方立即重读事实。 + self.system_readiness.clear_evidence(server_name); + } + + /// 会话级 ACP(MCP over ACP)连接的可见性过滤。 + /// + /// 无归属条目的 server(配置来源、dynamic 投影)对所有会话可见;有归属的 + /// 仅对归属会话可见——同一 ACP 连接下不同会话各自声明 server 时,工具不得 + /// 跨会话泄漏。 + pub fn is_visible_to_session(&self, server_name: &str, session_id: &str) -> bool { + match self.acp_owners.read().get(server_name) { + Some(owner) => owner == session_id, + None => true, + } + } + + /// 提交会话级 ACP 连接:准入锁下分配池内名并登记会话归属。 + /// + /// 池内名优先取 client 声明的 `name`;被其他归属(或本会话的上一代)占用时 + /// 追加 `_2`、`_3`…,避免覆盖既有条目。返回值是实际使用的池内名。 + pub(crate) fn commit_acp_connection( + self: &Arc, + session_id: &str, + preferred_name: &str, + mut handle: Arc, + service: McpServiceWrapper, + ) -> Result { + let _admission = self.lifecycle_registration.lock(); + if !self.is_open() { + return Err(service); + } + let name = self.allocate_acp_name(preferred_name, session_id); + Arc::make_mut(&mut handle).name = name.clone(); + self.acp_owners + .write() + .insert(name.clone(), session_id.to_string()); + self.advance_handle_generation(&handle); + self.services.lock().insert(name.clone(), service); + self.clients.write().insert(name.clone(), handle); + Ok(name) + } + + fn allocate_acp_name(&self, preferred: &str, session_id: &str) -> String { + let clients = self.clients.read(); + let same_owner = self + .acp_owners + .read() + .get(preferred) + .is_some_and(|owner| owner == session_id); + if !clients.contains_key(preferred) || same_owner { + return preferred.to_string(); + } + let mut index = 2u32; + loop { + let candidate = format!("{preferred}_{index}"); + if !clients.contains_key(&candidate) { + return candidate; + } + index += 1; + } + } + + /// 记录会话级 ACP 连接失败:池状态面留一条**归属该会话**的失败条目。 + /// + /// 失败连接从不进 `services`(没有可关闭的 service),但必须有可核对的事实: + /// 否则「建连失败」只存在于日志里,面板与模型概览都会把它读成「没有这台 + /// server」。返回实际使用的池内名。 + pub(crate) fn record_acp_failure( + self: &Arc, + session_id: &str, + preferred_name: &str, + reason: &str, + ) -> String { + let name = { + let _admission = self.lifecycle_registration.lock(); + if !self.is_open() { + return preferred_name.to_string(); + } + let name = self.allocate_acp_name(preferred_name, session_id); + self.acp_owners + .write() + .insert(name.clone(), session_id.to_string()); + name + }; + Self::insert_failed(self, &name, reason.to_string()); + name + } + + /// 关闭会话:移除该会话的全部 ACP 连接(关闭 service、清空归属),返回被移除的池内名。 + pub async fn remove_acp_servers_for_session(self: &Arc, session_id: &str) -> Vec { + let names: Vec = self + .acp_owners + .read() + .iter() + .filter(|(_, owner)| owner.as_str() == session_id) + .map(|(name, _)| name.clone()) + .collect(); + for name in &names { + self.acp_owners.write().remove(name); + self.remove_server(name).await; + } + names } /// 将服务器标记为 Disabled:关闭连接但保留 config 和 handle(用于面板展示) @@ -161,6 +263,8 @@ impl McpClientPool { channel_capable: false, }), ); + // 禁用不是「连接中」:本代证据失效,等待方立即得到 Disabled 事实。 + self.system_readiness.clear_evidence(server_name); } pub(crate) fn is_open(&self) -> bool { @@ -174,6 +278,8 @@ impl McpClientPool { } self.lifecycle .store(1, std::sync::atomic::Ordering::Release); + // 关闭事务开始:全部发现证据失效并唤醒等待方(它们在重读时看到 PoolClosed)。 + self.system_readiness.clear_all_evidence(); self.notifier.write().take(); self.oauth_event_callback.write().take(); self.pending_oauth_callbacks.lock().clear(); diff --git a/peri-middlewares/src/mcp/client/readiness.rs b/peri-middlewares/src/mcp/client/readiness.rs new file mode 100644 index 000000000..4f76308e6 --- /dev/null +++ b/peri-middlewares/src/mcp/client/readiness.rs @@ -0,0 +1,629 @@ +//! System MCP 启动准入的连接证据、等待与类型化错误(主 plan IF-M3,owner B-01)。 +//! +//! 本模块只回答一个问题:**本次入场(1R)时,配置声明的 System MCP 是否已完成 +//! transport + rmcp lifecycle + 能力协商 + live `tools/list`**。返回的 +//! [`NegotiatedSystemMcp`] 是「连接/协商完成」的证据,**不是** ready:ready 的 +//! 线性化点在目录提交后的最终复核(B-03)。 +//! +//! 判定纪律: +//! - 只有真实成功的生产路径提交的 [`DiscoveryEvidence`] 才算证据。空数组是 +//! `tools/list` 的成功结果;`Err`、超时、任务结束、旧缓存、光秃的 +//! `ClientStatus::Connected` 都不得代替发现证据。 +//! - 配置清单未完整发布([`SystemMcpManifest::Pending`])时,**不得**把空 +//! `configs` 当成「无 System MCP」;加载失败必须显式成为 `Failed`。 +//! - 等待有界:每个 server 的 deadline 都从调用方传入的入场时间起算(并发计时, +//! 不串行相加)。timeout 是终态失败,**不是**取消。 +//! - 需要人判断的状态(Disabled / 需要授权)一律返回错误,不主动弹 OAuth、 +//! 不自动重试、不跳过。 +//! - 证据只描述提交时刻的事实;是否属于**当前代**由读取方核对,旧代 Arc 永远 +//! 不被接受。 + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use peri_acp_types::plugin::McpServerConfig; +use peri_agent::agent::AgentCancellationToken; +use thiserror::Error; + +use super::{ClientStatus, McpClientHandle, McpClientPool, McpServiceWrapper, OAuthStatus}; +use crate::mcp::system_tools::SystemToolError; + +/// 安全展示用的 server 标签上限(字符数)。 +const MAX_SERVER_LABEL_CHARS: usize = 64; + +/// server/tool 展示用清洗:控制字符折叠为空格、移除 URL query、遮蔽凭据形态, +/// 并限制长度。错误文案不携带 env / headers / URL 认证信息 / 协议 payload。 +fn safe_server_label(raw: impl AsRef) -> String { + let collapsed: String = raw + .as_ref() + .chars() + .take(MAX_SERVER_LABEL_CHARS) + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect(); + super::redact_mcp_error(&collapsed) +} + +/// System MCP 启动等待的配置清单状态。 +/// +/// `Pending` 是构造初值,**不**等价于「无 System MCP」:`run_initialize` 在 +/// async 初始化内才装载 `configs`,空 map 不能作为「无依赖」的证据。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum SystemMcpManifest { + /// 完整配置清单(含空集合)尚未发布。 + #[default] + Pending, + /// 完整配置清单已发布;此刻起 `configs` 是可信的 System 依赖事实源。 + Loaded, + /// 配置加载或校验失败:不得退化成「无 System MCP」。 + Failed, +} + +/// 本代发现证据(主 plan IF-M3 冻结字段)。 +/// +/// 提交纪律(生产路径必须遵守): +/// - `generation` 取自该 server **当前已提交句柄**的 `handle_generation`;`0` +/// 表示句柄未经提交登记,读取方一律视为无效。 +/// - 一次发现尝试**结束时**才提交:尝试进行中不提交(无证据 = 仍在进行)。 +/// - `tools_list_ok` 只允许真实成功的 live `tools/list` 提交;空数组是成功结果, +/// `Err` / 超时不得置位。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct DiscoveryEvidence { + pub generation: u64, + pub initialize_ok: bool, + pub tools_list_ok: bool, +} + +impl DiscoveryEvidence { + /// initialize 失败(transport / lifecycle / 能力协商);`tools/list` 未执行。 + pub(crate) fn initialize_failed(generation: u64) -> Self { + Self { + generation, + initialize_ok: false, + tools_list_ok: false, + } + } + + /// initialize 成功但 live `tools/list` 失败:这不是「空清单」,也不是完成。 + pub(crate) fn discovery_failed(generation: u64) -> Self { + Self { + generation, + initialize_ok: true, + tools_list_ok: false, + } + } + + /// initialize 与 live `tools/list` 都成功(清单可以为空数组)。 + pub(crate) fn discovered(generation: u64) -> Self { + Self { + generation, + initialize_ok: true, + tools_list_ok: true, + } + } + + /// 是否构成本代的完整发现证据(`generation == 0` 永远不算)。 + pub(crate) fn is_complete(&self) -> bool { + self.generation != 0 && self.initialize_ok && self.tools_list_ok + } +} + +/// System MCP 要求(来自已发布的配置清单)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SystemMcpRequirement { + pub server: String, + /// 在所属 server 的**原始工具名**上精确匹配的必需工具;空数组表示只要求 ready。 + pub required_tools: Vec, + /// 该 server 的启动等待上限(从入场时间起算)。 + pub timeout: Duration, +} + +impl SystemMcpRequirement { + fn timeout_ms(&self) -> u64 { + u64::try_from(self.timeout.as_millis()).unwrap_or(u64::MAX) + } +} + +/// 本代协商完成的 System MCP(transport + lifecycle + 能力协商 + `tools/list`)。 +/// +/// `generation` 是 `handle` 在 pool 中的登记代际;调用方在后续任何异步步骤之后 +/// 必须重新核对代际(`pool.handle_generation`)与 pool 开闭状态,不得把本结构 +/// 当作长期有效的 ready。 +#[derive(Clone)] +pub(crate) struct NegotiatedSystemMcp { + pub requirement: SystemMcpRequirement, + pub handle: Arc, + pub generation: u64, +} + +impl std::fmt::Debug for NegotiatedSystemMcp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NegotiatedSystemMcp") + .field("server", &self.requirement.server) + .field("generation", &self.generation) + .finish_non_exhaustive() + } +} + +/// System MCP 启动准入错误(变体全集冻结于 sub-plan B §4.4;主 plan IF-M3 追加两条 +/// 硬约束:`Cancelled → AgentError::Interrupted`、timeout 不是 cancel)。 +/// +/// 全部变体的 Display 都是**固定模板**:不含 `ClientStatus::Failed` 原文、env、 +/// headers、URL 认证信息或协议 payload;`{server}` 经 [`safe_server_label`] 清洗。 +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub(crate) enum SystemReadinessError { + #[error("System MCP 启动失败:配置清单在 30000ms 内未就绪")] + ConfigurationUnavailable, + #[error("System MCP 启动失败:配置加载或校验失败")] + ConfigurationFailed, + #[error("System MCP 启动失败:连接池正在关闭或已关闭")] + PoolClosed, + #[error( + "System MCP \"{}\" 启动失败:服务器已禁用", + safe_server_label(.server) + )] + Disabled { server: String }, + #[error( + "System MCP \"{}\" 启动失败:需要完成授权", + safe_server_label(.server) + )] + AuthorizationRequired { server: String }, + #[error( + "System MCP \"{}\" 启动失败:transport 或协议初始化失败", + safe_server_label(.server) + )] + ConnectionFailed { server: String }, + #[error( + "System MCP \"{}\" 启动失败:缺少有效协议协商证据", + safe_server_label(.server) + )] + NegotiationIncomplete { server: String }, + #[error( + "System MCP \"{}\" 启动失败:tools/list 失败", + safe_server_label(.server) + )] + ToolDiscoveryFailed { server: String }, + #[error( + "System MCP \"{}\" 启动失败:连接代际已变化,请重试本次输入", + safe_server_label(.server) + )] + ConnectionChanged { server: String }, + #[error( + "System MCP \"{}\" 启动超时({}ms),未发布 ready", + safe_server_label(.server), + .timeout_ms + )] + Timeout { server: String, timeout_ms: u64 }, + #[error("System MCP 启动已取消")] + Cancelled, + #[error("System MCP 启动失败:{source}")] + RequiredTools { + #[source] + source: SystemToolError, + }, + #[error("System MCP 启动失败:工具目录发布被拒绝,未发布 ready")] + CatalogPublicationFailed, +} + +impl SystemReadinessError { + /// 映射到 Agent 边界错误(主 plan IF-M3 两条硬约束的落实点,B-03 在 + /// middleware 边界调用): + /// + /// - `Cancelled` → [`AgentError::Interrupted`](peri_agent::error::AgentError::Interrupted) + /// (取消不是失败); + /// - 其它(**含 timeout**)→ + /// [`AgentError::MiddlewareError`](peri_agent::error::AgentError::MiddlewareError), + /// reason 取本枚举的固定安全文案(`Middleware error: {middleware} - …`)。 + /// timeout 不是取消,不得映射 `Interrupted`。 + pub(crate) fn into_agent_error(self, middleware: &str) -> peri_agent::error::AgentError { + match self { + SystemReadinessError::Cancelled => peri_agent::error::AgentError::Interrupted, + other => peri_agent::error::AgentError::MiddlewareError { + middleware: middleware.to_string(), + reason: other.to_string(), + }, + } + } +} + +/// 独立的 readiness 事实与 watch revision。 +/// +/// 使用独立 watch(**不**复用 UI notifier):等待方先 `subscribe` 再读事实,任何 +/// 变化都会唤醒重读;不持 parking_lot guard 跨 await。 +#[derive(Debug)] +pub(crate) struct SystemReadinessTracker { + manifest: parking_lot::RwLock, + evidence: parking_lot::Mutex>, + revision: tokio::sync::watch::Sender, +} + +impl Default for SystemReadinessTracker { + fn default() -> Self { + Self::new() + } +} + +impl SystemReadinessTracker { + pub(crate) fn new() -> Self { + let (revision, _) = tokio::sync::watch::channel(0_u64); + Self { + manifest: parking_lot::RwLock::new(SystemMcpManifest::Pending), + evidence: parking_lot::Mutex::new(HashMap::new()), + revision, + } + } + + /// 订阅变化通知:先订阅再读事实,订阅之后发生的写入不会丢失。 + pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver { + self.revision.subscribe() + } + + pub(crate) fn manifest(&self) -> SystemMcpManifest { + *self.manifest.read() + } + + /// 发布配置清单事实(完整发布 / 加载失败都只唤醒等待方,不产生 ready)。 + pub(crate) fn publish_manifest(&self, manifest: SystemMcpManifest) { + if *self.manifest.read() == manifest { + return; + } + *self.manifest.write() = manifest; + self.bump(); + } + + pub(crate) fn evidence(&self, server: &str) -> Option { + self.evidence.lock().get(server).copied() + } + + /// 提交本代发现证据(只允许由成功/失败的**生产发现路径**调用)。 + pub(crate) fn commit_evidence(&self, server: &str, evidence: DiscoveryEvidence) { + self.evidence.lock().insert(server.to_string(), evidence); + self.bump(); + } + + /// 使某个 server 的证据失效(reconnect / disable / remove / 失败重建)。 + /// + /// 无论此前是否存在证据都唤醒等待方:新事实必须让等待方立刻重读,而不是 + /// 睡到 deadline。 + pub(crate) fn clear_evidence(&self, server: &str) { + self.evidence.lock().remove(server); + self.bump(); + } + + /// 使全部证据失效(pool 关闭等全局事实)。 + pub(crate) fn clear_all_evidence(&self) { + self.evidence.lock().clear(); + self.bump(); + } + + fn bump(&self) { + self.revision + .send_modify(|revision| *revision = revision.wrapping_add(1)); + } +} + +/// 当前登记代际;句柄不在表中时返回 `None`(`0` 是「未登记」哨兵,不算身份)。 +fn current_generation(pool: &McpClientPool, server: &str) -> Option { + pool.get_client(server) + .map(|handle| pool.handle_generation(&handle)) +} + +impl McpClientPool { + /// 发布 System 配置清单事实。**owner:B-02**(`run_initialize`)。 + /// + /// 契约:完整 `configs`(含空集合、含全部 disabled)一次性写入后发布 + /// `Loaded`;配置加载/校验失败发布 `Failed`。发布 `Loaded` 前 `configs` + /// 不是可信的 System 依赖事实源。 + pub(crate) fn publish_system_manifest(&self, manifest: SystemMcpManifest) { + self.system_readiness.publish_manifest(manifest); + } + + pub(crate) fn system_manifest(&self) -> SystemMcpManifest { + self.system_readiness.manifest() + } + + /// 提交本代发现证据。**owner:B-02**(initialize / reconnect / OAuth 成功路径)。 + pub(crate) fn commit_discovery_evidence(&self, server: &str, evidence: DiscoveryEvidence) { + self.system_readiness.commit_evidence(server, evidence); + } + + /// 使发现证据失效。**owner:B-02**(重连、重新发现、失败重建前调用)。 + pub(crate) fn clear_discovery_evidence(&self, server: &str) { + self.system_readiness.clear_evidence(server); + } + + pub(crate) fn discovery_evidence(&self, server: &str) -> Option { + self.system_readiness.evidence(server) + } + + /// 已发布配置清单中的 System 依赖(`system_mcp == Some(true)`),按 server 名排序 + /// 保证遍历确定性(HashMap 顺序不可依赖)。 + /// + /// 必需工具直接取配置声明(`None` 与 `Some([])` 都是「不注入额外工具」), + /// 超时取 `system_mcp_timeout` 缺省 [`McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS`]。 + pub(crate) fn system_requirements(&self) -> Vec { + let mut requirements: Vec = self + .configs + .read() + .iter() + .filter(|(_, config)| config.system_mcp == Some(true)) + .map(|(server, config)| SystemMcpRequirement { + server: server.clone(), + required_tools: config.system_mcp_tools.clone().unwrap_or_default(), + timeout: system_timeout(config), + }) + .collect(); + requirements.sort_by(|left, right| left.server.cmp(&right.server)); + requirements + } + + /// 冻结签名(主 plan IF-M3 / sub-plan B §4.2): + /// + /// ```ignore + /// pub(crate) async fn await_system_connections( + /// self: &Arc, + /// cancel: &peri_agent::agent::AgentCancellationToken, + /// started_at: tokio::time::Instant, + /// ) -> Result, SystemReadinessError>; + /// ``` + /// + /// 语义: + /// - 只等待 `system_mcp == Some(true)` 的 server;普通 MCP(`None` / `false`) + /// 无论 pending 还是 failed 都不阻塞。 + /// - 清单 `Pending` 时按缺省 30000ms bootstrap 上限等待;`Failed` 立即 + /// `ConfigurationFailed`,超时为 `ConfigurationUnavailable`。 + /// - 已知失败(Disabled / 需要授权 / transport 失败 / 发现失败 / 无协议证据) + /// 立即返回;未知/连接中等待到各自的 deadline(从 `started_at` 起算)。 + /// - 已证明完成过的 server 若代际变化,返回 `ConnectionChanged`,不无限重试。 + /// - 取消返回 `Cancelled`(middleware 边界映射 `AgentError::Interrupted`); + /// timeout 不是取消,映射 fatal。 + pub(crate) async fn await_system_connections( + self: &Arc, + cancel: &AgentCancellationToken, + // 冻结签名:`started_at` 为 `tokio::time::Instant`(本模块 `Instant` 即该类型)。 + started_at: tokio::time::Instant, + ) -> Result, SystemReadinessError> { + // 先订阅再读事实:订阅之后的任何变化都不会丢。 + let mut waiter = self.system_readiness.subscribe(); + let mut satisfied: HashMap = HashMap::new(); + loop { + if cancel.is_cancelled() { + return Err(SystemReadinessError::Cancelled); + } + if !self.is_open() { + return Err(SystemReadinessError::PoolClosed); + } + match self.system_readiness.manifest() { + SystemMcpManifest::Failed => { + return Err(SystemReadinessError::ConfigurationFailed); + } + SystemMcpManifest::Pending => { + let deadline = started_at + system_bootstrap_timeout(); + if tokio::time::Instant::now() >= deadline { + return Err(SystemReadinessError::ConfigurationUnavailable); + } + wait_for_readiness_change(&mut waiter, cancel, deadline).await?; + continue; + } + SystemMcpManifest::Loaded => {} + } + + let requirements = self.system_requirements(); + if requirements.is_empty() { + // Loaded 且无 System 依赖:立即通过,不等普通 transport。 + return Ok(Vec::new()); + } + + let now = tokio::time::Instant::now(); + let mut negotiated = Vec::with_capacity(requirements.len()); + let mut next_deadline: Option = None; + for requirement in &requirements { + let deadline = started_at + requirement.timeout; + match self.evaluate_system_requirement(requirement)? { + Some(found) => { + satisfied.insert(found.requirement.server.clone(), found.generation); + negotiated.push(found); + } + None => { + // 已证明完成过、随后代际变化:不无限重试,交回调用方重试输入。 + if let Some(previous) = satisfied.get(&requirement.server) { + if current_generation(self, &requirement.server) != Some(*previous) { + return Err(SystemReadinessError::ConnectionChanged { + server: requirement.server.clone(), + }); + } + } + if now >= deadline { + return Err(SystemReadinessError::Timeout { + server: requirement.server.clone(), + timeout_ms: requirement.timeout_ms(), + }); + } + next_deadline = + Some(next_deadline.map_or(deadline, |current| current.min(deadline))); + } + } + } + if negotiated.len() == requirements.len() { + return Ok(negotiated); + } + let deadline = next_deadline.expect("pending requirement must carry its deadline"); + wait_for_readiness_change(&mut waiter, cancel, deadline).await?; + } + } + + /// 单台 System server 的 readiness 事实判定。 + /// + /// `Ok(None)` = 尚未完成(等各自 deadline);`Ok(Some(_))` = 本代协商完成; + /// `Err(_)` = 已知失败,立即终止等待。 + fn evaluate_system_requirement( + &self, + requirement: &SystemMcpRequirement, + ) -> Result, SystemReadinessError> { + let server = requirement.server.as_str(); + // 配置层禁用是确定事实,不是「连接中」:不等待、不跳过。 + let configured_disabled = self + .configs + .read() + .get(server) + .is_some_and(|config| config.disabled.unwrap_or(false)); + if configured_disabled { + return Err(SystemReadinessError::Disabled { + server: server.to_string(), + }); + } + // 短临界区取出句柄快照后不再持锁,避免与提交路径的锁序交错。 + let Some(handle) = self.get_client(server) else { + return Ok(None); + }; + match &handle.status { + ClientStatus::Disabled => Err(SystemReadinessError::Disabled { + server: server.to_string(), + }), + ClientStatus::Failed(_) if handle.oauth_status == OAuthStatus::NeedsAuthorization => { + Err(SystemReadinessError::AuthorizationRequired { + server: server.to_string(), + }) + } + // 失败原因只保留阶段类别:不把 `Failed(String)` 原文写进错误链。 + ClientStatus::Failed(_) => { + Err(if self.discovery_concluded_with_failure(server, &handle) { + SystemReadinessError::ToolDiscoveryFailed { + server: server.to_string(), + } + } else { + SystemReadinessError::ConnectionFailed { + server: server.to_string(), + } + }) + } + ClientStatus::Disconnected => Err(SystemReadinessError::ConnectionFailed { + server: server.to_string(), + }), + ClientStatus::Uninitialized => Ok(None), + ClientStatus::Connected => self.evaluate_connected_requirement(requirement, handle), + } + } + + /// `Connected` 分支:协议证据 → 本代发现证据 → 完成。 + fn evaluate_connected_requirement( + &self, + requirement: &SystemMcpRequirement, + handle: Arc, + ) -> Result, SystemReadinessError> { + let server = requirement.server.as_str(); + let generation = self.handle_generation(&handle); + // 光秃的 Connected(peer=None / 无 peer_info / transport 已关闭 / 未提交 + // service / 未登记代际)既不是协商完成,也不能当「连接中」无限等待。 + if !self.has_protocol_evidence(server, &handle, generation) { + return Err(SystemReadinessError::NegotiationIncomplete { + server: server.to_string(), + }); + } + match self.discovery_evidence(server) { + // 无证据 = 发现尝试仍在进行(生产路径在尝试结束时才提交证据)。 + None => Ok(None), + // 旧代证据:新代重新协商中,旧 Arc 永不被接受。 + Some(evidence) if evidence.generation != generation => Ok(None), + Some(evidence) if !evidence.initialize_ok => { + Err(SystemReadinessError::NegotiationIncomplete { + server: server.to_string(), + }) + } + Some(evidence) if !evidence.tools_list_ok => { + Err(SystemReadinessError::ToolDiscoveryFailed { + server: server.to_string(), + }) + } + Some(_) => Ok(Some(NegotiatedSystemMcp { + requirement: requirement.clone(), + handle, + generation, + })), + } + } + + /// 本代协议协商证据:有效 peer + 已协商 peer_info + transport 未关闭 + + /// 已提交 service + 已登记代际(`0` 是「未登记」哨兵)。 + fn has_protocol_evidence( + &self, + server: &str, + handle: &Arc, + generation: u64, + ) -> bool { + if generation == 0 { + return false; + } + let Some(peer) = handle.peer.as_ref() else { + return false; + }; + if peer.peer_info().is_none() || peer.is_transport_closed() { + return false; + } + self.service_committed(server) + } + + /// service 表内是否存在未进入关闭事务的已提交连接。 + fn service_committed(&self, server: &str) -> bool { + match self.services.lock().get(server) { + Some(McpServiceWrapper::Closing(_)) | Some(McpServiceWrapper::Closed) => false, + Some(_) => true, + None => false, + } + } + + /// 本代证据是否已经把这次发现判定为**失败**(initialize 成功但 `tools/list` 失败)。 + fn discovery_concluded_with_failure( + &self, + server: &str, + handle: &Arc, + ) -> bool { + let generation = self.handle_generation(handle); + self.discovery_evidence(server).is_some_and(|evidence| { + evidence.generation == generation && evidence.initialize_ok && !evidence.tools_list_ok + }) + } +} + +/// System 启动等待的 bootstrap 上限:清单未知时使用的缺省值。 +fn system_bootstrap_timeout() -> Duration { + Duration::from_millis(McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS) +} + +fn system_timeout(config: &McpServerConfig) -> Duration { + // 越界值在配置解析期已被 A 拒绝;此处防御性收敛,避免 `0ms` 退化成即时超时。 + let milliseconds = config + .system_mcp_timeout + .unwrap_or(McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS) + .clamp( + McpServerConfig::MIN_SYSTEM_MCP_TIMEOUT_MS, + McpServerConfig::MAX_SYSTEM_MCP_TIMEOUT_MS, + ); + Duration::from_millis(milliseconds) +} + +/// 等待 readiness 事实变化、取消或 deadline;返回后由调用方重读全部事实。 +/// +/// 三路都不持锁、不跨 await 持 guard;`Revision` 与 `Deadline` 的区分对调用方 +/// 无意义(重读是幂等的),因此统一返回 `Ok(())`。 +async fn wait_for_readiness_change( + waiter: &mut tokio::sync::watch::Receiver, + cancel: &AgentCancellationToken, + deadline: tokio::time::Instant, +) -> Result<(), SystemReadinessError> { + tokio::select! { + _ = cancel.cancelled() => Err(SystemReadinessError::Cancelled), + // 发送端随 pool 一起销毁:不再有可观察变化,按连接池关闭收口。 + changed = waiter.changed() => changed.map_err(|_| SystemReadinessError::PoolClosed), + _ = tokio::time::sleep_until(deadline) => Ok(()), + } +} + +#[cfg(test)] +#[path = "readiness_test.rs"] +mod tests; diff --git a/peri-middlewares/src/mcp/client/readiness_test.rs b/peri-middlewares/src/mcp/client/readiness_test.rs new file mode 100644 index 000000000..afee48c1b --- /dev/null +++ b/peri-middlewares/src/mcp/client/readiness_test.rs @@ -0,0 +1,756 @@ +//! Tests for System MCP 启动准入(IF-M3) +//! +//! 夹具策略(testing.md / sub-plan B §6.1):只把**外部对端**换成内存 JSON-RPC +//! 假 server,客户端侧走真实 `serve_client_auto`(真实 rmcp lifecycle、真实 +//! peer_info、真实 transport 关闭语义);发现证据由测试显式提交,等价于 B-02 +//! 在 initialize / reconnect / OAuth 路径上的提交点。每个用例按 +//! pool begin-close → shutdown → join 假 server 收尾,不留 orphan task。 + +use super::super::serve_client_auto; +use super::*; +use crate::mcp::apps::McpCapabilityProfile; +use peri_agent::error::AgentError; +use rmcp::{service::RoleClient, transport::async_rw::AsyncRwTransport}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf}, + task::JoinHandle, +}; + +// ─── fixtures ───────────────────────────────────────────────────────────────── + +enum FakeInitialize { + /// 回退 legacy initialize 并返回成功结果(Auto 生命周期先试 `server/discover`)。 + Legacy, + /// initialize 回 JSON-RPC error:真实 SDK 握手失败。 + Error, +} + +/// 假 MCP 对端:`server/discover` 一律失败,initialize 按参数返回。 +fn spawn_fake_peer(server: DuplexStream, initialize: FakeInitialize) -> JoinHandle<()> { + let (server_read, mut server_write) = tokio::io::split(server); + tokio::spawn(async move { + let mut lines = BufReader::new(server_read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let request: serde_json::Value = match serde_json::from_str(&line) { + Ok(request) => request, + Err(_) => continue, + }; + let response = match request["method"].as_str() { + Some("server/discover") => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], + "error": { "code": -32601, "message": "Method not found" } + }), + Some("initialize") => match initialize { + FakeInitialize::Legacy => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], "result": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "readiness-fixture", "version": "1" } + } + }), + FakeInitialize::Error => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], + "error": { "code": -32603, "message": "fixture initialize failure" } + }), + }, + _ => continue, + }; + if server_write + .write_all(format!("{response}\n").as_bytes()) + .await + .is_err() + { + break; + } + if server_write.flush().await.is_err() { + break; + } + } + }) +} + +type FixtureTransport = + AsyncRwTransport, WriteHalf>; + +fn fixture_transport(client: DuplexStream) -> FixtureTransport { + let (read, write) = tokio::io::split(client); + AsyncRwTransport::new(read, write) +} + +fn system_config(required_tools: Option>, timeout_ms: Option) -> McpServerConfig { + McpServerConfig { + command: Some("readiness-fixture".to_string()), + args: None, + env: None, + url: None, + headers: None, + oauth: None, + disabled: None, + protocol_version: None, + subscriptions: None, + system_mcp: Some(true), + system_mcp_tools: required_tools, + system_mcp_timeout: timeout_ms, + source: None, + } +} + +fn ordinary_config() -> McpServerConfig { + McpServerConfig { + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, + ..system_config(None, None) + } +} + +struct SystemFixture { + pool: Arc, + servers: Vec>, +} + +impl SystemFixture { + fn new() -> Self { + Self { + pool: Arc::new(McpClientPool::new_pending()), + servers: Vec::new(), + } + } + + fn pool(&self) -> &Arc { + &self.pool + } + + fn config(&self, name: &str, config: McpServerConfig) { + self.pool.configs.write().insert(name.to_string(), config); + } + + fn publish_loaded(&self) { + self.pool.publish_system_manifest(SystemMcpManifest::Loaded); + } + + fn commit_evidence(&self, name: &str, evidence: DiscoveryEvidence) { + self.pool.commit_discovery_evidence(name, evidence); + } + + fn handle(name: &str, peer: Option>) -> Arc { + Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer, + tools: vec![], + resources: vec![], + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }) + } + + /// 真实握手 + 提交连接;返回 (句柄, 已登记代际)。 + async fn connect(&mut self, name: &str) -> (Arc, u64) { + let (client, server) = tokio::io::duplex(4096); + self.servers + .push(spawn_fake_peer(server, FakeInitialize::Legacy)); + let service = serve_client_auto( + fixture_transport(client), + None, + None, + &McpCapabilityProfile::default(), + Duration::from_secs(5), + ) + .await + .expect("fixture 握手不允许超时") + .expect("fixture 握手不允许失败"); + let service = self.pool.retain_service(service); + let peer = service.peer().clone(); + let handle = Self::handle(name, Some(peer)); + assert!( + handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .is_some(), + "fixture 必须完成真实 peer_info 协商" + ); + let committed = + self.pool + .try_commit_connection(name.to_string(), Arc::clone(&handle), service); + assert!(committed.is_ok(), "fixture 连接必须被 pool 接受"); + let generation = self.pool.handle_generation(&handle); + assert_ne!(generation, 0, "fixture 连接必须有登记代际"); + (handle, generation) + } + + /// 真实 initialize 失败:假 server 回 JSON-RPC error,随后按生产同款收口 + /// API 提交失败事实(`insert_failed` + 失败证据)。不读底层错误文案。 + async fn fail_initialize(&mut self, name: &str) { + let (client, server) = tokio::io::duplex(4096); + self.servers + .push(spawn_fake_peer(server, FakeInitialize::Error)); + let outcome = serve_client_auto( + fixture_transport(client), + None, + None, + &McpCapabilityProfile::default(), + Duration::from_secs(5), + ) + .await; + assert!( + matches!(outcome, Ok(Err(_)) | Err(_)), + "fixture initialize 必须真实失败" + ); + McpClientPool::insert_failed(self.pool(), name, "fixture: initialize failed".to_string()); + let handle = self.pool.get_client(name).expect("失败句柄必须落表"); + let generation = self.pool.handle_generation(&handle); + self.commit_evidence(name, DiscoveryEvidence::initialize_failed(generation)); + } + + async fn shutdown(self) { + self.pool.begin_shutdown(); + let _ = self.pool.shutdown().await; + for task in self.servers { + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("假 server 必须随连接关闭退出") + .expect("假 server 任务不得 panic"); + } + assert!(!self.pool.is_open()); + } +} + +/// 断言在给定时限内**不返回** ready:未就绪状态只能停在等待。 +async fn assert_pending( + pool: &Arc, + cancel: &AgentCancellationToken, + started_at: tokio::time::Instant, +) { + let outcome = tokio::time::timeout( + Duration::from_millis(50), + pool.await_system_connections(cancel, started_at), + ) + .await; + assert!(outcome.is_err(), "未就绪状态不得返回 ready: {outcome:?}"); +} + +/// 有界等待成功:1s 内必须返回 negotiated。 +async fn await_ready( + pool: &Arc, + cancel: &AgentCancellationToken, + started_at: tokio::time::Instant, +) -> Vec { + tokio::time::timeout( + Duration::from_secs(1), + pool.await_system_connections(cancel, started_at), + ) + .await + .expect("ready 路径不得超时") + .expect("ready 路径不得返回错误") +} + +/// 有界等待失败:1s 内必须返回类型化错误(证明不是睡到 deadline)。 +async fn await_error( + pool: &Arc, + cancel: &AgentCancellationToken, + started_at: tokio::time::Instant, +) -> SystemReadinessError { + tokio::time::timeout( + Duration::from_secs(1), + pool.await_system_connections(cancel, started_at), + ) + .await + .expect("已知失败必须立即返回,不得等待到 deadline") + .expect_err("该场景不得返回 ready") +} + +// ─── 等待与证据 ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn system_ready_waits_for_initialize_and_tools_list() { + let mut fixture = SystemFixture::new(); + fixture.config("sys", system_config(Some(vec![]), Some(5_000))); + fixture.publish_loaded(); + let cancel = AgentCancellationToken::new(); + let started_at = tokio::time::Instant::now(); + + // 阶段 1:transport / initialize 尚未完成(无句柄)→ 不返回 ready。 + assert_pending(fixture.pool(), &cancel, started_at).await; + + // 阶段 2:真实协商已成功(Connected + peer_info),但 live tools/list 尚未 + // 结束(生产路径此时不提交证据)→ 仍不返回 ready。 + let (_handle, generation) = fixture.connect("sys").await; + assert_pending(fixture.pool(), &cancel, started_at).await; + + // 阶段 3:成功路径提交本代发现证据(必需工具为空数组也是成功结果)。 + fixture.commit_evidence("sys", DiscoveryEvidence::discovered(generation)); + let negotiated = await_ready(fixture.pool(), &cancel, started_at).await; + assert_eq!(negotiated.len(), 1); + assert_eq!(negotiated[0].requirement.server, "sys"); + assert_eq!( + negotiated[0].requirement.required_tools, + Vec::::new() + ); + assert_eq!(negotiated[0].generation, generation); + assert!(negotiated[0].handle.tools.is_empty()); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_rejects_initialize_error() { + let mut fixture = SystemFixture::new(); + fixture.config("sys", system_config(None, Some(5_000))); + fixture.publish_loaded(); + fixture.fail_initialize("sys").await; + + let cancel = AgentCancellationToken::new(); + let error = await_error(fixture.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!( + error, + SystemReadinessError::ConnectionFailed { + server: "sys".to_string() + } + ); + // 失败文案不含底层原因原文,也不含任何传输细节。 + assert!(!error.to_string().contains("fixture")); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_rejects_tools_list_error_instead_of_empty() { + let mut fixture = SystemFixture::new(); + // 必需工具显式为空数组:list 失败仍然必须是失败,不是「空清单 ready」。 + fixture.config("sys", system_config(Some(vec![]), Some(5_000))); + fixture.publish_loaded(); + let (_handle, generation) = fixture.connect("sys").await; + fixture.commit_evidence("sys", DiscoveryEvidence::discovery_failed(generation)); + + let cancel = AgentCancellationToken::new(); + let error = await_error(fixture.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!( + error, + SystemReadinessError::ToolDiscoveryFailed { + server: "sys".to_string() + } + ); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_rejects_stale_generation_evidence() { + let mut fixture = SystemFixture::new(); + fixture.config("sys", system_config(None, Some(5_000))); + fixture.publish_loaded(); + let (_handle, generation) = fixture.connect("sys").await; + let cancel = AgentCancellationToken::new(); + let started_at = tokio::time::Instant::now(); + + // 旧代 / 非本代证据(含 generation 不匹配)不得被接受。 + fixture.commit_evidence("sys", DiscoveryEvidence::discovered(generation + 41)); + assert_pending(fixture.pool(), &cancel, started_at).await; + + fixture.commit_evidence("sys", DiscoveryEvidence::discovered(generation)); + let negotiated = await_ready(fixture.pool(), &cancel, started_at).await; + assert_eq!(negotiated[0].generation, generation); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_rejects_connected_without_protocol_evidence() { + let fixture = SystemFixture::new(); + fixture.config("sys", system_config(None, Some(5_000))); + fixture.publish_loaded(); + + // 旧式手工 Connected fixture:peer=None。即便"补齐"完整证据与登记代际, + // 无 peer / peer_info / 已提交 service 就不是协商完成,也不能当连接中等待。 + let handle = SystemFixture::handle("sys", None); + fixture.pool.advance_handle_generation(&handle); + let generation = fixture.pool.handle_generation(&handle); + assert_ne!(generation, 0); + fixture + .pool + .clients + .write() + .insert("sys".to_string(), handle); + fixture.commit_evidence("sys", DiscoveryEvidence::discovered(generation)); + + let cancel = AgentCancellationToken::new(); + let error = await_error(fixture.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!( + error, + SystemReadinessError::NegotiationIncomplete { + server: "sys".to_string() + } + ); + + fixture.shutdown().await; +} + +// ─── 清单与范围 ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn system_ready_ignores_non_system_pending_and_failed() { + let mut fixture = SystemFixture::new(); + fixture.config("sys", system_config(None, Some(5_000))); + fixture.config("ordinary-pending", ordinary_config()); + fixture.config("ordinary-failed", ordinary_config()); + fixture.publish_loaded(); + McpClientPool::insert_failed( + fixture.pool(), + "ordinary-failed", + "fixture ordinary failure".to_string(), + ); + + let requirements = fixture.pool().system_requirements(); + assert_eq!(requirements.len(), 1); + assert_eq!(requirements[0].server, "sys"); + + let (_handle, generation) = fixture.connect("sys").await; + fixture.commit_evidence("sys", DiscoveryEvidence::discovered(generation)); + + let cancel = AgentCancellationToken::new(); + let negotiated = await_ready(fixture.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!(negotiated.len(), 1); + assert_eq!(negotiated[0].requirement.server, "sys"); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_does_not_treat_unloaded_manifest_as_empty() { + let cancel = AgentCancellationToken::new(); + + // Pending + 空 configs:空 map 不是「无 System MCP」的证据,不得放行。 + let pending = SystemFixture::new(); + assert_eq!(pending.pool().system_manifest(), SystemMcpManifest::Pending); + assert_pending(pending.pool(), &cancel, tokio::time::Instant::now()).await; + + // Loaded(empty) 才通过,且立即通过(不等普通 transport)。 + pending.publish_loaded(); + let negotiated = await_ready(pending.pool(), &cancel, tokio::time::Instant::now()).await; + assert!(negotiated.is_empty()); + pending.shutdown().await; + + // 配置加载/校验失败:明确 Err,不是空集合。 + let failed = SystemFixture::new(); + failed + .pool() + .publish_system_manifest(SystemMcpManifest::Failed); + let error = await_error(failed.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!(error, SystemReadinessError::ConfigurationFailed); + failed.shutdown().await; +} + +#[test] +fn system_requirements_flatten_options_and_sort_by_server() { + let pool = McpClientPool::new_pending(); + pool.configs.write().insert( + "sys-b".to_string(), + system_config(Some(vec!["b".to_string()]), None), + ); + pool.configs.write().insert( + "sys-a".to_string(), + system_config(Some(vec![]), Some(1_500)), + ); + pool.configs + .write() + .insert("sys-off".to_string(), ordinary_config()); + + let requirements = pool.system_requirements(); + assert_eq!( + requirements + .iter() + .map(|r| r.server.as_str()) + .collect::>(), + vec!["sys-a", "sys-b"], + "遍历顺序必须确定,不受 HashMap 顺序影响" + ); + assert_eq!(requirements[0].required_tools, Vec::::new()); + assert_eq!(requirements[0].timeout, Duration::from_millis(1_500)); + assert_eq!( + requirements[1].timeout, + Duration::from_millis(McpServerConfig::DEFAULT_SYSTEM_MCP_TIMEOUT_MS) + ); + assert_eq!(requirements[1].required_tools, vec!["b".to_string()]); +} + +// ─── 失效、代际与 deadline ─────────────────────────────────────────────────── + +#[tokio::test] +async fn system_ready_reports_generation_change_after_proven_readiness() { + let mut fixture = SystemFixture::new(); + fixture.config("sys-a", system_config(None, Some(3_000))); + fixture.config("sys-b", system_config(None, Some(3_000))); + fixture.publish_loaded(); + let (_handle, generation) = fixture.connect("sys-a").await; + fixture.commit_evidence("sys-a", DiscoveryEvidence::discovered(generation)); + + let pool = Arc::clone(fixture.pool()); + let cancel = AgentCancellationToken::new(); + let started_at = tokio::time::Instant::now(); + let waiter = { + let cancel = cancel.clone(); + tokio::spawn(async move { pool.await_system_connections(&cancel, started_at).await }) + }; + // current-thread runtime:spawn 后让出一次,waiter 的首次评估(全同步)必然 + // 走完并 park 在 watch 上——此时 sys-a 已被证明完成、sys-b 仍 pending。 + tokio::task::yield_now().await; + assert!(!waiter.is_finished(), "waiter 必须停在 sys-b 上等待"); + + // 重连式换代:提交新代句柄 → 失效旧证据。已证明完成过的 server 换代后不再 + // 重试(不无限等待),交回调用方重试本次输入。 + let previous = fixture + .pool() + .get_client("sys-a") + .expect("旧代句柄必须存在"); + let replacement = SystemFixture::handle("sys-a", previous.peer.clone()); + let service = fixture + .pool() + .services + .lock() + .remove("sys-a") + .expect("旧代 service 必须存在"); + let committed = fixture.pool().try_commit_connection( + "sys-a".to_string(), + Arc::clone(&replacement), + service, + ); + assert!(committed.is_ok(), "换代连接必须被 pool 接受"); + let next_generation = fixture.pool().handle_generation(&replacement); + assert_ne!(next_generation, generation); + fixture.pool().clear_discovery_evidence("sys-a"); + + let outcome = waiter.await.expect("waiter 不得 panic"); + assert_eq!( + outcome.expect_err("旧代 ready 不得被复用"), + SystemReadinessError::ConnectionChanged { + server: "sys-a".to_string() + } + ); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_rejects_closed_pool_and_cancellation() { + // 取消:与 timeout 是不同事实。 + let cancelled = SystemFixture::new(); + cancelled.config("sys", system_config(None, Some(5_000))); + cancelled.publish_loaded(); + let cancel = AgentCancellationToken::new(); + cancel.cancel(); + let error = await_error(cancelled.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!(error, SystemReadinessError::Cancelled); + cancelled.shutdown().await; + + // 关闭中的连接池:不等 deadline,直接 PoolClosed。 + let closed = SystemFixture::new(); + closed.config("sys", system_config(None, Some(5_000))); + closed.publish_loaded(); + closed.pool().begin_shutdown(); + let cancel = AgentCancellationToken::new(); + let error = await_error(closed.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!(error, SystemReadinessError::PoolClosed); + closed.shutdown().await; +} + +#[tokio::test] +async fn system_ready_timeout_is_terminal_without_fallback() { + let mut fixture = SystemFixture::new(); + fixture.config("sys", system_config(None, Some(60))); + fixture.publish_loaded(); + let (_handle, generation) = fixture.connect("sys").await; + + let cancel = AgentCancellationToken::new(); + let started_at = tokio::time::Instant::now(); + let error = await_error(fixture.pool(), &cancel, started_at).await; + assert_eq!( + error, + SystemReadinessError::Timeout { + server: "sys".to_string(), + timeout_ms: 60, + } + ); + // 不是取消:timeout 必须映射 fatal,不能映射 Interrupted。 + assert!(matches!( + error.into_agent_error("McpMiddleware"), + AgentError::MiddlewareError { .. } + )); + + // 迟到成功不得把已失败的结果翻成成功;新一次入场可以重新准入。 + fixture.commit_evidence("sys", DiscoveryEvidence::discovered(generation)); + let negotiated = await_ready(fixture.pool(), &cancel, started_at).await; + assert_eq!(negotiated.len(), 1); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_parallel_deadlines_do_not_accumulate() { + let mut fixture = SystemFixture::new(); + fixture.config("sys-fast", system_config(None, Some(60))); + fixture.config("sys-slow", system_config(None, Some(3_000))); + fixture.publish_loaded(); + let (_fast, _fast_generation) = fixture.connect("sys-fast").await; + let (_slow, _slow_generation) = fixture.connect("sys-slow").await; + + let cancel = AgentCancellationToken::new(); + let started_at = tokio::time::Instant::now(); + let error = await_error(fixture.pool(), &cancel, started_at).await; + assert_eq!( + error, + SystemReadinessError::Timeout { + server: "sys-fast".to_string(), + timeout_ms: 60, + } + ); + assert!( + started_at.elapsed() < Duration::from_secs(1), + "deadline 必须从同一次入场时间并发计算(sys-slow 不得叠加)" + ); + + fixture.shutdown().await; +} + +#[tokio::test] +async fn system_ready_reports_disabled_and_authorization_required() { + let cancel = AgentCancellationToken::new(); + + // 禁用是确定事实:不等待、不跳过。 + let disabled = SystemFixture::new(); + let mut config = system_config(None, Some(5_000)); + config.disabled = Some(true); + disabled.config("sys", config); + disabled.publish_loaded(); + let error = await_error(disabled.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!( + error, + SystemReadinessError::Disabled { + server: "sys".to_string() + } + ); + disabled.shutdown().await; + + // 需要人工授权:AuthorizationRequired(不是「连接中」)。 + let auth = SystemFixture::new(); + auth.config("sys", system_config(None, Some(5_000))); + auth.publish_loaded(); + McpClientPool::insert_needs_auth( + auth.pool(), + "sys", + "fixture: authorization required".to_string(), + ); + let error = await_error(auth.pool(), &cancel, tokio::time::Instant::now()).await; + assert_eq!( + error, + SystemReadinessError::AuthorizationRequired { + server: "sys".to_string() + } + ); + auth.shutdown().await; +} + +// ─── 错误类型与文案 ─────────────────────────────────────────────────────────── + +#[test] +fn discovery_evidence_is_complete_only_with_nonzero_generation() { + assert!(!DiscoveryEvidence::default().is_complete()); + assert!(!DiscoveryEvidence::initialize_failed(7).is_complete()); + assert!(!DiscoveryEvidence::discovery_failed(7).is_complete()); + assert!(!DiscoveryEvidence::discovered(0).is_complete()); + assert!(DiscoveryEvidence::discovered(7).is_complete()); +} + +#[test] +fn system_readiness_error_display_is_secret_safe() { + // 危险形态:控制字符 + URL query 凭据(全部为虚构值)。 + let error = SystemReadinessError::Disabled { + server: "sys\u{0}\nhttps://mcp.example.invalid/endpoint?token=fixture-value".to_string(), + }; + let text = error.to_string(); + assert!(!text.contains('\n'), "文案不得包含换行: {text}"); + assert!(!text.contains('\u{0}'), "文案不得包含控制字符: {text}"); + assert!(!text.contains("fixture-value"), "文案不得包含凭据: {text}"); + assert!(text.contains("sys")); + assert!(text.contains("服务器已禁用")); + + // RequiredTools 保留 C 的 source 链与固定模板。 + let wrapped = SystemReadinessError::RequiredTools { + source: SystemToolError::MissingTool { + server: "sys".to_string(), + tool: "read".to_string(), + }, + }; + assert!(wrapped.to_string().starts_with("System MCP 启动失败:")); + assert!(std::error::Error::source(&wrapped).is_some()); +} + +#[test] +fn system_readiness_error_maps_cancellation_to_interrupted_only() { + assert!(matches!( + SystemReadinessError::Cancelled.into_agent_error("McpMiddleware"), + AgentError::Interrupted + )); + + let mapped = SystemReadinessError::ConnectionFailed { + server: "sys".to_string(), + } + .into_agent_error("McpMiddleware"); + match mapped { + AgentError::MiddlewareError { middleware, reason } => { + assert_eq!(middleware, "McpMiddleware"); + assert!(reason.contains("transport 或协议初始化失败")); + } + other => panic!("非取消错误必须映射 fatal: {other:?}"), + } +} + +#[test] +fn system_readiness_error_carries_the_frozen_status_set() { + // 冻结变体全集(sub-plan B §4.4):任何缺失都会在这里编译失败。 + let variants = [ + SystemReadinessError::ConfigurationUnavailable, + SystemReadinessError::ConfigurationFailed, + SystemReadinessError::PoolClosed, + SystemReadinessError::Disabled { + server: "s".to_string(), + }, + SystemReadinessError::AuthorizationRequired { + server: "s".to_string(), + }, + SystemReadinessError::ConnectionFailed { + server: "s".to_string(), + }, + SystemReadinessError::NegotiationIncomplete { + server: "s".to_string(), + }, + SystemReadinessError::ToolDiscoveryFailed { + server: "s".to_string(), + }, + SystemReadinessError::ConnectionChanged { + server: "s".to_string(), + }, + SystemReadinessError::Timeout { + server: "s".to_string(), + timeout_ms: 1, + }, + SystemReadinessError::Cancelled, + SystemReadinessError::RequiredTools { + source: SystemToolError::NotModelVisible { + server: "s".to_string(), + tool: "t".to_string(), + }, + }, + SystemReadinessError::CatalogPublicationFailed, + ]; + assert_eq!(variants.len(), 13); + for variant in variants { + assert!(!variant.to_string().is_empty()); + } +} diff --git a/peri-middlewares/src/mcp/client/status.rs b/peri-middlewares/src/mcp/client/status.rs index 9832afeb8..cf577fd76 100644 --- a/peri-middlewares/src/mcp/client/status.rs +++ b/peri-middlewares/src/mcp/client/status.rs @@ -103,6 +103,8 @@ impl McpClientPool { pool.clients.write().insert(name.to_string(), handle); old_status }; + // 新失败代际取代旧证据:等待方立即重读并得到 ConnectionFailed/ToolDiscoveryFailed。 + pool.system_readiness.clear_evidence(name); pool.record_status_change(name, old_status.as_ref()); peri_agent::metrics::emit( "mcp.error", @@ -149,6 +151,8 @@ impl McpClientPool { pool.clients.write().insert(name.to_string(), handle); old_status }; + // 需要授权是确定事实,不是「连接中」:本代证据失效并唤醒等待方。 + pool.system_readiness.clear_evidence(name); pool.record_status_change(name, old_status.as_ref()); } @@ -235,6 +239,18 @@ impl McpClientPool { result } + /// 会话可见的服务器清单([`Self::all_server_infos`] 的 ACP 归属过滤版)。 + /// + /// 会话内概览 / 发现面必须用它:ACP 连接属于声明它的会话,别的会话既不该 + /// 看见它的工具,也不该看见它的名字与状态。 + pub fn all_server_infos_visible_to(&self, session_id: Option<&str>) -> Vec { + let mut infos = self.all_server_infos(); + if let Some(session_id) = session_id { + infos.retain(|info| self.is_visible_to_session(&info.name, session_id)); + } + infos + } + // ── 状态变化统一出口(上下线通知) ────────────────────────────────────── /// 标记初始化完成。此后发生的状态变化才产生上下线通知(初始化期间的 @@ -254,10 +270,18 @@ impl McpClientPool { /// (`status_change_text`)写入 `pending_changes` 缓冲(McpMiddleware /// 经 before_model drain 后以 Info 消息推送进模型上下文),并调用 /// notifier 回调(发布 system-notification 给 TUI 通知面)。 + /// + /// 会话级 ACP 连接([`Self::is_visible_to_session`] 的归属条目)不在此列: + /// 缓冲与 notifier 都是**部署级**的(任一会话 drain 一次即清空),而这类 + /// 连接只属于声明它的会话,名字与状态进入共享面就是跨会话泄漏。其事实由 + /// 归属会话的首 turn 概览与 deferred 发现面呈现。 pub(crate) fn record_status_change(&self, name: &str, old: Option<&ClientStatus>) { if !self.initialized.load(std::sync::atomic::Ordering::SeqCst) { return; } + if self.acp_owners.read().contains_key(name) { + return; + } let Some(old) = old else { return }; let clients = self.clients.read(); let Some(handle) = clients.get(name) else { diff --git a/peri-middlewares/src/mcp/client_oauth.rs b/peri-middlewares/src/mcp/client_oauth.rs index 4c556fd5d..9d8e3ff6c 100644 --- a/peri-middlewares/src/mcp/client_oauth.rs +++ b/peri-middlewares/src/mcp/client_oauth.rs @@ -7,6 +7,10 @@ use super::{ McpServiceWrapper, OAuthStartDisposition, OAuthStatus, HTTP_CONNECT_TIMEOUT, SHUTDOWN_TIMEOUT, }, + initialize::{ + commit_discovery_failure, commit_discovery_success, downgrade_resource_listing, + fail_tool_discovery, list_discovered_tools, + }, oauth_flow::{OAuthFailureKind, OAuthFlowEvent, OAuthFlowManager}, }; @@ -162,6 +166,8 @@ impl McpClientPool { .get(server_name) .map(|c| c.status.clone()); self.clients.write().remove(server_name); + // 本次发现尝试开始:旧代证据立即作废,等待方按「仍在进行」重新判定。 + self.clear_discovery_evidence(server_name); // 使用认证传输层重新连接 let headers = cfg.headers.clone().unwrap_or_default(); @@ -180,9 +186,16 @@ impl McpClientPool { let rs = &service; let peer = rs.peer().clone(); let cache_version = self.install_peer_cache_version(server_name, &peer); - let tools = match self.list_all_tools_cached(server_name, &peer).await { + // 严格发现:`tools/list` 的 `Err` 不是「没有工具」。System MCP 走 + // 本次 live round-trip(不用历史缓存代替健康证据),失败即 + // ToolDiscoveryFailed,不提交 Connected。 + let tools = match list_discovered_tools(self, server_name, &peer, &cfg).await { Ok(tools) => tools, Err(source) => { + // 授权成功但 live `tools/list` 失败:不留下「无句柄」的 + // 模糊状态,显式记为 Failed + 本代 tools_list_ok = false, + // System 等待方据此立即判定,而不是等满 deadline。 + fail_tool_discovery(self, server_name, &source.to_string()); let error = McpPoolError::ToolDiscoveryFailed { server: server_name.to_string(), reason: source.to_string(), @@ -196,10 +209,13 @@ impl McpClientPool { return Err(error); } }; - let resources = self - .list_all_resources_cached(server_name, &peer) - .await - .unwrap_or_default(); + let resources = match self.list_all_resources_cached(server_name, &peer).await { + Ok(resources) => resources, + Err(error) => { + downgrade_resource_listing(server_name, &error.to_string()); + Vec::new() + } + }; let skills_capable = super::client::peer_declares_skills(&peer); let handle = Arc::new(McpClientHandle { name: server_name.to_string(), @@ -217,16 +233,21 @@ impl McpClientPool { channel_capable: false, skills_capable, }); + let committed = Arc::clone(&handle); if let Err(mut service) = self.try_commit_connection(server_name.to_string(), handle, service) { let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + // 提交被拒(pool 关闭):不留任何可被读成成功的证据。 + self.clear_discovery_evidence(server_name); return Err(McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: "MCP pool is closing".to_string(), }); } self.record_status_change(server_name, old_status.as_ref()); + // 唯一能提交本代发现证据的位置:live `tools/list` 已成功且句柄已登记代际。 + commit_discovery_success(self, server_name, &committed); return Ok(()); } Ok(Err(e)) => { @@ -243,6 +264,7 @@ impl McpClientPool { } else { Self::insert_failed(self, server_name, err_str.clone()); } + commit_discovery_failure(self, server_name, false); let error = McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: err_str, @@ -258,6 +280,7 @@ impl McpClientPool { Err(_) => { let msg = "连接超时".to_string(); Self::insert_failed(self, server_name, msg.clone()); + commit_discovery_failure(self, server_name, false); let error = McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: msg, diff --git a/peri-middlewares/src/mcp/client_test.rs b/peri-middlewares/src/mcp/client_test.rs index 905a124e1..a562af190 100644 --- a/peri-middlewares/src/mcp/client_test.rs +++ b/peri-middlewares/src/mcp/client_test.rs @@ -458,6 +458,9 @@ fn test_persistent_cache_is_disabled_for_authenticated_servers() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, }; let pool = McpClientPool::new_empty(); @@ -537,6 +540,9 @@ fn test_tools_cache_eligible_requires_version_and_allowed_policy() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, }; let pool = McpClientPool::new_empty(); diff --git a/peri-middlewares/src/mcp/config.rs b/peri-middlewares/src/mcp/config.rs index 51e27225a..166daa5e3 100644 --- a/peri-middlewares/src/mcp/config.rs +++ b/peri-middlewares/src/mcp/config.rs @@ -39,6 +39,19 @@ pub enum McpConfigError { #[source] source: std::io::Error, }, + /// typed 配置不满足契约不变量(含手工构造的 `McpServerConfig`)。 + #[error("MCP 服务器配置无效: {server_name}: {source}")] + InvalidServer { + server_name: String, + #[source] + source: peri_acp_types::plugin::McpServerConfigValidationError, + }, + /// 插件 MCP 配置加载失败(MCP 专用严格插件路径)。 + #[error("插件 MCP 配置加载失败: {source}")] + PluginLoadError { + #[source] + source: crate::plugin::loader::LoaderError, + }, } /// 从指定 JSON 文件加载 MCP 配置,文件不存在时返回空配置 @@ -56,7 +69,53 @@ pub(crate) fn load_from_path(path: &Path) -> Result Result, McpConfigError> { + serde_json::from_value::>(value.clone()).map_err(|source| { + McpConfigError::ParseError { + path: path.display().to_string(), + source, + } + }) +} + +/// 校验一段 `mcpServers` JSON:解析失败或任一 server 不满足契约即 Err。 +fn validate_servers_value(value: &serde_json::Value, path: &Path) -> Result<(), McpConfigError> { + let servers = parse_servers_value(value, path)?; + validate_config(&McpConfigFile { + mcp_servers: servers, + }) +} + +/// 校验 typed 配置的每个 server:按 server name 排序,首个错误稳定返回。 +/// +/// `disabled = true` 也照常校验——禁用不是绕过配置契约的通道。 +pub(crate) fn validate_config(config: &McpConfigFile) -> Result<(), McpConfigError> { + let mut names: Vec<&String> = config.mcp_servers.keys().collect(); + names.sort(); + for name in names { + if let Some(cfg) = config.mcp_servers.get(name) { + cfg.validate() + .map_err(|source| McpConfigError::InvalidServer { + server_name: name.clone(), + source, + })?; + } + } + Ok(()) +} + /// 从全局 settings.json 的 extra 字段中提取 mcpServers +/// +/// 两个候选 map(`config.mcpServers` 与顶层 `mcpServers`)都存在时**两者都先校验**: +/// 写入口可能操作的是备用 map,非法备用 map 不能静默通过;选择仍按既有优先级 +/// (nested > top-level)。 pub(crate) fn load_global_config( settings_json_path: &Path, ) -> Result { @@ -74,16 +133,19 @@ pub(crate) fn load_global_config( source: e, })?; // 从顶层 value 中提取 "config"."mcpServers" 或 "mcpServers" - let mcp_servers = v - .get("config") - .and_then(|c| c.get("mcpServers")) - .or_else(|| v.get("mcpServers")) - .cloned() - .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - let config = McpConfigFile { - mcp_servers: serde_json::from_value(mcp_servers).unwrap_or_default(), + let nested = v.get("config").and_then(|c| c.get("mcpServers")); + let top_level = v.get("mcpServers"); + let nested_servers = match nested { + Some(map) => Some(parse_servers_value(map, settings_json_path)?), + None => None, + }; + let top_level_servers = match top_level { + Some(map) => Some(parse_servers_value(map, settings_json_path)?), + None => None, }; - Ok(config) + Ok(McpConfigFile { + mcp_servers: nested_servers.or(top_level_servers).unwrap_or_default(), + }) } /// 基于 command+args+env 计算服务器配置的内容 hash,用于去重 @@ -111,6 +173,16 @@ pub(crate) fn server_config_hash(cfg: &McpServerConfig) -> u64 { if let Some(protocol_version) = &cfg.protocol_version { protocol_version.hash(&mut hasher); } + // System 启动依赖字段参与 hash:变更它们必须视为不同服务器。 + if let Some(system_mcp) = &cfg.system_mcp { + system_mcp.hash(&mut hasher); + } + if let Some(system_mcp_tools) = &cfg.system_mcp_tools { + system_mcp_tools.hash(&mut hasher); + } + if let Some(system_mcp_timeout) = &cfg.system_mcp_timeout { + system_mcp_timeout.hash(&mut hasher); + } hasher.finish() } @@ -209,6 +281,11 @@ pub(crate) fn expand_server_config_with_context( protocol_version: config.protocol_version, source: config.source.clone(), subscriptions: config.subscriptions.clone(), + // System key 原样复制:工具名数组是字面量,不得走 `expand`(否则 `${VAR}` + // 形态的工具名会被替换),`Some([])` 与 `None` 必须保持可区分。 + system_mcp: config.system_mcp, + system_mcp_tools: config.system_mcp_tools.clone(), + system_mcp_timeout: config.system_mcp_timeout, } } @@ -217,6 +294,21 @@ pub(crate) fn expand_server_config(config: &McpServerConfig) -> McpServerConfig expand_server_config_with_context(config, None, None, None) } +/// 加载并合并 MCP 配置:全局 + 插件 + 项目级三层合并(生产入口)。 +/// +/// 全局路径由 `~/.peri/settings.json` 决定;任何一层非法都返回错误, +/// 不降级为空配置。 +pub(crate) fn load_merged_config_full( + cwd: &Path, + claude_home: &Path, +) -> Result<(McpConfigFile, HashMap), McpConfigError> { + let global_path = dirs_next::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".peri") + .join("settings.json"); + load_merged_config_full_with_paths(cwd, claude_home, &global_path) +} + /// 加载并合并 MCP 配置:全局 + 插件 + 项目级三层合并 /// 优先级:global < plugin < project(项目级最高) /// 内容 hash 去重:手动配置(global/project)覆盖插件配置 @@ -224,36 +316,31 @@ pub(crate) fn expand_server_config(config: &McpServerConfig) -> McpServerConfig /// 返回合并后的配置 + plugin_sources(marketplace 追踪,用于 UI 展示插件来源) /// plugin_sources 的 key 格式为 `"plugin:{name}:{server}"`, /// 与工具名 `mcp__{plugin_name}__{server_name}` 中的 server 部分一致 -pub(crate) fn load_merged_config_full( +/// +/// 内部实现:允许注入全局路径(测试 seam)。加载顺序与校验顺序一致——被选择加载的 +/// global / plugin / project 输入先验证,再覆盖与去重;缺文件仍是空配置,非法文件不是。 +/// 插件来源走 MCP 专用严格入口(`load_enabled_plugins_for_mcp`),宽容聚合 API +/// 不作为启动输入。 +fn load_merged_config_full_with_paths( cwd: &Path, claude_home: &Path, -) -> (McpConfigFile, HashMap) { + global_path: &Path, +) -> Result<(McpConfigFile, HashMap), McpConfigError> { let mut plugin_sources: HashMap = HashMap::new(); // 1. 加载全局配置(~/.peri/settings.json) - let global_path = dirs_next::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".peri") - .join("settings.json"); - let mut global = load_global_config(&global_path).unwrap_or_else(|e| { - tracing::warn!( - path = %global_path.display(), - error = %e, - "加载全局 MCP 配置失败,跳过" - ); - McpConfigFile::default() - }); + let mut global = load_global_config(global_path)?; for cfg in global.mcp_servers.values_mut() { - cfg.source = Some(ConfigSource::Global(global_path.clone())); + cfg.source = Some(ConfigSource::Global(global_path.to_path_buf())); } - // 2. 加载插件 MCP 配置(~/.claude/ 目录下的已启用插件) - // 每插件独立上下文展开 env 变量,同时构建 plugin_sources(marketing 追踪) - let plugin_load_result = - crate::plugin::loader::load_enabled_plugins_aggregated(claude_home, None); + // 2. 加载插件 MCP 配置(claude_home 目录下的已启用插件) + // 每插件独立上下文展开 env 变量,同时构建 plugin_sources(marketplace 追踪) + let plugins = crate::plugin::loader::load_enabled_plugins_for_mcp(claude_home, None) + .map_err(|source| McpConfigError::PluginLoadError { source })?; let mut plugin_servers: HashMap = HashMap::new(); - for plugin in &plugin_load_result.plugins { + for plugin in &plugins { for (name, config) in &plugin.mcp_servers { let namespaced = format!("plugin:{}:{}", plugin.name, name); let mut cfg = config.clone(); @@ -293,19 +380,13 @@ pub(crate) fn load_merged_config_full( // 3. 加载项目级配置({cwd}/.mcp.json) let project_path = cwd.join(".mcp.json"); - let mut project = load_from_path(&project_path).unwrap_or_else(|e| { - tracing::warn!( - path = %project_path.display(), - error = %e, - "加载项目级 MCP 配置失败,跳过" - ); - McpConfigFile::default() - }); + let mut project = load_from_path(&project_path)?; for cfg in project.mcp_servers.values_mut() { cfg.source = Some(ConfigSource::Project(project_path.clone())); } // 4. 内容 hash 去重:移除与手动配置(global/project)内容相同的插件服务器 + // System MCP 不参与:其 namespace 归属必须保留,不得因跨 namespace 内容相同而消失。 let manual_hashes: std::collections::HashSet = global .mcp_servers .values() @@ -313,6 +394,9 @@ pub(crate) fn load_merged_config_full( .map(server_config_hash) .collect(); plugin_servers.retain(|_, cfg| { + if cfg.system_mcp == Some(true) { + return true; + } let hash = server_config_hash(cfg); if manual_hashes.contains(&hash) { tracing::debug!("插件 MCP 服务器与手动配置内容相同(hash 去重),已跳过"); @@ -345,13 +429,18 @@ pub(crate) fn load_merged_config_full( } } - (merged, plugin_sources) + // 7. 合并结果再次校验:覆盖与去重之后仍必须是合法配置。 + validate_config(&merged)?; + + Ok((merged, plugin_sources)) } -/// 加载并合并 MCP 配置(公开 API,向后兼容) -/// 等同于 `load_merged_config_full().0` -pub fn load_merged_config(cwd: &Path, claude_home: &Path) -> McpConfigFile { - load_merged_config_full(cwd, claude_home).0 +/// 加载并合并 MCP 配置(公开 API)。 +/// +/// 返回类型从 `McpConfigFile` 变为 `Result`:配置错误必须可传播,不再有 +/// fail-open 的兼容壳(非法配置曾被合并成「成功的空配置」)。 +pub fn load_merged_config(cwd: &Path, claude_home: &Path) -> Result { + Ok(load_merged_config_full(cwd, claude_home)?.0) } /// 原子写入 JSON 文件(先写临时文件,再 rename 替换) @@ -395,6 +484,10 @@ pub fn remove_server_from_config(cwd: &Path, server_name: &str) -> Result<(), Mc } /// 内部实现:允许注入全局路径(便于测试) +/// +/// 写入口语义:**修改前**校验全部相关 server map,**修改后**再次校验待写结果; +/// 任一步失败都不调用 `atomic_write_json`、不改动任何字节。删除非法条目也拒绝 +/// ——非法配置需先由用户修复,删除不是修复通道。 fn remove_server_from_config_with_paths( cwd: &Path, global_path: &Path, @@ -417,6 +510,7 @@ fn remove_server_from_config_with_paths( if config.mcp_servers.contains_key(server_name) { config.mcp_servers.remove(server_name); + validate_config(&config)?; let value = serde_json::to_value(&config).map_err(|e| McpConfigError::WriteError { path: project_path.display().to_string(), source: e.into(), @@ -440,6 +534,9 @@ fn remove_server_from_config_with_paths( source: e, })?; + // 全局支路只操作 Value:写盘前必须走一遍 typed 校验(两个候选 map 都查)。 + validate_value_servers(&value, global_path)?; + // 尝试 config.mcpServers 路径 let mut removed = false; if let Some(config) = value @@ -463,6 +560,7 @@ fn remove_server_from_config_with_paths( } if removed { + validate_value_servers(&value, global_path)?; atomic_write_json(global_path, &value)?; return Ok(()); } @@ -472,6 +570,17 @@ fn remove_server_from_config_with_paths( Ok(()) } +/// 校验全局 settings.json 的 Value 中所有存在的 `mcpServers` map +/// (nested 与 top-level 都查:写入口可能操作备用 map)。 +fn validate_value_servers(value: &serde_json::Value, path: &Path) -> Result<(), McpConfigError> { + let nested = value.get("config").and_then(|c| c.get("mcpServers")); + let top_level = value.get("mcpServers"); + for map in [nested, top_level].into_iter().flatten() { + validate_servers_value(map, path)?; + } + Ok(()) +} + /// 在配置文件中设置指定 MCP 服务器的 disabled 状态 /// 优先尝试项目级 .mcp.json,未找到则尝试全局 settings.json pub fn set_server_disabled( @@ -508,6 +617,10 @@ fn set_server_disabled_with_paths( source: e, })?; + if let Some(map) = value.get("mcpServers") { + validate_servers_value(map, &project_path)?; + } + if let Some(server_obj) = value .get_mut("mcpServers") .and_then(|s| s.get_mut(server_name)) @@ -518,6 +631,9 @@ fn set_server_disabled_with_paths( } else { server_obj.remove("disabled"); } + if let Some(map) = value.get("mcpServers") { + validate_servers_value(map, &project_path)?; + } atomic_write_json(&project_path, &value)?; return Ok(()); } @@ -537,6 +653,10 @@ fn set_server_disabled_with_paths( source: e, })?; + // 全局支路只操作 Value:写盘前必须走一遍 typed 校验(两个候选 map 都查), + // 且 disabled=true 不能成为绕过配置契约的通道。 + validate_value_servers(&value, global_path)?; + // 尝试 config.mcpServers 路径 let mut updated = false; if let Some(config) = value @@ -572,6 +692,7 @@ fn set_server_disabled_with_paths( } } + validate_value_servers(&value, global_path)?; atomic_write_json(global_path, &value)?; return Ok(()); } @@ -591,6 +712,9 @@ fn test_config() -> McpServerConfig { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, } } diff --git a/peri-middlewares/src/mcp/config_test.rs b/peri-middlewares/src/mcp/config_test.rs index 416314494..575fcac7a 100644 --- a/peri-middlewares/src/mcp/config_test.rs +++ b/peri-middlewares/src/mcp/config_test.rs @@ -3,6 +3,55 @@ use tempfile::NamedTempFile; use super::*; use crate::plugin::PluginOrigin; +/// 测试用显式全局路径(不存在 → 空全局配置):不读真实 `~/.peri/settings.json`。 +fn missing_global_path(dir: &Path) -> PathBuf { + dir.join("global-settings.json") +} + +/// 在 `claude_home` 下安装一个已启用插件,`mcp_servers_json` 为 manifest `mcpServers` 的值。 +fn install_enabled_plugin(claude_home: &Path, name: &str, mcp_servers_json: &str) -> PathBuf { + use crate::plugin::types::{InstallScope, InstalledPlugin, InstalledPlugins}; + + let plugin_dir = claude_home + .join("plugins") + .join("cache") + .join("mkt") + .join(name) + .join("1.0.0"); + std::fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap(); + std::fs::write( + plugin_dir.join(".claude-plugin").join("plugin.json"), + format!(r#"{{"name":"{name}","version":"1.0.0","mcpServers":{mcp_servers_json}}}"#), + ) + .unwrap(); + + let installed = InstalledPlugins { + version: 2, + plugins: vec![InstalledPlugin { + id: format!("{name}@mkt"), + name: name.to_string(), + version: "1.0.0".into(), + marketplace: "mkt".into(), + install_path: plugin_dir.clone(), + scope: InstallScope::User, + project_path: None, + origin: PluginOrigin::PeriInstalled, + }], + }; + std::fs::create_dir_all(claude_home.join("plugins")).unwrap(); + std::fs::write( + claude_home.join("plugins").join("installed_plugins.json"), + serde_json::to_string(&installed).unwrap(), + ) + .unwrap(); + std::fs::write( + claude_home.join("settings.json"), + format!(r#"{{"enabledPlugins":["{name}@mkt"]}}"#), + ) + .unwrap(); + plugin_dir +} + #[test] fn test_load_from_nonexistent_path() { let result = load_from_path(Path::new("/nonexistent/path/file.json")); @@ -385,8 +434,13 @@ fn test_expand_env_vars_with_context_fallback_to_env() { #[test] fn test_load_merged_config_full_no_plugins() { let dir = tempfile::tempdir().unwrap(); - // 没有 settings.json,没有插件目录 - let (config, plugin_sources) = load_merged_config_full(dir.path(), dir.path()); + // 没有 settings.json,没有插件目录(显式全局路径 seam:不读真实 home) + let (config, plugin_sources) = load_merged_config_full_with_paths( + dir.path(), + dir.path(), + &missing_global_path(dir.path()), + ) + .unwrap(); assert!(config.mcp_servers.is_empty()); assert!(plugin_sources.is_empty()); } @@ -448,7 +502,9 @@ fn test_load_merged_config_full_with_plugin() { ) .unwrap(); - let (config, plugin_sources) = load_merged_config_full(&cwd, &claude_home); + let (config, plugin_sources) = + load_merged_config_full_with_paths(&cwd, &claude_home, &missing_global_path(dir.path())) + .unwrap(); // 验证 env 注入 let srv_config = config @@ -560,7 +616,9 @@ fn test_load_merged_config_full_multiple_plugins() { ) .unwrap(); - let (_config, plugin_sources) = load_merged_config_full(&cwd, &claude_home); + let (_config, plugin_sources) = + load_merged_config_full_with_paths(&cwd, &claude_home, &missing_global_path(dir.path())) + .unwrap(); assert!( plugin_sources.contains_key("plugin:pa:srvA"), "should contain plugin:pa:srvA, got: {:?}", @@ -641,7 +699,9 @@ fn test_load_merged_config_full_plugin_env_preserves_existing() { ) .unwrap(); - let (config, _plugin_sources) = load_merged_config_full(&cwd, &claude_home); + let (config, _plugin_sources) = + load_merged_config_full_with_paths(&cwd, &claude_home, &missing_global_path(dir.path())) + .unwrap(); let srv_config = config .mcp_servers .get("plugin:p2:srv2") @@ -657,3 +717,530 @@ fn test_load_merged_config_full_plugin_env_preserves_existing() { // CLAUDE_PLUGIN_DATA 应也被注入 assert!(env.contains_key("CLAUDE_PLUGIN_DATA")); } + +// ─── System MCP 配置失败闭环(契约 1 / 4 的配置部分)────────────────────── + +/// 契约层固定规则正文:断言错误里必须能看见它,而不是被吞成空配置。 +const SYSTEM_TOOLS_RULE: &str = "system_mcp_tools requires system_mcp = true"; + +/// 断言错误是 ParseError,且路径与规则正文都被保留。 +fn assert_parse_error(error: McpConfigError, expected_path: &Path) { + let McpConfigError::ParseError { path, source } = error else { + panic!("非法配置必须返回 ParseError,实际: {error}"); + }; + assert_eq!(path, expected_path.display().to_string()); + assert!( + source.to_string().contains(SYSTEM_TOOLS_RULE), + "规则正文必须保留在解析错误中: {source}" + ); +} + +#[test] +fn test_system_mcp_project_rejects_tools_without_true() { + // 契约 1:无 system_mcp = true 却声明 system_mcp_tools(含显式 [])必须失败。 + const CASES: [&str; 4] = [ + r#"{"system_mcp_tools":[]}"#, + r#"{"system_mcp_tools":["search"]}"#, + r#"{"system_mcp":false,"system_mcp_tools":[]}"#, + r#"{"system_mcp":false,"system_mcp_tools":["search"]}"#, + ]; + for server in CASES { + let dir = tempfile::tempdir().unwrap(); + let project_path = dir.path().join(".mcp.json"); + std::fs::write( + &project_path, + format!(r#"{{"mcpServers":{{"sys":{server}}}}}"#), + ) + .unwrap(); + + let error = load_from_path(&project_path).expect_err("非法组合必须失败"); + assert_parse_error(error, &project_path); + } +} + +#[test] +fn test_system_mcp_global_rejects_invalid_maps() { + // nested / top-level 各自非法都必须失败,而不是 Ok(empty)。 + let nested_invalid = r#"{"config":{"mcpServers":{"sys":{"system_mcp_tools":["a"]}}}}"#; + let top_level_invalid = r#"{"mcpServers":{"sys":{"system_mcp_tools":["a"]}}}"#; + for content in [nested_invalid, top_level_invalid] { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write(&settings_path, content).unwrap(); + + let error = load_global_config(&settings_path).expect_err("非法 map 必须失败"); + assert_parse_error(error, &settings_path); + } + + // 双 map:nested 合法 + top-level 非法——备用 map 也要被拒绝, + // 不能因为选择的是 nested 就让非法 top-level 静默通过。 + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"config":{"mcpServers":{"ok":{"command":"npx"}}},"mcpServers":{"sys":{"system_mcp_tools":[]}}}"#, + ) + .unwrap(); + let error = load_global_config(&settings_path).expect_err("非法备用 map 必须失败"); + assert_parse_error(error, &settings_path); + + // 双 map 均合法:仍按 nested > top-level 选择。 + std::fs::write( + &settings_path, + r#"{"config":{"mcpServers":{"chosen":{"command":"npx"}}},"mcpServers":{"fallback":{"command":"uvx"}}}"#, + ) + .unwrap(); + let config = load_global_config(&settings_path).unwrap(); + assert_eq!(config.mcp_servers.len(), 1); + assert!(config.mcp_servers.contains_key("chosen")); +} + +#[test] +fn test_system_mcp_merged_errors_are_not_empty_success() { + // 全局非法:不得退化成功空配置。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let global_path = missing_global_path(dir.path()); + std::fs::write( + &global_path, + r#"{"mcpServers":{"sys":{"system_mcp_tools":[]}}}"#, + ) + .unwrap(); + let error = load_merged_config_full_with_paths(&cwd, &claude_home, &global_path) + .expect_err("全局非法配置必须失败"); + assert_parse_error(error, &global_path); + + // 项目非法:同样失败(非法文件不是缺文件)。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let project_path = cwd.join(".mcp.json"); + std::fs::write( + &project_path, + r#"{"mcpServers":{"sys":{"system_mcp":false,"system_mcp_tools":["a"]}}}"#, + ) + .unwrap(); + let error = + load_merged_config_full_with_paths(&cwd, &claude_home, &missing_global_path(dir.path())) + .expect_err("项目非法配置必须失败"); + assert_parse_error(error, &project_path); + + // 非法低优先级配置即便被有效项目同名覆盖也必须拒绝。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let global_path = missing_global_path(dir.path()); + std::fs::write( + &global_path, + r#"{"mcpServers":{"sys":{"system_mcp_tools":[]}}}"#, + ) + .unwrap(); + std::fs::write( + cwd.join(".mcp.json"), + r#"{"mcpServers":{"sys":{"command":"npx","system_mcp":true}}}"#, + ) + .unwrap(); + let error = load_merged_config_full_with_paths(&cwd, &claude_home, &global_path) + .expect_err("被覆盖的非法配置也必须拒绝"); + assert_parse_error(error, &global_path); +} + +#[test] +fn test_system_mcp_plugin_strict_error_reaches_merge() { + // 插件来源非法:严格插件路径必须把错误带到合并入口,而不是返回空 plugins。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let plugin_dir = install_enabled_plugin(&claude_home, "p1", r#"{"srv":"servers/.mcp.json"}"#); + let servers_dir = plugin_dir.join("servers"); + std::fs::create_dir_all(&servers_dir).unwrap(); + let broken = servers_dir.join(".mcp.json"); + std::fs::write( + &broken, + r#"{"mcpServers":{"bad":{"system_mcp_tools":["tool"]}}}"#, + ) + .unwrap(); + + let error = + load_merged_config_full_with_paths(&cwd, &claude_home, &missing_global_path(dir.path())) + .expect_err("插件非法 MCP 配置必须失败"); + + let McpConfigError::PluginLoadError { source } = &error else { + panic!("插件来源非法应返回 PluginLoadError,实际: {error}"); + }; + let crate::plugin::LoaderError::McpConfigInvalid { path, message } = source else { + panic!("插件 MCP 配置无效应保留 McpConfigInvalid,实际: {source}"); + }; + assert_eq!(path, &broken); + assert_eq!(message, &format!("bad: {SYSTEM_TOOLS_RULE}")); + // 错误链必须保留固定规则正文:面板/日志可以据此定位。 + assert!(error.to_string().contains(SYSTEM_TOOLS_RULE)); +} + +#[test] +fn test_system_mcp_typed_validation_includes_disabled() { + // disabled 不是绕过校验的通道;typed 构造(非 serde 路径)也要被拒绝。 + for disabled in [None, Some(true), Some(false)] { + let mut servers = HashMap::new(); + servers.insert( + "sys".to_string(), + McpServerConfig { + disabled, + system_mcp: Some(false), + system_mcp_tools: Some(Vec::new()), + ..test_config() + }, + ); + let config = McpConfigFile { + mcp_servers: servers, + }; + + let error = validate_config(&config).expect_err("disabled 不能绕过校验"); + let McpConfigError::InvalidServer { + server_name, + source, + } = error + else { + panic!("typed 校验必须返回 InvalidServer"); + }; + assert_eq!(server_name, "sys"); + assert_eq!( + source, + peri_acp_types::plugin::McpServerConfigValidationError::SystemMcpToolsRequiresSystemMcp + ); + + let display = validate_config(&config).unwrap_err().to_string(); + assert_eq!( + display, + format!("MCP 服务器配置无效: sys: {SYSTEM_TOOLS_RULE}") + ); + } +} + +#[test] +fn test_system_mcp_empty_tools_survive_config_pipeline() { + // 契约 4 配置部分:显式 [] 经加载 → 合并 → 展开 → 写回 → 再载入仍可区分于 None。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let global_path = missing_global_path(dir.path()); + let project_path = cwd.join(".mcp.json"); + std::fs::write( + &project_path, + r#"{"mcpServers":{"sys":{"command":"echo","system_mcp":true,"system_mcp_tools":[]}}}"#, + ) + .unwrap(); + + let (merged, _) = load_merged_config_full_with_paths(&cwd, &claude_home, &global_path).unwrap(); + let sys = merged.mcp_servers.get("sys").expect("应有 sys"); + assert_eq!(sys.system_mcp, Some(true)); + assert_eq!(sys.system_mcp_tools, Some(Vec::new())); + assert_ne!(sys.system_mcp_tools, None, "Some([]) 与 None 必须可区分"); + + // 展开不得把 [] 变成 None,也不得凭空产生工具名。 + let expanded = expand_server_config(sys); + assert_eq!(expanded.system_mcp_tools, Some(Vec::new())); + + // 写回(切换 disabled)后原始空数组仍在文件里、仍能无损读回。 + set_server_disabled_with_paths(&cwd, &global_path, "sys", true).unwrap(); + let raw = std::fs::read_to_string(&project_path).unwrap(); + let written: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let tools = &written["mcpServers"]["sys"]["system_mcp_tools"]; + assert!( + tools.as_array().is_some_and(|tools| tools.is_empty()), + "写回必须保留显式空数组(不得省略该 key): {raw}" + ); + let reloaded = load_from_path(&project_path).unwrap(); + assert_eq!( + reloaded.mcp_servers["sys"].system_mcp_tools, + Some(Vec::new()) + ); + assert_eq!(reloaded.mcp_servers["sys"].disabled, Some(true)); +} + +#[test] +fn test_system_mcp_tools_survive_expansion_and_namespace() { + // 契约 3 配置部分:工具数组字面量保真,所属 namespace 由 server key 决定。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let tools = r#"["Search","search","${PLUGIN_TOOL_VAR}",""]"#; + install_enabled_plugin( + &claude_home, + "p1", + &format!(r#"{{"srv":{{"command":"node","system_mcp":true,"system_mcp_tools":{tools}}}}}"#), + ); + + let (merged, _) = + load_merged_config_full_with_paths(&cwd, &claude_home, &missing_global_path(dir.path())) + .unwrap(); + let srv = merged + .mcp_servers + .get("plugin:p1:srv") + .expect("插件 server key 应带 plugin:{name}: 前缀"); + assert_eq!( + srv.system_mcp_tools.as_deref(), + Some( + ["Search", "search", "${PLUGIN_TOOL_VAR}", ""] + .map(String::from) + .as_slice() + ), + "顺序/大小写/重复项/空串/变量占位符字面量都不得改写,也不得加 MCP 前缀" + ); + + // global/project 同名覆盖是整条替换(不是数组拼接),source 随覆盖更新。 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let global_path = missing_global_path(dir.path()); + std::fs::write( + &global_path, + r#"{"mcpServers":{"sys":{"command":"echo","system_mcp":true,"system_mcp_tools":["global-tool"]}}}"#, + ) + .unwrap(); + let project_path = cwd.join(".mcp.json"); + std::fs::write( + &project_path, + r#"{"mcpServers":{"sys":{"command":"echo","system_mcp":true,"system_mcp_tools":["project-tool"]}}}"#, + ) + .unwrap(); + + let (merged, _) = load_merged_config_full_with_paths(&cwd, &claude_home, &global_path).unwrap(); + let sys = merged.mcp_servers.get("sys").expect("应有 sys"); + assert_eq!( + sys.system_mcp_tools.as_deref(), + Some(["project-tool".to_string()].as_slice()), + "同名覆盖必须是整条替换,不跨来源拼接" + ); + assert_eq!(sys.source, Some(ConfigSource::Project(project_path))); +} + +#[test] +fn test_system_mcp_dedup_preserves_required_namespaces() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let claude_home = dir.path().join(".claude-test"); + std::fs::create_dir_all(&claude_home).unwrap(); + let global_path = missing_global_path(dir.path()); + // 插件(System 声明 + 普通声明各一);手动配置与插件 server 内容完全一致, + // 使内容 hash 相等——去重规则本身成为唯一变量。 + let plugin_dir = install_enabled_plugin( + &claude_home, + "p1", + r#"{ + "sys-dup":{"command":"node","args":["s.js"],"system_mcp":true,"system_mcp_tools":["t"]}, + "plain-dup":{"command":"node","args":["p.js"]} + }"#, + ); + let plugin_env = serde_json::json!({ + "CLAUDE_PLUGIN_ROOT": plugin_dir.to_string_lossy(), + "CLAUDE_PLUGIN_DATA": plugin_dir.join(".claude-plugin").join("data").to_string_lossy(), + }); + let manual = serde_json::json!({"mcpServers": { + "sys-manual": { + "command":"node","args":["s.js"], + "system_mcp":true,"system_mcp_tools":["t"], + "env": plugin_env, + }, + "plain-manual": {"command":"node","args":["p.js"],"env": plugin_env}, + }}); + std::fs::write(&global_path, serde_json::to_string(&manual).unwrap()).unwrap(); + + let (merged, _) = load_merged_config_full_with_paths(&cwd, &claude_home, &global_path).unwrap(); + assert!( + merged.mcp_servers.contains_key("plugin:p1:sys-dup"), + "System MCP 不得因跨 namespace 内容相同被去重删除,实际 keys: {:?}", + merged.mcp_servers.keys().collect::>() + ); + assert!( + !merged.mcp_servers.contains_key("plugin:p1:plain-dup"), + "普通 MCP 既有内容去重仍必须生效,实际 keys: {:?}", + merged.mcp_servers.keys().collect::>() + ); + + // hash 必须覆盖 System 字段:变更它们视为不同服务器。 + let base = McpServerConfig { + command: Some("node".into()), + system_mcp: Some(true), + system_mcp_tools: Some(vec!["t".into()]), + ..test_config() + }; + assert_ne!( + server_config_hash(&base), + server_config_hash(&test_config()) + ); + let mut other_tools = base.clone(); + other_tools.system_mcp_tools = Some(vec!["t2".into()]); + assert_ne!(server_config_hash(&base), server_config_hash(&other_tools)); +} + +#[test] +fn test_system_mcp_disabled_write_rejects_invalid_input() { + // 写入口在修改前校验:非法输入不写盘、不改字节,disabled 不是绕过通道。 + for disabled in [true, false] { + // 项目文件非法 + let dir = tempfile::tempdir().unwrap(); + let project_path = dir.path().join(".mcp.json"); + let invalid = r#"{"mcpServers":{"sys":{"system_mcp_tools":[]}}}"#; + std::fs::write(&project_path, invalid).unwrap(); + let error = set_server_disabled_with_paths( + dir.path(), + &missing_global_path(dir.path()), + "sys", + disabled, + ) + .expect_err("项目非法配置必须拒绝写盘"); + assert_parse_error(error, &project_path); + assert_eq!(std::fs::read_to_string(&project_path).unwrap(), invalid); + + // 全局 nested 非法 + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let global_path = missing_global_path(dir.path()); + let invalid = + r#"{"config":{"mcpServers":{"sys":{"system_mcp_tools":[]}}},"otherSetting":42}"#; + std::fs::write(&global_path, invalid).unwrap(); + let error = set_server_disabled_with_paths(&cwd, &global_path, "sys", disabled) + .expect_err("全局 nested 非法配置必须拒绝写盘"); + assert_parse_error(error, &global_path); + assert_eq!(std::fs::read_to_string(&global_path).unwrap(), invalid); + + // 全局 top-level 非法 + let invalid = r#"{"mcpServers":{"sys":{"system_mcp":false,"system_mcp_tools":["a"]}}}"#; + std::fs::write(&global_path, invalid).unwrap(); + let error = set_server_disabled_with_paths(&cwd, &global_path, "sys", disabled) + .expect_err("全局 top-level 非法配置必须拒绝写盘"); + assert_parse_error(error, &global_path); + assert_eq!(std::fs::read_to_string(&global_path).unwrap(), invalid); + } +} + +#[test] +fn test_system_mcp_remove_rejects_invalid_input() { + // 删除非法条目不是修复通道:目标非法或其它 server 非法都拒绝,文件不变。 + let dir = tempfile::tempdir().unwrap(); + let project_path = dir.path().join(".mcp.json"); + let target_invalid = r#"{"mcpServers":{"sys":{"system_mcp_tools":[]}}}"#; + std::fs::write(&project_path, target_invalid).unwrap(); + let error = + remove_server_from_config_with_paths(dir.path(), &missing_global_path(dir.path()), "sys") + .expect_err("删除非法目标也必须拒绝"); + assert_parse_error(error, &project_path); + assert_eq!( + std::fs::read_to_string(&project_path).unwrap(), + target_invalid + ); + + let sibling_invalid = + r#"{"mcpServers":{"victim":{"command":"npx"},"sys":{"system_mcp_tools":[]}}}"#; + std::fs::write(&project_path, sibling_invalid).unwrap(); + let error = remove_server_from_config_with_paths( + dir.path(), + &missing_global_path(dir.path()), + "victim", + ) + .expect_err("同文件其它 server 非法也必须拒绝"); + assert_parse_error(error, &project_path); + assert_eq!( + std::fs::read_to_string(&project_path).unwrap(), + sibling_invalid + ); + + // 全局 nested / top-level + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let global_path = missing_global_path(dir.path()); + let nested_invalid = + r#"{"config":{"mcpServers":{"victim":{"command":"npx"},"sys":{"system_mcp_tools":[]}}}}"#; + std::fs::write(&global_path, nested_invalid).unwrap(); + let error = remove_server_from_config_with_paths(&cwd, &global_path, "victim") + .expect_err("全局 nested 非法必须拒绝"); + assert_parse_error(error, &global_path); + assert_eq!( + std::fs::read_to_string(&global_path).unwrap(), + nested_invalid + ); + + let top_level_invalid = + r#"{"mcpServers":{"victim":{"command":"npx"},"sys":{"system_mcp_tools":[]}}}"#; + std::fs::write(&global_path, top_level_invalid).unwrap(); + let error = remove_server_from_config_with_paths(&cwd, &global_path, "victim") + .expect_err("全局 top-level 非法必须拒绝"); + assert_parse_error(error, &global_path); + assert_eq!( + std::fs::read_to_string(&global_path).unwrap(), + top_level_invalid + ); +} + +#[test] +fn test_system_mcp_write_preserves_remaining_tools() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let global_path = missing_global_path(dir.path()); + std::fs::write( + &global_path, + r#"{"config":{"mcpServers":{ + "sys":{"command":"echo","system_mcp":true,"system_mcp_tools":["z","a","z"]}, + "plain":{"command":"npx"} + }},"otherSetting":42}"#, + ) + .unwrap(); + + // 删除普通 server:其余 System 数组顺序与值不变,其它 settings 字段仍在。 + remove_server_from_config_with_paths(&cwd, &global_path, "plain").unwrap(); + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&global_path).unwrap()).unwrap(); + assert!(value["config"]["mcpServers"].get("plain").is_none()); + assert_eq!(value["otherSetting"], 42); + let reloaded = load_global_config(&global_path).unwrap(); + assert_eq!( + reloaded.mcp_servers["sys"].system_mcp_tools.as_deref(), + Some(["z".to_string(), "a".to_string(), "z".to_string()].as_slice()) + ); + + // 切换 disabled:数组仍原样保留(顺序与重复项都不动)。 + set_server_disabled_with_paths(&cwd, &global_path, "sys", true).unwrap(); + let reloaded = load_global_config(&global_path).unwrap(); + assert_eq!(reloaded.mcp_servers["sys"].disabled, Some(true)); + assert_eq!( + reloaded.mcp_servers["sys"].system_mcp_tools.as_deref(), + Some(["z".to_string(), "a".to_string(), "z".to_string()].as_slice()) + ); + + // 显式 [] 写回不得被省略。 + let empty_path = dir.path().join("empty-settings.json"); + std::fs::write( + &empty_path, + r#"{"mcpServers":{"sys":{"command":"echo","system_mcp":true,"system_mcp_tools":[]}}}"#, + ) + .unwrap(); + set_server_disabled_with_paths(&cwd, &empty_path, "sys", true).unwrap(); + let raw = std::fs::read_to_string(&empty_path).unwrap(); + let written: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!( + written["mcpServers"]["sys"]["system_mcp_tools"] + .as_array() + .is_some_and(|tools| tools.is_empty()), + "写回必须保留显式空数组(不得省略该 key): {raw}" + ); +} diff --git a/peri-middlewares/src/mcp/discover_tool.rs b/peri-middlewares/src/mcp/discover_tool.rs index 47870d50f..0dbf2efe3 100644 --- a/peri-middlewares/src/mcp/discover_tool.rs +++ b/peri-middlewares/src/mcp/discover_tool.rs @@ -34,6 +34,8 @@ pub struct DiscoverMCPTool { pool: Arc, registry: Option>, agent_registry: Option>, + /// 会话 id:发现面按 ACP 连接归属过滤,`None` = 不过滤(print 模式)。 + session_id: Option, } impl DiscoverMCPTool { @@ -42,9 +44,16 @@ impl DiscoverMCPTool { pool, registry, agent_registry: None, + session_id: None, } } + /// 注入会话 id:其他会话声明的 ACP server 不出现在搜索 / 清单 / 详情面。 + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } + pub fn with_agent_registry(mut self, registry: Arc) -> Self { self.agent_registry = Some(registry); self @@ -72,7 +81,10 @@ impl DiscoverMCPTool { let mut results: Vec = Vec::new(); // server:全部已配置/已连接服务器(名称匹配) - for info in self.pool.all_server_infos() { + for info in self + .pool + .all_server_infos_visible_to(self.session_id.as_deref()) + { if info.name.to_lowercase().contains(&needle) { results.push(json!({ "type": "server", @@ -88,7 +100,10 @@ impl DiscoverMCPTool { } // tool / resource:已连接 server 的缓存快照(get_all_clients 只返回 Connected) - for handle in self.pool.get_all_clients() { + for handle in self + .pool + .get_all_clients_visible_to(self.session_id.as_deref()) + { for tool in &handle.tools { let name_hit = tool.name.to_lowercase().contains(&needle); let desc_hit = tool @@ -163,7 +178,10 @@ impl DiscoverMCPTool { let Some(server) = params.get("server").and_then(Value::as_str) else { return err_obj(-32602, "缺少 server 参数(string)"); }; - let Some(handle) = self.pool.get_client(server) else { + let Some(handle) = self + .pool + .get_client_visible_to(server, self.session_id.as_deref()) + else { return err_obj(-32000, format!("MCP 服务器 \"{server}\" 不存在")); }; if !matches!(&handle.status, ClientStatus::Connected) { @@ -217,7 +235,10 @@ impl DiscoverMCPTool { let Some(server) = params.get("server").and_then(Value::as_str) else { return err_obj(-32602, "缺少 server 参数(string)"); }; - let Some(handle) = self.pool.get_client(server) else { + let Some(handle) = self + .pool + .get_client_visible_to(server, self.session_id.as_deref()) + else { return err_obj(-32000, format!("MCP 服务器 \"{server}\" 不存在")); }; // detail 只服务已连接 server(spec 错误表:已配置但未连接 → -32000) @@ -332,6 +353,7 @@ fn config_source_str(source: &ConfigSource) -> &'static str { ConfigSource::Project(_) => "project", ConfigSource::Global(_) => "global", ConfigSource::Plugin => "plugin", + ConfigSource::Acp => "acp", } } diff --git a/peri-middlewares/src/mcp/dynamic/registry.rs b/peri-middlewares/src/mcp/dynamic/registry.rs index bdb9120ff..734ae82c5 100644 --- a/peri-middlewares/src/mcp/dynamic/registry.rs +++ b/peri-middlewares/src/mcp/dynamic/registry.rs @@ -109,6 +109,12 @@ impl DynamicMcpDeploymentPort for DynamicMcpRegistry { } } + /// 注册(或替换)本 session 的动态碰撞目录。 + /// + /// 目录会在启动闸门提交后随晚到的静态 MCP 工具重注册,因此同一 session 的 + /// 重复注册不是 no-op:先在同一把锁内用候选目录重验已发布动态工具,冲突则 + /// 拒绝且**保留旧目录**(无部分替换),否则整体替换。这样发现期借旧目录 + /// 放行的 load 不会在静态目录更新后继续以过期基线存在。 fn register_catalog( &self, session_id: &str, @@ -122,13 +128,17 @@ impl DynamicMcpDeploymentPort for DynamicMcpRegistry { "Dynamic MCP task admission is closed", )); } - match state.catalogs.entry(session_id.to_string()) { - std::collections::btree_map::Entry::Occupied(_) => Ok(()), - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(tools); - Ok(()) - } + if let Some(conflict) = candidate_catalog_conflict(&state, session_id, &tools) { + // safe_summary 固定为冲突工具名,调用方(stage_builder/tools.rs 的 + // 注册回调)据此映射 `StartupRegistrationRejected`。 + return Err(DynamicMcpFailure::new( + DynamicMcpErrorCode::ToolNameConflict, + DynamicMcpOperationState::Failed, + conflict, + )); } + state.catalogs.insert(session_id.to_string(), tools); + Ok(()) } fn capability(&self, session_id: &str) -> Arc { @@ -230,6 +240,49 @@ impl DynamicMcpDeploymentPort for DynamicMcpRegistry { } } +/// 候选静态目录与已发布动态工具的重名/别名冲突;返回首个冲突的候选条目名。 +/// +/// 判定与 `registry/capability.rs::tools_collide` 的过滤语义对称:大小写不敏感, +/// 且**同名静态 server** 的条目允许被该动态实例遮蔽,不参与冲突判定。只在 +/// 本 session 内比较:动态工具的碰撞基线不跨 session 共享。 +fn candidate_catalog_conflict( + state: &RegistryState, + session_id: &str, + tools: &[DynamicMcpCatalogTool], +) -> Option { + let snapshot = state.capabilities.get(session_id)?; + if snapshot.tools.is_empty() { + return None; + } + let mut candidates: BTreeMap> = BTreeMap::new(); + for tool in tools { + for name in std::iter::once(&tool.name).chain(tool.aliases.iter()) { + candidates + .entry(name.to_ascii_lowercase()) + .or_default() + .push(tool); + } + } + for capability in snapshot.tools.values() { + let server = capability.instance.logical.server_name.as_str(); + for name in + std::iter::once(capability.tool.name()).chain(capability.tool.aliases().iter().copied()) + { + let conflict = candidates + .get(&name.to_ascii_lowercase()) + .and_then(|entries| { + entries + .iter() + .find(|entry| entry.static_mcp_server.as_deref() != Some(server)) + }); + if let Some(entry) = conflict { + return Some(entry.name.clone()); + } + } + } + None +} + #[cfg(test)] #[path = "registry_test.rs"] mod tests; diff --git a/peri-middlewares/src/mcp/dynamic/registry/capability.rs b/peri-middlewares/src/mcp/dynamic/registry/capability.rs index 9638b608b..3cdf0d96f 100644 --- a/peri-middlewares/src/mcp/dynamic/registry/capability.rs +++ b/peri-middlewares/src/mcp/dynamic/registry/capability.rs @@ -239,6 +239,7 @@ impl CheckedSessionMcpProjection { &self.pool, Some(&self.skill_registry), Some(&self.command_registry), + Some(&self.session_id), &self.cancel, ); true diff --git a/peri-middlewares/src/mcp/dynamic/registry_test.rs b/peri-middlewares/src/mcp/dynamic/registry_test.rs index 76992e589..0c9210e62 100644 --- a/peri-middlewares/src/mcp/dynamic/registry_test.rs +++ b/peri-middlewares/src/mcp/dynamic/registry_test.rs @@ -663,7 +663,7 @@ async fn session_owned_projection_keeps_existing_discover_instance_live_until_cl } #[test] -fn repeated_catalog_registration_keeps_first_session_baseline() { +fn repeated_catalog_registration_revalidates_and_replaces_baseline() { let (_owner, spawner) = McpTaskOwner::new(); let registry = DynamicMcpRegistry::new(spawner, FakeConnector::new()); let initial = vec![DynamicMcpCatalogTool { @@ -671,24 +671,24 @@ fn repeated_catalog_registration_keeps_first_session_baseline() { aliases: vec!["builtin_alias".to_string()], static_mcp_server: None, }]; + let updated = vec![DynamicMcpCatalogTool { + name: "SubagentOnly".to_string(), + aliases: Vec::new(), + static_mcp_server: None, + }]; registry .register_catalog("session-a", initial.clone()) .unwrap(); + // 同值重复注册保持幂等。 registry.register_catalog("session-a", initial).unwrap(); + // 晚到的静态目录整体替换旧基线,而不是 first-write-wins。 registry - .register_catalog( - "session-a", - vec![DynamicMcpCatalogTool { - name: "SubagentOnly".to_string(), - aliases: Vec::new(), - static_mcp_server: None, - }], - ) + .register_catalog("session-a", updated.clone()) .unwrap(); let state = registry.state.lock(); - assert_eq!(state.catalogs["session-a"][0].name, "Builtin"); + assert_eq!(state.catalogs["session-a"], updated); } #[tokio::test] @@ -709,12 +709,12 @@ async fn load_and_unload_do_not_change_session_collision_baseline() { .await .unwrap(); wait_ready(®istry, "session-a", "example").await; + // 已发布动态工具既不改变基线,也不让与自身无重名的目录重注册失败。 registry - .register_catalog( - "session-a", - registry.capability("session-a").snapshot().dynamic_tools(), - ) + .register_catalog("session-a", baseline.clone()) .unwrap(); + assert_eq!(registry.state.lock().catalogs["session-a"], baseline); + registry .execute( "session-a", @@ -725,12 +725,137 @@ async fn load_and_unload_do_not_change_session_collision_baseline() { ) .await .unwrap(); - registry.register_catalog("session-a", Vec::new()).unwrap(); assert_eq!(registry.state.lock().catalogs["session-a"], baseline); owner.shutdown().await; } +#[tokio::test] +async fn late_static_catalog_with_live_dynamic_collision_is_rejected_without_partial_replace() { + let (mut owner, spawner) = McpTaskOwner::new(); + let registry = DynamicMcpRegistry::new(spawner, FakeConnector::new()); + let baseline = vec![DynamicMcpCatalogTool { + name: "Builtin".to_string(), + aliases: vec!["builtin_alias".to_string()], + static_mcp_server: None, + }]; + registry + .register_catalog("session-a", baseline.clone()) + .unwrap(); + + // 发现期基线没有该名字,load 因此被放行;晚到的静态条目必须被重验抓住。 + registry + .execute("session-a", load("example", "one")) + .await + .unwrap(); + wait_ready(®istry, "session-a", "example").await; + + let error = registry + .register_catalog( + "session-a", + vec![ + baseline[0].clone(), + DynamicMcpCatalogTool { + name: "mcp__Example__lookup".to_string(), + aliases: Vec::new(), + static_mcp_server: Some("Example".to_string()), + }, + ], + ) + .unwrap_err(); + + assert_eq!(error.code, DynamicMcpErrorCode::ToolNameConflict); + // 大小写不同视为同一碰撞;上报候选条目名而不是已有的动态工具名。 + assert_eq!(error.safe_summary, "mcp__Example__lookup"); + // 拒绝必须整体生效:旧基线保留,动态实例与已发布工具不受影响。 + assert_eq!(registry.state.lock().catalogs["session-a"], baseline); + assert!(registry + .capability("session-a") + .snapshot() + .tools + .contains_key("mcp__example__lookup")); + owner.shutdown().await; +} + +#[tokio::test] +async fn late_static_catalog_alias_conflict_is_rejected() { + let (mut owner, spawner) = McpTaskOwner::new(); + let registry = DynamicMcpRegistry::new(spawner, FakeConnector::new()); + registry + .execute("session-a", load("example", "one")) + .await + .unwrap(); + wait_ready(®istry, "session-a", "example").await; + + let error = registry + .register_catalog( + "session-a", + vec![DynamicMcpCatalogTool { + name: "mcp__other__lookup".to_string(), + aliases: vec!["MCP__EXAMPLE__LOOKUP".to_string()], + static_mcp_server: None, + }], + ) + .unwrap_err(); + + assert_eq!(error.code, DynamicMcpErrorCode::ToolNameConflict); + assert_eq!(error.safe_summary, "mcp__other__lookup"); + assert!(!registry.state.lock().catalogs.contains_key("session-a")); + owner.shutdown().await; +} + +#[tokio::test] +async fn late_static_catalog_shadows_same_named_live_dynamic_server() { + let (mut owner, spawner) = McpTaskOwner::new(); + let registry = DynamicMcpRegistry::new(spawner, FakeConnector::new()); + registry + .execute("session-a", load("example", "one")) + .await + .unwrap(); + wait_ready(®istry, "session-a", "example").await; + + let updated = vec![DynamicMcpCatalogTool { + name: "mcp__example__lookup".to_string(), + aliases: Vec::new(), + static_mcp_server: Some("example".to_string()), + }]; + registry + .register_catalog("session-a", updated.clone()) + .unwrap(); + + assert_eq!(registry.state.lock().catalogs["session-a"], updated); + owner.shutdown().await; +} + +#[tokio::test] +async fn catalog_revalidation_is_scoped_to_the_registering_session() { + let (mut owner, spawner) = McpTaskOwner::new(); + let registry = DynamicMcpRegistry::new(spawner, FakeConnector::new()); + registry + .execute("session-a", load("example", "one")) + .await + .unwrap(); + wait_ready(®istry, "session-a", "example").await; + + let conflicting = vec![DynamicMcpCatalogTool { + name: "mcp__example__lookup".to_string(), + aliases: Vec::new(), + static_mcp_server: None, + }]; + // 同目录在无动态实例的 session 上合法:碰撞基线不跨 session 共享。 + registry + .register_catalog("session-b", conflicting.clone()) + .unwrap(); + assert_eq!(registry.state.lock().catalogs["session-b"], conflicting); + + let error = registry + .register_catalog("session-a", conflicting) + .unwrap_err(); + assert_eq!(error.code, DynamicMcpErrorCode::ToolNameConflict); + assert!(!registry.state.lock().catalogs.contains_key("session-a")); + owner.shutdown().await; +} + #[tokio::test] async fn catalog_collision_rejects_load_without_publishing_capability() { let (mut owner, spawner) = McpTaskOwner::new(); diff --git a/peri-middlewares/src/mcp/dynamic/tool_test.rs b/peri-middlewares/src/mcp/dynamic/tool_test.rs index 3da1434a1..908ad4fb0 100644 --- a/peri-middlewares/src/mcp/dynamic/tool_test.rs +++ b/peri-middlewares/src/mcp/dynamic/tool_test.rs @@ -3,8 +3,9 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use peri_acp_types::{ dynamic_mcp::{ - CanonicalDynamicMcpAction, DynamicMcpAccepted, DynamicMcpFailure, DynamicMcpOperationId, - DynamicMcpOperationState, DynamicMcpResponse, DynamicMcpShutdownReport, + CanonicalDynamicMcpAction, DynamicMcpAccepted, DynamicMcpAction, DynamicMcpFailure, + DynamicMcpOperationId, DynamicMcpOperationState, DynamicMcpResponse, + DynamicMcpShutdownReport, }, ports::DynamicMcpDeploymentPort, }; @@ -199,6 +200,82 @@ fn invalid_dynamic_input_fails_before_deployment_or_hitl() { assert!(deployment.actions.lock().unwrap().is_empty()); } +/// 边界回归:动态 MCP 路径继续拒绝 System key,不实现 session 中途声明语义。 +/// +/// 三个 canonical key 与三个 camelCase alias 都必须在 wire 反序列化与 canonical bind +/// 之前失败,不得降级为可执行请求,也不得触达 deployment load。 +#[test] +fn test_dynamic_mcp_rejects_system_mcp_fields() { + let deployment = Arc::new(FakeDeployment::default()); + let tool = DynamicMcpTool::new( + "session-a", + Arc::clone(&deployment) as Arc, + ); + + let cases = [ + ("system_mcp", json!(true)), + ("systemMcp", json!(true)), + ("system_mcp_tools", json!(["Read"])), + ("systemMcpTools", json!(["Read"])), + ("system_mcp_timeout", json!(1500)), + ("systemMcpTimeout", json!(1500)), + ]; + + // 对照:不含 System key 的同一配置可以正常绑定,拒绝确实由 System key 引起。 + let bound = tool + .bind_invocation(json!({ + "method": "load", + "params": { + "name": "example", + "config": {"command": "example-mcp", "args": ["stdio"]} + } + })) + .expect("不含 System key 的配置必须可绑定") + .expect("load 必须返回可执行绑定"); + assert_eq!(bound.policy_name, "DynamicMCP.load"); + + for (key, value) in cases { + let mut config = json!({"command": "example-mcp", "args": ["stdio"]}); + config[key] = value; + let input = json!({ + "method": "load", + "params": {"name": "example", "config": config} + }); + + assert!( + DynamicMcpAction::from_tool_input(input.clone()).is_err(), + "{key} 必须在 wire 反序列化阶段被拒绝" + ); + assert!( + tool.bind_invocation(input).is_err(), + "{key} 必须在 canonical bind 前被拒绝,而不是降级为可执行请求" + ); + assert!( + deployment.actions.lock().unwrap().is_empty(), + "{key} 被拒绝时不得调用 deployment load" + ); + } + + // 同一 key 换到 params 与顶层同样被拒绝:两处各有独立的未知字段拦截。 + for input in [ + json!({ + "method": "load", + "params": {"name": "example", "systemMcp": true, "config": {"command": "example-mcp"}} + }), + json!({ + "method": "load", + "system_mcp": true, + "params": {"name": "example", "config": {"command": "example-mcp"}} + }), + ] { + assert!( + tool.bind_invocation(input).is_err(), + "System key 出现在 config 之外的位置时必须同样被拒绝" + ); + } + assert!(deployment.actions.lock().unwrap().is_empty()); +} + struct RecordingRejectBroker { names: Mutex>, } diff --git a/peri-middlewares/src/mcp/initialize.rs b/peri-middlewares/src/mcp/initialize.rs index cf6004878..68dcff7c5 100644 --- a/peri-middlewares/src/mcp/initialize.rs +++ b/peri-middlewares/src/mcp/initialize.rs @@ -1,14 +1,19 @@ use std::{path::Path, sync::Arc}; +use rmcp::{ + model::Tool, + service::{Peer, RoleClient, ServiceError}, +}; + use super::{ auth_store::FileCredentialStore, channel_handler::ChannelHandler, client::{ - build_http_transport, serve_client_auto, setup_subscription, ClientStatus, McpClientHandle, - McpClientPool, McpInitStatus, OAuthStatus, HTTP_CONNECT_TIMEOUT, SHUTDOWN_TIMEOUT, - STDIO_CONNECT_TIMEOUT, + build_http_transport, serve_client_auto, setup_subscription, ClientStatus, + DiscoveryEvidence, McpClientHandle, McpClientPool, McpInitStatus, OAuthStatus, + SystemMcpManifest, HTTP_CONNECT_TIMEOUT, SHUTDOWN_TIMEOUT, STDIO_CONNECT_TIMEOUT, }, - config::OAuthConfig, + config::{McpServerConfig, OAuthConfig}, oauth_flow::OAuthFlowEvent, transport::TransportConfig, }; @@ -17,6 +22,97 @@ use super::{ #[path = "initialize_test.rs"] mod tests; +/// 启动发现的 `tools/list` 来源:System MCP 必须走本次 live round-trip。 +/// +/// System MCP 的 ready 证据不得来自历史持久缓存——缓存命中只说明「过去某次列出过 +/// 这些工具」,不能证明本次启动的 server 仍能列出工具。`system_mcp_tools` 为空数组 +/// 时同样要走完这次 round-trip(清单可以是空数组)。普通 MCP 保持既有 cache 策略。 +/// +/// `Err` 一律表示发现失败:既不等于「服务器没有工具」,也不产生任何 ready 证据。 +pub(super) async fn list_discovered_tools( + pool: &McpClientPool, + server_name: &str, + peer: &Peer, + config: &McpServerConfig, +) -> Result, ServiceError> { + if config.system_mcp == Some(true) { + peer.list_all_tools().await + } else { + pool.list_all_tools_cached(server_name, peer).await + } +} + +/// 发现尝试的**成功**收口:证据绑定刚提交句柄的代际(`0` 表示未登记,读取方 +/// 一律视为无效)。只有真实成功的 live `tools/list` 才走到这里。 +pub(super) fn commit_discovery_success( + pool: &McpClientPool, + server_name: &str, + committed: &Arc, +) { + let generation = pool.handle_generation(committed); + pool.commit_discovery_evidence(server_name, DiscoveryEvidence::discovered(generation)); +} + +/// 发现尝试的**失败**收口。 +/// +/// `insert_failed` / `insert_needs_auth` 已经推进代际,证据必须绑定那个新句柄的 +/// 代际,读取方才能把「本代已得出结论」与「仍在进行」区分开。没有句柄可绑定时 +/// 清掉旧证据:宁可让等待方按「未完成」等到 deadline,也不留一条无法核对的成功。 +pub(super) fn commit_discovery_failure( + pool: &Arc, + server_name: &str, + initialize_ok: bool, +) { + let Some(handle) = pool.get_client(server_name) else { + pool.clear_discovery_evidence(server_name); + return; + }; + let generation = pool.handle_generation(&handle); + let evidence = if initialize_ok { + // initialize 成功但 live `tools/list` 失败:不是「空清单」,也不是完成。 + DiscoveryEvidence::discovery_failed(generation) + } else { + DiscoveryEvidence::initialize_failed(generation) + }; + pool.commit_discovery_evidence(server_name, evidence); +} + +/// `tools/list` 失败的统一收口:不提交 `Connected`(那会伪装成「发现完成且无工具」), +/// 改为显式 `Failed` + 本代 `tools_list_ok = false` 的证据。 +pub(super) fn fail_tool_discovery(pool: &Arc, server_name: &str, error: &str) { + let reason = format!("工具发现失败: {}", super::client::redact_mcp_error(error)); + tracing::warn!(server = %server_name, error = %reason, "MCP tools/list 失败,不发布连接与 ready 证据"); + McpClientPool::insert_failed(pool, server_name, reason); + commit_discovery_failure(pool, server_name, true); +} + +/// 资源清单的降级收口:resources 不是启动准入条件(契约 2 只冻结 transport / +/// initialize / 能力协商 / `tools/list`),连接与工具面保持可用;但解析失败不得 +/// 静默写成「成功返回空列表」,必须留下可查的失败记录。 +pub(super) fn downgrade_resource_listing(server_name: &str, error: &str) { + tracing::warn!( + server = %server_name, + error = %super::client::redact_mcp_error(error), + "MCP resources/list 失败,本次不发布资源" + ); +} + +/// 配置失败的统一发布:面板状态(pool)与 watch 通道同时置 Failed,System 配置 +/// 清单同时收口为 `Failed`。 +/// +/// 只发布失败,不 mark_initialized、不注册 server:失败不能退化成 ready 空配置, +/// 也不能让启动等待方把「配置加载失败」当「还没有 System MCP」睡到超时。 +fn publish_config_failure( + pool: &McpClientPool, + status_tx: &tokio::sync::watch::Sender, + message: &str, +) { + pool.publish_system_manifest(SystemMcpManifest::Failed); + let status = McpInitStatus::Failed(message.to_string()); + *pool.init_status.write() = status.clone(); + let _ = status_tx.send(status); +} + impl McpClientPool { pub async fn run_initialize( pool: Arc, @@ -26,7 +122,15 @@ impl McpClientPool { oauth_event_callback: Option>, channel_handler: Option>, ) { - let (config, plugin_sources) = super::load_merged_config_full(cwd, claude_home); + // 配置加载失败必须是可见的 Failed:不发布 Ready、不标记 initialized、 + // 不注册任何 server(因而也不会开始 transport)。B 在 1R 消费该失败。 + let (config, plugin_sources) = match super::load_merged_config_full(cwd, claude_home) { + Ok(loaded) => loaded, + Err(error) => { + publish_config_failure(&pool, &status_tx, &error.to_string()); + return; + } + }; Self::initialize_config( pool, cwd, @@ -48,9 +152,18 @@ impl McpClientPool { oauth_event_callback: Option>, channel_handler: Option>, ) { + // typed 配置(含手工构造)在任何 empty / disabled / ready 分支之前校验: + // 非法组合必须暴露为 Failed,不能因为「空配置」或「全部 disabled」被跳过。 + if let Err(error) = super::config::validate_config(&config) { + publish_config_failure(&pool, &status_tx, &error.to_string()); + return; + } let cwd = match pool.bind_execution_cwd(cwd) { Ok(cwd) => cwd, Err(error) => { + // 执行目录绑定失败后本 pool 不可能再发布配置清单:等待方必须立刻 + // 拿到终态,而不是把「不可用」睡成 bootstrap 超时。 + pool.publish_system_manifest(SystemMcpManifest::Failed); let status = McpInitStatus::Failed(error.to_string()); *pool.init_status.write() = status.clone(); let _ = status_tx.send(status); @@ -63,6 +176,8 @@ impl McpClientPool { .filter(|(_, sc)| !sc.disabled.unwrap_or(false)) .count(); if config.mcp_servers.is_empty() { + // 空集合也是**完整**清单:此后 system 依赖(零个)是可信事实。 + pool.publish_system_manifest(SystemMcpManifest::Loaded); let _ = status_tx.send(McpInitStatus::Ready { total: 0 }); *pool.init_status.write() = McpInitStatus::Ready { total: 0 }; pool.mark_initialized(); @@ -84,6 +199,9 @@ impl McpClientPool { .write() .insert(name.clone(), server_config.clone()); } + // 完整 configs(含全部 disabled)已一次性写入:此刻起配置清单是可信的 + // System 依赖事实源,等待方可以按 server 逐个判定发现证据。 + pool.publish_system_manifest(SystemMcpManifest::Loaded); let _ = status_tx.send(McpInitStatus::Initializing { connected: 0, total: connectable, @@ -93,8 +211,20 @@ impl McpClientPool { total: connectable, }; + // 启动依赖优先推进:System MCP 是本轮启动的阻塞条件,普通 MCP 的慢 + // transport / 慢握手不得把它排在后面(原实现按 HashMap 顺序串行连接)。 + // 同优先级内按 server 名排序,使连接顺序确定、可复现。 + let mut ordered: Vec<(&String, &McpServerConfig)> = config.mcp_servers.iter().collect(); + ordered.sort_by(|(left_name, left), (right_name, right)| { + let left_system = left.system_mcp == Some(true); + let right_system = right.system_mcp == Some(true); + right_system + .cmp(&left_system) + .then_with(|| left_name.cmp(right_name)) + }); + let mut connected = 0usize; - for (name, server_config) in &config.mcp_servers { + for (name, server_config) in ordered { // 跳过已禁用的服务器,注册为 Disabled 状态 if server_config.disabled.unwrap_or(false) { tracing::info!(server = %name, "MCP 服务器已禁用,跳过连接"); @@ -117,11 +247,15 @@ impl McpClientPool { ); continue; } + // 本次发现尝试开始:旧代证据立即作废,等待方按「仍在进行」重新判定, + // 不会把上一次尝试的成功当成这一次的证据。 + pool.clear_discovery_evidence(name); let transport_config = match TransportConfig::try_from(server_config) { Ok(tc) => tc, Err(e) => { tracing::warn!(server = %name, error = %e, "传输层构建失败"); Self::insert_failed(&pool, name, format!("传输层构建失败: {e}")); + commit_discovery_failure(&pool, name, false); continue; } }; @@ -158,6 +292,7 @@ impl McpClientPool { let err_str = super::client::redact_mcp_error(&e.to_string()); tracing::warn!(server = %name, error = %err_str, "MCP stdio 启动失败"); Self::insert_failed(&pool, name, format!("stdio 启动失败: {err_str}")); + commit_discovery_failure(&pool, name, false); continue; } }, @@ -215,14 +350,27 @@ impl McpClientPool { } let peer = rs.peer().clone(); let cache_version = pool.install_peer_cache_version(name, &peer); - let tools = pool - .list_all_tools_cached(name, &peer) - .await - .unwrap_or_default(); - let resources = pool - .list_all_resources_cached(name, &peer) - .await - .unwrap_or_default(); + // 严格发现(契约 2 / 主 plan IF-M3):`tools/list` 的 `Err` 既不是 + // 「服务器没有工具」,也不是 ready 证据。只有真实成功的 round-trip + // 才允许提交 `Connected`;失败必须显式失败并释放已建立的 service, + // 否则 `Connected + tools=[]` 会被下游误判为 discovery 完成。 + let tools = match list_discovered_tools(&pool, name, &peer, server_config).await + { + Ok(tools) => tools, + Err(error) => { + let mut service = rs; + let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + fail_tool_discovery(&pool, name, &error.to_string()); + continue; + } + }; + let resources = match pool.list_all_resources_cached(name, &peer).await { + Ok(resources) => resources, + Err(error) => { + downgrade_resource_listing(name, &error.to_string()); + Vec::new() + } + }; tracing::info!(server = %name, tools = tools.len(), resources = resources.len(), "MCP 连接成功"); let peer = rs.peer().clone(); let channel_capable = peer @@ -253,10 +401,16 @@ impl McpClientPool { channel_capable, skills_capable, }); + let committed = Arc::clone(&handle); if let Err(mut service) = pool.try_commit_connection(name.clone(), handle, rs) { let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + // 提交被拒(pool 关闭):不留任何可被读成成功的证据。 + pool.clear_discovery_evidence(name); break; } + // 唯一能提交「本代完整发现证据」的位置:transport + initialize + + // 能力协商 + 真实成功的 live `tools/list` 全部完成,且句柄已登记代际。 + commit_discovery_success(&pool, name, &committed); connected += 1; let _ = status_tx.send(McpInitStatus::Initializing { connected, @@ -277,6 +431,9 @@ impl McpClientPool { } else { Self::insert_failed(&pool, name, err_str); } + // initialize 未完成:证据必须明确记为「本代 initialize 失败」, + // 不能靠「没有证据」让等待方一直等到 deadline。 + commit_discovery_failure(&pool, name, false); } Err(_) => { // 超时是面板上可见、日志里必须可查的失败:首次启动需装依赖的 @@ -288,6 +445,7 @@ impl McpClientPool { "MCP 连接超时" ); Self::insert_failed(&pool, name, "连接超时".to_string()); + commit_discovery_failure(&pool, name, false); } } } @@ -343,6 +501,10 @@ impl McpClientPool { pool.notify_initial_connections(); } + /// 测试用构造:直接复用生产初始化路径。 + /// + /// 不保留第二套宽松的发现/提交逻辑——两条路径分叉时,测试会在与生产不同的 + /// 语义上变绿(`tools/list` 失败必须是显式 `Failed`,不是 `Connected` 空工具)。 #[cfg(test)] pub async fn initialize( cwd: &Path, @@ -350,202 +512,25 @@ impl McpClientPool { oauth_event_callback: Option>, channel_handler: Option>, ) -> Arc { - let (config, plugin_sources) = super::load_merged_config_full(cwd, claude_home); let pool = Arc::new(Self::new_pending()); - let cwd = match pool.bind_execution_cwd(cwd) { - Ok(cwd) => cwd, + let (config, plugin_sources) = match super::load_merged_config_full(cwd, claude_home) { + Ok(loaded) => loaded, Err(error) => { *pool.init_status.write() = McpInitStatus::Failed(error.to_string()); return pool; } }; - *pool.plugin_sources.write() = plugin_sources; - let token_store = Arc::new(FileCredentialStore::new()); - // OAuth 事件回调注入 pool(spawn_oauth_flow / start_oauth_flow 读取; - // 无回调时授权不自动触发,仅标记 NeedsAuthorization)。 - if let Some(cb) = oauth_event_callback { - pool.set_oauth_event_callback(cb); - } - - for (name, sc) in &config.mcp_servers { - pool.configs.write().insert(name.clone(), sc.clone()); - } - - for (name, server_config) in &config.mcp_servers { - // 跳过已禁用的服务器,注册为 Disabled 状态 - if server_config.disabled.unwrap_or(false) { - tracing::info!(server = %name, "MCP 服务器已禁用,跳过连接"); - pool.clients.write().insert( - name.clone(), - Arc::new(McpClientHandle { - name: name.clone(), - version: None, - cache_version: None, - peer: None, - tools: vec![], - resources: vec![], - status: ClientStatus::Disabled, - oauth_status: OAuthStatus::default(), - source: server_config.source.clone(), - url: server_config.url.clone(), - skills_capable: false, - channel_capable: false, - }), - ); - continue; - } - let tc = match TransportConfig::try_from(server_config) { - Ok(tc) => tc, - Err(e) => { - Self::insert_failed(&pool, name, format!("传输层构建失败: {e}")); - continue; - } - }; - let is_http = matches!(tc, TransportConfig::StreamableHttp { .. }); - let timeout = if is_http { - HTTP_CONNECT_TIMEOUT - } else { - STDIO_CONNECT_TIMEOUT - }; - // lifecycle 仅由显式 protocolVersion 选择;subscriptions 只负责连接后订阅。 - let protocol_version = server_config.protocol_version.as_ref(); - let subscriptions = server_config - .subscriptions - .as_ref() - .filter(|s| !s.is_empty()); - - let connect_result = match tc { - TransportConfig::Stdio { - ref command, - ref args, - ref env, - } => match pool.spawn_stdio_transport(command, args, env, cwd) { - Ok(t) => { - serve_client_auto( - t, - channel_handler.as_ref(), - protocol_version, - &pool.capability_profile, - timeout, - ) - .await - } - Err(e) => { - Self::insert_failed(&pool, name, format!("stdio 失败: {e}")); - continue; - } - }, - TransportConfig::StreamableHttp { - ref url, - ref headers, - ref oauth, - } => { - let oauth_cfg = oauth.as_ref().cloned().or_else(|| { - // 无显式 OAuth 配置时:若凭证文件已有该 server 的 token, - // 用默认配置走恢复路径(run_oauth_flow 快速路径跳过浏览器)。 - match tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(token_store.load_server(name))) { - Ok(Some(_)) => { - tracing::info!(server = %name, "发现已保存的 OAuth 凭证,使用默认配置恢复"); - Some(OAuthConfig::default()) - } - _ => None, - } - }); - if oauth_cfg.is_some() { - if pool.oauth_event_callback().is_some() { - // host pool:不主动触发授权(避免启动即弹 popup - // 打扰),统一标记 NeedsAuthorization,由用户经 - // MCP 面板显式发起(mcp/oauth_start RPC → - // spawn_oauth_flow → popup)。 - Self::insert_needs_auth(&pool, name, "OAuth 授权待完成".to_string()); - continue; - } - // TUI 面板池:无 UI 交互通道,走快速路径——尝试恢复 - // 磁盘凭证直接连接(不弹窗);凭据缺失/失效时保持 - // NeedsAuthorization,由 host pool 授权后共享凭证文件 - // 恢复。异步执行不阻塞初始化。 - pool.spawn_oauth_flow(name); - continue; - } else { - serve_client_auto( - build_http_transport(url, headers), - channel_handler.as_ref(), - protocol_version, - &pool.capability_profile, - timeout, - ) - .await - } - } - }; - - match connect_result { - Ok(Ok(rs)) => { - let rs = pool.retain_service(rs); - // 订阅配置存在:建立 subscriptions/listen 长流(2026-07-28)。 - if let Some(sub) = subscriptions { - setup_subscription(&pool, &rs, name, sub).await; - } - let peer = rs.peer().clone(); - let cache_version = pool.install_peer_cache_version(name, &peer); - let tools = pool - .list_all_tools_cached(name, &peer) - .await - .unwrap_or_default(); - let resources = pool - .list_all_resources_cached(name, &peer) - .await - .unwrap_or_default(); - let peer = rs.peer().clone(); - let channel_capable = peer - .peer_info() - .and_then(|info| { - info.capabilities - .experimental - .as_ref() - .and_then(|exp| exp.get("claude/channel")) - .cloned() - }) - .is_some(); - let oauth_status = OAuthStatus::default(); - let skills_capable = super::client::peer_declares_skills(&peer); - let handle = Arc::new(McpClientHandle { - name: name.clone(), - version: peer.peer_info().and_then(|info| { - info.server_info.as_ref().map(|si| si.version.clone()) - }), - cache_version: cache_version.clone(), - peer: Some(peer), - tools, - resources, - status: ClientStatus::Connected, - oauth_status, - source: server_config.source.clone(), - url: server_config.url.clone(), - channel_capable, - skills_capable, - }); - if let Err(mut service) = pool.try_commit_connection(name.clone(), handle, rs) { - let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; - break; - } - } - Ok(Err(e)) => { - let err_str = e.to_string(); - if Self::is_auth_required_error(&err_str, is_http) { - // 服务器要求授权(如 sentry 401):标记待授权,不主动 - // 触发——用户经 MCP 面板显式发起授权(mcp/oauth_start)。 - Self::insert_needs_auth(&pool, name, err_str); - } else { - Self::insert_failed(&pool, name, err_str); - } - } - Err(_) => { - Self::insert_failed(&pool, name, "连接超时".into()); - } - } - } - + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + Self::initialize_config( + pool.clone(), + cwd, + config, + plugin_sources, + status_tx, + oauth_event_callback, + channel_handler, + ) + .await; pool } } diff --git a/peri-middlewares/src/mcp/initialize_test.rs b/peri-middlewares/src/mcp/initialize_test.rs index d8fb30db2..59e1f8a2d 100644 --- a/peri-middlewares/src/mcp/initialize_test.rs +++ b/peri-middlewares/src/mcp/initialize_test.rs @@ -169,3 +169,635 @@ fn worktree_static_pool_rejects_rebinding_its_execution_directory() { assert!(pool.bind_execution_cwd(&fixture.path().join("b")).is_err()); assert_eq!(pool.execution_cwd.get().unwrap(), &fixture.path().join("a")); } + +/// 配置失败必须是可见的 Failed:不发布 Ready、不标记 initialized、不注册任何 +/// server(因而不会开始 transport)。只写日志不写状态时,1R 无法据状态放行/阻断。 +#[tokio::test] +async fn test_system_mcp_config_error_never_publishes_ready() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + // 非法项目配置:声明 system_mcp_tools 却没有 system_mcp = true。 + std::fs::write( + cwd.join(".mcp.json"), + r#"{"mcpServers":{"sys":{"command":"node","system_mcp_tools":["search"]}}}"#, + ) + .unwrap(); + let claude_home = fixture.path().join(".claude-test"); + std::fs::create_dir(&claude_home).unwrap(); + + let pool = Arc::new(McpClientPool::new_pending()); + let (status_tx, status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::run_initialize(pool.clone(), &cwd, &claude_home, status_tx, None, None).await; + + let expected_rule = "system_mcp_tools requires system_mcp = true"; + match &*pool.init_status.read() { + McpInitStatus::Failed(message) => assert!( + message.contains(expected_rule), + "pool 状态必须保留固定规则正文,实际: {message}" + ), + other => panic!("配置失败必须发布 Failed,实际: {other:?}"), + } + match &*status_rx.borrow() { + McpInitStatus::Failed(message) => assert!( + message.contains(expected_rule), + "watch 通道必须保留固定规则正文,实际: {message}" + ), + other => panic!("watch 通道必须发布 Failed,实际: {other:?}"), + } + assert!( + !pool.initialized.load(std::sync::atomic::Ordering::SeqCst), + "配置失败不得标记 initialized" + ); + assert!( + pool.clients.read().is_empty(), + "配置失败不得注册 server(未开始 transport)" + ); + assert!( + pool.configs.read().is_empty(), + "配置失败不得把非法配置写入 pool" + ); + // 配置清单同时收口:等待方必须立刻得到终态,不能把「加载失败」当 + // 「还没有 System 依赖」睡到 bootstrap 超时。 + assert_eq!( + pool.system_manifest(), + SystemMcpManifest::Failed, + "配置加载/校验失败必须把配置清单收口为 Failed" + ); +} + +/// 初始化成功但 `tools/list` 返回 RPC 错误:发现必须显式为失败。 +/// +/// 反面对照见 `empty_tools_list_is_success_not_failure`:空数组是成功结果, +/// 错误不是。二者若落在同一个 `Connected + tools=[]` 上,readiness 会把 +/// 「发现失败」当成「发现完成且无工具」放行(契约 2)。 +const BROKEN_DISCOVERY_SCRIPT: &str = r#" +const readline = require('node:readline').createInterface({ input: process.stdin }); +readline.on('line', line => { + const request = JSON.parse(line); + if (request.id === undefined) return; + // tools/list 不在允许列表内:一律以 JSON-RPC 错误拒绝。 + if (!['initialize', 'resources/list', 'ping'].includes(request.method)) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + error: { code: -32601, message: 'Method not found' } }) + '\n'); + return; + } + let result = {}; + if (request.method === 'initialize') result = { + protocolVersion: '2025-11-25', capabilities: {}, + serverInfo: { name: 'broken-discovery', version: '1' }, + }; + if (request.method === 'resources/list') result = { resources: [] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); +}); +"#; + +/// 声明持久缓存版本的 fixture:每次 live `tools/list` 追加一行到计数文件 +/// (argv[2]),用于区分「本次真实 round-trip」与「历史缓存命中」。 +const VERSIONED_DISCOVERY_SCRIPT: &str = r#" +const fs = require('node:fs'); +const counter = process.argv[2]; +const readline = require('node:readline').createInterface({ input: process.stdin }); +readline.on('line', line => { + const request = JSON.parse(line); + if (request.id === undefined) return; + if (!['initialize', 'tools/list', 'resources/list', 'ping'].includes(request.method)) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + error: { code: -32601, message: 'Method not found' } }) + '\n'); + return; + } + let result = {}; + if (request.method === 'initialize') result = { + protocolVersion: '2025-11-25', + capabilities: { extensions: { 'io.mcpp/server-cache-version': { cacheVersion: 'v1' } } }, + serverInfo: { name: 'versioned-discovery', version: '1' }, + }; + if (request.method === 'tools/list') { + fs.appendFileSync(counter, 'tools/list\n'); + result = { tools: [{ name: 'search', description: 'fixture tool', + inputSchema: { type: 'object', properties: {} } }] }; + } + if (request.method === 'resources/list') result = { resources: [] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); +}); +"#; + +fn discovery_lines(path: &std::path::Path) -> usize { + std::fs::read_to_string(path) + .map(|text| text.lines().count()) + .unwrap_or(0) +} + +#[tokio::test] +async fn tools_list_error_never_becomes_connected_with_empty_tools() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + std::fs::write(cwd.join("broken.js"), BROKEN_DISCOVERY_SCRIPT).unwrap(); + let config = serde_json::from_value(serde_json::json!({ + "mcpServers": { "broken-discovery": { "command": "node", "args": ["broken.js"] } } + })) + .unwrap(); + let (mut tasks, spawner) = super::super::task_scope::McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let (status_tx, status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::initialize_config( + pool.clone(), + &cwd, + config, + Default::default(), + status_tx, + None, + None, + ) + .await; + + let client = pool + .get_client("broken-discovery") + .expect("失败必须留下可核对的状态,而不是注册成 Connected"); + match &client.status { + ClientStatus::Failed(reason) => assert!( + reason.contains("工具发现失败"), + "tools/list 失败必须在正文里可查,实际: {reason}" + ), + other => panic!("tools/list 失败不得提交 Connected,实际: {other:?}"), + } + assert!( + client.tools.is_empty(), + "失败不得留下工具清单(否则与空数组成功无法区分)" + ); + // 不可伪造的发现证据:本代 initialize 成功、`tools/list` 失败,且代际绑定 + // 到当前句柄(B-01 的等待方据此返回 ToolDiscoveryFailed,而不是无限等待)。 + let evidence = pool + .discovery_evidence("broken-discovery") + .expect("发现尝试结束必须提交本代证据"); + assert!( + evidence.initialize_ok, + "initialize 已成功,证据必须如实记录" + ); + assert!( + !evidence.tools_list_ok, + "tools/list 失败不得提交 tools_list_ok = true" + ); + assert!(!evidence.is_complete(), "失败证据不构成完成证据"); + assert_eq!( + evidence.generation, + pool.handle_generation(&client), + "证据必须绑定当前句柄的代际" + ); + assert_ne!(evidence.generation, 0, "代际 0 是「未登记」哨兵,不算证据"); + assert_eq!( + pool.system_manifest(), + SystemMcpManifest::Loaded, + "完整 configs 已写入,配置清单必须是 Loaded" + ); + let lifecycle_failure = match &*pool.init_status.read() { + McpInitStatus::Failed(message) => message.clone(), + other => panic!("发现失败的 server 不得计入 ready,实际: {other:?}"), + }; + assert!( + lifecycle_failure.contains("工具发现失败"), + "聚合状态必须保留发现失败正文,实际: {lifecycle_failure}" + ); + match &*status_rx.borrow() { + McpInitStatus::Failed(message) => assert!( + message.contains("工具发现失败"), + "watch 通道不得发布 ready,实际: {message}" + ), + other => panic!("watch 通道不得发布 ready,实际: {other:?}"), + } + + pool.begin_shutdown(); + tasks.begin_shutdown(); + let _ = tasks.shutdown().await; + assert!(pool.shutdown().await.is_complete()); +} + +/// System 启动闸门读到的必须是**本次发现**的结论,而不是等到 deadline。 +/// +/// 这条同时核对证据的代际绑定:`ToolDiscoveryFailed` 只在「本代证据记录了 +/// initialize 成功 + live `tools/list` 失败」时产生;证据缺失或代际不符会退化成 +/// `ConnectionFailed`,两者在用户可见文案上不同(契约 2 的失败分类)。 +#[tokio::test] +async fn system_gate_concludes_tools_list_failure_without_waiting_for_timeout() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + std::fs::write(cwd.join("broken.js"), BROKEN_DISCOVERY_SCRIPT).unwrap(); + let config = serde_json::from_value(serde_json::json!({ + "mcpServers": { "gate-broken": { + "command": "node", + "args": ["broken.js"], + "system_mcp": true, + "system_mcp_tools": [], + "system_mcp_timeout": 5000, + } } + })) + .unwrap(); + let (mut tasks, spawner) = super::super::task_scope::McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::initialize_config( + pool.clone(), + &cwd, + config, + Default::default(), + status_tx, + None, + None, + ) + .await; + + let started_at = tokio::time::Instant::now(); + let outcome = pool + .await_system_connections( + &peri_agent::agent::AgentCancellationToken::new(), + started_at, + ) + .await + .expect_err("发现失败不得放行启动"); + match outcome { + crate::mcp::client::SystemReadinessError::ToolDiscoveryFailed { server } => { + assert_eq!(server, "gate-broken") + } + other => panic!("必须归类为工具发现失败,实际: {other:?}"), + } + assert!( + started_at.elapsed() < std::time::Duration::from_secs(2), + "已知失败必须立即返回,不得等满 system_mcp_timeout" + ); + + pool.begin_shutdown(); + tasks.begin_shutdown(); + let _ = tasks.shutdown().await; + assert!(pool.shutdown().await.is_complete()); +} + +#[tokio::test] +async fn empty_tools_list_is_success_not_failure() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + // 既有 fixture 对 tools/list 返回 `{tools: []}`:空数组是成功结果。 + std::fs::write(cwd.join("empty.js"), SCRIPT).unwrap(); + let config = serde_json::from_value(serde_json::json!({ + "mcpServers": { "empty-discovery": { "command": "node", "args": ["empty.js"] } } + })) + .unwrap(); + let (mut tasks, spawner) = super::super::task_scope::McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::initialize_config( + pool.clone(), + &cwd, + config, + Default::default(), + status_tx, + None, + None, + ) + .await; + + let client = pool + .get_client("empty-discovery") + .expect("空数组是成功结果"); + assert!( + matches!(client.status, ClientStatus::Connected), + "空数组不得判为失败,实际: {:?}", + client.status + ); + assert!(client.tools.is_empty(), "空数组必须原样保留为空清单"); + assert!( + matches!(&*pool.init_status.read(), McpInitStatus::Ready { .. }), + "空数组成功必须发布 ready,实际: {:?}", + *pool.init_status.read() + ); + // 与失败分支的差别落在证据上:空数组是「完整发现」,不是「发现失败」。 + let evidence = pool + .discovery_evidence("empty-discovery") + .expect("成功发现必须提交本代证据"); + assert!( + evidence.is_complete(), + "空数组是成功结果,必须构成完整发现证据,实际: {evidence:?}" + ); + assert_eq!( + evidence.generation, + pool.handle_generation(&client), + "证据必须绑定当前句柄的代际" + ); + + pool.begin_shutdown(); + tasks.begin_shutdown(); + let _ = tasks.shutdown().await; + assert!(pool.shutdown().await.is_complete()); +} + +/// System MCP 的启动发现必须是本次 live round-trip。 +/// +/// 持久缓存命中只证明「过去某个版本列过这些工具」,不能作为本次启动的 +/// transport/能力协商健康证据;普通 MCP 保持既有 cache 策略(版本命中级跳过网络)。 +/// 两轮初始化共用同一 pool 与同一隔离缓存根:普通 server 第二轮命中磁盘缓存, +/// System server 第二轮仍必须打到 fixture。 +#[tokio::test] +async fn system_discovery_is_live_while_ordinary_server_uses_cache() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + std::fs::write(cwd.join("versioned.js"), VERSIONED_DISCOVERY_SCRIPT).unwrap(); + let system_counter = fixture.path().join("system-tools-list.log"); + let ordinary_counter = fixture.path().join("ordinary-tools-list.log"); + let config: super::super::config::McpConfigFile = serde_json::from_value(serde_json::json!({ + "mcpServers": { + "system-srv": { + "command": "node", + "args": ["versioned.js", system_counter.to_str().unwrap()], + "system_mcp": true, + "system_mcp_tools": [], + }, + "ordinary-srv": { + "command": "node", + "args": ["versioned.js", ordinary_counter.to_str().unwrap()], + }, + } + })) + .unwrap(); + let (mut tasks, spawner) = super::super::task_scope::McpTaskOwner::new(); + let mut pool = McpClientPool::new_pending_with_spawner(spawner); + pool.resource_cache = crate::mcp::resource_cache::McpResourceCache::isolated_for_test(); + let pool = Arc::new(pool); + + for round in 1..=2 { + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::initialize_config( + pool.clone(), + &cwd, + config.clone(), + Default::default(), + status_tx, + None, + None, + ) + .await; + for name in ["system-srv", "ordinary-srv"] { + let client = pool.get_client(name).unwrap_or_else(|| { + panic!("第 {round} 轮 {name} 必须完成发现"); + }); + assert!( + matches!(client.status, ClientStatus::Connected), + "第 {round} 轮 {name} 必须完成发现,实际: {:?}", + client.status + ); + assert!( + !client.tools.is_empty(), + "第 {round} 轮 {name} 的发现结果必须进入 handle" + ); + let evidence = pool + .discovery_evidence(name) + .unwrap_or_else(|| panic!("第 {round} 轮 {name} 必须提交本代发现证据")); + assert!( + evidence.is_complete(), + "第 {round} 轮 {name} 的发现证据必须完整(含 live tools/list),实际: {evidence:?}" + ); + assert_eq!( + evidence.generation, + pool.handle_generation(&client), + "第 {round} 轮 {name} 的证据必须绑定当前句柄的代际" + ); + } + } + + assert_eq!( + discovery_lines(&system_counter), + 2, + "System MCP 每轮都必须走 live tools/list:required=[] 也要 round-trip,\ + 缓存命中的历史清单不能作为本次启动证据" + ); + assert_eq!( + discovery_lines(&ordinary_counter), + 1, + "普通 MCP 第二轮必须命中持久缓存(保持既有 cache 策略,不被 System 严格化波及)" + ); + + pool.begin_shutdown(); + tasks.begin_shutdown(); + let _ = tasks.shutdown().await; + assert!(pool.shutdown().await.is_complete()); +} + +/// 重连必须换掉发现证据:旧代的成功证据不得被新代复用。 +/// +/// 场景:先成功发现(证据完整),随后 server 的 `tools/list` 变坏再重连。若证据 +/// 按 server 名缓存而不换代,System 闸门会拿旧代证据放行一台已经报错的 server。 +const RECONNECT_DISCOVERY_SCRIPT: &str = r#" +const fs = require('node:fs'); +const marker = process.argv[2]; +const readline = require('node:readline').createInterface({ input: process.stdin }); +readline.on('line', line => { + const request = JSON.parse(line); + if (request.id === undefined) return; + if (!['initialize', 'tools/list', 'resources/list', 'ping'].includes(request.method)) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + error: { code: -32601, message: 'Method not found' } }) + '\n'); + return; + } + // 标记文件出现后 tools/list 一律失败,模拟「重连时 server 工具面已损坏」。 + if (request.method === 'tools/list') { + if (fs.existsSync(marker)) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + error: { code: -32603, message: 'tools unavailable' } }) + '\n'); + } else { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + result: { tools: [] } }) + '\n'); + } + return; + } + let result = {}; + if (request.method === 'initialize') result = { + protocolVersion: '2025-11-25', capabilities: {}, + serverInfo: { name: 'reconnect-discovery', version: '1' }, + }; + if (request.method === 'resources/list') result = { resources: [] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); +}); +"#; + +#[tokio::test] +async fn reconnect_replaces_discovery_evidence_with_current_generation() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + std::fs::write(cwd.join("reconnect.js"), RECONNECT_DISCOVERY_SCRIPT).unwrap(); + let marker = fixture.path().join("break-tools"); + let config = serde_json::from_value(serde_json::json!({ + "mcpServers": { "reconnect-srv": { + "command": "node", + "args": ["reconnect.js", marker.to_str().unwrap()], + "system_mcp": true, + "system_mcp_tools": [], + } } + })) + .unwrap(); + let (mut tasks, spawner) = super::super::task_scope::McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::initialize_config( + pool.clone(), + &cwd, + config, + Default::default(), + status_tx, + None, + None, + ) + .await; + + let first = pool.get_client("reconnect-srv").expect("首轮必须完成发现"); + let first_evidence = pool + .discovery_evidence("reconnect-srv") + .expect("首轮成功必须提交本代证据"); + assert!( + first_evidence.is_complete(), + "首轮成功必须构成完整证据,实际: {first_evidence:?}" + ); + + std::fs::write(&marker, "").unwrap(); + let reconnect = pool.reconnect("reconnect-srv", None).await; + assert!( + reconnect.is_err(), + "重连时 tools/list 失败必须返回错误,实际: {reconnect:?}" + ); + let current = pool + .get_client("reconnect-srv") + .expect("失败的重连必须留下可核对状态,而不是空句柄"); + assert!( + matches!(current.status, ClientStatus::Failed(_)), + "重连的 tools/list 失败必须显式 Failed,实际: {:?}", + current.status + ); + let evidence = pool + .discovery_evidence("reconnect-srv") + .expect("重连尝试结束必须提交本代证据"); + assert!( + evidence.initialize_ok && !evidence.tools_list_ok, + "重连证据必须如实记录 initialize 成功 / tools/list 失败,实际: {evidence:?}" + ); + assert_eq!( + evidence.generation, + pool.handle_generation(¤t), + "证据必须绑定重连后句柄的代际" + ); + assert_ne!( + evidence.generation, + pool.handle_generation(&first), + "重连必须换代,旧代证据不得复用" + ); + + pool.begin_shutdown(); + tasks.begin_shutdown(); + let _ = tasks.shutdown().await; + assert!(pool.shutdown().await.is_complete()); +} + +/// 启动依赖优先推进:普通 MCP 的慢握手不得把 System 发现排在后面。 +/// +/// 普通 fixture 阻塞在 `initialize`(放行文件出现前不回复),System fixture 立即 +/// 响应。断言 System 证据先完成——若实现回到 HashMap 顺序串行连接,这条会因 +/// fixture 的确定性阻塞而失败,而不是靠 sleep 猜时序。 +const GATED_ORDINARY_SCRIPT: &str = r#" +const fs = require('node:fs'); +const release = process.argv[2]; +const readline = require('node:readline').createInterface({ input: process.stdin }); +readline.on('line', line => { + const request = JSON.parse(line); + if (request.id === undefined) return; + if (!['initialize', 'tools/list', 'resources/list', 'ping'].includes(request.method)) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + error: { code: -32601, message: 'Method not found' } }) + '\n'); + return; + } + let result = {}; + if (request.method === 'initialize') { + const until = Date.now() + 20000; + while (!fs.existsSync(release) && Date.now() < until) {} + result = { protocolVersion: '2025-11-25', capabilities: {}, + serverInfo: { name: 'gated-ordinary', version: '1' } }; + } + if (request.method === 'tools/list') result = { tools: [] }; + if (request.method === 'resources/list') result = { resources: [] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); +}); +"#; + +#[tokio::test] +async fn system_requirement_is_discovered_before_gated_ordinary_server() { + let fixture = tempfile::tempdir().unwrap(); + let cwd = fixture.path().join("project"); + std::fs::create_dir(&cwd).unwrap(); + std::fs::write(cwd.join("gated.js"), GATED_ORDINARY_SCRIPT).unwrap(); + let release = fixture.path().join("release-ordinary"); + let config = serde_json::from_value(serde_json::json!({ + "mcpServers": { + "ordered-system": { + "command": "node", + "args": ["empty.js"], + "system_mcp": true, + "system_mcp_tools": [], + }, + "gated-ordinary": { + "command": "node", + "args": ["gated.js", release.to_str().unwrap()], + }, + } + })) + .unwrap(); + std::fs::write(cwd.join("empty.js"), SCRIPT).unwrap(); + let (mut tasks, spawner) = super::super::task_scope::McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let init_pool = pool.clone(); + let init_cwd = cwd.clone(); + let init = tokio::spawn(async move { + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::initialize_config( + init_pool, + &init_cwd, + config, + Default::default(), + status_tx, + None, + None, + ) + .await; + }); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + while !pool + .discovery_evidence("ordered-system") + .is_some_and(|evidence| evidence.is_complete()) + { + assert!( + tokio::time::Instant::now() < deadline, + "System 发现不得被普通 server 的慢握手挡住" + ); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + assert!( + pool.get_client("gated-ordinary").is_none(), + "普通 server 尚未放行,不应已经写入句柄(说明它排在 System 之前)" + ); + + std::fs::write(&release, "").unwrap(); + init.await.unwrap(); + let ordinary = pool + .get_client("gated-ordinary") + .expect("放行后必须写入句柄"); + assert!( + matches!(ordinary.status, ClientStatus::Connected), + "放行后普通 server 必须完成连接,实际: {:?}", + ordinary.status + ); + + pool.begin_shutdown(); + tasks.begin_shutdown(); + let _ = tasks.shutdown().await; + assert!(pool.shutdown().await.is_complete()); +} diff --git a/peri-middlewares/src/mcp/mcp_v4_seam_test.rs b/peri-middlewares/src/mcp/mcp_v4_seam_test.rs new file mode 100644 index 000000000..fdebf7984 --- /dev/null +++ b/peri-middlewares/src/mcp/mcp_v4_seam_test.rs @@ -0,0 +1,652 @@ +//! D-02:MCP v4-part-1 的 crate 内 seam 测试。 +//! +//! 定位(主 plan §6 W5 / §8 契约 3、4;sub-plan D §7 D-02): +//! - 断言层次是 **crate 内可观察层**:`McpMiddleware` 启动闸门的返回值、经 +//! `StartupState` 提交的候选,以及 `Middleware::collect_tools` 收集视图的 +//! direct 集合。首个 LLM 请求的 tools 入参由 B-07 在 `peri-acp` host seam +//! 断言(主 plan §5 R9);纯函数层语义由 C-INJ-02 的 `system_tools_test.rs` +//! 覆盖,本文件只在**接线后**的层上复核。 +//! - 本文件不调用 `build_session_tool_view`(`pub(super)`,只在 `peri-agent` +//! 内可断言),也不修改任何生产文件。 +//! +//! 夹具与证据边界(诚实声明): +//! - 客户端侧走真实 `serve_client_auto`:真实 rmcp lifecycle、真实 `peer_info` +//! 协商与真实 transport 关闭语义,闸门读到的协议证据不是伪造的。 +//! - 工具声明取自 fixture 写入的 `McpClientHandle::tools`(真实发现路径写入的 +//! 同一字段);配置清单与本代 `DiscoveryEvidence` 由测试按 B-02 的提交契约 +//! 显式发布,替代需要真实子进程 / HTTP 端点的 `initialize.rs` 路径。 +//! - 因此本文件证明「闸门 → 候选 → 收集视图」这条 crate 内链路的分类与 +//! namespace 解析,不证明 transport 端到端,也不构成五个 MCP 迁移完成的证据。 + +use std::sync::Arc; +use std::time::Duration; + +use peri_acp_types::plugin::McpServerConfig; +use peri_agent::error::{AgentError, AgentResult}; +use peri_agent::middleware::{capabilities as hook_state, r#trait::Middleware}; +use peri_agent::session::tool_catalog::{StartupRequiredTool, StartupToolUpdate}; +use peri_agent::tools::BaseTool; +use rmcp::model::Tool; +use rmcp::service::RoleClient; +use rmcp::transport::async_rw::AsyncRwTransport; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf}; + +use super::apps::McpCapabilityProfile; +use super::client::{ + serve_client_auto, ClientStatus, DiscoveryEvidence, McpClientHandle, McpClientPool, + OAuthStatus, SystemMcpManifest, +}; +use super::McpMiddleware; + +/// server 名刻意含 `:`(与仓库内 `plugin:p1:srv1` 形态一致):namespace +/// 解析必须经净化,不能靠裸拼接。 +const PLUGIN_SERVER: &str = "plugin:p1:workspace"; +/// 该 server 的净化后 namespace 前缀。 +const PLUGIN_NAMESPACE: &str = "mcp__plugin_p1_workspace__"; +/// 原始工具名含 `.`(净化后为 `read_file`),用于验证「配置匹配原始名」。 +const PLUGIN_REQUIRED_TOOL: &str = "read.file"; + +/// `collect_tools` 固定追加的两个工具(非 MCP 静态 bridge)。 +const APPENDED_TOOLS: [&str; 2] = ["mcp_read_resource", "DiscoverMCP"]; + +// ─── 夹具 ──────────────────────────────────────────────────────────────────── + +type SeamTransport = AsyncRwTransport, WriteHalf>; + +fn seam_transport(client: DuplexStream) -> SeamTransport { + let (read, write) = tokio::io::split(client); + AsyncRwTransport::new(read, write) +} + +/// 最小假 MCP 对端:`initialize` 成功;其余带 id 的请求(含 `server/discover`) +/// 回 Method not found,驱动 rmcp Auto lifecycle 回退 legacy initialize;通知 +/// 无 id,不回响应。 +fn spawn_fake_peer(server: DuplexStream) -> tokio::task::JoinHandle<()> { + let (server_read, mut server_write) = tokio::io::split(server); + tokio::spawn(async move { + let mut lines = BufReader::new(server_read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(request) = serde_json::from_str::(&line) else { + continue; + }; + let response = match request["method"].as_str() { + Some("initialize") => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], "result": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "mcp-seam-fixture", "version": "1" } + } + }), + _ if request["id"].is_null() => continue, + _ => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], + "error": { "code": -32601, "message": "Method not found" } + }), + }; + if server_write + .write_all(format!("{response}\n").as_bytes()) + .await + .is_err() + { + break; + } + if server_write.flush().await.is_err() { + break; + } + } + }) +} + +fn system_config(required_tools: Option>, timeout_ms: Option) -> McpServerConfig { + McpServerConfig { + command: Some("mcp-seam-fixture".to_string()), + args: None, + env: None, + url: None, + headers: None, + oauth: None, + disabled: None, + protocol_version: None, + subscriptions: None, + system_mcp: Some(true), + system_mcp_tools: required_tools, + system_mcp_timeout: timeout_ms, + source: None, + } +} + +fn ordinary_config() -> McpServerConfig { + McpServerConfig { + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, + ..system_config(None, None) + } +} + +fn fixture_tool(name: &str, input_schema: serde_json::Value) -> Tool { + serde_json::from_value(serde_json::json!({ + "name": name, + "description": "seam fixture", + "inputSchema": input_schema + })) + .expect("fixture tool 必须能被 rmcp Tool 接收") +} + +fn object_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }) +} + +fn connected_handle(name: &str, tools: Vec) -> Arc { + Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools, + resources: vec![], + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }) +} + +struct SeamFixture { + pool: Arc, + peers: Vec>, +} + +impl SeamFixture { + fn new() -> Self { + Self { + pool: Arc::new(McpClientPool::new_empty()), + peers: Vec::new(), + } + } + + fn middleware(&self) -> McpMiddleware { + McpMiddleware::new(Arc::clone(&self.pool)) + } + + fn config(&self, name: &str, config: McpServerConfig) { + self.pool.configs.write().insert(name.to_string(), config); + } + + /// 真实握手并提交连接;返回(句柄,pool 登记代际)。 + async fn connect(&mut self, name: &str, tools: Vec) -> (Arc, u64) { + let (client, server) = tokio::io::duplex(4096); + self.peers.push(spawn_fake_peer(server)); + let service = serve_client_auto( + seam_transport(client), + None, + None, + &McpCapabilityProfile::default(), + Duration::from_secs(5), + ) + .await + .expect("fixture 握手不得超时") + .expect("fixture 握手不得失败"); + let service = self.pool.retain_service(service); + let peer = service.peer().clone(); + let handle = Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: Some(peer), + tools, + resources: vec![], + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }); + assert!( + handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .is_some(), + "fixture 必须完成真实 peer_info 协商" + ); + assert!( + self.pool + .try_commit_connection(name.to_string(), Arc::clone(&handle), service) + .is_ok(), + "fixture 连接必须被 pool 接受" + ); + let generation = self.pool.handle_generation(&handle); + (handle, generation) + } + + /// 发布配置清单并提交本代发现证据(等价于 B-02 在 initialize / reconnect / + /// OAuth 成功路径上的提交点;`generation` 取自当前已提交句柄)。 + fn ready(&self, name: &str, generation: u64) { + self.pool.publish_system_manifest(SystemMcpManifest::Loaded); + self.pool + .commit_discovery_evidence(name, DiscoveryEvidence::discovered(generation)); + } + + async fn shutdown(self) { + self.pool.begin_shutdown(); + let _ = self.pool.shutdown().await; + for task in self.peers { + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("假 server 必须随连接关闭退出") + .expect("假 server 任务不得 panic"); + } + } +} + +/// 闸门状态探针:候选只经 `StartupState` 传递,不落 middleware 内部字段。 +/// +/// `set_active_middleware` 由 chain 在调用 hook 前写入(`peri-agent` 侧),本文件 +/// 直接调用 middleware hook,故只实现为空操作。 +#[derive(Default)] +struct StartupProbe { + staged: Option, + stage_calls: usize, +} + +impl hook_state::StartupState for StartupProbe { + fn set_active_middleware(&mut self, _middleware_name: &str) {} + + fn stage_startup_tools(&mut self, update: StartupToolUpdate) -> AgentResult<()> { + self.stage_calls += 1; + if self.staged.is_none() { + self.staged = Some(update); + } + Ok(()) + } + + fn take_startup_tools(&mut self) -> Option { + self.staged.take() + } +} + +// ─── 视图读取 helpers ──────────────────────────────────────────────────────── + +/// 切出收集视图中的静态 bridge 前缀:尾部两个是固定追加的 resource / discover 工具。 +fn static_bridges(view: &[Box]) -> &[Box] { + let split = view + .len() + .checked_sub(APPENDED_TOOLS.len()) + .expect("收集视图必须包含追加的 resource / discover 工具"); + let (static_tools, appended) = view.split_at(split); + let names: Vec<&str> = appended.iter().map(|tool| tool.name()).collect(); + assert_eq!(names, APPENDED_TOOLS, "追加工具与顺序必须保持原样"); + static_tools +} + +/// 收集顺序来自 pool 的 `HashMap` 遍历,**不是**契约的一部分:比较集合时先排序。 +fn sorted(mut names: Vec) -> Vec { + names.sort(); + names +} + +fn tool_names(tools: &[Box]) -> Vec { + tools + .iter() + .map(|tool| tool.name().to_string()) + .collect::>() +} + +fn direct_names_of<'a>(tools: impl Iterator) -> Vec { + sorted( + tools + .filter(|tool| tool.is_direct()) + .map(|tool| tool.name().to_string()) + .collect(), + ) +} + +fn all_deferred<'a>(tools: impl Iterator) -> bool { + tools.into_iter().all(|tool| !tool.is_direct()) +} + +fn count_named(tools: &[Box], name: &str) -> usize { + tools.iter().filter(|tool| tool.name() == name).count() +} + +// ─── 契约 3:required 工具经所属 namespace 解析后直接进入工具视图 ───────────── + +/// required 工具在**所属 server 的原始工具名**上解析,落到净化后的 namespace +/// 名,并在收集视图的 direct 集合中恰好出现一次;同 server 的其它工具与其它 +/// MCP 的工具保持 deferred,且闸门候选与收集视图的 direct 集合一致。 +#[tokio::test] +async fn required_tool_resolves_through_server_namespace_into_direct_view() { + let mut fixture = SeamFixture::new(); + fixture.config( + PLUGIN_SERVER, + system_config(Some(vec![PLUGIN_REQUIRED_TOOL.to_string()]), None), + ); + fixture.config("aux", ordinary_config()); + let (_, generation) = fixture + .connect( + PLUGIN_SERVER, + vec![ + fixture_tool(PLUGIN_REQUIRED_TOOL, object_schema()), + fixture_tool("glob.file", object_schema()), + ], + ) + .await; + fixture + .connect("aux", vec![fixture_tool("write.file", object_schema())]) + .await; + fixture.ready(PLUGIN_SERVER, generation); + + let mw = fixture.middleware(); + let mut probe = StartupProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("ready 后闸门必须放行"); + assert_eq!(probe.stage_calls, 1, "一次准入只提交一个候选"); + let update = probe.staged.expect("System 依赖就绪必须提交候选"); + + let effective = format!("{PLUGIN_NAMESPACE}read_file"); + assert_eq!( + update.required, + vec![StartupRequiredTool { + server_name: PLUGIN_SERVER.to_string(), + original_tool_name: PLUGIN_REQUIRED_TOOL.to_string(), + effective_tool_name: effective.clone(), + }], + "必需工具身份必须用净化后的 namespace 名,原始名单独保留" + ); + assert_eq!( + direct_names_of(update.tools.iter().map(|tool| tool.as_ref())), + vec![effective.clone()], + "候选内只有必需工具被提升 direct" + ); + assert_eq!( + update.tools.len(), + 3, + "候选是整批静态工具(含非 System server 的 deferred 工具)" + ); + + let view = ::collect_tools(&mw, "/tmp"); + let bridges = static_bridges(&view); + assert_eq!(bridges.len(), 3, "整批静态 bridge 各注册一次"); + for name in [ + effective.clone(), + format!("{PLUGIN_NAMESPACE}glob_file"), + "mcp__aux__write_file".to_string(), + ] { + assert_eq!(count_named(bridges, &name), 1, "{name} 必须恰好注册一次"); + } + assert_eq!( + direct_names_of(bridges.iter().map(|tool| tool.as_ref())), + direct_names_of(update.tools.iter().map(|tool| tool.as_ref())), + "闸门候选与收集视图的 direct 集合必须一致" + ); + assert_eq!( + sorted(tool_names(bridges)), + vec![ + "mcp__aux__write_file".to_string(), + format!("{PLUGIN_NAMESPACE}glob_file"), + effective.clone(), + ] + ); + assert!( + all_deferred( + bridges + .iter() + .map(|tool| tool.as_ref()) + .filter(|tool| tool.name() != effective) + ), + "非 required 的 MCP 工具必须保持 deferred" + ); + + fixture.shutdown().await; +} + +/// 配置项只在原始工具名上精确匹配:既不做净化折叠(`read_file`),也不解析 +/// effective name 前缀(`mcp__…__read_file`)。两种写法都必须 fatal 且不注入 direct。 +#[tokio::test] +async fn required_tool_matching_uses_original_name_not_effective_name() { + for configured in ["read_file", "mcp__plugin_p1_workspace__read_file"] { + let mut fixture = SeamFixture::new(); + fixture.config( + PLUGIN_SERVER, + system_config(Some(vec![configured.to_string()]), None), + ); + let (_, generation) = fixture + .connect( + PLUGIN_SERVER, + vec![fixture_tool(PLUGIN_REQUIRED_TOOL, object_schema())], + ) + .await; + fixture.ready(PLUGIN_SERVER, generation); + + let mw = fixture.middleware(); + let mut probe = StartupProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("非原始工具名不得解析"); + assert!( + matches!( + &error, + AgentError::MiddlewareError { reason, .. } + if reason.contains("未提供必需工具") && reason.contains(configured) + ), + "{configured} 期望 MissingTool 投影: {error:?}" + ); + assert!(probe.staged.is_none(), "失败不得提交候选"); + + let view = ::collect_tools(&mw, "/tmp"); + let bridges = static_bridges(&view); + assert_eq!( + count_named(bridges, &format!("{PLUGIN_NAMESPACE}read_file")), + 1, + "工具本身仍在集合内(只是没有 direct 提升)" + ); + assert!( + all_deferred(bridges.iter().map(|tool| tool.as_ref())), + "校验失败不得留下部分 direct: {:?}", + tool_names(bridges) + ); + + fixture.shutdown().await; + } +} + +/// 必需工具只在**所属 server** 的 namespace 内解析:另一台 server 提供的同名 +/// 原始工具不得被借用,错误必须指回声明方。 +#[tokio::test] +async fn required_tool_does_not_resolve_across_server_namespaces() { + let mut fixture = SeamFixture::new(); + fixture.config( + "alpha", + system_config(Some(vec!["remote_only".to_string()]), None), + ); + // beta 是 System 但只要求 ready:它提供的 remote_only 不得被 alpha 借用。 + fixture.config("beta", system_config(Some(vec![]), None)); + let (_, alpha_generation) = fixture + .connect("alpha", vec![fixture_tool("local_only", object_schema())]) + .await; + let (_, beta_generation) = fixture + .connect("beta", vec![fixture_tool("remote_only", object_schema())]) + .await; + fixture.ready("alpha", alpha_generation); + fixture.ready("beta", beta_generation); + + let mw = fixture.middleware(); + let mut probe = StartupProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("跨 server namespace 不得命中"); + assert!( + matches!( + &error, + AgentError::MiddlewareError { reason, .. } + if reason.contains("\"alpha\"") && reason.contains("remote_only") + ), + "错误必须指回声明方 server: {error:?}" + ); + assert!(probe.staged.is_none(), "失败不得提交候选"); + + let view = ::collect_tools(&mw, "/tmp"); + let bridges = static_bridges(&view); + assert_eq!( + sorted(tool_names(bridges)), + vec![ + "mcp__alpha__local_only".to_string(), + "mcp__beta__remote_only".to_string(), + ] + ); + assert!( + all_deferred(bridges.iter().map(|tool| tool.as_ref())), + "一台失败不得让另一台留下 direct" + ); + + fixture.shutdown().await; +} + +// ─── 契约 3/4:错误路径与空数组在视图层零注入 ──────────────────────────────── + +/// 工具缺失与 schema 结构非法两条错误路径:闸门 fatal、不提交候选,收集视图 +/// 零 direct,且相关工具仍被收集(deferred,不是删除)。 +#[tokio::test] +async fn missing_and_invalid_schema_required_tools_leave_zero_direct_in_view() { + let cases = [ + ( + "工具缺失", + vec![fixture_tool("glob.file", object_schema())], + "未提供必需工具", + ), + ( + "schema 非法", + vec![fixture_tool( + PLUGIN_REQUIRED_TOOL, + serde_json::json!({ "type": "object", "properties": 42 }), + )], + "input schema 结构非法", + ), + ]; + + for (label, tools, expected_reason) in cases { + let mut fixture = SeamFixture::new(); + fixture.config( + PLUGIN_SERVER, + system_config(Some(vec![PLUGIN_REQUIRED_TOOL.to_string()]), None), + ); + let (_, generation) = fixture.connect(PLUGIN_SERVER, tools).await; + fixture.ready(PLUGIN_SERVER, generation); + + let mw = fixture.middleware(); + let mut probe = StartupProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("错误路径必须阻止启动"); + assert!( + !matches!(error, AgentError::Interrupted), + "{label}: 校验失败不是取消" + ); + assert!( + matches!( + &error, + AgentError::MiddlewareError { reason, .. } if reason.contains(expected_reason) + ), + "{label} 期望固定文案: {error:?}" + ); + assert!(probe.staged.is_none(), "{label}: 失败不得提交候选"); + + let view = ::collect_tools(&mw, "/tmp"); + let bridges = static_bridges(&view); + assert_eq!(bridges.len(), 1, "{label}: 工具本身不得被删除"); + assert!( + all_deferred(bridges.iter().map(|tool| tool.as_ref())), + "{label}: 不得留下部分 direct" + ); + + fixture.shutdown().await; + } +} + +/// 契约 4:`system_mcp_tools: []` 只要求 ready,不注入额外工具 —— 候选与收集 +/// 视图的 direct 增量都为 0,该 server 的普通工具仍被收集且保持 deferred。 +#[tokio::test] +async fn empty_required_array_injects_zero_direct_tools() { + let mut fixture = SeamFixture::new(); + fixture.config("sys", system_config(Some(vec![]), None)); + let (_, generation) = fixture + .connect( + "sys", + vec![ + fixture_tool(PLUGIN_REQUIRED_TOOL, object_schema()), + fixture_tool("glob.file", object_schema()), + ], + ) + .await; + fixture.ready("sys", generation); + + let mw = fixture.middleware(); + let mut probe = StartupProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("空数组只验证 ready,不阻塞启动"); + let update = probe.staged.expect("System 依赖就绪必须提交候选"); + assert!(update.required.is_empty(), "空数组不得产生必需工具身份"); + assert_eq!(update.tools.len(), 2, "整批静态工具仍必须发布"); + assert!( + all_deferred(update.tools.iter().map(|tool| tool.as_ref())), + "空数组不得提升任何 direct 工具" + ); + + let view = ::collect_tools(&mw, "/tmp"); + let bridges = static_bridges(&view); + assert_eq!( + sorted(tool_names(bridges)), + vec![ + "mcp__sys__glob_file".to_string(), + "mcp__sys__read_file".to_string(), + ], + "零注入不等于删除该 MCP 的工具" + ); + assert!( + all_deferred(bridges.iter().map(|tool| tool.as_ref())), + "收集视图的 direct 集合必须为空" + ); + + fixture.shutdown().await; +} + +/// 边界面:配置清单未发布(Pending)时 `configs` 不是可信的 System 依赖事实源, +/// 即使句柄已 `Connected` 且有工具声明,也不得提升 direct。 +#[test] +fn pending_manifest_never_promotes_direct_tools_in_view() { + let pool = Arc::new(McpClientPool::new_empty()); + pool.configs.write().insert( + PLUGIN_SERVER.to_string(), + system_config(Some(vec![PLUGIN_REQUIRED_TOOL.to_string()]), None), + ); + pool.clients.write().insert( + PLUGIN_SERVER.to_string(), + connected_handle( + PLUGIN_SERVER, + vec![fixture_tool(PLUGIN_REQUIRED_TOOL, object_schema())], + ), + ); + + let mw = McpMiddleware::new(Arc::clone(&pool)); + let view = ::collect_tools(&mw, "/tmp"); + let bridges = static_bridges(&view); + assert_eq!( + sorted(tool_names(bridges)), + vec![format!("{PLUGIN_NAMESPACE}read_file")], + "deferred bridge 仍应被收集" + ); + assert!( + all_deferred(bridges.iter().map(|tool| tool.as_ref())), + "清单未发布不得提升 direct" + ); +} diff --git a/peri-middlewares/src/mcp/middleware.rs b/peri-middlewares/src/mcp/middleware.rs index fe8182cfa..c3f433f90 100644 --- a/peri-middlewares/src/mcp/middleware.rs +++ b/peri-middlewares/src/mcp/middleware.rs @@ -1,7 +1,10 @@ use peri_agent::middleware::capabilities as hook_state; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, }; use async_trait::async_trait; @@ -13,19 +16,109 @@ use peri_acp_types::system_reminder::{ }; use peri_agent::{ agent::AgentCancellationToken, + error::AgentError, middleware::r#trait::Middleware, - session::{MessageKind, MessageSource as QueueMessageSource, QueuedMessage}, + session::{ + tool_catalog::{StartupRequiredTool, StartupToolUpdate}, + MessageKind, MessageSource as QueueMessageSource, QueuedMessage, + }, tools::BaseTool, }; use serde_json::json; use super::{ - client::{ClientStatus, McpClientPool}, + client::{ + redact_mcp_error, ClientStatus, McpClientPool, NegotiatedSystemMcp, SystemMcpManifest, + SystemReadinessError, + }, discover_tool::DiscoverMCPTool, resource_tool::McpResourceTool, - tool_bridge::build_tool_bridges, + system_tools::{prepare_system_tools, SystemToolError}, + tool_bridge::{ + build_tool_bridges_visible_to, build_typed_tool_bridges_visible_to, McpToolBridge, + }, }; +/// 启动准入错误文案的展示上限(字符)。固定模板本身远短于此;该上限只约束 +/// 由 MCP 声明(server / tool 名)撑长的部分。 +const MAX_STARTUP_REASON_CHARS: usize = 512; + +/// 用户可见启动错误文本的最后一道清洗:控制字符折叠为空格、URL query 与凭据 +/// 形态遮蔽、限长。 +/// +/// ACP 不会替任意 MCP cause 自动脱敏(`AgentError::user_facing_message` 走 +/// `Display`),因此清洗必须在 MCP 边界完成;只保留阶段与安全类别,不输出 +/// env / headers / URL 认证信息 / 协议 payload / schema 默认值。 +fn safe_startup_reason(raw: &str) -> String { + let folded: String = raw + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect(); + redact_mcp_error(&folded) + .chars() + .take(MAX_STARTUP_REASON_CHARS) + .collect() +} + +/// 本次 System MCP 准入的候选快照(冻结签名:IF-M3 / sub-plan B §4.3)。 +/// +/// `bridges` 是**整批**静态 MCP bridge(必需项已提升 direct、其余保持 +/// deferred)。它只在本次 `before_react_start` 内存在并经 `StartupState` 提交, +/// 不落 middleware 字段、不跨 loop 复用。 +pub(crate) struct SystemReadySnapshot { + pub negotiated: Vec, + pub bridges: Vec, +} + +impl SystemReadySnapshot { + /// 无 System 依赖时不产生 startup update(普通 MCP 的 pending/failed 不阻塞)。 + fn has_system_dependency(&self) -> bool { + !self.negotiated.is_empty() + } + + /// 必需工具身份(原始名 + effective name),供目录提交后复核「可直达」。 + /// + /// `prepare_system_tools` 成功后每个必需项在整批 bridge 中恰好命中一次; + /// 缺失只能来自并发换代,按 fail-closed 返回错误,不发布 ready。 + fn required_tools(&self) -> Result, SystemToolError> { + let mut required: Vec = Vec::new(); + for item in &self.negotiated { + for tool in &item.requirement.required_tools { + let already = required.iter().any(|entry| { + entry.server_name == item.requirement.server + && entry.original_tool_name == *tool + }); + if already { + // 重复配置幂等:不产生第二份注册。 + continue; + } + let bridge = self.bridges.iter().find(|bridge| { + bridge.mcp_server_name() == Some(item.requirement.server.as_str()) + && bridge.original_tool_name() == tool + }); + let Some(bridge) = bridge else { + return Err(SystemToolError::MissingTool { + server: item.requirement.server.clone(), + tool: tool.clone(), + }); + }; + required.push(StartupRequiredTool { + server_name: item.requirement.server.clone(), + original_tool_name: tool.clone(), + effective_tool_name: bridge.name().to_string(), + }); + } + } + Ok(required) + } +} + /// MCP 中间件 —— 将所有已连接 MCP 服务器的工具和资源注入 ReAct 循环, /// 并向模型通报 MCP 连接状态(首 turn 概览 + 运行中上下线变化)。 pub struct McpMiddleware { @@ -43,6 +136,9 @@ pub struct McpMiddleware { /// 元数据面发现结果经 [`crate::mcp::skill_discovery::mcp_route_entries`] /// 转换后写本注册表。 command_registry: Option>, + /// 会话 id:MCP 事实面(工具 / 概览 / 发现)按 ACP 连接归属过滤, + /// `None` = 未装配会话上下文(print 模式 / 既有测试),不过滤。 + session_id: Option, /// session 取消令牌(发现任务持有;触发后 before_agent 不再投影/spawn) cancel: AgentCancellationToken, /// 是否已向模型提示过 tool search 用法(每个会话实例恰好一次) @@ -56,11 +152,18 @@ impl McpMiddleware { pool, registry: None, command_registry: None, + session_id: None, cancel: AgentCancellationToken::new(), hint_sent: AtomicBool::new(false), } } + /// 注入会话 id(装配槽位调用):ACP 声明的 server 只对本会话可见。 + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } + /// Use a deployment-owned pool for static tool bridges while retaining the session-projected /// pool for resources and discovery. pub fn with_tool_pool(mut self, tool_pool: Arc) -> Self { @@ -102,6 +205,7 @@ impl McpMiddleware { &self.pool, self.registry.as_ref(), self.command_registry.as_ref(), + self.session_id.as_deref(), &self.cancel, ); } @@ -114,6 +218,7 @@ pub(crate) fn run_ensure_discovery( pool: &Arc, registry: Option<&Arc>, command_registry: Option<&Arc>, + session_id: Option<&str>, cancel: &AgentCancellationToken, ) { let Some(registry) = registry else { @@ -123,7 +228,7 @@ pub(crate) fn run_ensure_discovery( return; } let connected: Vec<(String, HandleToken)> = pool - .get_all_clients() + .get_all_clients_visible_to(session_id) .into_iter() .map(|h| { let t: HandleToken = h.clone(); @@ -207,13 +312,23 @@ pub(crate) fn run_ensure_discovery( /// Completed 跳过 / 重连 ptr_eq);已连接 server 立即发现,连接中的 /// server 空跑,由首 turn 装配与连接完成事件兜底。cancel 持调用方 session /// token,session 关闭即早退。 +/// +/// `session_id` 必填:预热面按会话归属过滤(本会话声明的 ACP 连接才预热), +/// 与装配面 [`McpMiddleware::with_session_id`] 同口径。 pub fn prewarm_discovery( pool: &Arc, registry: &Arc, command_registry: &Arc, + session_id: &str, cancel: &AgentCancellationToken, ) { - run_ensure_discovery(pool, Some(registry), Some(command_registry), cancel); + run_ensure_discovery( + pool, + Some(registry), + Some(command_registry), + Some(session_id), + cancel, + ); } /// 挂接 pool 连接完成事件(决策 B):Connected 状态变化 → 触发幂等发现, @@ -253,6 +368,11 @@ pub fn attach_connection_notifier( &discovery_pool, discovery_registry.as_ref(), discovery_cmd.as_ref(), + // 连接事件通知器是部署级的(多会话可能共享一个 pool):事件 + // 文本不携带会话身份,这里按「不过滤」推进——会话级 ACP 连接 + // 不产生状态变化通知(`record_status_change` 按归属跳过), + // 走到本分支的只会是部署级 server。 + None, &discovery_cancel, ); } @@ -260,11 +380,174 @@ pub fn attach_connection_notifier( } impl McpMiddleware { + /// System MCP 启动闸门(冻结签名:IF-M3 / sub-plan B §4.3)。 + /// + /// 执行顺序:本次入场统一计时 → 等待 transport / initialize / 能力协商 / + /// live `tools/list` → 一次 typed 构建整批静态 bridge → + /// [`prepare_system_tools`] 逐项解析与校验 → 提交前复核(取消 / pool 开闭 / + /// 代际 / deadline)。 + /// + /// 返回**待目录提交的 candidate**:本函数不发布 ready、不改写 catalog。任何 + /// 失败都返回类型化错误,不 `warn` 后继续、不返回空集合、不产生部分结果。 + pub(crate) async fn await_system_ready( + &self, + ) -> Result { + // 1R 入场即计时:initialize / list / 必需工具校验之间不重置 deadline, + // 多台 server 并发计时,不串行相加。 + let started_at = tokio::time::Instant::now(); + let negotiated = self + .tool_pool + .await_system_connections(&self.cancel, started_at) + .await?; + if negotiated.is_empty() { + // 无 System 依赖:不产生 startup update;普通工具走原收集路径。 + return Ok(SystemReadySnapshot { + negotiated, + bridges: Vec::new(), + }); + } + // 必需工具来自本次协商的 requirement(含空数组:该 server 只要求 ready)。 + // 静态 bridge 一律取 deployment `tool_pool`,不用会混入动态投影的 session + // projection。 + let required: BTreeMap> = negotiated + .iter() + .map(|item| { + ( + item.requirement.server.clone(), + item.requirement.required_tools.clone(), + ) + }) + .collect(); + let typed = + build_typed_tool_bridges_visible_to(&self.tool_pool, self.session_id.as_deref()); + let bridges = prepare_system_tools(typed, &required) + .map_err(|source| SystemReadinessError::RequiredTools { source })?; + // `prepare_system_tools` 是同步校验,不 yield:返回后必须重新核对代际 / + // pool 开闭 / 取消 / deadline,避免用旧代快照发布 ready。 + self.recheck_system_snapshot(&negotiated, started_at)?; + Ok(SystemReadySnapshot { + negotiated, + bridges, + }) + } + + /// 提交前复核:任何一项不成立都不得发布 ready。 + fn recheck_system_snapshot( + &self, + negotiated: &[NegotiatedSystemMcp], + started_at: tokio::time::Instant, + ) -> Result<(), SystemReadinessError> { + if self.cancel.is_cancelled() { + return Err(SystemReadinessError::Cancelled); + } + if !self.tool_pool.is_open() { + return Err(SystemReadinessError::PoolClosed); + } + let now = tokio::time::Instant::now(); + for item in negotiated { + let server = item.requirement.server.as_str(); + let current = self + .tool_pool + .get_client(server) + .map(|handle| self.tool_pool.handle_generation(&handle)); + if current != Some(item.generation) { + return Err(SystemReadinessError::ConnectionChanged { + server: server.to_string(), + }); + } + if now >= started_at + item.requirement.timeout { + return Err(SystemReadinessError::Timeout { + server: server.to_string(), + timeout_ms: u64::try_from(item.requirement.timeout.as_millis()) + .unwrap_or(u64::MAX), + }); + } + } + Ok(()) + } + + /// 候选 → 目录提交 DTO;无 System 依赖时返回 `None`(不产生 startup update)。 + fn startup_tool_update( + &self, + snapshot: &SystemReadySnapshot, + ) -> Result, SystemToolError> { + if !snapshot.has_system_dependency() { + return Ok(None); + } + Ok(Some(StartupToolUpdate { + tools: snapshot + .bridges + .iter() + .cloned() + .map(|bridge| Arc::new(bridge) as Arc) + .collect::>>(), + required: snapshot.required_tools()?, + })) + } + + /// 闸门错误 → Agent 边界错误(IF-M3 两条硬约束 + 安全文案)。 + /// + /// `Cancelled` → `Interrupted`(取消不是失败,不算 fatal);**timeout 不是 + /// 取消**,与其它变体一起映射 fatal `MiddlewareError`,reason 为固定安全文案。 + fn startup_agent_error(&self, error: SystemReadinessError) -> AgentError { + match error.into_agent_error(self.name()) { + AgentError::MiddlewareError { middleware, reason } => AgentError::MiddlewareError { + middleware, + reason: safe_startup_reason(&reason), + }, + other => other, + } + } + + /// 本批收集的静态 MCP bridge 集合。 + /// + /// System 依赖已具备可信配置清单时使用 [`prepare_system_tools`] 的**整批** + /// 结果(必需项 direct、普通 deferred)整体替换 deferred 收集,禁止在旧集合上 + /// 再 append 一份所需工具。准入候选本身不落 middleware 字段(IF-M5):这里用与 + /// 闸门同一套纯函数按当次 handle 快照推导,不跨 loop / 跨 session 复用旧代标记。 + /// + /// 校验不通过(缺工具 / schema / 可见性 / 有效名碰撞)时退回既有 deferred + /// 收集:该结果不构成 ready,闸门仍会在进入 Compact 前以 fatal 结束本次 loop。 + fn static_tool_bridges(&self) -> Vec> { + match self.prepared_static_bridges() { + Some(prepared) => prepared + .into_iter() + .map(|bridge| Box::new(bridge) as Box) + .collect(), + None => build_tool_bridges_visible_to(&self.tool_pool, self.session_id.as_deref()), + } + } + + /// `Some(整批 prepared bridge)` 仅当配置清单已完整发布且必需工具校验通过。 + fn prepared_static_bridges(&self) -> Option> { + // 清单未发布(Pending / Failed)时 `configs` 不是可信的 System 依赖事实源。 + if self.tool_pool.system_manifest() != SystemMcpManifest::Loaded { + return None; + } + let required: BTreeMap> = self + .tool_pool + .system_requirements() + .into_iter() + .map(|requirement| (requirement.server, requirement.required_tools)) + .collect(); + if required.is_empty() { + // 无 System 依赖:prepared 与 deferred 集合等价,保持原路径。 + return None; + } + prepare_system_tools( + build_typed_tool_bridges_visible_to(&self.tool_pool, self.session_id.as_deref()), + &required, + ) + .ok() + } + /// 首 turn 概览:MCP 基础情况(服务器名 + 状态 + 工具数),失败报名字 + 错误。 /// /// 无任何已配置服务器时返回 `None`(零噪音,不注入)。 fn overview_text(&self) -> Option { - let infos = self.pool.all_server_infos(); + let infos = self + .pool + .all_server_infos_visible_to(self.session_id.as_deref()); if infos.is_empty() { return None; } @@ -364,23 +647,31 @@ impl Middleware for McpMiddleware { } fn collect_tools(&self, _cwd: &str) -> Vec> { - let mut tools = build_tool_bridges(&self.tool_pool); + // 整批替换初始 Vec(不是 append):System 依赖就绪时 prepared 集合已包含 + // 全部静态 bridge(必需项 direct),再 extend 会重复注册同一工具。 + let mut tools = self.static_tool_bridges(); - tools.push(Box::new(McpResourceTool::new( + let resource_tool = McpResourceTool::new( Arc::clone(&self.pool), // 未装配 session 注册表(print 模式/既有测试)→ 空注册表: // 无条目 = 无内容绑定校验(与现状一致)。 self.registry .clone() .unwrap_or_else(|| Arc::new(McpSkillRegistry::new())), - ))); + ); + tools.push(Box::new(match self.session_id.clone() { + Some(session_id) => resource_tool.with_session_id(session_id), + None => resource_tool, + })); - tools.push(Box::new( - DiscoverMCPTool::new(Arc::clone(&self.pool), self.registry.clone()) - .with_agent_registry(Arc::new(super::agent_registry::McpAgentRegistry::new( - Arc::clone(&self.pool), - ))), - )); + let discover_tool = DiscoverMCPTool::new(Arc::clone(&self.pool), self.registry.clone()) + .with_agent_registry(Arc::new(super::agent_registry::McpAgentRegistry::new( + Arc::clone(&self.pool), + ))); + tools.push(Box::new(match self.session_id.clone() { + Some(session_id) => discover_tool.with_session_id(session_id), + None => discover_tool, + })); tools } @@ -444,6 +735,30 @@ impl Middleware for McpMiddleware { Ok(()) } + /// 启动闸门:System MCP 未完成 transport / initialize / 能力协商 / 必需工具 + /// 检查前不得进入 Compact / Reason / Act(契约 2)。 + /// + /// - 无 System 依赖时零动作:普通 MCP 的 pending / failed 永不阻塞启动; + /// - 候选经 `StartupState` 暂存,失败或取消时随本次 state 丢弃,不落 middleware + /// 字段、不发布 ready、不写宿主共享工具表; + /// - 既有 discovery 与状态通知行为不变(仍在 `before_agent` / `before_model`)。 + async fn before_react_start( + &self, + state: &mut dyn hook_state::StartupState, + ) -> peri_agent::error::AgentResult<()> { + let snapshot = self + .await_system_ready() + .await + .map_err(|error| self.startup_agent_error(error))?; + match self.startup_tool_update(&snapshot) { + Ok(Some(update)) => state.stage_startup_tools(update), + Ok(None) => Ok(()), + Err(source) => { + Err(self.startup_agent_error(SystemReadinessError::RequiredTools { source })) + } + } + } + /// 每轮 ReAct 迭代:drain 状态变化缓冲并以 Info 消息推送(不唤醒循环; /// 空闲期变化由下个 turn 首轮 Receive 消费)。 async fn before_model( diff --git a/peri-middlewares/src/mcp/middleware_test.rs b/peri-middlewares/src/mcp/middleware_test.rs index 87b06c5de..274d9b427 100644 --- a/peri-middlewares/src/mcp/middleware_test.rs +++ b/peri-middlewares/src/mcp/middleware_test.rs @@ -743,7 +743,13 @@ async fn prewarm_discovery_triggers_idempotent_discovery() { // 模拟 session/new 路径(无 middleware 实例):预热 → 发现任务 // (peer=None)空回写落定。 - prewarm_discovery(&pool, ®, &cmd_reg, &AgentCancellationToken::new()); + prewarm_discovery( + &pool, + ®, + &cmd_reg, + "sess-1", + &AgentCancellationToken::new(), + ); wait_discovered(®, "srv").await; // 完成回写(A3 转换点同构):srv:hello 入投影 @@ -770,8 +776,20 @@ async fn prewarm_discovery_triggers_idempotent_discovery() { let after_first = counter.load(std::sync::atomic::Ordering::SeqCst); // 重复预热幂等:已 Discovered → 不重扫、不触发 on_change。 - prewarm_discovery(&pool, ®, &cmd_reg, &AgentCancellationToken::new()); - prewarm_discovery(&pool, ®, &cmd_reg, &AgentCancellationToken::new()); + prewarm_discovery( + &pool, + ®, + &cmd_reg, + "sess-1", + &AgentCancellationToken::new(), + ); + prewarm_discovery( + &pool, + ®, + &cmd_reg, + "sess-1", + &AgentCancellationToken::new(), + ); assert_eq!( counter.load(std::sync::atomic::Ordering::SeqCst), after_first, @@ -1117,3 +1135,774 @@ async fn before_agent_command_registry_plugin_server_disconnect_reconnect() { assert_eq!(r.entry.kind, CommandEntryKind::McpSkill); assert_eq!(state.messages().len(), 0, "断连/重连清理静默"); } + +// ─── System MCP 启动闸门(before_react_start,IF-M3 / IF-M4)────────────────── +// +// 夹具策略:只把外部对端换成内存 JSON-RPC 假 server,客户端侧走真实 +// `serve_client_auto`(真实 rmcp lifecycle / peer_info / transport 关闭语义); +// 配置清单与本代发现证据由测试显式发布,等价于 B-02 在 initialize / reconnect / +// OAuth 路径上的提交点。既有 `peer: None` + 手工 `Connected` 的夹具在闸门用例里 +// **不构成 ready 证据**,只用于负向断言。 +// +// 断言范围:本文件覆盖 crate 内可观察层(闸门返回值、候选、bridge 分类、收集 +// 结果)。首个 LLM 请求的 tools 入参与宿主终态由 B-07 在 `peri-acp` host seam +// 承担(主 plan §5 R9)。 + +use crate::mcp::apps::McpCapabilityProfile; +use crate::mcp::client::DiscoveryEvidence; +use peri_acp_types::plugin::McpServerConfig; +use rmcp::{service::RoleClient, transport::async_rw::AsyncRwTransport}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf}; + +/// 闸门状态探针:候选只经 `StartupState` 传递,不落 middleware 内部字段。 +#[derive(Default)] +struct StartupGateProbe { + staged: Option, +} + +impl hook_state::StartupState for StartupGateProbe { + fn set_active_middleware(&mut self, _middleware_name: &str) {} + + fn stage_startup_tools( + &mut self, + update: StartupToolUpdate, + ) -> peri_agent::error::AgentResult<()> { + assert!(self.staged.is_none(), "一次准入只允许登记一个候选"); + self.staged = Some(update); + Ok(()) + } + + fn take_startup_tools(&mut self) -> Option { + self.staged.take() + } +} + +type GateTransport = AsyncRwTransport, WriteHalf>; + +fn gate_transport(client: DuplexStream) -> GateTransport { + let (read, write) = tokio::io::split(client); + AsyncRwTransport::new(read, write) +} + +/// 假 MCP 对端:initialize 成功;其余请求(含 `server/discover`)回 +/// Method not found,驱动 Auto 生命周期回退 legacy initialize。通知无 id,不回响应。 +fn spawn_gate_peer(server: DuplexStream) -> tokio::task::JoinHandle<()> { + let (server_read, mut server_write) = tokio::io::split(server); + tokio::spawn(async move { + let mut lines = BufReader::new(server_read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(request) = serde_json::from_str::(&line) else { + continue; + }; + let response = match request["method"].as_str() { + Some("initialize") => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], "result": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "mcp-gate-fixture", "version": "1" } + } + }), + _ if request["id"].is_null() => continue, + _ => serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], + "error": { "code": -32601, "message": "Method not found" } + }), + }; + if server_write + .write_all(format!("{response}\n").as_bytes()) + .await + .is_err() + { + break; + } + if server_write.flush().await.is_err() { + break; + } + } + }) +} + +fn system_config(required_tools: Option>, timeout_ms: Option) -> McpServerConfig { + McpServerConfig { + command: Some("mcp-gate-fixture".to_string()), + args: None, + env: None, + url: None, + headers: None, + oauth: None, + disabled: None, + protocol_version: None, + subscriptions: None, + system_mcp: Some(true), + system_mcp_tools: required_tools, + system_mcp_timeout: timeout_ms, + source: None, + } +} + +fn ordinary_config() -> McpServerConfig { + McpServerConfig { + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, + ..system_config(None, None) + } +} + +fn fixture_tool(name: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { + serde_json::from_value(serde_json::json!({ + "name": name, + "description": "fixture", + "inputSchema": input_schema + })) + .expect("fixture tool 必须能被 rmcp Tool 接收") +} + +fn read_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }) +} + +struct GateFixture { + pool: Arc, + servers: Vec>, +} + +impl GateFixture { + fn new() -> Self { + Self { + pool: Arc::new(McpClientPool::new_pending()), + servers: Vec::new(), + } + } + + fn pool(&self) -> &Arc { + &self.pool + } + + fn config(&self, name: &str, config: McpServerConfig) { + self.pool.configs.write().insert(name.to_string(), config); + } + + fn publish_loaded(&self) { + self.pool.publish_system_manifest(SystemMcpManifest::Loaded); + } + + /// 真实握手并提交连接;返回(句柄,已登记代际)。 + async fn connect( + &mut self, + name: &str, + tools: Vec, + ) -> (Arc, u64) { + let (client, server) = tokio::io::duplex(4096); + self.servers.push(spawn_gate_peer(server)); + let service = crate::mcp::client::serve_client_auto( + gate_transport(client), + None, + None, + &McpCapabilityProfile::default(), + Duration::from_secs(5), + ) + .await + .expect("fixture 握手不得超时") + .expect("fixture 握手不得失败"); + let service = self.pool.retain_service(service); + let peer = service.peer().clone(); + let handle = Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: Some(peer), + tools, + resources: vec![], + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }); + assert!( + handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .is_some(), + "fixture 必须完成真实 peer_info 协商" + ); + assert!( + self.pool + .try_commit_connection(name.to_string(), Arc::clone(&handle), service) + .is_ok(), + "fixture 连接必须被 pool 接受" + ); + let generation = self.pool.handle_generation(&handle); + (handle, generation) + } + + /// 使某台 server 成为「本代发现完成」:清单已完整发布 + 本代成功证据。 + fn ready(&self, name: &str, generation: u64) { + self.publish_loaded(); + self.pool + .commit_discovery_evidence(name, DiscoveryEvidence::discovered(generation)); + } + + async fn shutdown(self) { + self.pool.begin_shutdown(); + let _ = self.pool.shutdown().await; + for task in self.servers { + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("假 server 必须随连接关闭退出") + .expect("假 server 任务不得 panic"); + } + } +} + +fn bridge_names(tools: &[std::sync::Arc]) -> Vec<(String, bool)> { + let mut names: Vec<(String, bool)> = tools + .iter() + .map(|tool| (tool.name().to_string(), tool.is_direct())) + .collect(); + names.sort(); + names +} + +/// 就绪后闸门放行并暂存**整批**静态 bridge:必需项 direct、其余 deferred, +/// 且候选只取自 deployment `tool_pool`(session projection 的伪 Connected 不参与)。 +#[tokio::test] +async fn system_mcp_ready_stages_candidate_with_direct_required_tool() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + let (_, generation) = fixture + .connect( + "sys", + vec![ + fixture_tool("Read", read_schema()), + fixture_tool("Glob", read_schema()), + ], + ) + .await; + fixture.ready("sys", generation); + + let projection = Arc::new(McpClientPool::new_empty()); + projection.clients.write().insert( + "dyn".to_string(), + make_connected_handle_with_tool("dyn", "shadow"), + ); + let mw = McpMiddleware::new(Arc::clone(&projection)).with_tool_pool(Arc::clone(fixture.pool())); + + let mut probe = StartupGateProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("ready 后闸门必须放行"); + + let update = probe.staged.expect("System 依赖就绪必须暂存候选"); + assert_eq!( + bridge_names(&update.tools), + vec![ + ("mcp__sys__Glob".to_string(), false), + ("mcp__sys__Read".to_string(), true), + ], + "整批静态 bridge:仅必需项 direct,投影池工具不得进入候选" + ); + assert_eq!( + update.required, + vec![StartupRequiredTool { + server_name: "sys".to_string(), + original_tool_name: "Read".to_string(), + effective_tool_name: "mcp__sys__Read".to_string(), + }] + ); + + fixture.shutdown().await; +} + +/// 必需工具缺失:闸门 fatal,不暂存候选(不发布 ready、不注入部分 direct)。 +#[tokio::test] +async fn system_mcp_missing_required_tool_blocks_startup() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + let (_, generation) = fixture + .connect("sys", vec![fixture_tool("Write", read_schema())]) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("缺必需工具必须阻止启动"); + + match error { + peri_agent::error::AgentError::MiddlewareError { + ref middleware, + ref reason, + } => { + assert_eq!(middleware, "McpMiddleware"); + assert!( + reason.contains("未提供必需工具") && reason.contains("Read"), + "错误需明确工具类别: {reason}" + ); + } + other => panic!("必须是 fatal MiddlewareError,实际 {other:?}"), + } + assert!(probe.staged.is_none(), "失败不得暂存候选"); + + fixture.shutdown().await; +} + +/// 必需工具 schema 结构非法:闸门 fatal,不暂存候选。 +#[tokio::test] +async fn system_mcp_invalid_schema_blocks_startup() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + let (_, generation) = fixture + .connect( + "sys", + vec![fixture_tool( + "Read", + serde_json::json!({ "type": "object", "properties": 42 }), + )], + ) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("非法 schema 必须阻止启动"); + + assert!( + matches!( + &error, + peri_agent::error::AgentError::MiddlewareError { reason, .. } + if reason.contains("input schema 结构非法") + ), + "期望 InvalidSchema 投影: {error:?}" + ); + assert!(probe.staged.is_none(), "失败不得暂存候选"); + + fixture.shutdown().await; +} + +/// 空数组契约 4:只验证 ready,不新增 direct 工具(普通工具仍 deferred)。 +#[tokio::test] +async fn system_mcp_empty_required_array_adds_no_direct_tools() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec![]), None)); + let (_, generation) = fixture + .connect("sys", vec![fixture_tool("Read", read_schema())]) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let mut probe = StartupGateProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("空数组仍应等待并放行"); + + let update = probe.staged.expect("System 依赖就绪必须暂存候选"); + assert!(update.required.is_empty(), "空数组不得产生必需工具身份"); + assert_eq!( + bridge_names(&update.tools), + vec![("mcp__sys__Read".to_string(), false)], + "direct 增量为 0,且普通 deferred 工具不被删除" + ); + + fixture.shutdown().await; +} + +/// 空数组仍必须完成 initialize / tools/list:从未连接 → timeout fatal。 +#[tokio::test] +async fn system_mcp_empty_required_array_still_requires_discovery() { + let fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec![]), Some(1))); + fixture.publish_loaded(); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("未经 discovery 的 System server 不得放行"); + + assert!( + matches!( + &error, + peri_agent::error::AgentError::MiddlewareError { reason, .. } + if reason.contains("启动超时") && reason.contains("sys") + ), + "期望 timeout fatal: {error:?}" + ); + assert!(probe.staged.is_none()); + + fixture.shutdown().await; +} + +/// 无 System 依赖(缺省 / 显式 false):零动作、不等待、不产生 startup update。 +#[tokio::test] +async fn system_mcp_absent_or_false_does_not_block_startup() { + for config in [ + ordinary_config(), + McpServerConfig { + system_mcp: Some(false), + ..system_config(None, None) + }, + ] { + let fixture = GateFixture::new(); + fixture.config("plain", config); + fixture.publish_loaded(); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let mut probe = StartupGateProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("普通 MCP 的 pending/failed 永不阻塞启动"); + assert!( + probe.staged.is_none(), + "无 System 依赖不产生 startup update" + ); + + fixture.shutdown().await; + } +} + +/// 取消 → `Interrupted`(不是 fatal);不暂存候选。 +#[tokio::test] +async fn system_mcp_cancelled_startup_is_interrupted() { + let fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + fixture.publish_loaded(); + let cancel = AgentCancellationToken::new(); + cancel.cancel(); + let mw = McpMiddleware::new(Arc::clone(fixture.pool())).with_skill_discovery(None, cancel); + + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("取消必须中断本次启动"); + + assert!( + matches!(error, peri_agent::error::AgentError::Interrupted), + "Cancelled 必须映射 Interrupted: {error:?}" + ); + assert!(probe.staged.is_none()); + + fixture.shutdown().await; +} + +/// timeout **不是**取消:映射 fatal `MiddlewareError`,不映射 `Interrupted`。 +#[tokio::test] +async fn system_mcp_timeout_is_fatal() { + let fixture = GateFixture::new(); + fixture.config( + "sys", + system_config(Some(vec!["Read".to_string()]), Some(1)), + ); + fixture.publish_loaded(); + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("超时必须阻止启动"); + + assert!( + !matches!(error, peri_agent::error::AgentError::Interrupted), + "timeout 不得映射 Interrupted" + ); + assert!( + matches!( + &error, + peri_agent::error::AgentError::MiddlewareError { reason, .. } + if reason.contains("启动超时(1ms)") + ), + "期望超时固定文案: {error:?}" + ); + + fixture.shutdown().await; +} + +/// 准入事实源是 deployment `tool_pool`:session projection 的连接证据不得放行。 +/// +/// 投影池刻意用**同名** server 且伪 `Connected`:若闸门误读投影池,得到的会是 +/// `NegotiationIncomplete`(无真实协议证据)而不是 deployment 侧的 Timeout。 +#[tokio::test] +async fn system_mcp_gate_uses_deployment_pool_not_session_projection() { + let fixture = GateFixture::new(); + fixture.config( + "sys", + system_config(Some(vec!["Read".to_string()]), Some(1)), + ); + fixture.publish_loaded(); + + let projection = Arc::new(McpClientPool::new_empty()); + projection.clients.write().insert( + "sys".to_string(), + make_connected_handle_with_tool("sys", "Read"), + ); + let mw = McpMiddleware::new(Arc::clone(&projection)).with_tool_pool(Arc::clone(fixture.pool())); + + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("投影池的伪 Connected 不得作为准入证据"); + + assert!( + matches!( + &error, + peri_agent::error::AgentError::MiddlewareError { reason, .. } + if reason.contains("启动超时(1ms)") + ), + "必须按 deployment 侧事实判定(无连接 → 超时): {error:?}" + ); + assert!(probe.staged.is_none()); + + fixture.shutdown().await; +} + +/// 错误文案安全:控制字符折叠为空格、凭据形态遮蔽(危险形态只用非真实凭据形状)。 +#[tokio::test] +async fn startup_error_text_folds_control_chars_and_redacts_credentials() { + let mut fixture = GateFixture::new(); + fixture.config( + "sys", + system_config( + Some(vec!["Re\u{7}ad?token=FAKE-SHAPE-ONLY".to_string()]), + None, + ), + ); + let (_, generation) = fixture + .connect("sys", vec![fixture_tool("Read", read_schema())]) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let mut probe = StartupGateProbe::default(); + let error = Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("必需工具缺失"); + + let peri_agent::error::AgentError::MiddlewareError { reason, .. } = &error else { + panic!("必须是 MiddlewareError: {error:?}"); + }; + assert!( + !reason.chars().any(char::is_control), + "控制字符必须折叠: {reason}" + ); + assert!( + !reason.contains("FAKE-SHAPE-ONLY"), + "凭据形态必须遮蔽: {reason}" + ); + assert!( + reason.contains("未提供必需工具"), + "错误类别仍需可见: {reason}" + ); + + fixture.shutdown().await; +} + +// ─── collect_tools 的整批替换与 deferred 回退 ──────────────────────────────── + +/// 就绪后收集:prepared 整批**替换**初始 Vec,必需项 direct 且只注册一次; +/// resource / discover 仍原样追加。 +#[tokio::test] +async fn collect_tools_replaces_initial_bridges_without_duplicate_registration() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + fixture.config("aux", ordinary_config()); + let (_, generation) = fixture + .connect( + "sys", + vec![ + fixture_tool("Read", read_schema()), + fixture_tool("Glob", read_schema()), + ], + ) + .await; + fixture + .connect("aux", vec![fixture_tool("Write", read_schema())]) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let collected = ::collect_tools(&mw, "/tmp"); + let names: Vec = collected + .iter() + .map(|tool| tool.name().to_string()) + .collect(); + + assert_eq!( + &names[names.len() - 2..], + ["mcp_read_resource", "DiscoverMCP"], + "resource / discover 仍原样追加: {names:?}" + ); + let bridges = &collected[..names.len() - 2]; + assert_eq!(bridges.len(), 3, "整批静态 bridge 恰好一次: {names:?}"); + for name in ["mcp__sys__Read", "mcp__sys__Glob", "mcp__aux__Write"] { + assert_eq!( + bridges.iter().filter(|tool| tool.name() == name).count(), + 1, + "{name} 不得重复注册: {names:?}" + ); + } + let read = bridges + .iter() + .find(|tool| tool.name() == "mcp__sys__Read") + .expect("必需 bridge 必须在集合内"); + assert!(read.is_direct(), "必需项 direct"); + assert!( + bridges + .iter() + .filter(|tool| tool.name() != "mcp__sys__Read") + .all(|tool| !tool.is_direct()), + "非必需项保持 deferred" + ); + + fixture.shutdown().await; +} + +/// 配置清单未发布(Pending):`configs` 不是可信依赖事实源,不得提升 direct。 +#[tokio::test] +async fn collect_tools_keeps_deferred_bridges_until_manifest_loaded() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + fixture + .connect("sys", vec![fixture_tool("Read", read_schema())]) + .await; + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let collected = ::collect_tools(&mw, "/tmp"); + let read = collected + .iter() + .find(|tool| tool.name() == "mcp__sys__Read") + .expect("deferred bridge 仍应存在"); + assert!(!read.is_direct(), "清单未发布不得提升 direct"); + assert_eq!(collected.len(), 3, "1 static bridge + resource + discover"); + + fixture.shutdown().await; +} + +/// 已 ready 但必需工具缺失:收集退回 deferred(不产生半成品 direct), +/// 该结果不构成 ready,闸门仍会在进入 Compact 前 fatal。 +#[tokio::test] +async fn collect_tools_falls_back_to_deferred_when_required_tool_missing() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + let (_, generation) = fixture + .connect("sys", vec![fixture_tool("Write", read_schema())]) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + let collected = ::collect_tools(&mw, "/tmp"); + let write = collected + .iter() + .find(|tool| tool.name() == "mcp__sys__Write") + .expect("普通工具仍应收集"); + assert!(!write.is_direct(), "校验失败不得留下部分 direct"); + assert_eq!(collected.len(), 3); + + fixture.shutdown().await; +} + +/// 闸门通过不改变既有 discovery 与状态通知行为:既不消费状态变化缓冲, +/// 也不自行触发发现;`before_agent` 仍按原语义触发(幂等增量挂点保留)。 +#[tokio::test] +async fn successful_gate_preserves_discovery_and_status_notifications() { + let pool = Arc::new(McpClientPool::new_empty()); + insert_skill_handle( + &pool, + "srv", + vec![Resource::new("skill://demo/SKILL.md", "d")], + ); + insert_skill_handle(&pool, "status", vec![]); + pool.publish_system_manifest(SystemMcpManifest::Loaded); + let reg = Arc::new(McpSkillRegistry::new()); + let mw = McpMiddleware::new(Arc::clone(&pool)) + .with_skill_discovery(Some(Arc::clone(®)), AgentCancellationToken::new()); + + pool.mark_initialized(); + pool.record_status_change("status", Some(&ClientStatus::Connected)); + if let Some(handle) = pool.clients.write().get_mut("status") { + Arc::make_mut(handle).status = ClientStatus::Failed("boom".to_string()); + } + pool.record_status_change("status", Some(&ClientStatus::Connected)); + + let mut probe = StartupGateProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("无 System 依赖的闸门必须放行"); + assert!( + probe.staged.is_none(), + "无 System 依赖不产生 startup update" + ); + assert!(reg.discovery_state("srv").is_none(), "闸门自身不得触发发现"); + assert_eq!( + pool.drain_pending_changes().len(), + 1, + "闸门不得消费状态变化缓冲" + ); + + let mut state = AgentState::new("/tmp"); + Middleware::before_agent(&mw, &mut state).await.unwrap(); + assert!( + matches!( + reg.discovery_state("srv"), + Some(ServerDiscoveryState::Started { .. }) + ), + "既有 before_agent 发现行为保留" + ); +} + +/// 失败不留下可复用的半成品:同一 middleware 连续两次准入,第二次按当次句柄 +/// 重新构建(不把上次的 direct 标记套到新代,也不复用失败的候选)。 +#[tokio::test] +async fn failed_gate_leaves_no_reusable_candidate() { + let mut fixture = GateFixture::new(); + fixture.config("sys", system_config(Some(vec!["Read".to_string()]), None)); + let (_, generation) = fixture + .connect("sys", vec![fixture_tool("Write", read_schema())]) + .await; + fixture.ready("sys", generation); + + let mw = McpMiddleware::new(Arc::clone(fixture.pool())); + + let mut probe = StartupGateProbe::default(); + Middleware::before_react_start(&mw, &mut probe) + .await + .expect_err("首次准入缺必需工具"); + assert!(probe.staged.is_none(), "失败不得留下候选"); + + // 第二代句柄补齐必需工具:证据必须重新按新代提交。 + let (_, generation) = fixture + .connect( + "sys", + vec![ + fixture_tool("Read", read_schema()), + fixture_tool("Write", read_schema()), + ], + ) + .await; + fixture.ready("sys", generation); + + Middleware::before_react_start(&mw, &mut probe) + .await + .expect("第二代就绪后必须放行"); + let update = probe.staged.expect("成功准入必须暂存候选"); + assert_eq!( + bridge_names(&update.tools), + vec![ + ("mcp__sys__Read".to_string(), true), + ("mcp__sys__Write".to_string(), false), + ], + "候选按当次句柄重建,不残留失败批次" + ); + + fixture.shutdown().await; +} diff --git a/peri-middlewares/src/mcp/mod.rs b/peri-middlewares/src/mcp/mod.rs index d9bd225ba..2bb9ab1cf 100644 --- a/peri-middlewares/src/mcp/mod.rs +++ b/peri-middlewares/src/mcp/mod.rs @@ -1,3 +1,4 @@ +pub mod acp; pub mod agent_registry; pub mod apps; pub mod apps_invoke; @@ -20,10 +21,12 @@ pub mod reconnect; pub mod resource_cache; pub mod resource_tool; pub(crate) mod skill_discovery; +pub(crate) mod system_tools; pub mod task_scope; pub mod tool_bridge; pub mod transport; +pub use acp::AcpMcpService; pub use agent_registry::{ActivatedMcpAgent, McpAgentMetadata, McpAgentRegistry}; pub use apps::{ canonical_resource_uri, raw_resource, raw_tool, tool_resource_uri, tool_visibility, @@ -54,3 +57,9 @@ pub use task_scope::{ TaskAdmissionError, }; pub use tool_bridge::{build_tool_bridges, McpToolBridge, ToolCallError}; + +// D-02:crate 内 seam 测试(主 plan §6 W5)。模块名参与 `cargo test` 过滤, +// 故不沿用 `mod tests`(过滤 `mcp::mcp_v4_seam` 必须命中本模块)。 +#[cfg(test)] +#[path = "mcp_v4_seam_test.rs"] +mod mcp_v4_seam_tests; diff --git a/peri-middlewares/src/mcp/reconnect.rs b/peri-middlewares/src/mcp/reconnect.rs index 20d081a16..345cf3e60 100644 --- a/peri-middlewares/src/mcp/reconnect.rs +++ b/peri-middlewares/src/mcp/reconnect.rs @@ -7,6 +7,10 @@ use super::{ ClientStatus, McpClientHandle, McpClientPool, McpPoolError, OAuthStartDisposition, OAuthStatus, HTTP_CONNECT_TIMEOUT, SHUTDOWN_TIMEOUT, STDIO_CONNECT_TIMEOUT, }, + initialize::{ + commit_discovery_failure, commit_discovery_success, downgrade_resource_listing, + fail_tool_discovery, list_discovered_tools, + }, oauth_flow::{OAuthFlowEvent, OAuthFlowManager}, transport::TransportConfig, }; @@ -40,6 +44,9 @@ impl McpClientPool { status: ClientStatus::Disconnected, })?; + // 重新发现开始:旧代证据立即作废,等待方按「仍在进行」重新判定 + // (旧代证据即使保留也不会被接受,但显式清除让等待方立刻重读事实)。 + self.clear_discovery_evidence(server_name); // Stop and join the old keyed subscription outside pool locks before // replacing its service, so a cancelled caller cannot detach it. self.stop_background(&super::task_scope::McpTaskKey::Subscription( @@ -102,6 +109,7 @@ impl McpClientPool { } Err(e) => { McpClientPool::insert_failed(self, server_name, format!("stdio 失败: {e}")); + commit_discovery_failure(self, server_name, false); return Err(McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: format!("stdio 失败: {e}"), @@ -210,17 +218,29 @@ impl McpClientPool { } let peer = rs.peer().clone(); let cache_version = self.install_peer_cache_version(server_name, &peer); - let tools = self - .list_all_tools_cached(server_name, &peer) - .await - .map_err(|e| McpPoolError::ToolDiscoveryFailed { - server: server_name.to_string(), - reason: e.to_string(), - })?; - let resources = self - .list_all_resources_cached(server_name, &peer) - .await - .unwrap_or_default(); + // 严格发现:`tools/list` 的 `Err` 不是「没有工具」。System MCP 走 + // 本次 live round-trip(不用历史缓存代替健康证据),失败即 + // ToolDiscoveryFailed,不提交 Connected。 + let tools = + match list_discovered_tools(self, server_name, &peer, &server_config).await { + Ok(tools) => tools, + Err(error) => { + // 不留下「无句柄」的模糊状态:显式 Failed + 本代发现失败 + // 证据,闸门据此立即判定,而不是等到 deadline。 + fail_tool_discovery(self, server_name, &error.to_string()); + return Err(McpPoolError::ToolDiscoveryFailed { + server: server_name.to_string(), + reason: error.to_string(), + }); + } + }; + let resources = match self.list_all_resources_cached(server_name, &peer).await { + Ok(resources) => resources, + Err(error) => { + downgrade_resource_listing(server_name, &error.to_string()); + Vec::new() + } + }; let skills_capable = super::client::peer_declares_skills(&peer); let oauth_status = if used_oauth { OAuthStatus::Authorized @@ -243,16 +263,21 @@ impl McpClientPool { channel_capable: false, skills_capable, }); + let committed = Arc::clone(&handle); if let Err(mut service) = self.try_commit_connection(server_name.to_string(), handle, rs) { let _ = service.close_with_timeout(SHUTDOWN_TIMEOUT).await; + // 提交被拒(pool 关闭):不留任何可被读成成功的证据。 + self.clear_discovery_evidence(server_name); return Err(McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: "MCP pool is closing".to_string(), }); } self.record_status_change(server_name, old_status.as_ref()); + // 重连同样只能由真实成功的 live `tools/list` 产生本代发现证据。 + commit_discovery_success(self, server_name, &committed); Ok(()) } Ok(Err(e)) => { @@ -262,6 +287,7 @@ impl McpClientPool { } else { McpClientPool::insert_failed(self, server_name, err_str.clone()); } + commit_discovery_failure(self, server_name, false); Err(McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: err_str, @@ -270,6 +296,7 @@ impl McpClientPool { Err(_) => { let msg = "连接超时"; McpClientPool::insert_failed(self, server_name, msg.to_string()); + commit_discovery_failure(self, server_name, false); Err(McpPoolError::ConnectionFailed { server: server_name.to_string(), reason: msg.to_string(), diff --git a/peri-middlewares/src/mcp/resource_cache_test.rs b/peri-middlewares/src/mcp/resource_cache_test.rs index d6df1f3de..074a3a319 100644 --- a/peri-middlewares/src/mcp/resource_cache_test.rs +++ b/peri-middlewares/src/mcp/resource_cache_test.rs @@ -272,6 +272,9 @@ fn test_cache_origin_does_not_expose_endpoint() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, }; let origin = cache_origin("server", Some(&config)); @@ -342,6 +345,9 @@ fn test_stdio_cache_origin_changes_with_config_identity() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, }; let second = McpServerConfig { diff --git a/peri-middlewares/src/mcp/resource_tool.rs b/peri-middlewares/src/mcp/resource_tool.rs index 20f317bc3..619221d12 100644 --- a/peri-middlewares/src/mcp/resource_tool.rs +++ b/peri-middlewares/src/mcp/resource_tool.rs @@ -43,6 +43,8 @@ const MAX_MCP_LINES: usize = 2000; /// MCP 资源读取工具——统一资源读取入口 pub struct McpResourceTool { client_pool: Arc, + /// 会话 id:资源读取按 ACP 连接归属过滤,`None` = 不过滤(print 模式)。 + session_id: Option, /// session 级 MCP skill 远端注册表(读面完整性校验;空注册表 = 不校验)。 /// 架构硬约束(issue C 节):挂 session 装配链,绝不挂 pool/全局。 registry: Arc, @@ -63,10 +65,17 @@ impl McpResourceTool { Self { client_pool, registry, + session_id: None, cached_description, } } + /// 注入会话 id:读不到其他会话声明的 ACP server 的资源。 + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } + /// 读取面热更新恢复(digest 不匹配 / 未列出时,仅 skill:// 且条目覆盖): /// `skills/get` 拉取当前条目快照 → 按新条目重读内容并全量校验 → /// `refresh_entries` 回写 registry。成功 → `Some((请求 uri 的新内容, @@ -190,7 +199,7 @@ impl BaseTool for McpResourceTool { // 2. 获取客户端句柄 let handle = self .client_pool - .get_client(server_name) + .get_client_visible_to(server_name, self.session_id.as_deref()) .ok_or_else(|| ResourceError::ServerNotFound { server: server_name.to_string(), })? diff --git a/peri-middlewares/src/mcp/system_tools.rs b/peri-middlewares/src/mcp/system_tools.rs new file mode 100644 index 000000000..4eae78e8d --- /dev/null +++ b/peri-middlewares/src/mcp/system_tools.rs @@ -0,0 +1,386 @@ +//! System MCP 必需工具的解析、批量验证与 direct 提升(冻结接口 IF-M2)。 +//! +//! 本模块是纯函数 seam:不读配置、不访问网络、不等待、不读连接状态,只消费 +//! 调用方(B 的启动闸门)已经证明 transport / initialize / 能力协商 / +//! `tools/list` 成功的静态 bridge 快照。因此这里的任何结果都不构成「协议完成」 +//! 或「ready」证据——ready 的判定与发布归 B。 +//! +//! 匹配口径:必需工具按**所属 server 的原始工具名**精确匹配(不折叠大小写、 +//! 不剥离 effective name 前缀、不跨 server 搜索);对模型暴露的名字仍由 bridge +//! 现有的 `mcp__{server}__{tool}` 命名规则给出。 + +use std::collections::{BTreeMap, BTreeSet}; + +use peri_agent::tools::BaseTool; +use serde_json::{Map, Value}; +use thiserror::Error; + +use super::tool_bridge::McpToolBridge; + +/// System MCP 必需工具的解析错误。 +/// +/// 变体只描述失败事实:不含 schema 内容、默认值或任何工具 payload。 +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub(crate) enum SystemToolError { + #[error("system MCP server \"{server}\" 未提供必需工具 \"{tool}\"")] + MissingTool { server: String, tool: String }, + #[error("system MCP server \"{server}\" 的必需工具 \"{tool}\" 存在多个同名注册")] + AmbiguousTool { + server: String, + tool: String, + /// 命中的全部注册的 effective name,顺序与输入一致。原始名相同时 + /// 各项文本相同,**长度**即冲突的注册数,不能据此任意取第一项。 + matches: Vec, + }, + #[error("system MCP server \"{server}\" 的工具 \"{tool}\" input schema 结构非法: {reason}")] + InvalidSchema { + server: String, + tool: String, + /// 只含字段路径与固定规则文本,不含 schema 值。 + reason: String, + }, + #[error("system MCP server \"{server}\" 的必需工具 \"{tool}\" 对模型不可见")] + NotModelVisible { server: String, tool: String }, + #[error("system 必需工具的 effective name \"{effective_name}\" 与其他工具冲突")] + EffectiveNameCollision { effective_name: String }, +} + +/// 把同一次 typed 构建得到的静态 bridge 集合中,所有必需工具提升为 direct。 +/// +/// 语义: +/// - **all-or-nothing**:先验证全部必需项,再统一调用 `with_direct`;任何错误 +/// 只返回 `Err`,不存在部分成功的集合,也不产生共享副作用。 +/// - 输入与输出的**长度、顺序、身份一致**,只有命中项 `is_direct()` 变为 true。 +/// - `required` 的 value 允许为空数组:该 server 不做必需工具检查,也不提升任何 +/// 工具(它的普通工具保持 deferred,不是「删除该 MCP 的工具」)。 +/// - 未被要求的 bridge 不参与 schema 校验,沿用既有 deferred 冲突策略。 +/// - 重复配置项幂等,不产生第二次注册。 +/// +/// 调用方必须先保证 `required` key 对应的 server 已经 ready;本函数无法从零匹配 +/// 集合证明 server 存在。 +pub(crate) fn prepare_system_tools( + bridges: Vec, + required: &BTreeMap>, +) -> Result, SystemToolError> { + // 阶段 1:逐项解析与验证。此阶段不修改任何 bridge,因此失败时无可发布的半成品。 + let mut selected: BTreeSet = BTreeSet::new(); + for (server, tools) in required { + for tool in unique_tools(tools) { + let index = resolve_required_bridge(&bridges, server, tool)?; + let bridge = &bridges[index]; + if !bridge.visible_to_model() { + return Err(SystemToolError::NotModelVisible { + server: server.clone(), + tool: tool.to_string(), + }); + } + validate_input_schema(&bridge.parameters()).map_err(|reason| { + SystemToolError::InvalidSchema { + server: server.clone(), + tool: tool.to_string(), + reason, + } + })?; + selected.insert(index); + } + } + + // 阶段 2:必需工具的 effective name 必须在整批静态 bridge 内唯一。 + validate_effective_names(&bridges, &selected)?; + + // 阶段 3:统一提升。整体替换原集合,不 append 第二份注册。 + Ok(bridges + .into_iter() + .enumerate() + .map(|(index, bridge)| { + if selected.contains(&index) { + bridge.with_direct() + } else { + bridge + } + }) + .collect()) +} + +/// 配置数组按出现顺序去重:重复项幂等,且不重排 `required` 的语义顺序。 +fn unique_tools(tools: &[String]) -> Vec<&str> { + let mut unique: Vec<&str> = Vec::with_capacity(tools.len()); + for tool in tools { + if !unique.contains(&tool.as_str()) { + unique.push(tool.as_str()); + } + } + unique +} + +/// 在所属 server 的原始工具名上精确匹配:0 个命中 → `MissingTool`, +/// 多于 1 个 → `AmbiguousTool`。 +fn resolve_required_bridge( + bridges: &[McpToolBridge], + server: &str, + tool: &str, +) -> Result { + let matches: Vec = bridges + .iter() + .enumerate() + .filter(|(_, bridge)| { + bridge.mcp_server_name() == Some(server) && bridge.original_tool_name() == tool + }) + .map(|(index, _)| index) + .collect(); + match matches.as_slice() { + [] => Err(SystemToolError::MissingTool { + server: server.to_string(), + tool: tool.to_string(), + }), + [index] => Ok(*index), + indices => Err(SystemToolError::AmbiguousTool { + server: server.to_string(), + tool: tool.to_string(), + matches: indices + .iter() + .map(|index| bridges[*index].name().to_string()) + .collect(), + }), + } +} + +/// 必需工具的 effective name 必须在整批静态 bridge 内唯一,且 ASCII 大小写折叠后 +/// 也不得与其他工具同名(净化碰撞与执行期大小写歧义都 fail closed)。 +/// +/// 只检查必需项:与必需项无关的普通 deferred 工具沿用既有冲突策略。 +fn validate_effective_names( + bridges: &[McpToolBridge], + selected: &BTreeSet, +) -> Result<(), SystemToolError> { + for index in selected { + let name = bridges[*index].name(); + let folded = name.to_ascii_lowercase(); + let collides = bridges + .iter() + .enumerate() + .any(|(other, bridge)| other != *index && bridge.name().to_ascii_lowercase() == folded); + if collides { + return Err(SystemToolError::EffectiveNameCollision { + effective_name: name.to_string(), + }); + } + } + Ok(()) +} + +// ─── input schema 结构解析 ────────────────────────────────────────────────── + +/// `type` 允许的 JSON Schema 类型名。 +const SCHEMA_TYPE_NAMES: &[&str] = &[ + "array", "boolean", "integer", "null", "number", "object", "string", +]; + +/// 值是单个 schema 节点的已知关键字。 +const SCHEMA_NODE_KEYWORDS: &[&str] = &[ + "additionalProperties", + "contains", + "else", + "if", + "items", + "not", + "propertyNames", + "then", +]; + +/// 值是 schema 数组的已知关键字。 +const SCHEMA_LIST_KEYWORDS: &[&str] = &["allOf", "anyOf", "oneOf", "prefixItems"]; + +/// 值是 `名称 → schema` 映射的已知关键字。 +const SCHEMA_MAP_KEYWORDS: &[&str] = &[ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", +]; + +/// 值是字符串的已知关键字(引用、标识与注记)。 +const STRING_KEYWORDS: &[&str] = &[ + "$anchor", + "$comment", + "$id", + "$ref", + "$schema", + "contentEncoding", + "contentMediaType", + "description", + "format", + "pattern", + "title", +]; + +/// 值是数值的已知关键字。 +const NUMBER_KEYWORDS: &[&str] = &[ + "exclusiveMaximum", + "exclusiveMinimum", + "maximum", + "minimum", + "multipleOf", +]; + +/// 值是非负整数的已知关键字。 +const NON_NEGATIVE_INTEGER_KEYWORDS: &[&str] = &[ + "maxItems", + "maxLength", + "maxProperties", + "minItems", + "minLength", + "minProperties", +]; + +/// 值是布尔量的已知关键字。 +const BOOLEAN_KEYWORDS: &[&str] = &["deprecated", "readOnly", "uniqueItems", "writeOnly"]; + +const RULE_NODE: &str = "must be an object or boolean schema node"; +const RULE_ROOT_OBJECT: &str = "root must be a JSON object"; +const RULE_ROOT_TYPE: &str = "root \"type\" must be \"object\""; +const RULE_MAP: &str = "must be a JSON object mapping names to schema nodes"; +const RULE_LIST: &str = "must be an array of schema nodes"; +const RULE_TYPE: &str = + "\"type\" must be a JSON Schema type name or a non-empty array of unique type names"; +const RULE_REQUIRED: &str = "\"required\" must be an array"; +const RULE_REQUIRED_ITEM: &str = "\"required\" entries must be unique strings"; +const RULE_ENUM: &str = "\"enum\" must be a non-empty array"; +const RULE_STRING: &str = "must be a string"; +const RULE_NUMBER: &str = "must be a number"; +const RULE_INTEGER: &str = "must be a non-negative integer"; +const RULE_BOOLEAN: &str = "must be a boolean"; + +/// MCP `inputSchema` 的结构解析检查。 +/// +/// 只检查根为 object、已知关键字的类型与嵌套位置合法;**不是**完整 JSON Schema +/// draft 的元 schema 校验或 instance validation,也不解析 `$ref`。annotation、 +/// vendor extension 以及 `default` / `examples` / `enum` / `const` 中的普通数据不按 +/// schema 递归。失败只返回「固定规则 + 字段路径」,不返回 schema 值。 +fn validate_input_schema(schema: &Value) -> Result<(), String> { + let Some(root) = schema.as_object() else { + return Err(invalid("/", RULE_ROOT_OBJECT)); + }; + if root.get("type").is_some_and(|value| value != "object") { + return Err(invalid("/type", RULE_ROOT_TYPE)); + } + validate_schema_object(root, "/") +} + +fn validate_schema_node(node: &Value, path: &str) -> Result<(), String> { + match node { + Value::Bool(_) => Ok(()), + Value::Object(object) => validate_schema_object(object, path), + _ => Err(invalid(path, RULE_NODE)), + } +} + +fn validate_schema_object(object: &Map, path: &str) -> Result<(), String> { + for (keyword, value) in object { + let child = child_path(path, keyword); + if SCHEMA_NODE_KEYWORDS.contains(&keyword.as_str()) { + validate_schema_node(value, &child)?; + } else if SCHEMA_LIST_KEYWORDS.contains(&keyword.as_str()) { + let Some(nodes) = value.as_array() else { + return Err(invalid(&child, RULE_LIST)); + }; + for (index, node) in nodes.iter().enumerate() { + validate_schema_node(node, &format!("{child}/{index}"))?; + } + } else if SCHEMA_MAP_KEYWORDS.contains(&keyword.as_str()) { + let Some(nodes) = value.as_object() else { + return Err(invalid(&child, RULE_MAP)); + }; + for (name, node) in nodes { + validate_schema_node(node, &child_path(&child, name))?; + } + } else if keyword == "type" { + validate_type_keyword(value, &child)?; + } else if keyword == "required" { + validate_required_keyword(value, &child)?; + } else if keyword == "enum" { + if !matches!(value, Value::Array(items) if !items.is_empty()) { + return Err(invalid(&child, RULE_ENUM)); + } + } else if STRING_KEYWORDS.contains(&keyword.as_str()) && !value.is_string() { + return Err(invalid(&child, RULE_STRING)); + } else if NUMBER_KEYWORDS.contains(&keyword.as_str()) && !value.is_number() { + return Err(invalid(&child, RULE_NUMBER)); + } else if NON_NEGATIVE_INTEGER_KEYWORDS.contains(&keyword.as_str()) + && value.as_u64().is_none() + { + return Err(invalid(&child, RULE_INTEGER)); + } else if BOOLEAN_KEYWORDS.contains(&keyword.as_str()) && !value.is_boolean() { + return Err(invalid(&child, RULE_BOOLEAN)); + } + // 其余关键字(annotation / vendor extension / const / default / examples) + // 不是 schema 位置:不递归、不校验内容。 + } + Ok(()) +} + +fn validate_type_keyword(value: &Value, path: &str) -> Result<(), String> { + if let Some(name) = value.as_str() { + return if SCHEMA_TYPE_NAMES.contains(&name) { + Ok(()) + } else { + Err(invalid(path, RULE_TYPE)) + }; + } + let Some(names) = value.as_array() else { + return Err(invalid(path, RULE_TYPE)); + }; + if names.is_empty() { + return Err(invalid(path, RULE_TYPE)); + } + let mut seen: Vec<&str> = Vec::with_capacity(names.len()); + for (index, name) in names.iter().enumerate() { + let Some(name) = name + .as_str() + .filter(|name| SCHEMA_TYPE_NAMES.contains(name)) + else { + return Err(invalid(&format!("{path}/{index}"), RULE_TYPE)); + }; + if seen.contains(&name) { + return Err(invalid(&format!("{path}/{index}"), RULE_TYPE)); + } + seen.push(name); + } + Ok(()) +} + +fn validate_required_keyword(value: &Value, path: &str) -> Result<(), String> { + let Some(names) = value.as_array() else { + return Err(invalid(path, RULE_REQUIRED)); + }; + let mut seen: Vec<&str> = Vec::with_capacity(names.len()); + for (index, name) in names.iter().enumerate() { + let Some(name) = name.as_str() else { + return Err(invalid(&format!("{path}/{index}"), RULE_REQUIRED_ITEM)); + }; + if seen.contains(&name) { + return Err(invalid(&format!("{path}/{index}"), RULE_REQUIRED_ITEM)); + } + seen.push(name); + } + Ok(()) +} + +/// JSON Pointer 风格路径:根为 `/`;token 内按 RFC 6901 转义 `~` 与 `/`。 +fn child_path(path: &str, token: &str) -> String { + let token = token.replace('~', "~0").replace('/', "~1"); + if path == "/" { + format!("/{token}") + } else { + format!("{path}/{token}") + } +} + +fn invalid(path: &str, rule: &'static str) -> String { + format!("{rule} at {path}") +} + +#[cfg(test)] +#[path = "system_tools_test.rs"] +mod tests; diff --git a/peri-middlewares/src/mcp/system_tools_test.rs b/peri-middlewares/src/mcp/system_tools_test.rs new file mode 100644 index 000000000..351314714 --- /dev/null +++ b/peri-middlewares/src/mcp/system_tools_test.rs @@ -0,0 +1,670 @@ +//! `system_tools` 的 crate 内可观察层测试(C-INJ-02)。 +//! +//! 断言范围止于 bridge 层:`prepare_system_tools` 的返回值、`is_direct()` 标志、 +//! 错误变体、参数保真与「空数组零注入」。首个 LLM 请求的 tools 入参、真实 session +//! catalog / `run_reason` 的断言由 B-07 在 `peri-acp` host seam 承担(主 plan §5 R9), +//! 本文件不调用 `build_session_tool_view`、不 mock catalog。 +//! +//! 所有 fixture 都用真实 `rmcp::model::Tool` + `peer: None` 的句柄构造:被测函数 +//! 不读连接状态,因此这些 fixture **不构成「协议 ready」证据**,也不能被读成 +//! 「System MCP 启动完成」。 + +use super::*; +use std::sync::Arc; + +use rmcp::model::Tool; +use serde_json::json; + +use crate::mcp::client::{ClientStatus, McpClientHandle}; +use crate::mcp::dynamic::admission::DynamicMcpAdmissionGate; + +// ─── fixtures ─────────────────────────────────────────────────────────────── + +/// 纯匹配用句柄:`peer: None` + `Failed`,避免被误读为已完成握手。 +fn fixture_handle(server: &str) -> Arc { + Arc::new(McpClientHandle { + name: server.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![], + resources: vec![], + status: ClientStatus::Failed("fixture: no live transport".to_string()), + oauth_status: Default::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }) +} + +/// 经真实反序列化构造 rmcp Tool:`_meta` 与结构非法的 schema 都从这里进入。 +fn fixture_tool(tool: serde_json::Value) -> Tool { + serde_json::from_value(tool).expect("fixture tool 必须能被 rmcp Tool 接收") +} + +fn read_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }) +} + +fn bridge(server: &str, tool: &str) -> McpToolBridge { + bridge_with_schema(server, tool, read_schema()) +} + +fn bridge_with_schema(server: &str, tool: &str, schema: serde_json::Value) -> McpToolBridge { + let tool = fixture_tool(json!({ + "name": tool, + "description": "fixture", + "inputSchema": schema + })); + McpToolBridge::new(server, &tool, fixture_handle(server)) +} + +/// `_meta.ui.visibility = ["app"]`:只对 App 可见,不可注入模型工具列表。 +fn app_only_bridge(server: &str, tool: &str) -> McpToolBridge { + let tool = fixture_tool(json!({ + "name": tool, + "description": "fixture", + "inputSchema": read_schema(), + "_meta": { "ui": { "visibility": ["app"] } } + })); + McpToolBridge::new(server, &tool, fixture_handle(server)) +} + +fn required(entries: &[(&str, &[&str])]) -> BTreeMap> { + entries + .iter() + .map(|(server, tools)| { + ( + (*server).to_string(), + tools.iter().map(|tool| (*tool).to_string()).collect(), + ) + }) + .collect() +} + +fn names(bridges: &[McpToolBridge]) -> Vec { + bridges + .iter() + .map(|bridge| bridge.name().to_string()) + .collect() +} + +fn direct_names(bridges: &[McpToolBridge]) -> Vec { + bridges + .iter() + .filter(|bridge| bridge.is_direct()) + .map(|bridge| bridge.name().to_string()) + .collect() +} + +/// `Vec` 未实现 `Debug`,无法使用 `unwrap_err` / `expect_err`。 +fn expect_ok( + bridges: Vec, + required: &BTreeMap>, +) -> Vec { + match prepare_system_tools(bridges, required) { + Ok(prepared) => prepared, + Err(error) => panic!("期望 Ok,实际返回 {error}"), + } +} + +fn expect_error( + bridges: Vec, + required: &BTreeMap>, +) -> SystemToolError { + match prepare_system_tools(bridges, required) { + Ok(_) => panic!("期望 Err(all-or-nothing),实际返回 Ok"), + Err(error) => error, + } +} + +// ─── 解析与命名空间 ───────────────────────────────────────────────────────── + +#[test] +fn test_system_required_tools_resolve_in_own_namespace() { + let before = vec![ + "mcp__workspace__Read".to_string(), + "mcp__archive__Read".to_string(), + ]; + let before_params: Vec = vec![read_schema(), read_schema()]; + + let prepared = expect_ok( + vec![bridge("workspace", "Read"), bridge("archive", "Read")], + &required(&[("workspace", &["Read"])]), + ); + + // 长度、顺序与有效名集合不变;只有所属 namespace 命中项被提升。 + assert_eq!(names(&prepared), before); + assert_eq!( + prepared + .iter() + .map(|bridge| bridge.parameters()) + .collect::>(), + before_params + ); + assert_eq!( + direct_names(&prepared), + vec!["mcp__workspace__Read".to_string()] + ); +} + +#[test] +fn test_system_required_tool_names_are_case_sensitive() { + let error = expect_error( + vec![bridge("workspace", "read")], + &required(&[("workspace", &["Read"])]), + ); + + // 不折叠大小写:报错内容与配置项逐字对应。 + assert_eq!( + error, + SystemToolError::MissingTool { + server: "workspace".to_string(), + tool: "Read".to_string(), + } + ); +} + +#[test] +fn test_system_effective_prefix_is_not_stripped() { + let error = expect_error( + vec![bridge("workspace", "Read")], + &required(&[("workspace", &["mcp__workspace__Read"])]), + ); + + // 配置数组按**原始工具名**匹配,不接受 effective name 作为替代写法。 + assert_eq!( + error, + SystemToolError::MissingTool { + server: "workspace".to_string(), + tool: "mcp__workspace__Read".to_string(), + } + ); +} + +#[test] +fn test_system_missing_tool_returns_explicit_error() { + // 另一 server 的同名工具不能补足本 server 的必需项,也不退化为「空 Ok」。 + let error = expect_error( + vec![bridge("workspace", "Glob"), bridge("archive", "Read")], + &required(&[("workspace", &["Read"])]), + ); + + assert_eq!( + error, + SystemToolError::MissingTool { + server: "workspace".to_string(), + tool: "Read".to_string(), + } + ); +} + +#[test] +fn test_system_error_selection_is_deterministic() { + let bridges = vec![bridge("workspace", "Read")]; + let required = required(&[("zulu", &["Read"]), ("alpha", &["Read"])]); + + let error = expect_error(bridges.clone(), &required); + assert_eq!( + error, + SystemToolError::MissingTool { + server: "alpha".to_string(), + tool: "Read".to_string(), + } + ); + + // 同批 fixture 的克隆不携带任何提升状态,重复调用结论一致。 + assert_eq!(expect_error(bridges, &required), error); +} + +#[test] +fn test_system_ambiguous_raw_tool_is_rejected() { + let error = expect_error( + vec![bridge("workspace", "Read"), bridge("workspace", "Read")], + &required(&[("workspace", &["Read"])]), + ); + + assert_eq!( + error, + SystemToolError::AmbiguousTool { + server: "workspace".to_string(), + tool: "Read".to_string(), + matches: vec![ + "mcp__workspace__Read".to_string(), + "mcp__workspace__Read".to_string(), + ], + } + ); +} + +#[test] +fn test_system_effective_name_collision_is_rejected() { + // 净化碰撞:不同原始 server 名落到同一 effective name。 + let error = expect_error( + vec![bridge("a.b", "Read"), bridge("a_b", "Read")], + &required(&[("a.b", &["Read"])]), + ); + assert_eq!( + error, + SystemToolError::EffectiveNameCollision { + effective_name: "mcp__a_b__Read".to_string(), + } + ); + + // ASCII 大小写折叠冲突:执行期无法区分这两个名字。 + let error = expect_error( + vec![bridge("workspace", "Read"), bridge("workspace", "read")], + &required(&[("workspace", &["Read"])]), + ); + assert_eq!( + error, + SystemToolError::EffectiveNameCollision { + effective_name: "mcp__workspace__Read".to_string(), + } + ); + + // 与必需项无关的普通 deferred 冲突沿用既有策略,本函数不改写。 + let prepared = expect_ok( + vec![ + bridge("a.b", "Other"), + bridge("a_b", "Other"), + bridge("workspace", "Read"), + ], + &required(&[("workspace", &["Read"])]), + ); + assert_eq!( + direct_names(&prepared), + vec!["mcp__workspace__Read".to_string()] + ); +} + +#[test] +fn test_system_app_only_required_tool_is_rejected() { + let bridges = vec![app_only_bridge("workspace", "Read")]; + // fixture 自检:`_meta` 确实把该工具设为 app-only。 + assert!(!bridges[0].visible_to_model()); + + let error = expect_error(bridges, &required(&[("workspace", &["Read"])])); + + // 必需项不覆盖 model visibility,也不会被改造成模型工具后算作成功。 + assert_eq!( + error, + SystemToolError::NotModelVisible { + server: "workspace".to_string(), + tool: "Read".to_string(), + } + ); +} + +// ─── schema 结构解析 ──────────────────────────────────────────────────────── + +#[test] +fn test_system_invalid_schema_returns_explicit_error() { + let cases: Vec<(&str, serde_json::Value, &str, &str)> = vec![ + ( + "properties 非映射", + json!({ "type": "object", "properties": 42 }), + "/properties", + RULE_MAP, + ), + ( + "properties 值非 schema", + json!({ "type": "object", "properties": { "path": "oops" } }), + "/properties/path", + RULE_NODE, + ), + ( + "required 非数组", + json!({ "type": "object", "required": "path" }), + "/required", + RULE_REQUIRED, + ), + ( + "required 元素非字符串", + json!({ "type": "object", "required": ["path", 1] }), + "/required/1", + RULE_REQUIRED_ITEM, + ), + ( + "required 重复", + json!({ "type": "object", "required": ["path", "path"] }), + "/required/1", + RULE_REQUIRED_ITEM, + ), + ( + "嵌套 type 非法", + json!({ "type": "object", "properties": { "path": { "type": "json" } } }), + "/properties/path/type", + RULE_TYPE, + ), + ( + "type 数组为空", + json!({ "type": "object", "properties": { "path": { "type": [] } } }), + "/properties/path/type", + RULE_TYPE, + ), + ( + "type 数组重复", + json!({ "type": "object", "properties": { "path": { "type": ["string", "string"] } } }), + "/properties/path/type/1", + RULE_TYPE, + ), + ( + "allOf 非数组", + json!({ "type": "object", "allOf": { "type": "object" } }), + "/allOf", + RULE_LIST, + ), + ( + "allOf 元素非 schema", + json!({ "type": "object", "allOf": [3] }), + "/allOf/0", + RULE_NODE, + ), + ( + "$defs 值非 schema", + json!({ "type": "object", "$defs": { "leaf": 7 } }), + "/$defs/leaf", + RULE_NODE, + ), + ( + "enum 空数组", + json!({ "type": "object", "enum": [] }), + "/enum", + RULE_ENUM, + ), + ( + "maxLength 非非负整数", + json!({ "type": "object", "properties": { "path": { "maxLength": -1 } } }), + "/properties/path/maxLength", + RULE_INTEGER, + ), + ( + "uniqueItems 非布尔", + json!({ "type": "object", "properties": { "path": { "uniqueItems": "no" } } }), + "/properties/path/uniqueItems", + RULE_BOOLEAN, + ), + ( + "$ref 非字符串", + json!({ "type": "object", "$ref": 42 }), + "/$ref", + RULE_STRING, + ), + ( + "minimum 非数值", + json!({ "type": "object", "properties": { "path": { "minimum": "1" } } }), + "/properties/path/minimum", + RULE_NUMBER, + ), + ]; + + for (label, schema, path, rule) in cases { + let error = expect_error( + vec![bridge_with_schema("workspace", "Read", schema)], + &required(&[("workspace", &["Read"])]), + ); + let SystemToolError::InvalidSchema { + server, + tool, + reason, + } = &error + else { + panic!("{label}: 期望 InvalidSchema,实际 {error}"); + }; + assert_eq!(server, "workspace", "{label}"); + assert_eq!(tool, "Read", "{label}"); + assert!( + reason.contains(path), + "{label}: 缺少字段路径 {path},实际 {reason}" + ); + assert!( + reason.contains(rule), + "{label}: 缺少固定原因,实际 {reason}" + ); + } +} + +#[test] +fn test_system_schema_root_must_be_object() { + for schema in [json!([]), json!("oops"), json!(42), json!(true)] { + let reason = validate_input_schema(&schema).expect_err("根非 object 必须失败"); + assert!(reason.starts_with(RULE_ROOT_OBJECT), "{reason}"); + assert!(reason.ends_with(" at /"), "{reason}"); + } + + // `{}` 是未声明约束的 object schema,合法。 + assert_eq!(validate_input_schema(&json!({})), Ok(())); + + // 根 `type` 若存在必须是 "object"。 + for schema in [json!({ "type": "array" }), json!({ "type": ["object"] })] { + let reason = validate_input_schema(&schema).expect_err("根 type 非 object 必须失败"); + assert!(reason.contains(RULE_ROOT_TYPE), "{reason}"); + } + assert_eq!(validate_input_schema(&json!({ "type": "object" })), Ok(())); +} + +#[test] +fn test_system_invalid_schema_reason_does_not_leak_values() { + let schema = json!({ + "type": "object", + "properties": { + "path": { "type": "bogus-type", "default": "fixture-marker-do-not-print" } + } + }); + let error = expect_error( + vec![bridge_with_schema("workspace", "Read", schema)], + &required(&[("workspace", &["Read"])]), + ); + + let SystemToolError::InvalidSchema { reason, .. } = &error else { + panic!("期望 InvalidSchema,实际 {error}"); + }; + assert!(reason.contains("/properties/path/type"), "{reason}"); + // 错误只含字段路径与固定规则,不回显非法值或 annotation 数据。 + assert!(!reason.contains("bogus-type"), "{reason}"); + assert!(!reason.contains("fixture-marker-do-not-print"), "{reason}"); + + let error = expect_error( + vec![bridge_with_schema( + "workspace", + "Read", + json!({ "type": "object", "properties": 42 }), + )], + &required(&[("workspace", &["Read"])]), + ); + let SystemToolError::InvalidSchema { reason, .. } = &error else { + panic!("期望 InvalidSchema,实际 {error}"); + }; + assert!(!reason.contains("42"), "{reason}"); +} + +#[test] +fn test_system_valid_schema_preserves_extensions_and_data() { + let schema = json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "format": "uri-reference", + "pattern": "^/", + "examples": ["/tmp/a"] + }, + "mode": { "enum": ["read", "write"], "default": "read", "const": null }, + "flag": { "type": ["boolean", "null"], "deprecated": false, "readOnly": true }, + "options": { + "type": "object", + "properties": { "recursive": true }, + "additionalProperties": false, + "x-vendor-extension": { "anything": [1, "two", null] } + }, + "window": { + "if": { "type": "object" }, + "then": { "required": ["start"] }, + "else": false, + "not": { "type": "array", "items": { "type": "integer" }, "uniqueItems": true } + }, + "bounds": { "minimum": 0, "exclusiveMaximum": 10, "multipleOf": 0.5 }, + "quota": { "$ref": "#/$defs/quota", "$comment": "vendor note" } + }, + "required": ["path"], + "patternProperties": { "^x-": { "type": "string" } }, + "propertyNames": { "pattern": "^[a-z]" }, + "dependentSchemas": { "path": { "required": ["mode"] } }, + "$defs": { + "quota": { "type": "array", "prefixItems": [{ "type": "integer" }, true] } + }, + "definitions": { "legacy": { "type": "object" } }, + "allOf": [{ "anyOf": [{ "type": "object" }, { "type": "null" }] }], + "oneOf": [{ "contains": { "type": "string" } }], + "unevaluatedPropertyLookup": { "unknown-keyword": [1, 2] } + }); + + let prepared = expect_ok( + vec![bridge_with_schema("workspace", "Read", schema.clone())], + &required(&[("workspace", &["Read"])]), + ); + + // MCP 原始 schema 被完整保留,未被改写或补齐。 + assert_eq!(prepared[0].parameters(), schema); + assert_eq!( + direct_names(&prepared), + vec!["mcp__workspace__Read".to_string()] + ); +} + +// ─── 空数组、幂等与 all-or-nothing ────────────────────────────────────────── + +#[test] +fn test_system_empty_required_array_adds_no_direct_tools() { + // 空数组只表示「不注入额外工具」,ready 由 B 的闸门判定:本测试不是 ready 测试。 + let prepared = expect_ok( + vec![ + bridge("workspace", "Read"), + bridge("workspace", "Write"), + // 非必需工具的结构非法 schema 不参与必需检查。 + bridge_with_schema( + "other", + "Broken", + json!({ "type": "object", "properties": 42 }), + ), + ], + &required(&[("workspace", &[])]), + ); + + assert_eq!(prepared.len(), 3); + assert!(direct_names(&prepared).is_empty()); + + // 该 server 一个 bridge 都没有时,空数组也不做存在性判断(C 无法证明 ready)。 + let prepared = expect_ok(vec![bridge("other", "Read")], &required(&[("absent", &[])])); + assert_eq!(prepared.len(), 1); + assert!(direct_names(&prepared).is_empty()); +} + +#[test] +fn test_system_duplicate_requirements_do_not_duplicate_registration() { + let prepared = expect_ok( + vec![bridge("workspace", "Read"), bridge("workspace", "Write")], + &required(&[("workspace", &["Read", "Read"])]), + ); + + assert_eq!(prepared.len(), 2, "重复配置不得增加注册数"); + assert_eq!( + names(&prepared) + .iter() + .filter(|name| name.as_str() == "mcp__workspace__Read") + .count(), + 1, + "Vec 层不得出现第二份同名注册" + ); + assert_eq!( + direct_names(&prepared), + vec!["mcp__workspace__Read".to_string()] + ); +} + +#[test] +fn test_system_repeated_admission_is_idempotent() { + let required = required(&[("workspace", &["Read"])]); + let first = expect_ok( + vec![bridge("workspace", "Read"), bridge("workspace", "Glob")], + &required, + ); + let second = expect_ok(first, &required); + + assert_eq!(second.len(), 2); + assert_eq!( + direct_names(&second), + vec!["mcp__workspace__Read".to_string()] + ); +} + +#[test] +fn test_system_validation_is_all_or_nothing() { + let broken = json!({ "type": "object", "properties": 42 }); + let error = expect_error( + vec![ + bridge("workspace", "Read"), + bridge_with_schema("workspace", "Write", broken.clone()), + ], + &required(&[("workspace", &["Read", "Write"])]), + ); + assert!( + matches!(error, SystemToolError::InvalidSchema { .. }), + "{error}" + ); + + // 失败后重建同一批 fixture:没有任何 direct 标记泄漏到共享状态(无部分成功)。 + let rebuilt = vec![ + bridge("workspace", "Read"), + bridge_with_schema("workspace", "Write", broken), + ]; + assert!(rebuilt.iter().all(|bridge| !bridge.is_direct())); + + // 非法项留在集合中但不被要求时,合法必需项可独立通过。 + let prepared = expect_ok(rebuilt, &required(&[("workspace", &["Read"])])); + assert_eq!(prepared.len(), 2); + assert_eq!( + direct_names(&prepared), + vec!["mcp__workspace__Read".to_string()] + ); +} + +#[test] +fn test_system_dynamic_control_remains_deferred() { + let tool = fixture_tool(json!({ + "name": "ShadowTool", + "description": "fixture", + "inputSchema": read_schema() + })); + let dynamic = McpToolBridge::new_dynamic( + "dynamic", + &tool, + fixture_handle("dynamic"), + DynamicMcpAdmissionGate::new(), + ) + .expect("动态 fixture 名称合法"); + assert!(!dynamic.is_direct(), "动态 bridge 缺省保持 deferred"); + + let prepared = expect_ok( + vec![bridge("workspace", "Read"), dynamic], + &required(&[("workspace", &["Read"])]), + ); + + assert_eq!( + direct_names(&prepared), + vec!["mcp__workspace__Read".to_string()] + ); + let dynamic = prepared + .iter() + .find(|bridge| bridge.name() == "mcp__dynamic__ShadowTool") + .expect("动态 bridge 仍在集合中"); + assert!(!dynamic.is_direct(), "动态 gate 路径不得被自动提升"); +} diff --git a/peri-middlewares/src/mcp/task_scope.rs b/peri-middlewares/src/mcp/task_scope.rs index 2facae439..a120017e1 100644 --- a/peri-middlewares/src/mcp/task_scope.rs +++ b/peri-middlewares/src/mcp/task_scope.rs @@ -33,6 +33,13 @@ pub enum McpTaskKey { OAuth(String), Reconnect(String), Subscription(String), + /// 会话级 MCP over ACP 建连(`mcp/connect` + 握手 + 工具发现)。 + /// + /// 会话关闭时按本键终止在建任务,避免连接在会话消失后仍提交进池。 + Acp { + session_id: String, + server_id: String, + }, Dynamic { kind: DynamicMcpTaskKind, session_id: String, diff --git a/peri-middlewares/src/mcp/tool_bridge.rs b/peri-middlewares/src/mcp/tool_bridge.rs index fb99f01d5..8b1e1997a 100644 --- a/peri-middlewares/src/mcp/tool_bridge.rs +++ b/peri-middlewares/src/mcp/tool_bridge.rs @@ -30,6 +30,10 @@ pub enum ToolCallError { } /// 将单个 MCP tool 包装为 BaseTool 实现 +/// +/// `Clone` 只复制已有的 String/Value/Arc/gate 字段,不建立新连接、不注册新 lease; +/// 供准入路径从已验证快照多次产出 Box,避免重复注册同一工具。 +#[derive(Clone)] pub struct McpToolBridge { server_name: String, tool_name: String, @@ -37,6 +41,8 @@ pub struct McpToolBridge { description: String, input_schema: serde_json::Value, model_visible: bool, + /// 是否直接进入模型 tools 参数;缺省 false(deferred)。 + direct: bool, server_generation: u64, client: Arc, binding_leases: Option>, @@ -112,6 +118,7 @@ impl McpToolBridge { description, input_schema, model_visible: super::apps::tool_visibility(tool).model, + direct: false, server_generation: 0, client, binding_leases: None, @@ -147,6 +154,7 @@ impl McpToolBridge { input_schema: serde_json::to_value(&*tool.input_schema) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())), model_visible: super::apps::tool_visibility(tool).model, + direct: false, server_generation: 0, client, binding_leases: None, @@ -166,6 +174,28 @@ impl McpToolBridge { self.binding_leases = Some(registry); self } + + /// 将本 bridge 提升为 direct(无需模型先搜索即可出现在 tools 参数中)。 + /// + /// 只改 direct 标记:visibility、名称、client、generation、admission 与 + /// binding leases 均不变,也不产生副本或新注册。 + /// + /// 调用点归 `system_tools::prepare_system_tools`(同 Wave 落地)。 + pub(crate) fn with_direct(mut self) -> Self { + self.direct = true; + self + } + + /// MCP 声明的原始工具名(未净化、未加 server 前缀)。 + /// + /// effective name 的净化不可逆(分隔符与 `__` 都可能出现在分量内), + /// 需要按原始名匹配时必须经此访问器,不得反拆 `name()`。 + /// server identity 用 [`BaseTool::mcp_server_name`]。 + /// + /// 调用点归 `system_tools::prepare_system_tools`(同 Wave 落地)。 + pub(crate) fn original_tool_name(&self) -> &str { + &self.tool_name + } } fn valid_name_component(name: &str) -> bool { @@ -201,6 +231,10 @@ impl BaseTool for McpToolBridge { self.model_visible } + fn is_direct(&self) -> bool { + self.direct + } + async fn invoke( &self, input: serde_json::Value, @@ -366,24 +400,144 @@ fn format_contents(contents: &[ContentBlock]) -> String { parts.join("\n") } -/// 从 McpClientPool 的所有已连接客户端中批量创建 McpToolBridge -pub fn build_tool_bridges(pool: &McpClientPool) -> Vec> { - let mut bridges: Vec> = Vec::new(); - for client in pool.get_all_clients() { +/// 会话可见的 typed bridge 集合(唯一 typed 构造入口)。 +/// +/// `build_tool_bridges` / [`McpToolBridge::with_direct`] 的 typed 版本:调用方 +/// 可以在同一批对象上做分类(如 [`McpToolBridge::with_direct`])后只装箱一次, +/// 避免同一工具被注册两份。两种 constructor、generation 与 binding leases 行为 +/// 与原实现一致。 +/// +/// `session_id` 为 `None` 表示不过滤(部署面视图);`Some` 时排除其他会话的 +/// ACP 连接(`McpClientPool::is_visible_to_session`),避免会话间工具泄漏。 +pub(crate) fn build_typed_tool_bridges_visible_to( + pool: &McpClientPool, + session_id: Option<&str>, +) -> Vec { + let mut bridges: Vec = Vec::new(); + for client in pool.get_all_clients_visible_to(session_id) { let generation = pool.handle_generation(&client); for tool in &client.tools { - bridges.push(Box::new( + bridges.push( McpToolBridge::new(&client.name, tool, Arc::clone(&client)) .with_server_generation(generation) .with_binding_leases(Arc::clone(&pool.app_binding_leases)), - )); + ); } } bridges } +/// 从 McpClientPool 的所有已连接客户端中批量创建 McpToolBridge +/// +/// 全部返回值保持 deferred 默认行为(`is_direct() == false`)。 +/// +/// 不过滤会话归属(部署面视图):会话内装配必须走 +/// [`build_tool_bridges_visible_to`],否则会拿到其他会话声明的 ACP 工具。 +pub fn build_tool_bridges(pool: &McpClientPool) -> Vec> { + build_tool_bridges_visible_to(pool, None) +} + +/// 会话可见的 bridge 集合([`build_tool_bridges`] 的 ACP 归属过滤版)。 +pub fn build_tool_bridges_visible_to( + pool: &McpClientPool, + session_id: Option<&str>, +) -> Vec> { + build_typed_tool_bridges_visible_to(pool, session_id) + .into_iter() + .map(|bridge| Box::new(bridge) as Box) + .collect() +} + /// 统一工具池组装:内置工具优先去重 #[cfg(test)] #[path = "tool_bridge_test.rs"] mod tests; + +/// C-INJ-01 focused 回归:typed bridge 的 direct 提升不改变 bridge 身份, +/// 且既有 public `build_tool_bridges` 的 deferred 默认行为不变。 +#[cfg(test)] +mod direct_flag_tests { + use super::*; + use crate::mcp::client::ClientStatus; + + fn make_tool(tool_name: &str) -> Tool { + serde_json::from_value(serde_json::json!({ + "name": tool_name, + "description": "Read a file", + "inputSchema": { + "type": "object", + "properties": { "path": { "type": "string" } } + } + })) + .unwrap() + } + + fn make_handle(server: &str, tools: Vec, status: ClientStatus) -> Arc { + Arc::new(McpClientHandle { + name: server.to_string(), + version: None, + cache_version: None, + peer: None, + tools, + resources: vec![], + status, + oauth_status: Default::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }) + } + + #[test] + fn test_system_direct_flag_preserves_bridge_identity() { + let bridge = McpToolBridge::new( + "workspace", + &make_tool("Read"), + make_handle("workspace", vec![], ClientStatus::Disconnected), + ); + assert!(!bridge.is_direct(), "new 缺省必须为 deferred"); + let name = bridge.name().to_string(); + let parameters = bridge.parameters(); + assert!(bridge.visible_to_model()); + + let promoted = bridge.with_direct(); + assert!(promoted.is_direct()); + assert_eq!(promoted.name(), name); + assert_eq!(promoted.original_tool_name(), "Read"); + assert_eq!(promoted.mcp_server_name(), Some("workspace")); + assert_eq!(promoted.parameters(), parameters); + assert!( + promoted.visible_to_model(), + "direct 不等于绕过 model visibility" + ); + } + + #[test] + fn test_build_tool_bridges_keeps_deferred_default_and_matches_typed() { + let pool = McpClientPool::new_pending(); + let handle = make_handle( + "workspace", + vec![make_tool("Read")], + ClientStatus::Connected, + ); + pool.clients + .write() + .insert("workspace".to_string(), Arc::clone(&handle)); + + let typed = build_typed_tool_bridges_visible_to(&pool, None); + let boxed = build_tool_bridges(&pool); + assert_eq!(typed.len(), 1); + assert_eq!(boxed.len(), typed.len()); + assert_eq!(boxed[0].name(), typed[0].name()); + assert!(!typed[0].is_direct(), "typed builder 缺省必须为 deferred"); + assert!( + !boxed[0].is_direct(), + "public builder 必须保持 deferred 默认" + ); + // 既有 generation / binding leases 传递行为不得因提取 typed builder 而丢失 + assert_eq!(typed[0].server_generation, pool.handle_generation(&handle)); + assert!(typed[0].binding_leases.is_some()); + } +} diff --git a/peri-middlewares/src/mcp/transport.rs b/peri-middlewares/src/mcp/transport.rs index faf7eb5b0..1018bf737 100644 --- a/peri-middlewares/src/mcp/transport.rs +++ b/peri-middlewares/src/mcp/transport.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use peri_acp_types::plugin::McpServerConfigValidationError; use thiserror::Error; use super::config::McpServerConfig; @@ -25,12 +26,18 @@ pub enum TransportConfig { pub enum TransportError { #[error("MCP 服务器配置无效: 缺少 command 或 url 字段")] InvalidConfig, + /// typed 配置不满足契约不变量。`McpServerConfig` 是公开 struct,可手工构造, + /// 因此 Deserialize 不是唯一闸门——建传输前同样要过同一份纯校验。 + #[error(transparent)] + InvalidSystemConfig(#[from] McpServerConfigValidationError), } impl TryFrom<&McpServerConfig> for TransportConfig { type Error = TransportError; fn try_from(config: &McpServerConfig) -> Result { + // System key 组合非法(含显式 `[]` 无 `system_mcp = true`)不得建立传输。 + config.validate()?; match (&config.command, &config.url) { (Some(command), _) => Ok(TransportConfig::Stdio { command: command.clone(), diff --git a/peri-middlewares/src/mcp/transport_test.rs b/peri-middlewares/src/mcp/transport_test.rs index 87227cc9d..4312b1a8b 100644 --- a/peri-middlewares/src/mcp/transport_test.rs +++ b/peri-middlewares/src/mcp/transport_test.rs @@ -11,6 +11,9 @@ fn test_config() -> McpServerConfig { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, } } @@ -152,3 +155,35 @@ fn test_oauth_field_skipped_when_disabled() { _ => panic!("Expected StreamableHttp"), } } + +#[test] +fn test_system_mcp_transport_rejects_invalid_typed_config() { + // 公开 struct 可手工构造:Deserialize 不是唯一闸门,建传输前同样要过契约校验。 + for tools in [Some(Vec::new()), Some(vec!["search".to_string()])] { + let config = McpServerConfig { + command: Some("npx".to_string()), + system_mcp: Some(false), + system_mcp_tools: tools, + ..test_config() + }; + let result = TransportConfig::try_from(&config); + assert!( + matches!( + result, + Err(TransportError::InvalidSystemConfig( + McpServerConfigValidationError::SystemMcpToolsRequiresSystemMcp + )) + ), + "手工构造的非法 System 配置必须被传输层拒绝" + ); + } + + // 合法组合不受影响。 + let valid = McpServerConfig { + command: Some("npx".to_string()), + system_mcp: Some(true), + system_mcp_tools: Some(Vec::new()), + ..test_config() + }; + assert!(TransportConfig::try_from(&valid).is_ok()); +} diff --git a/peri-middlewares/src/plugin/loader.rs b/peri-middlewares/src/plugin/loader.rs index 493d7b0b9..3b5cd79f4 100644 --- a/peri-middlewares/src/plugin/loader.rs +++ b/peri-middlewares/src/plugin/loader.rs @@ -11,6 +11,7 @@ use peri_acp_types::command::command_route::{ RouteEntry, }; use peri_acp_types::command::{CommandContext, CommandHandler, CommandOutcome}; +use peri_acp_types::plugin::McpServerConfigValidationError; use peri_resources::lsp::config::{lsp_config_from_plugin, LspServerConfig}; use serde::Deserialize; use thiserror::Error; @@ -18,7 +19,7 @@ use tracing::{debug, warn}; use crate::{ hooks::types::RegisteredHook, - mcp::{config::McpConfigFile, McpServerConfig}, + mcp::McpServerConfig, plugin::{ config::{ load_claude_settings, load_installed_plugins, load_plugin_manifest, @@ -46,6 +47,12 @@ pub enum LoaderError { ConfigError(#[from] crate::plugin::PluginConfigError), #[error("IO 错误: {0}")] Io(#[from] std::io::Error), + /// 插件 MCP 配置无效(MCP 专用严格路径)。 + /// + /// `message` 只保留固定规则正文或解析定位(行列/错误类别),不回显原始输入值 + /// ——env / headers / OAuth 字段的内容不得进入错误文本(ARC-SECRET-001)。 + #[error("插件 MCP 配置无效: {path}: {message}")] + McpConfigInvalid { path: PathBuf, message: String }, } #[derive(Debug, Deserialize, Default)] @@ -396,60 +403,143 @@ pub(crate) fn extract_agents_paths(manifest: &PluginManifest, base_dir: &Path) - result } +/// 插件清单文件路径(`.claude-plugin/plugin.json`)。 +fn plugin_manifest_path(install_path: &Path) -> PathBuf { + install_path.join(".claude-plugin").join("plugin.json") +} + +/// 解析错误的可诊断定位:只保留错误类别与行列,不回显原始输入值 +/// (serde 的 `invalid type` 正文会带上值本身,可能把 env / headers 内容写进日志)。 +fn describe_json_error(error: &serde_json::Error) -> String { + let kind = match error.classify() { + serde_json::error::Category::Io => "I/O 错误", + serde_json::error::Category::Syntax => "JSON 语法错误", + serde_json::error::Category::Data => "字段类型或取值不符合契约", + serde_json::error::Category::Eof => "JSON 提前结束", + }; + format!("{kind}(行 {} 列 {})", error.line(), error.column()) +} + +/// 契约层冻结的 System 规则正文:命中即回显固定规则文本,否则退回解析定位。 +/// +/// 这里匹配的是本仓库自己的冻结错误文案(`McpServerConfigValidationError` 的 +/// Display),不是任意用户输入;命中与否只决定错误文本,不决定跳过或继续。 +fn describe_server_parse_error(error: &serde_json::Error) -> String { + let text = error.to_string(); + let rule = [ + McpServerConfigValidationError::SystemMcpToolsRequiresSystemMcp, + McpServerConfigValidationError::SystemMcpTimeoutRequiresSystemMcp, + McpServerConfigValidationError::SystemMcpTimeoutOutOfRange, + ] + .into_iter() + .map(|rule| rule.to_string()) + .find(|rule| text.contains(rule)); + rule.unwrap_or_else(|| describe_json_error(error)) +} + +/// 解析单个 MCP server 条目:typed 反序列化(含 System 组合校验)后再做纯校验。 +fn parse_mcp_server_entry( + value: &serde_json::Value, + path: &Path, + server_name: &str, +) -> Result { + let config: McpServerConfig = + serde_json::from_value(value.clone()).map_err(|error| LoaderError::McpConfigInvalid { + path: path.to_path_buf(), + message: format!("{server_name}: {}", describe_server_parse_error(&error)), + })?; + validate_mcp_server_config(&config, path, server_name)?; + Ok(config) +} + +/// 单个 server 配置的纯校验(含手工构造的 typed 配置)。 +fn validate_mcp_server_config( + config: &McpServerConfig, + path: &Path, + server_name: &str, +) -> Result<(), LoaderError> { + config + .validate() + .map_err(|rule| LoaderError::McpConfigInvalid { + path: path.to_path_buf(), + message: format!("{server_name}: {rule}"), + }) +} + +/// 解析 `{"serverName": {...}}` 形态的 server map;任一 entry 非法即整体失败。 +fn parse_mcp_servers_object( + value: &serde_json::Value, + path: &Path, +) -> Result, LoaderError> { + let Some(object) = value.as_object() else { + return Err(LoaderError::McpConfigInvalid { + path: path.to_path_buf(), + message: "mcpServers 必须是对象".to_string(), + }); + }; + let mut result = HashMap::new(); + for (name, entry) in object { + result.insert(name.clone(), parse_mcp_server_entry(entry, path, name)?); + } + Ok(result) +} + /// Load MCP servers from a .mcp.json file, supporting both formats: /// - Standard: `{"mcpServers": {...}}` /// - Flat: `{"serverName": {...}}` (no mcpServers wrapper, used by context7/gitlab) -fn load_mcp_json_file(path: &Path) -> Option> { - let content = std::fs::read_to_string(path).ok()?; - let v: serde_json::Value = serde_json::from_str(&content).ok()?; - - // Try standard format first: {"mcpServers": {...}} - if let Some(_servers) = v.get("mcpServers") { - if let Ok(file_config) = serde_json::from_value::(v.clone()) { - if !file_config.mcp_servers.is_empty() { - return Some(file_config.mcp_servers); - } - } +/// +/// 严格语义:缺失文件是「未声明」(`Ok(None)`),存在但读取/解析失败是错误 +/// (`Err`),不当作可跳过的条目。wrapped 形态一旦出现就不再看 flat 形态; +/// flat 形态任一 entry 非法则整体失败,不保留部分成功。 +fn load_mcp_json_file( + path: &Path, +) -> Result>, LoaderError> { + if !path.exists() { + return Ok(None); } - - // Fallback: flat format — each key is a server name, value is a McpServerConfig - if let Some(obj) = v.as_object() { - let mut result = HashMap::new(); - for (key, val) in obj { - // Skip known non-server keys - if key == "mcpServers" { - continue; - } - if let Ok(cfg) = serde_json::from_value::(val.clone()) { - result.insert(key.clone(), cfg); - } - } - if !result.is_empty() { - return Some(result); - } + let content = std::fs::read_to_string(path).map_err(|error| LoaderError::McpConfigInvalid { + path: path.to_path_buf(), + message: format!("读取失败: {error}"), + })?; + let value: serde_json::Value = + serde_json::from_str(&content).map_err(|error| LoaderError::McpConfigInvalid { + path: path.to_path_buf(), + message: describe_json_error(&error), + })?; + + // Standard format: {"mcpServers": {...}}(空 map 也是「已声明」) + if let Some(servers) = value.get("mcpServers") { + return Ok(Some(parse_mcp_servers_object(servers, path)?)); } - None + // Flat format — each key is a server name, value is a McpServerConfig + Ok(Some(parse_mcp_servers_object(&value, path)?)) } /// Extract MCP servers from plugin manifest. /// Supports inline config objects and .mcp.json file path references. /// Falls back to install_path/.mcp.json when manifest has no mcpServers. +/// +/// 严格语义:manifest 声明了 `mcpServers`(包括空 map)就不回退根 `.mcp.json`; +/// 回退只在**未声明**时发生,不因解析失败而触发。内联条目逐项校验;被引用的 +/// 配置文件非法时整个提取失败,不部分接纳合法兄弟条目。 pub(crate) fn extract_mcp_servers( manifest: &PluginManifest, install_path: &Path, -) -> HashMap { +) -> Result, LoaderError> { let mut result = HashMap::new(); if let Some(entries) = &manifest.mcp_servers { + let manifest_path = plugin_manifest_path(install_path); for (name, entry) in entries { match entry { McpServerEntry::Config(cfg) => { + validate_mcp_server_config(cfg, &manifest_path, name)?; result.insert(name.clone(), (**cfg).clone()); } McpServerEntry::FilePath(path) => { let resolved = install_path.join(path); - match load_mcp_json_file(&resolved) { + match load_mcp_json_file(&resolved)? { Some(mcp_servers) => { for (srv_name, srv_cfg) in mcp_servers { // 文件路径引用中的服务器名保留,外层会再加命名空间 @@ -465,56 +555,113 @@ pub(crate) fn extract_mcp_servers( None => { warn!( path = %resolved.display(), - "插件 MCP 配置文件加载失败,跳过" + "插件 MCP 配置文件不存在,跳过该声明" ); } } } } } + return Ok(result); } // Fallback: if manifest has no mcpServers, try install_path/.mcp.json - if result.is_empty() { - let mcp_json = install_path.join(".mcp.json"); - if mcp_json.exists() { - debug!(path = %mcp_json.display(), "加载插件根目录 .mcp.json 作为 MCP 配置回退"); - if let Some(mcp_servers) = load_mcp_json_file(&mcp_json) { - result = mcp_servers; - } - } + let mcp_json = install_path.join(".mcp.json"); + if !mcp_json.exists() { + return Ok(result); } + debug!(path = %mcp_json.display(), "加载插件根目录 .mcp.json 作为 MCP 配置回退"); + Ok(load_mcp_json_file(&mcp_json)?.unwrap_or_default()) +} - result +/// 插件装配时对 MCP 配置错误采用的处理策略。 +/// +/// 严格化**只限 MCP 启动路径**:既有宽容 API(`load_enabled_plugins_aggregated` +/// 等展示/聚合入口)保持「坏插件不阻止宿主启动」的产品行为。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum McpConfigPolicy { + /// 宽容(展示 / 面板 / skills / hooks 聚合):记录安全诊断,该插件的 MCP 声明按空处理。 + Lenient, + /// 严格(MCP 启动路径):非法 MCP 配置直接失败,不降级为空配置。 + Strict, } pub fn load_plugins(installed: &InstalledPlugins) -> Result, LoaderError> { + load_plugins_with_policy(installed, McpConfigPolicy::Lenient) +} + +/// 装配已安装插件;`policy` 决定非法 MCP 配置是失败还是降级。 +fn load_plugins_with_policy( + installed: &InstalledPlugins, + policy: McpConfigPolicy, +) -> Result, LoaderError> { let mut result = Vec::new(); for plugin in &installed.plugins { let manifest = match load_manifest(&plugin.install_path) { Ok(m) => m, - Err(_) => { - // 尝试从 marketplace manifest 生成合成 plugin.json(兼容修复前安装的 LSP 插件) - if try_generate_synthetic_manifest_fallback( + Err(error) => { + let manifest_path = plugin_manifest_path(&plugin.install_path); + // 已存在但非法的清单不允许被合成清单覆盖修复,也不当作未安装: + // 严格路径直接报错,宽容路径记录诊断后跳过该插件。 + if manifest_path.exists() { + if policy == McpConfigPolicy::Strict { + return Err(error); + } + warn!( + plugin = %plugin.name, + error = %error, + "插件清单非法,跳过该插件" + ); + continue; + } + // 清单文件缺失:允许从 marketplace manifest 生成合成清单 + // (兼容修复前安装的 LSP 插件),生成结果同样按严格语义解析。 + if !try_generate_synthetic_manifest_fallback( &plugin.install_path, &plugin.name, &plugin.marketplace, ) { - match load_manifest(&plugin.install_path) { - Ok(m) => m, - Err(_) => continue, - } - } else { + warn!( + plugin = %plugin.name, + "插件清单缺失且无法生成合成清单,跳过该插件" + ); continue; } + match load_manifest(&plugin.install_path) { + Ok(m) => m, + Err(error) => { + if policy == McpConfigPolicy::Strict { + return Err(error); + } + warn!( + plugin = %plugin.name, + error = %error, + "合成清单解析失败,跳过该插件" + ); + continue; + } + } } }; let commands = extract_commands(&manifest, &plugin.install_path, &plugin.name); let skills_roots = extract_skills_paths(&manifest, &plugin.install_path, &plugin.name); let agents_dirs = extract_agents_paths(&manifest, &plugin.install_path); - let mcp_servers = extract_mcp_servers(&manifest, &plugin.install_path); + let mcp_servers = match extract_mcp_servers(&manifest, &plugin.install_path) { + Ok(servers) => servers, + Err(error) => match policy { + McpConfigPolicy::Strict => return Err(error), + McpConfigPolicy::Lenient => { + warn!( + plugin = %plugin.name, + error = %error, + "插件 MCP 配置无效,跳过该插件的 MCP 声明" + ); + HashMap::new() + } + }, + }; let data_path = plugin.install_path.join(".claude-plugin").join("data"); let hooks_config = crate::hooks::loader::extract_hooks(&manifest, &plugin.install_path); @@ -560,10 +707,13 @@ fn merge_enabled_plugins( project.enabled_plugins.iter().cloned().collect() } -pub fn load_enabled_plugins( +/// 选出已启用插件(installed 记录 ∩ enabledPlugins)。 +/// +/// 严格与宽容入口共用本函数:启用范围规则只有一份,两条路径不复制解析逻辑。 +fn select_enabled_plugins( claude_dir: &Path, cwd: Option<&Path>, -) -> Result, LoaderError> { +) -> Result { let plugins_path = claude_dir.join("plugins").join("installed_plugins.json"); let settings_path = claude_dir.join("settings.json"); @@ -584,12 +734,32 @@ pub fn load_enabled_plugins( .filter(|p| enabled_ids.contains(&p.id)) .collect(); - let filtered_installed = InstalledPlugins { + Ok(InstalledPlugins { version: installed.version, plugins: filtered, - }; + }) +} + +pub fn load_enabled_plugins( + claude_dir: &Path, + cwd: Option<&Path>, +) -> Result, LoaderError> { + load_plugins(&select_enabled_plugins(claude_dir, cwd)?) +} - load_plugins(&filtered_installed) +/// MCP 执行专用严格入口:复用启用选择与装配逻辑,但非法 MCP 配置直接失败 +/// ——不降级为空配置、不当作未安装继续。 +/// +/// 只有 MCP 合并(`mcp::config`)使用本入口;`load_enabled_plugins_aggregated` +/// 等宽容展示 API 的类型与行为保持不变。 +pub(crate) fn load_enabled_plugins_for_mcp( + claude_dir: &Path, + cwd: Option<&Path>, +) -> Result, LoaderError> { + load_plugins_with_policy( + &select_enabled_plugins(claude_dir, cwd)?, + McpConfigPolicy::Strict, + ) } pub struct PluginCommandProvider { @@ -632,8 +802,14 @@ pub fn merge_plugin_mcp_servers(plugins: &[LoadedPlugin]) -> HashMap) -> PluginLoadResult { let plugins = match load_enabled_plugins(claude_dir, cwd) { Ok(p) => p, - Err(_) => { - // 静默失败,避免在 TUI 上打印错误日志 + Err(error) => { + // 宽容展示路径:保留「返回空结果」的产品行为,但错误必须可见 + // ——不静默丢弃(合法诊断只含路径与固定规则/解析定位)。 + warn!( + claude_dir = %claude_dir.display(), + error = %error, + "插件聚合加载失败,返回空结果" + ); return PluginLoadResult { plugins: vec![], all_skill_roots: vec![], diff --git a/peri-middlewares/src/plugin/loader_test.rs b/peri-middlewares/src/plugin/loader_test.rs index 12c4a65e9..987fee756 100644 --- a/peri-middlewares/src/plugin/loader_test.rs +++ b/peri-middlewares/src/plugin/loader_test.rs @@ -415,12 +415,15 @@ fn test_extract_mcp_servers() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, })), ); manifest.mcp_servers = Some(servers); - let result = extract_mcp_servers(&manifest, Path::new("/tmp")); + let result = extract_mcp_servers(&manifest, Path::new("/tmp")).unwrap(); assert_eq!(result.len(), 1); assert!(result.contains_key("s1")); } @@ -428,7 +431,7 @@ fn test_extract_mcp_servers() { #[test] fn test_extract_mcp_servers_none() { let manifest = make_manifest_with_commands(vec![]); - let result = extract_mcp_servers(&manifest, Path::new("/tmp")); + let result = extract_mcp_servers(&manifest, Path::new("/tmp")).unwrap(); assert!(result.is_empty()); } @@ -451,7 +454,7 @@ fn test_extract_mcp_servers_file_path_ref() { ); manifest.mcp_servers = Some(servers); - let result = extract_mcp_servers(&manifest, &plugin_dir); + let result = extract_mcp_servers(&manifest, &plugin_dir).unwrap(); assert_eq!(result.len(), 1); assert!(result.contains_key("db")); assert_eq!(result["db"].command.as_deref(), Some("sqlite3")); @@ -468,7 +471,8 @@ fn test_extract_mcp_servers_file_path_not_found() { ); manifest.mcp_servers = Some(servers); - let result = extract_mcp_servers(&manifest, dir.path()); + // 被引用的文件缺失是「未声明」,不是非法配置;非法的 *内容* 才失败。 + let result = extract_mcp_servers(&manifest, dir.path()).unwrap(); assert!(result.is_empty()); } @@ -483,7 +487,7 @@ fn test_extract_mcp_servers_fallback_mcp_json_standard_format() { .unwrap(); let manifest = make_manifest_with_commands(vec![]); - let result = extract_mcp_servers(&manifest, dir.path()); + let result = extract_mcp_servers(&manifest, dir.path()).unwrap(); assert_eq!(result.len(), 1); assert!(result.contains_key("srv")); assert_eq!(result["srv"].command.as_deref(), Some("npx")); @@ -500,7 +504,7 @@ fn test_extract_mcp_servers_fallback_mcp_json_flat_format() { .unwrap(); let manifest = make_manifest_with_commands(vec![]); - let result = extract_mcp_servers(&manifest, dir.path()); + let result = extract_mcp_servers(&manifest, dir.path()).unwrap(); assert_eq!(result.len(), 1); assert!(result.contains_key("context7")); assert_eq!(result["context7"].command.as_deref(), Some("npx")); @@ -534,12 +538,15 @@ fn test_extract_mcp_servers_manifest_has_priority_over_fallback() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, })), ); manifest.mcp_servers = Some(servers); - let result = extract_mcp_servers(&manifest, dir.path()); + let result = extract_mcp_servers(&manifest, dir.path()).unwrap(); assert_eq!(result.len(), 1); assert!(result.contains_key("inline")); assert_eq!(result["inline"].command.as_deref(), Some("inline-cmd")); @@ -555,7 +562,7 @@ fn test_load_mcp_json_file_flat_format_multiple_servers() { ) .unwrap(); - let result = super::load_mcp_json_file(&mcp_json_path).unwrap(); + let result = super::load_mcp_json_file(&mcp_json_path).unwrap().unwrap(); assert_eq!(result.len(), 2); assert!(result.contains_key("srv1")); assert!(result.contains_key("srv2")); @@ -571,7 +578,7 @@ fn test_load_mcp_json_file_standard_format() { ) .unwrap(); - let result = super::load_mcp_json_file(&mcp_json_path).unwrap(); + let result = super::load_mcp_json_file(&mcp_json_path).unwrap().unwrap(); assert_eq!(result.len(), 1); assert!(result.contains_key("srv")); } @@ -579,7 +586,7 @@ fn test_load_mcp_json_file_standard_format() { #[test] fn test_load_mcp_json_file_nonexistent() { let result = super::load_mcp_json_file(Path::new("/nonexistent/mcp.json")); - assert!(result.is_none()); + assert!(matches!(result, Ok(None)), "缺失文件是未声明,不是错误"); } #[test] @@ -587,8 +594,13 @@ fn test_load_mcp_json_file_invalid_json() { let dir = tempdir().unwrap(); let mcp_json_path = dir.path().join("bad.mcp.json"); std::fs::write(&mcp_json_path, b"not json").unwrap(); - let result = super::load_mcp_json_file(&mcp_json_path); - assert!(result.is_none()); + let error = + super::load_mcp_json_file(&mcp_json_path).expect_err("非法 JSON 不得被当作可跳过条目"); + let LoaderError::McpConfigInvalid { path, message } = error else { + panic!("非法 MCP 文件应返回 McpConfigInvalid"); + }; + assert_eq!(path, mcp_json_path); + assert!(message.contains("JSON 语法错误"), "message: {message}"); } #[test] @@ -618,6 +630,9 @@ fn test_merge_plugin_mcp_servers() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, }, ); @@ -647,6 +662,9 @@ fn test_merge_plugin_mcp_servers() { disabled: None, protocol_version: None, subscriptions: None, + system_mcp: None, + system_mcp_tools: None, + system_mcp_timeout: None, source: None, }, ); @@ -1399,3 +1417,229 @@ fn test_load_lsp_servers_aggregated_injects_plugin_root_env() { }) ); } + +// ─── MCP 专用严格插件路径(契约 1 的插件来源入口)───────────────────────── + +const SYSTEM_TOOLS_RULE: &str = "system_mcp_tools requires system_mcp = true"; + +/// 在 `claude_home` 下安装一个插件并启用它;`manifest_mcp` 为 `mcpServers` 字段的 +/// 原始 JSON 文本(`None` 表示 manifest 不声明该字段)。 +fn install_plugin(claude_home: &Path, name: &str, manifest_mcp: Option<&str>) -> PathBuf { + let plugin_dir = claude_home + .join("plugins") + .join("cache") + .join("mkt") + .join(name) + .join("1.0.0"); + std::fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap(); + let mcp_field = manifest_mcp + .map(|raw| format!(r#","mcpServers":{raw}"#)) + .unwrap_or_default(); + std::fs::write( + plugin_dir.join(".claude-plugin").join("plugin.json"), + format!(r#"{{"name":"{name}","version":"1.0.0"{mcp_field}}}"#), + ) + .unwrap(); + + let installed = InstalledPlugins { + version: 2, + plugins: vec![InstalledPlugin { + id: format!("{name}@mkt"), + name: name.to_string(), + version: "1.0.0".into(), + marketplace: "mkt".into(), + install_path: plugin_dir.clone(), + scope: InstallScope::User, + project_path: None, + origin: PluginOrigin::PeriInstalled, + }], + }; + std::fs::create_dir_all(claude_home.join("plugins")).unwrap(); + std::fs::write( + claude_home.join("plugins").join("installed_plugins.json"), + serde_json::to_string(&installed).unwrap(), + ) + .unwrap(); + std::fs::write( + claude_home.join("settings.json"), + format!(r#"{{"enabledPlugins":["{name}@mkt"]}}"#), + ) + .unwrap(); + plugin_dir +} + +fn assert_mcp_config_invalid(error: LoaderError, expected_path: &Path, rule: &str) { + let LoaderError::McpConfigInvalid { path, message } = error else { + panic!("严格 MCP 路径应返回 McpConfigInvalid,实际: {error}"); + }; + assert_eq!(path, expected_path); + assert!( + message.contains(rule), + "固定规则正文必须保留(message={message})" + ); +} + +#[test] +fn test_system_mcp_plugin_strict_sources_reject_invalid() { + // 文件引用(wrapped):非法内容 → McpConfigInvalid,且不部分接纳合法兄弟条目。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin( + &claude_home, + "wrapped", + Some(r#"{"srv":"servers/.mcp.json"}"#), + ); + let servers_dir = plugin_dir.join("servers"); + std::fs::create_dir_all(&servers_dir).unwrap(); + let wrapped = servers_dir.join(".mcp.json"); + std::fs::write( + &wrapped, + r#"{"mcpServers":{"legal":{"command":"npx"},"bad":{"system_mcp_tools":["t"]}}}"#, + ) + .unwrap(); + let error = + load_enabled_plugins_for_mcp(&claude_home, None).expect_err("非法 wrapped 配置必须失败"); + assert_mcp_config_invalid(error, &wrapped, SYSTEM_TOOLS_RULE); + + // 文件引用(flat):同样失败。 + std::fs::write( + &wrapped, + r#"{"legal":{"command":"npx"},"bad":{"system_mcp":false,"system_mcp_tools":[]}}"#, + ) + .unwrap(); + let error = + load_enabled_plugins_for_mcp(&claude_home, None).expect_err("非法 flat 配置必须失败"); + assert_mcp_config_invalid(error, &wrapped, SYSTEM_TOOLS_RULE); + + // 根 .mcp.json 回退:manifest 未声明 mcpServers 时读取,非法同样失败。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "fallback", None); + let root_mcp = plugin_dir.join(".mcp.json"); + std::fs::write( + &root_mcp, + r#"{"mcpServers":{"bad":{"system_mcp_tools":["t"]}}}"#, + ) + .unwrap(); + let error = + load_enabled_plugins_for_mcp(&claude_home, None).expect_err("非法根 .mcp.json 必须失败"); + assert_mcp_config_invalid(error, &root_mcp, SYSTEM_TOOLS_RULE); + + // 内联 manifest:清单解析本身失败(内联 DTO 走契约层 Deserialize), + // 错误仍保留路径与固定规则正文,而不是被当作未安装跳过。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin( + &claude_home, + "inline", + Some(r#"{"bad":{"system_mcp_tools":["t"]}}"#), + ); + let error = + load_enabled_plugins_for_mcp(&claude_home, None).expect_err("非法内联 MCP 配置必须失败"); + let text = error.to_string(); + assert!( + text.contains(&plugin_manifest_path(&plugin_dir).display().to_string()), + "错误必须带清单路径: {text}" + ); + assert!( + text.contains(SYSTEM_TOOLS_RULE), + "错误必须带固定规则正文: {text}" + ); +} + +#[test] +fn test_system_mcp_plugin_invalid_manifest_has_no_fallback() { + // 已存在但非法的清单:不得 synthetic overwrite、不得从根配置兜底、文件字节不变。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "broken-manifest", None); + let manifest_path = plugin_manifest_path(&plugin_dir); + let broken = r#"{"name":"broken-manifest","version":"1.0.0","mcpServers":{"bad":{"system_mcp_tools":["t"]}}}"#; + std::fs::write(&manifest_path, broken).unwrap(); + // 根配置里放一个合法 server:非法清单不允许借它兜底「修复」。 + std::fs::write( + plugin_dir.join(".mcp.json"), + r#"{"mcpServers":{"ok":{"command":"npx"}}}"#, + ) + .unwrap(); + + let error = load_enabled_plugins_for_mcp(&claude_home, None).expect_err("非法现存清单必须失败"); + assert!( + error.to_string().contains(SYSTEM_TOOLS_RULE), + "必须保留清单解析的明确错误: {error}" + ); + assert_eq!( + std::fs::read_to_string(&manifest_path).unwrap(), + broken, + "非法清单不得被覆盖或修复" + ); +} + +#[test] +fn test_system_mcp_plugin_empty_manifest_map_has_no_fallback() { + // manifest 显式声明空 map:属于「已声明」,不得从根 .mcp.json 加载额外服务器。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "empty-map", Some("{}")); + std::fs::write( + plugin_dir.join(".mcp.json"), + r#"{"mcpServers":{"root-srv":{"command":"npx"}}}"#, + ) + .unwrap(); + + let plugins = load_enabled_plugins_for_mcp(&claude_home, None).unwrap(); + assert_eq!(plugins.len(), 1); + assert!( + plugins[0].mcp_servers.is_empty(), + "显式空 map 不得触发根配置回退: {:?}", + plugins[0].mcp_servers.keys().collect::>() + ); + + // 未声明(manifest 无 mcpServers 字段)才允许根回退。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "no-decl", None); + std::fs::write( + plugin_dir.join(".mcp.json"), + r#"{"mcpServers":{"root-srv":{"command":"npx"}}}"#, + ) + .unwrap(); + let plugins = load_enabled_plugins_for_mcp(&claude_home, None).unwrap(); + assert!(plugins[0].mcp_servers.contains_key("root-srv")); +} + +#[test] +fn test_system_mcp_plugin_lenient_aggregate_keeps_other_capabilities() { + // 宽容聚合路径(展示/面板)保持产品行为:坏 MCP 声明不阻止该插件的 + // hooks 装配,但错误必须被记录而不是静默丢弃。 + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin( + &claude_home, + "lenient", + Some(r#"{"srv":"servers/.mcp.json"}"#), + ); + let servers_dir = plugin_dir.join("servers"); + std::fs::create_dir_all(&servers_dir).unwrap(); + std::fs::write( + servers_dir.join(".mcp.json"), + r#"{"mcpServers":{"bad":{"system_mcp_tools":["t"]}}}"#, + ) + .unwrap(); + // 插件的其它能力(hooks 约定文件)必须仍然装配。 + std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); + std::fs::write( + plugin_dir.join("hooks").join("hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo hi"}]}]}}"#, + ) + .unwrap(); + + let aggregated = load_enabled_plugins_aggregated(&claude_home, None); + assert_eq!(aggregated.plugins.len(), 1, "坏 MCP 配置不得让插件整体消失"); + assert!( + aggregated.all_mcp_servers.is_empty(), + "非法 MCP 声明不得进入聚合目录" + ); + assert!(!aggregated.all_hooks.is_empty(), "插件的其它能力必须保留"); + assert_eq!(aggregated.all_hooks[0].plugin_name, "lenient"); +} diff --git a/peri-middlewares/tests/mcp_host_policy_contract.rs b/peri-middlewares/tests/mcp_host_policy_contract.rs new file mode 100644 index 000000000..89db2deb6 --- /dev/null +++ b/peri-middlewares/tests/mcp_host_policy_contract.rs @@ -0,0 +1,874 @@ +//! D-04:宿主策略与生命周期契约测试(验收契约 6,兼契约 3 的 deferred 半边)。 +//! +//! ## 证据范围(诚实分级) +//! +//! 本文件是 `peri-middlewares` 的**外部集成测试**,只能使用 crate 的 `pub` API。 +//! MCP 侧使用真实的 rmcp client service(`serve_client_with_lifecycle`)连到 +//! `tokio::io::duplex` 上的 JSON-RPC fixture:server 端记录真实收到的 `tools/call`, +//! 因此下面所有「未调用 / 恰好调用一次」断言都是 wire 事实,不是 mock 计数。 +//! +//! 逐能力断言(每条一个测试,不用一条测试覆盖多类能力): +//! +//! | 能力 | 断言 | 承担者 | +//! | --- | --- | --- | +//! | Permission + HITL | broker 收到 effective name;拒绝 → 0 次 wire 调用;批准 → 恰好 1 次 | 本文件的 `hitl_*` 测试 | +//! | effective tool name | 策略/审批看到 `mcp__{server}__{tool}`,wire 上仍是属于该 namespace 的裸工具名 | 同上 | +//! | cancel | 在飞 `tools/call` 被取消后不再重试,dispatch 返回 `Interrupted` | `in_flight_cancellation_*` | +//! | ToolSearch deferral | 未提升的 MCP bridge 不进 `direct_definitions`(与 Reason 阶段下发给 LLM 的工具集用**同一** `is_direct() && visible_to_model()` 谓词),只能经 SearchExtraTools/ExecuteExtraTool 到达 | `deferred_*` | +//! | session / event / host assembly | **BLOCKED**(见下) | B-07 | +//! +//! ## BLOCKED(不在本层假装覆盖) +//! +//! 1. **被提升为 direct 的真实 MCP bridge**(`McpToolBridge::with_direct` 是 +//! `pub(crate)`,`prepare_system_tools` / `system_mcp_tools` 解析同理)。本层只能 +//! 证明**判定输入**:Permission 的决策只看 `(name, input)`,从不接触 `BaseTool`, +//! 因此 `is_direct` 在结构上无法影响审批;而 `is_direct` → `direct_definitions` +//! 的过滤边界由 `deferred_*` 的探针工具单独钉住。真实 direct 提升后的端到端证据 +//! 归 **D-02**(crate 内 `mcp_v4_seam_test.rs`)与 **B-07**(`peri-acp` host seam)。 +//! 2. **`run_initialize` 驱动的配置 → 提升接线**在本层不可用:它是唯一会发布 +//! `SystemMcpManifest::Loaded` + system 依赖的 public 入口,但内部按 +//! `dirs_next::home_dir()/.peri/settings.json` 解析全局配置,没有可注入 seam +//! (`load_merged_config_full_with_paths` 不对外)。在进程内跑它等于读开发机上 +//! 真实的 MCP 配置并连真实 server(含凭据),因此本文件**不**调用它。该接线证据 +//! 同样归 **B-07** 的隔离 HOME host 场景;本文件记录为 BLOCKED。 +//! 3. **session 身份 / ACP 事件投影 / 首个 LLM 请求的 tools 入参 / 生产链装配 +//! (`ProductionChainAssembler` 槽位)**:需要 `peri-acp` host 装配层。 +//! 本层只断言 `StageContext` 的 render 事件属于同一 turn,不代替 host seam。 +//! 归 **B-07**(`peri-acp/src/host/mcp_v4_startup_test.rs`)。 +//! 4. **Hook / SubAgent / Workflow / Goal / PTC 的具体实现**未在本层重测;本层只证明 +//! 工具调用仍然完整经过 middleware chain(`before_tools_batch` 对 MCP bridge 可见), +//! 即 direct 注入没有短路链上既有 hook 位。 +//! +//! fixture 全部定义在本文件内(不建立 workspace 级共享 test helper),且不含任何真实 +//! 凭据:server 不校验 header / token,测试也不读取用户 HOME 下的 MCP 配置。 + +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +use async_trait::async_trait; +use parking_lot::{Mutex, RwLock}; +use peri_agent::{ + agent::{ + react::{Reasoning, ToolCall}, + stages::{tool_dispatch::dispatch_tools, SharedToolMap, StageContext}, + }, + interaction::{ + ApprovalDecision, InteractionContext, InteractionResponse, UserInteractionBroker, + }, + middleware::{capabilities as hook_state, r#trait::Middleware, MiddlewareChain}, + session::{tool_catalog::SessionToolCatalog, FrozenContext, Session}, + tools::{BaseTool, ToolContext}, +}; +use peri_middlewares::{ + mcp::{ClientStatus, McpClientHandle, McpToolBridge, OAuthStatus}, + permission::{ + default_requires_approval, PermissionMiddleware, PermissionMode, SharedPermissionMode, + }, + tool_search::{ + SearchExtraTools, ToolSearchIndex, ToolSearchMiddleware, EXECUTE_EXTRA_TOOL_NAME, + SEARCH_EXTRA_TOOLS_NAME, + }, + ExecuteExtraToolResolver, +}; +use rmcp::{ + model::{ClientCapabilities, Implementation, InitializeRequestParams}, + service::{serve_client_with_lifecycle, ClientLifecycleMode, RoleClient, RunningService}, + transport::async_rw::AsyncRwTransport, +}; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio_util::sync::CancellationToken; + +// ─── 真实 MCP wire fixture(duplex + rmcp 官方 client service)───────────────── + +const FIXTURE_SERVER: &str = "host-fixture"; +const REQUIRED_TOOL: &str = "write_note"; +const DEFERRED_TOOL: &str = "read_note"; + +fn effective_name(tool: &str) -> String { + format!("mcp__{FIXTURE_SERVER}__{tool}") +} + +fn tool_declaration(name: &str, description: &str, schema: Value) -> Value { + json!({ + "name": name, + "description": description, + "inputSchema": schema, + }) +} + +/// 一次 `tools/call` 的 wire 事实:server 端收到的原始参数。 +#[derive(Default)] +struct WireLog { + calls: Mutex>, + /// 每次收到 `tools/call` 唤醒;`Notify::notify_one` 会保存 permit, + /// 因此「先到调用、后 await」与「先 await、后到调用」都成立(无睡眠)。 + call_reached: tokio::sync::Notify, + /// `Some` 时 `tools/call` 挂起,直到测试显式放行(在飞取消用例)。 + release: Option>, +} + +impl WireLog { + fn calls(&self) -> Vec { + self.calls.lock().clone() + } + + fn called_tool_names(&self) -> Vec { + self.calls() + .iter() + .filter_map(|params| params["name"].as_str().map(str::to_string)) + .collect() + } +} + +/// 一个已连接的 fixture server:持有真实 client service,drop 即断开 transport。 +struct Fixture { + handle: Arc, + log: Arc, + _service: RunningService, +} + +impl Fixture { + fn bridge(&self, tool: &str) -> McpToolBridge { + let declaration = self + .handle + .tools + .iter() + .find(|candidate| candidate.name.as_ref() == tool) + .expect("fixture must expose the requested tool on the wire"); + McpToolBridge::new(FIXTURE_SERVER, declaration, Arc::clone(&self.handle)) + } +} + +/// 启动一个最小 MCP server(initialize / tools/list / tools/call), +/// 让真实 rmcp client service 完成握手并返回由 wire 声明的工具。 +async fn spawn_fixture(blocking_call: bool) -> Fixture { + let declarations = vec![ + tool_declaration( + REQUIRED_TOOL, + "写入一条笔记(写入型工具,默认需要审批)", + json!({ + "type": "object", + "properties": {"note": {"type": "string"}}, + "required": ["note"] + }), + ), + tool_declaration( + DEFERRED_TOOL, + "读取一条笔记", + json!({ + "type": "object", + "properties": {"id": {"type": "string"}} + }), + ), + ]; + let release = blocking_call.then(|| Arc::new(tokio::sync::Notify::new())); + let log = Arc::new(WireLog { + calls: Mutex::new(Vec::new()), + call_reached: tokio::sync::Notify::new(), + release: release.clone(), + }); + + let (client_io, server_io) = tokio::io::duplex(16 * 1024); + let server_log = Arc::clone(&log); + tokio::spawn(serve_fixture(server_io, declarations, server_log, release)); + + let (read, write) = tokio::io::split(client_io); + let transport = AsyncRwTransport::new(read, write); + let service = serve_client_with_lifecycle( + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::from_build_env(), + ), + transport, + ClientLifecycleMode::Initialize, + ) + .await + .expect("fixture handshake must succeed"); + + // 工具集来自真实 `tools/list` 响应:fixture 不做静态清单注入。 + let listed = service + .peer() + .list_tools(None) + .await + .expect("fixture tools/list must succeed"); + let handle = Arc::new(McpClientHandle { + name: FIXTURE_SERVER.to_string(), + version: None, + cache_version: None, + peer: Some(service.peer().clone()), + tools: listed.tools, + resources: Vec::new(), + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + channel_capable: false, + skills_capable: false, + }); + Fixture { + handle, + log, + _service: service, + } +} + +async fn serve_fixture( + io: tokio::io::DuplexStream, + declarations: Vec, + log: Arc, + release: Option>, +) { + let (read, mut write) = tokio::io::split(io); + let mut lines = BufReader::new(read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + let Some(method) = message["method"].as_str() else { + continue; + }; + let id = message.get("id").cloned(); + let response = match method { + "initialize" => json!({ + "protocolVersion": message["params"]["protocolVersion"], + "capabilities": {}, + "serverInfo": {"name": "host-policy-fixture", "version": "1"} + }), + "tools/list" => json!({"tools": declarations}), + "tools/call" => { + log.calls.lock().push(message["params"].clone()); + log.call_reached.notify_one(); + if let Some(release) = &release { + release.notified().await; + } + let tool = message["params"]["name"].as_str().unwrap_or_default(); + json!({ + "content": [{"type": "text", "text": format!("fixture handled {tool}")}], + "isError": false + }) + } + // 通知(如 notifications/initialized)没有 id,不回包。 + _ => { + let Some(id) = id else { continue }; + let error = json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": "Method not found"} + }); + write + .write_all(format!("{error}\n").as_bytes()) + .await + .expect("fixture must stay writable"); + continue; + } + }; + let Some(id) = id else { continue }; + let payload = json!({"jsonrpc": "2.0", "id": id, "result": response}); + write + .write_all(format!("{payload}\n").as_bytes()) + .await + .expect("fixture must stay writable"); + write.flush().await.expect("fixture flush must succeed"); + } +} + +// ─── broker / chain / context fixtures ──────────────────────────────────────── + +/// 记录 HITL 交互内容并返回固定决策的 broker。 +/// +/// 只用于审批分支 fixture:它不能替代真实 UI/HITL 交互证据,这一点在文件头已声明。 +struct RecordingBroker { + seen: Mutex>, + approve: bool, +} + +impl RecordingBroker { + fn new(approve: bool) -> Arc { + Arc::new(Self { + seen: Mutex::new(Vec::new()), + approve, + }) + } + + fn seen(&self) -> Vec<(String, Value)> { + self.seen.lock().clone() + } +} + +#[async_trait] +impl UserInteractionBroker for RecordingBroker { + async fn request(&self, ctx: InteractionContext) -> InteractionResponse { + let InteractionContext::Approval { items } = ctx else { + return InteractionResponse::Rejected; + }; + let decision = if self.approve { + ApprovalDecision::Approve { source: None } + } else { + ApprovalDecision::Reject { + reason: "contract fixture rejected".to_string(), + source: None, + } + }; + let mut decisions = Vec::with_capacity(items.len()); + for item in items { + self.seen + .lock() + .push((item.tool_name.clone(), item.tool_input.clone())); + decisions.push(decision.clone()); + } + InteractionResponse::Decisions(decisions) + } +} + +/// 记录 `before_tools_batch` 可见调用的 middleware:direct 注入不得短路链上 hook 位。 +struct PolicyRecorder(Arc>>); + +#[async_trait] +impl Middleware for PolicyRecorder { + fn name(&self) -> &str { + "PolicyRecorder" + } + + async fn before_tools_batch( + &self, + _state: &mut dyn hook_state::BeforeToolState, + calls: &[ToolCall], + ) -> Vec> { + self.0.lock().extend_from_slice(calls); + calls.iter().cloned().map(Ok).collect() + } +} + +/// 生产构造形态的审批链:`PermissionMiddleware::with_shared_mode` + +/// 生产 `default_requires_approval`,外加链位记录器。 +fn approval_chain( + broker: Arc, + policy: Arc>>, +) -> MiddlewareChain { + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(PermissionMiddleware::with_shared_mode( + broker, + default_requires_approval, + SharedPermissionMode::new(PermissionMode::Default), + None, + ))); + chain.add(Box::new(PolicyRecorder(policy))); + chain +} + +fn make_context( + tools: BTreeMap>, + chain: MiddlewareChain, +) -> ( + StageContext, + peri_agent::agent::events_v2::EventHandles, + Arc, +) { + let session = Session::new( + Arc::from("/tmp/mcp-host-policy"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let (event_bus, handles) = peri_agent::agent::events_v2::EventBus::new(Default::default()); + let shared: SharedToolMap = Arc::new(RwLock::new(tools)); + let catalog = Arc::new( + SessionToolCatalog::try_new(shared.read().clone(), None) + .expect("fixture tool map must not contain conflicting aliases"), + ); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_tools(shared) + .with_tool_catalog(Arc::clone(&catalog)) + .with_tool_invocation_resolver(Arc::new(ExecuteExtraToolResolver::default())) + .with_middleware_chain(Arc::new(chain)) + .with_event_bus(Arc::new(event_bus)) + .build(); + (context, handles, catalog) +} + +fn bridge_tools(bridges: &[McpToolBridge]) -> BTreeMap> { + bridges + .iter() + .map(|bridge| { + ( + bridge.name().to_string(), + Arc::new(bridge.clone()) as Arc, + ) + }) + .collect() +} + +fn direct_definition_names( + snapshot: &peri_agent::session::tool_catalog::SessionToolCatalogSnapshot, +) -> Vec { + snapshot + .direct_definitions + .iter() + .map(|definition| definition.name.clone()) + .collect() +} + +/// 只提供 `CatalogState` 需要的两项能力的最小 state。 +struct CatalogStateFixture { + tools: SharedToolMap, + recalls: Vec, +} + +impl hook_state::CatalogState for CatalogStateFixture { + fn local_tools(&self) -> Option<&SharedToolMap> { + Some(&self.tools) + } + + fn push_recall(&mut self, item: String) { + self.recalls.push(item); + } +} + +/// `is_direct` 过滤边界的探针:名字与 MCP 无关,只因 `is_direct()==true` 而进入 +/// direct 列表。它只用于证明 direct/deferred 的**分界是 `is_direct`**, +/// **不**构成「真实 MCP bridge 被提升为 direct」的证据(那归 D-02 / B-07)。 +struct DirectProbeTool { + name: &'static str, + invoked: Arc, +} + +#[async_trait] +impl BaseTool for DirectProbeTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "direct boundary probe" + } + + fn parameters(&self) -> Value { + json!({"type": "object", "properties": {}}) + } + + fn is_direct(&self) -> bool { + true + } + + async fn invoke( + &self, + _input: Value, + _ctx: ToolContext<'_>, + ) -> Result> { + self.invoked.store(true, Ordering::SeqCst); + Ok("probe".to_string()) + } +} + +fn assert_policy_saw(chain_seen: &Arc>>, expected_name: &str) { + let seen = chain_seen.lock(); + assert_eq!( + seen.len(), + 1, + "MCP bridge 调用必须完整经过 middleware chain(before_tools_batch)" + ); + assert_eq!(seen[0].name, expected_name); +} + +// ─── 契约 6:Permission + HITL + effective name(批准分支)──────────────────── + +/// 能力:Permission + HITL + effective tool name。 +/// +/// 一次批准后的 MCP 工具调用必须:审批看到 `mcp__{server}__{tool}`;wire 上只出现 +/// **一次**、且是所属 namespace 的裸工具名;调用完整经过 middleware chain; +/// render 事件属于同一 turn。 +#[tokio::test] +async fn hitl_approval_gates_mcp_bridge_by_effective_name_and_calls_server_once() { + let fixture = spawn_fixture(false).await; + let bridge = fixture.bridge(REQUIRED_TOOL); + let effective = effective_name(REQUIRED_TOOL); + + assert_eq!(bridge.name(), effective); + assert_eq!(bridge.mcp_server_name(), Some(FIXTURE_SERVER)); + // 判定输入事实:生产敏感规则对 `mcp__` 前缀生效——审批只看工具名, + // 不接触 BaseTool,因此 `is_direct` 在结构上无法影响这条决策。 + assert!(default_requires_approval(&effective)); + + let broker = RecordingBroker::new(true); + let chain_seen = Arc::new(Mutex::new(Vec::new())); + let chain = approval_chain(broker.clone(), Arc::clone(&chain_seen)); + let tools = bridge_tools(std::slice::from_ref(&bridge)); + let (context, mut events, _catalog) = make_context(tools, chain); + let reasoning = Reasoning::with_tools( + "", + vec![ToolCall::new( + "call-approved", + effective.clone(), + json!({"note": "hello"}), + )], + ); + + let outcome = dispatch_tools( + &context, + &reasoning, + &context.runtime.tool_catalog.snapshot(), + &CancellationToken::new(), + ) + .await + .expect("approved MCP call must settle without a fatal error"); + + // Permission/HITL:broker 收到的名字是 effective name,参数原样透传。 + assert_eq!( + broker.seen(), + vec![(effective.clone(), json!({"note": "hello"}))], + "HITL 必须看到 effective tool name,而不是裸 MCP 工具名" + ); + + // 链未被绕过:before_tools_batch 观察到同一次调用(Hook/Workflow/PTC 等 + // 链上能力的接入点)。具体实现不在本层重测。 + assert_policy_saw(&chain_seen, &effective); + + // 结果:来自真实 wire 的响应。 + assert_eq!(outcome.results.len(), 1); + let (call, result) = &outcome.results[0]; + assert_eq!(call.name, effective); + assert!(!result.is_error, "approved call must succeed: {result:?}"); + assert_eq!(result.tool_name, effective); + assert!(result.output.contains("fixture handled write_note")); + + // wire:恰好一次 tools/call,且 wire 上是所属 namespace 的裸工具名。 + assert_eq!( + fixture.log.called_tool_names(), + vec![REQUIRED_TOOL.to_string()], + "批准后必须恰好触发一次真实 MCP tools/call" + ); + assert_eq!( + fixture.log.calls()[0]["arguments"], + json!({"note": "hello"}) + ); + + // render 事件:effective name + 同一 turn(session/ACP 投影归 B-07)。 + let turn_id = context.turn_id(); + let started = events.render_rx.recv().await.expect("ToolStarted expected"); + match started { + peri_agent::agent::events_v2::RenderEvent::ToolStarted { + name, + input, + turn_id: event_turn, + .. + } => { + assert_eq!(name, effective); + assert_eq!(input, json!({"note": "hello"})); + assert_eq!(event_turn, turn_id); + } + event => panic!("expected ToolStarted, got {event:?}"), + } + let ended = events.render_rx.recv().await.expect("ToolEnded expected"); + match ended { + peri_agent::agent::events_v2::RenderEvent::ToolEnded { + name, + is_error, + turn_id: event_turn, + .. + } => { + assert_eq!(name, effective); + assert!(!is_error); + assert_eq!(event_turn, turn_id); + } + event => panic!("expected ToolEnded, got {event:?}"), + } +} + +// ─── 契约 6:Permission + HITL(拒绝分支)──────────────────────────────────── + +/// 能力:Permission + HITL 拒绝时不得触发真实 MCP 调用。 +#[tokio::test] +async fn hitl_rejection_never_reaches_the_mcp_server() { + let fixture = spawn_fixture(false).await; + let bridge = fixture.bridge(REQUIRED_TOOL); + let effective = effective_name(REQUIRED_TOOL); + + let broker = RecordingBroker::new(false); + let chain_seen = Arc::new(Mutex::new(Vec::new())); + let chain = approval_chain(broker.clone(), Arc::clone(&chain_seen)); + let tools = bridge_tools(std::slice::from_ref(&bridge)); + let (context, _events, _catalog) = make_context(tools, chain); + let reasoning = Reasoning::with_tools( + "", + vec![ToolCall::new( + "call-rejected", + effective.clone(), + json!({"note": "blocked"}), + )], + ); + + let outcome = dispatch_tools( + &context, + &reasoning, + &context.runtime.tool_catalog.snapshot(), + &CancellationToken::new(), + ) + .await + .expect("rejected call is settled, not fatal"); + + // 审批确实发生过(否则下面的「零调用」可能只是目标解析失败)。 + assert_eq!( + broker + .seen() + .iter() + .map(|(name, _)| name.clone()) + .collect::>(), + vec![effective.clone()] + ); + + let (_, result) = &outcome.results[0]; + assert!(result.is_error); + assert_eq!(result.tool_name, effective); + assert_eq!( + result.effective_error_code, + Some(peri_agent::tools::EffectiveToolErrorCode::UserRejected), + "拒绝必须分类为 UserRejected,不能退化成普通失败" + ); + + assert!( + fixture.log.calls().is_empty(), + "被拒绝的调用不得触发任何真实 MCP tools/call,实际收到 {:?}", + fixture.log.calls() + ); +} + +// ─── 契约 6:cancel ────────────────────────────────────────────────────────── + +/// 能力:cancel。在飞 `tools/call` 被取消后:dispatch 以 `Interrupted` 结束, +/// 不重放、不补发第二次 wire 请求。 +#[tokio::test] +async fn in_flight_cancellation_ends_the_call_without_a_second_wire_request() { + let fixture = spawn_fixture(true).await; + let bridge = fixture.bridge(REQUIRED_TOOL); + let effective = effective_name(REQUIRED_TOOL); + let wire_log = Arc::clone(&fixture.log); + + let broker = RecordingBroker::new(true); + let chain_seen = Arc::new(Mutex::new(Vec::new())); + let chain = approval_chain(broker.clone(), Arc::clone(&chain_seen)); + let tools = bridge_tools(std::slice::from_ref(&bridge)); + let (context, _events, _catalog) = make_context(tools, chain); + let reasoning = Reasoning::with_tools( + "", + vec![ToolCall::new( + "call-cancelled", + effective.clone(), + json!({"note": "cancel me"}), + )], + ); + let catalog = context.runtime.tool_catalog.snapshot(); + let cancel = CancellationToken::new(); + + let dispatch_cancel = cancel.clone(); + let dispatch_context = context.clone(); + let dispatch = tokio::spawn(async move { + dispatch_tools(&dispatch_context, &reasoning, &catalog, &dispatch_cancel).await + }); + + // 等到 server 真的收到 tools/call(显式信号,不用睡眠)后再取消: + // 取消必须打在**已批准且已在飞**的调用上,不是启动前拦截。 + wire_log.call_reached.notified().await; + cancel.cancel(); + + let outcome = dispatch.await.expect("dispatch task must not panic"); + match outcome { + Err(peri_agent::error::AgentError::Interrupted) => {} + Err(error) => panic!("取消必须以 Interrupted 结束,实际 Err({error:?})"), + Ok(outcome) => panic!("取消不得产出成功结果,实际 {} 项", outcome.results.len()), + } + + assert_eq!( + broker + .seen() + .iter() + .map(|(name, _)| name.clone()) + .collect::>(), + vec![effective.clone()], + "取消失效前的审批必须已完成(否则本用例退化为启动前拦截)" + ); + assert_policy_saw(&chain_seen, &effective); + assert_eq!( + wire_log.called_tool_names(), + vec![REQUIRED_TOOL.to_string()], + "取消不得触发重放或第二次 tools/call" + ); + + // 放行 fixture 收尾,避免后台 server 任务挂在通知上。 + if let Some(release) = &fixture.log.release { + release.notify_one(); + } +} + +// ─── 契约 3:deferred MCP 工具仍走 ToolSearch 路径 ─────────────────────────── + +/// 能力:ToolSearch deferral。 +/// +/// 未被提升的 MCP bridge 必须:不进 RCRA `direct_definitions`;可被 +/// `SearchExtraTools` 检索;只能经 `ExecuteExtraTool` 到达 wire;链与审批照旧。 +/// 探针工具钉住 direct/deferred 的分界是 `is_direct()`,不是别的过滤条件。 +#[tokio::test] +async fn deferred_mcp_bridge_is_reachable_only_through_tool_search() { + let fixture = spawn_fixture(false).await; + let bridge = fixture.bridge(DEFERRED_TOOL); + let effective = effective_name(DEFERRED_TOOL); + assert!( + !bridge.is_direct(), + "未要求提升的 MCP bridge 必须保持 deferred 默认行为" + ); + // 排除「因对模型不可见而被排除」这一替代解释:本用例的排除必须只由 deferred 造成。 + assert!( + bridge.visible_to_model(), + "fixture 工具必须对模型可见,否则 direct 排除失去因果意义" + ); + + let mut tools = bridge_tools(std::slice::from_ref(&bridge)); + tools.insert( + "DirectProbe".to_string(), + Arc::new(DirectProbeTool { + name: "DirectProbe", + invoked: Arc::new(AtomicBool::new(false)), + }) as Arc, + ); + let shared: SharedToolMap = Arc::new(RwLock::new(tools)); + let index = Arc::new(ToolSearchIndex::new()); + let tool_search = ToolSearchMiddleware::new(Arc::clone(&index), Arc::clone(&shared)); + + // 生产顺序:宿主先 merge `collect_tools` 的 meta 工具,再在 Reason 边界重绑。 + for tool in + ::collect_tools(&tool_search, "/tmp/mcp-host-policy") + { + let name = tool.name().to_string(); + shared.write().insert(name, Arc::from(tool)); + } + let mut state = CatalogStateFixture { + tools: Arc::clone(&shared), + recalls: Vec::new(), + }; + ::before_reason_catalog(&tool_search, &mut state) + .await + .expect("ToolSearch rebind must succeed"); + + // (a) direct 列表:探针在,deferred MCP bridge 不在。`direct_definitions` 与 + // Reason 阶段下发给 LLM 的工具集使用同一谓词 `is_direct() && visible_to_model()` + // (`peri-agent/src/agent/stages/reason.rs:104`),因此该集合可代表模型直连视图。 + let direct = direct_definition_names( + &SessionToolCatalog::try_new(shared.read().clone(), None) + .expect("fixture catalog must build") + .snapshot(), + ); + assert!( + direct.contains(&"DirectProbe".to_string()), + "is_direct 工具必须进入 direct_definitions(探针前提失败): {direct:?}" + ); + assert!( + !direct.contains(&effective), + "deferred MCP 工具不得进入模型直连工具列表: {direct:?}" + ); + + // (b) deferred 索引:MCP 工具可检索;direct 探针不在 deferred 索引里。 + let messages: Vec = Vec::new(); + let ctx = || ToolContext::new(&messages, "/tmp/mcp-host-policy"); + let search = SearchExtraTools::new(Arc::clone(&index)); + let found: Value = serde_json::from_str( + &search + .invoke(json!({"query": format!("select:{effective}")}), ctx()) + .await + .expect("search must succeed"), + ) + .expect("search output must be JSON"); + let found_names: Vec<&str> = found["results"] + .as_array() + .expect("results must be an array") + .iter() + .filter_map(|result| result["name"].as_str()) + .collect(); + assert!( + found_names.contains(&effective.as_str()), + "deferred MCP 工具必须能被 ToolSearch 检索: {found}" + ); + let probe: Value = serde_json::from_str( + &search + .invoke(json!({"query": "select:DirectProbe"}), ctx()) + .await + .expect("search must succeed"), + ) + .expect("search output must be JSON"); + assert!( + probe["results"] + .as_array() + .expect("results must be an array") + .is_empty(), + "direct 工具不得出现在 deferred 索引中: {probe}" + ); + + // (c) 经 ExecuteExtraTool 调用:审批看到 effective name,wire 收裸工具名一次。 + let broker = RecordingBroker::new(true); + let chain_seen = Arc::new(Mutex::new(Vec::new())); + let (context, _events, _catalog) = make_context( + shared.read().clone(), + approval_chain(broker.clone(), Arc::clone(&chain_seen)), + ); + let reasoning = Reasoning::with_tools( + "", + vec![ToolCall::new( + "call-deferred", + EXECUTE_EXTRA_TOOL_NAME, + json!({"tool_name": effective, "params": {"id": "n1"}}), + )], + ); + let outcome = dispatch_tools( + &context, + &reasoning, + &context.runtime.tool_catalog.snapshot(), + &CancellationToken::new(), + ) + .await + .expect("deferred call must settle"); + + assert_eq!( + broker + .seen() + .iter() + .map(|(name, _)| name.clone()) + .collect::>(), + vec![effective.clone()], + "wrapper 必须把 canonical effective name 交给审批,而不是 ExecuteExtraTool" + ); + assert_policy_saw(&chain_seen, &effective); + let (_, result) = &outcome.results[0]; + assert!(!result.is_error, "deferred call must succeed: {result:?}"); + assert_eq!(result.tool_name, effective); + assert_eq!( + fixture.log.called_tool_names(), + vec![DEFERRED_TOOL.to_string()], + "deferred 调用必须落到所属 namespace 的裸 MCP 工具名上,且只有一次" + ); +} + +/// 能力:契约 6 的「未绕过」补充说明——`SEARCH_EXTRA_TOOLS_NAME` 常量必须与 +/// `ToolSearchMiddleware` 注册的 meta 工具名一致,否则上面的断言会退化成 +/// 「调用了不存在的工具」。这是一条防止 seam 漂移的护栏,不是新能力覆盖。 +#[test] +fn tool_search_meta_tool_names_match_the_middleware_registration() { + let shared: SharedToolMap = Arc::new(RwLock::new(BTreeMap::new())); + let middleware = + ToolSearchMiddleware::new(Arc::new(ToolSearchIndex::new()), Arc::clone(&shared)); + let names: Vec = + ::collect_tools(&middleware, "/tmp/mcp-host-policy") + .into_iter() + .map(|tool| tool.name().to_string()) + .collect(); + assert_eq!( + names, + vec![ + SEARCH_EXTRA_TOOLS_NAME.to_string(), + EXECUTE_EXTRA_TOOL_NAME.to_string() + ] + ); +} diff --git a/peri-middlewares/tests/mcp_isolation_contract.rs b/peri-middlewares/tests/mcp_isolation_contract.rs new file mode 100644 index 000000000..4f6cc9f55 --- /dev/null +++ b/peri-middlewares/tests/mcp_isolation_contract.rs @@ -0,0 +1,541 @@ +//! MCP 实例隔离契约测试(主 plan §6 D-03 / 验收契约 5,W5)。 +//! +//! 这是**外部集成测试**(`tests/`,cargo 自动发现),因此只使用 `peri-middlewares` +//! 的 `pub` API:跳过 crate 内 readiness / system_tools seam(那些由 D-02 覆盖)。 +//! fixture 沿用仓库既有 MCP stdio fixture 约定(`command: "node"` + 临时目录内脚本, +//! 见 `mcp/initialize_test.rs`),并在文件内自定义、不建共享 helper。 +//! +//! # 断言范围(当前实现可观察的部分) +//! +//! - **独立 pool entry**:同一 pool 内每个 server name 一个 `clients` 条目,逐条目可见 +//! (`get_client` / `get_all_clients` / `all_server_infos` / `snapshot`); +//! - **独立 `McpClientHandle`**:两台 server 的句柄是不同 `Arc`,各自携带自己那次 +//! `initialize` 的身份(`version` 来自各自 serverInfo); +//! - **独立 transport wire**:两台 server 是两个真实子进程(各自独立 pid),每台只收到 +//! 自己的 JSON-RPC 请求; +//! - **namespace 路由**:`mcp__{server}__{tool}` 只向所属 server 发 `tools/call`, +//! wire 上使用的是该 server 的原始工具名; +//! - **无隐式跨 MCP 调用**:调用 A 不会在 B 的 wire 上产生任何请求,也不出现二次调用。 +//! +//! # PARTIAL —— 这些断言**不能**支撑「契约 5 完成」(主 plan §8 的 PARTIAL 分级) +//! +//! - **凭据隔离:未验证(UNVERIFIED)**。`McpClientHandle` 没有 credential 字段, +//! 凭证存储(`FileCredentialStore`)没有可安全读取的 per-instance identity。 +//! 本文件不断言、也无从断言两台 server 的凭据不共享;不使用真实 secret,也不比较、 +//! 打印任何凭据值。 +//! - **capability root 隔离:未验证(UNVERIFIED)**。`McpClientPool::capability_profile` +//! 是 pool-wide 字段且非 public,`McpConnectionKey` 亦非 public,本文件无法读取或比较 +//! 它们(因此也**未**断言 capability root 不共享)。 +//! - **五个目标 MCP 未迁移**。Workspace / Artifact / Web / Cron / LSP 五个生产实例尚不存在, +//! 本文件只覆盖「已落地连接的局部隔离」(两台 fixture),不代表契约 5 全文,也不代表 +//! 契约 2/3/4(ready gate、direct 注入、空数组语义分别由 B-07 / D-02 负责)。 + +use std::{ + collections::BTreeSet, + ffi::OsString, + path::{Path, PathBuf}, + sync::Arc, +}; + +use peri_acp_types::ports::McpPoolPort; +use peri_agent::tools::{BaseTool, ToolContext}; +use peri_middlewares::{ + mcp::{ + build_tool_bridges, ClientStatus, McpClientHandle, McpClientPool, McpInitStatus, + McpTaskOwner, McpToolBridge, + }, + process_env::{self, EnvLockFile}, +}; +use serde_json::{json, Map, Value}; + +/// 每台 fixture server 的 stdio MCP 实现(node): +/// - 每个收到的 JSON-RPC 行按原样追加到**自己**的 wire 日志(`#recv `); +/// - `server/discover` 一律 -32601,让客户端 Auto 回退到 `initialize` +/// (与 `initialize_test.rs` 的 legacy fixture 行为一致); +/// - 只声明并实现自己那一个工具,返回值带自己的身份,使「调用打到哪台 server」 +/// 在客户端返回值与 wire 日志两侧都可核对。 +const FIXTURE_SERVER_JS: &str = r#" +const fs = require('node:fs'); +const readline = require('node:readline'); + +const server = process.env.FIXTURE_SERVER; +const tool = process.env.FIXTURE_TOOL; +const version = process.env.FIXTURE_VERSION; +const logPath = process.env.FIXTURE_LOG; + +const log = (line) => fs.appendFileSync(logPath, `${line}\n`); +log(`#boot ${server} pid=${process.pid}`); + +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + log(`#recv ${line}`); + let request; + try { + request = JSON.parse(line); + } catch (error) { + return; + } + if (request.id === undefined) return; + const reply = (result) => + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`); + const refuse = (code, message) => + process.stdout.write( + `${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code, message } })}\n`, + ); + switch (request.method) { + case 'initialize': + reply({ protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: server, version } }); + break; + case 'tools/list': + reply({ + tools: [ + { + name: tool, + description: `${server} fixture tool`, + inputSchema: { type: 'object', properties: {} }, + }, + ], + }); + break; + case 'tools/call': + reply({ content: [{ type: 'text', text: `${server}:${tool}:ok` }] }); + break; + case 'resources/list': + reply({ resources: [] }); + break; + case 'ping': + reply({}); + break; + default: + refuse(-32601, 'Method not found'); + } +}); +"#; + +#[derive(Clone, Copy)] +struct Instance { + server: &'static str, + tool: &'static str, + version: &'static str, +} + +const INSTANCE_A: Instance = Instance { + server: "iso-a", + tool: "tool_a", + version: "1.0.0-a", +}; +const INSTANCE_B: Instance = Instance { + server: "iso-b", + tool: "tool_b", + version: "1.0.0-b", +}; + +fn effective_name(instance: Instance) -> String { + format!("mcp__{}__{}", instance.server, instance.tool) +} + +/// 临时 HOME:`run_initialize` 走的是生产加载路径,会读真实的 `~/.peri/settings.json` +/// 与凭证存储;不隔离就会去启动开发者本机配置的 MCP server(可能带真实凭据)。 +/// 进程级互斥沿用仓库既有 `EnvLockFile`(`Drop` 复原 `HOME` 时仍持锁)。 +struct EnvIsolation { + _lock: EnvLockFile, + previous: Option, +} + +impl EnvIsolation { + fn set(home: &Path) -> Self { + let lock = process_env::lock().expect("process env lock"); + let previous = std::env::var_os("HOME"); + std::env::set_var("HOME", home); + Self { + _lock: lock, + previous, + } + } +} + +impl Drop for EnvIsolation { + fn drop(&mut self) { + match self.previous.take() { + Some(home) => std::env::set_var("HOME", home), + None => std::env::remove_var("HOME"), + } + } +} + +struct IsolationFixture { + _dir: tempfile::TempDir, + _env: EnvIsolation, + pool: Arc, + tasks: McpTaskOwner, + logs: [PathBuf; 2], +} + +impl IsolationFixture { + fn log_path(&self, instance: Instance) -> &Path { + if instance.server == INSTANCE_A.server { + &self.logs[0] + } else { + &self.logs[1] + } + } + + fn wire(&self, instance: Instance) -> String { + std::fs::read_to_string(self.log_path(instance)).unwrap_or_default() + } + + /// `notifications/initialized` 这类通知也会落盘,因此按 method 精确取用。 + fn requests(&self, instance: Instance) -> Vec { + self.wire(instance) + .lines() + .filter_map(|line| line.strip_prefix("#recv ")) + .filter_map(|payload| serde_json::from_str::(payload).ok()) + .collect() + } + + fn methods(&self, instance: Instance) -> BTreeSet { + self.requests(instance) + .iter() + .filter_map(|request| request["method"].as_str().map(str::to_string)) + .collect() + } + + /// 该实例 wire 上收到的 `tools/call` 原始工具名(wire 上不得出现 effective name)。 + fn tool_call_names(&self, instance: Instance) -> Vec { + self.requests(instance) + .iter() + .filter(|request| request["method"] == "tools/call") + .filter_map(|request| request["params"]["name"].as_str().map(str::to_string)) + .collect() + } + + fn boot_pid(&self, instance: Instance) -> Option { + self.wire(instance) + .lines() + .find_map(|line| line.strip_prefix("#boot ")) + .map(str::to_string) + } + + fn connected(&self, instance: Instance) -> Arc { + match self.pool.get_client(instance.server) { + Some(handle) if matches!(handle.status, ClientStatus::Connected) => handle, + other => panic!( + "{} 未建立独立连接: {:?}\nwire 日志:\n{}", + instance.server, + other.as_ref().map(|handle| handle.status.clone()), + self.wire(instance), + ), + } + } + + async fn shutdown(&mut self) { + self.pool.begin_shutdown(); + self.tasks.begin_shutdown(); + let _ = self.tasks.shutdown().await; + assert!( + self.pool.shutdown().await.is_complete(), + "两台已落地连接都应能收尾" + ); + } +} + +/// 两台真实 stdio MCP server(各自独立子进程 / transport / wire 日志)+ 真实配置加载: +/// `run_initialize` 是唯一对 crate 外部可见的初始化入口,配置经 `{cwd}/.mcp.json` 注入, +/// plugin 加载目录指向临时 `claude_home`,`HOME` 指向临时目录以免碰到本机配置。 +async fn isolation_fixture() -> IsolationFixture { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + let claude_home = dir.path().join("claude"); + let cwd = dir.path().join("project"); + for path in [&home, &claude_home, &cwd] { + std::fs::create_dir_all(path).unwrap(); + } + let env = EnvIsolation::set(&home); + + let script = dir.path().join("fixture-mcp.cjs"); + std::fs::write(&script, FIXTURE_SERVER_JS).unwrap(); + let logs = [ + dir.path().join("wire-iso-a.log"), + dir.path().join("wire-iso-b.log"), + ]; + + let mut servers = Map::new(); + for (index, instance) in [INSTANCE_A, INSTANCE_B].into_iter().enumerate() { + servers.insert( + instance.server.to_string(), + json!({ + "command": "node", + "args": [script.to_string_lossy()], + "env": { + "FIXTURE_SERVER": instance.server, + "FIXTURE_TOOL": instance.tool, + "FIXTURE_VERSION": instance.version, + "FIXTURE_LOG": logs[index].to_string_lossy(), + }, + }), + ); + } + std::fs::write( + cwd.join(".mcp.json"), + json!({ "mcpServers": servers }).to_string(), + ) + .unwrap(); + + let (tasks, spawner) = McpTaskOwner::new(); + let pool = Arc::new(McpClientPool::new_pending_with_spawner(spawner)); + let (status_tx, _status_rx) = tokio::sync::watch::channel(McpInitStatus::Pending); + McpClientPool::run_initialize(pool.clone(), &cwd, &claude_home, status_tx, None, None).await; + + IsolationFixture { + _dir: dir, + _env: env, + pool, + tasks, + logs, + } +} + +async fn invoke_named(bridges: &[Box], name: &str) -> String { + let bridge = bridges + .iter() + .find(|bridge| bridge.name() == name) + .unwrap_or_else(|| panic!("工具列表里没有 {name}")); + bridge + .invoke(json!({}), ToolContext::new(&[], ".")) + .await + .unwrap_or_else(|error| panic!("{name} 调用失败: {error}")) +} + +/// pool 条目 / 句柄身份 / 工具目录 / 宿主投影都按 server 逐实例分离。 +#[tokio::test] +async fn distinct_instances_keep_distinct_pool_entries_and_handle_identity() { + let mut fixture = isolation_fixture().await; + + let a = fixture.connected(INSTANCE_A); + let b = fixture.connected(INSTANCE_B); + assert!( + !Arc::ptr_eq(&a, &b), + "两台 server 必须各持一个独立句柄,而不是共享同一份 Arc" + ); + assert_eq!( + (a.name.as_str(), b.name.as_str()), + (INSTANCE_A.server, INSTANCE_B.server) + ); + // 句柄身份来自各自的 initialize 响应:串线会立刻表现为版本相同。 + assert_eq!(a.version.as_deref(), Some(INSTANCE_A.version)); + assert_eq!(b.version.as_deref(), Some(INSTANCE_B.version)); + assert!( + a.peer.is_some() && b.peer.is_some(), + "已连接句柄必须各自持有自己的 peer" + ); + + // 工具目录按 server 分层:每台只列出自己的工具,不出现对方的工具名。 + let names = |handle: &Arc| -> Vec { + handle + .tools + .iter() + .map(|tool| tool.name.to_string()) + .collect() + }; + assert_eq!(names(&a), vec![INSTANCE_A.tool.to_string()]); + assert_eq!(names(&b), vec![INSTANCE_B.tool.to_string()]); + assert_eq!( + fixture + .pool + .get_tools(INSTANCE_A.server) + .iter() + .map(|tool| tool.name.to_string()) + .collect::>(), + vec![INSTANCE_A.tool.to_string()], + "按 server 取工具不得命中文档里的另一台" + ); + + let mut connected: Vec = fixture + .pool + .get_all_clients() + .iter() + .map(|handle| handle.name.clone()) + .collect(); + connected.sort(); + assert_eq!( + connected, + vec![INSTANCE_A.server.to_string(), INSTANCE_B.server.to_string()] + ); + + // 宿主可观察投影(面板 / `mcp/list` 命令面)逐 server 一条,不合并成一条。 + let mut infos: Vec<(String, String, usize)> = fixture + .pool + .all_server_infos() + .iter() + .map(|info| { + ( + info.name.clone(), + info.transport_type.clone(), + info.tool_count, + ) + }) + .collect(); + infos.sort(); + assert_eq!( + infos, + vec![ + (INSTANCE_A.server.to_string(), "stdio".to_string(), 1), + (INSTANCE_B.server.to_string(), "stdio".to_string(), 1), + ] + ); + let snapshot = fixture.pool.snapshot(); + assert_eq!(snapshot["initPhase"], "ready"); + let snapshot_servers: BTreeSet = snapshot["servers"] + .as_array() + .expect("snapshot.servers 必须是数组") + .iter() + .filter_map(|server| server["name"].as_str().map(str::to_string)) + .collect(); + assert_eq!( + snapshot_servers, + BTreeSet::from([INSTANCE_A.server.to_string(), INSTANCE_B.server.to_string()]) + ); + + fixture.shutdown().await; +} + +/// namespace 路由 + wire 不串 + 无隐式跨 MCP 调用。 +#[tokio::test] +async fn each_instance_wire_carries_only_its_own_requests() { + let mut fixture = isolation_fixture().await; + + // 生产 bridge 构造入口:从 pool 的已连接句柄出发生成 `mcp__{server}__{tool}`。 + let bridges = build_tool_bridges(&fixture.pool); + let mut names: Vec = bridges + .iter() + .map(|bridge| bridge.name().to_string()) + .collect(); + names.sort(); + assert_eq!( + names, + vec![effective_name(INSTANCE_A), effective_name(INSTANCE_B)] + ); + + let produced_a = invoke_named(&bridges, &effective_name(INSTANCE_A)).await; + let produced_b = invoke_named(&bridges, &effective_name(INSTANCE_B)).await; + assert!( + produced_a.contains("iso-a:tool_a:ok"), + "A 的调用返回值必须带 A 的身份: {produced_a}" + ); + assert!( + produced_b.contains("iso-b:tool_b:ok"), + "B 的调用返回值必须带 B 的身份: {produced_b}" + ); + + // wire 上只出现所属 server 的原始工具名(effective name 只存在于模型侧)。 + assert_eq!( + fixture.tool_call_names(INSTANCE_A), + vec![INSTANCE_A.tool.to_string()] + ); + assert_eq!( + fixture.tool_call_names(INSTANCE_B), + vec![INSTANCE_B.tool.to_string()] + ); + + // 正向对照:同一份日志里必须能读到自己的标识,下面的「不出现对方标识」才不是空断言。 + let wire_a = fixture.wire(INSTANCE_A); + let wire_b = fixture.wire(INSTANCE_B); + assert!( + wire_a.contains("iso-a") && wire_b.contains("iso-b"), + "wire 日志必须各自记录自己的实例标识:\nA:\n{wire_a}\nB:\n{wire_b}" + ); + + // 无隐式跨 MCP 调用:A 的 wire 上不出现 B 的任何标识,反之亦然。 + assert!( + !wire_a.contains("iso-b") && !wire_a.contains(INSTANCE_B.tool), + "A 的 wire 上出现了 B 的标识(跨实例串线):\n{wire_a}" + ); + assert!( + !wire_b.contains("iso-a") && !wire_b.contains(INSTANCE_A.tool), + "B 的 wire 上出现了 A 的标识(跨实例串线):\n{wire_b}" + ); + + // 两台分别是独立进程,且各自完成了自己的握手与工具清单(不是借来的目录)。 + let (pid_a, pid_b) = (fixture.boot_pid(INSTANCE_A), fixture.boot_pid(INSTANCE_B)); + assert!( + pid_a.is_some() && pid_b.is_some(), + "两台 fixture 都必须启动" + ); + assert_ne!(pid_a, pid_b, "两台 server 必须是两个独立进程"); + for instance in [INSTANCE_A, INSTANCE_B] { + let methods = fixture.methods(instance); + assert!( + methods.contains("initialize") && methods.contains("tools/list"), + "{} 必须自己走完 initialize + tools/list: {methods:?}", + instance.server + ); + } + + fixture.shutdown().await; +} + +/// 关闭一台实例不影响另一台的 transport 与句柄身份。 +#[tokio::test] +async fn disabling_one_instance_leaves_the_other_transport_intact() { + let mut fixture = isolation_fixture().await; + + let a_before = fixture.connected(INSTANCE_A); + let b_before = fixture.connected(INSTANCE_B); + // 在 A 被关闭前构造 A 的 bridge:模拟启动期已经持有该实例句柄的调用方。 + let stale_a: Arc = Arc::new(McpToolBridge::new( + INSTANCE_A.server, + &a_before.tools[0], + Arc::clone(&a_before), + )); + + fixture.pool.set_disabled(INSTANCE_A.server).await; + + let a_after = fixture + .pool + .get_client(INSTANCE_A.server) + .expect("禁用只在连接层面生效,面板条目仍保留"); + assert!(matches!(a_after.status, ClientStatus::Disabled)); + assert!(a_after.peer.is_none(), "被禁用的实例不得保留 peer"); + + // A 的关闭不得替换 B 的句柄:同一份 Arc、同一状态、peer 仍在。 + let b_after = fixture + .pool + .get_client(INSTANCE_B.server) + .expect("B 的条目必须不受影响"); + assert!( + Arc::ptr_eq(&b_before, &b_after), + "B 的句柄被 A 的关闭替换了" + ); + assert!(matches!(b_after.status, ClientStatus::Connected)); + assert!(b_after.peer.is_some()); + + // 关闭的是 A 自己的 transport:A 已持有的 bridge 立即失败,且错误归属 A。 + let error = stale_a + .invoke(json!({}), ToolContext::new(&[], ".")) + .await + .expect_err("已关闭实例上的调用必须失败"); + assert!( + error.to_string().contains(INSTANCE_A.server), + "失败必须归属 {}: {error}", + INSTANCE_A.server + ); + + // B 的 transport 仍然可用:真实 wire 上再收到一次 B 自己的 tools/call。 + let bridges = build_tool_bridges(&fixture.pool); + let produced_b = invoke_named(&bridges, &effective_name(INSTANCE_B)).await; + assert!( + produced_b.contains("iso-b:tool_b:ok"), + "B 在 A 被禁用后必须仍可调用: {produced_b}" + ); + assert_eq!( + fixture.tool_call_names(INSTANCE_B), + vec![INSTANCE_B.tool.to_string()] + ); + assert!( + fixture.tool_call_names(INSTANCE_A).is_empty(), + "A 已关闭,它的 wire 不该再收到调用:\n{}", + fixture.wire(INSTANCE_A) + ); + + fixture.shutdown().await; +} diff --git a/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md new file mode 100644 index 000000000..1eff0b834 --- /dev/null +++ b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md @@ -0,0 +1,152 @@ +# MCP adaptation v4-part-1 — 现场验收记录 + +**状态**:PARTIAL(契约 1–4、7 通过;契约 5、6 按能力分级的降级证据,见 §3、§4) +**优先级**:高 +**类型**:验收记录 / MCP 启动准入与一等工具注入 +**创建日期**:2026-09-25 +**最后核查**:2026-09-26(task D-05,W6;§5.1 为独立验证阶段的收口复跑) +**事实源**:`docs/design/mcp-adaptation-v4-part-1.md`(契约语义)、`spec/issues/2026-09-25-mcp-adaptation-v4-part-1-plan.md`(批次与接口冻结) +**范围**:只记录契约 1–4、7 的落地与契约 5、6 的分级证据;不实现生产代码,不回填设计文档。 + +## 1. 口径 + +本记录使用三态列,任何一格都不得跨态引用: + +- **目标归属**:设计文档的 v4 目标划分(「完全下放 / 部分下放 / 宿主保留」)。**不是**实现状态。 +- **当前实现**:代码与契约测试可核对的现状(含未迁移项)。 +- **本次运行时证据**:本次现场执行的命令、用例数、exit status 与承担该断言的测试名。无命令则写 `无`,不得用代码阅读替代。 + +裁决规则(沿用 plan §8/§9):0 tests 视为失败;绿色局部单测不升级为整体迁移结论;契约 5/6 只给降级证据时必须保持 `PARTIAL`。 + +## 2. 契约矩阵(1–7) + +| 契约 | 目标归属(设计) | 当前实现 | 本次运行时证据 | 裁决 | +| --- | --- | --- | --- | --- | +| 1 配置解析拒绝「无 `system_mcp = true` 却声明 `system_mcp_tools`」 | 配置层契约,不涉及迁移 | `McpServerConfig` 手写 `Deserialize` 解析即校验(`peri-acp-types/src/plugin.rs:202`、`:220`);`validate` 同一规则源另在 `mcp::config` 的 direct、global、merged 三个入口、`plugin::loader::load_enabled_plugins_for_mcp`(MCP 专用严格入口)、`TransportConfig::try_from` 复检 | `-- system_mcp` exit 0 / 8 passed(`test_system_mcp_tools_requires_true`、`test_system_mcp_key_aliases`、`test_system_mcp_rejects_null_and_wrong_types`、`test_system_mcp_empty_tools_roundtrip`、`test_system_mcp_timeout_requires_true` 等,plugin.rs 共 8 个测试全部命中);`mcp::config::tests` exit 0 / 45 passed(`test_system_mcp_project_rejects_tools_without_true`、`test_system_mcp_global_rejects_invalid_maps`、`test_system_mcp_merged_errors_are_not_empty_success`、`test_system_mcp_plugin_strict_error_reaches_merge`、`test_system_mcp_typed_validation_includes_disabled`);`plugin::loader` exit 0 / 58 passed(严格入参 + 宽容聚合各一条);`mcp::transport` exit 0 / 10 passed;`-- test_dynamic_mcp_rejects_system_mcp_fields` exit 0 / 1 passed | **PASS** | +| 2 未完成 transport / initialize / 协商 / 必需工具检查前不得进入可启动 react loop;失败或 timeout 返回错误 | `McpMiddleware` 在 1R 等待(v4 目标) | 新增启动闸门 hook `before_react_start`(`peri-agent/src/middleware/trait.rs:87`),调用点位于首批 `before_agent` 之后、Compact 之前(`peri-agent/src/agent/stages/mod.rs:902-909`);失败/超时→`LoopResult::Error`,`Interrupted`→`LoopResult::Interrupted`;`McpMiddleware::before_react_start` 在 `peri-middlewares/src/mcp/middleware.rs:703` | `host::mcp_v4_startup_tests` exit 0 / 11 passed:真实子进程 + 真实 rmcp stdio + counting model,`system_mcp_transport_failure_fails_first_prompt_without_model_call`、`system_mcp_connected_without_tool_discovery_is_not_ready`、`system_mcp_timeout_is_fatal_not_cancelled`、`system_mcp_disconnected_peer_fails_first_prompt`、`system_mcp_missing_required_tool_fails_before_reason` 均断言模型调用 0 次、`ExecutionFailureKind::Internal`、`TurnEnded(Error)`、ACP 投影 `-32000`+`kind=internal`;`system_mcp_gate_runs_after_receive_and_before_reason` 固定闸门位置;`ordinary_mcp_pending_does_not_block_startup` / `ordinary_mcp_failure_does_not_block_startup` 反向对照。`mcp::middleware` exit 0 / 40 passed | **PASS** | +| 3 `system_mcp_tools` 经所属 namespace 解析、schema 可构造 bridge、直接进入 RCRA 工具列表;普通 deferred 工具仍走 ToolSearch | direct 注入不经 ToolSearch(v4 目标) | `system_tools.rs` 解析/提升(`prepare_system_tools` + `McpToolBridge::with_direct`);候选经 `StartupState` 提交,`tool_catalog.replace_static_mcp_tools` 原子替换 static base(`peri-agent/src/session/tool_catalog.rs:260`) | `host::mcp_v4_startup_tests` exit 0 / 11 passed:`system_mcp_ready_exposes_required_tools_on_first_model_request` 断言**首个 LLM 请求** `tools` 含 `mcp__sys__echo`、不含同 server 非必需 `mcp__sys__glob`、普通 MCP 工具 `mcp__ord__ping` 仍在 deferred 摘要;`mcp::system_tools` exit 0 / 17 passed;`mcp::mcp_v4_seam` exit 0 / 6 passed(namespace 净化、原始名匹配、跨 server 不解析、schema 失败零提升);`mcp::tool_bridge` exit 0 / 14 passed;`tool_search` exit 0 / 65 passed | **PASS** | +| 4 必需工具为空数组只验证 ready,不注入额外工具 | 同上 | `Some([])` 与 `None` 可区分(`empty_config`/roundtrip 测试);空数组不提升任何工具 | 两条分支分别有独立断言:`system_mcp_empty_required_tools_ready_without_injection`(host,exit 0 / 11 passed 中)与 `test_system_empty_required_array_adds_no_direct_tools`(`mcp::system_tools` 17 passed);「`tools/list` 失败 ≠ 空列表」由 `system_mcp_tool_discovery_failure_is_not_an_empty_tool_list` 与 `initialize.rs` 消除 4 处 `unwrap_or_default()` 支撑 | **PASS** | +| 5 五个目标 MCP 的 transport / 状态 / 凭据 / capability root / client pool 不共享;无隐式跨 MCP 调用 | 「5 个彼此隔离的 MCP 实例」 | **未迁移**:Workspace / Artifact / Web / Cron / LSP 五个生产实例不存在;`McpClientPool` 为 pool-wide `capability_profile`,`McpClientHandle` 无 credential 字段 | `--test mcp_isolation_contract -- --test-threads=1` exit 0 / 3 passed:两个真实 stdio 子进程(不同 pid、各自 wire 日志)、不同 `Arc`、不同 pool entry、namespace 路由、A 的 wire 无 B 标识、关闭 A 不影响 B | **PARTIAL**(见 §3) | +| 6 Permission / HITL / Hook / SubAgent / Workflow / Goal / PTC 不得被绕过 | 「不经这 5 个 MCP」= 宿主保留 | direct 注入只改工具可见性,工具调用仍经 middleware chain 与 Permission/HITL | `--test mcp_host_policy_contract -- --test-threads=1` exit 0 / 5 passed(真实 rmcp service + duplex wire 上的 `tools/call` 事实);`host::mcp_v4_startup_tests` exit 0 / 11 passed(session/事件/ACP 投影/装配层) | **PARTIAL**(逐能力见 §4) | +| 7 未完成迁移前必须区分「目标归属」与「已落地能力」 | 文档纪律 | 本记录三态列;`docs/reference/mcp-ecosystem.md` §9.2 只写配置层 key 与拒绝规则,运行时语义回指设计文档;设计文档未被回填批次/勾选(mtime 2026-09-25 21:54,早于本次实施写入窗口 23:29–00:54) | `git diff --check` exit 0;`mcp-ecosystem.md#` 无外部锚点引用需要同步(全仓 grep 0 命中) | **PASS** | + +## 3. 契约 5 的 UNVERIFIED 项(硬要求) + +以下三项**没有本次运行时证据**,不得由 D-03 的绿色推断: + +| 子项 | 状态 | 原因(代码事实) | +| --- | --- | --- | +| **凭据隔离** | **UNVERIFIED** | `McpClientHandle` 无 credential 字段,pool 的 credential store 由 `initialize` 内 `FileCredentialStore::new()` 建立,没有可安全读取或比较的 per-instance identity public API。本记录与 D-03 都**未**断言、也无法断言两台 server 的凭据不共享;不打印、不比较任何凭据值,测试不含真实 secret。 | +| **capability root 隔离** | **UNVERIFIED** | `McpClientPool::capability_profile` 是 pool-wide 且非 public,`McpConnectionKey` 非 public;D-03 作为外部集成测试读不到二者,因此**未**断言 capability root 不共享。`local-mcp-server` 的 `RootDir` 也明确不是安全沙箱。 | +| **五个目标 MCP 的实例落地** | **未落地(目标归属 ≠ 已落地)** | 无任何可枚举 Workspace / Artifact / Web / Cron / LSP 五实例的事实接口;D-03 只覆盖两台最小 fixture 的「已落地连接局部隔离」。 | + +D-03 实际覆盖(PARTIAL 的正面部分):pool entry 分离、`Arc` 分离、真实 transport wire 分离(两个独立子进程 pid)、namespace 路由与 wire 裸名、无隐式跨实例调用、关闭一台不影响另一台。**不覆盖**:凭据、capability root、五实例迁移、跨进程/跨机器隔离。 + +## 4. 契约 6 的逐能力分级 + +| 能力 | 本层证据 | 承担者 | 裁决 | +| --- | --- | --- | --- | +| Permission + HITL(approve) | 审批只看 `(name, input)`;`default_requires_approval` 对 `mcp__` 前缀生效;批准后 wire 恰好一次 `tools/call` | D-04 `hitl_approval_gates_mcp_bridge_by_effective_name_and_calls_server_once` | **PASS**(本层) | +| Permission + HITL(reject) | broker 收到 effective name → `EffectiveToolErrorCode::UserRejected`,wire 零调用 | D-04 `hitl_rejection_never_reaches_the_mcp_server` | **PASS**(本层) | +| effective tool name | 策略/审批见 `mcp__{server}__{tool}`,wire 见裸工具名 | D-04 上述两例 + `deferred_*` | **PASS**(本层) | +| cancel | 在飞 `tools/call` 取消 → `Interrupted`,不重放、无第二次 wire 请求 | D-04 `in_flight_cancellation_ends_the_call_without_a_second_wire_request` | **PASS**(本层) | +| ToolSearch deferral 未被绕过 | deferred bridge 不进 `direct_definitions`(与 Reason 同一 `is_direct() && visible_to_model()` 谓词),可被 `SearchExtraTools` 检索、经 `ExecuteExtraTool` 到达 wire | D-04 `deferred_mcp_bridge_is_reachable_only_through_tool_search` + `tool_search_meta_tool_names_match_the_middleware_registration` | **PASS**(本层) | +| session / 事件 / ACP 投影 / 宿主装配 | render 事件属同一 turn(D-04);真实 `run_session_loop` 的 `AgentExecutionFailed → TurnEnded(Error) → done`、failure 归属 `McpMiddleware`、ACP `-32000`+`kind=internal`(B-07) | D-04 + `host::mcp_v4_startup_tests` / `host::prompt::tests` | **PARTIAL→强**(装配层在 B-07) | +| Hook | 仅证明调用**未短路链**:`before_tools_batch` 对 MCP bridge 可见。Hook 自身实现未在本层重测 | D-04 `assert_policy_saw` 断言 | **PARTIAL** | +| **SubAgent / Workflow / Goal / PTC** | **本层未验证**:不把 MCP 工具伪装成这些能力,也未重测其实现;只证明调用仍经过 middleware chain 的既有 hook 位 | 归各能力自身既有测试;本记录声明 UNVERIFIED | **UNVERIFIED** | +| 被提升为 direct 的真实 MCP bridge 走审批链 | **BLOCKED**:`with_direct` / `prepare_system_tools` 是 `pub(crate)`,D-04 层只能证明判定输入(审批不看 `BaseTool`,`is_direct` 结构上无法影响审批)与被提升后的**可见性**(B-07);没有一条用例真正**调用**已提升工具并同时观察审批与 wire | D-04 §BLOCKED 第 1 条;提升后的可见性归 B-07/D-02 | **BLOCKED**(缺口已记录,不由 D-03/D-04 冒充覆盖) | + +## 5. 运行命令与现场结果 + +命令按 plan §6 逐条复跑(W1–W5 全部)。**两次独立执行(后台脚本 + 前台复跑)计数完全一致**,未观察到 flake。 + +| Task | 命令 | exit | 测试数 | 0 tests? | +| --- | --- | ---: | --- | --- | +| W1 A-02a(闸门) | `cargo check --workspace --all-targets` | 0 | 非测试命令 | N/A | +| W1 A-01 | `cargo test -p peri-acp-types --lib -- system_mcp` | 0 | 8 passed / 0 failed | 否 | +| W1 B-05(a) | `cargo test -p peri-agent --lib tool_catalog` | 0 | 13 / 0 failed | 否 | +| W1 B-05(b) | `cargo test -p peri-agent --doc` | 0 | 10 doctests / 0 failed | 否 | +| W2 A-02b | `cargo test -p peri-middlewares --lib -- mcp::config::tests` | 0 | 45 / 0 failed | 否 | +| W2 B-04 | `cargo test -p peri-agent --lib agent::stages` | 0 | 122 / 0 failed | 否 | +| W2 C-INJ-01 | `cargo test -p peri-middlewares --lib -- mcp::tool_bridge` | 0 | 14 / 0 failed | 否 | +| W2 C-INJ-02 | `cargo test -p peri-middlewares --lib -- mcp::system_tools` | 0 | 17 / 0 failed | 否 | +| W3 B-01 | `cargo test -p peri-middlewares --lib -- mcp::client::tests` | 0 | 39 / 0 failed | 否 | +| W3 B-02 | `cargo test -p peri-middlewares --lib -- mcp::initialize` | 0 | 10 / 0 failed | 否 | +| W3 B-06 | `cargo test -p peri-middlewares --lib -- mcp::dynamic::registry` | 0 | 22 / 0 failed | 否 | +| W4 B-03 | `cargo test -p peri-middlewares --lib -- mcp::middleware` | 0 | 40 / 0 failed | 否 | +| W5 C-INJ-03 | `cargo test -p peri-middlewares --lib -- mcp::system_tools`;`-- tool_search` | 0 / 0 | 17 / 65 passed | 否 | +| W5 A-03 | `cargo test -p peri-middlewares --lib -- test_dynamic_mcp_rejects_system_mcp_fields` | 0 | 1 / 0 failed | 否 | +| W5 A-04 | `git diff --check` | 0 | 非测试命令 | N/A | +| W5 B-07 | `cargo test -p peri-acp --lib -- host::executor_flow_tests` | 0 | 29 / 0 failed | 否 | +| W5 D-02 | `cargo test -p peri-middlewares --lib -- mcp::mcp_v4_seam` | 0 | 6 / 0 failed | 否 | +| W5 D-03 | `cargo test -p peri-middlewares --test mcp_isolation_contract -- --test-threads=1` | 0 | 3 / 0 failed | 否 | +| W5 D-04 | `cargo test -p peri-middlewares --test mcp_host_policy_contract -- --test-threads=1` | 0 | 5 / 0 failed | 否 | + +计划命令合计:466 passed / 0 failed,全部 exit 0;无一条命令输出 0 tests(两条非测试命令按定义不产生 `test result` 行)。 + +**命令覆盖不足的补充复跑**(plan §6 的过滤器未命中以下模块,D-05 追加并记录): + +| 缺口 | 补充命令 | exit | 测试数 | +| --- | --- | ---: | --- | +| B-07 新增用例所在模块 `host::mcp_v4_startup_tests` 未被 `host::executor_flow_tests` 命中 | `cargo test -p peri-acp --lib -- host::mcp_v4_startup_tests` | 0 | 11 / 0 failed | +| `host::prompt::tests`(B-07 的 ACP 投影用例) | `cargo test -p peri-acp --lib -- host::prompt::tests` | 0 | 17 / 0 failed | +| `mcp::client::readiness`(B-01 的 readiness_test)不在 `mcp::client::tests` 内 | `cargo test -p peri-middlewares --lib -- mcp::client::readiness` | 0 | 17 / 0 failed | +| A-02b 的 `plugin::loader::tests` 不在 `mcp::config::tests` 内 | `cargo test -p peri-middlewares --lib -- plugin::loader` | 0 | 58 / 0 failed | +| A-02a 的 `mcp::transport`、`mcp::resource_cache` 只有编译闸门 | `cargo test -p peri-middlewares --lib -- mcp::transport`;`-- mcp::resource_cache` | 0 / 0 | 10 / 23 passed | +| B-05/B-06 的 `stage_builder`(`builder_v2_tests`) | `cargo test -p peri-agent --lib -- stage_builder` | 0 | 2 / 0 failed | + +补充命令合计:138 passed / 0 failed,全部 exit 0。全部测试命令合计 **604 passed / 0 failed**。 + +**辅助证据**:`cargo clippy --workspace --all-targets -- -D warnings` exit 0(cargo 复用已缓存 clippy 结果,无新诊断)。此项不属 plan §6 门禁,仅作参考。 + +**测试模块 wiring 复核**(plan §9 规则 2):新增测试文件均已挂载——`mcp/v4_seam_test.rs`→`mcp/mod.rs:62`、`mcp/system_tools_test.rs`→`system_tools.rs:386`、`mcp/client/readiness_test.rs`→`readiness.rs:628`、`peri-acp/src/host/mcp_v4_startup_test.rs`→`host/mod.rs:52`、`stage_builder/tools_test.rs`→`stage_builder/tools.rs:103`;两个 `tests/*.rs` 由 cargo 自动发现。未见未挂载的孤儿测试文件。 + +### 5.1 独立验证阶段的收口复跑(W6 之后) + +以下命令由独立验证方在三个提交冻结后复跑,与前表口径一致(0 tests 视为失败)。 + +| 命令 | exit | 测试数 | 0 tests? | 结论 | +| --- | ---: | --- | --- | --- | +| `cargo test --workspace --lib` | 0 | 1803 passed / 0 failed / 10 ignored | 否 | 全 workspace lib 目标无回归 | +| `cargo clippy --workspace --all-targets -- -D warnings` | 0 | 非测试命令 | N/A | 无 warning | +| `cargo test -p peri-acp-types -p peri-agent -p peri-middlewares --lib`(`cargo fmt --all` 之后) | 0 | 3113 passed / 0 failed / 5 ignored | 否 | 格式化未改变行为 | + +**关闭的缺口**(W3 gate 报告的两项): + +| 缺口 | 处置 | 承担用例 | +| --- | --- | --- | +| `catalog_registration` 接线零覆盖:`DynamicMcpErrorCode::ToolNameConflict → StartupRegistrationRejected` 与其余拒绝 → `InconsistentCapability` 的映射无测试(两端各自有测试,中间接线没有) | **已补**:新增 `peri-agent/src/session/exec/stage_builder/tools_test.rs`(3 例),在实现文件内挂载 | `startup_registration_maps_tool_conflict_to_rejection`、`startup_registration_maps_non_conflict_to_inconsistent_capability`、`startup_registration_forwards_session_and_candidate_tools`(`cargo test -p peri-agent --lib -- stage_builder::tools::tests` exit 0 / 3 passed) | +| W1 gate H-1:`stage_builder.rs` 认领但未改动,B-05 主张 `replace_static_mcp_tools` + `refresh()` 已足够 | **确认为正确,非缺口**:`replace_static_mcp_tools` 只更新 static base 与 published,Reason 边界仍完整走 `refresh → working map swap → before_reason_catalog → before_model → pin`(`tool_catalog.rs:214-300`);首个 LLM 请求入参已由 `system_mcp_ready_exposes_required_tools_on_first_model_request` 端到端断言 | 同上 host 用例 | +| `set_startup_catalog_registration` 上遗留的 `#[allow(dead_code)]` 与「本次提交前尚未接线」注释在接线后已过时 | **已清理**:移除 allow,注释改为「未接线的目录(子 agent 沿用自身 capability)保持 None」;clippy `-D warnings` 仍为 exit 0,反证调用点真实存在 | `peri-agent/src/session/tool_catalog.rs` | + +## 6. IF-M4 回退声明 + +**未实施回退。** 现场实现是 v2 方案(`peri-agent/src/middleware/trait.rs:87`、`peri-agent/src/agent/stages/mod.rs:902-909`),stages/mod.rs 的 diff 仅新增闸门调用与 `react_start_has_run` 标志;首批 `before_agent` 的 `tracing::warn!` 软失败降级**保持原样**(同一文件 `before_agent` 分支未改动)。 + +因此:**不存在「既有 before_agent 全局错误语义变更」这一契约变更**;v1 回退方案要求的三项补救义务(覆盖登记 / AgentsMd·AtMention·Plugin·SkillPreload 回归 / acceptance 声明)本次**不适用**。`StartupState` 的能力收窄由 2 个 `compile_fail` doctest 固定(`capabilities.rs:133`、`:139`,随 `-p peri-agent --doc` 10 doctests 通过)。 + +## 7. 文件所有权与 diff 越界复核(plan §10) + +- **矩阵内**:`git status` 的 40 个 modified 条目中 37 个、以及 8 个实现类新增文件(`mcp_v4_startup_test.rs`、`client/readiness.rs`、`client/readiness_test.rs`、`mcp_v4_seam_test.rs`、`system_tools.rs`、`system_tools_test.rs`、`tests/mcp_isolation_contract.rs`、`tests/mcp_host_policy_contract.rs`)全部落在 §4 所有权矩阵路径内;未发现任务文件被写入矩阵外路径。 +- **矩阵外但被改动(非 W1–W5 产出)**: + - `.github/workflows/ci.yml`(mtime 2026-09-25 09:06,早于实施窗口 23:29–00:54):内容为 CI 并发控制、`CARGO_PROFILE_*_DEBUG`、layer-import 门位置调整与测试分步,与 MCP v4 无关,不属 §4 任何 task 产出。 + - `docs/design/README.md`(mtime 2026-09-25 21:53):在 design 索引表登记 `mcp-adaptation-v4-part-1.md`,属设计文档立项步骤,早于 plan(23:29)与实施窗口。 + - `peri-cool` 子模块指针为 dirty 标记(非本次任务文件)。 + - 上述均无 MCP 运行时语义,记录在此以说明「§4 之外存在 diff」这一事实,不作为越界修复对象。 +- **矩阵内但未被改动**:`peri-agent/src/session/exec/stage_builder.rs`、`stage_builder/builder_v2_test.rs`(B-05 认领但无需修改,§5.1 已核实依据);`initialize.rs` 的 4 处 `unwrap_or_default()` 已消除(grep 0 命中)。 +- **独立验证阶段新增**:`peri-agent/src/session/exec/stage_builder/tools_test.rs`(关闭 §5.1 的接线覆盖缺口,落在 `stage_builder/` 路径内);`peri-agent/src/session/tool_catalog.rs`、`peri-acp-types/src/plugin.rs`、`peri-middlewares/src/mcp/mcp_v4_seam_test.rs` 三处经 `cargo fmt --all` 重排(仅格式,无语义变更,§5.1 已复跑)。 +- `docs/design/mcp-adaptation-v4-part-1.md` 未被修改(未回填批次/勾选/耗时)。 +- 提交切分:`ca0265e4`(规划与验收记录)、`10162ec8`(实现)、`041f9cac`(文档同步);`.github/workflows/ci.yml` 与 `peri-cool` 未纳入提交(与本次任务无关)。 + +## 8. 本次未验证 / 非目标 + +- **未验证**:契约 5 的凭据隔离、capability root 隔离、五个目标 MCP 实例落地(§3);契约 6 的 SubAgent / Workflow / Goal / PTC 实现、被提升 direct 工具的端到端审批链(§4);`Filesystem`/`Terminal`/`Web`/`Cron`/`Lsp` middleware 真实迁移。 +- **未运行**:`cargo test --workspace`(含 `peri-tui`)、`e2e/` TUI 场景、`side-projects/local-mcp-server` 独立 workspace 测试、Windows 平台(`host::mcp_v4_startup_tests` 用例带 `#[cfg(not(windows))]`,仅 macOS 本机验证)。plan §6 未把这些列为本批次门禁。 +- **非目标**(plan §1.2):不新增宿主 CLI 入口;不实现 `system_mcp` 的 session 中途声明;不改 `ToolSearchMiddleware` 既有 deferral 契约。 + +## 9. 整体裁决 + +- 契约 1、2、3、4、7:**PASS**(契约 2/3/4 为 host seam 层强证据:真实子进程 rmcp stdio + counting model + 首个 LLM 请求入参断言)。 +- 契约 5:**PARTIAL** —— 只覆盖已落地连接的局部隔离;凭据、capability root 与五实例迁移为 **UNVERIFIED / 未落地**。 +- 契约 6:**PARTIAL** —— Permission/HITL/effective name/cancel/ToolSearch/装配层已断言;Hook 为 PARTIAL,SubAgent/Workflow/Goal/PTC 与被提升 direct 工具链路为 UNVERIFIED/BLOCKED。 +- 整体:**v4-part-1 的契约 1–4、7 已落地并有本次运行时证据;契约 5、6 未完成,绿色单测不构成五 MCP 迁移完成的证据。** diff --git a/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-plan.md b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-plan.md new file mode 100644 index 000000000..83aaab389 --- /dev/null +++ b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-plan.md @@ -0,0 +1,356 @@ +# MCP adaptation v4-part-1 — 主实施计划(master plan) + +> 日期:2026-09-25。状态:**v2 已修订**(v1 经独立 check 后修订)。代码未实施。 +> +> 目标事实源:`docs/design/mcp-adaptation-v4-part-1.md`(下称「设计文档」)。本文件是**实施批次、任务编排与接口冻结**的唯一事实源;设计文档是**契约语义**的唯一事实源。二者冲突时以设计文档为准,并回到本文件修订任务。 +> +> 本文件不保存某一次执行的勾选状态、耗时或提交号;现场证据见 `2026-09-25-mcp-adaptation-v4-part-1-acceptance.md`(task D-05 产出)。 + +## 0. v2 修订说明 + +v1 经三个独立 subagent 校验(事实核对 / 对抗式评审 / 冲突与依赖分析)。以下为 v2 的实质性变更及其触发原因: + +| 变更 | 原因 | +| --- | --- | +| **推翻 v1 的 IF-M4**:不再把首批 `before_agent` 的 Err 从「warn 后继续」改为全局传播;改为**新增专用启动闸门 hook** | 全仓库有 6 个既有中间件的 `before_agent` 可返回 Err,其中 `AgentsMdMiddleware` 文件竞态、`AtMentionMiddleware` join 失败、`PluginMiddleware` 坏插件等属于**允许的软失败**。全局传播会把软失败升级为 turn fatal,属计划外回归 | +| **拆出 A-02a / A-02b** | A-01 给 `McpServerConfig` 加字段后,`peri-middlewares` 中大量 struct literal 与 `expand_server_config_with_context` 会立刻编译失败。A-01 与「机械收口」必须紧邻,否则同一 crate 内所有其它 task 都无法运行测试 | +| **插件严格化收窄为 MCP 专用路径** | 当前 `load_enabled_plugins_aggregated` 的宽容是**有意的产品行为**(坏插件不阻止宿主启动)。整体改 `Result` 会造成产品回归 | +| **明确 `tools/list` 的 discovery evidence** | `initialize.rs` 有 4 处 `unwrap_or_default()`(221/225/494/498 行)。`Connected + tools=[]` 会被误判为 ready,直接违反验收契约 2 与 4 | +| **统一接口可见性为 `pub(crate)`** | `peri-agent/src/session/exec/stage_builder/tools.rs:22` 的 `build_session_tool_view` 是 `pub(super)`;sub-plan 声称在 `peri-middlewares/tests/` 外部集成测试里断言真实工具视图**不可行**。冻结 `pub(crate)` 并把跨层断言上移到 `peri-acp` host seam | +| **补 `stage_builder.rs` 的所有权** | C 的接线需求指向该文件,但 v1 矩阵无人认领 —— 会导致「所有 task 绿色但 required 工具进不了首个 Reason」 | +| **补依赖边 B-05→B-04、B-04→B-03、A-02→B-01、D-05→D-06** | v1 把 B-04 排在 B-05 之前,方向反了 | +| **新增全局施工规则**:测试模块 wiring、禁止「0 tests 绿色」、禁止修改非自己拥有的文件 | 新增 `*_test.rs` 若不挂 `#[path] mod tests;`,`cargo test` 会以 0 tests 退出 0,产生假绿 | +| **验收矩阵降级契约 1/2/3/4/6 的证据强度声明** | v1 多处声称「强」,但外部集成测试无法触达真实 seam;必须写明实际断言层次 | + +**对 sub-plan 的覆盖登记见 §5。** sub-plan 保留其领域内的细节权威;与 §5 冲突处以 §5 为准。 + +## 1. 范围 + +### 1.1 本次实施(契约 1–4、7) + +| 契约 | 内容 | 主责 | +| --- | --- | --- | +| 1 | 配置解析拒绝「无 `system_mcp = true` 却声明 `system_mcp_tools`」 | A | +| 2 | System MCP 未完成 transport / initialize / 能力协商 / 必需工具检查前不得进入可启动 react loop;失败或 timeout 返回错误,不发布 ready | B | +| 3 | `system_mcp_tools` 每项经所属 MCP namespace 解析,schema 可构造 bridge,直接出现在 RCRA 工具列表;普通 deferred 工具仍走 `ToolSearchMiddleware` | C(解析与桥接)+ B(接线与目录发布) | +| 4 | 必需工具为空数组时只验证 ready,不注入额外工具 | C + B | +| 5 | 五个目标 MCP 的 transport / 状态 / 凭据 / capability root / client pool 不共享 | D(**只做已落地连接的隔离契约测试,PARTIAL**,见 §8) | +| 6 | 宿主保留能力(Permission / HITL / Hook / SubAgent / Workflow / Goal / PTC)不得被绕过 | D(**按能力分级**,见 §8) | +| 7 | 未完成迁移前必须区分「目标归属」与「已落地能力」 | D + 全部 sub-plan | + +### 1.2 非目标(本次不做,且不得在文档中写成已实现) + +- 不把 `Filesystem` / `Terminal` / `Web` / `Cron` / `Lsp` middleware 真实迁移为独立 MCP server。 +- 不新建 Artifact / Web / Cron / LSP MCP 实例;不改 MCP 实例划分。 +- 不实现 `system_mcp` 的 session 中途声明语义(动态 MCP 路径继续拒绝该 key,只加回归保护)。 +- 不新增宿主 CLI 暴露入口。 +- 不改 `ToolSearchMiddleware` 的既有 deferral 契约(只验证不回归)。 +- **不改写既有 `before_agent` 的全局错误语义**(见 §3 IF-M4)。 + +## 2. 事实基线(已核实) + +| 事实 | 位置 | +| --- | --- | +| `McpServerConfig` 是唯一字段定义;`mcp/config.rs` 仅 re-export | `peri-acp-types/src/plugin.rs:41-77` | +| `McpServerConfig` **没有** `rename_all = "camelCase"` | `peri-acp-types/src/plugin.rs:41-77`(对照 `:84-98`、`:116-130`) | +| 首批 `before_agent` 的 Err 被 `tracing::warn!` 后继续 | `peri-agent/src/agent/stages/mod.rs:879-886` | +| 后续批次 `before_input` 的 Err 会传播,且有 `Interrupted` 专门分支 | `peri-agent/src/agent/stages/mod.rs:887-894` | +| `run_before_agent` 已返回 `AgentResult`,可传播;链内任一 Err 立即返回 | `peri-agent/src/agent/stages/middleware_runner.rs:62-82`;`peri-agent/src/middleware/chain.rs:68-77` | +| `BaseTool::is_direct()` 默认 `false`;`McpToolBridge` 未 override → MCP bridge 全部 deferred | `peri-acp-types/src/tools.rs:608-612`;`peri-middlewares/src/mcp/tool_bridge.rs:179+` | +| `ClientStatus` 无 `Connecting` / `Reconnecting` 变体 | `peri-middlewares/src/mcp/client/types.rs:11-20` | +| 静态 MCP 无启动超时配置;动态 MCP 默认 30 000 ms | `peri-acp-types/src/dynamic_mcp.rs:198` | +| `tools/list` 结果有 **4 处** `unwrap_or_default()`(错误表现为 `Connected + tools=[]`) | `peri-middlewares/src/mcp/initialize.rs:221`、`225`、`494`、`498` | +| `build_session_tool_view` 是 `pub(super)`,外部集成测试不可调用 | `peri-agent/src/session/exec/stage_builder/tools.rs:22` | +| `peri-acp` host 的测试模块名是 `executor_flow_tests`(文件 `executor_flow_test.rs`) | `peri-acp/src/host/mod.rs:48-49` | +| 配置错误存在被吞成空配置的路径 | `peri-middlewares/src/mcp/config.rs:84`、`238-245`、`296-303`;`plugin/loader.rs:402-433`、`563-647` | +| `McpClientHandle` 无 credential 字段;`capability_profile` / `McpConnectionKey` 非 public | `peri-middlewares/src/mcp/client.rs:96-99`;`client/types.rs:83-132` | + +## 3. 冻结接口(Interface Freeze v2,唯一版本) + +### IF-M1 配置字段(owner:A-01 / A-02a) + +```rust +// peri-acp-types/src/plugin.rs::McpServerConfig +#[serde(default, rename = "system_mcp", alias = "systemMcp", skip_serializing_if = "is_false")] +pub system_mcp: Option, +#[serde(default, rename = "system_mcp_tools", alias = "systemMcpTools", skip_serializing_if = "Option::is_none")] +pub system_mcp_tools: Option>, +#[serde(default, rename = "system_mcp_timeout", alias = "systemMcpTimeout", skip_serializing_if = "Option::is_none")] +pub system_mcp_timeout: Option, // 毫秒;缺省 30_000;合法区间 1..=600_000 +``` + +**唯一错误枚举(三个变体,A 与主 plan 的统一版本)**: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum McpServerConfigValidationError { + #[error("system_mcp_tools requires system_mcp = true")] + SystemMcpToolsRequiresSystemMcp, + #[error("system_mcp_timeout requires system_mcp = true")] + SystemMcpTimeoutRequiresSystemMcp, + #[error("system_mcp_timeout must be within 1..=600000 milliseconds")] + SystemMcpTimeoutOutOfRange, +} + +impl McpServerConfig { + pub fn validate(&self) -> Result<(), McpServerConfigValidationError>; +} +``` + +- canonical 输出严格 snake_case 三 key;输入额外接受 camelCase 别名;两种拼法同时出现报 duplicate field。 +- `system_mcp` 缺省 `None`;消费判定固定为 `== Some(true)`。 +- `SystemMcpToolsRequiresSystemMcp` 触发条件为 `system_mcp_tools.is_some() && system_mcp != Some(true)`,**含显式 `[]`**。 +- 不 trim / 不排序 / 不去重 / 不展开 `${...}` / 不加前缀。`None` 与 `Some([])` 必须保持可区分并可无损写回。 + +### IF-M2 一等工具注入(owner:C-INJ-01 / C-INJ-02 / C-INJ-03) + +```rust +// 全部 pub(crate):见 §0 可见性决策 +pub(crate) fn build_typed_tool_bridges(pool: &McpClientPool) -> Vec; +pub(crate) fn prepare_system_tools( + bridges: Vec, + required: &BTreeMap>, +) -> Result, SystemToolError>; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SystemToolError { + MissingTool { server: String, tool: String }, + AmbiguousTool { server: String, tool: String, matches: Vec }, + InvalidSchema { server: String, tool: String, reason: String }, + NotModelVisible { server: String, tool: String }, + EffectiveNameCollision { effective_name: String }, +} + +impl McpToolBridge { + pub(crate) fn with_direct(self) -> Self; + pub(crate) fn original_tool_name(&self) -> &str; +} +``` + +- **保留既有 public API 不变**:`build_tool_bridges(pool) -> Vec>` 签名与 deferred 默认行为不得改变。 +- **防重复注册**:一次构造 typed bridges,在原对象上提升 direct;`collect_tools` **整体替换**初始 bridge 集合,禁止再 append 一份。 +- 工具名匹配:对模型暴露名 `mcp__{sanitize(server)}__{sanitize(tool)}`;配置数组在**所属 server 的原始工具名**上精确匹配,不折叠大小写、不剥离前缀。 +- **all-or-nothing**:先验证全部 required 项,再统一 `with_direct`;不得返回部分成功。 +- schema 只做结构解析(对象/属性存在性),**不做完整 JSON Schema draft 编译**。 + +### IF-M3 启动准入(owner:B-01 / B-03) + +```rust +pub(crate) async fn await_system_connections( + self: &Arc, + cancel: &peri_agent::agent::AgentCancellationToken, + started_at: tokio::time::Instant, +) -> Result, SystemReadinessError>; + +pub(crate) async fn await_system_ready(&self) -> Result; +``` + +- 时间类型统一 `tokio::time::Instant`(不用 `std::time::Instant`)。 +- 全部 `pub(crate)`:内部 seam,不对外暴露(避免 private-in-public)。 +- `SystemReadinessError` 变体全集由 sub-plan B §4.4 冻结;主 plan 只追加两条硬约束:① `Cancelled → AgentError::Interrupted`;② timeout **不是** cancel,必须映射 fatal。 +- **不可伪造的 discovery evidence(v2 新增,硬要求)**: + +```rust +pub(crate) struct DiscoveryEvidence { + pub generation: u64, + pub initialize_ok: bool, + pub tools_list_ok: bool, +} +``` + +只有**真实成功的 live `tools/list`** 路径才能提交 `tools_list_ok = true`。空数组是成功结果;`Err` 不得创建 ready evidence。`initialize.rs:221/225/494/498` 的 `unwrap_or_default()` 必须消除。 + +### IF-M4 启动闸门 hook(owner:B-05 / B-04)— **v2 推翻 v1** + +**v1 方案(已废弃)**:把 `stages/mod.rs:881-886` 的 warn 降级改为全局传播 `before_agent` 的 Err。 + +**v2 方案**:新增**专用启动闸门 hook**,只让 system 启动依赖经由它阻止 loop 启动。 + +```rust +#[async_trait] +trait Middleware { + /// 首次 Receive 输入准备完成后、Compact 前的启动闸门。 + /// 默认 no-op;只有声明启动依赖的 middleware 实现。 + async fn before_react_start( + &self, + _state: &mut dyn hook_state::StartupState, + ) -> AgentResult<()> { + Ok(()) + } +} +``` + +要求: + +- 新增 `StartupState` capability 接口,遵守 ARC-MW-002(hook 只接收其阶段真实支持的能力组合,不得继承完整 `MiddlewareState`)。 +- `StartupState` 必须提供 candidate 的暂存与取出(见 IF-M5),且**不暴露可写 transcript/queue**。 +- 调用点在首批 `before_agent` 之后、Compact 之前;`Interrupted` → `LoopResult::Interrupted`,其它 Err → `LoopResult::Error`。 +- **既有 `before_agent` 的 warn 降级保持不变**(不改 `stages/mod.rs:881-886` 对 `before_agent` 的处理)。 +- 不得按 middleware 名称字符串特判 MCP,不得新增 fail-open 开关。 +- 若实施中发现该 hook 不可行而必须回退到 v1 方案,**必须**:① 在 §5 登记覆盖;② 对 `AgentsMd` / `AtMention` / `Plugin` / `SkillPreload` 各补回归测试;③ 在 acceptance 记录中声明为全局契约变更。不得静默回退。 + +### IF-M5 目录发布时序(owner:B-05) + +session tool catalog 早于首批 `before_agent` 构建(`stage_builder.rs` 构建点),因此仅改 `collect_tools` 不足以让 direct 工具进入**首个 Reason**。 + +- candidate 必须经**本次 hook 的 state** 传递,**不得**存在 `McpMiddleware` 内部字段(避免失败未清除、cancel 后复用到下一轮、多 session 串用)。 + +```rust +pub(crate) struct StartupRequiredTool { + pub server_name: String, + pub original_tool_name: String, + pub effective_tool_name: String, +} + +pub(crate) struct StartupToolUpdate { + pub tools: Vec>, + pub required: Vec, +} +``` + +- 失败或取消时直接丢弃 state 内 candidate,不需要 middleware 内部的 commit/discard 状态协议。 +- 提交后**仍必须**完整走 ARC-TOOLS-001 的 Reason 发布顺序(`catalog refresh → working map swap → before_reason_catalog → before_model → pin`),startup 提交只能更新 static base,**不得**替代 Reason boundary、不得混入 dynamic overlay。 + +## 4. 文件所有权矩阵(v2 补漏) + +**同一文件在同一时刻只能有一个 owner。** 违反所有权即为计划外改动,必须回退。 + +| 文件 / 目录 | owner | 备注 | +| --- | --- | --- | +| `peri-acp-types/src/plugin.rs` | A-01 | 含 timeout 字段与三变体错误枚举 | +| `peri-middlewares/src/mcp/config.rs`、`config_test.rs` | A-02a(字段收口)→ A-02b(错误闭环) | 同一 owner 串行 | +| `peri-middlewares/src/mcp/transport.rs`、`transport_test.rs` | A-02a / A-02b | | +| `peri-middlewares/src/mcp/client_test.rs`、`resource_cache_test.rs` | A-02a | **v1 漏列,v2 补** | +| `peri-middlewares/src/mcp/initialize.rs`、`initialize_test.rs` | A-02b 先 → **B-02 后** | 见 §5 R1;`unwrap_or_default()` 的消除归 B-02 | +| `peri-middlewares/src/plugin/loader.rs`、`loader_test.rs` | A-02b | 只新增 MCP 专用严格路径 | +| `peri-middlewares/src/mcp/dynamic/tool_test.rs` | A-03 | 仅测试 | +| `docs/reference/mcp-ecosystem.md` | A-04 | | +| `peri-middlewares/src/mcp/tool_bridge.rs` | C-INJ-01 | | +| `peri-middlewares/src/mcp/system_tools.rs`、`system_tools_test.rs` | C-INJ-02 | 新增;测试模块挂在 `system_tools.rs` 内 | +| `peri-middlewares/src/mcp/mod.rs` | C-INJ-02(W2:`system_tools` 生产模块声明)→ **D-02(W5:自身测试模块挂载)** | 仓库约定为在**实现文件**内挂 `#[cfg(test)] #[path = "..."] mod tests;`;D-02 的测试模块挂载落在 `mcp/mod.rs`,故该文件在 W5 归 D-02,B-03 不得代加 | +| `peri-middlewares/src/mcp/middleware.rs`、`middleware_test.rs` | **B-03 唯一** | C 不拥有 | +| `peri-middlewares/src/mcp/client.rs`、`client/readiness.rs`、`client/readiness_test.rs`、`client/lifecycle.rs`、`client/status.rs` | B-01 | 模块声明放 `client.rs`,不碰 `mcp/mod.rs` | +| `peri-middlewares/src/mcp/reconnect.rs`、`client_oauth.rs` | B-02 | | +| `peri-middlewares/src/mcp/dynamic/registry.rs`、`registry_test.rs` | B-06 | | +| `peri-agent/src/middleware/trait.rs`、`capabilities.rs`、`chain.rs`、`agent/stages/middleware_runner.rs`、`middleware_runner_test.rs`、`session/tool_catalog.rs`、`tool_catalog_test.rs` | B-05 | 含新 hook 与 `StartupState` | +| `peri-agent/src/session/exec/stage_builder.rs`、`stage_builder/builder_v2_test.rs` | **B-05** | **v1 无人认领,v2 补** | +| `peri-agent/src/session/exec/stage_builder/tools.rs` | B-06 | | +| `peri-agent/src/agent/stages/mod.rs`、`stages_test.rs` | B-04 | 调用新 hook(非改 `before_agent`) | +| `peri-acp/src/host/mod.rs`、`host/executor_flow_test.rs`、`host/prompt_test.rs`、`host/mcp_v4_startup_test.rs`(新增) | **B-07 唯一** | `peri-acp` 侧测试与模块声明单一 owner,避免并发编辑 `mod.rs` | +| `peri-middlewares/tests/mcp_isolation_contract.rs` | D-03 | 新增 | +| `peri-middlewares/tests/mcp_host_policy_contract.rs` | D-04 | 新增 | +| `peri-middlewares/src/mcp/mcp_v4_seam_test.rs`(新增) | D-02 | 新增;crate 内可触达 `pub(crate)` seam;挂载见 `mcp/mod.rs` 行 | +| `spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md` | D-05 | 新增 | +| `docs/code-index/**`、`docs/standards/**`、`CLAUDE.md` 路由表 | **D-06 唯一** | 见 §5 R2 | + +## 5. 对 sub-plan 的覆盖登记 + +| 编号 | 覆盖内容 | 以何为准 | +| --- | --- | --- | +| R1 | `initialize.rs` / `initialize_test.rs` 由 A-02b 先完成并移交 B-02 | 本文件 §4(串行,不并行编辑) | +| R2 | `initialize.rs:221/225/494/498` 的 `unwrap_or_default()` 修复归 **B-02**,不归 A | 本文件 IF-M3;覆盖 sub-plan A 的“只接线错误”表述 | +| R3 | `docs/code-index/**` 唯一 owner 是 D-06;**A-04 不再拥有任何 `docs/code-index/` 文件**(sub-plan A 任务表 A-04 行作废) | 本文件 §4;A-04 只写 `docs/reference/mcp-ecosystem.md` 并向 D-06 提交索引条目 | +| R4 | `system_mcp_timeout` 归 A(字段 + serde + 校验 + 透传 + hash + 默认值 + 区间);覆盖 sub-plan A 的 IF-A5「B 决定 timeout,A 不新增字段」 | 本文件 IF-M1 | +| R5 | `McpServerConfigValidationError` 为**三变体**;覆盖 sub-plan A IF-A2 的单变体 | 本文件 IF-M1 | +| R6 | 新 hook `before_react_start` + `StartupState`;**覆盖 sub-plan B 的 B-04「改 `stages/mod.rs:881-886` 为传播」** | 本文件 IF-M4 | +| R7 | 插件严格化收窄为 MCP 专用路径(保留宽容 API);覆盖 sub-plan A IF-A4 的整体严格化表述 | 本文件 §0 + §4 | +| R8 | 接口可见性统一 `pub(crate)`;覆盖 sub-plan C 的 `pub` 与主 plan v1 的 `pub` 混用 | 本文件 IF-M2/IF-M3 | +| R9 | 跨层「首个 LLM 请求 tools」断言上移到 `peri-acp` host seam(B-07 拥有),D-02 只断言 crate 内可观察层;覆盖 sub-plan C 的“通过 `run_reason` 验证”与 sub-plan D 的 D-02 外部集成测试方案 | 本文件 §4 + §8 | +| R10 | `stage_builder.rs` 归 B-05 所有;覆盖 v1 的未分配 | 本文件 §4 | +| R11 | `SystemReadinessError` / `SystemToolError` 变体全集以 B/C sub-plan 的冻结章节为准,本文件只追加 IF-M3 的两条硬约束与 `DiscoveryEvidence` | 本文件 IF-M3 + sub-plan B §4.4 + sub-plan C §3 | +| R12 | B-04 依赖 B-05(v1 方向反了) | 本文件 §7 | +| R13 | D-06 依赖 D-05(v1 同 Wave) | 本文件 §7 | +| R14 | 新增 A-02a / A-02b 拆分;覆盖 sub-plan A 的单一 A-02 | 本文件 §6 | +| R15 | sub-plan C 的 C-INJ-03 纳入任务表 | 本文件 §6 | +| R16 | B-03 排在 Wave 4(v1 §5 R5 误写「Wave 3 末尾」) | 本文件 §7 | + +## 6. 任务表 + +任务 ID 沿用 sub-plan 原编号(新增项显式标注)。`→` 表示必须完成后才能开始。 + +| 批次 | Task | 标题 | owner 产出文件 | 依赖 | 验证命令 | +| --- | --- | --- | --- | --- | --- | +| W1 | **A-01** | 契约 DTO + 三变体校验 + timeout 字段 | `peri-acp-types/src/plugin.rs` | — | `cargo test -p peri-acp-types --lib -- system_mcp` | +| W1 | **A-02a** | 字段机械收口(恢复 workspace 编译) | `mcp/config.rs`、`config_test.rs`、`mcp/transport.rs`、`transport_test.rs`、`mcp/client_test.rs`、`resource_cache_test.rs`、`mcp/initialize.rs`、`initialize_test.rs`、`plugin/loader_test.rs` | A-01 → | `cargo check --workspace --all-targets` | +| W1 | **B-05** | 启动闸门 hook + `StartupState` + catalog 原子提交 | `peri-agent/src/middleware/trait.rs`、`capabilities.rs`、`chain.rs`、`agent/stages/middleware_runner.rs`、`middleware_runner_test.rs`、`session/tool_catalog.rs`、`tool_catalog_test.rs`、`session/exec/stage_builder.rs`、`builder_v2_test.rs` | — | `cargo test -p peri-agent --lib tool_catalog`;`cargo test -p peri-agent --doc` | +| W2 | **A-02b** | 配置错误闭环 + MCP 专用严格插件路径 | `mcp/config.rs`、`config_test.rs`、`mcp/initialize.rs`、`initialize_test.rs`、`mcp/transport.rs`、`transport_test.rs`、`plugin/loader.rs`、`loader_test.rs` | A-02a → | `cargo test -p peri-middlewares --lib -- mcp::config::tests` | +| W2 | **B-04** | 新 hook 的 Err 传播 + Interrupted 分类 | `peri-agent/src/agent/stages/mod.rs`、`stages_test.rs` | B-05 → | `cargo test -p peri-agent --lib agent::stages` | +| W2 | **C-INJ-01** | typed bridge 的 direct 提升 | `mcp/tool_bridge.rs` | A-02a → | `cargo test -p peri-middlewares --lib -- mcp::tool_bridge` | +| W2 | **C-INJ-02** | `system_tools` 解析 / 验证 / direct 提升(纯 crate 内测试) | `mcp/system_tools.rs`、`system_tools_test.rs`、`mcp/mod.rs`(一行) | A-02a、C-INJ-01 → | `cargo test -p peri-middlewares --lib -- mcp::system_tools` | +| W3 | **B-01** | 连接证据与等待(`DiscoveryEvidence`、watch、typed error) | `mcp/client.rs`、`client/readiness.rs`、`client/readiness_test.rs`、`client/lifecycle.rs`、`client/status.rs` | A-02b → | `cargo test -p peri-middlewares --lib -- mcp::client::tests` | +| W3 | **B-02** | 严格 discovery + 消除 4 处 `unwrap_or_default()` | `mcp/initialize.rs`、`initialize_test.rs`、`reconnect.rs`、`client_oauth.rs` | A-02b、R1 移交 → | `cargo test -p peri-middlewares --lib -- mcp::initialize` | +| W3 | **B-06** | 动态冲突目录配套 | `peri-agent/src/session/exec/stage_builder/tools.rs`、`mcp/dynamic/registry.rs`、`registry_test.rs` | B-05 → | `cargo test -p peri-middlewares --lib -- mcp::dynamic::registry` | +| W4 | **B-03** | `McpMiddleware` 闸门 + C 接线 | `mcp/middleware.rs`、`middleware_test.rs` | B-01、B-02、B-05、C-INJ-02 → | `cargo test -p peri-middlewares --lib -- mcp::middleware` | +| W5 | **C-INJ-03** | 验收接线回归(只读复核 + 复跑) | 无写入 | B-03 → | `cargo test -p peri-middlewares --lib -- mcp::system_tools`;`cargo test -p peri-middlewares --lib -- tool_search` | +| W5 | **A-03** | 动态配置拒绝 System key 边界回归 | `mcp/dynamic/tool_test.rs` | A-02b → | `cargo test -p peri-middlewares --lib -- test_dynamic_mcp_rejects_system_mcp_fields` | +| W5 | **A-04** | 配置参考文档(**不含 code-index**) | `docs/reference/mcp-ecosystem.md` | A-02b → | `git diff --check` + 人工核对 | +| W5 | **B-07** | host seam 验收(含首个 LLM 请求 tools) | `peri-acp/src/host/mod.rs`、`executor_flow_test.rs`、`prompt_test.rs`、`mcp_v4_startup_test.rs` | B-03、B-04 → | `cargo test -p peri-acp --lib -- host::executor_flow_tests` | +| W5 | **D-02** | crate 内 seam 测试 | `peri-middlewares/src/mcp/mcp_v4_seam_test.rs`(新增)、`mcp/mod.rs`(测试模块挂载) | B-03 → | `cargo test -p peri-middlewares --lib -- mcp::mcp_v4_seam` | +| W5 | **D-03** | MCP 实例隔离契约测试 | `peri-middlewares/tests/mcp_isolation_contract.rs` | B-01 → | `cargo test -p peri-middlewares --test mcp_isolation_contract -- --test-threads=1` | +| W5 | **D-04** | 宿主策略 / 生命周期契约测试 | `peri-middlewares/tests/mcp_host_policy_contract.rs` | B-03、C-INJ-02 → | `cargo test -p peri-middlewares --test mcp_host_policy_contract -- --test-threads=1` | +| W6 | **D-05** | 验收记录(契约矩阵 + 证据强度 + PARTIAL 标记) | `spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md` | W1–W5 全部 → | 复跑 W1–W5 全部命令并记录终态 | +| W7 | **D-06** | code-index / 标准口径同步 | `docs/code-index/**`、`docs/standards/**`、`CLAUDE.md` | D-05 → | `git diff --check` + 链接检查 | + +**v1 任务表中已作废的行**:`D-01`(`peri-middlewares/tests/mcp_system_ready_e2e.rs`,外部集成测试触达不到 readiness 内部 seam);其跨层职责由 **B-07**(host seam)与 **D-02**(crate 内 seam)承接。sub-plan D 的 D-01 行不再执行。 + +## 7. 执行批次(v2) + +| Wave | 并发 task | 同 crate 冲突 | 串行原因 | +| --- | --- | --- | --- | +| **W0(闸门)** | — | — | 起始 `cargo check --workspace --all-targets` 必须绿,作为基线 | +| **W1** | A-01、A-02a(同一 agent 串行)、B-05 | `peri-acp-types`(A) + `peri-middlewares`(A) + `peri-agent`(B-05) | A-01 与 A-02a 必须由**同一个 agent 连续执行**:A-01 落字段后 crate 立刻编译失败,只有 A-02a 能恢复 | +| **W2** | A-02b、B-04、C-INJ-01、C-INJ-02 | `peri-middlewares`:A-02b、C-INJ-01、C-INJ-02(**3 个并发**) | 均已过 W1 闸门,文件互斥;中间态编译错误只允许在自己的文件内修 | +| **W3** | B-01、B-02、B-06 | `peri-middlewares`:B-01、B-02(2 个并发) | B-01/B-02 共享 readiness 证据语义,需在同一 Wave 内协同,由 B-01 先冻结类型 | +| **W4** | B-03(单) | `peri-middlewares`(单) | B-03 是本计划的关键路径汇聚点,独占 `middleware.rs`,必须独占执行 | +| **W5** | C-INJ-03、A-03、A-04、B-07、D-02、D-03、D-04(7 个) | `peri-middlewares`:C-INJ-03(只读)、A-03、D-02、D-03、D-04;`peri-acp`:B-07(单) | 文件互斥(见 §4);`peri-acp/src/host/mod.rs` 由 B-07 独占 | +| **W6** | D-05(单) | — | 验收记录汇总 | +| **W7** | D-06(单) | — | 依赖 D-05 的终态 | + +## 8. 验收矩阵(诚实分级) + +| 契约 | 主责任务 | 断言层次 | 证据强度 | +| --- | --- | --- | --- | +| 1 | A-01、A-02b、A-03 | serde 类型层 + 四条加载入口(direct / global / project / plugin-MCP) | **PARTIAL→强**:仅在 MCP 专用严格路径落地后成立;宽容插件路径下的非法 MCP 配置必须仍被拒绝,需逐入口测试 | +| 2 | B-01、B-02、B-03、B-04、B-05、B-07 | 真实 transport seam + host prompt 路径(断言 fatal JSON-RPC error、模型调用计数为 0) | **强**(依赖 B-07 的 host seam 断言;仅有 crate 内单测不足以称强) | +| 3 | C-INJ-01、C-INJ-02、B-03、B-05、B-07 | bridge 层(C)+ catalog 层(B-05)+ 首个 LLM 请求 tools(B-07) | **强**:必须以 counting model 断言真实首个请求的工具入参 | +| 4 | C-INJ-02、B-03、B-07 | 空数组 → ready 且 direct 增量为 0;**且**须区分「tools/list 成功返回空」与「tools/list 失败」 | **强**:两条分支都要断言 | +| 5 | D-03 | pool entry / owner / transport wire / namespace / 无隐式跨 MCP 调用 | **PARTIAL**:凭据与 capability root 无 per-instance public observable;D-03 只覆盖「已落地连接的局部隔离」,**不覆盖契约 5 全文**。acceptance 必须标注 UNVERIFIED 项 | +| 6 | D-04 + B-07 | Permission / HITL / effective tool name / cancel 可在 middlewares 层断言;session / event / host assembly 需 host seam | **PARTIAL→中强**:按能力逐项标注 PASS / PARTIAL / BLOCKED,**不得**用一条测试覆盖七类能力 | +| 7 | D-05、D-06 | acceptance 记录使用三态(目标归属 / 当前实现 / 本次运行时证据) | **强**:契约 5、6 的 PARTIAL 必须显式写出 | + +## 9. 全局施工规则(所有 agent 必须遵守) + +1. **文件所有权**:只修改 §6 任务表中列为你产出的文件。编译错误出现在**非你拥有**的文件时,**忽略并继续**,不得顺手修复;完成自己的部分后如实报告。 +2. **测试模块 wiring**:新增 `*_test.rs` 必须在对应生产模块中挂载 `#[cfg(test)] #[path = "_test.rs"] mod tests;`。未挂载的测试文件不会被编译。 +3. **禁止假绿**:验证命令若输出 `0 tests`,视为**失败**。每个 task 完成报告必须包含实际执行的测试数与 exit status。 +4. **验证命令用精确过滤器**:不用过宽前缀(例:`host::prompt` 会匹配 `prompt_dispatch`)。模块名以真实模块为准(例:`host::executor_flow_tests`)。 +5. **不并发跑 `cargo`**:同一 Wave 内多个 agent 会争抢 target 锁;cargo 会阻塞等待,不要因为等待而改用其它命令,也不要 kill 别人的构建。 +6. **不新增 public API**:除非 §3 明确冻结为 `pub`;新增 seam 一律 `pub(crate)`。 +7. **错误信息不含 secret**:错误只保留文件定位、server 标识与固定规则文本;不打印 env / headers / URL 认证信息 / OAuth 值。测试不得使用真实 secret。 +8. **不写设计文档的进度**:设计文档不回填批次、提交号、勾选状态。 + +## 10. 风险登记 + +| 风险 | 影响 | 缓解 | +| --- | --- | --- | +| A-01 → A-02a 之间 crate 编译中断 | 同 Wave 其它 task 无法验证 | W1 由同一 agent 连续执行;W0 基线闸门;其余 middlewares task 全部排到 W2 之后 | +| 新 hook(IF-M4)不可行 | 需回退到全局传播方案 | 明确回退条件与三项补救义务(§3 IF-M4),禁止静默回退 | +| IF-M5 的 catalog 原子提交与 ARC-TOOLS-001 冲突 | 首个 Reason 拿不到 required 工具 | 提交只更新 static base;Reason boundary 顺序不变;B-07 以首个 LLM 请求入参为终审 | +| A-02b 触发插件产品行为回归 | 坏插件阻止启动 | 严格化只限 MCP 专用路径(R7);既有宽容 API 不动 | +| 契约 5/6 证据不足被读成「已验收」 | 虚假完成 | §8 强制 PARTIAL 标注;D-05 必须写出 UNVERIFIED 项 | +| 同一 crate 多 agent 中间态编译错误 | agent 互相"修"对方文件 | §9 规则 1 明确禁止;D-05 复核 diff 是否越界 | +| 关键路径过长(W1→W7) | 中途失败留下半成品 | 每 Wave 后构建闸门;父 agent 终态独立复跑验证 | + +## 11. 契约 7 的文档纪律 + +- 设计文档 `docs/design/mcp-adaptation-v4-part-1.md` **不回填**迁移批次、提交号和现场勾选状态。 +- 所有描述必须保留「当前实现」与「v4 目标归属」的区分;§1.2 的非目标不得写成已实现。 +- 引用「目标:完全下放 → Workspace MCP」时必须同时标注未迁移。 +- acceptance 记录使用 `PARTIAL` / `BLOCKED` / `UNVERIFIED` 显式标记,不用绿色局部单测代替整体结论。 + +## 12. sub-plan 索引 + +- [`sub-plan A:配置契约`](2026-09-25-mcp-adaptation-v4-part-1-sub-plan-a-config.md)(受 §5 R3/R4/R5/R7/R14 覆盖) +- [`sub-plan B:1R 启动准入`](2026-09-25-mcp-adaptation-v4-part-1-sub-plan-b-readiness.md)(受 §5 R1/R2/R6/R12 覆盖) +- [`sub-plan C:一等工具注入`](2026-09-25-mcp-adaptation-v4-part-1-sub-plan-c-injection.md)(受 §5 R8/R9/R15 覆盖) +- [`sub-plan D:验证与文档一致性`](2026-09-25-mcp-adaptation-v4-part-1-sub-plan-d-verification.md)(受 §5 R3/R9/R10/R13 覆盖) diff --git a/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-a-config.md b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-a-config.md new file mode 100644 index 000000000..174a167da --- /dev/null +++ b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-a-config.md @@ -0,0 +1,263 @@ +# MCP adaptation v4-part-1 — sub-plan A:配置契约 + +> **主 plan v2 覆盖(优先于本文)**:见 [`2026-09-25-mcp-adaptation-v4-part-1-plan.md`](2026-09-25-mcp-adaptation-v4-part-1-plan.md) §5。本文与主 plan 冲突处一律以主 plan 为准,涉及本文件的具体覆盖:**R3**(A-04 不再拥有任何 `docs/code-index/` 文件)、**R4**(`system_mcp_timeout` 归 A,本文 IF-A5「B 决定 timeout、A 不新增字段」作废)、**R5**(`McpServerConfigValidationError` 为三变体,本文 IF-A2 的单变体作废)、**R7**(插件严格化收窄为 MCP 专用路径,本文 IF-A4 的整体严格化表述作废)、**R14**(A-02 拆为 A-02a 机械收口 / A-02b 错误闭环)、**R2**(`initialize.rs` 的 `unwrap_or_default()` 修复归 B-02,不归 A)。 + +## 1. 元信息 + +- 日期:2026-09-25。状态:实现规划,未实施、未运行构建或测试。 +- 唯一目标事实源:已完整读取 `docs/design/mcp-adaptation-v4-part-1.md:1-244`。本计划覆盖验收契约 **1**、**3 的配置无损传递部分**、**4 的配置语义部分**;文档表述遵守契约 **7**。 +- 本次 workflow 仅实施契约 1–4、7;契约 5、6 不是本次迁移实施范围,但不允许破坏已有安全、生命周期契约。 +- 依赖关系:A 提供 B/C 的字段与错误接口;B 负责 1R 启动准入、ready/timeout/失败传播;C 负责所属 namespace 的逐工具解析、schema/bridge、direct 注入及空列表不注入的运行时证明。A 单测通过不等于契约 2–4 已完整验收。 +- 可验证产物:非法字段组合在直接 serde、项目、全局、插件来源均被拒绝且错误可追踪;失败不被合并成成功的空配置;禁用/删除配置不绕过校验;有效工具数组经合并、展开、写回保持值与顺序;`Some([])` 与未声明保持可区分。 +- 本轮唯一写入文件即本文;下文目标文件和命令都是后续实施计划,不是本轮写入授权。 + +## 2. 事实基线 + +### 2.1 权威语义与实际字段 + +- 设计 `:35-40`:`system_mcp=true` 是启动依赖;未标识/false 是普通 MCP。`:45-65`:工具名数组、所属 namespace、缺失必须失败、空数组只要求 ready。`:226-232`:7 条验收契约及目标/现状区分。 +- `peri-acp-types/src/plugin.rs:41-77::McpServerConfig` 是唯一字段定义;`peri-middlewares/src/mcp/config.rs:9-12` 仅 re-export,不应另定义平行 DTO。 + +| 当前字段 | 类型 | 实际 serde 属性 / 缺省行为 | +| --- | --- | --- | +| `command` | `Option` | 无字段属性;缺失为 None | +| `args` | `Option>` | `default` | +| `env` | `Option>` | `default` | +| `url` | `Option` | 无字段属性;缺失为 None | +| `headers` | `Option>` | `default` | +| `oauth` | `Option` | `default` | +| `disabled` | `Option` | `default, skip_serializing_if="is_false"`;`:111-113` 的 helper 将 None/Some(false) 都视为可省略 | +| `protocol_version` | `Option` | `default, rename="protocolVersion", skip_serializing_if="Option::is_none"` | +| `subscriptions` | `Option` | `default, skip_serializing_if="Option::is_none"` | +| `source` | `Option` | `skip`;运行时来源,不进入 wire | + +- **注意纠正一个容易误读的前提**:`McpServerConfig` 本身没有 `rename_all="camelCase"`。该属性实际在 `config.rs:14-18::McpConfigFile`、`plugin.rs:84-98::McpSubscriptionsConfig`、`:116-130::OAuthConfig`;`protocolVersion` 确实是单字段 `rename`。新增 key 不需要改变全结构命名策略。 +- `plugin.rs:142-179::McpServerEntry` 已手写 Deserialize,内联对象最终调用 `serde_json::from_value::`,保留 `serde::de::Error::custom`;字符串是文件引用。 + +### 2.2 加载、合并、写入和消费入口 + +| 实际入口与定位 | 当前行为 / 对实施的意义 | +| --- | --- | +| `peri-middlewares/src/mcp/config.rs:45-56::load_from_path` | 缺文件成功返回空配置;读取/serde 错误分别返回 ReadError/ParseError。类型级 Deserialize 校验可覆盖这里。 | +| 同文件 `:60-86::load_global_config` | 先取 `config.mcpServers`,否则顶层 `mcpServers`;`:84` 对服务器 map 反序列化 `unwrap_or_default()`,会吞掉非法配置,必须改为错误传播。 | +| 同文件 `:227-348::load_merged_config_full` | 实际全局位置由 home/.peri/settings.json 决定,`claude_home` 只用于插件;`:238-245`、`:296-303` 将全局/项目失败变为空配置。合并优先级 global < plugin < project;校验必须先于覆盖与去重,不能让非法低优先级输入被覆盖后消失。 | +| 同文件 `:250-291` | 调用宽容的插件聚合 API;为 server key 加 `plugin:{name}:{server}` 前缀、展开插件变量、注入插件 env 并记录 marketplace。不得在此改写工具名数组。 | +| 同文件 `:89-114::server_config_hash`、`:308-323` | hash 当前只处理 command/args/env/protocol_version;去重可删掉插件服务器。新增启动依赖字段必须参与 hash,且 System MCP 不应因跨 namespace 内容去重而丢失要求。 | +| 同文件 `:178-212::expand_server_config_with_context` | 逐字段重建 struct;新增字段若不显式复制会编译失败,若误做变量展开会损失工具名。 | +| 同文件 `:353-354::load_merged_config` | 公开 API 当前返回 McpConfigFile,无 Result;只取 full 的 `.0`。需要显式变更为可失败 API,不能保留返回空配置的兼容壳。 | +| 同文件 `:398-472::remove_server_from_config_with_paths` | 项目文件先解析为 McpConfigFile;全局只操作 Value,分别尝试 nested/top-level,成功后 atomic write。全局支路尚无类型校验。 | +| 同文件 `:490-579::set_server_disabled_with_paths` | 项目与全局均直接修改 Value 再写;全局找不到目标也可能重写文件。新增校验不能只放在 serde 类型化写回路径。 | +| `peri-middlewares/src/plugin/config.rs:492-506::load_plugin_manifest` | 读取 `.claude-plugin/plugin.json` 并反序列化为 PluginManifest,内联 MCP 会经过 McpServerEntry。 | +| `peri-middlewares/src/plugin/loader.rs:402-433::load_mcp_json_file` | 支持 wrapped/flat;`.ok()`、`if let Ok` 丢失错误;wrapped 解析失败还会尝试 flat。不能把非法 server 当作可跳过条目。 | +| 同文件 `:438-488::extract_mcp_servers` | 内联对象 clone;文件引用加 `entry.server` 前缀;当前以 `result.is_empty()` 触发根 `.mcp.json` 回退,不完全等同注释“manifest 未声明”。严格 MCP 路径必须按“未声明”决定 fallback,而非按解析失败决定。 | +| 同文件 `:491-537::load_plugins` | manifest 错误触发 synthetic fallback;再次失败则 continue。非法 MCP 不能被修复回退掩盖或当作未安装。 | +| 同文件 `:563-592::load_enabled_plugins`、`:632-647::load_enabled_plugins_aggregated` | 前者返回 Result,后者错误变成无诊断的空 PluginLoadResult。`:612-623` 仅 clone 加 namespace。执行加载必须离开这个宽容聚合通道。 | +| `peri-middlewares/src/mcp/initialize.rs:21-39::run_initialize`、`:346-371::initialize` | 两处直接解构合并结果;前者生产、后者 cfg(test)。`:60-69` 空配置发布 Ready;`:82-85` 把原 config clone 到 pool。错误变空会伪造 Ready;必须在这里将错误保留为 Failed,且在禁用过滤前校验 typed 配置。 | +| `peri-middlewares/src/mcp/transport.rs:30-46::TryFrom<&McpServerConfig>` | 当前仅检查 command/url;公开 Rust struct 可手工构造,所以 Deserialize 不是唯一闸门。transport 转换也要调用同一纯校验方法。 | + +其他持久化入口也已检索,不误称只有 config.rs 写磁盘: + +- `peri-middlewares/src/plugin/installer/mod.rs:139-147::generate_synthetic_manifest` 将 marketplace 的 `mcpServers` 原样拷入 manifest;严格加载必须验证生成后 manifest,不允许以生成成功替代配置合法。 +- `peri-tui/src/sync/writer.rs:124-164,177-203::write_sync_items` 可原样同步 settings、MCP、插件文件;`peri-tui/src/sync/channel_flow/staging.rs:79-97` 可暂存 `.mcp.json`。它们是文件搬运,不发布 MCP 配置/ready。本计划不要求禁止磁盘上出现非法字节(手动编辑同样可以);要求每次载入/使用这些字节必经严格配置入口。不得为此另扩展整个同步子系统。 +- `peri-acp/src/host/requests/plugin.rs:26-32` 明确插件 MCP 修改不触发池刷新,需下次装配/会话重启;热重载不是本计划承诺。 + +### 2.3 动态路径核实 + +- `peri-middlewares/src/mcp/dynamic/tool.rs:259-291::bind_invocation`:`DynamicMcpAction::from_tool_input(input)?.canonicalize()?`,不是 McpServerConfig。 +- `peri-acp-types/src/dynamic_mcp.rs:119-140::DynamicMcpConfig` 有 `rename_all="camelCase", deny_unknown_fields`;字段为 command/args/env/cwd/url/headers/timeout_ms/protocol_version/subscriptions,**没有**两个 System 字段。`:157-166::CanonicalDynamicMcpConfig` 也没有;`:197-198` 默认动态 timeout 为 30,000ms。 +- `dynamic/registry/load.rs:12-16,48-53,71-82` 接受 CanonicalDynamicMcpLoadRequest 并储存 canonical config;`dynamic/registry/connector.rs:69-85` 调 `prepare_single_server`;`dynamic/staged_connection.rs:411-447` 直接使用 canonical transport/timeout,不经过静态 struct。 +- 结论:现有动态 wire 会拒绝 System key(未知字段),不是静态校验的漏网通道。本计划保留拒绝,不新增 session 中途声明“启动依赖”的语义;增加回归保护。 + +### 2.4 错误、测试、API 与文档 + +- `peri-middlewares/src/mcp/config.rs:23-42::McpConfigError` 只有 ParseError、ReadError、WriteError,携带 path 和具体 source,无 `non_exhaustive`。 +- 对仓库 Rust 源码精确检索 `McpConfigError`:除定义/构造/返回签名/re-export 外,仅 `config_test.rs:60` 有 `matches!(..., Err(McpConfigError::ParseError { .. }))`;未发现枚举的穷尽 match。该 matches 宏有隐含 false 分支,新增变体不会使此处非穷尽。未能据此保证仓库外消费者不受影响。 +- `TransportError` 的对应检索仅发现 `transport_test.rs:74` 的 `matches!(..., InvalidConfig)`,没有穷尽错误枚举 match。 +- `config_test.rs:1-24,56-85`:`use super::*`、普通 `#[test]`、NamedTempFile、断言 Result/字段;`:228-260` 使用 tempdir 并回读文件;`:386-484` 用真实插件目录、manifest、installed_plugins、enabledPlugins 测试合并。当前 full tests 未注入全局路径,会读取真实 home 配置,新增/改造测试应使用显式路径 seam。 +- 搜索 `McpServerConfig {` 的文件:`peri-acp-types/src/plugin.rs`、`peri-middlewares/src/mcp/{config.rs,config_test.rs,client_test.rs,resource_cache_test.rs,transport_test.rs}`、`peri-middlewares/src/plugin/loader_test.rs`。新增 public 字段会破坏完整 struct literal;必须同步,而不改无关 fixture 语义。 +- `peri-acp-types/src/lib.rs:1-2,31-32,62`、该 crate `Cargo.toml:5,13-16`:跨层契约位置明确;serde/serde_json/thiserror 已有依赖。校验是纯数据不变量,不把文件 I/O、home 定位、插件发现、transport/ready 编排搬进契约层。 +- `docs/standards/architecture-contracts.md:54-58::ARC-TOOLS-001` 约束真实工具视图与 direct/deferred;`:122-126::ARC-SECRET-001` 禁止错误/日志泄漏凭据。没有找到一条专门规定“给 peri-acp-types struct 加字段必须使用某 feature gate”的规则,不能补造这种要求。 +- `spec/issues/2026-07-22-p1-6-api-stability-compile-enforce.md:10-14,18-36,86-118`:讨论的是 peri-agent API 分级,feature gate 阶段仍 Open;不是已实施的契约层强制门禁。本计划保持既有 public 类型/re-export identity,记录公开返回类型变化和 struct literal 源码兼容性,显式迁移工作区调用者,不顺便实施该 issue。 +- `CLAUDE.md:39-41,70` 要求核实 code-index 并同步、按 DOC-UPDATE-001 检查路由;`docs/standards/documentation.md:27-31,45-49` 要求更新受影响单一事实源,参考资料不得冒充权威。 +- `docs/reference/README.md:6` 只路由 MCP 生态文档;目录检索没有 `mcpServers`/`McpServerConfig`/`protocolVersion` 配置参考页。`docs/reference/mcp-ecosystem.md:3-5,546,564` 有接入与现状章节,应补充简短配置说明和权威设计链接,不另建平行配置手册。 +- `docs/code-index/peri-middlewares.md:39,49-50,111` 有 MCP 配置/插件入口条目;`docs/code-index/peri-acp-types.md:8,109-116` 有契约层定位但 plugin 仅在“其他”中列举;实施后分别更新严格加载入口、新字段与校验定位。 + +## 3. 接口冻结(Interface Freeze) + +### IF-A1:字段、wire key、默认值 + +在 `peri_acp_types::plugin::McpServerConfig` 增加以下 public 字段;既有字段及 re-export 保持: + +```rust +#[serde(default, rename = "system_mcp", alias = "systemMcp", skip_serializing_if = "is_false")] +pub system_mcp: Option, +#[serde(default, rename = "system_mcp_tools", alias = "systemMcpTools", skip_serializing_if = "Option::is_none")] +pub system_mcp_tools: Option>, +``` + +- **冻结决策:输出严格使用设计的 snake_case key;输入额外接受 camelCase 别名。** 理由:设计字面 key 为 canonical;别名兼容周边 camelCase 使用习惯,并防止拼成 camelCase 后被现有“忽略未知字段”行为悄悄降级为普通 MCP。两种拼法同时出现必须报 duplicate field,即使值相同也拒绝。 +- 不给整个 McpServerConfig 添加 rename_all 或 deny_unknown_fields,不改变其他历史 key 的解析政策。 +- `system_mcp` 缺省 None,消费判定固定为 `config.system_mcp == Some(true)`。false/None 写回可省略;显式 null 对新字段拒绝,不作为未配置。 +- `system_mcp_tools` 缺省 None;显式 `[]` 是 Some(vec![]),必须写回为 `"system_mcp_tools": []`,禁止 `Vec::is_empty` 省略;null、非数组、非字符串元素一律解析失败。 +- Deserialize 改为私有 wire helper + 手写转换,Serialize 保持 derive。helper 的两个新字段各使用 `default` 与拒绝显式 null 的 `deserialize_with`(bool / Vec 正常反序列化后包 Some),仅字段缺失时走默认 None。复制其余字段的现有 serde 属性,source 仍 skip。新 alias 的 duplicate detection 由 helper derive 保留,禁止先转 Value 导致重复 key 丢失。 +- 不 trim、不排序、不去重、不展开 `${...}`、不加 MCP 前缀、不把数组拼成字符串;同名工具属于各自 server key。空字符串/重复工具等运行时可解析性由 C 决定,A 不新增设计未要求的工具名限制。 +- true + None 与 true + Some([]) 在消费语义上都是只要求 ready,无必需工具;仍保存结构区别。false/None + Some([]) **非法**,不能用列表非空判断是否“声明”。 + +### IF-A2:纯校验及错误 + +定义位置:`peri-acp-types/src/plugin.rs`。 + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum McpServerConfigValidationError { + #[error("system_mcp_tools requires system_mcp = true")] + SystemMcpToolsRequiresSystemMcp, +} + +impl McpServerConfig { + pub fn validate(&self) -> Result<(), McpServerConfigValidationError>; +} +``` + +实现规则唯一为 `self.system_mcp_tools.is_some() && self.system_mcp != Some(true)` 时返回该变体;其余 Ok。方法无副作用,无 namespace/transport/I/O。Deserialize 调用 validate,错误通过 `serde::de::Error::custom` 保留上述固定正文。 + +- 私有 helper 精确签名(同文件):`fn deserialize_present_system_mcp<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error>`;`fn deserialize_present_system_mcp_tools<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result>, D::Error>`。它们分别调用 bool / Vec Deserialize 后 map(Some),因此显式 null 拒绝。 +- 配置层保留现有 ParseError 文案 `MCP 配置文件解析失败: {path}: {source}`;非法 wire 组合返回 ParseError,source 正文含固定契约错误,可有 serde 行列后缀,不冻结行列数字。 +- 在 `peri-middlewares/src/mcp/config.rs::McpConfigError` 增加 `InvalidServer { server_name: String, source: McpServerConfigValidationError }`,source 标记 `#[source]`,Display 固定 `MCP 服务器配置无效: {server_name}: {source}`。它用于手工构造 typed 配置校验,不把错误定义反向依赖到 middlewares。 +- 同枚举增加 `PluginLoadError { source: crate::plugin::loader::LoaderError }`,source 标记 `#[source]`,Display `插件 MCP 配置加载失败: {source}`。LoaderError 不反向持有 McpConfigError,避免递归错误类型。 +- `TransportError` 增加 `InvalidSystemConfig(#[from] McpServerConfigValidationError)`,Display 采用 `#[error(transparent)]`;TryFrom 首行调用 validate,保留原 InvalidConfig 的 command/url 语义。 +- 错误中只保留文件定位、server 标识和固定规则文本,不打印整个配置、env、headers、URL 认证信息或 OAuth 值。测试不得用真实 secret。 + +### IF-A3:严格加载与失败闭环 + +`peri-middlewares/src/mcp/config.rs` 冻结签名: + +```rust +pub fn load_merged_config(cwd: &Path, claude_home: &Path) + -> Result; +pub(crate) fn load_merged_config_full(cwd: &Path, claude_home: &Path) + -> Result<(McpConfigFile, HashMap), McpConfigError>; +pub(crate) fn validate_config(config: &McpConfigFile) -> Result<(), McpConfigError>; +fn load_merged_config_full_with_paths(cwd: &Path, claude_home: &Path, global_path: &Path) + -> Result<(McpConfigFile, HashMap), McpConfigError>; +``` + +- validate_config 按 server name 排序调用每个 cfg.validate,首个错误稳定返回 InvalidServer;不能跳过 disabled 条目。B 对直接传入的 typed config 在任何 empty/disabled/ready 分支前调用它。 +- full 委托 with_paths,生产仍按既有 home 路径;测试显式传 tempdir 下的全局路径,不改 HOME、不碰用户文件。 +- load_from_path/load_global_config 原签名不变;所有 serde 错误返回 ParseError,现存文件解析失败不得 default。全局读取优先级仍 nested > top-level;如果两个 map 都存在,两者先校验,选择仍按原优先级,避免写入口操作到未经验证的备用 map。 +- 全部被选择加载的 global/plugin/project 输入先验证,再覆盖/去重;缺文件仍可空,非法文件不是缺文件。合并输出再次 validate_config。 +- 写入口在**修改前**验证所有相关 server map,修改后的待写结果再验证。失败不调用 atomic_write_json、不改任何字节;不能用 disabled=true 绕过。删除非法条目也拒绝(与当前项目分支先 typed parse 一致),需用户先修复文件;不引入“删除即修复”特殊权限。 +- `expand_server_config_with_context` 复制 system_mcp、clone system_mcp_tools。hash 增加两个新字段;System MCP 配置不参与跨来源、跨 namespace 的内容去重删除,保留各 server 所属 namespace。同一 server key 的显式优先级覆盖仍是整条配置替换,不跨来源拼接工具数组。 +- `initialize.rs` 两处接收 Result:Err 时记录/发送 `McpInitStatus::Failed(error.to_string())` 并返回,不 mark_initialized、不发布空 Ready、不开始连接;这是 A 的配置错误接线,不实现 B 的 ready 等待策略。B 必须在 1R 消费该失败,而非仅日志后继续。 + +### IF-A4:插件路径的严格/展示边界 + +`peri-middlewares/src/plugin/loader.rs` 新增执行专用入口: + +```rust +pub(crate) fn load_enabled_plugins_for_mcp( + claude_dir: &Path, + cwd: Option<&Path>, +) -> Result, LoaderError>; +``` + +- MCP 合并唯一调用该严格入口(本次保持当前 `cwd=None` 的插件选择规则,不顺带改变启用范围)。它复用 installed/enabled 选择与插件装配逻辑;内部严格/宽容调用共用解析函数,禁止复制两份配置解析器。 +- 将 `load_mcp_json_file` 的内部返回冻结为 `Result>, LoaderError>`:不存在可 None,存在而非法必须 Err;wrapped 一旦出现就不尝试 flat;flat 任一 entry 非法则整个失败,不保留部分成功。 +- 将 `extract_mcp_servers` 内部返回冻结为 `Result, LoaderError>`;typed 内联逐项 validate;有 manifest.mcp_servers(包括空 map)就不回退根文件。严格链保留该 Err。 +- LoaderError 新增 `McpConfigInvalid { path: PathBuf, message: String }`,Display 固定 `插件 MCP 配置无效: {path}: {message}`。message 使用规则/解析位置而非原始输入值;对规则失败固定为 `system_mcp_tools requires system_mcp = true`。严格 manifest 解析同样保留明确错误;不通过匹配错误字符串决定是否跳过。 +- synthetic fallback 只允许 manifest 文件不存在时发生,生成后必须严格解析;已存在但非法的 manifest 不允许覆盖修复。严格入口不能沿用 load_plugins 的 continue/aggregate 的 default。 +- 现有供插件面板、skills/hooks 聚合使用的 `load_enabled_plugins_aggregated` 可保持公开返回类型,作为宽容展示路径,但必须记录安全且明确的配置错误,不能默默丢弃;不得作为任何 MCP transport/ready 的输入。当前调用点 `peri-acp/src/host/assemble.rs:75,293`、`peri-middlewares/src/host_ports.rs:94`、`peri-tui/src/launch.rs:113` 保持类型不变;本仓库 all_mcp_servers 搜索未发现这些点用于启动连接。 +- **准入边界明确**:保留宽容 UI 不代表允许非法 MCP 被启动;生产 MCP 全局/插件/项目加载必须严格,且 B 在启动前消费失败。若实施中发现新增消费者用宽容聚合结果构建 MCP,则必须切换严格入口,不允许宣告验收完成。 + +### IF-A5:交接给 B/C 的语义 + +- B 判定 System 只看 `Some(true)`,不能看 tools 非空;工具为空也必须等待 ready。B 决定 timeout 配置接口,A 本次不增加 timeout 字段。 +- C 输入是 `(server_key, &McpServerConfig)` 或其等价的无损 clone;仅在 `system_mcp == Some(true)` 时遍历 `system_mcp_tools.as_deref().unwrap_or(&[])`。该 slice 为空就没有额外直接注入请求,不意味着注入该 server 的全部工具。 +- source 和 plugin_sources 按现有来源逻辑保留;C 负责由 server_key 解析 namespace/effective name,不从工具名猜 namespace。 +- A 不定义 direct bridge 或 ready 状态;B/C 不重新定义字段、错误或独立校验规则。 + +## 4. 任务表 + +下列任务按依赖执行,各任务目标文件集合互不重叠。“独立验证”指前置任务已落地后可单独执行本行命令,不要求互相依赖的 Rust 类型变更可以任意顺序 cherry-pick。A-02 是完整的配置失败传播纵切片:签名、literal 和调用者必须一次收口,否则只是不可编译的半成品;不得再把同一文件分派给不同任务。所有命令在仓库根目录运行,**仅供后续实施,本轮未执行**。 + +| Task ID | 标题 | 目标文件(精确路径) | 改动摘要 | 验证命令 | 预估 diff 规模 | 与其它 task 的文件冲突面 | +| --- | --- | --- | --- | --- | --- | --- | +| A-01 | 冻结契约 DTO 与纯校验 | `peri-acp-types/src/plugin.rs` | IF-A1/A2 字段、私有 wire helper、手写 Deserialize、validate/error;同文件 cfg(test) 小型契约测试。只增加既有依赖可实现的纯逻辑。 | `cargo test -p peri-acp-types --lib -- system_mcp` | 180–260 行 | A 内无重叠;B 的 timeout 若也改此文件,必须由 A owner 合并一次,先确认 key;不能并行覆盖。 | +| A-02 | 静态配置全部入口失败闭环与无损传递 | `peri-middlewares/src/mcp/config.rs`;`peri-middlewares/src/mcp/config_test.rs`;`peri-middlewares/src/mcp/initialize.rs`;`peri-middlewares/src/mcp/initialize_test.rs`;`peri-middlewares/src/mcp/transport.rs`;`peri-middlewares/src/mcp/transport_test.rs`;`peri-middlewares/src/mcp/client_test.rs`;`peri-middlewares/src/mcp/resource_cache_test.rs`;`peri-middlewares/src/plugin/loader.rs`;`peri-middlewares/src/plugin/loader_test.rs` | 依赖 A-01。完成 IF-A2–A4;删除配置错误被当空的执行路径;严格插件入口;配置原子写前后校验;数组 clone、hash/namespace 去重保护;两处初始化错误接线;补齐完整 struct literal;增加本节与第 5 节真实文件契约测试。client/resource 文件仅补默认字段,不改测试含义。 | `cargo test -p peri-middlewares --lib -- system_mcp`;`cargo test -p peri-middlewares --lib -- mcp::config::tests`;`cargo test -p peri-middlewares --lib -- plugin::loader::tests`;`cargo check --workspace --all-targets` | 500–850 行(含回归测试及机械字段补齐) | A 内无重叠;B 可能改 initialize.rs/initialize_test.rs,A 先完成配置失败处理再交 B,不并行编辑;C 不改这些文件。若需更细提交,在同一 owner 下串行,不新增重叠任务。 | +| A-03 | 动态配置拒绝 System key 的边界回归 | `peri-middlewares/src/mcp/dynamic/tool_test.rs` | 依赖 A-02 可编译基线;通过 bind_invocation / from_tool_input 覆盖两个 snake key 和两个 alias,确认拒绝且不调用 deployment load;不改动态生产代码/DTO。 | `cargo test -p peri-middlewares --lib -- test_dynamic_mcp_rejects_system_mcp_fields` | 30–65 行 | A 内无重叠;B/C 不扩大动态生命周期,本文件仅由 A 持有。 | +| A-04 | 配置参考与索引同步 | `docs/reference/mcp-ecosystem.md`;`docs/code-index/peri-acp-types.md`;`docs/code-index/peri-middlewares.md` | 依赖 A-02。增加 canonical/alias/空数组/非法组合说明与权威设计路由;更新契约类型及严格入口索引;只标注实际验证到的配置能力,不宣称 5 MCP 迁移。 | `git diff --check -- docs/reference/mcp-ecosystem.md docs/code-index/peri-acp-types.md docs/code-index/peri-middlewares.md`;`git diff -- docs/reference/mcp-ecosystem.md docs/code-index/peri-acp-types.md docs/code-index/peri-middlewares.md`(按 DOC-UPDATE-001 人工核对) | 30–65 行 | A 内无重叠;B/C 如也需索引更新,只向此 task 提供条目,由一个文档 owner 汇总。 | + +A-01 完成时 middlewares 的完整 literals 尚待 A-02 补齐,不宣称工作区可编译;A-01 自己的契约 crate 测试可独立验证。A-02 必须连带修复所有已核实调用点后再交接 B,不能把编译失败留给 B/C 猜测。 + +## 5. 验证计划 + +### 5.1 契约层(A-01) + +以下函数放在 `peri-acp-types/src/plugin.rs` 的 cfg(test) module;用最小无凭据 JSON,不运行外部进程。 + +| 测试函数名 | 可观察断言 | +| --- | --- | +| `test_system_mcp_legacy_defaults` | 旧 JSON 解析后两个字段 None,validate Ok;输出不含新增 key,既有 protocolVersion/source 语义不变。 | +| `test_system_mcp_tools_requires_true` | 表驱动:system 缺失/false × tools 空/非空均 Err;直接 typed 构造断言 `SystemMcpToolsRequiresSystemMcp` 变体,serde 错误正文 contains 固定文案;true 两种数组成功。 | +| `test_system_mcp_empty_tools_roundtrip` | true + [] 往返后 Some(true)、Some(empty),输出确有 snake key 和空数组;true + 缺失 tools 为 None,不能序列化为自动补充的工具。 | +| `test_system_mcp_key_aliases` | snake/alias 输入得到相同字段;输出只有 snake;同一字段两种 key 同时出现报 duplicate field;alias tools 无 true 同样失败。 | +| `test_system_mcp_rejects_null_and_wrong_types` | system null/string、tools null/string/非字符串元素均 Err;不接受为 None/空数组。只断言固定解析种类或字段上下文,不断言 serde 行列数字。 | +| `test_system_mcp_tools_preserve_exact_values` | 大小写、重复项、空字符串、含 `${VAR}` 的字面工具名及原顺序都不变;不隐式 namespace 化。 | + +### 5.2 配置契约测试(A-02) + +除最后两组外,以下测试均放 `peri-middlewares/src/mcp/config_test.rs`;每个都可用 `cargo test -p peri-middlewares --lib -- <函数名>` 运行。新增文件场景使用 tempdir/NamedTempFile 和显式 global_path,禁止环境变量 HOME 切换、网络或子进程。 + +| 测试函数名 | 验收 / 可观察断言 | +| --- | --- | +| `test_system_mcp_project_rejects_tools_without_true` | 契约 1:项目四个非法组合返回 McpConfigError::ParseError;path 是 fixture 文件;source contains `system_mcp_tools requires system_mcp = true`。 | +| `test_system_mcp_global_rejects_invalid_maps` | 契约 1:nested/top-level 分别表驱动,均返回 ParseError 而非 Ok(empty);双 map 时非法备用 map 也拒绝;路径和错误正文保留。 | +| `test_system_mcp_merged_errors_are_not_empty_success` | 契约 1:全局/项目/插件各放非法配置,在严格 full_with_paths 上均 Err;非法低优先级配置即使被有效项目同名覆盖也拒绝。 | +| `test_system_mcp_typed_validation_includes_disabled` | typed 构造非法 config,包括 disabled=true,validate_config 返回 InvalidServer;断言 server_name、具体 source 变体及完整固定 Display。 | +| `test_system_mcp_empty_tools_survive_config_pipeline` | 契约 4 配置部分:true + [] 经加载、三层合并、展开、禁用状态写回再载入仍 Some(empty);不产生工具名,不推导 ready。 | +| `test_system_mcp_tools_survive_expansion_and_namespace` | 契约 3 配置部分:插件 server key 成为 plugin:p:s,工具 Vec 与输入逐项完全相等;global/project 同名覆盖为整条替换,source 保留;不是拼接数组。 | +| `test_system_mcp_dedup_preserves_required_namespaces` | 相同 command/args/env 的 System server 不因插件内容去重消失;普通 MCP 既有去重仍有效;变更 system/tools 字段改变 hash。 | +| `test_system_mcp_disabled_write_rejects_invalid_input` | 项目/nested/top-level 及 disabled true/false 矩阵:返回 ParseError,前后文件 bytes 完全相等,未触发原子替换。 | +| `test_system_mcp_remove_rejects_invalid_input` | 三种位置中目标或其他 server 非法均返回 ParseError,文件不变;删除非法目标不作为例外;合法删除仍成功。 | +| `test_system_mcp_write_preserves_remaining_tools` | 删除普通 server / 切换 disabled 后,剩余 System 数组顺序和值不变,[] 不被省略;全局其他 settings 字段仍存在。 | + +`peri-middlewares/src/plugin/loader_test.rs`: + +- `test_system_mcp_plugin_strict_sources_reject_invalid`:内联 manifest、文件引用 wrapped/flat、根 `.mcp.json` 各返回 LoaderError::McpConfigInvalid 或保留明确 manifest 解析 source 的错误;断言路径和固定规则正文;不部分接纳合法兄弟条目。 +- `test_system_mcp_plugin_invalid_manifest_has_no_fallback`:非法现存 manifest 不触发 synthetic overwrite、不从根配置兜底;原文件 bytes 不变。 +- `test_system_mcp_plugin_empty_manifest_map_has_no_fallback`:显式空 map 不从根加载额外服务器;无声明才允许根回退。 +- `test_system_mcp_plugin_strict_error_reaches_merge`:启用插件非法配置到 full_with_paths 返回 PluginLoadError,source chain 保留规则正文;不是空 plugins 的成功结果。(该函数可放 config_test.rs 以访问私有路径 seam;最终归 A-02 单一 owner。) + +`peri-middlewares/src/mcp/transport_test.rs`:`test_system_mcp_transport_rejects_invalid_typed_config`,断言 InvalidSystemConfig 的内层变体,证明公开 struct 构造不能绕过 transport 校验。 + +`peri-middlewares/src/mcp/initialize_test.rs`:`test_system_mcp_config_error_never_publishes_ready`,使用非法临时项目配置调用实际 run_initialize;watch 和 pool 状态为 Failed 且 message contains 固定规则正文,pool 未标 initialized,未出现 Ready、未开始 transport。不能以只测 parser 代替这条错误到状态 seam。 + +### 5.3 动态与跨 sub-plan 验收 + +- A-03 `test_dynamic_mcp_rejects_system_mcp_fields` 位于 `dynamic/tool_test.rs`:每个新 key/alias 都在 canonical bind 前拒绝,Err 不是成功空请求;deployment load 调用次数为 0。不要求未知字段错误与静态组合错误同变体。 +- B/C 必须另有真实 seam 测试:`system_mcp=true, system_mcp_tools=[]` 时协议 ready 前 1R 不放行;ready 后 direct 列表中没有由此配置新增的 bridge。**A 只证明数组保真,不能证明 ready 或零注入。** 测试命名/文件由 B/C 冻结,A 不虚构它们已存在。 +- 后续顺序:A 的 focused tests → B/C 的 transport/1R/工具视图 tests → workspace all-targets 检查和文档差异审阅。现有测试命令按 CLAUDE 路由;本轮没有执行任何 Cargo 命令,所有结果仍待实施验证。 + +## 6. 风险与未知 + +1. **timeout 未冻结**:设计只说配置的 timeout,没有定义静态 key、类型、单位、默认值或每 server/总等待范围。动态 `timeoutMs`/30,000ms 是独立 DTO 的事实,不能直接冒充静态设计。B 必须先决策;若新增字段仍落 plugin.rs,由 A owner 一次合并,更新 literal 清单。 +2. **disabled + system=true**:A 验证规则不禁止该组合,设计未明示禁用与启动依赖谁优先;B 必须明确准入行为,不能在 A 默默改成普通 MCP。非法 tools 组合即便 disabled 也始终拒绝。 +3. **公开 API 兼容性**:load_merged_config 改 Result 是有意的源码兼容性变化;仓库内未发现外部调用点,不等于不存在仓库外消费者。不得借保兼容继续提供 fail-open 返回;如版本政策要求迁移窗口,由父计划明确版本策略,不由 A 添 feature gate。 +4. **serde helper 漂移**:必须逐字段保留本节列出的旧属性,并有旧 JSON/protocolVersion/source 回归。Option 本身默认接受 null,因此拒绝显式 null 必须真正实现 deserialize_with,不能仅写文档。 +5. **插件宽容 API**:严格 MCP 入口与展示聚合必须共享解析器,不能共享吞错结果。展示路径保留安全诊断;任何将聚合 all_mcp_servers 转作 runtime 输入的新调用都是集成阻塞。未实跑插件安装、迁移、同步或 UI;这里只静态核实了路径。 +6. **synthetic fallback 与外部文件**:仅缺文件允许生成、生成后严格解析;不修复非法 manifest。同步允许落盘非法字节但使用时拒绝。若父计划要求“同步/安装写磁盘时也原子拒绝所有非法输入”,那是更强的写入产品契约,需另确认范围,不能声称本计划覆盖整个文件同步事务。 +7. **去重与 namespace 耦合**:普通 MCP 保留既有去重;System MCP 禁止被跨 namespace 内容去重删除。现有 hash 未包含 URL/headers 是既有问题,本计划不扩展为通用去重重构,也不调整凭据共享策略;C 必须知道 System 两个配置可同时留下。 +8. **实施任务原子性**:字段加到 public struct 后全工作区中间态会暂时不编译,A-01 只运行契约 crate 检查,A-02 负责全部已检索 literals/调用者的闭环。不能把 B/C 同时修改 initialize 的冲突误报成编译事实。 +9. **运行时证据缺口**:本轮只使用 Read/Grep/Glob 核实;未 build/test,未验证测试 fixture 的实际耗时/平台行为,也未确认仓库外消费者。最终不能将本文的命令列表写成验证通过记录。 +10. **标准的准确引用**:API stability issue 不是现行编译期门禁,architecture-contracts 也没有字段新增 feature-gate 条款。确实适用的是类型归属、工具视图/生命周期/秘密保护等既有边界,不能凭推测扩大要求。 + +## 7. 非目标 + +- 不实现 5 个 MCP 实例真实迁移,不拆出独立 Workspace/Artifact/Web/Cron/LSP MCP server;不把 Filesystem、Terminal、Web、Cron、LSP、GitWatch、SkillTool 等从宿主下放。 +- 不以本计划或绿色配置单测宣称验收契约 5、6 完成,更不宣称 v4 整体迁移完成;目标归属始终单列为尚未落地的目标。 +- 不实现 B 的协议 initialize、能力/健康检查、超时、1R ready 屏障或关闭事务;只为配置失败接通 Failed 状态。 +- 不实现 C 的所属 namespace 查找、schema 验证、effective tool name、bridge 构造、direct/deferred 分类及 RCRA 注入。 +- 不改变 DynamicMCP 的 DTO、审批、session/incarnation、projection lease、加载/卸载 lifecycle;只保护它拒绝静态 System key 的现有边界。 +- 不改变 protocolVersion 策略,不固定所有连接握手版本,不新增宿主 CLI 暴露入口。 +- 不实施 API stability feature gate,不改插件启用优先级,不增加 MCP 热重载,不重构通用同步/安装事务。 +- 本轮不写生产代码、测试或其他文档,不运行 cargo build/cargo test,不创建提交。 diff --git a/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-b-readiness.md b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-b-readiness.md new file mode 100644 index 000000000..e3ffe9400 --- /dev/null +++ b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-b-readiness.md @@ -0,0 +1,398 @@ +# MCP adaptation v4-part-1 — sub-plan B:System MCP 启动准入 + +> **主 plan v2 覆盖(优先于本文)**:见 [`2026-09-25-mcp-adaptation-v4-part-1-plan.md`](2026-09-25-mcp-adaptation-v4-part-1-plan.md) §5。本文与主 plan 冲突处一律以主 plan 为准,涉及本文件的具体覆盖:**R6(最重要,推翻本文 B-04)** — 不再把 `peri-agent/src/agent/stages/mod.rs:881-886` 的 `before_agent` Err 从 warn 降级改为全局传播(全仓库 6 个既有中间件的 `before_agent` 可返回 Err,其中多属允许的软失败),改为新增专用启动闸门 hook `before_react_start` + `StartupState`(主 plan §3 IF-M4);**R1**(`initialize.rs` 由 A-02b 先完成再移交 B-02)、**R2**(4 处 `unwrap_or_default()` 的修复归 B-02)、**R12**(B-04 依赖 B-05,v1 顺序反了)。本文的 `DiscoveryEvidence` 需求由主 plan IF-M3 冻结为硬要求。 + +## 1. 元信息 + +- 日期:2026-09-25;状态:**实现规划,未实施、未运行 Cargo 验证**。 +- 主责验收契约 **2**;衔接 A 的契约 1、C 的契约 3/4,遵守契约 7。整个 workflow 只实施 **1–4、7**,不是五个 MCP 实例真实迁移。 +- 权威目标:`docs/design/mcp-adaptation-v4-part-1.md:33-65,222-234`,已完整读取 244 行。以下行号为侦察时代码基线,不表示目标已实现。 +- 依赖 A:严格配置解析、可信且完整的 System MCP requirement 集合、加载失败不得当作空配置;新增 timeout 字段由 A 持有。依赖 C:`build_typed_tool_bridges`、`prepare_system_tools`、`SystemToolError`,接口见 `spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-c-injection.md:68-164`。 +- 可验证产出:首次有效 Receive 后存在 fail-closed 屏障;transport/协商/tools discovery/必需工具检查未完成,不放行 Compact/Reason/Act;失败或超时成为 prompt fatal error;普通 MCP 不成为连接等待条件;首个真实 Reason 使用已验证工具快照。 +- **必须改 `peri-agent`。** 首批 `before_agent` 当前吞掉错误,且工具 catalog 在该 hook 之前构造;单改 McpMiddleware 不足以交付。 +- **B 独占 `peri-middlewares/src/mcp/middleware.rs`;C 不拥有该文件,只提供函数,由 B 接线。** B 还拥有任务表列出的 Agent loop、middleware 接口/runner、catalog 文件。A/C 不得同时编辑;新增配置字段导致 B 文件中的 literal 变化也由 B 合并。 +- 本次唯一写入物为本文;所有验证命令都是未来实施后的命令,未执行。 + +## 2. 「1R 阶段」定位论证 + +### 2.1 生命周期与候选调用链 + +`peri-agent/src/middleware/trait.rs:16-52` 列出 session `on_session_start/end`、输入 `on_user_prompt`、初始化 `before_agent`、每批输入 `before_input`、每次模型调用 `before_model/after_model`、工具 batch/tool、`after_agent`、compact、通知与 error hooks。补充定义:`first_turn_reminder` 在 `:78-93`,`before_reason_catalog` 在 `:145-154`,`on_session_start` 在 `:224-231`。 + +| 候选 hook | 实际证据 | 判断 | +| --- | --- | --- | +| `on_session_start` | trait 注释称 session/new 后、loop 前(`trait.rs:226`);chain `chain.rs:240-249` 用 `?`。但全仓库检索 `run_on_session_start(` 只有此定义,没有生产调用;middlewares 中也未找到该 hook 实现 | **排除**。不能把注释当作执行证据;不是“证实会日志降级”,而是当前没有可依赖的生产调用链。补接会引入 session/new/load/resume/subagent 生命周期工作 | +| `first_turn_reminder` | chain `chain.rs:280-292` 用 `?`;executor `session/exec/executor_helpers/v2_execute.rs:369-391` 捕获 Err 仅 warn;只在首个模型可见用户 turn 触发 | **排除**。错误被吞、已有历史与 continuation 不覆盖,语义是通知而非准入 | +| 首批 `before_agent` | `agent/stages/mod.rs:874-894` 在首次有效 Receive 后、Compact 前;chain `chain.rs:68-78` 对每个 middleware 交错调用 `before_agent → before_input`;runner `middleware_runner.rs:62-82` 原样返回 Result,Err 也 reconcile | **选定,但必须修正 loop 对 Err 的消费**。当前 `mod.rs:885` 仅 warn,然后仍进入 Compact/Reason | +| `before_input` | 首批跟随各 before_agent;后续非空批次由 runner `:84-98` 调用,`mod.rs:887-894` 错误会终止 | 排除主门禁:首批仍被同一 warn 分支吞掉;每批输入也不是一次初始化语义 | +| `before_reason_catalog` / `before_model` | `reason.rs:22-40`:refresh → map swap → catalog hook `?` → model hook `?` → pin;`mod.rs:911-923` 传播 Reason stage 错误 | 能阻止 LLM,但已进入 Compact/Reason,晚于第一 Receive 启动边界。仅保留目录重绑/运行中防御,不代替主门禁 | + +**结论:“1R”落实为 `run_react_loop` 首次有效 Receive 后、进入 Compact 前,由链内 `McpMiddleware::before_agent` 执行的启动准入屏障。** 这不是“尚未调用 run_react_loop 函数”:Receive 先接纳输入才符合当前 RCRA。等待期间可以已经发 TurnStarted、消费输入,但不能进入 Compact、Reason、Act 或调用模型。空队列/已取消/no-op keepgoing 不产生可启动模型工作,无须准入。 + +### 2.2 首批语义,不是每个 ReAct iteration + +- 每次 `run_react_loop` 新建 `LoopState::default()`(`stages/mod.rs:677-680`);`:879-880` 的 `before_agent_has_run` 限制一次,后续仅 before_input。它是**每次 loop 执行的首批**,不是每个 iteration,也不是 session 永久仅一次。 +- trait `:66-75`、chain `:68-88`、`stages_test.rs:1048-1177,1186-1230` 相互印证。`mcp/middleware.rs:427` 的“每轮”注释须改成“每次 Agent 执行的首批”。 +- 当前文档没有 ARC-MW-001 编号;用户引述内容实际在 **ARC-MIDDLEWARE-CAPABILITY-001**(`docs/standards/architecture-contracts.md:116-120`):首批交错初始化/输入准备、后续仅输入准备、不可持 guard 跨外部 await。不得为 MCP 重排 `session/factory.rs:95` 的 production blueprint。 + +### 2.3 Err 如何让整个 prompt 失败 + +现有执行链:ACP `host/prompt.rs:579-590` 注册 PromptHandle → `host/prompt_handle.rs:62` 调 `run_session_loop` → Agent `session/exec/executor.rs:217` → `v2_execute.rs:350-391` 入队输入/reminder,`:397-418` 发 TurnStarted 并调 loop → `Receive → before_agent → Compact → Reason → Act`(`stages/mod.rs:672-677,874-923`)。 + +B 修改 `stages/mod.rs:879-886`:首批 runner Err 与后续输入 Err 一样,`Interrupted → LoopResult::Interrupted`,其它 → `LoopResult::Error(error)`;初始化与目录提交成功后才置 flag。不能只改 chain/runner,它们已返回 Err;不按 middleware 名称字符串特判 MCP,也不新增 fail-open 开关。 + +后续现有投影可复用: + +1. `classify_loop_terminal`(`v2_execute.rs:635-705`):MiddlewareError 为 fatal,`ok=false`、`failure=Some`、`TurnStatus::Error`。当前通用 fatal 的 `TurnErrorKind` 是 **LlmFailure**(`:704`),本次不借机重做这个遗留分类。 +2. `ExecutionFailure::from_agent_error`(`peri-acp-types/src/session/execution.rs:92-122`)将它投影为 `kind=Internal`;`AgentError::user_facing_message` 对 MiddlewareError 走 `other.to_string()`(`error.rs:267-306`)。所以安全、明确的文案必须在 MCP 边界构造,ACP 不会替任意 MCP cause 自动脱敏。 +3. `v2_execute.rs:543-591` 用同一 safe public message 发 `AgentExecutionFailed`,再唯一 TurnEnded,保留 drain/done 收尾。 +4. ACP `host/prompt.rs:613-649` 先采纳历史/清理 cancel token,再 `prompt_wire_response`;`:122-128` 对 failure=Some 返回 Err;`:39,59-109` 为 **JSON-RPC `-32000`,`data={"kind":"internal"}`**,无 status/内部 cause。 +5. ARC-EVENT-001(`docs/standards/architecture-contracts.md:48-51`):canonical 用户错误是 **session/prompt error response**。`AgentExecutionFailed` 仅 capability-gated 兼容事件,不能以 TUI 通知代替失败响应。 + +### 2.4 必要的目录发布配套 + +- `session/exec/stage_builder.rs:456-466` 在 1R 前完成 collect_tools、build_session_tool_view、register_tool_catalog、StageContext。 +- before_agent 可访问 local_tools(`middleware/capabilities.rs:51-67`),但只改 working map 会被 `reason.rs:23-30` 的 refresh/swap 覆盖。 +- `SessionToolCatalog.base_tools` 当前不可变(`session/tool_catalog.rs:98-103`),refresh 按 dynamic generation 短路(`:181-195`)。B 必须增加明确的准入后静态 MCP 更新接口,不可盼 collect_tools 自动重跑。 +- 选择默认无贡献的同步 `startup_tool_update` hook:只返回本次准入的整批静态 MCP bridge;不重跑整链 collect、不覆盖非 MCP 有状态工具。runner 在首批整链成功/reconcile 后原子更新 catalog 与 working map;ToolSearch 仍在原 before_reason_catalog 重绑。 +- 动态冲突目录也需同步:`stage_builder/tools.rs:48-60` 在初始装配注册,而 `mcp/dynamic/registry.rs:112-132` 对重复 register 是 no-op。B-06 必须重验/更新晚到静态工具的碰撞目录,不能以旧目录接受冲突 load。C 不承担此宿主接线。 + +## 3. 事实基线 + +### 3.1 全量状态机与 ready 证据缺口 + +| 层 | 全量状态 | file:line | +| --- | --- | --- | +| ClientStatus | `Connected`、`Failed(String)`、`Disconnected`、`Disabled`、`Uninitialized`;**无 Connecting/Reconnecting 变体** | `peri-middlewares/src/mcp/client/types.rs:11-20`,标签穷尽映射 `client/status.rs:34-41` | +| McpInitStatus | `Pending`、`Initializing { connected,total }`、`Ready { total }`、`Failed(String)` | `client/types.rs:22-29` | +| OAuthStatus | `None`、`Authorized`、`NeedsAuthorization`,授权状态不等于 ready | `client/types.rs:31-41`;`status.rs:119-152` 的 NeedsAuthorization 同时写 ClientStatus::Failed | +| pool lifecycle | `0=Open → 1=Closing → 2=Closed`;Closing 禁任务与连接提交 | `client.rs:51-53`;`lifecycle.rs:166-220,329-332` | +| service wrapper | `Default`、`Channel`、`Shared` 是运行服务容器;`Closing`、`Closed` 无有效 peer;test-only `Controlled` 无协议 peer | `service.rs:65-93,140-193` | +| task owner / task | owner `Open/Closing/Closed`;task `Running/Stopping/Finished`;不是协议状态 | `task_scope.rs:72-84` | + +转换和关键事实: + +1. new_pending 是空 clients/configs + Pending + initialized=false(`client.rs:106-148`)。**空 map 不证明“无 System MCP”**,配置在 async 初始化内才装载。 +2. initialize_config 先注册 configs、置 Initializing(`initialize.rs:82-94`),当前按配置串行连接(`:96-97`)。禁用→Disabled(`:98-118`);transport 构造/启动、握手失败/超时→Failed(`:120-160,270-290`)。连接中的无 handle server 被 `all_server_infos` 合成为 Uninitialized(`status.rs:183-231`)。 +3. `serve_client_auto` 成功才表示 rmcp lifecycle 完成。缺省 Auto,显式 2026-07-28 用 Discover、不回退 legacy(`client/transport.rs:13-56`);client capabilities 在 `service.rs:199-211`,server 协商结果在 `peer.peer_info()`。 +4. 初次连接 `initialize.rs:218-225` **tools/list、resources/list 错误 unwrap_or_default**,随后 `:240-258` 仍 commit Connected。test-only initialize 同样退化(`:490-529`)。所以 Connected 不能证明 discovery 或必需工具校验成功。 +5. tools/list 清单存 `McpClientHandle.tools: Vec`(`types.rs:83-103`);`get_tools` missing 返回空列表(`client.rs:181-187`),不能作完成证据。`list_all_tools_cached` 按协商 cache-version 返回缓存或 `peer.list_all_tools()`(`client/cache.rs:254-285`),没有本层整体 timeout。 +6. 全部待 OAuth 可 pool Ready{0};部分失败但至少一台成功也可 Ready(`initialize.rs:295-335`)。mark_initialized 只启通知(`status.rs:240-275`)。`client.rs:251-267` snapshot 将它映射 initPhase=ready;这不是 System ready。 +7. reconnect 先关闭旧 service 再删 handle(`reconnect.rs:43-61`),没有 Reconnecting;窗口可能先见旧 Connected、后合成 Uninitialized。tools/list 用 `?`(`:213-219`)但失败可能仅返回 Err,没有 Failed handle;成功 commit 新 Arc(`:230-255`)。真实文件是 `mcp/reconnect.rs`,不存在 `client/reconnect.rs`。 +8. OAuth 是独立提交路径:工具发现错误返回,成功 commit(`client_oauth.rs:175-229`)。remove 删除 handle/config(`lifecycle.rs:116-127`);disable 替换 Disabled(`:129-164`);shutdown 置 Disconnected 并清 peer(`:224-237`)。所有路径都需使 readiness evidence 失效/更新。 + +### 3.2 McpMiddleware 现有语义 + +- collect_tools(`middleware.rs:366-385`)从 **deployment tool_pool** 同步构建 bridge,再加 resource/discover;projection pool 不等价于 tool_pool(`:31-37,64-69`;`assembly/mcp.rs:26-70`)。 +- first_turn_reminder(`:390-425`)仅 connection summary 入队,不等待。 +- before_agent(`:439-444`)仅 ensure_discovery 后 Ok;这是 Skills/commands 发现,不是 initialize/tools/list 准入;其执行体按 handle identity 去重/spawn(`:113-178`)。 +- before_model(`:449-454`)仅 drain 状态通知。 +- prewarm_discovery(`:204-217`)供 session/new 预热,未连接 server 空跑;attach_connection_notifier(`:219-259`)是覆盖式单 notifier,以连接文本触发发现,弱引用 pool。**不得复用 UI notifier 作可靠 readiness watch**。 +- McpTaskOwner 只提供 keyed 准入/abort/join:key `task_scope.rs:30-40`,spawn `:272-323`,stop `:326-351`;Finished 不是协商成功证据。 + +### 3.3 timeout 与配置 + +真实 McpServerConfig 定义在 `peri-acp-types/src/plugin.rs:41-77`,`mcp/config.rs:9-12` 仅 re-export。已有 command/args/env/url/headers/oauth/disabled/protocolVersion/subscriptions/source;**没有连接 timeout 或 System ready timeout 配置**。protocolVersion 只选 lifecycle。 + +检索整个 mcp 目录的 `Duration::from_secs` 后确认: + +| 值 | 用途 | file:line | +| --- | --- | --- | +| 10s / 30s | stdio / HTTP connect | `client.rs:102-103`;`initialize.rs:128-135`;`reconnect.rs:69-76` | +| 5s | service/process shutdown | `client.rs:104`;`lifecycle.rs:29,79-83` | +| 120s | tool call、resource read、OAuth callback | `tool_bridge.rs:46`;`resource_tool.rs:40`;`callback_server.rs:10,43-52` | +| 30s | agent resource read、skill resource read / skills list | `agent_registry.rs:17`;`skill_discovery.rs:77,81` | +| 30s | Dynamic MCP drain | `dynamic/registry.rs:80` | +| 300s / 120s | Apps binding/raw-result TTL,不是启动 timeout | `apps.rs:17-18` | +| shutdown + 1s | staged cleanup | `dynamic/staged_connection.rs:259` | +| 1/2/4s,3 次 | subscription retry backoff,不是 readiness | `client/subscription.rs:67-69,193-201` | +| 2s / 5s / 60s | 测试保护时间或 cache fixture TTL | `client/transport_test.rs:49,83,146`;`client/service_test.rs:80,98,104,136`;`client_test.rs:581`;`resource_cache_test.rs:19` | + +没有可复用的 pool“等待全部依赖 ready”的配置上限;McpTaskOwner shutdown 等待 tracker join(`task_scope.rs:208-219`)也不是 startup timeout。 + +**决策:新增每个 server 的 `system_mcp_timeout: Option`,JSON key 同名,单位毫秒,缺省 30000,合法范围 1..=600000,由 A 负责 serde/验证/透传。** 现有 transport 10s/30s 保留;启动外层从 1R 入场统一计时,不在 initialize/list/C 校验之间重置。多个 system 并发等待各自 deadline,不能串行相加。manifest 未知时用 30000ms bootstrap 上限;Loaded 且 system 集合为空立即通过,不等普通 transport。manifest 的 Loaded 不是 System Ready。 + +### 3.4 测试脚手架 + +- `middleware_test.rs:12-52,64-100,395-475` 手工插 Connected + peer=None,适合通知/负向状态测试,**不能证明 System ready**。 +- `client/service_test.rs:31-85` 使用 duplex + JSON-RPC 假 server + serve_client_auto 得到真实 RunningService;DelayedClose(`:9-29`)仅延迟 close。可同样用 oneshot/Notify 闸住 initialize/list。 +- `client/transport_test.rs:5-55,60-92` 验证 Auto fallback 与显式版本不回退,覆盖两种 handler。 +- `initialize_test.rs:3-25` 有 Node stdio 假 server;`:63-95` 直接注入合并配置和真实 owner/spawner,不读取用户配置。新增 transport 层首选 Rust 内存 fixture;完整宿主装配复用 Node fixture或 loopback HTTP,明确 Node 前置条件。 +- Agent `stages_test.rs:9-18,1186-1230` 可构造真实 Session/StageContext、计数 LLM/hook;ACP `host/executor_flow_test.rs:1-8,83,1450-1485` 有完整装配、sink 与 failure/terminal/done 顺序断言。 +- 已有 futures/tokio/parking_lot/rmcp(`peri-middlewares/Cargo.toml:14-23,34-43`);Agent dev tokio 有 test-util(`peri-agent/Cargo.toml:33-35`)。不增生产库;middlewares 单包测试不能假设启用了 paused clock,使用明确闸门与短 timeout。 + +## 4. 接口冻结 + +以下为拟新增 API,不是现存实现。A/C 开工前须确认 cross-plan 接口,不能并行编辑共享文件。 + +### 4.1 A ↔ B:配置清单 + +- A 提供 `system_mcp`、`system_mcp_tools` 与上述 `system_mcp_timeout`,保留原始 server identity,配置错误不退化空集合。 +- B 的 pool manifest 状态为 `Pending / Loaded / Failed`,完整 configs 一次性发布后才 Loaded。run_initialize 消费 A 的严格 Result,失败写 Failed 并唤醒 waiter。 +- A 拥有 plugin/config 类型及 loader;B 拥有 initialize 调用点。`config.rs:83-85` 的 unwrap_or_default 与 `:238-245` 合并时 warn/empty 必须由 A消除 system 相关静默退化,否则本契约不可成立。 + +### 4.2 连接/协商完成接口 + +新增 `mcp/client/readiness.rs`,由 B 的 client.rs 声明/re-export,不占 C 的 mcp/mod.rs: + +```rust +pub(crate) struct SystemMcpRequirement { + pub server: String, + pub required_tools: Vec, + pub timeout: std::time::Duration, +} +pub(crate) struct NegotiatedSystemMcp { + pub requirement: SystemMcpRequirement, + pub handle: std::sync::Arc, + pub generation: u64, +} +impl McpClientPool { + pub(crate) async fn await_system_connections( + self: &std::sync::Arc, + cancel: &peri_agent::agent::AgentCancellationToken, + started_at: tokio::time::Instant, + ) -> Result, SystemReadinessError>; +} +``` + +返回 handle.tools 即完整工具清单;成功只表示 transport + rmcp lifecycle + 能力信息 + tools/list 完成,**还不是 System ready**。要求本 generation 的发现成功记录、有效 peer/peer_info、未关闭 service、当前 Arc identity;拒绝旧句柄与 peer=None。证据仅由成功生产提交路径生成,不能用任务 Finished/空 tools 猜测。 + +采用独立 watch revision:先 subscribe 再检查,变化后重读;不使用 UI notifier,不持 parking_lot guard 跨 await。initialize/reconnect/OAuth/disable/remove/shutdown 都更新证据/revision。已知失败立即 Err;未知/连接中只等待到 deadline;不主动弹 OAuth、不自动跳过或重试。 + +System tools/list 使用本次 live `peer.list_all_tools()`,不以历史 cache 代替启动健康证据;required=[] 仍完成此 round-trip,清单可为空。普通 MCP 保持 cache 策略。必要健康检查限定为 lifecycle、peer 存活、成功 list;不额外强制 ping/resources/skills/subscription 或执行有副作用工具。 + +### 4.3 B ↔ C:最终检查与 candidate + +定义在 middleware.rs: + +```rust +pub(crate) struct SystemReadySnapshot { + pub negotiated: Vec, + pub bridges: Vec, +} +impl McpMiddleware { + pub(crate) async fn await_system_ready( + &self, + ) -> Result; +} +``` + +该函数完成全部 MCP/必需工具检查,返回**待目录提交的 candidate**;不发外部 ready。执行顺序: + +1. before_agent 最前捕获 started_at,调用 tool_pool.await_system_connections,不以 projection pool 作依赖事实源。 +2. 用 C 的 `build_typed_tool_bridges(&self.tool_pool)` 构建整批静态 bridge;`prepare_system_tools(typed, &required)`,其中 required 为 `BTreeMap>`,来自 negotiated requirement,包含空数组。 +3. 构建前后核对 system Arc/generation、open、cancel/deadline;代际变化返回 ConnectionChanged,不无限重试。C 同步检查返回后也比较 deadline,防止未 yield 的校验漏掉超时。 +4. C 返回整批静态 bridges(required direct、普通 deferred),不能再 extend 旧 bridge;所有检查成功才保存 candidate,失败不保存部分结果。C 读取清单事实为同代 `NegotiatedSystemMcp.handle.tools`,不再次通过 get_tools 猜测完成。 +5. 无 system 时直接走普通 collect_tools,不产生 startup update。每个新 loop 重验,不把整个 session 永久缓存为 ready;普通工具仍保留每个 turn 的收集机会。 +6. C 接口保持:`prepare_system_tools(Vec, &BTreeMap>) -> Result, SystemToolError>`。C 不负责网络等待,B 接线调用并包装 RequiredTools。 + +### 4.4 错误类型与安全文案 + +在 client/readiness.rs 定义 crate 可见 thiserror 类型: + +```rust +pub(crate) enum SystemReadinessError { + ConfigurationUnavailable, + ConfigurationFailed, + PoolClosed, + Disabled { server: String }, + AuthorizationRequired { server: String }, + ConnectionFailed { server: String }, + NegotiationIncomplete { server: String }, + ToolDiscoveryFailed { server: String }, + ConnectionChanged { server: String }, + Timeout { server: String, timeout_ms: u64 }, + Cancelled, + RequiredTools { source: crate::mcp::system_tools::SystemToolError }, + CatalogPublicationFailed, +} +``` + +| 变体 | 稳定 Display | +| --- | --- | +| ConfigurationUnavailable | `System MCP 启动失败:配置清单在 30000ms 内未就绪` | +| ConfigurationFailed | `System MCP 启动失败:配置加载或校验失败` | +| PoolClosed | `System MCP 启动失败:连接池正在关闭或已关闭` | +| Disabled | `System MCP "{server}" 启动失败:服务器已禁用` | +| AuthorizationRequired | `System MCP "{server}" 启动失败:需要完成授权` | +| ConnectionFailed | `System MCP "{server}" 启动失败:transport 或协议初始化失败` | +| NegotiationIncomplete | `System MCP "{server}" 启动失败:缺少有效协议协商证据` | +| ToolDiscoveryFailed | `System MCP "{server}" 启动失败:tools/list 失败` | +| ConnectionChanged | `System MCP "{server}" 启动失败:连接代际已变化,请重试本次输入` | +| Timeout | `System MCP "{server}" 启动超时({timeout_ms}ms),未发布 ready` | +| Cancelled | `System MCP 启动已取消` | +| RequiredTools | `System MCP 启动失败:{source}`,source 为 C 固定模板 | +| CatalogPublicationFailed | `System MCP 启动失败:工具目录发布被拒绝,未发布 ready` | + +保留 C 的 source 链;server/tool 展示必须转义控制字符、限长并清洗敏感形态,不输出 env/headers/URL/协议 payload/schema 默认值。底层失败只保留阶段/安全类别,不把 Failed(String) 原文嵌入 reason,更不记录未经清洗的 cause。 + +Middleware 边界:Cancelled → `AgentError::Interrupted`;其它 → `AgentError::MiddlewareError { middleware: "McpMiddleware".into(), reason: safe_display }`。可见前缀为 `Middleware error: McpMiddleware - ...`(`peri-acp-types/src/error.rs:40-41`)。timeout 不是 cancel,不可映射 Interrupted。 + +### 4.5 Agent 目录提交接口 + +Agent 不导入 MCP concrete 类型。新增 DTO 定义于 `session/tool_catalog.rs`: + +```rust +pub struct StartupToolUpdate { + pub tools: Vec>, + pub required_names: Vec, +} +// Middleware trait,默认 None。 +fn startup_tool_update(&self) -> Option; +// 提交成功后收口准入事实;默认 Ok(()),失败仍阻止后续 stage。 +fn on_startup_tools_committed(&self) -> AgentResult<()>; +// 本次失败时清除候选;默认空实现,不发布 ready。 +fn discard_startup_tool_update(&self); +// MiddlewareChain,对上述接口按链序转发。 +pub(crate) fn collect_startup_tool_updates(&self) -> Vec; +pub(crate) fn run_on_startup_tools_committed(&self) -> AgentResult<()>; +pub(crate) fn discard_startup_tool_updates(&self); +// SessionToolCatalog,同步、fallible、先验证后原子提交。 +pub fn replace_static_mcp_tools( + &self, + update: StartupToolUpdate, +) -> Result, CatalogRefreshError>; +``` + +McpMiddleware 仅有 candidate 时返回整批 prepared bridge + required effective names;无 system 返回 None。runner 在首批整链成功/reconcile 后:合并更新(禁止重复 provider 的冲突目标)→ catalog 提交 → working map 替换 → committed 回调 → 返回 Ok。任何失败 discard candidate、返回 Err,不进入 Compact。committed 回调再次检查 deadline/cancel/generation,并记录本次准入;若此时失败,该 turn 永不启动,已提交的内部 catalog 随本次 StageContext 丢弃,不发 ready、不写宿主共享工具表。 + +catalog 使用一个内部状态锁同步 base/published;先验证来源/alias/原 tool_filter/dynamic overlay/required_names,再提交,不能先改 base 后失败。StaticMcp 替换不得覆盖 core/middleware 或已发布动态身份;必需项被策略过滤或动态遮蔽则拒绝,不提升权限。子 agent 的 tool_filter 事实源见 `session/subagent/v2_bridge.rs:260-271`。 + +`CatalogRefreshError` 新增 `InvalidStartupSource`、`RequiredToolUnavailable`、`StartupRegistrationRejected`,固定安全文案;runner 映射为前述目录发布 MiddlewareError。动态碰撞注册以 catalog 构造时注入的同步回调接入: + +```rust +pub type StartupCatalogRegistration = std::sync::Arc< + dyn Fn(Vec) + -> Result<(), CatalogRefreshError> + Send + Sync +>; +``` + +由 stage_builder/tools.rs 捕获已有 deployment/session_id,调用 register_catalog;registry 锁内验证现有 live entries 和候选 static catalog,再替换,occupied 不再直接 no-op。catalog 完成本地所有 fallible 校验后调用注册回调,回调成功后的本地提交不再失败;锁序 catalog→registry,回调不得重入 catalog。无回调的子 agent 沿用 session capability 与自身 filter,不改父 session 的注册目录。 + +### 4.6 ready 真值表与发布边界 + +| 状态/证据 | 协商/发现成功 | 本次 System ready | 动作 | +| --- | --- | --- | --- | +| manifest Pending/未知 | 否 | 否 | 有界等待 manifest;错误不能当空集合 | +| Loaded,无 system=true | 不要求 | 不建立 System ready;准入可通过 | 立即继续,普通 pending/failed 不阻塞 | +| system 无 handle/Uninitialized/connecting/reconnecting | 否 | 否 | 等待或已知失败 Err,绝不默认 true | +| Failed,含 initialize 错误 | 否 | 否 | ConnectionFailed/ToolDiscoveryFailed | +| Failed + NeedsAuthorization | 否 | 否 | AuthorizationRequired | +| Disabled | 否 | 否 | Disabled,不跳过 | +| Disconnected | 否 | 否 | 已知显式 reconnect 中等新代;否则 ConnectionFailed | +| Connected,但无有效 peer/info/本代成功记录,或 service 关闭 | 否 | 否 | 等待尚未完成 discovery 或 NegotiationIncomplete,不能信 status | +| 协商成功、tools/list 未结束 | 否 | 否 | 等待到 deadline | +| tools/list Err/解析错误 | 否 | 否 | ToolDiscoveryFailed,不转空 Vec | +| list 成功,必需工具检查未完成 | 是 | 否 | 调 C,不发布 | +| C 缺工具/schema/visibility/collision Err | 是 | 否 | RequiredTools | +| required=[],list 成功 | 是 | catalog 提交且最终复核后是 | 不增加 direct 工具 | +| C 成功、catalog/策略/冲突注册拒绝 | 是 | 否 | CatalogPublicationFailed,无部分发布 | +| 所有依赖同代、open、未超时/取消,C+catalog+最终复核成功 | 是 | 是 | 放行后续 stage | +| pool Closing/Closed、代际变更、deadline/cancel | 否或失效 | 否 | 明确错误/Interrupted | +| 仅 McpInitStatus::Ready 或 initialized=true | 未知 | 否 | 不是准入证据 | + +ready 的线性化点为目录提交后 committed 回调的最终复核。连接 Connected 通知只能表示连接,不能改名当 ready。含 System MCP 的 legacy aggregate `McpInitStatus::Ready` 不得早于依赖验证;保留 discovery 完成与准入完成两个事实,仅两者满足才更新 pool.init_status **和外部 status_tx**。普通-only 的原面板语义保留。已失败/超时的 prompt 不得被迟到后台成功翻成成功;之后的新 prompt 可以重新准入。全局连接显示与每次 prompt 准入不可相互替代。 + +## 5. 任务表 + +任务间文件不重叠;每行含该任务生产与测试文件。估算是未来实现规模。B-01/02/03 与 B-04/05/06 按依赖串行集成,不能局部绿色就宣告完成。 + +| Task ID | 标题 | 目标文件 | 改动摘要 | 验证命令(未来) | 预估 diff 规模 | 文件冲突面 | +| --- | --- | --- | --- | --- | --- | --- | +| B-01 | 连接证据与等待 | `peri-middlewares/src/mcp/client.rs`;`client/readiness.rs`、`client/readiness_test.rs`(新增);`client/lifecycle.rs`;`client/status.rs` | manifest/发现证据、watch、await_system_connections、typed error;失效/代际/open 检查;维护 status sender | `cargo test -p peri-middlewares --lib mcp::client` | 300–500 行 | B 独占,模块声明在 client.rs,不碰 C 的 mcp/mod.rs | +| B-02 | 初始/重连/OAuth 严格发现 | `peri-middlewares/src/mcp/initialize.rs`;`initialize_test.rs`;`reconnect.rs`;`client_oauth.rs` | 消费 A 严格 Result;原子 manifest;system 并发优先推进,避免普通慢连接排队;live list 错误/超时不 default;同代证据提交;test initialize 不保留第二套宽松逻辑 | `cargo test -p peri-middlewares --lib mcp::initialize`;`cargo test -p peri-middlewares --lib mcp::client` | 200–350 行 | A 不修改 initialize.rs;若原 A 已占用,父计划先移交 | +| B-03 | McpMiddleware 闸门/C 接线 | `peri-middlewares/src/mcp/middleware.rs`;`middleware_test.rs` | await_system_ready、C 调用、candidate/update/commit/discard、collect_tools 替换;保留 discovery/通知;安全错误 | `cargo test -p peri-middlewares --lib mcp::middleware` | 250–450 行 | **B 独占,C 不拥有 middleware.rs**;C 仅提供函数 | +| B-04 | 首批 Err 终止 loop | `peri-agent/src/agent/stages/mod.rs`;`stages_test.rs` | 首批 Err 返回 LoopResult,Interrupted 分类,成功才置 flag;一次/链序回归 | `cargo test -p peri-agent --lib agent::stages` | 80–150 行 | **必须改 peri-agent,B 独占** | +| B-05 | startup catalog 原子提交 | `peri-agent/src/middleware/trait.rs`;`middleware/chain.rs`;`agent/stages/middleware_runner.rs`;`middleware_runner_test.rs`;`session/tool_catalog.rs`;`tool_catalog_test.rs` | DTO/hook/runner、static base 更新、filter/alias/dynamic overlay、required 检查与 commit/discard | `cargo test -p peri-agent --lib middleware_runner`;`cargo test -p peri-agent --lib tool_catalog`;`cargo test -p peri-agent --doc` | 250–420 行 | B 拥有上述全部 Agent 文件;C 不碰,不导入 MCP concrete 类型 | +| B-06 | 动态冲突目录配套 | `peri-agent/src/session/exec/stage_builder/tools.rs`;`peri-middlewares/src/mcp/dynamic/registry.rs`;`dynamic/registry_test.rs` | 注入注册回调;重复注册重验更新而非 no-op;保持 runtime 隔离/过滤,不改实例语义 | `cargo test -p peri-middlewares --lib mcp::dynamic::registry`;`cargo test -p peri-agent --lib session::exec` | 100–180 行 | B 独占,父计划如有 dynamic 工作需串行合并 | +| B-07 | 宿主/ACP 验收 | `peri-acp/src/host/executor_flow_test.rs`;`host/prompt_test.rs` | 真 assembler + controlled transport + counting model;错误/成功工具视图、终态、wire allowlist | `cargo test -p peri-acp --lib host::executor_flow`;`cargo test -p peri-acp --lib host::prompt` | 180–300 行 | B 仅拥有这些测试;不改 ACP 生产投影,不与 C system_tools_test 重叠 | + +A 单独持有 timeout serde/严格加载/合并/hash/透传与其测试;C 单独持有 tool_bridge/system_tools 与 mcp/mod.rs 模块声明。配置新字段导致上表文件中的 literal 变化由 B 处理,不能成为 A 越界修改理由。 + +## 6. 验证计划 + +本次未运行 cargo build/test/check。以下为实施后的测试;遵循 `docs/standards/testing.md:12-19`,fixture 局部定义,不建共享 mock 框架。 + +### 6.1 MCP transport seam + +位置:`peri-middlewares/src/mcp/client/readiness_test.rs`。采用 duplex + serve_client_auto 真实 SDK,JSON-RPC server 仅替换外部依赖;oneshot 确认请求到达再控制响应,不靠 sleep 猜时序。 + +| 测试函数 | 可观察断言 | +| --- | --- | +| `system_ready_waits_for_initialize_and_tools_list` | 卡 initialize,再只放 initialize 卡 list;两阶段 waiter pending、无 ready;list 成功只得到 negotiated,尚非最终 ready | +| `system_ready_rejects_initialize_error` | initialize JSON-RPC error → Failed evidence/ConnectionFailed,无 Connected/ready;回归显式 Discover 不 fallback | +| `system_ready_rejects_tools_list_error_instead_of_empty` | list Err/畸形结果,空 required 也 Err,不出现默认空列表成功 | +| `system_ready_timeout_is_terminal_without_fallback` | 不响应 initialize/list,deadline 返回错误,无快照;迟到释放不能改变已失败结果 | +| `system_ready_rejects_connected_without_protocol_evidence` | 旧 Connected+peer=None fixture 被拒绝,未知状态不 ready | +| `system_ready_ignores_non_system_pending_and_failed` | manifest 已 Loaded,ordinary pending/failed 不等待;混合场景仅 system 完成即可通过连接屏障 | +| `system_ready_does_not_treat_unloaded_manifest_as_empty` | new_pending 空 map 不放行,Loaded(empty) 才通过;加载失败明确 Err | +| `system_ready_rechecks_generation_and_pool_close` | reconnect/disable/shutdown 或提交前换代,无旧 Arc ready | +| `system_ready_parallel_deadlines_do_not_accumulate` | 多个 timeout 从同一入场时间计,最早失败及时终止,不依 HashMap 顺序叠加 | + +位置 `mcp/initialize_test.rs`: + +- `system_initialization_is_not_queued_behind_ordinary_transport`:普通 server 卡实际 transport,system 仍收到 initialize/list 并完成;不能只手工写 Pending 代替真实并行初始化。 +- `system_initialization_never_emits_aggregate_ready_before_validation`:观察实际 status/watch/notifier,list 已成功但 C/catalog 未提交,无含 system 的 Ready;ordinary-only 保持原行为。 + +### 6.2 必需工具与 middleware seam + +位置 `mcp/middleware_test.rs`,运行真实 before_agent 与 C 函数,声明来自 runtime list。 + +- `system_mcp_required_tool_check_must_finish_before_ready`:握手/list 成功但缺必需工具,RequiredTools(MissingTool)→MiddlewareError;candidate/目录未 ready。不能以 Connected 阳性 fixture 代替检查。 +- `system_mcp_invalid_schema_aborts_startup`:实际 list 返回非法 schema,C InvalidSchema,无部分 direct、无模型调用。 +- `system_mcp_empty_required_still_waits_for_connection`:required=[] 仍等待 initialize/list;成功 direct 增量 0,失败仍 Err。 +- `system_mcp_false_does_not_block_before_agent`:false/缺省各一 case,ordinary 永不响应但 before_agent 可结束,skill discovery 可异步进行。 +- `system_mcp_cancelled_startup_never_publishes_ready`:取消→Interrupted,与 timeout fatal 区别;不关闭 deployment pool/其它 session 任务。 +- `system_mcp_gate_uses_tool_pool_not_projected_pool`:projection 伪 Connected 而 deployment pending/failed,仍阻塞/失败;成功身份来自 tool_pool。 + +### 6.3 Agent RCRA 与实际工具视图 seam + +位置 `peri-agent/src/agent/stages/stages_test.rs`: + +- `before_agent_error_stops_before_compact_and_reason`:真实 loop 返回 MiddlewareError;Receive 已接受输入,但 Compact/Reason/Act observer 和模型计数为 0,后续 middleware 不执行。 +- `before_agent_interrupted_is_not_fatal_error`:Interrupted 分类正确,模型计数 0。 +- `before_agent_runs_once_per_loop_after_receive`:工具回合/后续输入不重跑初始化,保留链序与附件转换。 + +位置 `middleware_runner_test.rs` / `session/tool_catalog_test.rs`: + +- `startup_catalog_update_survives_first_reason_refresh`:初始 catalog 无 required,提交后运行真实 run_reason,LLM 入参含 required effective name,普通仍 deferred。 +- `startup_catalog_update_is_atomic_and_preserves_policy`:schema/策略/dynamic 遮蔽或注册拒绝,整批失败,旧表不部分修改、不扩大权限。 +- `startup_catalog_update_keeps_non_mcp_tools_and_dynamic_generation`:Search/Execute/SubAgent 有状态对象不被覆盖,动态 overlay/generation 保持原事实源。 +- `startup_commit_rechecks_deadline_and_generation`:C 成功后、catalog 提交期间超时/换代,committed 回调失败,loop 仍未进入 Compact,不发布 ready。 + +### 6.4 宿主与用户可观察 seam + +位置 `peri-acp/src/host/executor_flow_test.rs`: + +- `system_mcp_startup_failure_has_fatal_prompt_terminal`:ProductionChainAssembler + 实际 MCP fixture,initialize/list/missing-tool/timeout 矩阵;PromptResult.ok=false、failure Internal、安全明确文案、模型 0;AgentExecutionFailed→TurnEnded(Error)→done 各一次,不残留 loading。 +- `system_mcp_ready_reaches_first_model_with_required_tools`:全部 gate 放行后,第一次 ModelRequest.tools 出现 required effective names,无需 ToolSearch;普通 deferred 不直接出现,空 required 不新增 direct。 +- `ordinary_mcp_pending_does_not_delay_prompt_model`:ordinary 永久 pending,模型仍到达;不因 pool connecting 把普通 prompt 超时失败。 + +位置 `host/prompt_test.rs`: + +- `system_mcp_middleware_error_projects_standard_acp_error`:通过真实 ExecutionFailure::from_agent_error 与 prompt_wire_response,断言 -32000、data.kind=internal、无 status/diagnostic/raw cause、message 含失败类别;cancel 保持成功 Cancelled response。 +- `system_mcp_error_projection_contains_no_transport_secrets`:动态构造非真实凭据的危险形态,wire 不含 URL query/header/env/schema payload,不以“调用脱敏函数”代替输出断言。 + +**通过标准:** transport 失败矩阵、首批 Err 传播、首次 Reason catalog、宿主 prompt error 四层全部通过,才能声称验收 2 完成。仅静态表、mock Connected、单个 hook 单测或 ACP 纯投影测试都不充分。最后执行目标测试及 git diff --check,报告实际结果;局部绿色不等于五实例迁移完成。 + +## 7. 风险与未知 + +1. **首批吞 Err 已确认。** B-04 不可省略;全链初始化 Err fatal 会暴露其它 middleware 过去被掩盖的失败,要回归附件/skills/preload,不因回归压力保留 MCP fail-open。 +2. **无真实 server 的测试可行。** 现有 duplex 已跑真实 rmcp lifecycle;ready=释放 initialize+list,failed=协议 error/关闭 transport,timeout=持 stream 不响应。本次新增 gated fixture 未编译;测试必须 join 假 server,并按 pool begin-close→owner abort/join→pool shutdown 清理,不留 orphan tasks。 +3. **A 接口尚待确认。** 已读取 C 的规划并采用其函数/错误名;A 文档在相关侦察时尚未出现,timeout 单位/范围、严格 loader Result 是跨 plan 依赖,不是假称已经协商一致。 +4. **catalog 冲突面明确扩大。** static base 不可变、dynamic 重复注册 no-op、child filter 均有证据。B-05/06 是必要配套,父计划必须分配上述文件;不能模糊写“由 C 接线”。锁序 catalog→registry,注册回调不可重入 catalog;实现时必须审查父/子与多 session 并发。 +5. **ready 的层次。** legacy aggregate Ready 不是 System gate;本计划要求独立发现证据、candidate、catalog commit 和最终复核,含 system 的外部 Ready 不得提前。pool.init_status 与 status_tx 都需统一更新。已成功的其它 session 不代表当前 prompt 已准入。 +6. **配置未发布窗口。** host `assemble.rs:451-464` async spawn Initialize,可能先等 activation;不能从空 pool 放行。manifest Pending 有上限;false-only 在配置 Loaded 后不等待 transport。若父计划要求连配置读取也零等待,需 A 将可信 manifest 前置到宿主构造,并另分配 host/TUI 文件;本文不假称现有接口已支持。 +7. **普通 server 间接阻塞。** initialize 当前串行(`:96-97`),只做 waiter 会被普通慢连接拖住;B-02 必须优先并发推进 system。单 prompt timeout 不 abort deployment 共享初始化;旧 prompt 的迟到结果不得发布其 ready。 +8. **协议与健康范围。** 不固定唯一握手版本,保留 Auto/Discover;空 required 仍要求 live list;resources/skills/subscription 不自动升级为必需条件。新增 server-specific 健康检查需要另外声明,不猜测工具、不执行副作用探活。 +9. **持续健康不是本契约。** 同代启动原子发布不保证远端随后永不掉线。新 loop 重验、提交前换代拒绝、工具调用沿用既有失败路径;不以一次准入宣称长期健康。 +10. **运行时核实缺口。** 本次禁止 Cargo,故新 API 可编译性、fixture 调度稳定性、真实外部 server 兼容性均待实施验证。状态/调用链结论来自源码,未将静态侦察冒充运行结果。 + +## 8. 非目标 + +- 不迁移五个真实 MCP,不新增它们的 transport/process/凭据/capability root,不宣称验收 5/6 完成。 +- 不改 Permission/HITL/Hook/SubAgent/Workflow/Goal/PTC 架构,不绕过 allow/disallow、effective name 或 dynamic policy。 +- 不重排 blueprint,不新接 on_session_start,不把通知 hook 当准入 hook,不新增 Agent 对 peri-middlewares 的依赖。 +- 不把普通 MCP 改为阻塞启动,不把 ordinary discovery 失败升级为 system failure,不强制 OAuth UI/skills/subscription 就绪。 +- 不造通用重试/健康监控框架,不用 shutdown timeout 或 protocolVersion 代替启动配置,不把未知/空清单/任务结束/旧缓存当 ready。 +- 本次只写本文,不写生产/测试代码、其它文档,不运行 cargo build/test/check,不提交 git。 diff --git a/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-c-injection.md b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-c-injection.md new file mode 100644 index 000000000..25a635e41 --- /dev/null +++ b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-c-injection.md @@ -0,0 +1,248 @@ +# MCP adaptation v4-part-1 — sub-plan C:System MCP 一等工具注入 + +> **主 plan v2 覆盖(优先于本文)**:见 [`2026-09-25-mcp-adaptation-v4-part-1-plan.md`](2026-09-25-mcp-adaptation-v4-part-1-plan.md) §5。本文与主 plan 冲突处一律以主 plan 为准,涉及本文件的具体覆盖:**R8**(所有新增 seam 统一 `pub(crate)`,本文的 `pub` 表述作废)、**R9**(「通过真实 catalog / `run_reason` 验证首个 Reason」上移到 `peri-acp` host seam 由 B-07 承担;`build_session_tool_view` 是 `pub(super)`,`peri-middlewares` 无法调用)、**R15**(C-INJ-03 已纳入主 plan 任务表)、**R2 相关**(`McpMiddleware` 的闸门由 B-03 接线,本文不改 `middleware.rs`)。 + +## 1. 元信息 + +- 日期:2026-09-25;状态:实现规划,未实施、未运行测试。 +- 权威目标:`docs/design/mcp-adaptation-v4-part-1.md:33-65,222-234`;规划前已完整阅读该文件 244 行。 +- 本 sub-plan 主责验收 **3、4**,并遵守 **7** 的状态表述;整个 workflow 只实施 **1–4、7**。五个 MCP 实例的真实迁移不在此次范围。 +- 依赖配置 sub-plan(以下称 A)提供合法、按原始 server identity 分组的 `system_mcp_tools`;依赖 B 提供 1R ready 闸门、发现完成证据、timeout、失败传播及目录发布。A/B 的实际接口尚未在仓库出现,不把建议接口写成现存实现。 +- C 拥有 `peri-middlewares/src/mcp/tool_bridge.rs`、新增 `system_tools.rs` / `system_tools_test.rs`,以及 `mcp/mod.rs` 的一行模块声明。**C 不修改 `mcp/middleware.rs`,不修改 Agent、ToolSearch、Dynamic registry 或配置文件。** +- 可验证结果:所属 namespace 内逐项解析必需工具;同一 bridge 被提升为 direct 而非另注册一个副本;首个 Reason 的实际 LLM tools 含必需工具;普通 deferred 仍可搜索;缺失/schema 错误阻止启动;空数组不增加 direct 工具。 +- 本次规划唯一写入文件就是本文件;文中 cargo 命令均为未来实施后的验证命令,本次未执行。 + +设计契约逐条落点: + +| 设计契约 | 实施责任与落点 | +| --- | --- | +| initialize、能力协商、tools/list 完成后逐项确认 | B 先证明 discovery 完成;C 的 `prepare_system_tools` 对该快照逐项确认;不能以 Connected 或空 tools 推断成功 | +| 全部 ready 后直接注入,不需搜索 | C 批量验证成功后才修改 bridge direct 标志;B 原子发布完整结果;实际断言在 `run_reason` 的 LLM 入参 | +| 所属 namespace 匹配,继续 effective name | 精确匹配 `(原始 server name, 原始 tool name)`;展示名复用现有命名函数,不注册裸名 alias | +| 缺失/schema/initialize/timeout 均阻止启动 | C 产出工具解析错误;B 包装为 `SystemReadinessError` 并从 1R 返回错误;失败不发布结果、不调用 LLM | +| 空数组只要求 ready | C 对该 server 不做必需工具 schema 检查、不提升任何工具;B 仍做连接、初始化与 discovery 等 ready 检查 | + +## 2. 事实基线 + +### 2.1 可见性与真实分类 seam + +1. `BaseTool::is_direct()` 默认为 `false`,`visible_to_model()` 默认为 `true`:`peri-acp-types/src/tools.rs:608-618`。两者是独立条件,direct 不代表可以绕过 model visibility。 +2. `McpToolBridge` 当前 **没有 override `is_direct()`**;结构体只有 `model_visible` 等字段(`peri-middlewares/src/mcp/tool_bridge.rs:33-44`),`impl BaseTool` 从 `:179` 开始,`:200-202` 仅 override `visible_to_model()`。当前静态和动态 bridge 均继承 deferred 默认值。 +3. ARC-TOOLS-001 指向 session-local 事实源:`docs/standards/architecture-contracts.md:54-58`。实际产出函数是 `peri-agent/src/session/exec/stage_builder/tools.rs:22-46::build_session_tool_view`,可见性为 **`pub(super)`**,不能由 peri-middlewares 的测试直接调用。 +4. 该函数本身负责过滤残留 middleware 名称、合并工具,**不负责 direct/deferred 分类**。生产调用在 `peri-agent/src/session/exec/stage_builder.rs:456-458`,先 `chain.collect_tools`,再注册 catalog;`:460-466` 才组装 StageContext。 +5. `SessionToolCatalog::finalize` 在 `peri-agent/src/session/tool_catalog.rs:268-276` 以 `is_direct() && visible_to_model()` 生成 `direct_definitions`。真正 Reason 先 refresh、替换 working map、跑目录 hook、pin(`peri-agent/src/agent/stages/reason.rs:22-40`),再以同一条件选择 LLM 入参(`:97-106`)。**不能只断言 bridge 上一个布尔值就宣告验收 3 通过。** +6. ToolSearch 在 `peri-middlewares/src/tool_search/middleware.rs:55-101` 优先读取 local_tools、否则读 shared_tools;只有 `!is_direct()` 进入 deferred_arcs 和 request index;direct 名称传给 Search/Execute 元工具。它只重绑两个元工具,不改写 MCP bridge。`:104-128` 重建共享索引及 prompt contribution,`:150-158` 在 before_agent / before_reason_catalog 均重绑。`ToolSearchIndex::build` 自身不再次分类(`tool_index.rs:189-195`),测试应调用真实 middleware hook,而不是手工先过滤再宣称路径通过。 + +### 2.2 effective tool name:精确规则 + +- `McpToolBridge::new` 保存原始 `tool.name` 为 `tool_name`,`full_name = effective_mcp_tool_name(server_name, tool_name)`:`peri-middlewares/src/mcp/tool_bridge.rs:97-119`。 +- `effective_mcp_tool_name` 的格式严格为 **`mcp__{S(server)}__{S(tool)}`**:`:89-95`。`S` 逐字符保留 ASCII 字母、数字、`_`、`-`,其它字符每个替换成 `_`,**不折叠大小写、不 trim**:`:49-60`。 +- `new_dynamic` 先要求两个原始分量非空且仅含 ASCII 字母数字、`_`、`-`,否则返回 `ToolCallError::Unavailable`,再使用相同 full_name 格式:`:122-154,171-176`。它不是另一种 `DynamicMCP.xxx` bridge 命名格式。 +- `BaseTool::name()` 返回 `full_name`;`mcp_server_name()` 返回 **`Some(&原始 server_name)`**,不是 sanitized server,也不是带 `mcp__` 前缀的字符串:`:179-194`。调用 MCP 时使用原始 `tool_name`:`:230-233`。 +- `DynamicMcpMiddleware::collect_tools` 只注册控制工具 `DynamicMCP`:`peri-middlewares/src/mcp/dynamic/tool.rs:12,51-62,114-121`。其 bound 操作使用 canonical action 的 policy name(`:273-289`),不是把各 MCP 工具都命名为控制工具的方法。 +- 配置解析决策:`mcpServers.workspace.system_mcp_tools = ["Read"]` 只匹配 `client.name == "workspace"` 且 `tool.name == "Read"` 的那一项;暴露 **`mcp__workspace__Read`**。`read` 不匹配 `Read`;不剥离 `mcp__workspace__`,不接受有效名作为原始名的替代写法,不跨 server 搜索,不使用搜索的大小写规则。若 MCP 本身真的声明原始名 `mcp__workspace__Read`,只有同字面配置才匹配,并正常再次加 server 前缀。 +- 已有净化回归 fixture:`peri-middlewares/src/mcp/tool_bridge_test.rs:33-60`,如 `plugin.ctx/web.reader → mcp__plugin_ctx__web_reader`。 + +### 2.3 桥接集合、去重与动态 shadow + +- `build_tool_bridges` 遍历所有 connected client 的所有 tools,每项创建一个 bridge,并保留 `handle_generation` 和 `app_binding_leases`:`peri-middlewares/src/mcp/tool_bridge.rs:369-382`;connected 过滤见 `peri-middlewares/src/mcp/client.rs:198-204`。 +- `McpMiddleware::collect_tools` 在 `peri-middlewares/src/mcp/middleware.rs:366-385` 先取得上述 bridges,再追加 `McpResourceTool` 和 `DiscoverMCPTool`。静态 bridge 使用 **deployment-owned `tool_pool`**,资源/发现用 session-projected `pool`:`:32-37,64-68`。 +- `build_tool_bridges` 没有实际 dedup。其末尾“内置工具优先去重”注释(`tool_bridge.rs:385`)下面没有实现,不能当作证据。 +- `build_session_tool_view` 使用 `BTreeMap::insert`,同名是**后写覆盖**:`peri-agent/src/session/exec/stage_builder/tools.rs:40-44`。两份同名 direct/deferred 不会在最终 map 中同时存在,却可能由顺序决定哪份存活;collect Vec 层已经重复注册。不能依赖这层“去重”。 +- catalog 的 source 优先来自 `mcp_server_name()`:`peri-agent/src/session/tool_catalog.rs:135-145`;动态 capability 按整个 server 删除静态工具后插入动态工具:`:238-264`;finalize 只额外检查 alias 冲突:`:277-286`。 +- 动态 registry 对 effective 名作 ASCII 小写冲突检查:`peri-middlewares/src/mcp/dynamic/registry/capability.rs:29-60,64-99`;跨 catalog 检查刻意排除同 server 静态条目(`:75-77`),因此不能认为 static system MCP 天然不会被 shadow。 +- C 的决策:**构建一次 typed bridges,在同一批对象上打 direct 标记,最终只 boxing 一次。** 不额外 append required bridges;不通过全局 registry 修补;不复制动态 bridge 到静态池。 + +### 2.4 错误与测试脚手架 + +- 现有 `ToolCallError` 只有 `NotConnected / Unavailable / CallFailed / Timeout`,用于执行期:`peri-middlewares/src/mcp/tool_bridge.rs:11-30,217-247`。不把启动时工具缺失伪装成调用失败,也不增加一个含义模糊的 `Unavailable` 分支。 +- 两个现有 constructor 都只是将 `Tool.input_schema` 转成 JSON Value,并 fallback `{}`,没有 schema 结构校验:`tool_bridge.rs:106-107,147-148`。`Tool` 的 input_schema 已是 JSON object 数据;单纯测试 `to_value` 成功无法证明 schema 合法。 +- manifest 已有 serde_json、thiserror、rmcp,没有专用 JSON Schema validator:`peri-middlewares/Cargo.toml:14-18,38-43`;workspace manifests 的检索也未找到 jsonschema/schemars 依赖。不得在计划中虚构现有完整 JSON Schema 编译器。 +- `tool_bridge_test.rs:4-14` 用 `serde_json::from_value` 构造 `Tool`;`:16-31` 直接构造 `McpClientHandle { peer: None, tools: vec![], ... }`。可无真实 server 构造存在、缺失、语义结构非法 schema 三类 fixture,供 C 纯逻辑测试使用;这些 fixture **不能证明协议 ready**。 +- 非法 fixture 应用 `inputSchema: {"type":"object","properties":42}` 等可被 `Tool` 接收、但结构不合法的数据。`inputSchema: []` 可能直接在 rmcp Tool 反序列化失败,属于 B 的 discovery 错误,不能声称 C 接到了这种 Tool。 +- 初始化发现存在吞错风险:`peri-middlewares/src/mcp/initialize.rs:218-221,491-494` 对 `list_all_tools_cached` 使用 `unwrap_or_default()`。空数组契约尤其不能依靠“tools 为空且 Connected”绕过失败;C 无法从 bridge 集合恢复被吞掉的错误,必须由 B 解决/携带成功证据。 +- `SystemReadinessError` 和 `system_mcp_tools` 在本次源码检索时尚不存在。现有边界支持 `AgentError::MiddlewareError { middleware, reason }`:`peri-acp-types/src/error.rs:40-41`;`Other` 的用户文案是通用错误(`:273-276`),不应让明确的启动诊断只剩通用文案。 + +## 3. 接口冻结 + +以下为**拟新增接口**,不是当前已经存在的 API。使用 crate 内可见性,避免为本次需求扩大 public surface。 + +### 3.1 `tool_bridge.rs`:typed 单次构建与 direct 标记 + +```rust +impl McpToolBridge { + pub(crate) fn with_direct(mut self) -> Self; + pub(crate) fn original_tool_name(&self) -> &str; +} + +pub(crate) fn build_typed_tool_bridges( + pool: &McpClientPool, +) -> Vec; + +// 已有 public 接口保持签名及默认 deferred 行为不变。 +pub fn build_tool_bridges(pool: &McpClientPool) -> Vec>; +``` + +- 新增 private `direct: bool`,`new` / `new_dynamic` 初始化为 false;override `BaseTool::is_direct()` 返回该字段;`with_direct` 只改 direct,不改变 visibility、名字、client、generation、admission 或 leases。 +- 为 `McpToolBridge` 增加 `Clone`:仅 clone 原有 String/Value/Arc/gate 等字段,不新建连接。目的是 B 可从已验证快照多次返回新 Box,而不是重复连接或重注册。 +- 将原 `build_tool_bridges` 的循环提取到 typed helper,原 public 函数只做 `.map(|bridge| Box::new(bridge) as Box)`。两种 constructor、generation 和 lease 行为保持原样。 +- `original_tool_name` 暴露实际原始名,避免从 effective name 反拆(净化不可逆、`__` 也可能出现在分量中)。server identity 继续用已有 trait 方法。 + +### 3.2 新模块 `system_tools.rs` + +`mcp/mod.rs` 仅新增一行 `pub(crate) mod system_tools;`。 + +```rust +use std::collections::BTreeMap; +use super::tool_bridge::McpToolBridge; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum SystemToolError { + MissingTool { server: String, tool: String }, + AmbiguousTool { server: String, tool: String }, + InvalidSchema { + server: String, + tool: String, + path: String, + reason: &'static str, + }, + NotModelVisible { server: String, tool: String }, + EffectiveNameCollision { effective_name: String }, +} + +pub(crate) fn prepare_system_tools( + bridges: Vec, + required: &BTreeMap>, +) -> Result, SystemToolError>; +``` + +**输入/输出不变量:** + +1. `required` 只包含经 A 合法化的 System MCP,key 为原始 server name;允许 value 为空。B 必须在调用前确认这些 server 的 transport、initialize、协商及 tools/list 成功。C 不接收 config、不读网络、不等待、不判断 ClientStatus 是否足以 ready。 +2. 输入是同一次 `build_typed_tool_bridges(&self.tool_pool)` 的结果。按 raw server/raw tool exact match;重复配置项幂等,不造成重复输出;同一 server 有多个同名原始 Tool 则 `AmbiguousTool`。 +3. **先验证所有必需项,再消费输入给选中 bridge 调用 `with_direct()`**。任何错误都只返回 Err,没有部分成功集合;B 不得缓存半成品或先发布 ready。 +4. 对被选中的 effective name,检查整批静态 bridges 是否存在第二个同 effective 名或 ASCII case-fold 冲突;冲突返回 `EffectiveNameCollision`。这覆盖 sanitize 碰撞及大小写执行歧义,不改变完全无关普通 deferred 工具的历史冲突策略。 +5. 必需项 `visible_to_model() == false` 返回 `NotModelVisible`。不得擅自把 app-only 工具变成模型工具,也不得将“direct=true 但模型不可见”当作成功注入。 +6. 返回 Vec 与输入**长度相等、顺序不变、身份不变**,仅 required 对应项 direct=true。空数组不代表删除该 MCP 普通工具:原来的 deferred 工具依旧存在,新增 direct 数量为 0;无需对该 server 的普通 schema 增加校验。 +7. 没有额外 `ToolCallError` 变体。C 负责上述解析/结构错误,B 只包装和传播,不再实现第二套工具存在性/schema 检查。 + +**schema 校验口径:** + +- C 在新模块内新增私有结构校验器,针对 `bridge.parameters()` 的 MCP input schema;不改普通 bridge constructor 的历史行为、不增依赖。 +- 根必须是 object;`type` 若存在须为 `"object"`,允许 `{}` 这种未声明约束的 object schema。递归 schema 节点必须为 object 或 boolean;校验已知关键字的结构:`type` 为合法 JSON Schema 类型名或非空无重复类型数组,`properties / patternProperties / $defs / definitions / dependentSchemas` 为 schema map,`required` 为无重复字符串数组,`items / additionalProperties / contains / not / if / then / else / propertyNames` 为 schema,`allOf / anyOf / oneOf / prefixItems` 为 schema 数组;字符串引用/标识与数值、布尔约束也按各关键字应有类型检查。`enum` 非空数组,`const` 可为任意 JSON 值。 +- annotation、vendor extension、`default`、`examples`、`enum`、`const` 内容不是 schema,不对其中普通数据递归套 schema 规则。错误只包含字段路径和固定原因,不打印整个 schema、默认值或 Tool payload。 +- 这是 **MCP 声明的结构解析检查**,不是完整 JSON Schema draft 的元 schema 验证、instance validation 或远程 `$ref` 解析;不得联网解析 `$ref`。目前仓库无统一 draft/validator 策略;若父计划将“schema 无法解析”明确要求为完整 draft 编译,必须先调整接口实现与依赖文件所有权,不能将有限结构检查伪称完整 validator(见第 6 节)。 + +### 3.3 给 B 的精确接线契约 + +**责任划分:所有下列 `middleware.rs` 接线均由 B 实施,C 不写该文件。** + +1. B 的 1R 闸门在所有 System MCP 的 discovery 成功后,执行: + + ```rust + let typed = build_typed_tool_bridges(&self.tool_pool); + let prepared = prepare_system_tools(typed, &required) + .map_err(|source| SystemReadinessError::RequiredTools { source })?; + ``` + + `required` 来自 A 的有效配置。B 使用 deployment tool_pool,不能换成会混入动态投影的 self.pool。成功结果为**整批静态 MCP bridges**,含标记后的 required 和未改动的 deferred;B 在所有 required server 校验成功后原子保存为本次准入的快照。 + +2. B 定义并拥有: + + ```rust + SystemReadinessError::RequiredTools { source: SystemToolError } + ``` + + 该 error 类型须与 `SystemToolError` 的 crate 可见性兼容。B 保留 source 链;在既有 AgentResult 边界转换为 `AgentError::MiddlewareError { middleware: "McpMiddleware".into(), reason: safe_error.to_string() }`,保证 server/tool/error 类别明确可见。initialize、discovery、timeout、取消等仍由 B 的其它变体承担,不由 C 假造。不得 `unwrap_or_default`、warn 后继续、panic 或改成成功空集合。 + +3. **`collect_tools` 修改锚点:当前 `peri-middlewares/src/mcp/middleware.rs:367`。** 无 System MCP 配置时保留 `build_tool_bridges(&self.tool_pool)` 原路径;配置了 System MCP 且已有本次准入快照时,用 `prepared.iter().cloned().map(|bridge| Box::new(bridge) as Box).collect()` **替换**这一行的初始 Vec。 + + **不允许**先调用旧 `build_tool_bridges` 再 extend `prepared`。因为 prepared 已包含所有静态 bridges,再 extend 会重复。随后 `:369-383` 现有 resource/discover push 原样保留,`:385` 返回值类型不变。 + +4. `collect_tools` 返回 Vec,**不能传播 Result**;所有工具解析错误必须在 B 的 fallible 1R 路径产出。1R 前尚无快照时可保持原 deferred 收集行为,但这不是 ready 或可启动凭据;闸门成功后必须让实际首个 Reason 看见 prepared 的完整结果。 + +5. **不能遗漏的时间顺序依赖:**当前 `build_session_tool_view` / catalog 构建早于 1R(`stage_builder.rs:456-466`)。仅在 `before_agent` 缓存 prepared、然后期望 collect_tools 自动重跑是错误方案;仅改 local_tools 也会被 Reason 的 refresh/map swap 覆盖(`reason.rs:23-31`)。B 必须在其方案中给出准入后重建/发布真实 session catalog 的宿主接线,保证校验快照、generation、实际工具视图一致,并重新应用 allow/disallow policy;不得绕过 policy 或覆盖 dynamic capability。**该宿主发布点超出 C 所有权,是 B/父计划的实施前阻塞项;若 B 也无此文件权限,由父计划先分配,不得擅自扩写 C。** + +6. typed 快照必须对应本次 ready 的 handle generation。B 若检测到重连/快照变化,应重新准入、重新构建并原子替换,不能把旧 required 标记套到新 handle。普通 MCP 不应因该缓存永久失去原有每 turn 收集机会:B 在后续 turn 收集时重新取得当次静态快照并按同一闸门/校验流程发布;不要冻结整个 deployment 工具目录到会话结束。 + +7. 空 `required[server]` 也必须由 B 等待 ready;C 无法从零匹配集合证明 server 存在。成功后 collect 的 MCP Vec 与基线相同,没有多一份工具、没有把整个 server 设为 direct。 + +## 4. 任务表 + +实施顺序为 C-INJ-01 → C-INJ-02 → B 接线后的 C-INJ-03 验证。不同任务没有文件写入重叠;可独立验证指在所列前置依赖完成后单独运行该任务的目标测试,不要求依赖倒置。 + +| Task ID | 标题 | 目标文件 | 改动摘要 | 验证命令 | 预估 diff 规模 | 文件冲突面 | +| --- | --- | --- | --- | --- | --- | --- | +| C-INJ-01 | 支持 typed bridge 的 direct 提升 | `peri-middlewares/src/mcp/tool_bridge.rs` | direct 默认 false、override、with_direct、原始名 accessor、Clone;提取 typed builder,public builder 保持兼容;同文件新增不足 30 行的 focused test | `cargo test -p peri-middlewares --lib mcp::tool_bridge` | 约 50–90 行 | 仅 C;不改原 `tool_bridge_test.rs`、middleware 或动态调用方 | +| C-INJ-02 | 解析、批量验证及真实分类 seam 测试 | 新 `peri-middlewares/src/mcp/system_tools.rs`、新 `peri-middlewares/src/mcp/system_tools_test.rs`、`peri-middlewares/src/mcp/mod.rs` 仅一行 | 实现冻结函数/错误、schema 结构检查;本地 fixture;通过真实 catalog、ToolSearch hook、run_reason 验证模型工具视图;测试模块声明随主模块一并落地 | `cargo test -p peri-middlewares --lib mcp::system_tools` | 约 500–750 行(多数为 fixture/测试) | 新模块归 C;mod.rs 该行需父计划协调;不改 B 文件 | +| C-INJ-03 | 验收接线与首 Reason 回归 | 无写入;只读 B/父计划交付物 | 检查 prepare 原子性、collect 替换而非 append、真实 view 发布、空数组仍等待;复跑目标集与历史回归 | `cargo test -p peri-middlewares --lib mcp::system_tools`;`cargo test -p peri-middlewares --lib tool_search`;`cargo test -p peri-agent --lib session::exec`;B 的目标测试过滤器 | 0 行 | 不领取或修改 B 的文件;失败退回具体 owner 修复 | + +C-INJ-02 若实现过程中扩展成通用 schema 引擎,应停止拆解范围,不以“顺便完善”为由突破文件所有权。测试辅助代码按 `docs/standards/testing.md:14-19` 放本地 `_test.rs`,不新增共享 test_helpers。 + +## 5. 验证计划 + +### 5.1 C 的确定性无服务器测试 + +以下除首项外均放 `peri-middlewares/src/mcp/system_tools_test.rs`,不需要真实 MCP server,不访问网络。fixture 仿照 `tool_bridge_test.rs:4-31` 构造真实 rmcp Tool、真实 McpToolBridge,绝不只用 MockTool 代替被测 bridge。 + +| 测试函数名 | seam / 可观察断言 | +| --- | --- | +| `test_system_direct_flag_preserves_bridge_identity`(`tool_bridge.rs` 新的小型内联测试模块) | new 默认 false;with_direct 后 true,name、raw tool name、mcp_server_name、parameters、visible_to_model 不变 | +| `test_system_required_tools_resolve_in_own_namespace` | prepare 输入含 workspace/Read 和 archive/Read;仅 workspace 项被提升;返回 effective 名正确,Vec 数量和身份不变 | +| `test_system_required_tool_names_are_case_sensitive` | workspace/read 不满足 Read;返回 MissingTool 的 server/tool 精确对应配置 | +| `test_system_effective_prefix_is_not_stripped` | 仅有 raw Read 时,配置 mcp__workspace__Read 返回 MissingTool,不偷用 effective name 搜索 | +| `test_system_missing_tool_returns_explicit_error` | prepare 返回 MissingTool,不是空 Ok;另一 server 有同名工具也不能补足 | +| `test_system_invalid_schema_returns_explicit_error` | Tool 反序列化成功后,properties=42 / required 非字符串数组 / 嵌套非法 type 等表驱动输入均返回 InvalidSchema;断言安全路径、固定原因,无 schema 值泄漏 | +| `test_system_valid_schema_preserves_extensions_and_data` | object、嵌套 schema、boolean schema 节点、vendor extension、default/enum 中普通数据合法;parameters 完整保留,不改写 MCP 原始 schema | +| `test_system_empty_required_array_adds_no_direct_tools` | required 含 workspace:[];prepare 成功,输入/输出工具数与定义一致、direct 增量为 0;普通 deferred schema 不触发必需检查;不把此测试称为 ready 测试 | +| `test_system_duplicate_requirements_do_not_duplicate_registration` | [Read,Read] 只提升已有一项;断言 prepare Vec 中 effective name 计数为 1,且 catalog map 仍为 1;不能只看 map 长度掩盖重复 Vec | +| `test_system_ambiguous_raw_tool_is_rejected` | 同一 server 两份 raw Read → AmbiguousTool,不任意取第一项 | +| `test_system_effective_name_collision_is_rejected` | required 命中项与不同原始名/不同 server 的 sanitized effective 名碰撞,或 ASCII case-fold 冲突 → EffectiveNameCollision | +| `test_system_app_only_required_tool_is_rejected` | _meta 将工具设为 app-only → NotModelVisible,禁止强制 model visibility | +| `test_system_validation_is_all_or_nothing` | [合法 Read, 非法 Write] → Err,无可发布的部分 Vec;测试 B 之前不宣称 ready 原子性已被验证 | +| `test_system_required_tools_reach_first_reason_without_search` | prepare → `SessionToolCatalog::try_new` →真实 `run_reason`,记录型 ReactLLM 捕获实际 tools 入参;mcp__workspace__Read 恰好一次、is_direct=true、schema 等于原声明,无需调用 SearchExtraTools;ReasonOutput.catalog.direct_definitions 同样包含它 | +| `test_system_ordinary_tools_remain_deferred_through_tool_search` | 同批 required Read、未选中 Glob、普通 MCP 工具;执行真实 before_agent/before_reason_catalog;index.get_tool(Read)=None,Glob/普通工具=Some;run_reason 的 LLM tools 不含这些 deferred,SearchExtraTools 仍可发现它们 | +| `test_system_direct_tool_survives_tool_search_rebind` | 重复调用 before_reason_catalog,比较 working map 中 direct bridge 标志/身份及 pinned direct_definitions;ToolSearch 不覆盖或重新索引该工具 | +| `test_system_dynamic_control_remains_deferred` | new_dynamic / DynamicMCP 控制工具不因新增 direct 字段而被自动提升;动态 gate 路径保持原样 | + +实际 seam 构造说明: + +- `SessionToolCatalog::try_new`、`snapshot`、`pin_working_tools` 是可访问的真实产物(`peri-agent/src/session/tool_catalog.rs:114-119,177-178,201-224`),测试使用其输出而非重写分类函数。 +- `run_reason` 为 public(`peri-agent/src/agent/stages/reason.rs:14`,模块导出见 `stages/mod.rs:13`);记录型 LLM 实现真实 `ReactLLM::generate_reasoning` 接口(`peri-agent/src/agent/react.rs:263-269`)。`StageContext::builder` fixture 模式见 `peri-agent/src/agent/stages/reason_test.rs:13-18,32-38`;只替换 LLM 外部边界,不 mock catalog/ToolSearch。 +- ToolSearch local_tools fixture 模式见 `peri-middlewares/src/tool_search/middleware_test.rs:192-228`;新测试可用更小的真实 CatalogState 实现,不复制整个测试文件。索引可观察 API 在 `tool_index.rs:289-290,349-350`。 +- C 测试可证明 prepare 输出经过真实 Reason 分类,但不能从另一个 crate 直接调用 private `build_session_tool_view`,也不能证明 B 的 1R 自动更新了生产视图。 + +### 5.2 必须由 B/父计划补齐的生产装配断言 + +以下是交接的测试需求,**不作为 C 领取文件的任务**。建议由 B 在其拥有的 `peri-middlewares/src/mcp/middleware_test.rs` 编写 gate 测试;跨 Agent 私有 seam 的测试由父计划指定 owner 在 `peri-agent/src/session/exec/stage_builder/builder_v2_test.rs` 中实现,不擅自修改其权限。 + +- `test_system_gate_missing_required_tool_blocks_first_reason`:真实 B gate 收到 C MissingTool,返回 RequiredTools 包装错误;ready 未发布,记录型 LLM 调用次数为 0。 +- `test_system_gate_invalid_schema_blocks_first_reason`:同上,错误类别为 InvalidSchema,不把 schema 错误降为工具搜索不可用。 +- `test_system_gate_empty_required_array_waits_for_discovery`:空数组且 discovery pending 时不得通过;成功后 direct 增量 0;initialize/list 失败或 timeout 时仍 Err。可用内存 transport fixture,不能用 peer=None 伪造握手完成。 +- `test_system_ready_refreshes_initial_session_tool_view`:初始 collect 时工具未 ready,1R 后完成协议与发现;断言由真实 `build_session_tool_view` / catalog 发布链送到**首个 Reason**的工具集合含 required 且无重复,deferred 路径仍有效。禁止只手工调用第二次 collect 来替代生产发布验证。 +- 同一组测试断言 gate 失败不会留下可被下次启动复用的半成品 direct 快照。 + +验收 3 完成条件是 C 的正/负例、真实 Reason 入参、B 的首 Reason 接线三个层面均通过;验收 4 还必须有 B 的 ready/timeout 测试。只通过 C 的无网络测试不等于验收完成,更不等于五个 MCP 迁移完成。 + +## 6. 风险与未知 + +1. **重复注册与有效名碰撞:已查明,不依赖猜测。**现有 Vec 不去重、session map 后写覆盖。采用单次 typed 构建、原对象标记、整体替换初始 Vec;required 涉及的净化/大小写碰撞 fail closed。绝不 append 第二份 direct bridge。 +2. **1R 与工具视图的时间错位是阻塞项。**B 必须明确在实际宿主 seam 发布 prepared;当前 cache-only / collect-only 接线不足以完成验收。本文没有替 B 发明一个现存的 refresh API,也不越权新增它。父计划未分配所需宿主接线前,不可宣称本计划已经端到端可实施完成。 +3. **Dynamic session-scoped registry:现有行为必须保留。**同 server 动态 projection 会整组 shadow static,即使 static 已 direct。C 不使 new_dynamic 默认 direct,不改 registry、不泄漏其它 session 的 client/lease,也不通过静态缓存覆盖动态实例。对于“已激活的动态 server 与 System MCP 同名”,设计未规定优先级;父计划/B 必须在准入时解决有效来源一致性(建议明确报冲突并阻止本次启动,而不是默默用静态 ready 证明动态能力)。错误产生在 B 的有效来源准入层,不由缺少 session capability 的纯函数凭空判断。运行中是否允许后来 shadow 必需工具,亦需父计划明确;不能借本次工作私自改变动态 load/unload 语义。 +4. **Schema 保证的范围尚无统一 draft 事实源。**typed JSON 可序列化不等于 JSON Schema 有效。本文冻结最小结构解析并覆盖可构造的非法 fixture,不宣称完整 draft 编译。若需要完整 meta-schema 验证或 `$ref` 可解析性,这是待父计划确认的材料性要求,涉及新增依赖/manifest 所有权;不允许悄悄新增 library、联网解引用或仅用 roundtrip 冒充验证。 +5. **app-only 与 policy 过滤。**必需项不能强行覆盖 model visibility;effective allow/disallow policy 仍有效。若 policy 排除了必需 direct 工具,B/父计划必须在实际准入视图明确处理不满足启动要求,而不是 C 绕过权限过滤。正常注入测试必须使用允许该工具的 policy。 +6. **连接 ready 的证据:**initialize 当前有 tools/list 吞错分支;空数组尤其暴露该问题。B 需保证成功 evidence,不以 get_all_clients 的 Connected 过滤代替协议完成证明。B 的错误类型及字段实际代码尚未落地,RequiredTools 变体为此处的冻结交接要求。 +7. **快照与新鲜度:**prepared clone 会复制 schema/description,但复用 client/gate/leases;B 不应跨 generation 盲目复用。普通 deferred 来源的更新机会不可丢失,不能把此处“同一次准入快照”误实现为永不刷新的 session 全局表。 +8. **Prompt 体积是设计输入。**direct 工具每次 Reason 携带完整 schema/description,成本近似为必需工具声明序列化大小之和;根 `CLAUDE.md:15` 明确成本约束。只提升显式数组,空数组为零增量;不默认整台 System MCP 全量 direct、不再把 required schema 拼一份进搜索提示词。验证时记录 direct 数量与序列化字节增量(不输出内容/秘密),不编造 token 数,不设置设计未授权的硬上限,也不以预算为由静默截掉必需工具。 +9. **验证边界:**本次只做 Read/Grep/Glob 侦察及本文写入,没有编译或运行时结果;行号是侦察时快照,并行实现后应按符号复核。B/父计划的宿主接线、动态冲突决策、完整 schema draft 要求均未能由当前代码证明,须在实施前闭合。 + +## 7. 非目标 + +- 不实施 Workspace/Artifact/Web/Cron/LSP 五个实例的真实迁移、拆包、进程化或 capability root 隔离改造。 +- 不领取验收 5、6 的实现范围;仍不得破坏已有 Permission/HITL/cancel/effective name/session 契约。 +- 不改 MCP transport、OAuth、重试、timeout、协议版本协商或 B 的 ready 状态机。 +- 不把普通 deferred 工具改成 direct,不更换 ToolSearch 索引或搜索算法,不新增裸工具名 alias。 +- 不改变动态 MCP registry 的 session 隔离、projection lease、admission 或 shadow 架构。 +- 不实现完整 JSON Schema 执行验证器、远程引用加载或 provider-specific schema 降级。 +- 不增加全局 registry 写入、不以修改 shared_tools 绕过 session-local 事实源。 +- 不修改本文件之外的源码、测试、配置或文档,不提交 git,不运行 cargo build/test;本文描述的实现与验证均是未来工作。 diff --git a/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-d-verification.md b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-d-verification.md new file mode 100644 index 000000000..5d2995dc0 --- /dev/null +++ b/spec/issues/2026-09-25-mcp-adaptation-v4-part-1-sub-plan-d-verification.md @@ -0,0 +1,175 @@ +# MCP adaptation v4-part-1:Sub-plan D 跨层验证与文档一致性 + +> **主 plan v2 覆盖(优先于本文)**:见 [`2026-09-25-mcp-adaptation-v4-part-1-plan.md`](2026-09-25-mcp-adaptation-v4-part-1-plan.md) §5。本文与主 plan 冲突处一律以主 plan 为准,涉及本文件的具体覆盖:**R3**(`docs/code-index/**` 唯一 owner 是 D-06)、**R9**(本文 D-01 行作废:`peri-middlewares/tests/` 外部集成测试触达不到 readiness 内部 seam;其跨层职责由 B-07 host seam 与 D-02 crate 内 seam 承接)、**R10**(`peri-agent/src/session/exec/stage_builder.rs` 归 B-05 所有)、**R13**(D-06 依赖 D-05,排在 D-05 之后的独立 Wave)。 + +## 1. 元信息 + +- **范围**:验收契约 5、6、7,以及契约 2/3/4 的跨层验证;本计划不实现生产代码,不实现五个目标 MCP 的真实迁移。 +- **实施边界**:本 workflow 只落地契约 1–4、7;为契约 5/6 增加契约测试和文档口径,不把“测试通过”写成五个 MCP 已完成隔离迁移。 +- **依赖 sub-plan**:依赖 A 的 `system_mcp` / `system_mcp_tools` 配置字段及错误语义;依赖 B 的 `await_system_ready` 类等待接口、ready/timeout/failure 状态及 pool 生命周期;依赖 C 的 system tools 注入函数、RCRA direct tool projection 和 effective MCP tool name 语义。 +- **接口冻结占位**:以下名称是计划占位,最终测试只使用 A/B/C 冻结后的 public API:`system_mcp`、`system_mcp_tools`、`await_system_ready` 类函数、system tools 注入函数。若最终符号或参数不同,不通过访问私有字段或修改 A/B/C 源文件适配,而是在测试文件内增加一层最小的局部 fixture/adapter;若没有可观察的 public seam,则把该测试标为 blocked,并在验收记录中写明缺口。 +- **产出**:跨层运行时断言、宿主 Permission/HITL/effective tool name/cancel 不绕过的证据、契约 5/6 的降级证据及限制、契约 7 的状态口径和独立验收记录模板。 +- **文件所有权**:本 plan 只新增/修改 `peri-middlewares/tests/**`、必要时 `e2e/**` 下新测试和 `docs/**`、`spec/issues/**` 中本 plan 所属产物;不修改 A/B/C 所有的源文件。 + +## 2. 事实基线 + +### 2.1 设计事实源与边界 + +- 权威设计文档说明:MCP transport 可以是 builtin 内存 transport 或 external stdio/HTTP,但每个实例必须独立拥有 transport、状态、凭据和 capability root;共享 library/schema/fixture 不等于共享实例(`docs/design/mcp-adaptation-v4-part-1.md:22-31`)。 +- 目标是五个相互隔离的 MCP,且 MCP 之间不得隐式调用;当前 workflow 明确不实现五个实例迁移(`docs/design/mcp-adaptation-v4-part-1.md:67-77`)。 +- System MCP 必须在 react loop 前完成 transport、initialize、能力协商、健康检查;超时/失败不得发布 ready(`docs/design/mcp-adaptation-v4-part-1.md:33-41`)。工具注入必须直接进入 RCRA 工具列表,不能经 `ToolSearchMiddleware`,但仍保留 effective tool name namespace(`docs/design/mcp-adaptation-v4-part-1.md:43-65`)。 +- 七条验收契约中,契约 5/6/7 的规范文本分别在 `docs/design/mcp-adaptation-v4-part-1.md:222-234`;本计划的测试不得用静态清单代替 transport、宿主装配和 RCRA 工具视图上的运行时证据。 + +### 2.2 Rust 测试基建 + +- 测试规范规定:跨模块端到端验证放在 crate 根 `tests/`,只能访问 crate 的 `pub` API;局部单元测试放在同目录 `_test.rs`(`docs/standards/testing.md:12-19`)。因此本计划的宿主装配 seam 优先放在 `peri-middlewares/tests/`,不复制 A/B/C 的私有单元测试。 +- 现有集成测试包括:`middleware_tool_registration.rs:1-42` 只构造 `FilesystemMiddleware`/`TerminalMiddleware` 并检查工具名称;`canonical_tool_invocation_contract.rs:1-128` 构造 `StageContext`、`MiddlewareChain`、`SessionToolCatalog`、`EffectiveToolDispatcher`,可验证工具调用经过 middleware/事件/规范化路径;`run_ptc_code_e2e.rs:1-128` 是 PTC 的进程内真实 Node fixture,但不是 MCP transport/host assembly 测试。 +- 现有 MCP client 脚手架已经使用 `rmcp` 和内存 duplex:`client/service_test.rs:1-85` 构造 `tokio::io::duplex`、`AsyncRwTransport`,在进程内对 initialize/discover 响应;`client/transport_test.rs:1-106` 以 `duplex` 断言 Auto/Discover 的真实 JSON-RPC 首请求和 fallback。它们是真实 client transport wire,但不是 rmcp `ServerHandler` 组成的完整 MCP server,也未跨过宿主装配和 RCRA 视图。 +- `client/process_test.rs:1-80` 可以启动真实子进程、检查进程树、handshake timeout 和 pool cleanup,但其子进程是 `bash` 生命周期 fixture,不是 MCP server;不能据此宣告 external MCP 端到端成立。 +- `mcp/middleware_test.rs:10-52`、`:64-101` 目前主要用手造 `McpClientHandle`/`rmcp::model::Tool` 及 deployment/projected pool 检查 tool projection;`:561-579` 已有 cancel 后 `before_agent` 无动作测试;`:782-828` 已验证连接事件触发 discovery。它们是 MCP middleware 单元契约,不覆盖真实 transport → initialize → assembly → RCRA seam。 +- `mcp/config_test.rs:6-120` 是配置解析、协议版本和 OAuth 默认值的单元测试;契约 1 的解析拒绝由 A 负责,D 只在装配后验证错误是否阻止启动。 +- 测试规范还要求集成测试跨越声称支持的真实生命周期、使用真实 wire/ordering,静态共享代码和单进程 unit test 不能替代(`docs/standards/testing.md:182-186`);fixture 必须在测试文件内部定义,不建立共享 workspace test helper(`docs/standards/testing.md:190-199`)。 + +### 2.3 独立 `local-mcp-server` 与 e2e + +- `side-projects/local-mcp-server/Cargo.toml:1-15` 有独立 `[workspace]`,根 `Cargo.toml:1-18` 的 members 不包含它;测试规范明确 side project 不由根 workspace 测试门禁覆盖,必须进入其目录执行本地命令(`docs/standards/testing.md:62-67`、`:317-322`)。 +- 独立 server 的测试使用 `env!("CARGO_BIN_EXE_local-mcp-server")`:`side-projects/local-mcp-server/tests/e2e_support/mod.rs:37-43`;其真实 stdio 进程 E2E 的范围和断言见 `tests/e2e_stdio.rs:1-16`,并非根 workspace 的 fixture。 +- 全仓只核实到上述独立项目使用 `CARGO_BIN_EXE_local-mcp-server`;未核实到根 workspace 测试以 `CARGO_BIN_EXE_...` 或 path dependency 调用它。结论是根测试不能直接依赖该 binary 的 Cargo 注入;若要使用,必须由独立项目先按自身命令构建并通过显式环境变量传入,且不能作为根测试默认门禁。更稳妥的契约 fixture 是在 `peri-middlewares/tests/` 内用 `tokio::io::duplex` + 局部 rmcp server handler,避免跨 workspace target/lock 和本机 binary 依赖。 +- `e2e/CLAUDE.md:1-24` 规定 e2e 是 `tui-tester` + tmux 的真实 TUI 交互测试,推荐单文件命令为 `npm run e2e -- --file tests/.test.ts --serial --retry 0`,分层门禁为 `npm run e2e:l0`/`e2e:l1`/`e2e:release`;数据流为 `run-e2e.mjs → helpers/peri.ts → dev.sh → Peri TUI`(`:26-45`)。该层不是 MCP client/host assembly 的直接 seam,故不把 TUI E2E 当作契约 2/3/4 的唯一证据;只有当 B/C 提供宿主启动配置和可观察 UI 结果时,才新增辅助 TUI 场景。 + +### 2.4 文档同步规则与验收记录惯例 + +- 根 `CLAUDE.md` 要求先读 `docs/standards/index.md`、按意图查 `docs/code-index/`,变更时同步索引(`CLAUDE.md:37-42`);MCP/工具/中间件任务还应读 `peri-middlewares/CLAUDE.md`,E2E 任务读 `e2e/CLAUDE.md`(`CLAUDE.md:43-55`)。 +- `DOC-UPDATE-001` 要求实现变更检查受影响 standards、模块 CLAUDE、测试 canonical 路由和命令,只更新单一事实源(`docs/standards/documentation.md:27-31`);`DOC-LINK-001` 要求移动/合并/删除时同步 CLAUDE、standards、code-index、active spec 和引用,并做链接检查(`:51-55`)。 +- `docs/standards/index.md:1-40` 是 standards 路由和优先级索引,不复制测试规则;若本次只新增 MCP 验证,不应无理由改 standards,但如新增 canonical 命令或测试边界,必须更新 `docs/standards/testing.md` 并同步其索引路由。 +- 当前 MCP 代码索引把 transport、static MCP execution directory、`McpClientPool`、`McpMiddleware` 和真实 client process/service test 作为入口,见 `docs/code-index/peri-middlewares.md:15-23`;`local-mcp-server` 的独立边界、入口和验证命令见 `docs/code-index/local-mcp-server.md:1-34`。代码变更后这两个 index 是必查对象,只有入口/命令/职责事实改变时才实际改动。 +- 近期 issue 采用“状态/优先级/类型/日期 → 目标/事实 → 已验证/已证伪/未验证 → 待办/验收标准/相关文件”的证据结构:`spec/issues/2026-09-01-workflow-delivery-git-postcondition.md:1-11`、`:42-56`;flake 记录明确区分本地证据、未复现、未验证平台和 PARTIAL 判定(`spec/issues/2026-09-10-meta-session-readonly-flake-unproven.md:53-90`)。 +- **决策**:本次现场命令、实际执行用例数、transport/assembly/RCRA 证据和限制不回填 `docs/design/mcp-adaptation-v4-part-1.md`,也不把本 sub-plan 当最终验收勾选表;在实施完成后新建 `spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md`。该记录复用上述 issue 结构,增加“契约矩阵(1–7)/运行命令与 exit status/实际用例数/证据强度/blocked 与 skipped/目标归属≠已落地能力/非目标”章节,并以 `PARTIAL` 或 `PASS` 明确整体裁决。契约 5/6 若只有降级断言,必须保持 `PARTIAL`,不能由契约 1–4 的绿色推导整体完成。 + +## 3. 验收契约 5 的验证设计 + +### 3.1 可以运行时断言的部分 + +**测试文件**:`peri-middlewares/tests/mcp_isolation_contract.rs`,建议函数: + +- `system_mcp_instances_use_distinct_pool_and_transport_owners`:创建两个配置项(不同 server name、不同 stdio fixture identity 或不同 HTTP fixture endpoint),通过 A/B 冻结后的初始化入口分别构造两个 MCP 连接;在真实 `rmcp` duplex/stdio wire 上记录 initialize、tools/list 和关闭顺序,断言两个实例使用不同 `McpClientPool`/service owner、各自只收到自己的请求、各自关闭不影响另一条 transport。若 B 只允许单 pool 多 server,则断言至少是不同的 `McpConnectionKey::Static { server_name }`、不同 `McpClientHandle` `Arc` 和不同 service/transport owner;不能把同 pool 容器本身误判为实例共享。 +- `system_mcp_instances_do_not_implicitly_call_each_other`:fixture A 的唯一工具返回带 A 标记,fixture B 的唯一工具返回带 B 标记;从 RCRA direct tool view 分别调用 A/B effective name,server 端只允许对应请求,断言 A 调用不会收到 B 的请求,且不出现由 A 触发 B 的二次 JSON-RPC 请求。该断言是运行时无隐式依赖证据,而非检查 source list。 +- `distinct_configs_keep_distinct_pool_entries_and_config_identity`:通过冻结后的 `system_mcp` 配置装配两个 server,断言 pool snapshot/handle view 中 server name、transport 类型、tool namespace 和 handle `Arc` 各自对应;不比较或打印 credential 值,只断言每个配置只能命中自己的 credential/transport context。若接口只暴露 snapshot,则至少断言不同 entry、不同 transport type/endpoint identity 和 namespace 路由。 + +**可证明强度**:不同 pool entry、不同 `Arc`、不同 transport wire、不同 process owner、无跨 server 请求,是强运行时证据;它不能证明尚未装配的 Artifact/Web/Cron/LSP 五个生产实例已经存在。 + +### 3.2 只能降级断言的部分 + +- **凭据不共享**:当前 `McpClientHandle` 的可见字段是 `peer/tools/status/oauth_status/source/url/...`(`peri-middlewares/src/mcp/client/types.rs:83-104`),pool 的共享字段包括 services/processes/configs 和一个 pool-wide `capability_profile`(`peri-middlewares/src/mcp/client.rs:44-100`);实际 token/credential store 在 initialize 中由 `FileCredentialStore::new()` 创建(`peri-middlewares/src/mcp/initialize.rs:74-85`),没有可安全读取的 per-instance credential identity public API。不能通过比较秘密、日志或 debug 输出来证明“不共享”。降级为:不同配置项分别触发独立 authorization/headers 注入路径,服务端 fixture 记录各自收到的非秘密 sentinel header 名/opaque credential fingerprint(测试不得输出原值);若 B 未提供可观察 hook,则只能断言 `McpConnectionKey`/配置归属不混淆,并在 acceptance 记录标注“凭据隔离未完整可测”。 +- **capability root**:当前 `McpClientPool` 明确暴露的是 deployment-level `capability_profile`,而 `McpClientHandle` 没有 capability root 字段(`peri-middlewares/src/mcp/client.rs:94-99`、`peri-middlewares/src/mcp/client/types.rs:83-104`)。`local-mcp-server` 的 `RootDir` 是独立项目内部的工作区根能力边界,索引明确说明它不是安全沙箱,且 Bash 不受文件 root 限制(`docs/code-index/local-mcp-server.md:6-10`、`:23-26`)。因此不能声称已完成五个 MCP 的 capability-root 隔离。若 B/C 提供 root identity/URI public view,断言两个 fixture 各自只能读写自己的 temp root;否则只做“配置 cwd/root 参数不共享、工具请求不跨 fixture root”的降级断言,并将其证据强度记为 partial。 +- **五个目标 MCP 真实归属**:当前 pool/handle 结构只能观察已配置 server,不存在能枚举 Workspace/Artifact/Web/Cron/LSP 五个已迁移实例的事实接口。测试只验证两个最小隔离 fixture 和“没有隐式调用”,不能替代五实例迁移验收;契约 7 记录必须写“目标归属仍来自设计,实例落地未完成”。 + +### 3.3 运行位置与命令 + +- 首选 `peri-middlewares/tests/mcp_isolation_contract.rs`,因为它必须跨 crate public API;命令规划为 `cargo test -p peri-middlewares --test mcp_isolation_contract -- --test-threads=1`。本次只写计划,不执行 Cargo 命令。 +- 不把 `side-projects/local-mcp-server` 作为根测试的隐式 build dependency。若后续决定增加独立项目对照,使用其目录内的 `cargo test --test e2e_stdio`/`e2e_http`,并在 acceptance 记录列为独立 evidence,不把它计入根 workspace 测试通过数。 + +## 4. 验收契约 6 的验证设计 + +### 4.1 最可能被绕过的契约 + +本次改动触及工具可见性、System MCP ready gate、direct injection 和宿主装配,最危险的回归不是 MCP wire 本身,而是把 `system_mcp_tools` 直接写进 RCRA 工具表时绕过既有 `PermissionMiddleware`、HITL broker、事件/session/cancel 和 effective tool name dispatch。设计明确 Permission、HITL、Hook、SubAgent、Workflow、Goal、PTC 仍由宿主持有(`docs/design/mcp-adaptation-v4-part-1.md:161-174`、`:195-209`),所以“direct”只能跳过 `ToolSearchMiddleware`,不能跳过宿主安全和生命周期链。 + +### 4.2 真实验证方案 + +**测试文件**:`peri-middlewares/tests/mcp_host_policy_contract.rs`,建议函数: + +- `system_direct_mcp_tool_enters_permission_and_hitl_with_effective_name`:用真实 duplex MCP server 返回一个写入型工具;通过 B ready gate 和 C system tools 注入函数装配 `ProductionChainAssembler`/RCRA tool map;调用 RCRA 中的 `mcp____`,注入记录 broker。断言 broker 收到的 tool name 是 effective name(不是裸 server tool name),拒绝时返回既有 `AgentError::ToolRejected`/HITL rejection,server 端没有收到 call;批准后才收到一次对应 MCP `tools/call`。这证明 direct injection 只绕过 deferred search,不绕过 Permission/HITL。 +- `system_direct_mcp_tool_preserves_event_and_session_identity`:在同一个 `Session`/`StageContext` 中调用批准后的 MCP bridge,记录 before/after tool 事件或 `PolicyRecorder` 看到的 `ToolCall`,断言 session/canonical effective name 和结果事件属于同一 turn;不得从 bridge 内另造 session 或直接向 server 发起绕过 stage 的调用。现有 `canonical_tool_invocation_contract.rs:104-128` 已展示 `StageContext`、`MiddlewareChain`、`SessionToolCatalog` 和 event bus 的可复用脚手架,但新测试必须把真实 `McpToolBridge` 接入,不能只继续使用 `RecordingTool`。 +- `system_direct_mcp_tool_cancel_stops_call_and_does_not_publish_ready`:用 server fixture 在 `tools/call` 后挂起,触发 session/agent cancellation;断言调用 future 结束为取消/既有错误,pool/service owner 仍由既有 shutdown owner 收尾,RCRA 不把取消误报成成功/ready。MCP middleware 已有 cancel 后 `before_agent` no-op 单元证据(`mcp/middleware_test.rs:561-579`),client close/transport cancel 也已有真实 duplex/process 生命周期证据(`client/service_test.rs:88-109`、`client/process_test.rs:45-80`);D 测试要把 cancel 接在宿主 direct tool seam 上。 +- `deferred_mcp_tool_still_requires_tool_search_path`:同一 fixture 中把一个非 `system_mcp_tools` 工具留作 deferred,断言它不在 direct RCRA list,而通过现有 ToolSearch projection 后才可见;该测试同时防止为了让 required tools 可见而把所有 MCP 工具都 direct 注入。 + +### 4.3 与既有宿主契约测试的关系 + +- Permission 的 approve/reject、MCP effective-name prefix 和 MCP 需要审批已有单元证据:`peri-middlewares/src/permission/mod_test.rs:52-102`、`:123-140`、`:171-200`;D 不复制这些纯函数断言,而验证真实 MCP bridge 走到这些路径。 +- Hook 的 cancel/进程树 drain 证据在 `peri-middlewares/src/hooks/lifecycle_test.rs:146-200`;PTC 的 caller cancellation/TaskManager cleanup 在 `peri-middlewares/src/ptc/ptc_test.rs:92-117`;SubAgent 的事件身份/Start-Stop 在 `peri-middlewares/src/subagent/tool/tool_test/events_contract_test.rs:3-9`、`:64-99`。本次不把 MCP 工具伪装成 Hook/SubAgent/PTC,也不重新测试这些实现;只验证 direct injection 没有绕过它们所属的宿主链和 session cancel 入口。 +- HITL 目录未发现独立 `_test.rs` 文件,现有 Permission tests 使用 `UserInteractionBroker` 的 approve/reject fixture(`peri-middlewares/src/permission/mod_test.rs:5-40`)。因此若 A/B/C 没有可注入 HITL broker 的装配 public seam,必须把测试状态标为 blocked,而不是用自动批准替代 HITL 证据;自动 broker 只能作为批准分支 fixture,不能证明真实 UI/HITL 交互契约。 + +## 5. 跨层端到端验证设计 + +### 5.1 契约 2:ready gate 与启动准入 + +- **文件/函数**:`peri-middlewares/tests/mcp_system_ready_e2e.rs`: + - `system_mcp_ready_waits_for_real_transport_initialize_and_tools_list`; + - `system_mcp_missing_required_tool_blocks_assembly`; + - `system_mcp_initialize_failure_or_timeout_never_publishes_ready`; + - `non_system_mcp_does_not_block_react_start`。 +- **fixture**:测试文件内局部 rmcp server/duplex fixture,支持可控的 initialize 延迟、能力响应、`tools/list` 工具集、missing tool、malformed schema 和 transport close;使用 `Notify`/显式 barrier,不用墙钟 sleep 作为成功条件。fixture 必须返回真实 JSON-RPC 响应并让 B 的 `await_system_ready` 类接口观察实际状态。 +- **seam 断言**:先启动 transport,再调用 B ready wait,再调用宿主装配;未 ready 前 RCRA/react-loop start latch 不得触发;成功时 latch 触发且 pool status 为 ready;失败/timeout 时错误类型/消息明确、ready 不发布、react loop 不启动。不能只断言 B 的 future 返回 `Ok`,也不能只检查 config struct。 +- **边界**:A 单测负责非法配置解析;B 单测负责连接/timeout 状态机;D 负责 transport 已完成初始化后是否真的挡住宿主装配和 react start。 + +### 5.2 契约 3:namespace、schema、direct RCRA 视图 + +- **文件/函数**:`peri-middlewares/tests/mcp_tool_view_e2e.rs`: + - `required_tools_are_schema_backed_and_directly_visible_in_rcra_view`; + - `required_tool_uses_effective_mcp_name_and_calls_own_namespace`; + - `unknown_required_tool_blocks_rcra_view`。 +- **fixture**:真实 MCP duplex server 返回两个工具(一个 required、一个 deferred),required 工具含非空 JSON schema;server 端记录 `tools/list` 和 `tools/call` 的原始 name。宿主 fixture 要使用 A/B/C 冻结接口完成 ready、system tools injection、RCRA tool catalog 构造。 +- **seam 断言**:RCRA 可见工具包含 `mcp____`,参数 schema 等于/语义等价于 server declaration;裸名不出现在 direct view;调用 bridge 时 wire 上是所属 namespace 内的裸 MCP tool name;deferred 工具不因 system list 自动 direct 出现,仍需 ToolSearch。schema parse 失败和 required missing 必须阻止启动。 +- **边界**:C 单测可验证 bridge/name/schema 转换;D 要验证转换结果真的进入宿主 RCRA tool map,并从该 map 经 Permission/HITL 调度。 + +### 5.3 契约 4:空数组 + +- **文件/函数**:同一 `mcp_tool_view_e2e.rs`:`empty_system_mcp_tools_waits_ready_without_extra_rcra_tools`。 +- **fixture**:真实 transport 完成 initialize、能力协商和 `tools/list`,但 `system_mcp_tools = []`;同时配置一个普通 deferred tool 以排除“空数组导致整个 MCP 消失”的误判。 +- **seam 断言**:ready 成功;RCRA direct list 不增加该 MCP 的额外 tool;普通 deferred tool 仍按 ToolSearch 路径存在/可检索;server 端仍观察到协议初始化和 tools/list,证明空数组不是跳过 MCP lifecycle。 +- **边界**:B 负责 ready,C 负责注入函数的空数组语义,D 负责最终 RCRA view 和 deferred path 的组合结果。 + +### 5.4 是否新增 `e2e/` TUI 测试 + +默认不新增 `e2e/` 测试:现有 e2e 启动真实 TUI/tmux,依赖 `dev.sh` 和可能的 provider,不能精确观察 MCP transport、host assembly、RCRA view 三个 seam(`e2e/CLAUDE.md:3-33`)。若 workflow 后续要求 UI 显示 System MCP ready/failure,另新增 `e2e/tests/scenarios/mcp-system-ready.test.ts`,fixture 通过隔离 HOME/配置和本地无网络 MCP server 注入,命令沿用 `e2e/CLAUDE.md:7-24`;它只能作为宿主进程级补充,不替代 Rust seam 测试。 + +## 6. 文档一致性任务 + +### 6.1 必须同步的事实源 + +- **`spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md`(新增验收记录)**:记录契约 1–7 的实际结果、命令、exit status、执行用例数、fixture、证据强度和 blocked/unsupported。契约 5/6 使用“完整运行时证据/降级证据/未验证”三态;契约 7 明确“目标归属”来自设计表,“已落地能力”只由本次运行时证据决定。 +- **`docs/code-index/peri-middlewares.md`**:若 A/B/C 改变 MCP config、ready、assembly 或 tool bridge 入口,更新 MCP 速查表中对应主文件/入口/验证入口;补充跨层测试文件和命令,但不把目标五实例写成当前实现。当前索引的 MCP 入口在 `docs/code-index/peri-middlewares.md:15-23`。 +- **`docs/code-index/peri-acp-types.md`**:若 A 把 `McpServerConfig` 的字段事实源或插件契约入口改变,更新对应 protocol/config 行;只写当前入口,不复制设计目标。 +- **`docs/code-index/local-mcp-server.md`**:只有当 fixture/验证命令或 capability-root 语义改变才更新;目前必须保留“独立项目、不属于根 workspace”和“root 不是安全沙箱”的口径(`docs/code-index/local-mcp-server.md:1-10`)。 +- **`docs/standards/testing.md`**:仅在本次确认了新的 canonical 跨层测试命令、根 workspace 与独立 project 的边界,或新增“System MCP seam 测试”稳定规则时更新;应放在测试目录/生命周期/命令相关章节,不把一次验收结果写入标准。索引 `docs/standards/index.md:20-26` 只需在标准文件新增/移动时核对,不重复规则。 +- **`CLAUDE.md` / `peri-middlewares/CLAUDE.md`**:只在任务路由、MCP 稳定不变量或 canonical command 发生变化时更新;不能为了本次 issue 添加动态 inventory 或“迁移已完成”清单。DOC-UPDATE-001 要求只改受影响事实源(`docs/standards/documentation.md:27-31`)。 + +### 6.2 明确不能改/不能写的口径 + +- 不回填 `docs/design/mcp-adaptation-v4-part-1.md` 的具体迁移批次、提交号、现场勾选或测试耗时;设计文档已经规定这些应进入 issue/验收记录(`:9`、`:234`)。 +- 设计表中的“目标:完全下放/部分下放/宿主保留”必须继续标成目标归属,而不是实现状态(`docs/design/mcp-adaptation-v4-part-1.md:176-180`)。验收记录新增“已落地能力”列,逐项填当前可观察的实例、工具 view、ready/error 和宿主链证据;未有证据的目标写 `未落地/未验证`。 +- 不得用 `McpClientPool::new_empty()`、手造 `McpClientHandle`、绿色的 config/middleware 单测宣告契约 5/6/整体迁移完成;这些只能作为单元基线,现有例子见 `mcp/middleware_test.rs:30-52`。 + +## 7. 任务表 + +| Task ID | 标题 | 目标文件 | 改动摘要 | 验证命令 | 预估 diff 规模 | 依赖(A/B/C 的哪些产出) | +|---|---|---|---|---|---:|---| +| D-01 | System MCP ready 跨层测试 | `peri-middlewares/tests/mcp_system_ready_e2e.rs` | 局部真实 rmcp/duplex fixture;验证 transport→initialize→tools/list→ready→宿主启动及 failure/timeout/non-system 分支 | `cargo test -p peri-middlewares --test mcp_system_ready_e2e -- --test-threads=1` | 180–280 行 | A:`system_mcp`/`system_mcp_tools` 解析;B:`await_system_ready` 类接口、错误和状态;C:装配入口可观察 ready gate | +| D-02 | RCRA tool view 跨层测试 | `peri-middlewares/tests/mcp_tool_view_e2e.rs` | 验证 required schema/namespace/direct list/empty array/deferred search,实际调用真实 MCP wire | `cargo test -p peri-middlewares --test mcp_tool_view_e2e -- --test-threads=1` | 220–340 行 | B:ready 后 handle/tool declaration;C:system tools 注入函数、effective name、RCRA view | +| D-03 | MCP 实例隔离契约测试 | `peri-middlewares/tests/mcp_isolation_contract.rs` | 两个真实 transport/config fixture,断言 pool entry、owner、namespace、无隐式跨 MCP 调用;凭据/root 按可见 API 分级 | `cargo test -p peri-middlewares --test mcp_isolation_contract -- --test-threads=1` | 180–280 行 | B:独立 connection/service owner 或可观察 connection key;A:配置身份;若有 root/credential view 则使用,无则降级 | +| D-04 | 宿主策略/生命周期契约测试 | `peri-middlewares/tests/mcp_host_policy_contract.rs` | direct MCP tool 经过 Permission/HITL/effective name/session event/cancel,deferred 仍走 ToolSearch | `cargo test -p peri-middlewares --test mcp_host_policy_contract -- --test-threads=1` | 240–380 行 | B:ready/tool call cancel;C:注入函数和 tool view;宿主 assembly:可注入 broker/cancel/event 的 public seam | +| D-05 | 验收记录与契约 7 口径 | `spec/issues/2026-09-25-mcp-adaptation-v4-part-1-acceptance.md` | 新建现场验收记录;契约矩阵、命令/exit status/用例数、证据强度、目标归属≠已落地能力、PARTIAL 规则 | `git diff --check`;验收阶段再执行 D-01–D-04 命令并记录终态 | 100–180 行 | A/B/C 所有接口冻结和 D-01–D-04 结果 | +| D-06 | MCP code-index/标准口径同步 | `docs/code-index/peri-middlewares.md`、必要时 `docs/code-index/peri-acp-types.md`、`docs/standards/testing.md`、`CLAUDE.md`/模块指引 | 只同步受影响入口、canonical 命令和测试路由;不更新设计文档进度 | Markdown link check;`git diff --check`;若改 Rust 测试再跑对应 cargo 命令 | 20–80 行 | A/B/C 最终路径和 D-01–D-05 实际命令 | + +任务之间不共享目标文件:D-01–D-04 各自独占一个集成测试文件;D-05 独占验收记录;D-06 只改索引/标准/指引文件。若 D-06 发现无需同步某文件,应在验收记录写“核对但未变更”,而不是为制造 diff 改文档。 + +## 8. 风险与未知 + +- **接口冻结漂移**:如果 A/B/C 最终没有按 `system_mcp`、`system_mcp_tools`、`await_system_ready` 类函数、system tools 注入函数提供 public seam,D 先在测试文件内使用最小 adapter 适配参数重命名;adapter 只能组合 public 行为,不能 downcast 私有实现、读私有字段或重复生产逻辑。若无法观察 ready 阻塞、RCRA view 或 Permission dispatch,则测试标记 `blocked`,并把“缺失 seam”列入跨 plan 依赖,不修改 A/B/C 文件。 +- **公开字段不足**:当前 `McpClientHandle` 不暴露 transport、credential、capability root(`peri-middlewares/src/mcp/client/types.rs:83-104`),pool 的 capability profile 是 pool-wide(`peri-middlewares/src/mcp/client.rs:94-99`)。契约 5 的凭据/root 只能降级到配置归属、连接 owner、server-side sentinel 请求和 root 行为;这不构成完整契约 5 证据,验收必须保持 PARTIAL。 +- **真实 MCP server fixture 的边界**:现有 duplex fixture 是手写 JSON-RPC responder,不是完整 rmcp server;引入 rmcp `ServerHandler` 需先核对当前 crate feature/API。若 API 不稳定,保留手写 wire fixture,但在 acceptance 记录说明它证明 client transport wire,不证明独立 server 实例装配;不能改用静态工具清单。 +- **local-mcp-server 依赖风险**:独立 workspace 的 `CARGO_BIN_EXE_local-mcp-server` 只在其自身测试编译上下文有效;跨 workspace 直接依赖会产生 target/lock 竞争和不可复现的 binary 前置条件。除非 workflow 明确提供外部 binary 路径,否则不纳入根 D 测试门禁。 +- **HITL seam 未知**:未发现独立 HITL `_test.rs`;Permission fixture 有 broker,但真实 UI/HITL 交互需 public broker/session seam。没有该 seam 时不以自动批准测试冒充 HITL,标为 blocked。 +- **取消和异步稳定性**:ready timeout、server hang、进程关闭可能受 tokio scheduling 影响。使用 `Notify`、barrier、手动 server state 和 `--test-threads=1`;不以“睡眠后应该完成”作为唯一证据。测试规范要求确定性、精确错误断言和独立运行(`docs/standards/testing.md:127-180`)。 +- **既有 flake**:`spec/issues/2026-09-20-p2-parallel-lib-test-atom-races.md:1-12` 记录 macOS 全量并行测试偶发失败,`2026-09-10-meta-session-readonly-flake-unproven.md:53-90` 记录本地证据不足和平台未验证。D 测试不运行全 workspace 并行门禁;使用定向、串行、临时 HOME/fixture,并在验收记录记录首次失败、重跑次数和 flake.firstAttemptFailed。任何重跑通过都不能清除第一次失败证据。 +- **证据强度误读**:D-01/D-02 通过只说明契约 2–4 在一个真实 fixture seam 上成立;D-03 的降级断言不等于五 MCP 隔离;D-04 通过只说明当前 direct injection 未绕过已暴露宿主路径;四个测试全绿也不等于 v4 目标归属全部落地。验收裁决必须按契约矩阵逐项给出,不得以绿色局部单测宣告整体迁移。 + +## 9. 非目标 + +- 不实现 Workspace、Artifact、Web、Cron、LSP 五个真实 MCP 实例,不迁移 middleware,不共享或拆分生产 pool/transport/credentials/capability root。 +- 不修改 A/B/C 所有的 `plugin.rs`、`mcp/config.rs`、`config_test.rs`、`mcp/middleware.rs`、`mcp/client/**`、`readiness.rs`、`tool_bridge.rs`、`system_tools.rs` 或其他生产源码;需要这些变更时只在跨 plan 依赖中记录。 +- 不把 `ToolSearchMiddleware` 删除或改成 MCP;required direct injection 仅验证跳过 deferred search,保留宿主 Permission/HITL/Hook/SubAgent/Workflow/Goal/PTC 语义。 +- 不把 `local-mcp-server` 纳入根 workspace,不把独立项目测试数量算入根 workspace 验收,不新增真实外部网络或真实用户凭据测试。 +- 不修改权威设计文档以记录批次、提交、现场验收、耗时或完成清单;这些只写独立 acceptance issue。 +- 不运行 `cargo build`、`cargo test`、`cargo run`,不提交 git commit;本 session 仅完成侦察和本 sub-plan 文档。 \ No newline at end of file From 3cc19bfea97e79af73da0b325a3b3b5c4bd71733 Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Sun, 27 Sep 2026 15:29:24 +0800 Subject: [PATCH 2/5] feat(sessions)!: separate durable storage from agent execution Introduce a configurable session storage facade with deployment-owned shutdown, local migration and remote Turso/libSQL adapters. Unify remote tables and serialization with the local canonical shape, preserving read-only fallback and explicit unsupported operations. Keep the approved boundaries, migration notes and code index alongside the implementation. Co-Authored-By: deepseek-v4-flash Co-Authored-By: gpt-6-astra --- .github/workflows/ci.yml | 36 +- .typos.toml | 4 + CLAUDE.md | 60 +- Cargo.lock | 70 +- docs/code-index/peri-acp-types.md | 9 +- docs/code-index/peri-acp.md | 4 +- docs/code-index/peri-controller.md | 2 +- docs/code-index/peri-resources.md | 37 +- docs/code-index/peri-tui.md | 12 +- docs/design/message-transcript.md | 2 +- docs/design/peri-acp-protocol.md | 8 + docs/design/session-workspace-identity.md | 4 +- docs/standards/architecture-contracts.md | 4 +- docs/standards/index.md | 24 +- peri-acp-types/src/command.rs | 14 +- peri-acp-types/src/command_handler_test.rs | 2 +- peri-acp-types/src/frozen.rs | 7 +- peri-acp-types/src/lib.rs | 6 +- peri-acp-types/src/session_resources.rs | 683 ++++++ peri-acp-types/src/session_resources_test.rs | 191 ++ peri-acp-types/src/session_store.rs | 172 ++ peri-acp-types/src/session_store_test.rs | 111 + peri-acp-types/src/store/history.rs | 206 ++ peri-acp-types/src/store/history_test.rs | 307 +++ peri-acp-types/src/{store.rs => store/mod.rs} | 24 +- peri-acp-types/src/workspace.rs | 37 +- peri-acp/src/dispatch/execute_command.rs | 6 +- peri-acp/src/dispatch/execute_command_test.rs | 33 +- peri-acp/src/dispatch/list_sessions.rs | 21 +- peri-acp/src/dispatch/mod.rs | 3 +- peri-acp/src/dispatch/rewind.rs | 2 +- peri-acp/src/dispatch/session_fork.rs | 283 ++- peri-acp/src/dispatch/session_fork_test.rs | 391 ++-- peri-acp/src/dispatch/session_load.rs | 16 +- peri-acp/src/host/assemble.rs | 151 +- peri-acp/src/host/compact_recovery_test.rs | 321 ++- peri-acp/src/host/executor_flow_test.rs | 93 +- peri-acp/src/host/lifecycle.rs | 23 +- peri-acp/src/host/mcp_v4_startup_test.rs | 41 +- peri-acp/src/host/mod.rs | 15 +- peri-acp/src/host/prediction.rs | 16 +- peri-acp/src/host/prepared.rs | 193 ++ peri-acp/src/host/prepared_test.rs | 426 ++++ peri-acp/src/host/prompt.rs | 10 +- peri-acp/src/host/requests.rs | 19 +- peri-acp/src/host/requests/legacy_session.rs | 104 +- peri-acp/src/host/requests/rewind.rs | 12 +- .../src/host/requests/session_lifecycle.rs | 582 +++-- peri-acp/src/host/requests_legacy_test.rs | 120 +- peri-acp/src/host/requests_recovery_test.rs | 126 +- peri-acp/src/host/requests_test.rs | 385 ++-- peri-acp/src/host/stage_builder.rs | 2 +- peri-acp/src/host/stdio/mod.rs | 30 +- .../host/stdio/run_server_integration_test.rs | 90 +- .../host/stdio/session_store_shutdown_test.rs | 147 ++ peri-acp/src/host/workflow_agent.rs | 1 - peri-acp/src/host/workspace.rs | 170 +- peri-acp/src/prompt/mod.rs | 55 +- peri-acp/src/session/command/compact_test.rs | 375 ++-- peri-acp/src/session/command/rewind.rs | 8 +- peri-acp/src/session/frozen.rs | 22 +- peri-acp/src/session/mod.rs | 22 +- peri-acp/src/session/mod_test.rs | 110 +- peri-agent/src/agent/compact_v2/full.rs | 6 +- peri-agent/src/agent/compact_v2/full_test.rs | 120 +- .../src/agent/compact_v2/trigger_test.rs | 28 +- .../budget_recovery_integration_test.rs | 17 +- peri-agent/src/agent/stages/stages_test.rs | 20 +- peri-agent/src/agent/state.rs | 130 +- peri-agent/src/agent/workflow/agent.rs | 5 - peri-agent/src/resources.rs | 72 +- .../src/session/exec/compact_pipeline.rs | 67 +- peri-agent/src/session/exec/executor.rs | 2 +- .../src/session/exec/executor/agent_build.rs | 5 +- .../src/session/exec/executor/context.rs | 6 +- .../executor_helpers/compact_cancel_test.rs | 360 +++- .../exec/executor_helpers/intercept.rs | 28 +- .../exec/executor_helpers/v2_execute.rs | 15 +- .../src/session/exec/executor_helpers_test.rs | 2 +- .../session/exec/executor_provenance_test.rs | 13 +- peri-agent/src/session/exec/executor_test.rs | 3 +- peri-agent/src/session/exec/stage_builder.rs | 6 +- .../src/session/exec/stage_builder/agent.rs | 5 +- .../exec/stage_builder/session_setup.rs | 2 +- .../exec/stage_builder/subagent_setup.rs | 3 +- peri-agent/src/session/factory.rs | 6 +- peri-agent/src/session/mod.rs | 4 + peri-agent/src/session/subagent.rs | 2 +- peri-agent/src/session/subagent/background.rs | 20 +- peri-agent/src/session/subagent/factory.rs | 39 + .../src/session/subagent/factory/claim.rs | 230 +- .../src/session/subagent/factory/context.rs | 6 +- .../src/session/subagent/factory/resume.rs | 109 +- .../src/session/subagent/factory/spawn.rs | 165 +- peri-agent/src/session/subagent/lifecycle.rs | 25 +- .../src/session/subagent/provenance_test.rs | 101 +- peri-agent/src/session/subagent/run_sync.rs | 24 +- peri-agent/src/session/subagent/types.rs | 25 +- peri-agent/src/session/subagent_test.rs | 610 +++--- peri-agent/src/session/test_resources.rs | 112 + .../session/test_resources/mock/fixtures.rs | 117 + .../src/session/test_resources/mock/mod.rs | 431 ++++ .../session/test_resources/mock/observe.rs | 57 + .../test_resources/mock/session_resources.rs | 415 ++++ peri-agent/src/session/transcript.rs | 264 ++- .../src/session/transcript/persistence.rs | 306 ++- peri-agent/src/session/transcript_test.rs | 865 ++------ peri-agent/src/thread/mod.rs | 13 +- peri-controller/src/controller.rs | 41 +- peri-controller/src/controller_test.rs | 77 +- peri-middlewares/src/assembly_test.rs | 5 +- peri-middlewares/src/plugin/loader.rs | 85 +- peri-middlewares/src/plugin/loader_test.rs | 108 + peri-middlewares/src/plugin/mod.rs | 7 +- .../src/subagent/tool/configuration.rs | 20 +- peri-middlewares/src/subagent/tool/define.rs | 8 +- .../src/subagent/tool/execute_bg.rs | 4 +- .../src/subagent/tool/execute_fork.rs | 4 +- .../src/subagent/tool/execute_resume.rs | 12 +- .../src/subagent/tool/spawn_context.rs | 9 +- .../src/subagent/tool/tool_test.rs | 231 +- .../tool/tool_test/active_message_test.rs | 58 +- .../tool/tool_test/events_contract_test.rs | 35 +- .../subagent/tool/tool_test/invoke_test.rs | 22 +- .../tool/tool_test/model_tier_test.rs | 23 +- .../tool/tool_test/resume_integration_test.rs | 184 +- .../subagent/tool/tool_test/resume_test.rs | 267 ++- peri-resources/Cargo.toml | 8 + peri-resources/src/context.rs | 287 ++- peri-resources/src/context_test.rs | 283 ++- peri-resources/src/lib.rs | 2 +- peri-resources/src/sessions/canonical.rs | 144 ++ peri-resources/src/sessions/data.rs | 228 ++ .../src/sessions/default_path_test.rs | 4 +- peri-resources/src/sessions/filesystem.rs | 4 +- .../src/sessions/filesystem_test.rs | 2 +- peri-resources/src/sessions/local_port.rs | 191 ++ peri-resources/src/sessions/mod.rs | 58 +- peri-resources/src/sessions/open.rs | 407 ++++ peri-resources/src/sessions/open_test.rs | 741 +++++++ .../remote/cloud_deployment_child_test.rs | 628 ++++++ .../sessions/remote/cloud_deployment_test.rs | 629 ++++++ .../src/sessions/remote/cloud_history_test.rs | 314 +++ .../sessions/remote/cloud_identity_test.rs | 368 ++++ .../sessions/remote/cloud_lifecycle_test.rs | 500 +++++ .../src/sessions/remote/cloud_limit_test.rs | 662 ++++++ .../sessions/remote/cloud_mutation_test.rs | 612 ++++++ .../sessions/remote/cloud_recovery_test.rs | 133 ++ .../src/sessions/remote/cloud_session_test.rs | 429 ++++ .../src/sessions/remote/cloud_test.rs | 868 ++++++++ .../src/sessions/remote/composition.rs | 80 + .../src/sessions/remote/connection.rs | 282 +++ .../sessions/remote/connection_close_test.rs | 232 ++ .../remote/connection_recovery_test.rs | 407 ++++ .../src/sessions/remote/credentials.rs | 140 ++ .../src/sessions/remote/endpoint.rs | 181 ++ peri-resources/src/sessions/remote/failure.rs | 139 ++ .../src/sessions/remote/generation.rs | 204 ++ .../sessions/remote/initialization_test.rs | 396 ++++ peri-resources/src/sessions/remote/ledger.rs | 230 ++ .../src/sessions/remote/ledger_test.rs | 126 ++ peri-resources/src/sessions/remote/mod.rs | 221 ++ .../src/sessions/remote/mutation.rs | 780 +++++++ .../src/sessions/remote/mutation_test.rs | 200 ++ .../sessions/remote/recovery_fixture_test.rs | 474 ++++ .../src/sessions/remote/remote_test.rs | 181 ++ peri-resources/src/sessions/remote/schema.rs | 241 +++ .../src/sessions/remote/schema_test.rs | 124 ++ .../remote/session_child_guard_test.rs | 153 ++ .../src/sessions/remote/session_close_test.rs | 29 + .../src/sessions/remote/session_codec.rs | 262 +++ .../src/sessions/remote/session_data.rs | 760 +++++++ .../src/sessions/remote/session_history.rs | 632 ++++++ .../src/sessions/remote/session_lifecycle.rs | 452 ++++ .../src/sessions/remote/session_read.rs | 257 +++ .../src/sessions/remote/session_schema.rs | 40 + .../src/sessions/remote/session_shape_test.rs | 687 ++++++ .../src/sessions/remote/session_sql.rs | 442 ++++ .../src/sessions/remote/session_write.rs | 382 ++++ peri-resources/src/sessions/remote/sql.rs | 66 + peri-resources/src/sessions/resources.rs | 818 +++++++ .../src/sessions/resources/claim.rs | 138 ++ peri-resources/src/sessions/resources/gate.rs | 212 ++ .../src/sessions/resources/lifecycle.rs | 64 + peri-resources/src/sessions/resources_test.rs | 1915 +++++++++++++++++ .../sessions/sqlite_inherited_context_test.rs | 4 +- peri-resources/src/sessions/sqlite_store.rs | 323 ++- .../src/sessions/sqlite_store/compaction.rs | 87 +- .../src/sessions/sqlite_store/connection.rs | 36 +- .../src/sessions/sqlite_store/context.rs | 395 ++-- .../src/sessions/sqlite_store/database.rs | 45 + .../src/sessions/sqlite_store/execution.rs | 492 ++++- .../src/sessions/sqlite_store/failure.rs | 199 ++ .../src/sessions/sqlite_store/legacy_test.rs | 6 +- .../src/sessions/sqlite_store/local.rs | 562 +++++ .../src/sessions/sqlite_store/row_mapping.rs | 8 +- .../src/sessions/sqlite_store/schema.rs | 282 ++- .../src/sessions/sqlite_store/schema_test.rs | 59 +- .../sessions/sqlite_store/schema_v10_test.rs | 376 ++++ .../sessions/sqlite_store/schema_v7_test.rs | 370 ++++ .../src/sessions/sqlite_store/session_data.rs | 1178 ++++++++++ .../sqlite_store/session_data_test.rs | 1072 +++++++++ .../src/sessions/sqlite_store/session_rows.rs | 130 ++ .../sqlite_store/thread_child_delete_test.rs | 297 +++ .../src/sessions/sqlite_store/workspace.rs | 61 +- .../sessions/sqlite_store/workspace_test.rs | 28 +- .../src/sessions/sqlite_store_test.rs | 14 +- .../tests/session_resources_contract.rs | 411 ++++ peri-tui/locales/en/main.ftl | 4 +- peri-tui/locales/zh-CN/main.ftl | 4 +- .../src/acp_client/client/recovery_test.rs | 17 +- peri-tui/src/acp_client/client/session.rs | 6 +- peri-tui/src/app/mod.rs | 25 +- peri-tui/src/app/service_registry.rs | 6 +- peri-tui/src/cli_integration_test.rs | 68 + peri-tui/src/cli_meta.rs | 75 +- peri-tui/src/cli_meta_test.rs | 95 +- peri-tui/src/cli_print.rs | 16 +- peri-tui/src/kit/atoms.rs | 4 +- peri-tui/src/kit/entry.rs | 14 +- peri-tui/src/kit/popup_overlay.rs | 18 +- peri-tui/src/kit/popups/confirm_popup.rs | 258 ++- ...y_recovery_test.rs => risk_choice_test.rs} | 190 +- peri-tui/src/launch.rs | 12 +- peri-tui/src/launch_test.rs | 5 +- peri-tui/src/main.rs | 77 +- peri-tui/src/main_test.rs | 118 + peri-tui/src/thread/mod.rs | 10 +- scripts/check-file-size.sh | 12 +- scripts/import-exemptions.conf | 14 +- spec/issues/2026-09-26-session-store-plan.md | 187 ++ ...2026-09-26-session-store-remote-backend.md | 1677 +++++++++++++++ ...9-26-session-store-sub-plan-a-contracts.md | 234 ++ ...26-09-26-session-store-sub-plan-b-local.md | 254 +++ ...26-09-26-session-store-sub-plan-c-turso.md | 252 +++ ...-session-store-sub-plan-d-configuration.md | 176 ++ ...9-26-session-store-sub-plan-e-consumers.md | 174 ++ ...6-session-store-sub-plan-f-verification.md | 172 ++ .../2026-09-27-v4-cloud-architecture-audit.md | 81 + 239 files changed, 37997 insertions(+), 4663 deletions(-) create mode 100644 peri-acp-types/src/session_resources.rs create mode 100644 peri-acp-types/src/session_resources_test.rs create mode 100644 peri-acp-types/src/session_store.rs create mode 100644 peri-acp-types/src/session_store_test.rs create mode 100644 peri-acp-types/src/store/history.rs create mode 100644 peri-acp-types/src/store/history_test.rs rename peri-acp-types/src/{store.rs => store/mod.rs} (94%) create mode 100644 peri-acp/src/host/prepared.rs create mode 100644 peri-acp/src/host/prepared_test.rs create mode 100644 peri-acp/src/host/stdio/session_store_shutdown_test.rs create mode 100644 peri-agent/src/session/test_resources.rs create mode 100644 peri-agent/src/session/test_resources/mock/fixtures.rs create mode 100644 peri-agent/src/session/test_resources/mock/mod.rs create mode 100644 peri-agent/src/session/test_resources/mock/observe.rs create mode 100644 peri-agent/src/session/test_resources/mock/session_resources.rs create mode 100644 peri-resources/src/sessions/canonical.rs create mode 100644 peri-resources/src/sessions/data.rs create mode 100644 peri-resources/src/sessions/local_port.rs create mode 100644 peri-resources/src/sessions/open.rs create mode 100644 peri-resources/src/sessions/open_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_deployment_child_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_deployment_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_history_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_identity_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_lifecycle_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_limit_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_mutation_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_recovery_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_session_test.rs create mode 100644 peri-resources/src/sessions/remote/cloud_test.rs create mode 100644 peri-resources/src/sessions/remote/composition.rs create mode 100644 peri-resources/src/sessions/remote/connection.rs create mode 100644 peri-resources/src/sessions/remote/connection_close_test.rs create mode 100644 peri-resources/src/sessions/remote/connection_recovery_test.rs create mode 100644 peri-resources/src/sessions/remote/credentials.rs create mode 100644 peri-resources/src/sessions/remote/endpoint.rs create mode 100644 peri-resources/src/sessions/remote/failure.rs create mode 100644 peri-resources/src/sessions/remote/generation.rs create mode 100644 peri-resources/src/sessions/remote/initialization_test.rs create mode 100644 peri-resources/src/sessions/remote/ledger.rs create mode 100644 peri-resources/src/sessions/remote/ledger_test.rs create mode 100644 peri-resources/src/sessions/remote/mod.rs create mode 100644 peri-resources/src/sessions/remote/mutation.rs create mode 100644 peri-resources/src/sessions/remote/mutation_test.rs create mode 100644 peri-resources/src/sessions/remote/recovery_fixture_test.rs create mode 100644 peri-resources/src/sessions/remote/remote_test.rs create mode 100644 peri-resources/src/sessions/remote/schema.rs create mode 100644 peri-resources/src/sessions/remote/schema_test.rs create mode 100644 peri-resources/src/sessions/remote/session_child_guard_test.rs create mode 100644 peri-resources/src/sessions/remote/session_close_test.rs create mode 100644 peri-resources/src/sessions/remote/session_codec.rs create mode 100644 peri-resources/src/sessions/remote/session_data.rs create mode 100644 peri-resources/src/sessions/remote/session_history.rs create mode 100644 peri-resources/src/sessions/remote/session_lifecycle.rs create mode 100644 peri-resources/src/sessions/remote/session_read.rs create mode 100644 peri-resources/src/sessions/remote/session_schema.rs create mode 100644 peri-resources/src/sessions/remote/session_shape_test.rs create mode 100644 peri-resources/src/sessions/remote/session_sql.rs create mode 100644 peri-resources/src/sessions/remote/session_write.rs create mode 100644 peri-resources/src/sessions/remote/sql.rs create mode 100644 peri-resources/src/sessions/resources.rs create mode 100644 peri-resources/src/sessions/resources/claim.rs create mode 100644 peri-resources/src/sessions/resources/gate.rs create mode 100644 peri-resources/src/sessions/resources/lifecycle.rs create mode 100644 peri-resources/src/sessions/resources_test.rs create mode 100644 peri-resources/src/sessions/sqlite_store/database.rs create mode 100644 peri-resources/src/sessions/sqlite_store/failure.rs create mode 100644 peri-resources/src/sessions/sqlite_store/local.rs create mode 100644 peri-resources/src/sessions/sqlite_store/schema_v10_test.rs create mode 100644 peri-resources/src/sessions/sqlite_store/schema_v7_test.rs create mode 100644 peri-resources/src/sessions/sqlite_store/session_data.rs create mode 100644 peri-resources/src/sessions/sqlite_store/session_data_test.rs create mode 100644 peri-resources/src/sessions/sqlite_store/session_rows.rs create mode 100644 peri-resources/src/sessions/sqlite_store/thread_child_delete_test.rs create mode 100644 peri-resources/tests/session_resources_contract.rs rename peri-tui/src/kit/popups/{dirty_recovery_test.rs => risk_choice_test.rs} (58%) create mode 100644 spec/issues/2026-09-26-session-store-plan.md create mode 100644 spec/issues/2026-09-26-session-store-remote-backend.md create mode 100644 spec/issues/2026-09-26-session-store-sub-plan-a-contracts.md create mode 100644 spec/issues/2026-09-26-session-store-sub-plan-b-local.md create mode 100644 spec/issues/2026-09-26-session-store-sub-plan-c-turso.md create mode 100644 spec/issues/2026-09-26-session-store-sub-plan-d-configuration.md create mode 100644 spec/issues/2026-09-26-session-store-sub-plan-e-consumers.md create mode 100644 spec/issues/2026-09-26-session-store-sub-plan-f-verification.md create mode 100644 spec/issues/2026-09-27-v4-cloud-architecture-audit.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ccd9775..10195b65a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,16 @@ on: pull_request: branches: [main] +# 同一 PR 只保留最新运行;main 的每次 push 仍独立验证。 +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always + # 仅关闭 CI 调试信息,减少编译、链接与缓存开销;保留断言和溢出检查。 + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" jobs: build: @@ -31,24 +39,27 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + # 平台无关的源码检查只在 Linux 执行,并在工具链和缓存恢复前快速失败。 + # §0 依赖方向 CI 门(Seam 3 完整版,PRD 决策 2):八条边(TUI / ACP + # 业务面 / ACP→model / Controller / Runtime / Resources / Middlewares / + # Agent)× use 导入 + 全路径引用双模式全校验;豁免清单唯一事实源 = + # scripts/import-exemptions.conf。 + - name: Check §0 layer imports + if: runner.os == 'Linux' + run: bash scripts/check-layer-imports.sh + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} targets: ${{ matrix.target }} + components: clippy - name: Cache cargo registry and build uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.target }} - # §0 依赖方向 CI 门(Seam 3 完整版,PRD 决策 2):八条边(TUI / ACP - # 业务面 / ACP→model / Controller / Runtime / Resources / Middlewares / - # Agent)× use 导入 + 全路径引用双模式全校验;豁免清单唯一事实源 = - # scripts/import-exemptions.conf。 - - name: Check §0 layer imports - run: bash scripts/check-layer-imports.sh - - name: Build run: cargo build --workspace --all-targets @@ -63,10 +74,13 @@ jobs: working-directory: npm-packages/@peri-ptc run: bun run build - - name: Run tests - run: | - cargo test --workspace --exclude peri-middlewares - cargo test -p peri-middlewares -- --test-threads=1 + # 分步记录耗时,也确保 Windows 下第一组失败不会被后续成功覆盖。 + - name: Run workspace tests (excluding middlewares) + run: cargo test --workspace --exclude peri-middlewares + + # 涉及进程级全局状态的测试仍须串行执行。 + - name: Run middleware tests (serial) + run: cargo test -p peri-middlewares -- --test-threads=1 - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/.typos.toml b/.typos.toml index 323d1face..c2a8ede3c 100644 --- a/.typos.toml +++ b/.typos.toml @@ -9,6 +9,10 @@ fle = "fle" gti = "gti" bulder = "bulder" +[default.extend-identifiers] +# 用户已有的环境变量名;只豁免该标识符,不自动改名或屏蔽同类拼写错误。 +TURSO_TOEKN = "TURSO_TOEKN" + [default] # commit hash / 十六进制标识符不是拼写错误(如 59ba70b8 中的 ba) extend-ignore-re = ["\\b[0-9a-f]{7,40}\\b"] diff --git a/CLAUDE.md b/CLAUDE.md index 819f68334..c3ee0ebcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,44 +1,50 @@ - +# CLAUDE.md -# CLAUDE.md — Perihelion +Peri 是终端 AI 编程助手:用户交付任务,Agent 推进工作,过程可理解、可介入,结果可核对。长期可维护性是设计目标。 -Perihelion 是终端 AI 编程助手:用户交付任务,Agent 推进工作,过程可理解、可介入,结果可核对。长期可维护性是设计目标。 +## 项目大目标 -## 设计哲学 +v4 目标是存算分离:持久状态、Agent 计算与工具执行环境独立,本地与云端共用核心,分阶段落地。存储后端可配置,Peri 实现持久化;计算核心减少重型依赖,实例可替换、执行可恢复,支持 serverless。Agent 决策、模型推理与工具执行可分开部署。迁移状态以代码、契约测试及 active spec 为准。 -- **任务完成与用户控制共同成立。** Agent 主动推进已授权的工作,需要人判断的取舍交还用户。界面优先呈现结果、阻塞和必要决策,过程按需展开;不让用户学习内部编排才能完成任务,不以自动化为由隐藏失败或削弱取消、审批能力。 -- **模型负责判断,系统负责确定性。** 模型输出可以不确定,执行的身份、顺序、权限与终态应可验证。能由类型、协议和状态机保证的约束就在代码落实;不靠提示词弥补执行层缺口,不用重试或兜底把未知状态伪装成成功。 -- **事实与视图分离。** 先确定事实的持有者,再派生模型上下文和界面视图。压缩、缓存和渲染围绕事实构建,不另建可独立漂移的真相。 -- **边界稳定,能力可组合。** 用生命周期和职责决定状态归属,协议、执行、外部能力与界面各守边界。新能力优先接入已有扩展点;不为少写几行跨层直连,不为假想需求预建框架。 -- **成本是设计输入。** 上下文、token、CPU、内存和用户注意力都有限。按需加载、渐进披露,让历史处理、后台任务和缓存的成本有界。性能取舍依据测量,不用数据失真、关键事件丢失或不可恢复状态换取速度。 +1. **Harness**:RCRA(Receive → Compact → Reason → Act)循环执行,hook 扩展生命周期,Middleware 承载业务能力。 +2. **Sessions**:存储会话、消息及执行恢复状态,后端可替换;会话、任务运行与计算实例具有独立生命周期。 +3. **Resources**:文件系统工具、Skill、Cron 等能力经 MCP Middleware 接入;工作区与工具环境可独立于计算实例驻留。 +4. **Orchestration**:基于同构 Agent,管理 Subagent、Multitask 与 Workflow 的任务关系、协调、等待和恢复;Middleware 提供接入,编排生命周期不绑定某个活跃 Harness 实例。 +5. **Endpoint**:ACP 是统一出口协议,stdio 是本地传输方式;传输层可自定义,客户端复用同一业务语义。 -理念不代表能力已实现。取舍先守住数据、权限与生命周期契约,再比较交付收益、理解成本和运行成本。 +**内部依赖走 MCP,外部出口走 ACP**:依赖按能力消费方向定义,与部署位置无关;MCP 能力边界不强制对应独立进程,部署隔离按信任边界和生命周期确定。 + +## 核心工程原则 v1.5 + +1. **架构与领域优先**:编码前明确目标、领域边界、职责、依赖方向和数据流。复用型抽象等第二个真实用例再提取;协议隔离、依赖反转和确定的可替换边界可在首个用例建立接口,须说明当前需要。 +2. **模块封装复杂度**:接口精简、稳定,文件按职责拆分;规模限制及验证遵循 `STD-SIZE-001`。 +3. **边界与数据流清晰**:协议、领域、持久化与视图模型各守边界;在边界校验和转换,避免跨层共享可变状态。 +4. **保留维护上下文**:记录决策原因、影响与风险,临时方案注明移除条件;技术债务关联任务,关键决策同步文档,不留无上下文的 `TODO`。 +5. **删除优于兼容**:内部重构删除过时实现,不新增兼容层、deprecated shim 或双写;对外兼容义务按协议评估。 +6. **业务规则单一权威**:同一领域规则只维护一份实现;允许多个存储后端和协议 adapter 封装适配差异,共用业务规则与契约。 +7. **优先验证完整行为**:先验证用户可观察的行为,再按风险补齐回归、失败和生命周期测试;遵循 `testing.md`。 ## 行事风格 -- **像研究员一样判断,像工程师一样交付。** 区分观察、推断和假设,用代码、复现或实验形成结论。直说理由与局限,不营销、不补造数字,不把计划或命令启动当成完成。 -- **在授权范围内主动闭环。** 常规选择依据仓库证据自行处理;改变用户目标、权限或不可逆结果的歧义及时澄清。不同意方案时说明代价并给出替代方案,不迎合,也不把日常判断推给用户。 -- **改动要小而完整。** 沿因果链修复,覆盖受影响的调用方、契约和文档;不遮盖症状,不混入无关重构。必要重构以减少本次问题的复杂性为界;交付说明改动、验证证据和未验证项。 +- **像研究员一样判断,像工程师一样交付。** 区分观察、推断和假设,以代码、复现或实验支持结论;说明局限,不补造数字,不把命令启动当成完成。 +- **在授权范围内主动闭环。** 依据仓库证据处理常规选择;澄清目标、权限或不可逆结果的歧义。异议须说明代价和替代方案。 +- **改动要小而完整。** 沿因果链覆盖调用方、契约和文档;重构限于本次问题,交付说明改动、验证证据和未验证项。 ## 代码风格的取舍 -- **显式表达领域语义。** 命名体现职责,类型表达身份、状态和错误;所有权与副作用沿调用链可见。避免用字符串约定、布尔组合和隐式共享状态承载关键语义。 -- **降低理解成本。** 优先清楚的控制流、小接口和内聚实现;抽象应封装变化,减少调用方的认知负担。少量重复可接受,不为消除重复制造通用层、无语义转发或参数开关集合。 -- **遵循邻近模式,解释必要例外。** 格式、依赖和惯用法沿用已有实践;注释解释不变量、取舍与非显然原因。局部模式违反契约时修正问题,不机械复制。细则见 [rust.md](docs/standards/rust.md)。 +- **显式表达领域语义。** 命名与类型表达职责、身份、状态和错误;所有权和副作用可见,避免字符串约定与隐式共享。 +- **降低理解成本。** 控制流清楚、接口精简;允许少量结构重复,避免制造通用层或参数开关集合。 +- **遵循邻近模式。** 沿用格式和惯用法,注释解释不变量与取舍;违反契约的模式应修正。细则见 [rust.md](docs/standards/rust.md)。 ## 测试风格的取舍 -- **测试保护行为和契约。** 按场景断言可观察结果:纯逻辑看输入输出,边界看序列化、错误、顺序与生命周期。内部重构不应迫使无关测试跟着改;不靠复制一遍实现来证明正确。 -- **测试要能揭示目标故障。** 回归测试暴露原问题,并在修复后通过;覆盖相关失败、取消与边界情况。外部不确定性在边界替换,内部关键链路用真实实现;不以全套 mock 自洽推导生产可用。 -- **验证力度随风险扩大。** 从目标测试开始,跨层验证完整链路,进程、恢复或平台承诺验证相应生命周期。测试要确定、隔离、可独立运行;不追求用例数量,不为样板代码制造负担。范围、门禁和证据见 [testing.md](docs/standards/testing.md)。 +- **保护行为和契约。** 按场景断言输入输出、序列化、错误与顺序;避免复制实现或让无关测试耦合内部重构。 +- **揭示目标故障。** 回归测试须暴露原问题,修复后通过;外部不确定性在边界替换,内部关键链路用真实实现。 +- **随风险扩大验证。** 覆盖相关失败、取消、进程恢复和平台生命周期;测试应确定、隔离、可独立运行。范围与证据见 [testing.md](docs/standards/testing.md)。 ## 事实源与任务路由 -信息优先级:代码/契约测试 > `docs/standards/` > 模块 `CLAUDE.md` > `docs/design/` > active spec > history。此顺序核对现行行为,不把缺陷当作目标;变更时同步事实源。 - -先读 [标准索引](docs/standards/index.md)。定位先查 `docs/code-index/`,按意图找主文件、核实入口符号,变更时同步索引。loader 不继承父目录,需显式读取模块指引。 +先读 [标准索引](docs/standards/index.md),区分现状与目标。按 `docs/code-index/` 核实入口、同步变更。Peri loader 不继承父目录,须显式读取模块指引。 | 任务 | 先读 | | --- | --- | @@ -52,9 +58,9 @@ Perihelion 是终端 AI 编程助手:用户交付任务,Agent 推进工作 | 文档站 | `peri-cool/CLAUDE.md` + documentation | | 历史学习 | `.claude/skills/learn-from-history/SKILL.md` | -简称均指 `docs/standards/`:architecture = `architecture-contracts.md`,其余同名。跨层边界、prompt、事件、工具、中间件顺序或安全变更先读 architecture;Git 操作读 `git.md`,指引维护读 `documentation.md`。 +简称指 `docs/standards/` 同名文件,architecture 指 `architecture-contracts.md`;跨层、prompt、事件、工具、链序或安全变更读 architecture,Git 操作读 `git.md`,指引维护读 `documentation.md`。 -设计:`docs/design/README.md`;需求:`spec/issues/`;历史:`spec/global/problems.md`。主路径 `peri-tui → peri-acp → peri-agent::run_react_loop`,退出语义见 Agent 指引;workspace 以 `Cargo.toml` 为准。 +设计:`docs/design/README.md`;需求:`spec/issues/`;历史:`spec/global/problems.md`。主路径 `peri-tui → peri-acp → peri-agent::run_react_loop`;退出语义查 Agent 指引,workspace 查 `Cargo.toml`。 ## Workspace 命令 @@ -67,4 +73,4 @@ lefthook run pre-commit cargo clippy --workspace --all-targets -- -D warnings ``` -按变更范围选择命令;改 doc comment 跑 doc tests;E2E 命令见其指引。完成前按 `DOC-UPDATE-001` 核对路由。未经用户要求不 commit。 +按范围选择命令;改 doc comment 跑 doc tests;E2E 查其指引。交付前按 `DOC-UPDATE-001` 核对路由。未经要求不 commit。 diff --git a/Cargo.lock b/Cargo.lock index b51c19f67..b24b4d792 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,6 +1079,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -2669,9 +2679,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4108,6 +4120,7 @@ dependencies = [ "peri-acp-types", "peri-lsp", "peri-workflow", + "reqwest", "serde", "serde_json", "sha2 0.10.9", @@ -4115,6 +4128,8 @@ dependencies = [ "tempfile", "tokio", "tracing", + "turso_serverless", + "url", "uuid", "windows-sys 0.61.2", ] @@ -5096,8 +5111,10 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -5106,6 +5123,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -5310,7 +5328,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -5452,7 +5470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6257,6 +6275,27 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -6900,6 +6939,22 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "turso_serverless" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4548bc9f685877497b6a74c4da874a72e03cc8306e77ac812fd6bd53d0ff803e" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "type-map" version = "0.5.1" @@ -7557,6 +7612,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/docs/code-index/peri-acp-types.md b/docs/code-index/peri-acp-types.md index aad6e8691..089a1b8b0 100644 --- a/docs/code-index/peri-acp-types.md +++ b/docs/code-index/peri-acp-types.md @@ -14,14 +14,14 @@ | 我想做什么 | 主文件 | 入口/关键函数 | 关键逻辑 | | --- | --- | --- | --- | -| 改项目、工作区与执行绑定协议 | `src/workspace.rs` + `src/store.rs` + `src/peri_caps.rs` | `ProjectId`、`WorkspaceId`、`SessionBinding`、`ResolvedWorkspace`、`ThreadScope`、`ScopedThreadQuery`、`SessionExecutionLease`、`RecoveryRequiredDetails`、`WorkspaceErrorData`、`ReadOnlyAdmission`、`ResetDirtyRequest`、`ThreadStore::reset_dirty_execution`、`ThreadStore::{validate_session_binding,reassert_session_binding}`、`PeriCaps::session_recovery_v1` | 身份独立于路径;ThreadStore封装发现/验证/lease与SQL scope;`validate_session_binding` 是准入级复核(关系 + 关键文件对象 + 一次完整发现,一次准入只调用一次),`reassert_session_binding` 供准入内后续检查使用(同上但不启动外部进程);Peri扩展经sessionWorkspaceV1显式协商,错误不得当空列表或legacy绑定;dirty 详情只携带精确 `(thread_id, generation)`,`WorkspaceErrorData`/`ResetDirtyRequest` 用 `deny_unknown_fields` 严格解析(缺字段即拒绝),显式解除路径(`peri/session_reset_dirty`)由 `peri.sessionRecoveryV1` 门控,默认关闭;宿主在准入时替未协商该能力的连接解除 dirty 不经这条 RPC,见 `peri-acp` 索引;`ReadOnlyAdmission` 是准入降级的原因(他处持有 / 精确 dirty 代际 / 本节点不提供所有权),与 `WorkspaceErrorData` 同为 adjacently tagged,置于 `_meta.peri.sessionWorkspaceV1.read_only`,只覆盖可从错误降级的三种原因(`from_workspace_error`),其余失败原样上报;`WorkspaceError::ReadOnlyStore` 不属于可降级原因——只读存储连会话都还没有 | +| 改项目、工作区与执行绑定协议 | `src/workspace.rs` + `src/store/mod.rs` + `src/peri_caps.rs` | `ProjectId`、`WorkspaceId`、`SessionBinding`、`ResolvedWorkspace`、`ThreadScope`、`ScopedThreadQuery`、`SessionExecutionLease`、`RecoveryRequiredDetails`、`WorkspaceErrorData`、`ReadOnlyAdmission`、`ResetDirtyRequest`、`ThreadStore::reset_dirty_execution`、`ThreadStore::{validate_session_binding,reassert_session_binding}`、`PeriCaps::session_recovery_v1` | 身份独立于路径;ThreadStore封装发现/验证/lease与SQL scope;`validate_session_binding` 是准入级复核(关系 + 关键文件对象 + 一次完整发现,一次准入只调用一次),`reassert_session_binding` 供准入内后续检查使用(同上但不启动外部进程);Peri扩展经sessionWorkspaceV1显式协商,错误不得当空列表或legacy绑定;dirty 详情只携带精确 `(thread_id, generation)`,`WorkspaceErrorData`/`ResetDirtyRequest` 用 `deny_unknown_fields` 严格解析(缺字段即拒绝),显式解除路径(`peri/session_reset_dirty`)由 `peri.sessionRecoveryV1` 门控,默认关闭;宿主在准入时替未协商该能力的连接解除 dirty 不经这条 RPC,见 `peri-acp` 索引;`ReadOnlyAdmission` 是准入降级的原因(他处持有 / 精确 dirty 代际 / 本节点不提供所有权),与 `WorkspaceErrorData` 同为 adjacently tagged,置于 `_meta.peri.sessionWorkspaceV1.read_only`,只覆盖可从错误降级的三种原因(`from_workspace_error`),其余失败原样上报;`WorkspaceError::ReadOnlyStore` 不属于可降级原因——只读存储连会话都还没有;`WorkspaceErrorData` 只含 `peri.recoveryRequiredV1`(可由客户端分派的具体修复动作),与本集合之外的失败区分 | | 改后台任务与外部执行排空契约 | `src/tasks.rs` | `TaskManager::{spawn_owned,begin_external_execution,execution_cancel_token,shutdown}`、`ExternalExecutionGuard`、`TaskShutdownReport` | 请求取消与实际停止分开;UI活跃数不是执行证据;确认外部停止不抢先改变Defer/完成事件顺序 | | 改后台 Shell 输出引用 | `src/event.rs` + `src/tasks.rs` | `BackgroundTaskResult::shell_output`、`ShellOutput`、`TaskManager::finalize_bg_shell` | 可选 DTO 保持旧数据可读;stdout/stderr 路径、完整性、落盘错误和已知退出码由采集端提供;通知不携带输出正文,DTO 不执行文件 I/O | | 改用户待发送 wire 契约 | `src/session/user_input.rs` + `src/session/queue.rs` + `src/event_v2/{types,executor_mapping}.rs` | 四类 `UserInput*Request`;`UserInputQueueSnapshot` / `UserInputQueueReceipt`;`withdraw_user_inputs` | generation/revision、稳定输入及命令身份、实际运行 request ID;只精确撤出 UserInput,不影响后台消息;三个 canonical 事件经既有 ACP 链路投影,能力为 `peri.userInputQueue`(ARC-EVENT-001) | | 改旧 Compact 上下文的传输兼容 | `src/compact_reminder.rs` + `src/system_reminder.rs` | `legacy_compact_reminders`;`encode_legacy_system_reminder` | 精确识别 plain-text Human 的文件/Skill 回注及摘要格式,仅在模型投影和 ACP replay 出口生成 Legacy reminder;保留数据库原文,分块并转义正文,不提升可信来源;kind 区分 `compact_file` / `compact_skill` / `compact_summary`,供客户端显示简短类型标题 | -| 改 compact 继承与失败契约 | `src/store.rs` + `src/session/execution.rs` + `src/error.rs` | `InheritedContext`;`ThreadStore::{store_inherited_context,load_inherited_context}`;`PromptResult::default`;`AgentError::CompactBudgetUnrecovered` | 版本化 payload/flags 快照校验版本与 ID 完整性;缺失 PromptResult 默认不可恢复热历史;Full 后持续高压给出安全错误文案;ARC-COMPACT-001 | +| 改 compact 继承与失败契约 | `src/store/mod.rs` + `src/session/execution.rs` + `src/error.rs` | `InheritedContext`;`ThreadStore::{store_inherited_context,load_inherited_context}`;`PromptResult::default`;`AgentError::CompactBudgetUnrecovered` | 版本化 payload/flags 快照校验版本与 ID 完整性;缺失 PromptResult 默认不可恢复热历史;Full 后持续高压给出安全错误文案;ARC-COMPACT-001 | | 改 CompactConfig 阈值 | `src/compact.rs`(`CompactConfig` 事实源,struct :210;`peri-agent/src/agent/compact_v2/config.rs:8` 仅 re-export;加载方 `peri-acp/src/host/compact_config.rs:14`;`peri-acp/src/provider/config.rs:194` 可挂配置) | 字段:`auto_compact_threshold`(默认 0.95)、`micro_compact_threshold`(默认 0.75)、`micro_compact_stale_steps`(默认 3)、`smart_compact_enabled`(deprecated、默认 false,但运行时仍尊重 true,:246);`apply_env_overrides`(:325);`has_valid_micro_field_limits`(:316) | serde 反序列化仅对 `auto_compact_threshold` 经 `deserialize_threshold_range`(:185)clamp 到 [0.0,1.0] 并 warn;`micro_compact_threshold` 当前不走该 helper;`DISABLE_COMPACT` → 禁用 + micro 阈值=1.0,`DISABLE_AUTO_COMPACT` → 仅禁 auto,`COMPACT_THRESHOLD` 校验后仅覆盖 auto 阈值(:326-339) | -| 改 System Reminder 契约/codec/筛选 | `src/system_reminder.rs` + `src/session/{queue,inbox}.rs` + `src/store.rs` + `src/event.rs` | `SystemReminder` / `TrustedSystemReminderFactory`;`encode_system_reminder` / legacy parser;`QueuedPayload::SystemReminder`、`InboxHandle::push_system_reminder`;`PersistedPayload::SystemReminder`;`ExecutorEvent::SystemReminder` | producer 只构造 trusted canonical DTO;`MessageKind` 继续独立决定 wake;模型 wire 编码仅在 transcript 投影边界;持久化与 ACP event 保留结构化字段,未知/损坏版本 fail closed | +| 改 System Reminder 契约/codec/筛选 | `src/system_reminder.rs` + `src/session/{queue,inbox}.rs` + `src/store/mod.rs` + `src/event.rs` | `SystemReminder` / `TrustedSystemReminderFactory`;`encode_system_reminder` / legacy parser;`QueuedPayload::SystemReminder`、`InboxHandle::push_system_reminder`;`PersistedPayload::SystemReminder`;`ExecutorEvent::SystemReminder` | producer 只构造 trusted canonical DTO;`MessageKind` 继续独立决定 wake;模型 wire 编码仅在 transcript 投影边界;持久化与 ACP event 保留结构化字段,未知/损坏版本 fail closed | | 改 BaseTool trait / is_direct 默认值 | `src/tools.rs`(trait 事实源;`peri-agent/src/tools/mod.rs:8` re-export;实现方在 peri-middlewares 各工具) | `BaseTool`(:146);`is_direct`(:199,默认 **false** = deferred);`context_retention`(:193,默认 `Preserve`);`timeout`(:170,默认 120s);`definition`(:152 组合 name/desc/params);`derive_title_from_name`(:70) | 默认值即行为契约:新工具不覆写 `is_direct` 即为 deferred(经 SearchExtraTools 发现);`context_retention` 默认 Preserve = 不被压缩;`ToolContext`(:129)只读借用 state,工具不可绕过 dispatch 统一写入 | | 改 CancelRequest 三元组 | `src/identity.rs`(`CancelRequest` 事实源 :262;`CancelPolicy` 事实源 `src/thread/types.rs:17`) | `CancelRequest::new(identity, policy)`(:273,clear_queue 默认 **false**);`with_clear_queue`(:282);`AttemptIdentity`(:140,四元组) | 定位四元组 (session_id, session_epoch, turn_id, attempt_id),**幂等判定取三元组** (session_id, turn_id, attempt_id);epoch 不可复用(`SessionEpoch::next` :70 只增);cancel ≠ 清除待办;消费方仅传递不解释语义:controller `cancel`(controller.rs:336)、runtime `cancel`(runtime.rs:169)、`RuntimePort::cancel`(`src/runtime.rs:85`)、prompt_handle(peri-acp:20 / peri-agent:23) | | 改 v2 事件枚举与身份提取 | `src/event_v2/types.rs`(`src/event_v2.rs` 保留公共 re-export) | `RenderEvent` / `StateEvent` / `ObserveEvent` / `Event` / `TurnErrorReason`;`turn_id` / `agent_id` | 三层事件强制身份字段;TurnCompleted 保持 Render FIFO,ProtocolEvent 保留系统提醒的协议载荷;类型定义不持发送端或执行状态 | @@ -111,7 +111,8 @@ | 功能 | 入口/关键点 | | --- | --- | | MCP server 配置契约 | `plugin.rs`(`McpServerConfig` :46,`system_mcp` / `system_mcp_tools` / `system_mcp_timeout` 三字段与 camelCase 别名;`validate` :220;`McpServerConfigValidationError` :241;`DEFAULT_SYSTEM_MCP_TIMEOUT_MS` :209 / `MIN` :211 / `MAX` :213)——`Deserialize` 为手写实现(`McpServerConfigWire`),解析期即拒绝非法组合与显式 `null`;消费方(`peri-middlewares/src/mcp/config.rs` 的 direct/global/merged 入口、`plugin/loader.rs` 的 MCP 严格路径、`mcp/transport.rs`)各自复检 `validate`;`None` 与 `Some([])` 必须可区分并无损写回 | -| 线程/存储 | `thread/types.rs`(`CancelPolicy` :17、`AgentStatus` :56、`ThreadMeta` :126);`store.rs`(ThreadStore/CompactionLifecycle/MessageFlags) | +| 线程/存储 | `thread/types.rs`(`CancelPolicy` :17、`AgentStatus` :56、`ThreadMeta` :126);`store/mod.rs`(ThreadStore/CompactionChange/MessageFlags,ThreadStore 是待 E 阶段退出的迁移桥);`store/history.rs`(纯历史变换:fork 重映射、投影 flag、flags 批次、追加 ID 冲突检测、rewind 边界、compaction 变更应用,不生成 UUID/不读时钟/不碰数据库) | +| 会话资源门面 | `session_resources.rs`(`SessionResources` 门面:24 个行为全部为必需方法、无 no-op 默认;`AccessMode` / `DataCapabilities` / `ExecutionAvailability` / `SessionAvailability` 三事实互不推导;`NewSession` / `ForkSnapshot` / `ChildSnapshot` / `SessionSnapshot` / `BindingState` / `FrozenState` / `SessionMetaPatch` / `RewindBoundary` / `FrozenSnapshotBytes`;`SessionResourceError`(原因 `SessionResourceErrorKind` + 效果 `MutationOutcome`,`Unknown` 只来自未决持久化,`SavedButNotAdmitted` 已生效);`ChildResumeClaim`、`PersistenceRecovery`;业务门面**不含关闭**——关闭整个存储由 `SessionStoreShutdownPort::shutdown` 单独承载(部署生命周期端口,non-Clone,由 TUI/print/stdio 宿主在任务排空后消费);关闭不可逆地停止新写入,但只有真实检查(在途写入、未决持久化)全部结清后才确认关闭:未确认的重复调用必须重新检查,未确认期间恢复/排空仍可用);`SessionResourceError::{kind, effect, is_persistence_uncertain, workspace_error, read_only_admission}`(只读降级集合 = 三种既有 workspace 原因 + `ReadOnlyStore`,`PersistenceUncertain` 不进入))——生产实现 `SessionResourcesImpl` 已落(见 peri-resources 索引),消费侧迁移见 E | | 冻结数据 | `frozen.rs`(`FrozenData` :26、`ThreadPersistence` :39)——会话创建时冻结,SubAgent 复用 | | 运行端口 | `runtime.rs`(`RuntimePort`,`cancel` :85);`ports.rs`(McpPoolPort/ToolSearchPort/WorkflowMiddlewarePort/SkillsPort/AcpMcpServerPort/AcpMcpGatewayPort) | | MCP over ACP 契约 | `acp_mcp.rs`(`AcpMcpServerSpec`、`AcpMcpInbound`、`AcpMcpError`:`CODE_NOT_FOUND` / `CODE_UNAVAILABLE`);`ports.rs`(`AcpMcpGatewayPort` 出站切片、`AcpMcpServerPort`:`attach` / `request` / `notify` / `owns_connection` / `close_session`)——只描述协议载荷与运行时标识,不依赖 rmcp 或具体 transport;实现在 `peri-middlewares/src/mcp/acp/`,host 接线在 `peri-acp/src/host/requests/acp_mcp.rs`(契约 ARC-MCP-ACP-001) | diff --git a/docs/code-index/peri-acp.md b/docs/code-index/peri-acp.md index debedae92..be84eafac 100644 --- a/docs/code-index/peri-acp.md +++ b/docs/code-index/peri-acp.md @@ -19,7 +19,7 @@ | 改插件 marketplace 搜索 | `src/host/requests/plugin.rs` + `plugin_search_test.rs` | `handle_search` / `search_marketplace_plugins` | 经 PluginManagerPort 获取缓存目录,复用 `plugin::marketplace::find_marketplace_json` 读取根或 `.claude-plugin` 布局;名称、描述、marketplace 名均忽略大小写匹配;无匹配明确返回空数组;回归经真实 `handle_request` 读取临时磁盘目录 | | 改 compact 后失败恢复 | `src/host/prompt.rs` + `src/host/compact_recovery_test.rs` | `finish_prompt_turn` | 不按 `ok` 丢弃可信 canonical snapshot;取消/模型或 forwarder 失败仍保留已提交 Full 摘要;persistence_inconsistent 移除热会话,冷加载恢复磁盘,ARC-COMPACT-001 | | 改 System Reminder producer/ACP 投影 | `src/session/dynamic_mcp.rs` + `src/host/continuation.rs` + `src/session/event_sink.rs` + `src/dispatch/session_replay.rs` | `SessionDynamicMcpNotificationSink`;`enqueue_cron_trigger`;`push_system_reminder`;`send_system_reminder` | Dynamic MCP lifecycle/OAuth 与 Cron trigger 直接入 canonical queue;不改变 OAuth/cron 控制;ACP client 声明 `peri.systemReminder` 时收结构化 event,否则只收展示 fallback;load/replay 不伪装 user message;旧 Compact plain-text Human 经 `compact_reminder::legacy_compact_reminders` 生成 Legacy 通知,MPSC/stdio 共用出口 | -| 新增/改会话协议方法 | `src/host/requests.rs`(注册面,`handle_request` :22,按方法分派到 `host/requests/{session_lifecycle,plugin,config_options,mcp_oauth,workflow,rewind}.rs`);`src/host/server_loop.rs`(`session/prompt` 单独处理,spawn 后台 task);`src/session/frozen_snapshot.rs`(版本化 frozen owner state);`src/dispatch/session_fork.rs`(fork payload 独立复制);stdio 侧部署装配点 `src/host/stdio/mod.rs`(`run_acp_stdio` 持有进程日志初始化,`assemble_stdio_config` 只装配配置,业务处理走统一 `run_acp_server`) | `handle_new/load/resume/fork`(requests/session_lifecycle.rs);`handle_reset_dirty`(`peri/session_reset_dirty`,需 `peri.sessionRecoveryV1` 与显式 `accept_risk`);`fork_session`;`encode_frozen_snapshot` / `decode_frozen_snapshot`;`after_new_response`;其余 plugin/config/workflow/rewind handler | new 持久化 frozen 后才发布 session;load/resume 冷恢复原快照,未绑定 legacy 根恢复经 `requests/legacy_session.rs::prepare_for_restore` 按保存 cwd 原子接纳 binding + 缺失 frozen、loser 重读 winner;已绑定缺快照保持错误,未知/损坏版本及存储错误 fail closed;fork 继承 source frozen,并以新 `MessageId` 复制 payload/compact flags,使新 thread 独立拥有可压缩历史;new/fork 写失败补偿删除。`session/load` 保持 response 前 replay/通知;load/resume 补载驻留空历史时同步 canonical payload 与消息投影,后续 prompt/fork 从同一 payload 读取;`session/prompt` 是唯一 spawn 后台执行的方法;stdio 与 TUI 共用统一 host | +| 新增/改会话协议方法 | `src/host/requests.rs`(注册面,`handle_request` :22,按方法分派到 `host/requests/{session_lifecycle,plugin,config_options,mcp_oauth,workflow,rewind}.rs`);`src/host/server_loop.rs`(`session/prompt` 单独处理,spawn 后台 task);`src/session/frozen_snapshot.rs`(版本化 frozen owner state);`src/dispatch/session_fork.rs`(fork payload 独立复制);stdio 侧部署装配点 `src/host/stdio/mod.rs`(`run_acp_stdio` 持有进程日志初始化,`assemble_stdio_config` 只装配配置,业务处理走统一 `run_acp_server`) | `handle_new/load/resume/fork`(requests/session_lifecycle.rs);`new_session_from_prepared`(new 的发布段:消费已定格准备输入写 meta/binding/frozen 并发布,不重读配置/插件、不重建 frozen);`handle_reset_dirty`(`peri/session_reset_dirty`,需 `peri.sessionRecoveryV1` 与显式 `accept_risk`);`fork_session`;`encode_frozen_snapshot` / `decode_frozen_snapshot`;`after_new_response`;其余 plugin/config/workflow/rewind handler | new 持久化 frozen 后才发布 session;load/resume 冷恢复原快照,未绑定 legacy 根恢复经 `requests/legacy_session.rs::prepare_for_restore` 按保存 cwd 原子接纳 binding + 缺失 frozen、loser 重读 winner;已绑定缺快照保持错误,未知/损坏版本及存储错误 fail closed;fork 继承 source frozen,并以新 `MessageId` 复制 payload/compact flags,使新 thread 独立拥有可压缩历史;new/fork 写失败补偿删除。`session/load` 保持 response 前 replay/通知;load/resume 补载驻留空历史时同步 canonical payload 与消息投影,后续 prompt/fork 从同一 payload 读取;`session/prompt` 是唯一 spawn 后台执行的方法;stdio 与 TUI 共用统一 host | | 改 prompt 执行流程(keepgoing/挂起注入/错误响应) | `src/host/prompt.rs` + `src/host/prompt_dispatch.rs` + `src/session/executor.rs` | `run_prompt`;`prompt_wire_response` / `execution_failure_to_acp_error`;`dispatch_prompt_turn`;`session/executor.rs` **仅 re-export** `peri_agent::session::exec::executor` 的执行入口(ARC-BOUNDARY-001) | 挂起时 prompt 注入 inbox;keepgoing 短路在 Agent 层;重试中的 `LlmRetrying` 是进度事件,不结束 prompt;仅 fatal `PromptResult.failure` 在历史/state/cancel-token 后处理完成后映射为 `session/prompt` JSON-RPC server error(`-32000`):message 保留脱敏限长后的 LLM/provider 原意,allowlist data 携带 `kind` 与可选 HTTP `status`、受控 diagnostic facts;ACP 不序列化完整 AgentError/ModelError/provider body;cancel/interrupted/max iterations/输出截断预算耗尽仍返回携带对应停止原因的标准 `PromptResponse`,协议成功不代表任务完成(ARC-OUTPUT-COMPLETION-001);mpsc/stdio 共用统一 host | | 改事件映射(ExecutorEvent → 协议) | `src/event/mapper.rs` + `src/event/tool_projection.rs` + `src/event/mod.rs` + `src/event/activity.rs` + `src/session/event_sink.rs` + `src/session/event_sink/{legacy,stdio}.rs` + `src/dispatch/session_replay.rs` | `map_event`;`map_agent_activity`;`project_tool_start` / `project_tool_completion`;`tool_result_content`(保留 mapper public 路径);`TransportEventSink::push_event`;`push_legacy_event`;`StdioEventSink::push_event`;`AcpEvent` DTO | Transport sink 依次发送标准 update → safe activity → legacy,两个扩展面按各自 caps 门控;工具 live/replay 的 kind、完成状态、展示内容与 safe failure meta 由 `tool_projection` 统一;adapter 保留来源、replay 标记及 rawOutput 格式差异。ToolEnd live/replay 使用标准 `failed`/`completed`,同时写标准 `ToolCallUpdate.content` 与兼容 `rawOutput`,失败空文本有安全 fallback;SubAgent 来源写入 ACP 标准 `SessionNotification._meta.peri.sourceAgentId`(mpsc/stdio 同构,typed SDK 往返保留);`CompactStarted/CompactCompleted` 经 `peri/agent_event` 透传 strategy、trigger 与安全计数供 TUI 展示;`BgRegistryEvent` 是私有功能载体:无标准 `SessionUpdate`,TUI 私有事件仍按 `agent_event` cap 门控,Hub/Web 仅经 `map_agent_activity` 输出去正文、哈希 correlation 的 capability-gated allowlist 摘要;契约 ARC-EVENT-001 | | 改 Goal 状态、持久化与客户端投影 | `src/session/goal_state/mod.rs` + `src/session/event_sink/legacy.rs` + `src/event/{mod,mapper}.rs`;契约 DTO 在 `peri-acp-types/src/{goal,event,event_v2}.rs` | `GoalState::snapshot`;`GoalController::increment_continuation`;`StateEvent::GoalSnapshot` → `ExecutorEvent::GoalSnapshot` → `AcpEvent::GoalSnapshot` | continuation 计数归 session Goal 状态持有并随 Goal 持久化;Agent 每轮发只读快照,event sink 按 `agent_event` capability 投递给客户端,TUI 不直读 Agent/Middleware;契约 ARC-BOUNDARY-001 / ARC-EVENT-001 | @@ -116,7 +116,7 @@ | 消息循环与请求分类 | host/server_loop.rs | `ServerLoop::run`;`spawn_prompt` / `spawn_mcp_apps_request` / `spawn_acp_mcp_request` 经 task owner 准入;`dispatch_request` 保持 response → after_new_response → 会话 setup 声明受理(`session_setup` + `requests::acp_mcp::attach_session_servers`);`mcp/message` 通知在 `dispatch_notification` 内就地路由 | | Prompt 编排 / 预测 | host/prompt_dispatch.rs + host/prediction.rs | `dispatch_prompt_turn` 保留 host 根 re-export;`spawn_prediction` 在原 prompt lock 范围内准入 | | OAuth 事件投递 | host/oauth_delivery.rs | `spawn_oauth_consumer` / `deliver_oauth_event`;safe 与 legacy caps 分别裁决 | -| EOF 收尾 | host/shutdown.rs | `shutdown_host` 借用唯一强 owner;先撤销准入,再取消并 drain 会话,最后关闭 LSP/MCP | +| EOF 收尾 | host/shutdown.rs + host/lifecycle.rs | `shutdown_host` 借用唯一强 owner;先撤销准入,再取消并 drain 会话,最后关闭 LSP/MCP;`HostExitContext::finish` 在排空为 `Complete` 之后消费部署关闭权(`AcpServerConfig::session_store_shutdown`,仅装配点注入),未确认的关闭报 `Incomplete` 并保留上下文重试(重复关闭重新做真实检查) | | 方法注册面(mpsc) | host/requests.rs + host/requests/*.rs | `handle_request`(requests.rs:22,30 个方法分派到子模块;各 handle_* 均为 `pub(super)` 定义在对应子文件) | | MCP over ACP(client 声明的 `type: "acp"` server) | host/requests/acp_mcp.rs + host/server_loop.rs + host/workspace.rs | `attach_session_servers`(setup 响应后受理 `mcpServers`);`AcpTransportGateway`(`AcpMcpGatewayPort` 实现:`mcp/connect` / `mcp/message` / `mcp/disconnect` 出站);`route_inbound`(按 `connectionId` 定位承载会话服务);`SessionEnvironment::shutdown` 调 `AcpMcpServerPort::close_session` | 会话 setup 各自解析、会话级服务持有连接;建连在后台(不阻塞会话建立,失败留在 MCP 池状态面),内层 MCP 错误码原样透传;契约 ARC-MCP-ACP-001 | | notification 处理 | host/notify.rs | `handle_notification`(:28)/`extract_session_id`(:153);`host/unify_wire_baseline_test.rs` 锁定发射面 payload 与 schema typed `SessionNotification` 的逐字段一致性;统一 host 入口见 ARC-STDIO-001 与 `docs/design/architecture.md` | diff --git a/docs/code-index/peri-controller.md b/docs/code-index/peri-controller.md index 3b92daea5..712d636c0 100644 --- a/docs/code-index/peri-controller.md +++ b/docs/code-index/peri-controller.md @@ -26,7 +26,7 @@ peri-model 和 langfuse-client 是现行 Langfuse 适配依赖,不能由索引 | 订阅与排空事件 | `peri-controller/src/controller.rs` | `subscribe`:465、`pop_events`:472、`Subscription::recv`:131、`try_recv`:142 | 队列有界满丢弃,广播 Lagged 可恢复;退订只 drop receiver,无额外簿记 | | 注册与定位会话 | `peri-controller/src/controller.rs` | `register_session`:327、`run_session`:311、`session_ids`:350、`contains_session`:355 | register_or_replace 归 Runtime;Controller 只转发,不解释执行结果 | | 等待、销毁或注入会话 | `peri-controller/src/controller.rs` | `join_session`:364、`destroy_session`:385、`submit_input`:406 | 捕获 Runtime Arc 后调用;销毁返回的已补打事件经 publish 按顺序双投递 | -| 注入部署端口 | `peri-controller/src/controller.rs` | `Controller::new`:198、`with_runtime`:216、`with_resources`:223、`with_mcp_pool`、`with_cron_scheduler`、`with_tool_search`、`with_lsp_servers` | builder 消费 self 后赋值;对应 pick 方法克隆句柄/配置,不引入共享可写配置 | +| 注入部署端口 | `peri-controller/src/controller.rs` | `Controller::new`、`with_runtime`、`with_mcp_pool`、`with_cron_scheduler`、`with_tool_search`、`with_lsp_servers` | builder 消费 self 后赋值;对应 pick 方法克隆句柄/配置,不引入共享可写配置 | | 调整启动参数 | `peri-controller/src/controller.rs` | `AgentRef`:49、`LiteParams`:70 | 仅承载定义引用、cwd、初始消息和工具;消费与执行归 Agent | | 配置与创建 Langfuse 批处理 | `peri-controller/src/langfuse/session.rs` + `langfuse-client/src/{config,batcher}.rs` | `LangfuseSession::new`;`Batcher::try_new` | 生产构造在 spawn 前拒绝零容量/零间隔/容量超限,沿既有 Option 路径返回 None 并记录安全诊断;重试参数只归 LangfuseClient,Batcher legacy max_retries 不覆盖;`session_test.rs` 覆盖非法配置 | | 关闭部署 Langfuse | `peri-controller/src/langfuse/session.rs` | `LangfuseSession::new_owned`;`LangfuseShutdownOwner::shutdown`;`LangfuseSession::shutdown` | fresh deployment 得到不可克隆的关闭权限;只转发唯一 Batcher join,报告包含已由 turn 观察的累计 HTTP 失败并区分 worker 失败;turn-facing SessionLike 仍只提供 flush(ARC-HOST-SHUTDOWN-001) | diff --git a/docs/code-index/peri-resources.md b/docs/code-index/peri-resources.md index dfd676126..590146632 100644 --- a/docs/code-index/peri-resources.md +++ b/docs/code-index/peri-resources.md @@ -1,6 +1,6 @@ # peri-resources 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-12(模块职责拆分与 compact/历史恢复修复合并) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-27(存储登记/准入撤销:v10 删除本机远程痕迹表,门面回到「配置即用」,`SessionDataPort::register_store`、`LocalExecutionPort::admission` 与 `StoreAdmission`、`ExecutionAvailability::NoLocalRegistration`、`remote/{registration,local_execution}.rs` 一并移除;`sqlite_store/{remote_operations,remote_execution,store_registration}.rs` 与 `host_facts.rs` 删除,`execution_runs` 回到 `thread_id` 单键(无 store 维度),`recover_persistence` 收敛为「会话数据可读即已收敛」;同日回归收口:`threads` 行删除改为**显式**先删子表行(`session_rows::THREAD_CHILD_DELETES` + `delete_thread_child_rows`,级联降为安全网,不变量见 `sqlite_store/thread_child_delete_test.rs`),远端新建恢复 root-only 输入判定(`remote/session_write.rs`,回归 `remote/session_child_guard_test.rs`));同日**会话存储模式统一**:远端 adapter 改为直接说本机形状的 SQL(表名/列名/删除语句全部取自新的 `src/sessions/canonical.rs`,`peri_sessions`/`peri_session_messages` 与扁平绑定列删除,canonical 顺序统一到 `messages.rowid`),远端 `peri_store_meta.schema_version` 与本机 `CURRENT_SCHEMA_VERSION` 同源、契约推进到 `peri.session.store/v2`(旧形状库拒绝且不迁移),远端写打开把 `PRAGMA foreign_keys` 归位(父行在远端没有来源) > 依据:peri-resources/src 源码、lib.rs 模块注释(伞形 PRD 决策 20) ## 架构速览 @@ -13,13 +13,25 @@ | 我想做什么 | 主文件 | 入口/关键函数 | 关键逻辑 | | --- | --- | --- | --- | -| 改工作区身份、执行锁与项目会话列表 | `src/sessions/sqlite_store/{discovery,workspace,execution}.rs` | `resolve_workspace`、`create_bound_thread`、`adopt_legacy_thread`、`validate_session_binding`、`reassert_session_binding`、`acquire_execution_lease`、`reset_dirty_execution`、`list_scoped_threads` | Git common dir 区分项目,checkout 区分工作区;一次准入至多一次完整 Git 发现(`resolve_workspace` 或 `validate_session_binding`),准入内的后续复核用 `reassert_session_binding`:SQL 关系加关键文件对象(`Discovery::reassert_key_objects`),不启动外部进程,目录替换/换位/Git 位置消失仍然失败;Git 发现只用旧版也认得的选项(common dir 由 Git 写入的 `commondir` 文件推导,不请求 `--git-common-dir`;相对输出按 cwd 还原,`worktree list` 不支持 `-z` 时退回换行分隔、子命令整体缺失时跳过成员交叉核对,真实失败不退回);Git 可执行文件缺失时以 cwd 建目录工作区,已有绑定仍复核完整快照;权限/损坏/中途失败不降级;默认 threads.db;对象身份只使用 Unix device/inode 或 Windows volume/file index,schema 3→4 在事务内规范化旧 identity JSON;开库不回填绑定;列表保留 nullable binding 的旧历史,恢复时原子接纳旧根 binding + frozen;lease 覆盖含旧未绑定子会话的写入;`acquire_execution_lease` 复用同一 stable lock 文件,发现前代 `clean=0` 时以 `WorkspaceError::RecoveryRequired(target)` 返回精确 `(thread_id, generation)`;`reset_dirty_execution` 只在同一锁内以事务 CAS 精确解除该代际,锁被活 owner 持有时是 `ExecutionBusy` 而非可解除的 dirty;`lock_execution` 在有界预算内重试(10ms 间隔、最多 500ms)以吸收子进程 `fork` 到 `exec` 窗口内被继承描述符造成的瞬时持有(`CLOEXEC` 只在子进程 exec 时生效),预算耗尽后仍按 `ExecutionBusy` 上报;同一目录对象再次解析时复用原项目/工作区 ID,仅刷新观测快照(`git init` / 移除 `.git` 不再 `NeedsRelink`);Git 未回答的目录观测不覆盖已登记仓库布局;登记键是 (canonical root, 该目录的文件对象证据) 组合,同一路径的新对象或同一对象的新路径各自登记(新项目/新工作区),旧绑定按各自证据复核并失败关闭,项目只在定位与证据同时一致时复用(linked worktree 换位仍属原项目);只读打开的 store 不写:观测快照的 UPDATE 在只读下跳过,未登记目录与新线程登记在进入 SQL 前按 `WorkspaceError::ReadOnlyStore` 失败(读请求按「本节点没有这条登记」失败,而不是交给 SQLite 报只读) | -| 打开全部资源(会话存储) | `src/context.rs` | `Resources::open`;`Resources::open_with`;`open_with_default`(注入默认路径的 seam);`open_read_only` / `degradable_open_failure`;`Resources::thread_store` | 默认路径 `~/.peri/threads/threads.db`(`SqliteThreadStore::default_path` 与只读入口共用 `sessions::default_database_path`);先写打开,写打开失败且属于可恢复占用(schema 锁被占、库文件/WAL 不可写)时降级为只读打开并记 `tracing::warn!`——「写打不开」不等于「历史读不了」,不再挡住进入;不认识的 schema(`UnsupportedSchemaVersion`/`UnsupportedDatabaseSchema`)在写打开走到版本判定时不降级;写打开在版本判定前失败(锁被占、库或目录不可写)时降级只按读取兼容的列形状把关、不复查 `user_version`,由更新构建写入且列形状兼容的库可被只读读取;只读打开也失败时返回写打开的原错误;不使用共享临时数据库 fallback | -| 只读打开已有 session 数据库 | `src/sessions/mod.rs` + `src/sessions/sqlite_store/connection.rs` | `open_thread_store_read_only`;`SqliteThreadStore::open_existing_read_only`;`SqliteThreadStore::require_writable`;`probe_load_meta_shape`;`classify_shape_probe_failure`;`ReadOnlyThreadStoreError` | 显式路径或默认路径只选择一个已存在普通文件;SQLite 使用 read-only、`create_if_missing(false)`、单连接和有界 busy timeout;按 `load_meta` 所需 schema shape fail closed,不创建目录/数据库、不初始化或迁移 schema;只有表/列缺失或 `SQLITE_CORRUPT`/`SQLITE_NOTADB` 才判定 schema 不兼容,锁竞争与 IO 等瞬时失败归 `database_unreadable`;同一入口也是启动降级的落点(`Resources::open_with` 写打开失败后复用);只读 store 的写入在进入 SQL 前被 `require_writable` 按 `WorkspaceError::ReadOnlyStore` 拒绝,不把「attempt to write a readonly database」留给 SQL 层 | -| 改会话存储 SQL 实现 | `src/sessions/sqlite_store.rs`(唯一 pool owner / ThreadStore impl)+ `sqlite_store/connection.rs`(连接/close)+ `sqlite_store/schema.rs`(事务升级) | `SqliteThreadStore::new`;`close`;`default_path`;`init_schema`;`ThreadStore` impl;轻量列表 `list_thread_entries`;`load_frozen_snapshot` / `store_frozen_snapshot_if_absent` | trait 方法须与 `peri-acp-types/src/store.rs::ThreadStore` 签名一致;`close` 等待连接释放并要求 `-wal`/`-shm` 收尾已完成(仅 `Drop` 返回不保证);frozen owner state 存在独立 nullable `frozen_context` 列且不进入 list projection,写入使用 `IS NULL` CAS(ARC-FROZEN-001);TUI 列表查询只投影 thread 摘要并在 SQL 层按 cwd/hidden/message_count 过滤;另含 compaction 生命周期与 context cache | +| 改工作区身份、执行锁与项目会话列表 | `src/sessions/sqlite_store/{discovery,workspace,execution}.rs` | `resolve_workspace`、`create_bound_thread`、`adopt_legacy_thread`、`validate_session_binding`、`reassert_session_binding`、`acquire_execution_lease`、`reset_dirty_execution`、`list_scoped_threads` | Git common dir 区分项目,checkout 区分工作区;一次准入至多一次完整 Git 发现(`resolve_workspace` 或 `validate_session_binding`),准入内的后续复核用 `reassert_session_binding`:SQL 关系加关键文件对象(`Discovery::reassert_key_objects`),不启动外部进程,目录替换/换位/Git 位置消失仍然失败;Git 发现只用旧版也认得的选项(common dir 由 Git 写入的 `commondir` 文件推导,不请求 `--git-common-dir`;相对输出按 cwd 还原,`worktree list` 不支持 `-z` 时退回换行分隔、子命令整体缺失时跳过成员交叉核对,真实失败不退回);Git 可执行文件缺失时以 cwd 建目录工作区,已有绑定仍复核完整快照;权限/损坏/中途失败不降级;默认 threads.db;对象身份只使用 Unix device/inode 或 Windows volume/file index,schema 3→4 在事务内规范化旧 identity JSON;开库不回填绑定;列表保留 nullable binding 的旧历史,恢复时原子接纳旧根 binding + frozen;lease 覆盖含旧未绑定子会话的写入;`acquire_execution_lease` 复用同一 stable lock 文件,发现前代 `clean=0` 时以 `WorkspaceError::RecoveryRequired(target)` 返回精确 `(thread_id, generation)`;`reset_dirty_execution` 只在同一锁内以事务 CAS 精确解除该代际,锁被活 owner 持有时是 `ExecutionBusy` 而非可解除的 dirty;`lock_execution` 在有界预算内重试(10ms 间隔、最多 500ms)以吸收子进程 `fork` 到 `exec` 窗口内被继承描述符造成的瞬时持有(`CLOEXEC` 只在子进程 exec 时生效),预算耗尽后仍按 `ExecutionBusy` 上报;执行代际与 sidecar 锁**只有一个执行域**(v10 撤销了按 store 分区的内部类型 `ExecutionDomain`):`execution_runs.thread_id` 就是 thread id 原文,锁名是 `local_lock_name` 给出的 `.lock`(落在 `.execution-locks/`),既有行与既有锁名逐字节不变(升级不改本机身份);远程会话在本机不造 `threads` 行,但仍各自一行代际、一把锁,busy/dirty/clean 都按 thread id 判定(`reset_dirty` 只 CAS 这一行,`mark_clean` 只宣告这一行);同一目录对象再次解析时复用原项目/工作区 ID,仅刷新观测快照(`git init` / 移除 `.git` 不再 `NeedsRelink`);Git 未回答的目录观测不覆盖已登记仓库布局;登记键是 (canonical root, 该目录的文件对象证据) 组合,同一路径的新对象或同一对象的新路径各自登记(新项目/新工作区),旧绑定按各自证据复核并失败关闭,项目只在定位与证据同时一致时复用(linked worktree 换位仍属原项目);只读打开的 store 不写:观测快照的 UPDATE 在只读下跳过,未登记目录与新线程登记在进入 SQL 前按 `WorkspaceError::ReadOnlyStore` 失败(读请求按「本节点没有这条登记」失败,而不是交给 SQLite 报只读) | +| 打开全部资源(会话存储) | `src/context.rs` | `Resources::open`;`Resources::open_with`;`open_with_default`(注入默认路径的 seam);`open_read_only` / `degradable_open_failure`;`Resources::{session_resources,into_parts,into_session_resources}`;`SessionStoreShutdownOwner::take`(仅 crate 内装配可见) | 默认路径 `~/.peri/threads/threads.db`(`SqliteThreadStore::default_path` 与只读入口共用 `sessions::default_database_path`);先写打开,写打开失败且属于可恢复占用(schema 锁被占、库文件/WAL 不可写)时降级为只读打开并记 `tracing::warn!`——「写打不开」不等于「历史读不了」,不再挡住进入;不认识的 schema(`UnsupportedSchemaVersion`/`UnsupportedDatabaseSchema`)在写打开走到版本判定时不降级;写打开在版本判定前失败(锁被占、库或目录不可写)时降级只按读取兼容的列形状把关、不复查 `user_version`,由更新构建写入且列形状兼容的库可被只读读取;只读打开也失败时返回写打开的原错误;不使用共享临时数据库 fallback;`Resources` **不可克隆**:业务句柄(`Arc`,可克隆)经 `session_resources`/`into_parts` 交给 Agent/Controller/middleware,全局关闭权只由 non-Clone 的 `SessionStoreShutdownOwner`(`impl SessionStoreShutdownPort`)承载,随部署装配在任务排空之后消费;`into_session_resources` 只给没有部署生命周期的调用点(只读命令/测试夹具) | +| 按部署参数打开(本机/远程后端选择) | `src/context.rs`(唯一后端选择点)+ `src/sessions/open.rs`(locator/引擎名/凭证来源的纯解析)+ `src/sessions/remote/composition.rs`(远程装配) | `Resources::open_deployment`;`SessionStoreOpenRequest::{from_deployment,resolve_locator,access,credential_source}`;`ResolvedLocator`;`RemoteEndpoint::parse`;`open_remote` | 部署参数(`SessionStoreDeployment`)在此一次性转成 typed open request:形态/引擎名/凭证来源冲突在任何 I/O(含建目录、开库、连接)之前失败;定位按**类型**分派——`--db-path` 归一为已确认本机路径(`SessionStoreLocator::LocalPath`,不进 locator 解析:不解 `env:`、不判 URL、非 UTF-8 字节不经字符串往返),`--session-store` 原文(`SessionStoreLocator::Locator`)才按词法解析(`env:` 是间接定位);本机 locator 走既有 SQLite 装配,远程 locator 走「远端数据 adapter + 本机执行面」的真装配,**不静默回落**;**配置即用**:配了哪个 store 就直接用,「本机未登记 → 拒绝执行」这一步已按用户裁决撤销(v10 删表),远程能否执行只取决于本机执行面是否可写、绑定复核与 root owner 是否成立;显式只读只读打开本机执行面库(`DatabaseNotFound` 如实失败、不退化成空库),不建目录/库/锁,只读打开未初始化的远端 store 直接拒绝;环境变量只在 `env:` locator 与凭证来源两处被读,存在云 URL/token 变量不切换后端;端到端云回归见 `src/sessions/remote/cloud_deployment_test.rs`(只读零副作用) | +| 只读打开已有 session 数据库 | `src/sessions/mod.rs` + `src/sessions/sqlite_store/connection.rs` | `open_session_resources_read_only`(crate 内);`SqliteThreadStore::open_existing_read_only`;`SqliteThreadStore::require_writable`;`probe_load_meta_shape`;`classify_shape_probe_failure`;`ReadOnlyThreadStoreError` | 显式路径或默认路径只选择一个已存在普通文件;SQLite 使用 read-only、`create_if_missing(false)`、单连接和有界 busy timeout;按 `load_meta` 所需 schema shape fail closed,不创建目录/数据库、不初始化或迁移 schema;只有表/列缺失或 `SQLITE_CORRUPT`/`SQLITE_NOTADB` 才判定 schema 不兼容,锁竞争与 IO 等瞬时失败归 `database_unreadable`;同一入口也是启动降级的落点(`Resources::open_with` 写打开失败后复用);只读 store 的写入在进入 SQL 前被 `require_writable` 按 `WorkspaceError::ReadOnlyStore` 拒绝,不把「attempt to write a readonly database」留给 SQL 层 | +| 改会话存储 SQL 实现 | `src/sessions/sqlite_store.rs`(唯一 pool owner / ThreadStore impl)+ `sqlite_store/connection.rs`(连接/close)+ `sqlite_store/schema.rs`(事务升级) | `SqliteThreadStore::new`;`close`;`default_path`;`init_schema`;`ThreadStore` impl;轻量列表 `list_thread_entries`;`load_frozen_snapshot` / `store_frozen_snapshot_if_absent` | trait 方法须与 `peri-acp-types/src/store/mod.rs::ThreadStore` 签名一致;`close` 等待连接释放并要求 `-wal`/`-shm` 收尾已完成(仅 `Drop` 返回不保证);frozen owner state 存在独立 nullable `frozen_context` 列且不进入 list projection,写入使用 `IS NULL` CAS(ARC-FROZEN-001);TUI 列表查询只投影 thread 摘要并在 SQL 层按 cwd/hidden/message_count 过滤;另含 compaction 生命周期与 context cache | +| 改远程会话数据 adapter(Turso)与操作账本 | `src/sessions/canonical.rs`(**两种执行器共用的那一份 canonical schema**:表名、逐条建表/建索引清单、显式删除语句、`role` 派生)+ `src/sessions/remote/session_data.rs`(`SessionDataPort` 实现、`recover_persistence`)+ `remote/{session_read,session_write,session_sql,session_codec,session_schema}.rs` + `remote/{session_history,session_lifecycle}.rs` + `remote/{mutation,ledger,schema,failure,connection,credentials,endpoint,generation}.rs` + `remote/composition.rs` | `RemoteSessionData::{open,store,recover_persistence,close,root_for}`;`RemoteStore::{new,connect,generation,apply_qualified,apply_qualified_reporting,close_operation,resolve_operation}`(后两者是终态封闭工具,现在只有云实验调用:生产恢复路径不再按本机日志向账本求证);`ConnectionGate::{mint,is_invalid,invalidate,lease}`;`ConnectionFactory` / `RemoteConnectionFactory`;`RemoteTransport`(`SdkTransport` 为生产实现);`OperationId::{mint,from_record}`;`OperationIdentity::with_digest`;`LedgerRow`;`StoreIdentityOutcome::{Created,Existing}`;`MutationOutcome` / `OperationResolution`;`force_parent_checks_off` | 远端会话表**就是**本机形状(`threads`/`messages`/`session_bindings`/`projects`/`workspaces`,DDL 与删除语句都取自 `sessions::canonical`),没有第二套表名或列名映射:绑定住在 `session_bindings`,canonical 历史顺序由 `messages.rowid` 承载(原先的 `ordinal` 列删除);版本标记仍是 `peri_store_meta` 单行(服务端拒绝写 `PRAGMA user_version`),但 `schema_version` 直接取本机 `CURRENT_SCHEMA_VERSION`、契约推进到 `peri.session.store/v2`——旧形状的库(v1 契约,或「有 `peri_sessions` 却没有本构建身份」的库)在写打开时**拒绝且不迁移**;`projects`/`workspaces` 在远端是空表(workspace 证据是本机事实、远端没有来源),因此每次写打开把 `PRAGMA foreign_keys` 归位(父行检查在远端无从满足,引用完整性由显式父子写入/删除顺序保证);每次领域调用铸造**唯一操作 id**(输入摘要只进身份做一致性校验,id 不由内容派生:同一次领域调用重复发生就是两次操作,重试不复活原 id——本机日志已随 v10 删除,没有「按已落盘的原 id 恢复」这条路);资格写入是**远端原子批的第一条语句**(发送前本机不再登记任何东西,也没有「确定终态才结清」这一步:资格与效果同生共死);本机不再按 **store 域**问未结清范围(`session_remote_operations` 已删,没有本机记录可查,也没有可被结清的记录);`recover_persistence` 收敛为「会话数据仍可读即已收敛」——只复核数据事实读得到,不逐条向账本求证(进程崩溃前发出的远端请求是否生效已不再判定,这是被撤销的能力而非遗漏);账本行仍是资格与封闭证据、**没有任何清理路径删它**(显式云实验的清理器只删本轮 `{run}%` 合成会话/消息,收据只增不减并报告条数,复核走新连接);一次端口调用 = 一个托管批 + 批内资格守卫,0 行受影响不以成功收场;身份初始化返回**结构化竞争结论**(`Created` = 本事务确切插入元数据行并提交,`Existing` = 唯一键冲突后读回的既有身份或已有数据)——只有 `Created` 才产出 `CreatedByThisOpen`,丢响应/超时等未知结果直接失败、不发身份;只读打开不建 schema、不认识的 schema 不覆盖;`close` 的幂等只属于**确认关闭**:连接一开始关闭就离开业务路径(业务读一律失败、不重连),但真实资源被保留在关闭句柄里——关闭失败或在途关闭被丢弃都可重试,重试关的是**同一条**连接(不新建连接),只有真实关闭成功返回才算确认,空槽(从来没有过连接)不谎报成功;关闭的完成条件是两条:本机传输面关闭走完 + 本进程活跃租约上没有未结清写入(在途写入与 `is_uncertain`;本机已无 durable 锚点,判定不按 store 作用域提问);`turso_serverless` 0.1.3 的 `Connection::close` 恒返回 `Ok(())` 且吞掉远端关闭错误,因此 SDK 的成功不证明服务端连接已释放,也不证明未知 writes 没执行(本机不再留 durable 锚点,进程崩溃前的未知写不再可判定:未结清只在活跃租约上表达);凭证/端点/SDK 类型不出本模块,显式云回归见 `remote/cloud_*_test.rs`(P5–P7 边界实验 `cloud_limit_test.rs`、跨进程恢复 `cloud_recovery_test.rs`;真进程强杀演练 `cloud_kill_*_test.rs` 已随被删的本机事实端口一并撤销)(`#[ignore]`,键名由 `PERI_CLOUD_URL_KEY`/`PERI_CLOUD_TOKEN_KEY` 选择器给出);**连接按代际记账**:一次已经发出的在途调用被丢弃(取消、20s 预算超时)或传输类失败时,用到它的那一代连接被记为失效——守卫在 future 的 drop 点**同步**落下这个事实,`RemoteSessionData::store` 在下一次访问时按同一份打开事实重建(新连接只做只读的 store 身份重核实,然后替换槽位、关闭退场连接),失效判定按代际**单调累积**(只增不减的失效水位,`代际 ≤ 水位` 才算失效),因此迟到任务标记旧代际不会碰到新连接(新代际号晚于水位诞生),已经落下的失效事实也不会被更早的代际号撤销(否则一条无法证明的连接会被当回可用的);重建**不**自动重发任何 mutation(本机已无未决记录可依据:重建只替换连接,未结清仍由活跃租约上的标记承载),关闭开始后不再重连(确认关闭、关闭失败、在途关闭被取消都不复活业务读、也不重建连接),只读打开的重建不写 schema、不写账本、不登记(本地确定故障证据:`remote/connection_recovery_test.rs`) | +| 改本机 schema 版本、升级与 v10 回退 | `src/sessions/sqlite_store/schema.rs`(受控升级与回退)+ `src/sessions/sqlite_store/connection.rs`(只读形状探针) | `inspect`;`SchemaState`;`CURRENT_SCHEMA_VERSION`;`SqliteSessionDatabase::init_schema` / `migrate_schema`;`ensure_execution_runs_without_foreign_key`;`drop_remote_local_state`;`DROPPED_LOCAL_TABLES`;`require_columns`;`open_existing_read_only` / `classify_shape_probe_failure` | 建表/建索引清单与远端**同源**(`sessions::canonical::CREATE_TABLES` / `CREATE_INDEXES`,逐条执行),升级路径先做同名对象预检(canonical 表名上若立着 VIEW 之类的异类对象 → 拒绝,不让 `IF NOT EXISTS` 静默跳过);DDL 与 `PRAGMA user_version` 在同一 `BEGIN IMMEDIATE` 事务提交,失败整体回滚(版本不动、可重试);不认识的版本以 `UnsupportedSchemaVersion{found,supported}` 拒绝、必需表/列缺失以 `UnsupportedDatabaseSchema` 拒绝,都不降级;**v10 回退**一次性删除 v7..v9 写下的 5 张本机远程痕迹表(`session_store_registrations`、`session_lifecycle_commitments`、`session_remote_operations`、`remote_execution_runs`、`remote_lifecycle_commitments`),**先按列形状校验再 DROP**(形状不符说明是同名的别的业务表 → 不删并拒绝升级,fail-closed)、表不存在即跳过(幂等),不触碰 `threads`/`messages`/`session_bindings`/`workspaces`/`projects`,也不删改 `execution_runs` 的任何一行(远程会话可能残留代际行,按用户裁决是有效事实);`execution_runs` 收敛到 v7 形状(去掉指向 `threads` 的外键:远程模式下本机没有 `threads` 行,级联删除会抹掉别的机器持有的执行代际),已有带外键的表逐行复制重建并校验前后行数,行内容一字不改;只读打开不读也不写 `user_version`、不建表,按必需列形状放行(缺列按 `classify_shape_probe_failure` 如实分类) | | 改消息读写/祖先链 | `src/sessions/sqlite_store/context.rs`(trait 委托入口在 `sqlite_store.rs`) | `store_inherited_context`;`load_inherited_context`;`load_context_payloads`;`resolve_ancestor_chain`;`load_payloads_up_to` | child 的版本化只读继承快照及 frozen flags 存于独立 `inherited_context` 列;own payloads 只来自当前 thread;legacy 逐边读取 child metadata 保存的截止 ID,指定截止不存在或循环 fail closed;未记录截止时继承区为空;snapshot 损坏或未来版本不得覆盖,旧快照缺失时无法重建历史时刻的 flags | | 改 compaction 持久化 / flags / 回滚删除 | `src/sessions/sqlite_store/compaction.rs`;trait 入口仍在 `sqlite_store.rs` | `commit_compaction_lifecycle`(:78);`update_message_flags`(:44);`delete_messages_since`(:173) | compact flags、追加消息、message_count 与 cache epoch 在同一事务提交;精确检查被更新消息归属当前 thread,缺失消息导致整批回滚 | | 改测试用文件存储 | `src/sessions/filesystem.rs` | `FilesystemThreadStore`;`new`;`default_path`;`frozen_snapshot_path`;`store_inherited_context` / `load_inherited_context`;`atomic_write_json_if_absent` | 纯测试用途(sessions/mod.rs:3),生产实现是 sqlite;frozen snapshot 使用每 thread 的 `frozen.json` sidecar,不写入 `index.json`,继承上下文另存 `inherited.json`;完整 temp + hard-link 提供 no-clobber write-once | +| 改数据端口 seam(新门面的数据面) | `src/sessions/data.rs` | `SessionDataPort`(crate 内部 trait:`save_new_session` / `load_snapshot` / `append_history` / `save_fork` / `save_child` / `apply_compaction` / `apply_message_projections` / `rewind_history` / `delete_tree` / `recover_persistence` / `drain` / `close` …);`ChildResumeRecord`、`ensure_child_relation`(child 快照父子/根归属自洽的唯一判定,门面与两个 adapter 共用) | 业务侧拿不到本 trait:只有门面(`SessionResourcesImpl`,B-03 已落)调用;每个方法都有生产调用方(`save_new_session` 由远程组合调用;flags 没有独立入口,随 `load_snapshot` 返回);执行授权(owner/dirty/准入/排空)属执行面、不在端口里;端口不暴露事务/CAS/隔离级别/连接/SQL batch/重试令牌;后置条件「整体生效或整体不生效」由 adapter 自行选机制;契约语义见 `peri-acp-types::session_resources` | +| 改 SQLite 数据面行为(new/fork/child/legacy/snapshot/append/投影/compact/rewind/删除/metadata/列表) | `src/sessions/sqlite_store/session_data.rs` | `SqliteSessionData`(`impl SessionDataPort`;只有门面 `SessionResourcesImpl` 持有);私有原语 `binding_row_state_on`、`thread_tree_on`、`thread_root_on`、`frozen_bytes_on`、`refresh_history_derivations` | 与执行/登记面共用同一 `SqliteSessionDatabase`(同一 pool、同一个库);同一个行为一个事务:`save_new_session`/`save_fork`/`save_child` 一次落 meta+binding+frozen(fork/child 另落 canonical 历史/继承区),`delete_tree` 在同一事务里先删整棵树的 `execution_runs` 行与子表行(`session_rows::THREAD_CHILD_DELETES`)再删 `threads` 行——**子行清理是显式的,不借 `ON DELETE CASCADE`**:级联只在本机 SQLite 上存在、远端执行器提供不了(传输面实测结论见母 issue §9.28 的例外 2/3/4),删外键要重建实盘库的表,所以声明保留为安全网、承重的是显式语句(新增 `REFERENCES threads` 子表会被 `sqlite_store/thread_child_delete_test.rs` 拦下);v10 起不再写删除墓碑:删除即删除,`load_snapshot` 在单连接延迟事务内读 meta/binding/frozen/自有 payload/flags/继承区;append 碰撞(批次内或库存)明确失败而不是 `INSERT OR IGNORE`;child 的 frozen 必须逐字节等于 root 已保存快照、root 归属必须与 parent 链一致、绑定身份必须继承父会话,且写入前复用门面同一条 `ensure_child_relation` 拒绝「声明与 `target.meta` 不一致」的快照(不经门面直接调用数据面时同样安全);`rewind_history` 区分 `KeepThrough`(保留目标)与 `RemoveFrom`(移除目标及之后),未知截止点无变更;`load_meta`/列表用 `THREAD_META_COLUMNS`(不加载 `cached_context` 正文);只读打开或端口 `close()` 后的 mutation 在进入 SQL 前失败;数据面不再回答「有没有未决」(`has_pending_persistence` 随 v10 移除):本机写入与执行代际同事务完成,未结清只在门面的活跃租约上表达 | +| 改会话资源门面(消费侧唯一入口) | `src/sessions/resources.rs` | `SessionResourcesImpl::open` / `open_existing_read_only` / `default_database_path`;`impl SessionResources`(全部行为);私有 `execution_availability`、`classify_binding`、`admit_saved_creation` | 把本机执行面与数据面组合成唯一入口:每个 mutation 先过 `MutationGate`(能力/权限 → 本 root 有效 owner),再按 `MutationOutcome` 结清写入准入(`Applied`/`NotApplied` 才 `finish`,`Unknown` 留给 `Drop` 置未决);只读打开时已有会话的写入按 `ReadOnlyStore` 失败(历史可读、执行权不可得),需要登记新身份/新绑定的写入按 `Workspace(ReadOnlyStore)` 失败(没有可降级的对象);`classify_binding` 的 legacy 认定只属于本机组合:数据在远端(`home == SessionDataHome::RemoteStore`)时原样返回,不去本机表里找来源证据;`create_session` 走本地塌缩(OS 预留 + 数据与执行代际同一事务),identity 已存在时收敛准入或返回 `SavedButNotAdmitted`;`save_fork` 先完整落库再准入,失败同样如实报告;`abandon_initialization` 校验传入 lease 就是本进程这条 identity 的活 owner,再关闭准入、等待在途写入、撤销数据、释放锁;`claim_child_resume` 在 root 的写侧门禁内完成「读状态 + 写 active」;`save_child` 的写入门禁挂在 root owner 上(child 自身既无 identity 也无执行代际,用 target id 解析得不到 owner,取消后就没有未决证据),且在**任何副作用之前**先按 `ensure_child_relation` 拒绝自身不一致的快照(落库用的是 `target.meta.parent_thread_id`,只校验声明会写出独立 root);`delete_session_tree` 需要活 owner 且无未决写;`drain_persistence`/`close` 有界等待在途写入并报告未结清(`close` 只停止准入,不代写 clean、不关连接池);`close` 是**部署关闭行为**(`pub(crate)`,业务 trait 不含它):调用点只有 `SessionStoreShutdownOwner`,业务侧只拿 `Arc`,没有任何关闭全局存储的路径;关闭按生命周期 `Open → Closing → Closed` 推进(`resources/lifecycle.rs`):`Closing` 起停止新写入但保留恢复/排空权限,只有真实检查(活跃租约的在途写入与 `is_uncertain`;本机已无 durable 锚点)全部结清并关闭数据面之后才确认 `Closed`;失败或取消的关闭停在 `Closing`,重复关闭必须重新检查,只有确认关闭的重复调用才幂等成功;数据面(远程下是唯一连接句柄)在结清证据完成之后才关,不提前取走;`take` 与 `close` 都是 `pub(crate)`:crate 外没有第二条取得关闭权的路径(按具体类型打开只服务 I/O;`sessions::open_session_resources_read_only` 同样只在 crate 内) |`recover_session_persistence` 先等本进程在途写入(有界)再让数据面判定,不需要调用方提供任何令牌 | +| 改本机执行面(发现/登记/owner/dirty/创建准入) | `src/sessions/sqlite_store/local.rs` | `LocalExecution`:`resolve_workspace`、`validate_binding_value`、`legacy_confirmed`、`owner_lease`、`live_owner`、`execution_state`、`write_guard`、`exclusive_guard`、`acquire_lease`、`reset_dirty`、`dispose_execution`、`create_with_lease`、`admit_existing`、`abandon_initialization`、`live_leases`、`same_lease` | 与数据面共用同一个 `SqliteSessionDatabase`;`create_with_lease` 是本地塌缩:先占稳定 OS 锁,再在一个 `BEGIN IMMEDIATE` 内校验绑定关系、拒绝被撤销/删除过的 identity、写 `threads`+`session_bindings`+`execution_runs`(gen 1, clean=0),失败则不留任何行;`admit_existing` 只为「数据已保存、执行代际未写」的收敛补准入(已有代际时拒绝插队);`legacy_confirmed` 只看本机来源证据(无绑定/无父/无代际 + 保存的绝对 cwd 落在已登记工作区内);`same_lease` 按分配地址判定「是不是同一次所有权」 | +| 改远程组合与本机执行面(数据在远端、执行事实在本机) | `src/sessions/remote/composition.rs`(装配)+ `src/sessions/local_port.rs`(本机执行面端口)+ `src/sessions/resources/gate.rs`(门面双端口) | `open_remote`(凭证解析 → 本机执行面 → 远端打开 → 装配);`SessionResourcesImpl::from_ports`;`LocalExecutionPort`(`is_read_only`、`resolve_workspace`、`validate_binding_value`、`legacy_confirmed`、`execution_state`、`owner_lease`、`live_owner`、`live_leases`、`write_guard`/`exclusive_guard`、`acquire_lease`、`reset_dirty`、`create_session`、`admit_existing`、`abandon_initialization`、`dispose_execution`);`SessionDataPort`;`SessionDataHome::{LocalLibrary,RemoteStore}`;`StoreAccess::of` | 门面只有一套:`MutationGate` 持 `Arc` + `Arc`,组合决定后端;凭证是**值**(解析在 D 边界);**配置即用**——不再有本机登记、准入裁决与跨安装来源判定,`remote/registration.rs`、`remote/local_execution.rs`、`StoreAdmission` 与 `session_store_registrations` 表都已按用户裁决撤销;执行代际与 sidecar 锁只在本机库(不在 `threads` 造远程假行);本机执行面只回答**本机事实**——绑定字节、这棵树有没有绑定与树根都由数据面给出(调用方传 `SessionFacts { binding, bound, root }`,`owner_lease`/`live_owner`/`write_guard`/`exclusive_guard`/`acquire_lease` 都按这份事实判定,端口不查本机 `threads`/`session_bindings`),远端绑定与本机 workspace 证据走同一套 `validate_binding_value`,远程 `legacy_confirmed` 恒 false;远程 `create_session` = 远端保存 → 本机 `admit_existing`(失败 `saved_but_not_admitted`:数据已保存、执行资格不可得,历史仍可读),`close` 按活 owner 有界排空 | +| 改写入准入与效果结清 | `src/sessions/resources/gate.rs` + `src/sessions/sqlite_store/execution.rs` | `MutationGate`(`ensure_open`/`ensure_recovery_permitted`/`ensure_session_write`/`ensure_registration_write`/`admit`/`with_mutation`/`with_exclusive`/`session_facts`);`WriteScope::settle`;`ExecutionWriteGuard` / `ExclusiveExecutionGuard` / `TransactionEffect`;`require_execution_lease` / `exclusive_execution_guard` / `owner_lease` / `live_owner_lease` | 未结清的 mutation 在 `Drop` 时把租约置 `mutation_uncertain`(`MutationOutcome::Unknown` 不在 `settle` 里结清),后续写入与 `mark_clean` 都被拒绝——v10 之后未结清只由**活跃租约上的这个标记**承载,本机不再有远程操作日志表,也没有跨端口查询未决范围的谓词;`owner_lease` 只查这条 identity 与 `SessionFacts::root` 两处(树形事实由数据面给出,本机不沿自己的父链上溯;有绑定而无 owner 是 `ExecutionLeaseRequired`,不是「无 owner」),诊断读取用 `live_owner_lease`(不把无 owner 当错误);`mark_clean` 的「记录缺失」不再有墓碑容忍分支:刻意删除走 `dispose_ownership` 显式结束所有权(数据与代际行一起消失,之后锁已释放即幂等成功),所以行不见了只按 `WorkspaceError::RecoveryRequired` 如实上报,不用「行不在」猜「被删了」;`TransactionEffect` 把「提交自身的失败」单独标出,桥的显式事务据此决定是否结清 | +| 改 child resume 认领 handle | `src/sessions/resources/claim.rs` | `ChildResumeClaimHandle`(`impl ChildResumeClaim`:`mark_running` / `hand_off_to_background` / `mark_failed` / `mark_terminated`) | 认领期间保存「原状态」,终态方法把它写回去(`mark_failed`/`mark_terminated` 同一条恢复路径);移交后台后前台不能覆盖后台持有的终态;每次写入都走同一套准入检查(能力/权限 → root owner) | +| 改失败分类映射 | `src/sessions/sqlite_store/failure.rs` | `read_failure` / `write_failure` / `execution_failure` / `binding_relation_failure` / `map_sqlx` / `not_found` / `read_only_store` / `lease_required` | 数据面、执行面与门面共用同一套映射:会话行缺失是 `NotFound`,本机 workspace 语义原样保留变体,唯一键冲突是「identity 已存在」,外键/未登记是 `InvalidBinding`,解码失败是「记录读不懂」,其余 SQL 失败是「后端暂不可用」 | +| 改 SQLite 库的所有权/连接 | `src/sessions/sqlite_store/database.rs` + `connection.rs` | `SqliteSessionDatabase`(pool / read_only / db_path / execution_leases);`open`、`open_existing_read_only`、`close`、`require_writable`、`probe_load_meta_shape`、`default_database_path`;`lock_schema_open` | 同一库只有一条连接真相:数据面与执行面各自持有同一 `Arc`,不重建第二份 pool 或第二个库文件;`SqliteThreadStore` 仅为消费侧迁移桥(转发到共享句柄),E 阶段随 `ThreadStore` 一起退出 | | 改全局配置路径 | `src/config/mod.rs` | `peri_dir`(:9,`~/.peri`);`settings_path`(:14,`~/.peri/settings.json`) | 仅路径入口,配置读取语义之外的逻辑不迁入本 crate | | 引用 LSP 能力 | `src/lsp.rs` | 门面:`pub use peri_lsp::{client, config, diagnostics, error, jsonrpc, pool, protocol, uri}` | 唯一引用入口;实例化/持有(池生命周期)收口至 Resources context 后,本模块仅类型/能力出口 | | 引用 Workflow 能力 | `src/workflow.rs` | 门面:`pub use peri_workflow::{error, journal, progress, protocol, registry, rpc, runner, tool}` | 同上;消费方(Middleware 等)不直接依赖 peri-workflow | @@ -28,13 +40,16 @@ | 功能 | 文件 | 入口/关键点 | | --- | --- | --- | -| Resources 门面(唯一实例化入口) | src/context.rs | `Resources`(:17,持 `Arc`) | +| Resources 门面(唯一实例化入口) | src/context.rs | `Resources`(non-Clone:`session_resources: Arc` + `shutdown: SessionStoreShutdownOwner`) | | 全局配置路径 | src/config/mod.rs | `peri_dir` / `settings_path` | -| SQLite 会话存储 | src/sessions/sqlite_store.rs | `SqliteThreadStore`(:35,唯一 pool owner);唯一 `ThreadStore` impl 处理 metadata/payload/frozen,context/compaction 委托私有模块 | -| SQLite 连接与解码 | src/sessions/sqlite_store/{connection,schema,row_mapping}.rs | connection.rs(连接、read-only probe、安全错误);schema.rs(按必需真实表/列识别旧库、保留额外业务表、共享列定义、事务升级并移除无状态 revision 列;不认识的 `user_version` 以 `WorkspaceError::UnsupportedSchemaVersion { found, supported }` 拒绝并复述两个版本号,`CURRENT_SCHEMA_VERSION` 是接受判定、收尾写入与上限文案的单一来源;schema 2–5→6 在事务内重建 projects / workspaces,把单列唯一放宽为组合登记键(含被旧 writer 误标 5 的漏迁移库,保留健康 5 已有组合登记);2/3 先完成原有 revision / 身份载荷迁移,重建需在事务外关闭外键并在提交前用 `PRAGMA foreign_key_check` 补齐校验);row_mapping.rs(`ThreadRow` :24、`meta_from_row` :54、`role_of` :43、`extract_title` :96、完整/列表列投影) | -| SQLite 上下文与事务 | src/sessions/sqlite_store/{context,compaction}.rs | context.rs(ancestor payload、cache、child/session tree);compaction.rs(flags、事务提交、回滚删除) | +| SQLite 会话存储 | src/sessions/sqlite_store.rs | `SqliteThreadStore`(:60,迁移桥,持共享库句柄);`ThreadStore` impl 处理 metadata/payload/frozen,context/compaction 委托私有模块;`delete_thread` 与数据面删除同语义(递归删整棵树:每节点显式删执行行 → 子表行 → `threads` 行,不借外键级联;v10 起不再写删除墓碑) | +| 会话资源门面(生产入口) | src/sessions/resources.rs + resources/{gate,claim,lifecycle}.rs | `SessionResourcesImpl`(公开 API,`Arc` 的构造点);`Lifecycle::{state,begin_closing,confirm_closed}`(关闭生命周期事实) | +| 本机执行面 | src/sessions/sqlite_store/local.rs | `LocalExecution`(发现/登记/owner/dirty/创建准入/撤销),可见性收在 `crate::sessions` | +| SQLite 连接与解码 | src/sessions/sqlite_store/{database,connection,schema,row_mapping}.rs | connection.rs(连接、read-only probe、安全错误);schema.rs(按必需真实表/列识别旧库、保留额外业务表、共享列定义、事务升级并移除无状态 revision 列;不认识的 `user_version` 以 `WorkspaceError::UnsupportedSchemaVersion { found, supported }` 拒绝并复述两个版本号,`CURRENT_SCHEMA_VERSION` 是接受判定、收尾写入与上限文案的单一来源;schema 2–9→10:同一事务内重建 `execution_runs` 去掉 `threads` 外键(逐行保留 generation/clean 并校验行数);v7..v9 建过 5 张本机远程痕迹表(`session_store_registrations`、`session_lifecycle_commitments`、`session_remote_operations`、`remote_execution_runs`、`remote_lifecycle_commitments`),已由 v10(`CURRENT_SCHEMA_VERSION = 10`)按用户裁决整体删除——删前校验表形状,不符即不删并拒绝升级,表不存在即幂等跳过;同名表形状不符即失败,迁移失败整体回滚、`user_version` 停留在升级前的版本;2–5 来源的库同时在同一事务内重建 projects / workspaces,把单列唯一放宽为组合登记键(含被旧 writer 误标 5 的漏迁移库,保留健康 5 已有组合登记);2/3 先完成原有 revision / 身份载荷迁移,重建需在事务外关闭外键并在提交前用 `PRAGMA foreign_key_check` 补齐校验;只读打开不读也不写 `user_version`,按必需列形状放行、版本号不参与判定);row_mapping.rs(`ThreadRow` :24、`meta_from_row` :54、`role_of` :43、`extract_title` :96、完整/列表列投影) | +| SQLite 上下文与事务 | src/sessions/sqlite_store/{context,compaction}.rs | context.rs(`*_on` 连接作用域读原语与取连接的包装:ancestor payload、cache、child/session tree);compaction.rs(flags、事务提交、回滚删除;`load_flags_on` 对损坏 ID/projection 失败而不是跳过) | | 测试文件存储 | src/sessions/filesystem.rs | `FilesystemThreadStore`(:25) | -| 会话存储 re-export / 只读入口 | src/sessions/mod.rs | `SqliteThreadStore` / `FilesystemThreadStore`(:10-11);`open_thread_store_read_only`;`default_database_path`(读写共用的纯路径解析) | +| SQLite 行写入原语 | src/sessions/sqlite_store/session_rows.rs | `ThreadRowInsert` / `insert_thread_row` / `insert_binding_row`(数据面与迁移桥共用同一份列清单与绑定形状校验) | +| 会话存储 re-export / 只读入口 | src/sessions/mod.rs | `SqliteThreadStore` / `FilesystemThreadStore` / `SessionResourcesImpl`;`open_session_resources_read_only`(crate 内);`default_database_path`(读写共用的纯路径解析) | | LSP 门面 | src/lsp.rs | 全量 re-export peri_lsp 模块 | | Workflow 门面 | src/workflow.rs | 全量 re-export peri_workflow 模块 | @@ -43,5 +58,5 @@ - Worktree 归属见[身份设计](../design/session-workspace-identity.md):新会话使用 ProjectId / WorkspaceId / SessionBinding 与跨进程 lease;历史 cwd 接口仅保留精确目录兼容语义。 - 消费方:`peri-tui/src/app/mod.rs:88` 与 `peri-tui/src/cli_print.rs:136`(`Resources::open_with`,默认或显式路径失败均直接传播);`peri-controller/src/controller.rs:222`(`Resources::open()` 后调用);`peri-middlewares/src/`(lsp/middleware.rs:11-12、lsp/tool.rs:6-7、plugin/loader.rs:14、workflow/mod.rs、assembly.rs) -- 契约类型:`ThreadStore` trait / `ThreadMeta` / `BaseMessage` / `MessageFlags` 事实源在 `peri-acp-types/src/store.rs`(sessions/mod.rs 明确「接口契约归 peri-acp-types」) +- 契约类型:`ThreadStore` trait / `ThreadMeta` / `BaseMessage` / `MessageFlags` 事实源在 `peri-acp-types/src/store/mod.rs`(sessions/mod.rs 明确「接口契约归 peri-acp-types」) - 门面依赖:Cargo.toml 依赖 `peri-lsp`、`peri-workflow`(决策 20:既有 crate 归位),门面仅 re-export 不解释业务语义 diff --git a/docs/code-index/peri-tui.md b/docs/code-index/peri-tui.md index 9c8fe5627..f511fd864 100644 --- a/docs/code-index/peri-tui.md +++ b/docs/code-index/peri-tui.md @@ -1,6 +1,6 @@ # peri-tui 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-19(transcript 居中带与窗口级滚动条) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-09-27(存储登记/准入撤销:客户端不再于会话操作前探测本机接纳裁决,`client/store_registration.rs` 与 `RiskPrompt::StoreRegistration` 一并删除;dirty 恢复的三态风险选择保持不变) > 依据:peri-tui/CLAUDE.md、docs/standards/architecture-contracts.md、docs/design/tui-acp-data-flow.md、源码 ## 架构速览 @@ -37,7 +37,7 @@ | 改 print 计量与终止状态 | `src/cli_print.rs` + `src/acp_client/client/requests.rs` + `src/cli_print_usage_test.rs` + `tests/print_exit.rs` | `PrintUsage::from_meta` / `PrintOutput::{handle_session_update,result}` / `prompt_with_response` / `drain_print_notifications`;`print_elicitation_response` / `print_permission_response`(cli_print.rs) | `assistant.message.usage` 为每次非 replay 调用,`result.usage` 为累计;完整 input 扣缓存后输出 Claude 四字段,未知 usage/成本保留 null;typed ACP 终态进入 result 的 stop_reason/status/is_error,非 EndTurn 非零退出,cleanup_error 与任务状态区分;关闭后排空通知才发 final,契约 ARC-OUTPUT-COMPLETION-001;`-p` 无交互界面:审批回 allow_once,提问按 ARC-HITL-001 声明 `_meta.peri.elicitationUnanswered`(`elicitation_unanswered_response`),不得裸 cancel 让 `AskUserQuestion` 伪造空回答(回归:`tests/print_exit.rs::ask_user_unanswered_exits_process_without_fabricated_answer`) | | 验收 print 后台 Bash 完成退出 | `tests/print_background_exit.rs` | `promoted_background_output_is_readable_and_print_exits`、`explicit_background_output_is_readable_and_print_exits` | 本地 provider 驱动真实 Bash、短完成通知和 Read;超过 2 MiB 的 UTF-8 输出与 stderr 完整落盘,保留非零退出码,最终 result 后 CLI 自行退出 | | 改内嵌 host / print 退出 | `src/acp_client/deployment.rs` + `src/launch.rs` + `src/cli_print.rs` | `AcpDeployment::shutdown`(deployment.rs:20)/`run`(:27);`teardown_app`(launch.rs:199);`run_print`(cli_print.rs:25) | 显式 client close 打破 pump/atoms 的 Arc 保活后等待原 host;print 成功和 new/prompt 错误共用退出路径,资源 Incomplete/TaskFailed 为可见退出错误,HTTP 遥测失败保留旁路报告(ARC-HOST-SHUTDOWN-001) | -| 改配置/启动流程 | `src/main.rs` + `src/launch.rs` + `src/config/` + `src/app/mod.rs` | `main`;`build_runtime`;`run_tui`;`build_app_and_acp`;`attach_acp`;`App::new`;`TuiConfig::from_extra`;`save_effective` | `PeriConfig` 等类型事实源在 `peri-acp/src/provider/config.rs`,`config/mod.rs` 仅 re-export;CLI 权限使用 `--permission-mode` / `--dangerously-skip-permissions`,默认 Bypass;配置源句柄 `CONFIG_SOURCE_HANDLE` 启动时 set 一次,加载与保存共用同一决策;`teardown_app` 收尾 hooks/MCP 后经 AcpDeployment 关闭并 join ACP host/Langfuse | +| 改配置/启动流程 | `src/main.rs` + `src/launch.rs` + `src/config/` + `src/app/mod.rs` | `main`;`build_runtime`;`run_tui`;`build_app_and_acp`;`attach_acp`;`App::new`;`TuiConfig::from_extra`;`save_effective` | `PeriConfig` 等类型事实源在 `peri-acp/src/provider/config.rs`,`config/mod.rs` 仅 re-export;CLI 权限使用 `--permission-mode` / `--dangerously-skip-permissions`,默认 Bypass;配置源句柄 `CONFIG_SOURCE_HANDLE` 启动时 set 一次,加载与保存共用同一决策;`teardown_app` 收尾 hooks/MCP 后经 AcpDeployment 关闭并 join ACP host/Langfuse;`Resources::open_deployment` 拆业务句柄(进 `ServiceRegistry`)与部署关闭权(留 `App` → `HostAssemblyInput.session_store_shutdown`),会话存储由宿主在任务排空之后关闭 | | 改首次 setup / 重新配置 | `src/app/setup_wizard/mod.rs` + `src/kit/setup_wizard.rs` + `src/kit/setup_wizard/handler.rs` + `src/kit/entry.rs` | `needs_setup`、`state_from_config`、`save_setup`;`run_kit_fullscreen` 的首次配置 preflight;`launch::attach_acp` | 无可用 provider 时先完成向导,再装配唯一 ACP 和 consumers;取消退出,保存失败留在向导;运行中配置等待 `update_config` 成功后关闭。回归:`e2e/tests/scenarios/fresh-setup.test.ts` | | 改 `peri workflow` CLI | `src/cli_workflow.rs` + `src/main.rs` | `argv_requests_workflow`;`run_before_configuration` | 经 Clap 识别与冲突校验,在配置初始化前通过 `peri-acp::workflow_cli` 执行内嵌 Node artifact;CLI grammar/退出码回归在 `cli_workflow_test.rs` 与 `peri-workflow/src/cli_test.rs` | | 改 `peri meta session` CLI | `src/main.rs` + `src/cli_meta.rs` + `src/thread/mod.rs` | `MetaAction::Session`;`try_run_meta_before_configuration`;`run_meta_session`;`SessionMetaDtoV1`;`open_thread_store_read_only` re-export | Meta 在 settings/config/env 初始化前按受限 grammar 路由;先校验 UUID,再经 `peri-resources` 只读 seam 调用 `ThreadStore::load_meta`;human/JSON 使用九字段 allowlist,稳定错误与退出码由 adapter 映射;不进入 ACP、Agent、Runtime 或 TUI session owner | @@ -122,15 +122,15 @@ | 功能 | 文件 | 入口/关键点 | | --- | --- | --- | -| 应用状态 | src/app/mod.rs | `App`(:29)/`App::new`(:47);`spawn_mcp_init`(:127)、`get_compact_config`(:182);子模块 agent.rs/cron_state.rs/provider.rs/service_registry.rs/setup_wizard/ | +| 应用状态 | src/app/mod.rs | `App`(:32)/`App::new`(:48);`spawn_mcp_init`、`get_compact_config`;`App::session_store_shutdown`(部署关闭权,non-Clone,`attach_acp` 时移交宿主配置);子模块 agent.rs/cron_state.rs/provider.rs/service_registry.rs/setup_wizard/ | | 配置 | src/config/ | `PeriConfig` 等 re-export 自 `peri-acp/src/provider/config.rs`(事实源);`TuiConfig`(tui_config.rs:9,本地扩展,`from_extra` :48 / `sync_to_extra` :80);`save_effective`(mod.rs:21) | | ACP 客户端入口与构造 | src/acp_client/client.rs | `AcpTuiClient` / `AcpNotification` / `ClientProjectionMode` 保持公共路径;`new_with_mode` 装配 lifecycle、weak notifier 与 Drop settlement worker | | ACP 通知泵与 reverse wire admission | src/acp_client/client/pump.rs | `spawn_pump` / `run_pump` / `plan_reverse_request` / `flush_buffered`;按原始接收顺序解码,ordinary notification 经 lifecycle 路由,reverse 在投递前注册 owner | -| Session 切换与 load reservation | src/acp_client/client/session.rs | `ensure_session` / `new_session` / `load_session` / `delete_session`;`SessionLoadReservation` / `reserve_session_load` / `open_prompt_after_session_loads` 保持同步 reservation 与 operation gate 的线性化;`read_only_admission` / `recovery_target` 统一「失败错误」与「只读准入标记」两条来源;dirty 恢复在一次 load transition 内确认(`RecoveryRequired` data 或只读标记 → `confirm_dirty_recovery` → `peri/session_reset_dirty` → 重新 load),source/target 与有效 cwd 在等待回答前固定;只读准入照样提交会话(取消确认也不阻挡进入),执行所有权仍由 host 的 `require_owner` 把关,客户端不复制该规则 | -| dirty 恢复确认 | src/kit/popups/confirm_popup.rs + src/kit/popup_overlay.rs | `RecoveryConfirmation`(一次性 `answer`、`displayed` 许可)/ `confirm_dirty_recovery` / `DirtyRecoveryPopup` / `recovery_choice` / `cancel_pending_dirty_recovery` | 默认选中取消,只有显式选择接受才返回 true;确认未完整渲染、等待方被丢弃、弹窗被占用或通用确认路径都 fail closed;`open_popup`/`close_popup` 在替换与撤销边界精确结清旧 dirty 载荷并保留新 popup(首帧前无 render Drop),`PopupOverlay` effect 对绕过 helper 的直接 `POPUP_KIND` 写入做一致性收敛 | +| Session 切换与 load reservation | src/acp_client/client/session.rs | `ensure_session` / `new_session` / `load_session` / `delete_session`;`SessionLoadReservation` / `reserve_session_load` / `open_prompt_after_session_loads` 保持同步 reservation 与 operation gate 的线性化;`read_only_admission` / `recovery_target` 统一「失败错误」与「只读准入标记」两条来源;dirty 恢复在一次 load transition 内确认(`RecoveryRequired` data 或只读标记 → `confirm_risk_choice` → `peri/session_reset_dirty` → 重新 load),source/target 与有效 cwd 在等待回答前固定;只读准入照样提交会话(取消确认也不阻挡进入),执行所有权仍由 host 的 `require_owner` 把关,客户端不复制该规则 | +| 风险接受确认(dirty 恢复) | src/kit/popups/confirm_popup.rs + src/kit/popup_overlay.rs | `RiskPrompt`(`DirtyRecovery`)/ `RiskConfirmation`(一次性 `answer`、`displayed` 许可)/ `RiskChoice`(`Accepted` / `Declined` / `NotShown`)/ `confirm_risk_choice` / `RiskPopup` / `risk_choice` / `cancel_pending_risk_choice` | 默认选中取消,只有「用户显式接受 + 确认内容当前完整渲染」才是 `Accepted`;装不下的帧不结清选择(窗口变化后同一次确认仍可成立,等待方仍可接受),等待方被丢弃、弹窗被占用、首帧前被替换与通用确认路径按 `Declined`/`NotShown` fail closed;`open_popup`/`close_popup` 在替换与撤销边界精确结清旧载荷并保留新 popup(首帧前无 render Drop),`PopupOverlay` effect 对绕过 helper 的直接 `POPUP_KIND` 写入做一致性收敛 | | ACP 请求封装 | src/acp_client/client/requests.rs | `register_ui_commands` / `prompt` / `prompt_with_bg_results` / `cancel` / `set_config_option` / `send_raw_request`;prompt 持 lease,返回后在 gate 内结算 | | Interaction response 与 UI publication | src/acp_client/client/interaction.rs | `respond_interaction` / `publish_if_owned` / `reject_interaction` / `settle_claims_owned`;owner first-claim 与同步 UI publication 共用 gate,通知仅升级 weak sender | -| ACP client 契约测试 | src/acp_client/client_test.rs + client_reverse_test.rs + client/recovery_test.rs | `client::tests` 验证 done identity / 删除过滤,`client::reverse_tests` 覆盖 owner、gate、startup/load reservation 与 Drop settlement,`client::recovery_tests` 覆盖 dirty 确认的身份固定、取消/撤销零写入、gate 与 reservation 释放;涉全局 atom 的用例用局部 RAII 快照完整恢复并在结束前收束后台任务 | +| ACP client 契约测试 | src/acp_client/client_test.rs + client_reverse_test.rs + client/recovery_test.rs | `client::tests` 验证 done identity / 删除过滤,`client::reverse_tests` 覆盖 owner、gate、startup/load reservation 与 Drop settlement,`client::recovery_tests` 覆盖 dirty 确认的身份固定、取消/撤销零写入、gate 与 reservation 释放;涉全局 atom 的用例用局部 RAII 快照完整恢复(含 `NOTIFICATION`)并在结束前收束后台任务 | | 启动/CLI | src/main.rs、launch.rs、cli_args.rs、cli_plugin.rs、update.rs | `main`(main.rs:613)/`run_tui`(:847);`build_app_and_acp`(launch.rs:41)/`teardown_app`(:199);`run_kit_fullscreen`(kit/entry.rs:52);插件/更新 CLI 子命令 | ### 设备同步与线程存储(src/sync/ src/thread/ src/components/) diff --git a/docs/design/message-transcript.md b/docs/design/message-transcript.md index e1743f6a1..113bb7977 100644 --- a/docs/design/message-transcript.md +++ b/docs/design/message-transcript.md @@ -97,7 +97,7 @@ graph TB ### 2.4 持久化 -ThreadStore 负责 Transcript 的完整持久化。`ThreadStore` trait 定义已下沉 `peri-acp-types/src/store.rs:41`,实现迁至 `peri-resources/src/sessions/`(`filesystem.rs` 的 `FilesystemThreadStore` / `sqlite_store.rs` 的 `SqliteThreadStore`),`thread/mod.rs` 仅 re-export。 +ThreadStore 负责 Transcript 的完整持久化。`ThreadStore` trait 定义已下沉 `peri-acp-types/src/store/mod.rs`(`pub trait ThreadStore` :221),实现迁至 `peri-resources/src/sessions/`(`filesystem.rs` 的 `FilesystemThreadStore` / `sqlite_store.rs` 的 `SqliteThreadStore`),`thread/mod.rs` 仅 re-export。该 trait 是**迁移桥**:目标契约是 `peri-acp-types/src/session_resources.rs` 的 `SessionResources`(行为门面,数据与本机执行两面分离),历史纯变换(fork/投影/compaction/rewind)在 `peri-acp-types/src/store/history.rs`,消费侧迁移见 active spec 2026-09-26-session-store。 #### ThreadStore trait 核心方法概览 diff --git a/docs/design/peri-acp-protocol.md b/docs/design/peri-acp-protocol.md index b8514f5b1..cfebd1965 100644 --- a/docs/design/peri-acp-protocol.md +++ b/docs/design/peri-acp-protocol.md @@ -47,6 +47,14 @@ TUI 的所有主动行为通过标准 ACP JSON-RPC 方法调用。不定义自 待恢复的精确代际只在协商了 `peri.sessionRecoveryV1` 的连接上停住(客户端确认后调用 `peri/session_reset_dirty`),没有确认交互的连接由宿主直接解除该代际并取得所有权。 只读准入不改变独占:写入与执行仍要 owner,`session/fork` 不接受降级。 +- ~~会话存储准入由 initialize 的 `peri.sessionStoreRegistrationV1` 显式协商: + `peri/session_store_status` 返回本机对当前存储的接纳裁决,`peri/session_register_store` + 在用户显式接受风险时登记。~~ **已撤销**(2026-09-27 用户裁决):不再有本机登记、准入 + 裁决与跨安装来源判定,因此这两条方法与 `peri.sessionStoreRegistrationV1` 都不再存在 + (原语义、`StoreNotRegistered` / `StoreRegisteredFromDifferentOrigin` 两条拒绝原因见 + `spec/issues/2026-09-26-session-store-remote-backend.md` 的历史记录)。**现行语义是 + 「配置即用」**:配置里指到哪个会话存储就直接用哪个,不要求先登记;远端库与本地库是 + 同一种存储模式,两者存储模式一致(schema/SQL 统一是后续工作)。 - `session/metadata` 读取轻量标题与当前会话配置投影;不做逐 tick Git 发现。 类型事实源为 `peri-acp-types::workspace`;身份、恢复和执行锁约束见 diff --git a/docs/design/session-workspace-identity.md b/docs/design/session-workspace-identity.md index 64592417d..e9916b716 100644 --- a/docs/design/session-workspace-identity.md +++ b/docs/design/session-workspace-identity.md @@ -337,7 +337,9 @@ hooks、插件与 MCP 展示取当前会话环境。TUI 本地配置面板仍编 ## 8. 单库存储与版本边界 默认读写始终使用 `~/.peri/threads/threads.db`,`--db-path` 仍可选择显式路径。 -schema 版本记录在 `PRAGMA user_version`,当前为 `6`,不另建数据库文件。新 writer +schema 版本记录在 `PRAGMA user_version`,当前为 `10`(`CURRENT_SCHEMA_VERSION`; +v10 撤销本机远程痕迹后本机表集合回到 v6 时代,版本号仍只增不减,2..9 的库都经升级 +路径收敛到 10),不另建数据库文件。新 writer 按必需的 `threads` / `messages` 真实表及其列识别未设置版本号的旧 schema; 同库额外业务表(例如 `thread_goals`)及其数据保持原样,不能以整库表数量拒绝 兼容旧库。在单个事务中补齐 diff --git a/docs/standards/architecture-contracts.md b/docs/standards/architecture-contracts.md index 3ffc441e7..1a4e8a148 100644 --- a/docs/standards/architecture-contracts.md +++ b/docs/standards/architecture-contracts.md @@ -85,9 +85,9 @@ ### ARC-HOST-SHUTDOWN-001 - **Scope**:`peri-acp` host/stdio,`peri-middlewares` MCP pool,`peri-tui` MCP panel/内嵌 ACP deployment,Controller/Langfuse 部署关闭。 -- **Rule**:ACP transport 终止是一个显式所有权事务:先关闭 host 任务准入并取消会话,再在可控 cooperative grace 后 abort/drain host-owned 任务;对 MCP 必须按 `pool.begin_shutdown → McpTaskOwner.begin/shutdown → pool.shutdown` 顺序执行。`HostTaskOwner` 与 `McpTaskOwner` 均是 deployment-held、non-Clone 强 owner;ACP 仅经 `peri-acp-types::ports::McpTaskOwnerPort` 持有 boxed owner capability,具体 `McpTaskOwner` 与 keyed registry 仍归 `peri-middlewares`。配置/task/pool 只保留 weak spawner,callback/notifier 只弱引用 pool;pool 不得反向持有会捕获 `Arc` 的 task handle。MCP 的 init/OAuth/reconnect/subscription 准入与 owner 注册在 pool lifecycle gate 下线性化,`Open → Closing` 后 callback 注册、新连接/service 发布与新任务均拒绝。Pool service-close 必须由 pool state 持有单一、不捕获 pool 的 shutdown worker;调用者取消、并发或重试只能继续观察同一事务,不能取得 drained service 的唯一所有权。只有每个 service 的 close/join 终态均已记录才可发布 `Closed`;`close_with_timeout == Ok(None)` 必须显式报告 `Incomplete` 并保持 `Closing`。EOF 对 local 与 `SessionManager` ID 并集执行 pre-close/close,任何 task join、LSP/MCP close 或外部 callback 都不得持有 session/lifecycle/registry/services 锁。超过 abort-drain guard 只能报告 `Incomplete` 并保持 `Closing`,不得宣称已释放图。内嵌 mpsc 部署必须显式 close transport 结算双向 pending,不能依赖 client Arc 全部释放;部署保留实际 host JoinHandle,取消等待不丢失退出任务,Incomplete 保留实际待关闭 session/资源 owner 以供重试,不以永久失败计数替代资源。Dynamic MCP 的 session close registration 仅在实际 Complete 后缓存完成,取消或 Incomplete 不得跳过重试。只有本契约范围内的资源 drain 完整后,才可使用 fresh assembly 授予的 non-Clone Langfuse shutdown 权限;外部共享 session 注入默认不授予权限,turn 仅 flush,Batcher 是唯一 worker/join owner。print 的正常与业务错误退出走相同 close→host join 路径;资源 Incomplete/TaskFailed 为可见退出错误,保留原业务错误;HTTP 遥测失败保持旁路报告,不能伪装为成功发送。 +- **Rule**:ACP transport 终止是一个显式所有权事务:先关闭 host 任务准入并取消会话,再在可控 cooperative grace 后 abort/drain host-owned 任务;对 MCP 必须按 `pool.begin_shutdown → McpTaskOwner.begin/shutdown → pool.shutdown` 顺序执行。`HostTaskOwner` 与 `McpTaskOwner` 均是 deployment-held、non-Clone 强 owner;ACP 仅经 `peri-acp-types::ports::McpTaskOwnerPort` 持有 boxed owner capability,具体 `McpTaskOwner` 与 keyed registry 仍归 `peri-middlewares`。配置/task/pool 只保留 weak spawner,callback/notifier 只弱引用 pool;pool 不得反向持有会捕获 `Arc` 的 task handle。MCP 的 init/OAuth/reconnect/subscription 准入与 owner 注册在 pool lifecycle gate 下线性化,`Open → Closing` 后 callback 注册、新连接/service 发布与新任务均拒绝。Pool service-close 必须由 pool state 持有单一、不捕获 pool 的 shutdown worker;调用者取消、并发或重试只能继续观察同一事务,不能取得 drained service 的唯一所有权。只有每个 service 的 close/join 终态均已记录才可发布 `Closed`;`close_with_timeout == Ok(None)` 必须显式报告 `Incomplete` 并保持 `Closing`。EOF 对 local 与 `SessionManager` ID 并集执行 pre-close/close,任何 task join、LSP/MCP close 或外部 callback 都不得持有 session/lifecycle/registry/services 锁。超过 abort-drain guard 只能报告 `Incomplete` 并保持 `Closing`,不得宣称已释放图。内嵌 mpsc 部署必须显式 close transport 结算双向 pending,不能依赖 client Arc 全部释放;部署保留实际 host JoinHandle,取消等待不丢失退出任务,Incomplete 保留实际待关闭 session/资源 owner 以供重试,不以永久失败计数替代资源。Dynamic MCP 的 session close registration 仅在实际 Complete 后缓存完成,取消或 Incomplete 不得跳过重试。只有本契约范围内的资源 drain 完整后,才可使用 fresh assembly 授予的 non-Clone Langfuse shutdown 权限;外部共享 session 注入默认不授予权限,turn 仅 flush,Batcher 是唯一 worker/join owner。会话存储的关闭同属 deployment-held、non-Clone capability:业务门面 `SessionResources` 不含关闭,只有装配点(TUI/print/stdio)从资源工厂取得 `SessionStoreShutdownPort` 并注入宿主,宿主在排空为 `Complete` 之后消费一次;未确认的关闭报 `Incomplete` 并保留上下文重试,不得以「调用过」冒充幂等成功。关闭权的构造入口同样只在 Resources 层:`SessionStoreShutdownOwner::take` 与门面 `close` 都是 crate 内可见,装配点以外没有路径能把具体实例转成关闭权(按具体类型打开只服务 I/O)。print 的正常与业务错误退出走相同 close→host join 路径;资源 Incomplete/TaskFailed 为可见退出错误,保留原业务错误;HTTP 遥测失败保持旁路报告,不能伪装为成功发送。 - **Boundary**:会话工作区环境的 host/MCP 任务、compact hooks 与 Agent `TaskManager` 排空共同约束执行 lease 释放(ARC-WORKSPACE-001);事件交付仍按 ARC-EVENT-001 验证。外部共享资源不因会话关闭而取得全局销毁权限。 -- **Verify**:`cargo test -p peri-acp --lib -- host::task_scope`、`cargo test -p peri-acp --lib -- host::stdio::run_server_integration_tests`、`cargo test -p peri-middlewares --lib -- mcp::task_scope`、`cargo test -p peri-middlewares --lib -- mcp::client`、`cargo test -p peri-tui --lib -- app::mcp_lifecycle_tests`、`cargo test -p peri-tui --lib -- acp_client::deployment::tests`、`cargo test -p peri-middlewares --lib -- test_close_registration_`;ACP `host/stdio/langfuse_shutdown_test.rs` 覆盖实际尾部事件、共享会话与移交 session close owner 的 Incomplete 重试,`transport/mpsc_test.rs` 覆盖显式 close 的双向 pending 与 EOF。 +- **Verify**:`cargo test -p peri-acp --lib -- host::task_scope`、`cargo test -p peri-acp --lib -- host::stdio::run_server_integration_tests`、`cargo test -p peri-middlewares --lib -- mcp::task_scope`、`cargo test -p peri-middlewares --lib -- mcp::client`、`cargo test -p peri-tui --lib -- app::mcp_lifecycle_tests`、`cargo test -p peri-tui --lib -- acp_client::deployment::tests`、`cargo test -p peri-middlewares --lib -- test_close_registration_`;ACP `host/stdio/langfuse_shutdown_test.rs` 覆盖实际尾部事件、共享会话与移交 session close owner 的 Incomplete 重试,`transport/mpsc_test.rs` 覆盖显式 close 的双向 pending 与 EOF;`host/stdio/session_store_shutdown_test.rs` 覆盖「排空之前不动用关闭权、排空之后恰好一次」与「未确认的关闭报 Incomplete 且重试重新真实检查」;`cargo test -p peri-resources --test session_resources_contract` 覆盖部署关闭权的交付(crate 外只经 `Resources::into_parts` 取得,具体实例不含关闭)。 ### ARC-MCP-ACP-001 diff --git a/docs/standards/index.md b/docs/standards/index.md index 7fc622eb8..3d24db8dd 100644 --- a/docs/standards/index.md +++ b/docs/standards/index.md @@ -2,16 +2,12 @@ 本目录是工程规则的单一事实源;按任务读取,不默认整目录加载。 -## 信息优先级 +## 事实、规则与目标 -1. 代码与契约测试 -2. `docs/standards/` -3. 模块 `CLAUDE.md` -4. `docs/design/` -5. active spec -6. history - -冲突时服从更高优先级;代码变更与规则不一致时,先定位契约,再同步规则或代码。 +- **现行行为**:由代码、契约测试和可复现结果证明;文档不能把尚未实现的目标写成现状。 +- **工程规则**:`docs/standards/` 是可执行规则的单一事实源;根 `CLAUDE.md` 提供项目目标、设计取舍和路由,模块 `CLAUDE.md` 补充局部不变量与入口,不覆盖 standards。 +- **变更目标**:由用户已确认的任务契约及已批准设计确定;active spec 记录实施范围与验收,history 仅提供背景。目标尚未实现不构成文档过时,已有测试也可能需要随获批契约变更而更新。 +- **冲突裁决**:按 `STD-INDEX-002` 区分实现缺陷、文档过时与获批契约变更,不用一条信息优先级同时裁决现状和目标。 ## 路由 @@ -36,5 +32,11 @@ ### STD-INDEX-002 - **Scope**:规则与实现冲突。 -- **Rule**:以代码和契约测试为准;在同一变更中修正过时文档,不能以文档覆盖已验证行为。 -- **Verify**:检查对应 crate 的测试入口;无自动测试时,人工核对实现与本索引优先级。 +- **Rule**:先核实当前行为及适用的已批准契约,再分类处理:实现违反契约时修复实现并补回归验证;文档过时且实现符合契约时修正文档;用户已批准改变契约时同步规则、设计、实现和测试。不得仅因实现或旧测试存在就降低目标要求。若目标与现行标准冲突,先核实本次授权是否包含该契约变更;无法从任务与批准记录判定时,只澄清影响目标或契约的分歧,不自行改写目标。 +- **Verify**:核对当前行为证据、目标契约来源和冲突分类;检查同一变更中的实现、测试与文档保持一致,未实现目标仍明确标注状态。 + +### STD-SIZE-001 + +- **Scope**:仓库源码及测试文件(`.rs`、`.ts`、`.tsx`、`.mjs`、`.js`);扫描遵循 `.gitignore` 并排除构建产物目录,文档预算另见 `documentation.md`。 +- **Rule**:单个文件最多 1000 行,含空行和注释,源码与测试使用相同上限。新增或修改文件须满足限制;既有未改动超限文件的治理另立任务,不要求在无关任务中重构。拆分按职责和模块边界进行,不以机械搬移或压缩排版规避限制。 +- **Verify**:`bash scripts/check-file-size.sh` 全量列出超限文件;对本次变更范围用 `wc -l ` 核对。全量扫描中的存量超限须如实报告,不宣称全库通过;自定义扫描阈值不替代此验收上限。 diff --git a/peri-acp-types/src/command.rs b/peri-acp-types/src/command.rs index 28a7cdfe3..c786b220a 100644 --- a/peri-acp-types/src/command.rs +++ b/peri-acp-types/src/command.rs @@ -12,7 +12,6 @@ use tokio_util::sync::CancellationToken; use crate::compact::CompactConfig; use crate::event::EventSink; use crate::messages::BaseMessage; -use crate::store::ThreadStore; use crate::tasks::TaskManager; // ─── 命令契约子模块(Phase 1 拆出,经本模块导出,lib.rs 挂载点不变)──────── @@ -107,9 +106,12 @@ pub struct CommandContext { pub parsed_args: Option, /// 取消令牌,用于 Ctrl+C 打断长时间运行的命令(如 compact 的 LLM 调用)。 pub cancel_token: CancellationToken, - /// 持久化存储,用于 rewind 等需要删除消息的命令。 - pub thread_store: Option>, - /// 当前会话的 thread ID,配合 thread_store 使用。 + /// 会话资源门面:rewind/compact 等需要读写会话历史的命令经此访问。 + /// + /// `None` = 该上下文没有持久化后端(打印/测试路径);命令按「无持久化」处理, + /// 不另建存储旁路。 + pub session_resources: Option>, + /// 当前会话的 thread ID,配合 `session_resources` 使用。 pub thread_id: Option, /// 后台任务管理器(Immediate 命令依赖,如 rewind 的异步执行)。 pub task_manager: Option>, @@ -188,7 +190,7 @@ impl CommandContext { supports_inject: false, args: String::new(), parsed_args: None, - thread_store: None, + session_resources: None, thread_id: None, task_manager: None, frozen_claude_md: None, @@ -425,7 +427,7 @@ mod context_deps_tests { // 旧字段默认值(消费方迁移前经 `new()` + 逐字段赋值全量预填)。 assert_eq!(ctx.args, ""); assert!(ctx.auxiliary_model.is_none()); - assert!(ctx.thread_store.is_none()); + assert!(ctx.session_resources.is_none()); assert!(ctx.thread_id.is_none()); assert!(ctx.task_manager.is_none()); assert!(ctx.frozen_claude_md.is_none()); diff --git a/peri-acp-types/src/command_handler_test.rs b/peri-acp-types/src/command_handler_test.rs index 4aa6a5d3d..cf9a22957 100644 --- a/peri-acp-types/src/command_handler_test.rs +++ b/peri-acp-types/src/command_handler_test.rs @@ -34,7 +34,7 @@ fn make_context(args: &str) -> CommandContext { args: args.to_string(), parsed_args: None, cancel_token: tokio_util::sync::CancellationToken::new(), - thread_store: None, + session_resources: None, thread_id: None, task_manager: None, frozen_claude_md: None, diff --git a/peri-acp-types/src/frozen.rs b/peri-acp-types/src/frozen.rs index e5e647024..536951fa0 100644 --- a/peri-acp-types/src/frozen.rs +++ b/peri-acp-types/src/frozen.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use crate::event::AgentEventHandler; -use crate::store::ThreadStore; /// 子 Agent event handler 工厂:child_thread_id → child 专属 handler。 pub type ChildHandlerFactory = Arc Arc + Send + Sync>; @@ -37,8 +36,10 @@ pub struct FrozenData { /// 子 Agent 线程持久化分组(零跨依赖)。 #[derive(Clone, Default)] pub struct ThreadPersistence { - /// Thread persistence store for child thread creation (None = non-persistent) - pub store: Option>, + /// 会话资源门面:child 保存/状态/历史写入的唯一入口(None = 不持久化) + pub session_resources: Option>, + /// 本会话 root 的执行所有权(child 保存的前置证明;None = 不落库) + pub execution_owner: Option>, /// Parent thread ID for child thread hierarchy (None = top-level agent) pub parent_thread_id: Option, /// Register callback: called when a child agent starts executing. diff --git a/peri-acp-types/src/lib.rs b/peri-acp-types/src/lib.rs index 85afb3e70..0cc5853f6 100644 --- a/peri-acp-types/src/lib.rs +++ b/peri-acp-types/src/lib.rs @@ -7,7 +7,9 @@ //! - `summary` — migrated event DTOs re-exported via peri-acp::event //! - `messages` — 消息契约(BaseMessage/MessageContent/...),peri-agent 保留 re-export //! - `thread` — Thread 元数据契约(ThreadMeta/ThreadId/CancelPolicy/AgentStatus...) -//! - `store` — ThreadStore 持久化契约(trait + CompactionLifecycle + MessageFlags) +//! - `store` — ThreadStore 持久化契约(trait + CompactionChange + MessageFlags); +//! 纯历史变换见 `store::history` +//! - `session_resources` — 会话资源门面契约(SessionResources + 中性领域 I/O、错误与能力枚举) //! - `projection` — compact 投影指令纯数据契约 //! - `identity` — §9 身份标识契约(AgentId/EventEnvelope/CancelRequest/...) //! - `event` / `event_v2` — 事件契约(ExecutorEvent + v2 三层事件 + EventBus + v1 兼容映射) @@ -66,6 +68,8 @@ pub mod projection; pub mod runtime; pub mod sentinel; pub mod session; +pub mod session_resources; +pub mod session_store; pub mod skills; pub mod store; pub mod summary; diff --git a/peri-acp-types/src/session_resources.rs b/peri-acp-types/src/session_resources.rs new file mode 100644 index 000000000..75bf00652 --- /dev/null +++ b/peri-acp-types/src/session_resources.rs @@ -0,0 +1,683 @@ +//! 会话资源门面 — 消费侧唯一的会话行为契约。 +//! +//! 业务侧(Agent / ACP / Controller / TUI)只依赖本 trait 的行为语义,注入的是 +//! `Arc`。数据的持久化位置(本机 SQLite / Turso Cloud)与 +//! 本机执行事实(发现、绑定、owner、dirty、排空)由资源内部两面分别实现, +//! 数据端口不向业务暴露事务、CAS、SQL batch、连接或补偿令牌。 +//! +//! 三条必须成立的约束: +//! +//! 1. **不提供 no-op 默认实现。** 每个行为都是必需方法:无法完成的行为必须返回 +//! 明确失败,不能让调用方把「不支持」当成「没有数据」。 +//! 2. **声明即保证。** 行为要么满足完整后置条件,要么在副作用前失败;声明 +//! [`DataCapabilities::Complete`] 的 adapter 不得对任何行为退化为 no-op。 +//! 3. **效果确定性单独表达。** 失败原因与「是否已生效」分别建模,见 +//! [`SessionResourceError`] 与 [`MutationOutcome`]。 +//! +//! 只读入口(`AccessMode::ReadOnly`)不得靠创建一个临时未绑定会话绕过执行授权。 +//! 旧 `ThreadStore`([`crate::store::ThreadStore`])是迁移桥,见其模块文档。 + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::messages::MessageId; +use crate::store::{CompactionChange, InheritedContext, MessageFlags, PersistedPayload}; +use crate::thread::{AgentStatus, CancelPolicy, ThreadId, ThreadMeta}; +use crate::workspace::{ + ReadOnlyAdmission, RecoveryRequiredDetails, ResetDirtyRequest, ResolvedWorkspace, + ScopedThreadPage, ScopedThreadQuery, SessionBinding, SessionExecutionLease, WorkspaceError, +}; + +// ─── 能力与准入 ──────────────────────────────────────────────────────────────── + +/// 本次打开实际取得的读写权限 — 配置/授权事实,不推导数据能力。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AccessMode { + ReadWrite, + ReadOnly, +} + +/// 后端能安全完成的会话行为面。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DataCapabilities { + /// §行为清单全部满足完整后置条件。 + Complete, + /// 只支持读取历史;所有 mutation 在副作用前返回 `Unsupported`。 + HistoryReadOnly, +} + +/// 本机执行资格 — 与数据能力、与访问模式都独立。 +/// +/// 三种事实互不推导:`ReadOnly` 不蕴含 `HistoryReadOnly`(只读授权下数据能力仍可 +/// 为 `Complete`,只是本次不允许写);`Complete` 不蕴含可执行。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExecutionAvailability { + Available, + /// 会话没有可验证的执行绑定。 + BindingMissing, + /// 绑定的工作目录在本机不可用或已变化。 + WorkspaceUnavailable, + /// 执行所有权在别处。 + OwnedElsewhere, + /// 上次执行未干净收尾:需要按精确代际确认后解除。 + Dirty(RecoveryRequiredDetails), + /// 存在无法证明终态的持久化写入:先收敛再执行。 + PersistencePending, + /// 本次打开只读,无法取得执行所有权。 + ReadOnlyStore, + /// 本机执行在该后端不支持。 + Unsupported, +} + +/// 一次诊断读取:本次打开的权限、后端能力面,以及(给定会话时的)执行资格。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionAvailability { + pub access: AccessMode, + pub capabilities: DataCapabilities, + /// `None` 表示未指定会话,本次没有查询会话级执行事实。 + pub execution: Option, +} + +/// 未决持久化的收敛结果:可重载,或仍阻塞。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PersistenceRecovery { + /// 已收敛,会话可重新加载并继续写入。 + Recovered, + /// 仍无法证明上次写入的终态:保持阻塞,调用方不得继续写。 + StillBlocked, +} + +// ─── 领域输入 ────────────────────────────────────────────────────────────────── + +/// 会话创建时冻结的版本化上下文快照(opaque 字节,沿用现有 JSON envelope)。 +/// +/// 构建与解码归 ACP frozen owner(`session::{frozen,frozen_snapshot}`);存储只 +/// 原样保存与返回,不解释内容、不因版本变化丢弃数据。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FrozenSnapshotBytes(String); + +impl FrozenSnapshotBytes { + pub fn new(bytes: impl Into) -> Self { + Self(bytes.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +/// 新会话的初始 metadata。 +/// +/// 不携带 `message_count` / `updated_at` / `cached_context` / `context_cache_epoch`: +/// 这些是行为维护的派生事实,构建方给值只会制造第二个真相。 +#[derive(Clone, Debug)] +pub struct NewSessionMeta { + pub title: Option, + pub cwd: String, + pub parent_thread_id: Option, + pub hidden: bool, + pub cancel_policy: CancelPolicy, + pub snapshot_at_message_id: Option, +} + +/// 固定身份的待创建会话:ID 与创建时间在构建时一次生成,重试不重建。 +#[derive(Clone, Debug)] +pub struct NewSession { + pub thread_id: ThreadId, + /// RFC3339 创建时间,构建时生成一次。 + pub created_at: String, + pub meta: NewSessionMeta, + /// 不可变执行绑定。 + pub binding: SessionBinding, + pub frozen: FrozenSnapshotBytes, +} + +/// fork 目标:目标身份 + source 截止点 + 已完成 ID 重映射的 payload/flags。 +/// +/// source 只读;ID 重映射由 [`crate::store::history::remap_fork_history`] 在构建时 +/// 完成,adapter 不再执行一遍 fork 算法。 +#[derive(Clone, Debug)] +pub struct ForkSnapshot { + pub target: NewSession, + pub source_id: ThreadId, + pub payloads: Vec, + pub flags: HashMap, +} + +/// child 目标:父子/根归属 + 继承快照。 +/// +/// frozen 由不可变 parent/root 关系解析并复制原字节,不重新扫描目录;inherited +/// 保存 child 创建时刻的继承区与 flags,不得用父会话当前 flags 顶替。 +/// +/// 不变量:父子关系只允许有一个真相——`target.meta.parent_thread_id` 必须等于 `parent_id` +/// (落库用的是前者),`parent_id`/`root_id` 都不得等于 `target.thread_id`。不满足的快照 +/// 是调用方的错误,必须在产生任何副作用之前被拒绝。 +#[derive(Clone, Debug)] +pub struct ChildSnapshot { + pub target: NewSession, + pub parent_id: ThreadId, + pub root_id: ThreadId, + pub inherited: InheritedContext, +} + +// ─── 读取结果 ────────────────────────────────────────────────────────────────── + +/// 会话的执行绑定分类。 +/// +/// `None` 不再同时表达 legacy、不支持和损坏:损坏与版本不支持是错误 +/// ([`SessionResourceError`]),不是状态。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BindingState { + /// 已按本机 workspace 绑定。 + Bound(SessionBinding), + /// 无绑定,但按本机规则确认是 legacy 历史(来源与记录状态联合判定)。 + LegacyConfirmed, + /// 外来会话或本机登记缺失:历史可读,但不得当作 legacy 自动接纳。 + ExternalOrUnregistered, + /// 既无绑定也无 legacy 证据。 + Missing, +} + +/// 会话 frozen 快照的状态。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrozenState { + Present(FrozenSnapshotBytes), + /// legacy 会话尚未持久化快照;由既有 legacy 规则决定能否补齐。 + LegacyAbsent, + /// 存在快照但本构建读不懂(版本/形状不支持)——不是「缺失」。 + Unsupported, +} + +/// 绑定复核的力度:两次复核的差别只在是否重跑一次完整发现。 +/// +/// 与「事务」「CAS」无关,也不表达复核结果的确定性差异:两者都在不一致时失败, +/// `Recorded` 只是省去外部进程。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BindingRecheck { + /// 权威复核:SQL 关系、关键文件对象,加一次完整发现快照比对。 + Full, + /// 同一次准入内的复核:SQL 关系与关键文件对象,不启动外部进程。 + Recorded, +} + +/// 一次一致读取的会话视图。 +/// +/// 不含 pool、缓存 epoch、事务状态或执行 handle:这些是实现细节或另一面的事实。 +#[derive(Clone, Debug)] +pub struct SessionSnapshot { + pub meta: ThreadMeta, + pub binding: BindingState, + pub frozen: FrozenState, + pub payloads: Vec, + pub flags: HashMap, + pub inherited: InheritedContext, +} + +// ─── 定向更新 ────────────────────────────────────────────────────────────────── + +/// rewind 边界:区分「保留到目标」与「从目标开始移除」。 +/// +/// transcript rewind 保留目标本身,ACP 用户 rewind 移除目标及以后;两者语义不可 +/// 合并,合并会把「保留到目标」失真成「删掉目标」。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RewindBoundary { + KeepThrough(MessageId), + RemoveFrom(MessageId), +} + +impl RewindBoundary { + pub fn message_id(&self) -> MessageId { + match self { + Self::KeepThrough(id) | Self::RemoveFrom(id) => *id, + } + } +} + +/// 定向 metadata 更新:`None` 表示不更新,`Some(None)` 表示清除。 +/// +/// 不提供整份 [`ThreadMeta`] 覆盖入口:cwd、binding、父子身份、计数与缓存都不是 +/// 调用方可以借更新顺手改掉的字段。 +#[derive(Clone, Debug, Default)] +pub struct SessionMetaPatch { + pub title: Option>, + pub status: Option, + pub cancel_policy: Option, + /// 会话配置 JSON 快照。 + pub config: Option>, +} + +// ─── 结果与错误 ──────────────────────────────────────────────────────────────── + +/// 副作用的确定性 — 与失败原因分开建模。 +/// +/// 不从 `Timeout` / `Unavailable` 自动推导「未生效」:那两种原因本身不回答 +/// 「写进去了没有」,只能由 adapter 在能证明时给出 `NotApplied`。 +/// +/// 与 A §5.4 的三态一致;那里的 `NotAppliedReason` / `UnknownReason` 由 +/// [`SessionResourceErrorKind`] 承载(原因是原因,效果是效果)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MutationOutcome { + /// 已确认未生效。 + NotApplied, + /// 已确认生效(例如数据已保存、执行准入失败)。 + Applied, + /// 无法证明生效与否:热态必须失效并阻塞续写与 clean。 + Unknown, +} + +/// 失败原因。不携带 SQL、token、事务 ID 或连接细节。 +#[derive(Debug)] +pub enum SessionResourceErrorKind { + InvalidInput { + detail: String, + }, + NotFound, + /// 后端能力面不支持该行为(含只读能力面下的 mutation)。 + Unsupported, + /// 本次打开只有读权限。 + ReadOnlyStore, + /// 本机 workspace / binding / owner 语义,保持 [`WorkspaceError`] 的分类。 + Workspace(WorkspaceError), + /// 记录存在但无法解释(损坏、格式不可读)。 + Corrupt { + detail: String, + }, + /// 后端暂不可用(网络、锁、IO):可重试,但不代表没生效。 + Unavailable { + detail: String, + }, + Timeout, + /// 无法证明上一次写入的终态;携带可定位的 session/root 关联。 + PersistenceUncertain { + thread_id: Option, + }, + /// 数据已完整保存,但执行准入失败:不是「确定未创建」。 + SavedButNotAdmitted { + thread_id: ThreadId, + }, + Internal { + detail: String, + }, +} + +/// 会话行为失败:原因 + 效果确定性。 +/// +/// `effect` 由构造器从 `kind` 派生,调用方无法拼出互相矛盾的组合: +/// `Unknown` 只对应未决持久化,`Applied` 只对应「已保存但准入失败」。 +#[derive(Debug)] +pub struct SessionResourceError { + kind: SessionResourceErrorKind, + effect: MutationOutcome, +} + +/// 会话行为的统一返回类型。 +pub type SessionResourceResult = std::result::Result; + +impl SessionResourceError { + pub fn new(kind: SessionResourceErrorKind) -> Self { + let effect = match &kind { + SessionResourceErrorKind::PersistenceUncertain { .. } => MutationOutcome::Unknown, + SessionResourceErrorKind::SavedButNotAdmitted { .. } => MutationOutcome::Applied, + _ => MutationOutcome::NotApplied, + }; + Self { kind, effect } + } + + /// 无法证明写入终态:门面据此使热态失效并阻塞续写/clean。 + pub fn persistence_uncertain(thread_id: Option) -> Self { + Self::new(SessionResourceErrorKind::PersistenceUncertain { thread_id }) + } + + /// 数据已完整保存,但执行准入失败;包含可定位的 session identity。 + pub fn saved_but_not_admitted(thread_id: ThreadId) -> Self { + Self::new(SessionResourceErrorKind::SavedButNotAdmitted { thread_id }) + } + + pub fn kind(&self) -> &SessionResourceErrorKind { + &self.kind + } + + pub fn effect(&self) -> MutationOutcome { + self.effect + } + + pub fn is_persistence_uncertain(&self) -> bool { + matches!( + self.kind, + SessionResourceErrorKind::PersistenceUncertain { .. } + ) + } + + /// 仅当失败来源于本机 workspace 语义时给出原错误,供只读降级等既有映射使用。 + pub fn workspace_error(&self) -> Option<&WorkspaceError> { + match &self.kind { + SessionResourceErrorKind::Workspace(error) => Some(error), + _ => None, + } + } + + /// 只读准入原因:本次没能取得执行所有权,但历史仍可按只读会话进入。 + /// + /// 三种既有 workspace 原因原样保留;只读存储([`SessionResourceErrorKind::ReadOnlyStore`]) + /// 归入同一集合——「本节点给不出执行所有权、历史可读」是同一件事,消费侧的降级路径 + /// 因此不必按错误种类分支。 + /// + /// 不在本集合内的原因不降级:`ReadOnlyStore` 的 workspace 变体(连会话都还没有的 + /// 登记失败)与 `PersistenceUncertain`(终态未证明)都保持原样上报。 + pub fn read_only_admission(&self) -> Option { + match &self.kind { + SessionResourceErrorKind::Workspace(error) => { + ReadOnlyAdmission::from_workspace_error(error) + } + SessionResourceErrorKind::ReadOnlyStore => { + Some(ReadOnlyAdmission::ExecutionLeaseRequired) + } + _ => None, + } + } +} + +impl From for SessionResourceError { + fn from(error: WorkspaceError) -> Self { + Self::new(SessionResourceErrorKind::Workspace(error)) + } +} + +impl std::fmt::Display for SessionResourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (reason, detail) = match &self.kind { + SessionResourceErrorKind::InvalidInput { detail } => ("invalid input", detail.as_str()), + SessionResourceErrorKind::NotFound => ("session not found", ""), + SessionResourceErrorKind::Unsupported => ("behavior is unsupported here", ""), + SessionResourceErrorKind::ReadOnlyStore => ("session store is read-only", ""), + SessionResourceErrorKind::Workspace(_) => ("workspace check failed", ""), + SessionResourceErrorKind::Corrupt { detail } => { + ("session data is corrupt", detail.as_str()) + } + SessionResourceErrorKind::Unavailable { detail } => { + ("store is unavailable", detail.as_str()) + } + SessionResourceErrorKind::Timeout => ("store operation timed out", ""), + SessionResourceErrorKind::PersistenceUncertain { .. } => { + ("persistence outcome is unknown; reload the session", "") + } + SessionResourceErrorKind::SavedButNotAdmitted { .. } => { + ("session data was saved but execution admission failed", "") + } + SessionResourceErrorKind::Internal { detail } => { + ("internal storage error", detail.as_str()) + } + }; + match &self.kind { + SessionResourceErrorKind::Workspace(error) => write!(f, "{reason}: {error}"), + _ if detail.is_empty() => { + write!(f, "{reason} ({:?})", self.effect) + } + _ => write!(f, "{reason}: {detail}"), + } + } +} + +impl std::error::Error for SessionResourceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.kind { + SessionResourceErrorKind::Workspace(error) => Some(error), + _ => None, + } + } +} + +// ─── 门面 ────────────────────────────────────────────────────────────────────── + +/// 子会话 resume 认领 handle。 +/// +/// 认领期间的 active/原状态/终态由资源内部保存,调用方只提交领域结果,不拼补偿 +/// 写入、不接触事务或重试令牌。 +#[async_trait] +pub trait ChildResumeClaim: Send + Sync { + /// 认领成功并开始运行。 + async fn mark_running(&self) -> SessionResourceResult<()>; + /// 移交后台执行(仍属本次认领)。 + async fn hand_off_to_background(&self) -> SessionResourceResult<()>; + /// 认领后准备失败:恢复到认领前的状态,不留 active 残留。 + async fn mark_failed(&self) -> SessionResourceResult<()>; + /// 认领终止(取消/宿主退出):恢复到认领前的状态。 + async fn mark_terminated(&self) -> SessionResourceResult<()>; +} + +/// 会话资源门面:业务侧唯一的会话行为入口。 +/// +/// 每个方法都要么满足后置条件,要么在产生副作用前返回明确失败;没有默认实现可用 +/// 来冒充成功。实现者必须同时保证: +/// +/// - mutation 先检查本 root 的有效 owner 与未决持久化,再落盘; +/// - `AccessMode::ReadOnly` 下所有 mutation 返回 [`SessionResourceErrorKind::ReadOnlyStore`]; +/// - 结果不确定时返回 [`SessionResourceError::persistence_uncertain`],不得重试后伪装成功。 +#[async_trait] +pub trait SessionResources: Send + Sync { + // ── 能力与准入 ── + + /// 本次打开的权限、后端能力面;给定 `session` 时附带该会话的执行资格。 + /// + /// 只暴露行为面的能力,不暴露事务、CAS 或隔离级别。 + async fn inspect_availability( + &self, + session: Option<&ThreadId>, + ) -> SessionResourceResult; + + /// 解析并登记一个本机执行目录。 + async fn resolve_workspace(&self, cwd: &Path) -> SessionResourceResult; + + /// 校验会话与给定 workspace 的绑定关系;不取得执行所有权。 + async fn validate_session( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult<()>; + + /// 取得本机执行所有权(唯一 owner)。 + /// + /// 他处持有、dirty 未解除、存在未决持久化或本次只读时明确拒绝,不降级为成功。 + async fn acquire_execution( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult>; + + /// 解除精确代际的本机 dirty;永远不解除未决持久化,也不接受把普通 dirty 映射成 reset。 + async fn reset_dirty_execution(&self, request: &ResetDirtyRequest) + -> SessionResourceResult<()>; + + // ── 创建与接纳 ── + + /// 创建新会话:meta/binding/frozen 完整保存后返回执行准入。 + /// + /// 数据已保存但准入失败时返回 + /// [`SessionResourceError::saved_but_not_admitted`],不得报告成「确定未创建」; + /// 未发布创建的撤销由门面内部承担(见 [`Self::abandon_initialization`])。 + async fn create_session( + &self, + input: &NewSession, + ) -> SessionResourceResult>; + + /// 撤销本次尚未发布的创建(不是通用 rollback,不修改既有 source 会话)。 + async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + ) -> SessionResourceResult<()>; + + /// 接纳已确认的本机 legacy 会话:binding 与缺失的 frozen 一起成立。 + /// + /// 竞争时返回胜者事实,不返回 CAS 布尔值;`saved_cwd` 必须是会话保存的原始 + /// 路径(不允许调用方借接纳顺手改绑)。 + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()>; + + // ── 读取 ── + + /// 一次一致读取:metadata、binding 分类、frozen 状态、own payload/flags、inherited。 + async fn load_session_snapshot(&self, id: &ThreadId) -> SessionResourceResult; + + /// 轻量绑定分类:只回答本机能否验证这条会话的绑定,不加载历史、不启动发现。 + /// + /// 与 [`Self::load_session_snapshot`] 的同一套分类规则,供只关心绑定/workspace 的 + /// 读取(`session/context`、metadata 之外的准入身份)使用;会话不存在是 + /// [`SessionResourceErrorKind::NotFound`],损坏与版本不支持是错误,不冒充状态。 + async fn load_session_binding(&self, id: &ThreadId) -> SessionResourceResult; + + /// 按会话 identity 复核绑定并给出执行目录;不取得执行所有权。 + /// + /// 绑定缺失、绑定指向的本机登记已不存在或当前目录与记录不一致时明确失败, + /// 不降级成「没有绑定」,也不在复核中改绑。`check` 决定复核力度,见 + /// [`BindingRecheck`]。 + async fn validate_bound_workspace( + &self, + id: &ThreadId, + check: BindingRecheck, + ) -> SessionResourceResult; + + /// 完整逻辑上下文(继承区在前、自有 payload 在后):历史回放与 `metadata(history)` + /// 的唯一读取,不返回派生缓存。 + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult>; + + /// 小型 metadata 投影;不加载历史或大快照。 + async fn load_session_meta(&self, id: &ThreadId) -> SessionResourceResult; + + /// 按 scope/cursor/limit 分页列举;过滤在数据端完成。 + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult; + + /// 直接子会话 metadata。 + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult>; + + /// 以 `root` 为根的整棵会话树 metadata(含自身)。 + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult>; + + // ── 写入 ── + + /// 追加 canonical payload 批次。 + /// + /// 顺序稳定、计数与自动标题一致维护;id 已存在或批次内重复必须失败, + /// 不得静默忽略(见 [`crate::store::history::ensure_distinct_ids`])。 + async fn append_history( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()>; + + /// 保存 fork 目标快照;source 不变。 + async fn save_fork( + &self, + fork: &ForkSnapshot, + ) -> SessionResourceResult>; + + /// 保存 child:继承区与父子关系一起成立,沿用已存在的 root owner。 + /// + /// 父子关系在快照里出现两次([`ChildSnapshot::parent_id`] 与 + /// [`NewSessionMeta::parent_thread_id`]),实现必须在写入前要求两者一致;不一致、 + /// 自指父关系、把自己当根都必须拒绝,且拒绝不留任何写入(详见 `ChildSnapshot` 不变量)。 + async fn save_child( + &self, + child: &ChildSnapshot, + lease: &Arc, + ) -> SessionResourceResult<()>; + + /// 在有效根 owner 下串行认领 child resume。 + async fn claim_child_resume( + &self, + child: &ThreadId, + root: &ThreadId, + ) -> SessionResourceResult>; + + /// 应用一次 compaction 变更:摘要、flags、计数与缓存视图全部生效或全部不生效。 + async fn apply_compaction( + &self, + id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()>; + + /// 应用投影/flags 变更集,并由资源统一维护派生缓存视图。 + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()>; + + /// 按显式边界 rewind;派生计数与缓存同步更新。 + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()>; + + /// 按 id 集合精确移除历史条目。 + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()>; + + /// 定向更新 metadata(标题/状态/取消策略/配置),不整份覆盖。 + async fn update_session_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()>; + + /// 删除整棵会话树。 + /// + /// 数据删除一致完成;执行关闭与未决持久化证据不得被级联提前抹掉 + /// (删除是刻意行为这一事实必须留下)。 + async fn delete_session_tree(&self, id: &ThreadId) -> SessionResourceResult<()>; + + /// 收敛未决持久化:adapter 内部完成,调用方不提供 operation token。 + async fn recover_session_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult; + + /// 排空该会话已排队的持久化写入(有界等待)。 + async fn drain_persistence(&self, id: &ThreadId) -> SessionResourceResult<()>; +} + +/// 部署生命周期关闭端口:关闭整个会话存储的**唯一**入口。 +/// +/// 门面([`SessionResources`])是业务句柄:可以克隆、可以注入 Agent/Controller/ +/// middleware,它只提供会话行为与必要的收敛能力(恢复、排空)。**关闭整个存储不是 +/// 业务行为**——那会让任何持有句柄的调用方获得全局销毁权,因此关闭权单独由本端口 +/// 承载,并由部署装配(TUI/print/stdio 的宿主配置)在**自己的任务排空之后**调用一次。 +/// +/// 实现必须把两件事分开: +/// +/// - 关闭**不可逆地**停止新写入,但「停止新写入」不等于「已关闭」:只有真实检查 +/// (在途写入、未决持久化)全部结清并确实关闭数据面之后才确认关闭,此后的重复调用 +/// 才幂等成功; +/// - 失败或被取消的关闭不构成确认,重复调用必须重新检查。未确认期间该存储的恢复与 +/// 排空仍然可用(业务句柄不受影响),否则第一次关闭失败就再也没有收敛路径。 +#[async_trait] +pub trait SessionStoreShutdownPort: Send + Sync { + /// 排空之后关闭会话存储;只有确认关闭才返回 `Ok`。 + async fn shutdown(&self) -> SessionResourceResult<()>; +} + +#[cfg(test)] +#[path = "session_resources_test.rs"] +mod tests; diff --git a/peri-acp-types/src/session_resources_test.rs b/peri-acp-types/src/session_resources_test.rs new file mode 100644 index 000000000..2999f2f45 --- /dev/null +++ b/peri-acp-types/src/session_resources_test.rs @@ -0,0 +1,191 @@ +//! 门面契约的不变量测试:三个独立事实、效果确定性、定向更新语义。 + +use super::*; +use crate::thread::ThreadId; + +fn thread_id() -> ThreadId { + ThreadId::from("0197f3f0-0000-7000-8000-000000000001".to_string()) +} + +#[test] +fn access_mode_capabilities_and_execution_are_independent_facts() { + // 只读授权不蕴含「只能读历史」:数据能力仍可为 Complete,只是本次不允许写。 + let read_only_remote = SessionAvailability { + access: AccessMode::ReadOnly, + capabilities: DataCapabilities::Complete, + execution: Some(ExecutionAvailability::ReadOnlyStore), + }; + assert_eq!(read_only_remote.access, AccessMode::ReadOnly); + assert_eq!(read_only_remote.capabilities, DataCapabilities::Complete); + assert_eq!( + read_only_remote.execution, + Some(ExecutionAvailability::ReadOnlyStore) + ); + + // 数据能力 Complete 不蕴含可执行:上次执行没有干净收尾时必须先按代际恢复。 + let writable_but_dirty = SessionAvailability { + access: AccessMode::ReadWrite, + capabilities: DataCapabilities::Complete, + execution: Some(ExecutionAvailability::Dirty(RecoveryRequiredDetails { + thread_id: thread_id(), + generation: 3, + })), + }; + assert_ne!( + writable_but_dirty.execution, + Some(ExecutionAvailability::Available) + ); + + // 历史只读后端仍可声明读权限;未指定会话时不编造执行事实。 + let history_read_only = SessionAvailability { + access: AccessMode::ReadWrite, + capabilities: DataCapabilities::HistoryReadOnly, + execution: None, + }; + assert_eq!(history_read_only.execution, None); +} + +#[test] +fn persistence_uncertain_is_the_only_unknown_effect() { + let id = thread_id(); + let uncertain = SessionResourceError::persistence_uncertain(Some(id.clone())); + assert_eq!(uncertain.effect(), MutationOutcome::Unknown); + assert!(uncertain.is_persistence_uncertain()); + assert!(matches!( + uncertain.kind(), + SessionResourceErrorKind::PersistenceUncertain { thread_id: Some(inner) } if *inner == id + )); + + // 已保存但准入失败:数据确实生效,不能报告成「确定未创建」。 + let not_admitted = SessionResourceError::saved_but_not_admitted(id.clone()); + assert_eq!(not_admitted.effect(), MutationOutcome::Applied); + + // 其余原因默认「已确认未生效」:不从 Timeout/Unavailable 推导别的含义。 + for kind in [ + SessionResourceErrorKind::NotFound, + SessionResourceErrorKind::Unsupported, + SessionResourceErrorKind::ReadOnlyStore, + SessionResourceErrorKind::Timeout, + SessionResourceErrorKind::Unavailable { + detail: "network".into(), + }, + SessionResourceErrorKind::Corrupt { + detail: "bad row".into(), + }, + ] { + assert_eq!( + SessionResourceError::new(kind).effect(), + MutationOutcome::NotApplied + ); + } +} + +#[test] +fn error_display_keeps_effect_but_leaks_no_identity() { + let id = thread_id(); + let uncertain = SessionResourceError::persistence_uncertain(Some(id.clone())); + let rendered = uncertain.to_string(); + assert!(rendered.contains("reload"), "{rendered}"); + assert!( + !rendered.contains(id.as_str()), + "错误文本不携带会话标识:{rendered}" + ); + + let not_admitted = SessionResourceError::saved_but_not_admitted(id.clone()); + assert!(!not_admitted.to_string().contains(id.as_str())); +} + +#[test] +fn workspace_failure_keeps_local_semantics_and_source() { + let error: SessionResourceError = WorkspaceError::RecoveryRequired(RecoveryRequiredDetails { + thread_id: thread_id(), + generation: 7, + }) + .into(); + + assert_eq!(error.effect(), MutationOutcome::NotApplied); + assert!(matches!( + error.workspace_error(), + Some(WorkspaceError::RecoveryRequired(details)) if details.generation == 7 + )); + assert!(std::error::Error::source(&error).is_some()); + assert!(error.to_string().contains("recovery is required")); +} + +#[test] +fn meta_patch_distinguishes_untouched_from_cleared() { + let untouched = SessionMetaPatch::default(); + let cleared = SessionMetaPatch { + title: Some(None), + ..Default::default() + }; + + assert!(untouched.title.is_none(), "None 表示不更新"); + assert_eq!(cleared.title, Some(None), "Some(None) 表示清除"); + + let set = SessionMetaPatch { + title: Some(Some("new title".into())), + status: Some(AgentStatus::Done), + cancel_policy: Some(CancelPolicy::Independent), + config: Some(None), + }; + assert_eq!(set.title, Some(Some("new title".into()))); + assert_eq!(set.status, Some(AgentStatus::Done)); + assert_eq!(set.cancel_policy, Some(CancelPolicy::Independent)); + assert_eq!(set.config, Some(None)); +} + +#[test] +fn frozen_snapshot_bytes_stay_opaque() { + let envelope = r#"{"version":1,"data":{"entries":[]}}"#; + + let bytes = FrozenSnapshotBytes::new(envelope); + + assert_eq!(bytes.as_str(), envelope, "存储不得重编码 envelope"); + assert_eq!(bytes.clone().into_string(), envelope); +} + +#[test] +fn rewind_boundary_reports_its_target() { + let id = MessageId::new(); + assert_eq!(RewindBoundary::KeepThrough(id).message_id(), id); + assert_eq!(RewindBoundary::RemoveFrom(id).message_id(), id); +} + +#[test] +fn read_only_admission_keeps_degradation_and_blocking_separate() { + use crate::workspace::{ReadOnlyAdmission, RecoveryRequiredDetails, WorkspaceError}; + + // 只读存储:历史可按只读会话进入,「本节点给不出执行所有权」与既有原因同类。 + let read_only = SessionResourceError::new(SessionResourceErrorKind::ReadOnlyStore); + assert_eq!( + read_only.read_only_admission(), + Some(ReadOnlyAdmission::ExecutionLeaseRequired) + ); + + // 本机 workspace 原因原样保留。 + let busy = SessionResourceError::from(WorkspaceError::ExecutionBusy); + assert_eq!( + busy.read_only_admission(), + Some(ReadOnlyAdmission::ExecutionBusy) + ); + let dirty_details = RecoveryRequiredDetails { + thread_id: thread_id(), + generation: 7, + }; + let dirty = SessionResourceError::from(WorkspaceError::RecoveryRequired(dirty_details.clone())); + assert_eq!( + dirty.read_only_admission(), + Some(ReadOnlyAdmission::RecoveryRequired(dirty_details)) + ); + + // 连会话都没有的登记失败不降级。 + let registration = SessionResourceError::from(WorkspaceError::ReadOnlyStore); + assert_eq!(registration.read_only_admission(), None); + assert!(registration.workspace_error().is_some()); + + // 未证明终态的写入不进入只读降级,也不能被自动 reset 分支吞掉。 + let uncertain = SessionResourceError::persistence_uncertain(Some(thread_id())); + assert_eq!(uncertain.read_only_admission(), None); + assert!(uncertain.is_persistence_uncertain()); +} diff --git a/peri-acp-types/src/session_store.rs b/peri-acp-types/src/session_store.rs new file mode 100644 index 000000000..d98c03af3 --- /dev/null +++ b/peri-acp-types/src/session_store.rs @@ -0,0 +1,172 @@ +//! 会话存储部署参数 — 跨层传递的**中性**定位描述(D:定位、凭证与部署装配)。 +//! +//! 这个类型只承载「用户希望把会话存储放在哪里、用什么访问」,是部署入口(TUI / +//! print / ACP stdio / meta)与资源装配层之间的载体:它**不是**解析结果,也不 +//! 建立连接。真正的纯解析(locator 形态、`env:` 解引用、引擎名、凭证来源)与后端 +//! 选择只发生在资源装配层,见 `peri-resources` 的 typed open request。 +//! +//! 两种定位输入在类型上保持区分([`SessionStoreLocator`]):`--db-path` 是**已确认的 +//! 本机路径**,`--session-store` 是**待解析的 locator 原文**。路径不经 `String` 往返, +//! 资源层也不会把它当 `env:` 引用或远程 URL 重新解释。 +//! +//! 纪律(与资源层一致): +//! +//! - 只接受凭证**来源**(环境变量名),不接受 token 字面量,不读 `.env`; +//! - `engine` 只在 locator 形态无法唯一决定引擎时给出,资源层不按 scheme/端口猜; +//! - `access` 是入口决定的意图,**不是权限**:能不能写由打开结果回答; +//! - 本类型不含凭证值,`Debug` 也不回显任何 locator 取值(远程 locator 含主机与库名)。 + +use std::fmt; +use std::path::PathBuf; + +use crate::session_resources::AccessMode; + +/// 中性定位描述:默认本机库 / 已确认本机路径 / 待解析 locator 原文。 +/// +/// 三者的区别是**语义**,不是表示形式: +/// +/// - [`Self::LocalPath`]:`--db-path` 归一后的已确认本机路径。保留平台路径语义与原始 +/// 字节(Windows drive/UNC、Unix 非 UTF-8 文件名),资源层原样交给本机装配——既不按 +/// `env:` 解引用,也不按 URL 猜后端。因此 `--db-path env:UNSET` 指的是名为 +/// `env:UNSET` 的本机文件; +/// - [`Self::Locator`]:`--session-store` 原文,形态未定(本机路径、远程 URL 或 +/// `env:<变量名>`),交由资源装配层纯解析。`--session-store env:UNSET` 是解引用环境 +/// 变量 `UNSET`,与上一条语义不同; +/// - [`Self::Default`]:未指定,由资源层换成默认本机库(`~/.peri/threads/threads.db`)。 +#[derive(Clone, PartialEq, Eq)] +pub enum SessionStoreLocator { + Default, + LocalPath(PathBuf), + Locator(String), +} + +impl fmt::Debug for SessionStoreLocator { + /// 只给形态,不给取值:`Locator` 原文可能是含主机与库名的远程 locator, + /// 本机路径含用户环境。 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Default => formatter.write_str("Default"), + Self::LocalPath(_) => formatter.write_str("LocalPath()"), + Self::Locator(_) => formatter.write_str("Locator()"), + } + } +} + +/// 会话存储部署参数。 +/// +/// 默认值([`SessionStoreDeployment::default_local`])=默认本机库 + 读写意图, +/// 与既有 `--db-path` 缺省行为一致;`--db-path ` 归一为 +/// [`SessionStoreDeployment::local_path`],`--session-store ` 归一为 +/// [`SessionStoreDeployment::from_locator`],两者与 `--session-store` 互斥。 +#[derive(Clone, PartialEq, Eq)] +pub struct SessionStoreDeployment { + locator: SessionStoreLocator, + engine: Option, + credential_env: Option, + access: AccessMode, +} + +impl SessionStoreDeployment { + /// 默认本机库(`~/.peri/threads/threads.db`)、读写意图。 + pub fn default_local() -> Self { + Self { + locator: SessionStoreLocator::Default, + engine: None, + credential_env: None, + access: AccessMode::ReadWrite, + } + } + + /// 既有 `--db-path` 归一:已确认本机路径,语义与迁移前一致。 + /// + /// 路径按 `PathBuf` 原样保留,不转成字符串:`env:` 之类的**文件名**不会被当成 + /// 环境变量引用,非 UTF-8 字节也不会在往返中损坏。 + pub fn local_path(path: PathBuf) -> Self { + Self { + locator: SessionStoreLocator::LocalPath(path), + engine: None, + credential_env: None, + access: AccessMode::ReadWrite, + } + } + + /// `--session-store ` 原文(本机路径、远程 URL 或 `env:<变量名>`)。 + /// + /// 原文形态未定,由资源装配层解析;本构造不做任何形态判断。 + pub fn from_locator(raw: impl Into) -> Self { + Self { + locator: SessionStoreLocator::Locator(raw.into()), + engine: None, + credential_env: None, + access: AccessMode::ReadWrite, + } + } + + /// `--session-store-engine <名>`:只在 locator 形态无法唯一决定引擎时需要。 + pub fn with_engine(mut self, engine: impl Into) -> Self { + self.engine = Some(engine.into()); + self + } + + /// `--session-store-token-env <变量名>`:凭证来源,不接受凭证值。 + pub fn with_credential_env(mut self, name: impl Into) -> Self { + self.credential_env = Some(name.into()); + self + } + + /// 访问意图(入口决定;只读入口不得给出读写意图)。 + pub fn with_access(mut self, access: AccessMode) -> Self { + self.access = access; + self + } + + /// 定位描述:默认本机库 / 已确认本机路径 / 待解析 locator 原文。 + pub fn locator(&self) -> &SessionStoreLocator { + &self.locator + } + + /// 显式引擎名(尚未校验;校验归资源层)。 + pub fn engine_name(&self) -> Option<&str> { + self.engine.as_deref() + } + + /// 凭证来源变量名(尚未校验;值从不进入本结构)。 + pub fn credential_env_name(&self) -> Option<&str> { + self.credential_env.as_deref() + } + + /// 入口声明的访问意图。 + pub fn access(&self) -> AccessMode { + self.access + } +} + +impl Default for SessionStoreDeployment { + fn default() -> Self { + Self::default_local() + } +} + +impl fmt::Debug for SessionStoreDeployment { + /// 故意不打印 locator 取值与凭证变量名之外的内容:诊断只需要「形态 + 是否配置」。 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SessionStoreDeployment") + .field( + "locator", + &match self.locator { + SessionStoreLocator::Default => "", + SessionStoreLocator::LocalPath(_) => "", + SessionStoreLocator::Locator(_) => "", + }, + ) + .field("engine_configured", &self.engine.is_some()) + .field("credential_env_configured", &self.credential_env.is_some()) + .field("access", &self.access) + .finish() + } +} + +#[cfg(test)] +#[path = "session_store_test.rs"] +mod tests; diff --git a/peri-acp-types/src/session_store_test.rs b/peri-acp-types/src/session_store_test.rs new file mode 100644 index 000000000..9e8eaf472 --- /dev/null +++ b/peri-acp-types/src/session_store_test.rs @@ -0,0 +1,111 @@ +//! 会话存储部署参数的确定性测试:默认值、构造归一、两类定位输入的语义区分与 +//! `Debug` 不泄密。 + +use std::path::PathBuf; + +use crate::session_resources::AccessMode; +use crate::session_store::{SessionStoreDeployment, SessionStoreLocator}; + +#[test] +fn default_is_local_read_write() { + let deployment = SessionStoreDeployment::default_local(); + assert_eq!(deployment.locator(), &SessionStoreLocator::Default); + assert!(deployment.engine_name().is_none()); + assert!(deployment.credential_env_name().is_none()); + assert_eq!(deployment.access(), AccessMode::ReadWrite); + assert_eq!(SessionStoreDeployment::default(), deployment); +} + +#[test] +fn builders_carry_locator_engine_credential_and_access() { + let deployment = SessionStoreDeployment::from_locator("env:TURSO_URL") + .with_engine("turso") + .with_credential_env("TURSO_TOEKN") + .with_access(AccessMode::ReadOnly); + assert_eq!( + deployment.locator(), + &SessionStoreLocator::Locator("env:TURSO_URL".to_owned()) + ); + assert_eq!(deployment.engine_name(), Some("turso")); + assert_eq!(deployment.credential_env_name(), Some("TURSO_TOEKN")); + assert_eq!(deployment.access(), AccessMode::ReadOnly); +} + +/// `--db-path` 归一后是**已确认本机路径**:平台拼写(Windows drive)原样保留在 +/// `PathBuf` 里,不再被表达成一个可能被二次解释的字符串。 +#[test] +fn local_path_keeps_platform_path_spelling() { + let path = PathBuf::from("C:\\work\\threads.db"); + let deployment = SessionStoreDeployment::local_path(path.clone()); + assert_eq!(deployment.locator(), &SessionStoreLocator::LocalPath(path)); + assert!(deployment.engine_name().is_none()); +} + +/// 同一段字符走两条入口得到不同语义:`--db-path` 是文件名,`--session-store` 是原文。 +#[test] +fn db_path_and_session_store_inputs_are_distinct() { + let as_path = SessionStoreDeployment::local_path(PathBuf::from("env:UNSET")); + let as_locator = SessionStoreDeployment::from_locator("env:UNSET"); + + assert_eq!( + as_path.locator(), + &SessionStoreLocator::LocalPath(PathBuf::from("env:UNSET")) + ); + assert_eq!( + as_locator.locator(), + &SessionStoreLocator::Locator("env:UNSET".to_owned()) + ); + assert_ne!(as_path, as_locator); +} + +/// Unix 非 UTF-8 文件名:字节在部署参数里原样保留(`to_string_lossy` 会把它换成 +/// U+FFFD,进而指向另一个文件)。 +#[cfg(unix)] +#[test] +fn local_path_keeps_non_utf8_bytes() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let path = PathBuf::from(OsStr::from_bytes(b"threads-\xff\xfe.db")); + assert!(path.to_str().is_none(), "用例本身要求非 UTF-8 路径"); + + let deployment = SessionStoreDeployment::local_path(path.clone()); + match deployment.locator() { + SessionStoreLocator::LocalPath(kept) => { + assert_eq!(kept.as_os_str().as_bytes(), path.as_os_str().as_bytes()); + } + other => panic!("期望 LocalPath,得到 {other:?}"), + } +} + +#[test] +fn debug_does_not_echo_locator_or_credential_name() { + let deployment = SessionStoreDeployment::from_locator("turso://sentinel-db-sentinel.turso.io") + .with_credential_env("PERI_SENTINEL_TOKEN_NAME"); + let rendered = format!("{deployment:?}"); + + assert!(rendered.contains("")); + assert!(rendered.contains("ReadWrite")); + assert!(!rendered.contains("sentinel-db-sentinel")); + assert!(!rendered.contains("PERI_SENTINEL_TOKEN_NAME")); +} + +#[test] +fn debug_marks_default_local() { + let rendered = format!("{:?}", SessionStoreDeployment::default_local()); + assert!(rendered.contains("")); + assert!(rendered.contains("engine_configured: false")); +} + +/// 本机路径也不回显取值(路径含用户环境),只标形态。 +#[test] +fn debug_does_not_echo_local_path_value() { + let deployment = + SessionStoreDeployment::local_path(PathBuf::from("/sentinel-home-sentinel/threads.db")); + let rendered = format!("{deployment:?}"); + let locator_debug = format!("{:?}", deployment.locator()); + + assert!(rendered.contains("")); + assert!(!rendered.contains("sentinel-home-sentinel")); + assert!(!locator_debug.contains("sentinel-home-sentinel")); +} diff --git a/peri-acp-types/src/store/history.rs b/peri-acp-types/src/store/history.rs new file mode 100644 index 000000000..1483a187e --- /dev/null +++ b/peri-acp-types/src/store/history.rs @@ -0,0 +1,206 @@ +//! 会话历史纯变换 — fork / compact / 投影 / rewind 的领域规则唯一实现。 +//! +//! 这里的函数不读时钟、不生成 UUID、不访问环境与数据库:新 ID、时间戳与持久化 +//! 一律由调用方以显式输入提供(fork 的 ID 分配经 `allocate_id` 注入)。同一输入 +//! 必然得到同一输出,因此 ACP、transcript 与资源 adapter 可以共用同一份规则, +//! 而不是各自复制一份「差不多」的实现。 +//! +//! 这里只做变换,不做 I/O,也不决定事务边界:整体生效/不生效由调用这些函数的 +//! adapter 负责(SQLite 用同一事务,远程 adapter 见其自身保证)。 + +use std::collections::{HashMap, HashSet}; + +use crate::messages::{BaseMessage, MessageId}; +use crate::projection::MessageProjectionDirective; +use crate::session_resources::RewindBoundary; +use crate::store::{CompactionChange, MessageFlags, PersistedPayload}; + +/// 纯变换的输入无法解释,或与既有历史冲突。 +/// +/// 调用方必须在产生任何副作用之前处理这些错误:它们都表示「没做任何事」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum HistoryError { + /// rewind/删除的边界目标不在给定历史中。 + #[error("history target {id:?} is not present")] + TargetMissing { id: MessageId }, + /// 追加的 payload id 已存在,或同一批次内重复。 + #[error("history payload id {id:?} already exists or repeats within the batch")] + DuplicateId { id: MessageId }, +} + +/// fork 目标历史:payload 与 flags 都已完成 ID 重映射。 +#[derive(Clone, Debug, Default)] +pub struct ForkedHistory { + pub payloads: Vec, + pub flags: HashMap, +} + +/// rewind 结果:保留的 payload 与此前存在、现在被移除的 id(按历史顺序)。 +#[derive(Clone, Debug)] +pub struct RewoundHistory { + pub kept: Vec, + pub removed: Vec, +} + +/// 复制 source 的 payload/flags 到 fork 目标,逐条分配新 ID 并重写投影引用。 +/// +/// - `allocate_id` 注入新 ID:生产用 `MessageId::new()`,测试可用计数器,本函数 +/// 自身不产生随机性。 +/// - flags 只复制「payload 确实存在」的条目;投影条目里指向被复制消息的引用 +/// 一并改写到新 ID,否则目标会话会引用 source 的消息。 +/// - source 只读,本函数不修改任何输入。 +pub fn remap_fork_history( + source_payloads: &[PersistedPayload], + source_flags: &HashMap, + mut allocate_id: impl FnMut() -> MessageId, +) -> ForkedHistory { + let mut payloads = Vec::with_capacity(source_payloads.len()); + let mut id_map: HashMap = HashMap::with_capacity(source_payloads.len()); + for source in source_payloads { + let source_id = source.id(); + let forked = match source { + PersistedPayload::Message(message) => { + PersistedPayload::Message(message.clone().with_message_id(allocate_id())) + } + PersistedPayload::SystemReminder { reminder, .. } => PersistedPayload::SystemReminder { + id: allocate_id(), + reminder: reminder.clone(), + }, + }; + id_map.insert(source_id, forked.id()); + payloads.push(forked); + } + let flags = source_payloads + .iter() + .filter_map(|source| { + let source_id = source.id(); + let flags = source_flags.get(&source_id)?.clone(); + let forked_id = id_map[&source_id]; + Some((forked_id, remap_projection_references(flags, &id_map))) + }) + .collect(); + ForkedHistory { payloads, flags } +} + +/// 投影 flag 规则:设置 directive 的同时标记 `truncated`(Micro compact 语义)。 +/// +/// 保留 `excluded` 等既有标记:投影与排除是两个独立事实,不互相清除。 +pub fn flags_with_projection( + existing: &MessageFlags, + directive: MessageProjectionDirective, +) -> MessageFlags { + let mut flags = existing.clone(); + flags.truncated = true; + flags.projection = Some(directive); + flags +} + +/// 批量应用 flags 变更:默认 flags 表示「无标记」,从视图移除而不是存入默认值。 +/// +/// 顺序即后写覆盖先写;同一 id 在批次内出现多次时以最后一次为准。 +pub fn apply_flag_updates( + flags: &mut HashMap, + updates: &[(MessageId, MessageFlags)], +) { + for (id, value) in updates { + if *value == MessageFlags::default() { + flags.remove(id); + } else { + flags.insert(*id, value.clone()); + } + } +} + +/// 追加前的 ID 冲突检测:已知 id 或批次内重复都必须在副作用前失败。 +/// +/// `is_known` 由调用方给出自己的视图(transcript 用内存索引,adapter 用持久化事实)。 +pub fn ensure_distinct_ids( + payloads: &[PersistedPayload], + is_known: impl Fn(MessageId) -> bool, +) -> Result<(), HistoryError> { + let mut seen = HashSet::with_capacity(payloads.len()); + for payload in payloads { + let id = payload.id(); + if !seen.insert(id) || is_known(id) { + return Err(HistoryError::DuplicateId { id }); + } + } + Ok(()) +} + +/// rewind 边界对应的保留长度(不含移除部分)。 +/// +/// `KeepThrough` 保留目标本身(transcript rewind),`RemoveFrom` 移除目标及以后 +/// (用户 rewind)。两个语义不可合并:合并会把「保留到目标」失真成「删掉目标」。 +pub fn rewind_keep_len(ids: &[MessageId], boundary: RewindBoundary) -> Result { + let target = boundary.message_id(); + let index = ids + .iter() + .position(|id| *id == target) + .ok_or(HistoryError::TargetMissing { id: target })?; + Ok(match boundary { + RewindBoundary::KeepThrough(_) => index + 1, + RewindBoundary::RemoveFrom(_) => index, + }) +} + +/// 按边界裁剪 canonical payload,返回保留部分与被移除的 id。 +/// +/// 不修改输入:调用方拿到结果后再决定如何提交(内存替换 / 持久化)。 +pub fn apply_rewind( + payloads: &[PersistedPayload], + boundary: RewindBoundary, +) -> Result { + let ids: Vec = payloads.iter().map(PersistedPayload::id).collect(); + let keep_len = rewind_keep_len(&ids, boundary)?; + Ok(RewoundHistory { + kept: payloads[..keep_len].to_vec(), + removed: ids[keep_len..].to_vec(), + }) +} + +/// 把一次 compaction 变更应用到 canonical 历史视图。 +/// +/// 追加消息的 id 必须与既有历史不冲突(同一 id 已存在或批次内重复即失败), +/// flags 更新按 [`apply_flag_updates`] 的规则合并。返回 `Err` 时输入未被修改。 +pub fn apply_compaction_change( + payloads: &mut Vec, + flags: &mut HashMap, + change: &CompactionChange, +) -> Result<(), HistoryError> { + let appended = appended_payloads(&change.appended_messages); + { + let known: HashSet = payloads.iter().map(PersistedPayload::id).collect(); + ensure_distinct_ids(&appended, |id| known.contains(&id))?; + } + apply_flag_updates(flags, &change.flag_updates); + payloads.extend(appended); + Ok(()) +} + +/// 改写 flags 中指向已复制消息的投影条目。 +fn remap_projection_references( + mut flags: MessageFlags, + id_map: &HashMap, +) -> MessageFlags { + if let Some(projection) = flags.projection.as_mut() { + for entry in &mut projection.entries { + if let Some(remapped) = id_map.get(&entry.message_id) { + entry.message_id = *remapped; + } + } + } + flags +} + +/// 追加消息的 canonical payload 形式(供只需要 payload 视图的调用方复用)。 +pub fn appended_payloads(messages: &[BaseMessage]) -> Vec { + messages + .iter() + .map(|message| PersistedPayload::Message(message.clone())) + .collect() +} + +#[cfg(test)] +#[path = "history_test.rs"] +mod tests; diff --git a/peri-acp-types/src/store/history_test.rs b/peri-acp-types/src/store/history_test.rs new file mode 100644 index 000000000..ad5844735 --- /dev/null +++ b/peri-acp-types/src/store/history_test.rs @@ -0,0 +1,307 @@ +//! 纯历史变换的行为测试:fork 重映射、投影 flag、compaction 批次与 rewind 边界。 + +use super::*; +use crate::projection::{ProjectionAction, ProjectionActionEntry, ProjectionTarget}; +use crate::session_resources::RewindBoundary; +use crate::system_reminder::{ + ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, + ReminderSource, SystemReminder, TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, +}; + +/// 确定性 ID 分配器:同一输入在测试中必然得到同一结果(本模块不产生随机性)。 +fn deterministic_ids() -> impl FnMut() -> MessageId { + let mut counter = 0u128; + move || { + counter += 1; + MessageId::from(uuid::Uuid::from_u128(counter)) + } +} + +fn reminder_payload() -> PersistedPayload { + let reminder = SystemReminder { + version: SYSTEM_REMINDER_VERSION, + category: ReminderCategory::Security, + source: ReminderSource("history_test".into()), + kind: "notice".into(), + severity: ReminderSeverity::Warning, + delivery: ReminderDelivery::Required, + audiences: ReminderAudiences(vec![ReminderAudience::Model]), + body: "body".into(), + summary: None, + metadata: serde_json::json!({}), + }; + PersistedPayload::SystemReminder { + id: MessageId::new(), + reminder: TrustedSystemReminderFactory::for_producer() + .construct(reminder) + .unwrap(), + } +} + +fn directive_referencing(id: MessageId) -> MessageProjectionDirective { + MessageProjectionDirective { + policy_version: 1, + entries: vec![ProjectionActionEntry { + message_id: id, + target: ProjectionTarget::Message, + action: ProjectionAction::CompactText { max_chars: 10 }, + }], + } +} + +#[test] +fn fork_remap_allocates_new_ids_and_keeps_payload_discriminants() { + let message = PersistedPayload::Message(BaseMessage::human("hello")); + let source_payloads = vec![message.clone(), reminder_payload()]; + let forked = remap_fork_history(&source_payloads, &HashMap::new(), deterministic_ids()); + + assert_eq!(forked.payloads.len(), 2); + assert!(matches!(forked.payloads[0], PersistedPayload::Message(_))); + assert!(matches!( + forked.payloads[1], + PersistedPayload::SystemReminder { .. } + )); + // 新 ID 必须与 source 不同:SQLite 的 message_id 是全库主键,沿用会让 fork 丢行。 + for (source, copied) in source_payloads.iter().zip(&forked.payloads) { + assert_ne!(source.id(), copied.id()); + } +} + +#[test] +fn fork_remap_rewrites_projection_references_and_drops_foreign_flags() { + let source_payloads = vec![ + PersistedPayload::Message(BaseMessage::human("a")), + PersistedPayload::Message(BaseMessage::ai("b")), + ]; + let mut source_flags = HashMap::new(); + source_flags.insert( + source_payloads[0].id(), + MessageFlags { + truncated: false, + excluded: true, + projection: Some(directive_referencing(source_payloads[0].id())), + }, + ); + // 不在 payload 里的 flags 属于 source 的其他消息:不能带进 fork 目标。 + source_flags.insert( + MessageId::new(), + MessageFlags { + truncated: true, + excluded: false, + projection: None, + }, + ); + let payloads_before = payload_ids(&source_payloads); + let flags_before = source_flags.clone(); + + let forked = remap_fork_history(&source_payloads, &source_flags, deterministic_ids()); + + assert_eq!(forked.flags.len(), 1); + let forked_id = forked.payloads[0].id(); + let flags = forked.flags.get(&forked_id).expect("forked flags"); + assert!(flags.excluded, "既有标记必须保留"); + assert!(!flags.truncated); + let directive = flags.projection.as_ref().expect("projection preserved"); + assert_eq!(directive.entries.len(), 1); + assert_eq!(directive.entries[0].message_id, forked_id); + // source 只读:变换不得修改输入。 + assert_eq!(payload_ids(&source_payloads), payloads_before); + assert_eq!(source_flags, flags_before); +} + +#[test] +fn fork_remap_without_source_flags_yields_empty_flags() { + let source_payloads = vec![PersistedPayload::Message(BaseMessage::human("a"))]; + + let forked = remap_fork_history(&source_payloads, &HashMap::new(), deterministic_ids()); + + assert!(forked.flags.is_empty()); + assert_eq!(forked.payloads.len(), 1); +} + +#[test] +fn projection_flag_rule_marks_truncated_and_keeps_excluded() { + let id = MessageId::new(); + let existing = MessageFlags { + truncated: false, + excluded: true, + projection: None, + }; + + let flags = flags_with_projection(&existing, directive_referencing(id)); + + assert!(flags.truncated, "投影与 truncated 是同一规则"); + assert!(flags.excluded, "投影不解除排除标记"); + assert!(flags.projection.is_some()); + assert!(!existing.truncated, "输入不被修改"); +} + +#[test] +fn flag_updates_remove_defaults_and_apply_in_order() { + let first = MessageId::new(); + let second = MessageId::new(); + let mut flags = HashMap::new(); + flags.insert( + first, + MessageFlags { + truncated: true, + excluded: false, + projection: None, + }, + ); + + apply_flag_updates( + &mut flags, + &[ + (first, MessageFlags::default()), + ( + second, + MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + ), + ], + ); + assert!(!flags.contains_key(&first), "默认 flags 表示无标记"); + + apply_flag_updates( + &mut flags, + &[ + ( + second, + MessageFlags { + truncated: true, + excluded: false, + projection: None, + }, + ), + (second, MessageFlags::default()), + ], + ); + assert!( + !flags.contains_key(&second), + "同一 id 在批次内以最后一次为准" + ); +} + +#[test] +fn distinct_ids_reject_batch_duplicates_and_known_ids() { + let known = PersistedPayload::Message(BaseMessage::human("known")); + let known_id = known.id(); + let fresh = PersistedPayload::Message(BaseMessage::human("fresh")); + + assert!(ensure_distinct_ids(std::slice::from_ref(&fresh), |id| id == known_id).is_ok()); + + let error = ensure_distinct_ids(&[fresh.clone(), fresh.clone()], |_| false).unwrap_err(); + assert_eq!( + error, + HistoryError::DuplicateId { id: fresh.id() }, + "批次内重复必须失败,不能静默忽略" + ); + + let error = ensure_distinct_ids(std::slice::from_ref(&known), |id| id == known_id).unwrap_err(); + assert_eq!(error, HistoryError::DuplicateId { id: known_id }); +} + +fn payload_ids(payloads: &[PersistedPayload]) -> Vec { + payloads.iter().map(PersistedPayload::id).collect() +} + +#[test] +fn rewind_keeps_or_removes_the_target_itself() { + let payloads = vec![ + PersistedPayload::Message(BaseMessage::human("a")), + PersistedPayload::Message(BaseMessage::ai("b")), + PersistedPayload::Message(BaseMessage::human("c")), + ]; + let target = payloads[1].id(); + + let keep = apply_rewind(&payloads, RewindBoundary::KeepThrough(target)).unwrap(); + assert_eq!(payload_ids(&keep.kept), payload_ids(&payloads[..2])); + assert_eq!(keep.removed, vec![payloads[2].id()]); + + let remove = apply_rewind(&payloads, RewindBoundary::RemoveFrom(target)).unwrap(); + assert_eq!(payload_ids(&remove.kept), payload_ids(&payloads[..1])); + assert_eq!(remove.removed, vec![target, payloads[2].id()]); +} + +#[test] +fn rewind_missing_target_fails_before_touching_history() { + let payloads = vec![PersistedPayload::Message(BaseMessage::human("a"))]; + let missing = MessageId::new(); + let before = payload_ids(&payloads); + + let error = apply_rewind(&payloads, RewindBoundary::RemoveFrom(missing)).unwrap_err(); + + assert_eq!(error, HistoryError::TargetMissing { id: missing }); + assert_eq!(payload_ids(&payloads), before, "失败不产生部分变换"); + assert_eq!( + rewind_keep_len( + &payload_ids(&payloads), + RewindBoundary::KeepThrough(missing) + ) + .unwrap_err(), + HistoryError::TargetMissing { id: missing } + ); +} + +#[test] +fn compaction_change_applies_flags_and_appends_messages() { + let mut payloads = vec![PersistedPayload::Message(BaseMessage::human("a"))]; + let existing_id = payloads[0].id(); + let mut flags = HashMap::new(); + let appended = BaseMessage::ai("summary"); + let change = CompactionChange { + flag_updates: vec![( + existing_id, + MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + )], + appended_messages: vec![appended.clone()], + }; + + apply_compaction_change(&mut payloads, &mut flags, &change).unwrap(); + + assert_eq!(payload_ids(&payloads), vec![existing_id, appended.id()]); + assert!(flags[&existing_id].excluded); +} + +#[test] +fn compaction_change_rejects_duplicate_id_without_partial_apply() { + let existing = PersistedPayload::Message(BaseMessage::human("a")); + let mut payloads = vec![existing.clone()]; + let mut flags = HashMap::new(); + let before = payload_ids(&payloads); + let change = CompactionChange { + flag_updates: vec![( + existing.id(), + MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + )], + appended_messages: vec![BaseMessage::ai("summary").with_message_id(existing.id())], + }; + + let error = apply_compaction_change(&mut payloads, &mut flags, &change).unwrap_err(); + + assert_eq!(error, HistoryError::DuplicateId { id: existing.id() }); + assert_eq!(payload_ids(&payloads), before, "整体生效或不生效"); + assert!(flags.is_empty(), "失败时 flags 也不得被改"); +} + +#[test] +fn appended_payloads_preserve_message_identity() { + let message = BaseMessage::ai("summary"); + + let payloads = appended_payloads(std::slice::from_ref(&message)); + + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].id(), message.id()); +} diff --git a/peri-acp-types/src/store.rs b/peri-acp-types/src/store/mod.rs similarity index 94% rename from peri-acp-types/src/store.rs rename to peri-acp-types/src/store/mod.rs index 501467e0a..e48dd614a 100644 --- a/peri-acp-types/src/store.rs +++ b/peri-acp-types/src/store/mod.rs @@ -2,6 +2,19 @@ //! //! 接口契约归 peri-acp-types:`SqliteThreadStore`(peri-resources)实现本 trait, //! Agent/ACP/TUI 经本 trait 引用存储,不直接实例化。 +//! +//! 本 trait 是**迁移桥**:目标契约是 [`crate::session_resources::SessionResources`] +//! (会话行为门面,数据与本机执行两面分离)。在消费侧迁移完成前保留本 trait 不动 +//! 摇现有调用方;迁移期间遵守两条约束: +//! +//! 1. 不为新场景扩展本 trait;新行为加到 `SessionResources`。 +//! 2. 不新增返回 no-op / 空值 / `None` 的默认实现冒充「不支持」——无法完成的行为 +//! 必须显式失败,否则调用方无法区分「不支持」与「确实不存在」。 +//! +//! 纯历史规则(fork 重映射、投影 flag、compaction 批次、rewind 边界)见 +//! [`history`],不得在调用方或 adapter 内各写一份。 + +pub mod history; use std::collections::HashMap; @@ -173,8 +186,15 @@ impl InheritedContext { } } +/// 一次 compaction 的领域变更:flags 更新 + 摘要追加。 +/// +/// 名字描述领域事实而不是提交机制:调用方给出「这次 compact 改变了什么」, +/// 由 adapter 保证整体生效或整体不生效(原名 `CompactionLifecycle` 隐含了 +/// 「调用方管理提交」的机制含义,已废弃)。 +/// +/// 纯应用规则见 [`history::apply_compaction_change`]。 #[derive(Clone, Debug)] -pub struct CompactionLifecycle { +pub struct CompactionChange { pub flag_updates: Vec<(MessageId, MessageFlags)>, pub appended_messages: Vec, } @@ -434,7 +454,7 @@ pub trait ThreadStore: Send + Sync { async fn commit_compaction_lifecycle( &self, thread_id: &ThreadId, - lifecycle: &CompactionLifecycle, + lifecycle: &CompactionChange, ) -> Result<()> { let _ = (thread_id, lifecycle); anyhow::bail!("unsupported compact lifecycle persistence") diff --git a/peri-acp-types/src/workspace.rs b/peri-acp-types/src/workspace.rs index b1a8701ac..aa05b8c31 100644 --- a/peri-acp-types/src/workspace.rs +++ b/peri-acp-types/src/workspace.rs @@ -54,6 +54,22 @@ pub struct SessionBinding { pub cwd_relative_to_workspace: PathBuf, } +impl SessionBinding { + /// 以 workspace 事实构造不可变绑定。 + /// + /// 版本与 revision 由契约固定,调用方只能提供已解析的 workspace——创建入口 + /// (门面 `create_session`)不接受调用方自行拼装的绑定字段。 + pub fn from_workspace(workspace: &ResolvedWorkspace) -> Self { + Self { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + } + } +} + /// A validated execution directory. The store revalidates this before binding a thread. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedWorkspace { @@ -120,6 +136,21 @@ pub enum WorkspaceErrorData { RecoveryRequired(RecoveryRequiredDetails), } +impl WorkspaceErrorData { + /// 按 workspace 失败构造类型化数据;本集合之外的失败不带数据(调用方只看消息)。 + /// + /// 这里是「哪些失败可以被客户端分派到具体修复动作」的唯一清单:加一条就在 + /// [`WorkspaceError`] 上多一个可编程分支,因此只收需要客户端采取不同动作的变体。 + pub fn from_workspace_error(error: &WorkspaceError) -> Option { + match error { + WorkspaceError::RecoveryRequired(details) => { + Some(Self::RecoveryRequired(details.clone())) + } + _ => None, + } + } +} + /// 只读准入的原因:会话历史可读,但本次准入没有取得执行所有权。 /// /// 客户端据此区分「等待他处释放」与「需要用户显式接受风险解除 dirty」:后者必须 @@ -159,7 +190,11 @@ pub struct ResetDirtyRequest { pub accept_risk: bool, } -#[derive(Debug, thiserror::Error)] +/// 本机 workspace/binding/owner 语义的失败分类。 +/// +/// `Clone`:错误在落到 `SessionResourceError` 之前会经过 `anyhow` 链,资源层需要按 +/// 原分类重建同一个错误值(不重新解释、不丢变体)。 +#[derive(Clone, Debug, thiserror::Error)] pub enum WorkspaceError { #[error("workspace discovery failed: {0}")] DiscoveryError(String), diff --git a/peri-acp/src/dispatch/execute_command.rs b/peri-acp/src/dispatch/execute_command.rs index 06afcaa45..1de470f6a 100644 --- a/peri-acp/src/dispatch/execute_command.rs +++ b/peri-acp/src/dispatch/execute_command.rs @@ -59,8 +59,8 @@ fn check_immediate_level(entry: &RouteEntry) -> Result<(), AcpError> { /// 随 `ui:` 域迁移统一处理,不引入 session_manager 依赖), and runs it /// synchronously (blocking the caller) and returns the updated message list. /// -/// 存储访问经 `controller.sessions()`(ARC-BOUNDARY-001 方向),不再由调用方 -/// 直传 `thread_store`。 +/// 存储访问经 `controller.sessions()`(ARC-BOUNDARY-001 方向),不再由 +/// 调用方直传存储句柄。 /// /// # Errors /// @@ -195,7 +195,7 @@ pub async fn execute_command( ctx.raw_text = command_str.to_string(); ctx.args = args_string; ctx.parsed_args = parsed_args; - ctx.thread_store = Some(controller.sessions()); + ctx.session_resources = Some(controller.sessions()); ctx.thread_id = thread_id; ctx.task_manager = task_manager; ctx.frozen_claude_md = frozen_claude_md; diff --git a/peri-acp/src/dispatch/execute_command_test.rs b/peri-acp/src/dispatch/execute_command_test.rs index 184d7c8f4..1be98ce4e 100644 --- a/peri-acp/src/dispatch/execute_command_test.rs +++ b/peri-acp/src/dispatch/execute_command_test.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use async_trait::async_trait; use peri_acp_types::{event::ExecutorEvent, messages::BaseMessage}; -use peri_agent::thread::FilesystemThreadStore; use peri_controller::Controller; use tokio_util::sync::CancellationToken as AgentCancellationToken; @@ -124,9 +123,11 @@ async fn test_execute_command_unknown_command_returns_acp_error() { events: Arc::new(std::sync::Mutex::new(Vec::new())), }); let tmp = tempfile::tempdir().unwrap(); - let store: Arc = - Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); - let controller = Controller::new(store); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); + let controller = Controller::new(session_resources); let err = execute_command( ¶ms, @@ -196,9 +197,11 @@ async fn test_execute_command_clear_returns_empty_messages_no_compact_event() { events: events.clone(), }); let tmp = tempfile::tempdir().unwrap(); - let store: Arc = - Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); - let controller = Controller::new(store); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); + let controller = Controller::new(session_resources); let result = execute_command( ¶ms, @@ -257,9 +260,11 @@ async fn test_execute_command_outer_cancel_preserves_history() { let peri_config = Arc::new(PeriConfig::default()); let event_sink: Arc = Arc::new(PendingEventSink); let tmp = tempfile::tempdir().unwrap(); - let store: Arc = - Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); - let controller = Controller::new(store); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); + let controller = Controller::new(session_resources); let result = execute_command( ¶ms, @@ -410,9 +415,11 @@ async fn run_execute_command( events: events.clone(), }); let tmp = tempfile::tempdir().unwrap(); - let store: Arc = - Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); - let controller = Controller::new(store); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); + let controller = Controller::new(session_resources); let result = execute_command( ¶ms, history, diff --git a/peri-acp/src/dispatch/list_sessions.rs b/peri-acp/src/dispatch/list_sessions.rs index 590cacced..316dbf38e 100644 --- a/peri-acp/src/dispatch/list_sessions.rs +++ b/peri-acp/src/dispatch/list_sessions.rs @@ -1,24 +1,33 @@ -//! List sessions via Controller 存储通道,返回 ACP [`SessionInfo`] entries。 +//! List sessions via 会话资源门面,返回 ACP [`SessionInfo`] entries。 use agent_client_protocol_schema::v1::{SessionId, SessionInfo}; use anyhow::{Context, Result}; +use peri_acp_types::workspace::{ScopedThreadQuery, ThreadScope}; use peri_controller::Controller; /// Query all sessions from persistent storage, convert to ACP /// [`SessionInfo`] entries, and optionally filter by `cwd`. /// -/// 存储访问经 [`Controller::sessions`](ARC-BOUNDARY-001 方向)。 +/// 存储访问经 [`Controller::sessions`](ARC-BOUNDARY-001 方向); +/// 列表是轻量 metadata 投影,不加载历史。 pub async fn list_sessions_as_info( controller: &Controller, cwd_filter: Option<&str>, ) -> Result> { - let threads = controller - .sessions() - .list_threads() + let resources = controller.sessions(); + let page = resources + .list_sessions(&ScopedThreadQuery { + scope: ThreadScope::All, + cursor: None, + // 全量列举:现有 `session/list` 语义无分页,游标由调用方决定。 + limit: u32::MAX, + }) .await .context("Failed to list sessions")?; - Ok(threads + Ok(page + .entries .into_iter() + .map(|entry| entry.thread) .filter(|t| { if let Some(cwd) = cwd_filter { t.cwd == cwd diff --git a/peri-acp/src/dispatch/mod.rs b/peri-acp/src/dispatch/mod.rs index d2fdb1181..1d1f633b1 100644 --- a/peri-acp/src/dispatch/mod.rs +++ b/peri-acp/src/dispatch/mod.rs @@ -22,8 +22,7 @@ pub use list_sessions::list_sessions_as_info; pub use prompt::{extract_prompt_params, handle_prompt}; pub use rewind::{rewind_execute, rewind_preview}; pub use rewind_candidates::rewind_candidates; -pub(crate) use session_fork::fork_bound_session; -pub use session_fork::fork_session; +pub(crate) use session_fork::{fork_bound_session, load_fork_source}; pub use session_load::load_session_payloads; pub use session_replay::{ replay_persisted_session_history, replay_session_history, ReplayError, ReplaySender, diff --git a/peri-acp/src/dispatch/rewind.rs b/peri-acp/src/dispatch/rewind.rs index 19696c983..deb002056 100644 --- a/peri-acp/src/dispatch/rewind.rs +++ b/peri-acp/src/dispatch/rewind.rs @@ -292,7 +292,7 @@ pub async fn rewind_execute( ctx.raw_text = String::new(); // Phase 5 Step 5:不再构造 CommandContext.args JSON(slash 形态解析已迁 // ArgsSchema)——RPC 前置校验已拿到结构化参数,直接调共享执行体。 - ctx.thread_store = Some(controller.sessions()); + ctx.session_resources = Some(controller.sessions()); ctx.thread_id = thread_id; ctx.task_manager = task_manager; ctx.frozen_claude_md = frozen_claude_md; diff --git a/peri-acp/src/dispatch/session_fork.rs b/peri-acp/src/dispatch/session_fork.rs index 1ec323b08..745b5f60e 100644 --- a/peri-acp/src/dispatch/session_fork.rs +++ b/peri-acp/src/dispatch/session_fork.rs @@ -1,192 +1,149 @@ -//! Fork a session: create a new thread and copy messages from source. +//! Fork 普通会话:一致 source 快照 → 领域纯 ID 映射 → 一次门面保存。 //! -//! 存储访问经 [`Controller::sessions`](ARC-BOUNDARY-001 方向)。 +//! 存储访问经会话资源门面(ARC-BOUNDARY-001 方向):ACP 不逐条写 flags、不拼 +//! create/append/flags 分步序列,也没有「复制失败再删除新 thread」的存储补偿—— +//! 目标快照由 [`SessionResources::save_fork`] 一次保存并返回目标 root owner。 +//! 未发布创建的撤销由调用方经 `abandon_initialization` 承担。 -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; -use anyhow::{Context, Result}; -use peri_acp_types::messages::MessageId; -use peri_acp_types::store::{MessageFlags, PersistedPayload, ThreadStore}; -use peri_acp_types::thread::{ThreadId, ThreadMeta}; -use peri_controller::Controller; +use anyhow::{bail, Context, Result}; +use peri_acp_types::messages::{BaseMessage, MessageId}; +use peri_acp_types::session_resources::{ + BindingState, ForkSnapshot, FrozenSnapshotBytes, FrozenState, NewSession, NewSessionMeta, + SessionResources, SessionSnapshot, +}; +use peri_acp_types::store::history::remap_fork_history; +use peri_acp_types::store::{MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{CancelPolicy, ThreadId}; +use peri_acp_types::workspace::{ResolvedWorkspace, SessionBinding, SessionExecutionLease}; -/// Clone one logical payload into an independently owned fork row. +/// fork source 的一致快照:payload/flags/binding/frozen 同一时刻读出。 /// -/// SQLite stores `message_id` as a database-wide primary key, so preserving the -/// source ID would make `INSERT OR IGNORE` silently leave the fork without rows. -fn clone_payload_for_fork(payload: &PersistedPayload) -> PersistedPayload { - let id = MessageId::new(); - match payload { - PersistedPayload::Message(message) => { - PersistedPayload::Message(message.clone().with_message_id(id)) - } - PersistedPayload::SystemReminder { reminder, .. } => PersistedPayload::SystemReminder { - id, - reminder: reminder.clone(), - }, - } -} - -fn clone_flags_for_fork( - mut flags: MessageFlags, - source_id: MessageId, - forked_id: MessageId, -) -> MessageFlags { - if let Some(projection) = flags.projection.as_mut() { - for entry in &mut projection.entries { - if entry.message_id == source_id { - entry.message_id = forked_id; - } - } - } - flags +/// source 原样保留——此处只读,不改动 source 的 flags 或 frozen。 +pub(crate) struct ForkSource { + pub(crate) thread_id: ThreadId, + /// source 已登记的绑定:普通 fork 沿用同一执行绑定。 + pub(crate) binding: SessionBinding, + /// source 已持久化的精确冻结字节:fork 不按当前目录/日期重冻。 + pub(crate) frozen: FrozenSnapshotBytes, + payloads: Vec, + flags: HashMap, } -async fn cleanup_failed_fork( - store: &dyn ThreadStore, +/// 读取 fork source:一次一致快照,并在领域侧校验可 fork 性。 +/// +/// source 必须是已绑定、frozen 可读且工具调用已闭合的会话;缺绑定、frozen 缺失或 +/// 本构建读不懂时明确失败,不用当前环境补一份。 +pub(crate) async fn load_fork_source( + resources: &Arc, source_thread_id: &str, - new_thread_id: &ThreadId, -) -> Result<()> { - if store.delete_thread(new_thread_id).await.is_err() { - tracing::error!( - event = "session_fork_persistence_inconsistency", - source_thread_id, - new_thread_id, - copy_failed = true, - compensation_failed = true, - classification = "persistence_inconsistency", - "session fork persistence inconsistency" - ); - anyhow::bail!( - "Session fork failed due to a persistence inconsistency; manual recovery may be required" - ); - } - Ok(()) +) -> Result { + let thread_id = source_thread_id.to_owned(); + let snapshot = resources.load_session_snapshot(&thread_id).await?; + let binding = bound_binding(&snapshot)?; + let frozen = source_frozen(&snapshot)?; + let payloads = ensure_complete_tool_calls(snapshot.payloads)?; + Ok(ForkSource { + thread_id, + binding, + frozen, + payloads, + flags: snapshot.flags, + }) } -/// Fork a session by creating a new thread and copying source messages. +/// 一次保存 fork 目标:纯 ID 映射在前,门面保存 meta/binding/frozen/payload/flags +/// 并返回目标 root owner。 /// -/// Returns `Ok((new_thread_id, copied_messages))` on success. -/// The caller is responsible for inserting the new session into its session map. -pub async fn fork_session( - controller: &Controller, - source_thread_id: &str, - source_payloads: &[PersistedPayload], - cwd: &str, -) -> Result<(String, Vec)> { - let meta = ThreadMeta::new(cwd); - let store = controller.sessions(); - let source_thread_id = ThreadId::from(source_thread_id.to_string()); - let source_flags = store - .load_message_flags(&source_thread_id) - .await - .context("Failed to load source compact flags")?; - let new_thread_id = store - .create_thread(meta) - .await - .context("Thread creation failed")?; - - let copied_payloads = source_payloads - .iter() - .map(clone_payload_for_fork) - .collect::>(); - let copied_flags = source_payloads - .iter() - .zip(&copied_payloads) - .filter_map(|(source, copied)| { - source_flags.get(&source.id()).cloned().map(|flags| { - ( - copied.id(), - clone_flags_for_fork(flags, source.id(), copied.id()), - ) - }) +/// 目标复用 source 的 binding 与冻结字节;`created_at` 与目标 identity 由构建层 +/// 生成一次(重试不重建)。 +pub(crate) async fn fork_bound_session( + resources: &Arc, + source: &ForkSource, + workspace: &ResolvedWorkspace, + created_at: String, +) -> Result<( + String, + Vec, + Arc, +)> { + let cwd = workspace + .cwd + .to_str() + .context("Execution directory is not UTF-8")? + .to_owned(); + // SQLite 的 message_id 是库级主键:复用 source ID 会让写入静默丢行,因此复制前 + // 先做纯 ID 重映射(adapter 不重复执行 fork 算法)。 + let forked = remap_fork_history(&source.payloads, &source.flags, MessageId::new); + let target_id = uuid::Uuid::now_v7().to_string(); + let owner = resources + .save_fork(&ForkSnapshot { + target: NewSession { + thread_id: target_id.clone(), + created_at, + meta: NewSessionMeta { + title: None, + cwd, + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: source.binding.clone(), + frozen: source.frozen.clone(), + }, + source_id: source.thread_id.clone(), + payloads: forked.payloads.clone(), + flags: forked.flags, }) - .collect::>(); + .await?; + tracing::info!( + source = %source.thread_id, + new = %target_id, + msg_count = forked.payloads.len(), + "Session forked" + ); + Ok((target_id, forked.payloads, owner)) +} - if !copied_payloads.is_empty() { - if let Err(copy_error) = store - .append_payloads(&new_thread_id, &copied_payloads) - .await - { - cleanup_failed_fork(store.as_ref(), source_thread_id.as_str(), &new_thread_id).await?; - return Err(copy_error).context("Failed to copy session payloads"); +/// 已绑定 source 才可 fork:legacy/外来/缺绑定都不是「可复制的执行身份」。 +fn bound_binding(snapshot: &SessionSnapshot) -> Result { + match &snapshot.binding { + BindingState::Bound(binding) => Ok(binding.clone()), + BindingState::LegacyConfirmed => bail!("Cannot fork legacy history without a binding"), + BindingState::ExternalOrUnregistered => { + bail!("Cannot fork a session bound to another workspace") } + BindingState::Missing => bail!("Cannot fork a session without a local binding"), } +} - for (message_id, flags) in copied_flags { - if let Err(copy_error) = store.update_message_flags(&message_id, &flags).await { - cleanup_failed_fork(store.as_ref(), source_thread_id.as_str(), &new_thread_id).await?; - return Err(copy_error).context("Failed to copy session compact flags"); +/// fork 复制的是 source 的精确冻结字节,来源必须已持久化且可读。 +fn source_frozen(snapshot: &SessionSnapshot) -> Result { + match &snapshot.frozen { + FrozenState::Present(bytes) => Ok(bytes.clone()), + FrozenState::LegacyAbsent => bail!("Source frozen snapshot is missing"), + FrozenState::Unsupported => { + bail!("Source frozen snapshot is not readable by this build") } } - - tracing::info!( - source = %source_thread_id, - new = %new_thread_id, - msg_count = copied_payloads.len(), - "Session forked" - ); - - Ok((new_thread_id, copied_payloads)) } -/// Fork a bound, idle source while the caller holds its lifecycle gate and owner. -pub(crate) async fn fork_bound_session( - controller: &Controller, - source_thread_id: &str, - workspace: &peri_acp_types::workspace::ResolvedWorkspace, -) -> Result<( - String, - Vec, - std::sync::Arc, -)> { - let store = controller.sessions(); - let source_id = source_thread_id.to_owned(); - let payloads = store.load_payloads(&source_id).await?; - // A source snapshot must finish every assistant tool invocation before it can - // be copied into an independently executable history. - let mut pending = std::collections::HashSet::new(); +/// 工具调用闭合校验:未闭合的调用不能复制进一条独立可执行的历史。 +fn ensure_complete_tool_calls(payloads: Vec) -> Result> { + let mut pending = HashSet::new(); for message in payloads.iter().filter_map(PersistedPayload::as_message) { for call in message.tool_calls() { pending.insert(call.id.clone()); } - if let peri_acp_types::messages::BaseMessage::Tool { tool_call_id, .. } = message { + if let BaseMessage::Tool { tool_call_id, .. } = message { pending.remove(tool_call_id); } } - anyhow::ensure!( - pending.is_empty(), - "Cannot fork history with incomplete tool calls" - ); - let flags = store.load_message_flags(&source_id).await?; - let cwd = workspace - .cwd - .to_str() - .context("Execution directory is not UTF-8")?; - let id = store - .create_bound_thread(ThreadMeta::new(cwd), workspace) - .await?; - let lease = store.acquire_execution_lease(&id).await?; - let copied: Vec<_> = payloads.iter().map(clone_payload_for_fork).collect(); - let result = async { - store.append_payloads(&id, &copied).await?; - for (source, copied) in payloads.iter().zip(&copied) { - if let Some(flags) = flags.get(&source.id()) { - store - .update_message_flags( - &copied.id(), - &clone_flags_for_fork(flags.clone(), source.id(), copied.id()), - ) - .await?; - } - } - Ok::<_, anyhow::Error>(()) - } - .await; - if let Err(error) = result { - cleanup_failed_fork(store.as_ref(), source_thread_id, &id).await?; - lease.mark_clean().await?; - return Err(error); + if !pending.is_empty() { + bail!("Cannot fork history with incomplete tool calls"); } - Ok((id, copied, lease)) + Ok(payloads) } diff --git a/peri-acp/src/dispatch/session_fork_test.rs b/peri-acp/src/dispatch/session_fork_test.rs index 72cd00ded..f87e8dbc5 100644 --- a/peri-acp/src/dispatch/session_fork_test.rs +++ b/peri-acp/src/dispatch/session_fork_test.rs @@ -1,140 +1,89 @@ -use std::io; -use std::sync::{Arc, Mutex}; - -use anyhow::{anyhow, Result}; -use async_trait::async_trait; -use peri_acp_types::messages::{BaseMessage, MessageId}; -use peri_acp_types::store::{CompactionLifecycle, MessageFlags, PersistedPayload, ThreadStore}; -use peri_acp_types::thread::{ThreadId, ThreadListEntry, ThreadMeta}; -use peri_controller::Controller; -use peri_resources::sessions::{FilesystemThreadStore, SqliteThreadStore}; -use tracing_subscriber::fmt::MakeWriter; - -use super::fork_session; - -const COPY_CANARY: &str = "COPY_CANARY /private/secret-copy.db postgres://user:pass@host/copy"; -const CLEANUP_CANARY: &str = - "CLEANUP_CANARY /private/secret-cleanup.db postgres://user:pass@host/cleanup"; - -#[derive(Clone, Default)] -struct CapturedLogs(Arc>>); - -struct CapturedWriter(Arc>>); - -impl io::Write for CapturedWriter { - fn write(&mut self, bytes: &[u8]) -> io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl<'a> MakeWriter<'a> for CapturedLogs { - type Writer = CapturedWriter; - - fn make_writer(&'a self) -> Self::Writer { - CapturedWriter(self.0.clone()) - } -} - -impl CapturedLogs { - fn contents(&self) -> String { - String::from_utf8(self.0.lock().unwrap().clone()).unwrap() - } +//! fork 行为回归:一致 source 快照 → 纯 ID 映射 → 一次门面保存。 +//! +//! 存储补偿(复制失败删新 thread)在新路径上不存在:目标快照由门面一次保存, +//! 失败不留下已发布的目标身份。这里用真实 SQLite 门面验证 ID/flags 独立性与 +//! 领域拒绝条件,不用 mock 存储自洽推导。 + +use std::sync::Arc; + +use peri_acp_types::messages::{BaseMessage, ToolCallRequest}; +use peri_acp_types::projection::{ + MessageProjectionDirective, ProjectionAction, ProjectionActionEntry, ProjectionTarget, +}; +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResourceError, + SessionResourceErrorKind, SessionResources, +}; +use peri_acp_types::store::{CompactionChange, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::CancelPolicy; +use peri_acp_types::workspace::{ScopedThreadQuery, SessionBinding, ThreadScope}; + +use super::{fork_bound_session, load_fork_source}; + +const SOURCE: &str = "source"; + +async fn open_facade(tmp: &tempfile::TempDir) -> Arc { + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap() } -struct FailingForkStore { - inner: FilesystemThreadStore, - fail_delete: bool, +/// 建立一条已绑定、已持久化 frozen 的 source 会话(与生产 new 同一条路径)。 +/// +/// 返回的 owner 必须由调用方持有:会话上的写入按「本 root 有活 owner」准入, +/// 与生产把 owner 存进 `SessionState` 是同一条规则。 +async fn create_source( + resources: &Arc, + id: &str, + cwd: &str, +) -> Arc { + let workspace = resources + .resolve_workspace(std::path::Path::new(cwd)) + .await + .unwrap(); + resources + .create_session(&NewSession { + thread_id: id.to_owned(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: None, + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding::from_workspace(&workspace), + frozen: FrozenSnapshotBytes::new(format!(r#"{{"v":1,"id":"{id}"}}"#)), + }) + .await + .unwrap() } -#[async_trait] -impl ThreadStore for FailingForkStore { - async fn create_thread(&self, meta: ThreadMeta) -> Result { - self.inner.create_thread(meta).await - } - - async fn append_messages(&self, _id: &ThreadId, _msgs: &[BaseMessage]) -> Result<()> { - Err(anyhow!(COPY_CANARY)) - } - - async fn append_payloads(&self, _id: &ThreadId, _payloads: &[PersistedPayload]) -> Result<()> { - Err(anyhow!(COPY_CANARY)) - } - - async fn load_messages(&self, id: &ThreadId) -> Result> { - self.inner.load_messages(id).await - } - - async fn load_meta(&self, id: &ThreadId) -> Result { - self.inner.load_meta(id).await - } - - async fn update_meta(&self, id: &ThreadId, meta: ThreadMeta) -> Result<()> { - self.inner.update_meta(id, meta).await - } - - async fn list_threads(&self) -> Result> { - self.inner.list_threads().await - } - - async fn list_thread_entries(&self, cwd: &str) -> Result> { - self.inner.list_thread_entries(cwd).await - } - - async fn delete_thread(&self, id: &ThreadId) -> Result<()> { - if self.fail_delete { - Err(anyhow!(CLEANUP_CANARY)) - } else { - self.inner.delete_thread(id).await - } - } - - async fn load_context(&self, id: &ThreadId) -> Result> { - self.inner.load_context(id).await - } - - async fn list_child_threads(&self, id: &ThreadId) -> Result> { - self.inner.list_child_threads(id).await - } - - async fn list_session_threads(&self, id: &ThreadId) -> Result> { - self.inner.list_session_threads(id).await - } - - async fn update_thread_status(&self, id: &ThreadId, status: &str) -> Result<()> { - self.inner.update_thread_status(id, status).await - } - - async fn invalidate_context_cache(&self, id: &ThreadId) -> Result<()> { - self.inner.invalidate_context_cache(id).await - } - - async fn delete_messages(&self, id: &ThreadId, message_ids: &[MessageId]) -> Result<()> { - self.inner.delete_messages(id, message_ids).await - } - - async fn update_message_flags(&self, id: &MessageId, flags: &MessageFlags) -> Result<()> { - self.inner.update_message_flags(id, flags).await - } +async fn session_ids(resources: &Arc) -> Vec { + resources + .list_sessions(&ScopedThreadQuery { + scope: ThreadScope::All, + cursor: None, + limit: 100, + }) + .await + .unwrap() + .entries + .into_iter() + .map(|entry| entry.thread.id) + .collect() } #[tokio::test] async fn forked_payloads_have_independent_ids_flags_and_compaction_lifecycle() { - use peri_acp_types::projection::{ - MessageProjectionDirective, ProjectionAction, ProjectionActionEntry, ProjectionTarget, - }; - let temp = tempfile::tempdir().unwrap(); - let store = Arc::new( - SqliteThreadStore::new(temp.path().join("fork-ownership.db")) - .await - .unwrap(), - ); - let source_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); + let resources = open_facade(&temp).await; + let cwd = temp.path().to_string_lossy().into_owned(); + let _source_owner = create_source(&resources, SOURCE, &cwd).await; + let workspace = resources.resolve_workspace(temp.path()).await.unwrap(); + let source_id = SOURCE.to_owned(); + let source_messages = [ BaseMessage::human("source question"), BaseMessage::tool_result("read-1", "source tool output"), @@ -144,39 +93,46 @@ async fn forked_payloads_have_independent_ids_flags_and_compaction_lifecycle() { .cloned() .map(PersistedPayload::Message) .collect::>(); - store - .append_payloads(&source_id, &source_payloads) + resources + .append_history(&source_id, &source_payloads) .await .unwrap(); let source_tool_id = source_messages[1].id(); - store - .update_message_flags( - &source_tool_id, - &MessageFlags { - truncated: true, - excluded: false, - projection: Some(MessageProjectionDirective { - policy_version: peri_agent::agent::compact_v2::PROJECTION_POLICY_VERSION, - entries: vec![ProjectionActionEntry { - message_id: source_tool_id, - target: ProjectionTarget::Message, - action: ProjectionAction::CompactToolResult { - keep_head: 8, - keep_tail: 8, - preserve_recovery_handle: true, - }, - }], - }), - }, + resources + .apply_message_projections( + &source_id, + &[( + source_tool_id, + MessageFlags { + truncated: true, + excluded: false, + projection: Some(MessageProjectionDirective { + policy_version: peri_agent::agent::compact_v2::PROJECTION_POLICY_VERSION, + entries: vec![ProjectionActionEntry { + message_id: source_tool_id, + target: ProjectionTarget::Message, + action: ProjectionAction::CompactToolResult { + keep_head: 8, + keep_tail: 8, + preserve_recovery_handle: true, + }, + }], + }), + }, + )], ) .await .unwrap(); - let controller = Controller::new(store.clone()); - let (fork_id, copied_payloads) = - fork_session(&controller, &source_id, &source_payloads, "/tmp") - .await - .unwrap(); + let source = load_fork_source(&resources, SOURCE).await.unwrap(); + let (fork_id, copied_payloads, _owner) = fork_bound_session( + &resources, + &source, + &workspace, + "2026-09-26T00:00:00Z".to_owned(), + ) + .await + .unwrap(); assert_eq!(copied_payloads.len(), source_payloads.len()); assert!(source_payloads @@ -194,9 +150,12 @@ async fn forked_payloads_have_independent_ids_flags_and_compaction_lifecycle() { .map(BaseMessage::content) .collect::>() ); - let stored_fork = store.load_payloads(&fork_id).await.unwrap(); + + // 一次一致快照读回的目标历史与复制结果逐条一致(顺序与 ID 都不漂移)。 + let stored = resources.load_session_snapshot(&fork_id).await.unwrap(); assert_eq!( - stored_fork + stored + .payloads .iter() .map(PersistedPayload::id) .collect::>(), @@ -206,8 +165,7 @@ async fn forked_payloads_have_independent_ids_flags_and_compaction_lifecycle() { .collect::>() ); let copied_tool_id = copied_payloads[1].id(); - let copied_flags = store.load_message_flags(&fork_id).await.unwrap(); - let copied_tool_flags = &copied_flags[&copied_tool_id]; + let copied_tool_flags = &stored.flags[&copied_tool_id]; assert!(copied_tool_flags.truncated); assert_eq!( copied_tool_flags.projection.as_ref().unwrap().entries[0].message_id, @@ -215,10 +173,10 @@ async fn forked_payloads_have_independent_ids_flags_and_compaction_lifecycle() { "fork 后 projection directive 必须引用新的消息 ID" ); - store - .commit_compaction_lifecycle( + resources + .apply_compaction( &fork_id, - &CompactionLifecycle { + &CompactionChange { flag_updates: copied_payloads .iter() .map(|payload| { @@ -236,79 +194,52 @@ async fn forked_payloads_have_independent_ids_flags_and_compaction_lifecycle() { ) .await .expect("fork history 必须由新 thread 独立拥有并可提交 Full lifecycle"); - let source_flags = store.load_message_flags(&source_id).await.unwrap(); - assert!(source_flags[&source_tool_id].truncated); - assert!(!source_flags[&source_tool_id].excluded); + let source_snapshot = resources.load_session_snapshot(&source_id).await.unwrap(); + assert!(source_snapshot.flags[&source_tool_id].truncated); + assert!(!source_snapshot.flags[&source_tool_id].excluded); } #[tokio::test] -async fn payload_copy_failure_compensates_new_thread() { +async fn fork_rejects_source_with_incomplete_tool_calls() { let temp = tempfile::tempdir().unwrap(); - let store = Arc::new(FailingForkStore { - inner: FilesystemThreadStore::new(temp.path()), - fail_delete: false, - }); - let controller = Controller::new(store.clone()); - - let error = fork_session( - &controller, - "source", - &[PersistedPayload::Message(BaseMessage::human("history"))], - "/tmp", - ) - .await - .unwrap_err(); - - assert!(error - .to_string() - .contains("Failed to copy session payloads")); - assert!(store.list_threads().await.unwrap().is_empty()); -} - -#[tokio::test] -async fn compensation_failure_returns_and_logs_redacted_inconsistency() { - let logs = CapturedLogs::default(); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_writer(logs.clone()) - .finish(); - let _subscriber = tracing::subscriber::set_default(subscriber); - let temp = tempfile::tempdir().unwrap(); - let store = Arc::new(FailingForkStore { - inner: FilesystemThreadStore::new(temp.path()), - fail_delete: true, - }); - let controller = Controller::new(store.clone()); + let resources = open_facade(&temp).await; + let cwd = temp.path().to_string_lossy().into_owned(); + let _source_owner = create_source(&resources, SOURCE, &cwd).await; + let source_id = SOURCE.to_owned(); + + // 未闭合的工具调用:没有配对的 ToolResult,不能复制进独立可执行的历史。 + let open_call = BaseMessage::ai_with_tool_calls( + "calling read", + vec![ToolCallRequest::new( + "call-1", + "read", + serde_json::json!({}), + )], + ); + resources + .append_history(&source_id, &[PersistedPayload::Message(open_call)]) + .await + .unwrap(); - let error = fork_session( - &controller, - "source", - &[PersistedPayload::Message(BaseMessage::human("history"))], - "/tmp", - ) - .await - .unwrap_err(); - let message = error.to_string(); - let captured = logs.contents(); + let error = match load_fork_source(&resources, SOURCE).await { + Ok(_) => panic!("未闭合工具调用必须被拒绝"), + Err(error) => error, + }; + assert!( + error.to_string().contains("incomplete tool calls"), + "未闭合工具调用必须被领域侧拒绝,实际:{error}" + ); + // 拒绝发生在任何写入之前:没有因此产生新的目标身份。 + assert_eq!(session_ids(&resources).await, vec![source_id]); - for sensitive in [ - COPY_CANARY, - CLEANUP_CANARY, - "/private/secret-copy.db", - "/private/secret-cleanup.db", - "postgres://user:pass@host/copy", - "postgres://user:pass@host/cleanup", - ] { - assert!(!message.contains(sensitive)); - assert!(!captured.contains(sensitive)); - } - assert!(message.contains("persistence inconsistency")); - assert!(captured.contains("session_fork_persistence_inconsistency")); - assert!(captured.contains("classification=\"persistence_inconsistency\"")); - assert!(captured.contains("copy_failed=true")); - assert!(captured.contains("compensation_failed=true")); - assert!(captured.contains("source_thread_id=\"source\"")); - assert!(captured.contains("new_thread_id=")); - assert_eq!(store.list_threads().await.unwrap().len(), 1); + // 缺失 source 同样在读取阶段失败(领域错误分类保留),不产生目标行。 + let missing = match load_fork_source(&resources, "no-such-source").await { + Ok(_) => panic!("缺失 source 必须失败"), + Err(error) => error, + }; + let missing = missing + .downcast::() + .expect("门面错误分类必须保留到调用方"); + assert!(matches!(missing.kind(), SessionResourceErrorKind::NotFound)); + assert_eq!(session_ids(&resources).await, vec![SOURCE.to_owned()]); } diff --git a/peri-acp/src/dispatch/session_load.rs b/peri-acp/src/dispatch/session_load.rs index 9610ffc80..8ddc8dbeb 100644 --- a/peri-acp/src/dispatch/session_load.rs +++ b/peri-acp/src/dispatch/session_load.rs @@ -1,7 +1,7 @@ -//! Load session context from ThreadStore (includes ancestor chain snapshots). +//! 会话历史回放读取(含继承区)。 //! -//! 存储访问经 [`Controller::sessions`](ARC-BOUNDARY-001 方向:ACP 不直操 -//! `ThreadStore`,统一经 Controller 通道)。 +//! 存储访问经 [`Controller::sessions`](ARC-BOUNDARY-001 方向:ACP 不直操存储, +//! 统一经 Controller 通道),拿到的是完整逻辑上下文:继承区在前、自有 payload 在后。 use crate::transport::types::AcpError; use peri_acp_types::store::PersistedPayload; @@ -10,16 +10,18 @@ use peri_controller::Controller; /// Load complete context for a session thread including ancestor snapshots. /// -/// Uses `ThreadStore::load_context` (via [`Controller::sessions`]) which assembles -/// the full message chain (ancestor snapshots + own messages) with materialized -/// caching. Returns an empty `Vec` if the thread does not exist (with a warning log). +/// Uses [`SessionResources::load_session_history`] (via [`Controller::sessions`]) which +/// assembles the full message chain (inherited region + own messages) without materializing +/// a derived cache. Missing sessions surface the storage failure as an internal error. +/// +/// [`SessionResources::load_session_history`]: peri_acp_types::session_resources::SessionResources::load_session_history pub async fn load_session_payloads( controller: &Controller, thread_id: &str, ) -> Result, AcpError> { controller .sessions() - .load_context_payloads(&ThreadId::from(thread_id.to_string())) + .load_session_history(&ThreadId::from(thread_id.to_string())) .await .map_err(|error| AcpError::new(-32603, format!("session history load failed: {error}"))) } diff --git a/peri-acp/src/host/assemble.rs b/peri-acp/src/host/assemble.rs index 152674596..14e501c0b 100644 --- a/peri-acp/src/host/assemble.rs +++ b/peri-acp/src/host/assemble.rs @@ -17,8 +17,8 @@ use peri_acp_types::mcp::McpSubscriptionPort; use peri_acp_types::permission::SharedPermissionMode; use peri_acp_types::plugin::{PluginLoadResult, PluginManagerPort}; use peri_acp_types::ports::{McpPoolPort, SkillsPort, ToolSearchPort}; +use peri_acp_types::session_resources::{SessionResources, SessionStoreShutdownPort}; use peri_acp_types::skills::SkillRoot; -use peri_acp_types::store::ThreadStore; use crate::provider::{LlmProvider, PeriConfig}; use crate::session::SessionManager; @@ -33,7 +33,7 @@ use super::AcpServerConfig; /// SkillsProvider / PluginManager / SettingsHooksLoader / /// WorkflowAgentMiddlewareFactory / 插件聚合数据)全部由本装配面内部构造 /// ——「ACP Host = 部署单元」,TUI/print/stdio 只提供协议面输入 -/// (provider / config / permission / thread_store / cwd),不再直接触碰 +/// (provider / config / permission / session_resources / cwd),不再直接触碰 /// 业务 crate(§0 依赖方向,`docs/top-level.md` §7/§8)。 #[derive(Clone)] pub(crate) struct WorkspaceAssembly { @@ -42,53 +42,18 @@ pub(crate) struct WorkspaceAssembly { pub(crate) mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile, } -/// Discover frozen inputs for the saved workspace without starting MCP, hooks or tasks. -/// Plugin discovery follows normal session assembly (including its manifest cache repair). -pub(crate) fn build_legacy_frozen_data( - host: &AcpServerConfig, +/// 准备阶段的严格只读插件发现(无合成清单、无插件缓存写)。 +/// +/// 会话准备面(`host/prepared.rs`)经本函数调用:具体实现与插件装配同属宿主 +/// 装配面,准备面不新建越层引用(§0 依赖门边 2)。 +pub(crate) fn discover_enabled_plugins_readonly( + claude_dir: &std::path::Path, cwd: &str, -) -> anyhow::Result { - let Some(source) = host.workspace_assembly.as_ref() else { - return Ok(host.session_manager.build_frozen_data( - cwd, - &host.plugin_skill_roots, - &host.plugin_agent_dirs, - )); - }; - let config = if std::fs::canonicalize(&source.startup_cwd).ok().as_deref() - == Some(std::path::Path::new(cwd)) - { - host.peri_config.read().clone() - } else { - crate::provider::ConfigSource::load_at( - std::path::Path::new(cwd), - host.config_source.global_path().to_owned(), - )? - .loaded_merged() - }; - let plugins = if source.bare { - None - } else { - let claude_dir = dirs_next::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".claude"); - Some(peri_middlewares::plugin::load_enabled_plugins_aggregated( - &claude_dir, - Some(std::path::Path::new(cwd)), - )) - }; - Ok(host.session_manager.build_frozen_data_with_config( - &config, - cwd, - plugins - .as_ref() - .map(|plugins| plugins.all_skill_roots.as_slice()) - .unwrap_or_default(), - plugins - .as_ref() - .map(|plugins| plugins.all_agent_dirs.as_slice()) - .unwrap_or_default(), - )) +) -> Result { + peri_middlewares::plugin::load_enabled_plugins_aggregated_readonly( + claude_dir, + Some(std::path::Path::new(cwd)), + ) } /// Bind session execution identity before static discovery or dynamic load can use the pool. @@ -113,6 +78,16 @@ fn pending_mcp_pool( pool } +/// 准备路径一次加载的插件数据:聚合本身 + 由该聚合派生的 roots。 +/// +/// 装配面消费同一对象,不重读插件目录;`data` 为 `None` 表示 host 级/bare。 +#[derive(Clone)] +pub struct PreparedPlugins { + pub data: Option, + pub skill_roots: Vec, + pub agent_dirs: Vec, +} + pub struct HostAssemblyInput { pub provider: LlmProvider, pub peri_config: Arc>, @@ -120,7 +95,13 @@ pub struct HostAssemblyInput { /// 经 [`crate::provider::ConfigSource::load`] 构建一次)。 pub config_source: Arc, pub permission_mode: Arc, - pub thread_store: Arc, + /// 会话资源门面(消费侧唯一会话行为句柄):Agent transcript/subagent、middleware、 + /// 协议面与 Controller 都经它访问会话,装配面不再另开裸存储句柄。 + pub session_resources: Arc, + /// 部署关闭权(non-Clone):由部署入口(TUI/print/stdio)从资源工厂取得后注入, + /// 宿主在**自己的任务排空之后**消费它关闭会话存储。会话级装配与测试注入 `None` + /// ——它们不是部署 owner,没有全局销毁权。 + pub session_store_shutdown: Option>, /// 工作目录(用于加载 project/local settings hooks) pub cwd: String, /// 跳过 settings hooks / LSP / 插件(print --bare 语义) @@ -128,6 +109,9 @@ pub struct HostAssemblyInput { /// 驱动 cron tick(TUI=true,复刻迁移前 TUI 每秒 tick 行为;print/stdio /// 保持现状无 tick——行为零变化,L2 遗留登记 M-TUI issue)。 pub drive_cron_tick: bool, + /// 准备路径一次加载的插件聚合:`Some` 时装配面不再重读插件目录 + /// (`None` = 既有语义,由装配面自行加载;仅 host 级/非准备调用点如此)。 + pub prepared_plugins: Option, } /// Construct terminal hook execution; the session environment owns admission and joining. @@ -194,7 +178,7 @@ pub fn assemble_hook_groups( /// peri_config 冻结快照 + cron scheduler(可选)注入。 #[allow(clippy::too_many_arguments)] // 装配注入面:端口/工厂逐项注入,L5 装配迁出后可分组 pub fn build_session_manager( - thread_store: Arc, + session_resources: Arc, provider: LlmProvider, peri_config: &Arc>, permission_mode: Arc, @@ -207,7 +191,7 @@ pub fn build_session_manager( ) -> SessionManager { let peri_config_snapshot = Arc::new(peri_config.read().clone()); SessionManager::new( - thread_store, + session_resources, provider, peri_config_snapshot, permission_mode, @@ -266,7 +250,9 @@ pub async fn assemble_server_config_with_mcp_apps( pub(crate) async fn assemble_server_config_with_mcp_profile( input: HostAssemblyInput, mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile, - session_resources: bool, + // `true` = 会话级装配(带 workspace 装配与 per-session MCP 池);`false` = host 级 + // 装配。与会话资源门面字段 `session_resources` 不同义,故另名 `session_scoped`。 + session_scoped: bool, activation: Option, ) -> AcpServerConfig { let (host_task_owner, host_task_spawner) = HostTaskOwner::new(); @@ -276,19 +262,33 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( peri_config, config_source, permission_mode, - thread_store, + session_resources, + session_store_shutdown, cwd, bare, drive_cron_tick, + prepared_plugins, } = input; let claude_dir = dirs_next::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".claude"); - // ── 插件聚合数据(bare 时跳过;迁移前 TUI launch / cli_print 各自构造)── - let plugin_data: Option = if bare || !session_resources { + // ── 插件聚合数据(bare 时跳过;准备路径消费同一聚合,不重读插件目录)── + let (prepared_data, prepared_skill_roots, prepared_agent_dirs) = match prepared_plugins { + Some(prepared) => ( + Some(prepared.data), + Some(prepared.skill_roots), + Some(prepared.agent_dirs), + ), + None => (None, None, None), + }; + let prepared_supplied = prepared_skill_roots.is_some(); + let plugin_data: Option = if bare || !session_scoped { None + } else if prepared_supplied { + // 准备路径已严格只读加载一次:装配面消费同一聚合,不重读插件目录。 + prepared_data.flatten() } else { Some(peri_middlewares::plugin::load_enabled_plugins_aggregated( &claude_dir, @@ -330,7 +330,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( let (oauth_event_tx, oauth_event_rx) = tokio::sync::mpsc::unbounded_channel::(); let mcp_pool_concrete: Option> = if bare - || !session_resources + || !session_scoped { None } else { @@ -473,7 +473,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( pending_mcp_pool( mcp_task_spawner.clone(), mcp_profile.clone(), - session_resources.then_some(std::path::Path::new(&cwd)), + session_scoped.then_some(std::path::Path::new(&cwd)), ) }), ), @@ -489,7 +489,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( .clone() .map(|p| p as Arc); // MCP over ACP 服务:会话 setup 声明的 `type: "acp"` server 经它建连,连接 - // 进的就是**本装配的池**——会话级装配(`session_resources`)才有池,连接 + // 进的就是**本装配的池**——会话级装配(`session_scoped`)才有池,连接 // 因此只能进声明它的会话的工具面。host 级装配无池即无此服务。 let acp_mcp: Option> = mcp_pool_concrete.clone().map(|pool| { @@ -519,7 +519,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( peri_middlewares::assembly::default_workflow_middleware_factory(); // E2:启动时清理孤儿插件文件(迁移前 TUI launch 行为;bare 时跳过) - if !bare && !session_resources { + if !bare && !session_scoped { let claude_dir_clone = claude_dir.clone(); let _ = host_task_spawner.spawn( HostTaskOwnerKind::Startup, @@ -536,20 +536,24 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( ); } - let plugin_skill_roots = plugin_data - .as_ref() - .map(|pd| pd.all_skill_roots.clone()) - .unwrap_or_default(); + let plugin_skill_roots = prepared_skill_roots.unwrap_or_else(|| { + plugin_data + .as_ref() + .map(|pd| pd.all_skill_roots.clone()) + .unwrap_or_default() + }); // Phase 6 B2:插件命令静态条目预转(全路径引用豁免见 // scripts/import-exemptions.conf 边 2 assemble 路径;bare 时为空)。 let plugin_command_entries = plugin_data .as_ref() .map(|pd| peri_middlewares::plugin::plugin_route_entries(&pd.all_commands)) .unwrap_or_default(); - let plugin_agent_dirs = plugin_data - .as_ref() - .map(|pd| pd.all_agent_dirs.clone()) - .unwrap_or_default(); + let plugin_agent_dirs = prepared_agent_dirs.unwrap_or_else(|| { + plugin_data + .as_ref() + .map(|pd| pd.all_agent_dirs.clone()) + .unwrap_or_default() + }); // H5:全局 settings.json(config.lspServers)与插件 LSP 服务器合并 //(优先级对齐 MCP:global < plugin;无插件时全局配置单独生效)。 // 读取路径跟随宿主全局配置加载机制(config_path,支持测试重定向)。 @@ -573,7 +577,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( &plugin_hooks, settings_hooks.as_ref(), &cwd, - bare || !session_resources, + bare || !session_scoped, ); let flat_hooks: Vec = hook_groups.iter().flatten().cloned().collect(); tracing::info!( @@ -585,7 +589,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( let shared_tools = Arc::new(parking_lot::RwLock::new(std::collections::BTreeMap::new())); let session_manager = build_session_manager( - thread_store.clone(), + session_resources.clone(), provider.clone(), &peri_config, permission_mode.clone(), @@ -600,7 +604,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( ); // Langfuse 观测(与迁移前 TUI/stdio/print 一致:环境启用时创建) - let (langfuse_session, langfuse_shutdown_owner) = if let Some(config) = (!session_resources) + let (langfuse_session, langfuse_shutdown_owner) = if let Some(config) = (!session_scoped) .then(peri_controller::langfuse::LangfuseConfig::from_env) .flatten() { @@ -614,7 +618,7 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( }; AcpServerConfig { - workspace_assembly: (!session_resources).then(|| WorkspaceAssembly { + workspace_assembly: (!session_scoped).then(|| WorkspaceAssembly { startup_cwd: cwd.clone(), bare, mcp_profile, @@ -649,8 +653,9 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( settings_hooks, shared_tools, workflow_middleware_factory, - thread_store: thread_store.clone(), - controller: Arc::new(peri_controller::Controller::new(thread_store.clone())), + session_resources: session_resources.clone(), + session_store_shutdown, + controller: Arc::new(peri_controller::Controller::new(session_resources.clone())), langfuse_session, langfuse_shutdown_owner, // 默认 false(TUI/print 保留全部命令);stdio 装配点(assemble_stdio_config) diff --git a/peri-acp/src/host/compact_recovery_test.rs b/peri-acp/src/host/compact_recovery_test.rs index a3f526615..39b2602dc 100644 --- a/peri-acp/src/host/compact_recovery_test.rs +++ b/peri-acp/src/host/compact_recovery_test.rs @@ -4,10 +4,16 @@ use super::*; use crate::host::{prompt::finish_prompt_turn, SessionState, SharedSessions}; use peri_acp_types::{ messages::MessageId, - store::{CompactionLifecycle, MessageFlags, PersistedPayload}, + session_resources::{ + ChildResumeClaim, ChildSnapshot, ForkSnapshot, FrozenSnapshotBytes, NewSession, + PersistenceRecovery, RewindBoundary, SessionAvailability, SessionMetaPatch, + SessionResourceError, SessionResourceErrorKind, SessionResourceResult, SessionResources, + SessionSnapshot, + }, + store::{CompactionChange, MessageFlags, PersistedPayload, ThreadStore}, thread::{ThreadId, ThreadMeta}, + workspace::{ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, SessionExecutionLease}, }; -use peri_agent::thread::SqliteThreadStore; use std::{collections::HashMap, sync::atomic::AtomicBool}; const SUMMARY: &str = "COMMITTED_COMPACT_RECOVERY_SUMMARY"; @@ -47,9 +53,13 @@ enum AfterCommitAction { Error, } -// 故障注入均包在真实 SQLite 调用外围,不能用内存替身伪造提交。 +/// 真门面 + 提交点注入:故障包在真实 SQLite 门面调用外围,不能用内存替身伪造提交。 +/// +/// 迁移后 compact/append/删除都走 [`SessionResources`];本包装只改写「Full 提交点被 +/// 取消 / 提交后确认丢失 / 提交后的追加失败」三种时序,其余行为逐项转发真实门面 +/// (同一库句柄,见 [`make_recovery_context`])。 struct RecoveryStore { - inner: SqliteThreadStore, + inner: Arc, compact_commits: AtomicUsize, fail_appends: AtomicBool, fail_after_full: bool, @@ -58,93 +68,165 @@ struct RecoveryStore { } #[async_trait] -impl ThreadStore for RecoveryStore { - async fn create_thread(&self, meta: ThreadMeta) -> anyhow::Result { - self.inner.create_thread(meta).await +impl SessionResources for RecoveryStore { + async fn inspect_availability( + &self, + session: Option<&ThreadId>, + ) -> SessionResourceResult { + self.inner.inspect_availability(session).await } - async fn append_messages(&self, id: &ThreadId, msgs: &[BaseMessage]) -> anyhow::Result<()> { - self.append_payloads( - id, - &msgs - .iter() - .cloned() - .map(PersistedPayload::Message) - .collect::>(), - ) - .await + + async fn resolve_workspace( + &self, + cwd: &std::path::Path, + ) -> SessionResourceResult { + self.inner.resolve_workspace(cwd).await } - async fn append_payloads( + + async fn validate_session( &self, id: &ThreadId, - payloads: &[PersistedPayload], - ) -> anyhow::Result<()> { - if self.fail_appends.load(Ordering::SeqCst) - || (self.fail_after_full && self.compact_commits.load(Ordering::SeqCst) > 0) - { - anyhow::bail!("injected post-compact writer failure"); - } - self.inner.append_payloads(id, payloads).await + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult<()> { + self.inner.validate_session(id, workspace).await } - async fn load_messages(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.load_messages(id).await + + async fn acquire_execution( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult> { + self.inner.acquire_execution(id, workspace).await } - async fn load_payloads(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.load_payloads(id).await + + async fn reset_dirty_execution( + &self, + request: &peri_acp_types::workspace::ResetDirtyRequest, + ) -> SessionResourceResult<()> { + self.inner.reset_dirty_execution(request).await } - async fn load_meta(&self, id: &ThreadId) -> anyhow::Result { - self.inner.load_meta(id).await + + async fn create_session( + &self, + input: &NewSession, + ) -> SessionResourceResult> { + self.inner.create_session(input).await } - async fn update_meta(&self, id: &ThreadId, meta: ThreadMeta) -> anyhow::Result<()> { - self.inner.update_meta(id, meta).await + + async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + ) -> SessionResourceResult<()> { + self.inner.abandon_initialization(id, lease).await } - async fn list_threads(&self) -> anyhow::Result> { - self.inner.list_threads().await + + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + self.inner + .adopt_legacy_session(id, saved_cwd, workspace, frozen) + .await } - async fn delete_thread(&self, id: &ThreadId) -> anyhow::Result<()> { - self.inner.delete_thread(id).await + + async fn load_session_snapshot(&self, id: &ThreadId) -> SessionResourceResult { + self.inner.load_session_snapshot(id).await } - async fn load_context(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.load_context(id).await + + async fn load_session_binding( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + self.inner.load_session_binding(id).await } - async fn load_context_payloads(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.load_context_payloads(id).await + + async fn validate_bound_workspace( + &self, + id: &ThreadId, + check: peri_acp_types::session_resources::BindingRecheck, + ) -> SessionResourceResult { + self.inner.validate_bound_workspace(id, check).await } - async fn list_child_threads(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.list_child_threads(id).await + + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + self.inner.load_session_history(id).await } - async fn list_session_threads(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.list_session_threads(id).await + + async fn load_session_meta(&self, id: &ThreadId) -> SessionResourceResult { + self.inner.load_session_meta(id).await } - async fn update_thread_status(&self, id: &ThreadId, status: &str) -> anyhow::Result<()> { - self.inner.update_thread_status(id, status).await + + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult { + self.inner.list_sessions(query).await } - async fn invalidate_context_cache(&self, id: &ThreadId) -> anyhow::Result<()> { - self.inner.invalidate_context_cache(id).await + + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult> { + self.inner.list_children(parent).await } - async fn delete_messages(&self, id: &ThreadId, ids: &[MessageId]) -> anyhow::Result<()> { - self.deletes.fetch_add(1, Ordering::SeqCst); - self.inner.delete_messages(id, ids).await + + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult> { + self.inner.list_session_tree(root).await } - async fn update_message_flags( + + async fn append_history( &self, - id: &MessageId, - flags: &MessageFlags, - ) -> anyhow::Result<()> { - self.inner.update_message_flags(id, flags).await + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + if self.fail_appends.load(Ordering::SeqCst) + || (self.fail_after_full && self.compact_commits.load(Ordering::SeqCst) > 0) + { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Unavailable { + detail: "injected post-compact writer failure".to_owned(), + }, + )); + } + self.inner.append_history(id, payloads).await + } + + async fn save_fork( + &self, + fork: &ForkSnapshot, + ) -> SessionResourceResult> { + self.inner.save_fork(fork).await } - fn supports_compaction_lifecycle(&self) -> bool { - true + + async fn save_child( + &self, + child: &ChildSnapshot, + lease: &Arc, + ) -> SessionResourceResult<()> { + self.inner.save_child(child, lease).await } - async fn commit_compaction_lifecycle( + + async fn claim_child_resume( + &self, + child: &ThreadId, + root: &ThreadId, + ) -> SessionResourceResult> { + self.inner.claim_child_resume(child, root).await + } + + async fn apply_compaction( &self, id: &ThreadId, - lifecycle: &CompactionLifecycle, - ) -> anyhow::Result<()> { - self.inner - .commit_compaction_lifecycle(id, lifecycle) - .await?; - if !lifecycle.appended_messages.is_empty() { + change: &CompactionChange, + ) -> SessionResourceResult<()> { + self.inner.apply_compaction(id, change).await?; + if !change.appended_messages.is_empty() { self.compact_commits.fetch_add(1, Ordering::SeqCst); + // guard 必须在 await 之前释放(`SessionResources` 的 future 需要 Send)。 let action = self.after_commit.lock().unwrap().take(); match action { Some(AfterCommitAction::Cancel(cancel)) => { @@ -152,18 +234,72 @@ impl ThreadStore for RecoveryStore { std::future::pending::<()>().await; } Some(AfterCommitAction::Error) => { - anyhow::bail!("injected error after durable compact commit"); + return Err(SessionResourceError::new( + SessionResourceErrorKind::Unavailable { + detail: "injected error after durable compact commit".to_owned(), + }, + )); } None => {} } } Ok(()) } - async fn load_message_flags( + + async fn apply_message_projections( &self, id: &ThreadId, - ) -> anyhow::Result> { - self.inner.load_message_flags(id).await + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()> { + self.inner.apply_message_projections(id, updates).await + } + + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + self.deletes.fetch_add(1, Ordering::SeqCst); + self.inner.rewind_history(id, boundary).await + } + + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + self.deletes.fetch_add(1, Ordering::SeqCst); + self.inner.remove_history_entries(id, ids).await + } + + async fn update_session_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + self.inner.update_session_meta(id, patch).await + } + + async fn delete_session_tree(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.inner.delete_session_tree(id).await + } + + async fn recover_session_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + self.inner.recover_session_persistence(id).await + } + + async fn drain_persistence(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.inner.drain_persistence(id).await + } +} + +impl RecoveryStore { + /// 夹具侧直接读回持久化历史(走门面的一致快照,不另开连接)。 + async fn load_payloads(&self, id: &ThreadId) -> anyhow::Result> { + Ok(self.inner.load_session_snapshot(id).await?.payloads) } } @@ -205,10 +341,15 @@ async fn make_recovery_context( model: Arc, fail_after_full: bool, ) -> (SessionContext, Arc, SharedSessions) { - let store = Arc::new(RecoveryStore { - inner: SqliteThreadStore::new(dir.path().join("recovery.db")) + // 夹具与生产同构:迁移桥与门面来自**同一次打开**(同一 pool、同一 owner 登记表)。 + // 桥只用于夹具侧的建会话与直接读断言,生产路径一律走注入的门面包装。 + let (bridge, facade) = + peri_resources::sessions::open_store_and_facade_for_tests(dir.path().join("recovery.db")) .await - .unwrap(), + .unwrap(); + let bridge = Arc::new(bridge); + let store = Arc::new(RecoveryStore { + inner: Arc::new(facade), compact_commits: AtomicUsize::new(0), fail_appends: AtomicBool::new(false), fail_after_full, @@ -216,13 +357,13 @@ async fn make_recovery_context( after_commit: Mutex::new(None), }); let cwd = dir.path().to_str().unwrap(); - let thread_id = store.create_thread(ThreadMeta::new(cwd)).await.unwrap(); + let thread_id = bridge.create_thread(ThreadMeta::new(cwd)).await.unwrap(); let history = vec![BaseMessage::human(OLD), BaseMessage::ai("old answer")]; - store.append_messages(&thread_id, &history).await.unwrap(); - let mut ctx = make_session_context(&thread_id); + bridge.append_messages(&thread_id, &history).await.unwrap(); + let mut ctx = make_session_context(&thread_id).await; ctx.cwd = cwd.into(); ctx.thread_id = Some(thread_id); - ctx.thread_store = Some(store.clone()); + ctx.session_resources = Some(store.clone()); ctx.primary_llm_factory = Some(Arc::new(move || model.clone())); let sessions = make_host_sessions( &ctx, @@ -401,14 +542,13 @@ async fn test_full_compact_writer_error_evicts_host_and_cold_reload_recovers_sum 0, "不得删除已提交 lifecycle 的消息" ); - // 释放原执行与持久化 owner,再打开全新的 SQLite store。 - ctx.thread_store = None; + // 释放原执行与持久化 owner,再打开全新的 SQLite store(桥与门面重新配对)。 + ctx.session_resources = None; drop(store); - let recovered = Arc::new( - SqliteThreadStore::new(dir.path().join("recovery.db")) + let (recovered, recovered_facade) = + peri_resources::sessions::open_store_and_facade_for_tests(dir.path().join("recovery.db")) .await - .unwrap(), - ); + .unwrap(); let thread_id = ctx.thread_id.as_ref().unwrap(); let payloads = recovered.load_payloads(thread_id).await.unwrap(); let flags = recovered.load_message_flags(thread_id).await.unwrap(); @@ -426,7 +566,7 @@ async fn test_full_compact_writer_error_evicts_host_and_cold_reload_recovers_sum .count(), 1 ); - ctx.thread_store = Some(recovered); + ctx.session_resources = Some(Arc::new(recovered_facade)); let cold_sessions = make_host_sessions(&ctx, payloads); assert_next_turn_sees_summary(ctx, &cold_sessions).await; } @@ -575,13 +715,12 @@ async fn assert_unconfirmed_commit_requires_cold_recovery(cancel_after_commit: b assert!(wire.message.contains("reload")); assert!(!sessions.lock().await.contains_key(&ctx.session_id)); assert_eq!(store.deletes.load(Ordering::SeqCst), 0); - ctx.thread_store = None; + ctx.session_resources = None; drop(store); - let recovered = Arc::new( - SqliteThreadStore::new(dir.path().join("recovery.db")) + let (recovered, recovered_facade) = + peri_resources::sessions::open_store_and_facade_for_tests(dir.path().join("recovery.db")) .await - .unwrap(), - ); + .unwrap(); let thread_id = ctx.thread_id.as_ref().unwrap(); let payloads = recovered.load_payloads(thread_id).await.unwrap(); let flags = recovered.load_message_flags(thread_id).await.unwrap(); @@ -599,7 +738,7 @@ async fn assert_unconfirmed_commit_requires_cold_recovery(cancel_after_commit: b .count(), 1 ); - ctx.thread_store = Some(recovered); + ctx.session_resources = Some(Arc::new(recovered_facade)); let cold_sessions = make_host_sessions(&ctx, payloads); assert_next_turn_sees_summary(ctx, &cold_sessions).await; } diff --git a/peri-acp/src/host/executor_flow_test.rs b/peri-acp/src/host/executor_flow_test.rs index aa2b481ce..ad3a7766d 100644 --- a/peri-acp/src/host/executor_flow_test.rs +++ b/peri-acp/src/host/executor_flow_test.rs @@ -25,12 +25,11 @@ use peri_acp_types::{ interaction::{InteractionContext, InteractionResponse, UserInteractionBroker}, messages::{BaseMessage, MessageContent}, permission::{PermissionMode, SharedPermissionMode}, - store::ThreadStore, + session_resources::SessionResources, }; use peri_agent::session::exec::executor_helpers::{ ForwarderLauncherFn, StageBuildFn, StageBuildRequest, }; -use peri_agent::thread::FilesystemThreadStore; use serial_test::serial; use tokio_util::sync::CancellationToken as AgentCancellationToken; @@ -61,7 +60,10 @@ struct HomeGuard { #[cfg_attr(windows, allow(dead_code))] impl HomeGuard { fn set(home: &std::path::Path) -> Self { - let lock = HOME_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + let lock = HOME_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::env::var_os("HOME"); std::env::set_var("HOME", home); Self { @@ -277,14 +279,19 @@ impl UserInteractionBroker for NoopBroker { /// LlmProvider + AgentPool 烘焙,装配路径实际调用)。 /// /// `pub(super)`:`host::mcp_v4_startup_tests` 复用同一装配面后按需注入 MCP pool。 -pub(super) fn make_session_context(session_id: &str) -> SessionContext { +pub(super) async fn make_session_context(session_id: &str) -> SessionContext { // 事件广播宿主:发射端(EventPublisher 适配)与订阅端(subscribe 工厂) // 共享同一 Controller 实例,保持迁移前「publish/subscribe 同一广播」语义。 - let controller = Arc::new(peri_controller::Controller::new( - Arc::new(FilesystemThreadStore::new( - std::env::temp_dir().join(format!("peri-exec-flow-{}", uuid::Uuid::new_v4())), - )) as Arc, - )); + // Controller 只持会话资源门面:夹具开一个临时真实库(该门面在本用例里不被执行)。 + let controller: Arc = + peri_agent::resources::open_session_resources_with(Some( + std::env::temp_dir() + .join(format!("peri-exec-flow-{}", uuid::Uuid::new_v4())) + .join("threads.db"), + )) + .await + .unwrap(); + let controller = Arc::new(peri_controller::Controller::new(controller)); // 测试 LlmProvider + AgentPool + PeriConfig(与迁移前 executor_test 同源) let provider = LlmProvider::OpenAi { api_key: "test-key".to_string(), @@ -410,7 +417,8 @@ pub(super) fn make_session_context(session_id: &str) -> SessionContext { broker: Arc::new(NoopBroker), permission_mode: SharedPermissionMode::new(PermissionMode::Bypass), session_access: None, - thread_store: None, + session_resources: None, + execution_owner: None, thread_id: None, plugin_skill_roots: vec![], plugin_agent_dirs: vec![], @@ -465,9 +473,11 @@ async fn make_session_context_with_manager( session_id: &str, tmp: &tempfile::TempDir, ) -> (SessionContext, SessionManager) { - let mut ctx = make_session_context(session_id); - let thread_store = - Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))) as Arc; + let mut ctx = make_session_context(session_id).await; + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![ProviderConfig { @@ -488,7 +498,7 @@ async fn make_session_context_with_manager( ..Default::default() }; let sm = SessionManager::new( - thread_store, + session_resources, LlmProvider::from_config(&peri_config).unwrap(), Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -748,7 +758,7 @@ async fn test_production_first_reason_sees_after_before_agent_dynamic_contributi let model = Arc::new(CapturePromptModel { requests: Arc::clone(&requests), }) as Arc; - let mut ctx = make_session_context("dynamic-first-reason"); + let mut ctx = make_session_context("dynamic-first-reason").await; ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let frozen = frozen_with_dynamic_prompt_policy("DYNAMIC_BASE_SENTINEL", &[]); let (out, _) = make_stage_build(&ctx)(make_stage_request(frozen, None)).unwrap(); @@ -829,7 +839,7 @@ async fn test_production_fresh_stage_rebuild_recomputes_dynamic_contributions_fr requests: Arc::clone(&requests), }) as Arc; let shared_index = Arc::new(ToolSearchIndex::default()); - let mut first_ctx = make_session_context("dynamic-fresh-enabled"); + let mut first_ctx = make_session_context("dynamic-fresh-enabled").await; first_ctx.primary_llm_factory = { let model = Arc::clone(&model); Some(Arc::new(move || Arc::clone(&model))) @@ -874,7 +884,7 @@ async fn test_production_fresh_stage_rebuild_recomputes_dynamic_contributions_fr drop(first.bg_event_rx); drop(first_ctx); - let mut second_ctx = make_session_context("dynamic-fresh-disabled"); + let mut second_ctx = make_session_context("dynamic-fresh-disabled").await; second_ctx.primary_llm_factory = { let model = Arc::clone(&model); Some(Arc::new(move || Arc::clone(&model))) @@ -963,7 +973,7 @@ async fn test_production_stage_propagates_frozen_snapshot_to_main_and_child() { SessionFactory, SubagentCancelPolicy, SubagentRunMode, SubagentSpawnConfig, }; - let mut ctx = make_session_context("frozen-production-parent"); + let mut ctx = make_session_context("frozen-production-parent").await; ctx.language = Some("en-US".into()); let stage_build = make_stage_build(&ctx); let sentinel = make_sentinel_frozen(); @@ -1009,7 +1019,8 @@ async fn test_production_stage_propagates_frozen_snapshot_to_main_and_child() { compact_config: None, context_budget: None, compact_llm: None, - thread_store: None, + session_resources: None, + execution_owner: None, event_handler: None, bg_event_sender: None, task_manager: None, @@ -1072,7 +1083,7 @@ async fn test_production_stage_uses_frozen_language_and_keeps_override_out_of_ba let model = Arc::new(CapturePromptModel { requests: Arc::clone(&requests), }) as Arc; - let mut ctx = make_session_context("frozen-language-override"); + let mut ctx = make_session_context("frozen-language-override").await; ctx.language = Some("en-US".into()); ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let sentinel = make_sentinel_frozen(); @@ -1165,7 +1176,7 @@ async fn test_production_stage_keeps_empty_frozen_prompt_inputs_after_late_files let model = Arc::new(CapturePromptModel { requests: Arc::clone(&requests), }) as Arc; - let mut ctx = make_session_context("late-frozen-files"); + let mut ctx = make_session_context("late-frozen-files").await; ctx.cwd = cwd.to_string_lossy().into_owned(); ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let stage_build = make_stage_build(&ctx); @@ -1226,7 +1237,8 @@ async fn test_production_stage_keeps_empty_frozen_prompt_inputs_after_late_files compact_config: None, context_budget: None, compact_llm: None, - thread_store: None, + session_resources: None, + execution_owner: None, event_handler: None, bg_event_sender: None, task_manager: None, @@ -1265,7 +1277,7 @@ async fn test_production_stage_keeps_empty_frozen_prompt_inputs_after_late_files #[tokio::test] async fn test_continuation_bypasses_keepgoing_short_circuit() { // Arrange:预取消 token,保证进入管线后快速中断(不触发真实 LLM 调用) - let ctx = make_session_context("test-continuation"); + let ctx = make_session_context("test-continuation").await; ctx.cancel.cancel(); let stage_build = make_stage_build(&ctx); let mock_sink = Arc::new(MockEventSink::new()); @@ -1301,7 +1313,7 @@ async fn test_continuation_bypasses_keepgoing_short_circuit() { async fn test_turn_terminal_state_unique_and_last() { // Arrange:预取消 token,进入管线后立即中断(不触发真实 LLM 调用) let mock_sink = Arc::new(MockEventSink::new()); - let ctx = make_session_context("test-turn-terminal"); + let ctx = make_session_context("test-turn-terminal").await; ctx.cancel.cancel(); let stage_build = make_stage_build(&ctx); let turn = make_turn_input( @@ -1367,7 +1379,7 @@ async fn test_turn_terminal_state_unique_and_last() { #[tokio::test] async fn test_forwarder_barrier_orders_final_usage_before_done() { let model: Arc = Arc::new(UsageModel); - let mut ctx = make_session_context("test-forwarder-usage-barrier"); + let mut ctx = make_session_context("test-forwarder-usage-barrier").await; ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let stage_build = make_stage_build(&ctx); let sink = Arc::new(MockEventSink::new()); @@ -1417,7 +1429,7 @@ async fn test_forwarder_barrier_orders_final_usage_before_done() { #[tokio::test] async fn test_forwarder_join_error_fails_turn_before_done_without_late_usage() { let model: Arc = Arc::new(UsageModel); - let mut ctx = make_session_context("test-forwarder-join-error"); + let mut ctx = make_session_context("test-forwarder-join-error").await; ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let stage_build = make_stage_build(&ctx); let sink = Arc::new(MockEventSink::new()); @@ -1510,7 +1522,7 @@ async fn test_cancel_during_reason_has_one_interrupted_terminal() { let model: Arc = Arc::new(CancelGateModel { entered: Mutex::new(Some(entered_tx)), }); - let mut ctx = make_session_context("test-cancel-during-reason"); + let mut ctx = make_session_context("test-cancel-during-reason").await; ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let cancel = ctx.cancel.clone(); let stage_build = make_stage_build(&ctx); @@ -1576,7 +1588,7 @@ async fn test_cancel_during_reason_has_one_interrupted_terminal() { #[tokio::test] async fn test_fatal_failure_precedes_turn_end_and_done() { let model: Arc = Arc::new(FatalModel); - let mut ctx = make_session_context("test-fatal-terminal-order"); + let mut ctx = make_session_context("test-fatal-terminal-order").await; ctx.primary_llm_factory = Some(Arc::new(move || Arc::clone(&model))); let stage_build = make_stage_build(&ctx); let sink = Arc::new(MockEventSink::new()); @@ -1669,7 +1681,7 @@ async fn test_continuation_skips_empty_prompt_push() { ); // Act 2:keepgoing(continuation=false,同为空 content)——对比组 - let mut ctx2 = make_session_context(session_id); + let mut ctx2 = make_session_context(session_id).await; ctx2.session_access = Some(Arc::new(sm.clone()) as Arc); ctx2.cancel.cancel(); @@ -1701,8 +1713,11 @@ async fn test_continuation_skips_empty_prompt_push() { // ── FrozenSessionData 渲染测试(L5:渲染面留 ACP,经 build_frozen_data)─── /// 构造带 SkillsProvider 的 SessionManager(frozen 渲染输入)。 -fn make_manager(tmp: &tempfile::TempDir) -> SessionManager { - let thread_store = Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); +async fn make_manager(tmp: &tempfile::TempDir) -> SessionManager { + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![ProviderConfig { @@ -1723,7 +1738,7 @@ fn make_manager(tmp: &tempfile::TempDir) -> SessionManager { ..Default::default() }; SessionManager::new( - thread_store, + session_resources, LlmProvider::from_config(&peri_config).unwrap(), Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -1753,7 +1768,7 @@ fn make_manager(tmp: &tempfile::TempDir) -> SessionManager { #[serial] async fn test_frozen_session_data_build_is_deterministic() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_manager(&tmp); + let mgr = make_manager(&tmp).await; let cwd = "/tmp"; let a = mgr.build_frozen_data(cwd, &[], &[]); @@ -1783,7 +1798,7 @@ async fn test_frozen_session_data_build_is_deterministic() { #[serial] async fn test_frozen_system_prompt_immune_to_disk_changes() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_manager(&tmp); + let mgr = make_manager(&tmp).await; let cwd = tmp.path().to_str().unwrap(); // 冻结前:cwd 含 skill-a @@ -1834,7 +1849,7 @@ async fn test_frozen_system_prompt_immune_to_disk_changes() { #[tokio::test] async fn test_frozen_prompt_never_claims_workflow() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_manager(&tmp); + let mgr = make_manager(&tmp).await; let cwd = "/tmp"; let frozen = mgr.build_frozen_data(cwd, &[], &[]); @@ -1850,7 +1865,7 @@ async fn test_frozen_prompt_never_claims_workflow() { #[tokio::test] async fn test_frozen_subagent_prompt_identical_to_main() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_manager(&tmp); + let mgr = make_manager(&tmp).await; let cwd = "/tmp"; let frozen = mgr.build_frozen_data(cwd, &[], &[]); @@ -1877,7 +1892,7 @@ async fn test_frozen_subagent_prompt_identical_to_main() { #[tokio::test] async fn test_workflow_prompt_excludes_hitl_section() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_manager(&tmp); + let mgr = make_manager(&tmp).await; let frozen = mgr.build_frozen_data("/tmp", &[], &[]); // 主链冻结 prompt 保留 10_hitl(PermissionMiddleware 默认装配) @@ -2016,7 +2031,7 @@ fn make_parity_context( bg_event_tx, on_bg_complete: None, langfuse_bridge: None, - thread_store: None, + session_resources: None, parent_thread_id: None, register_runtime: None, deregister_runtime: None, @@ -2341,7 +2356,7 @@ async fn test_ptc_runs_through_acp_session_agent_production_path() { source, }) as Arc; let approvals = Arc::new(Mutex::new(Vec::new())); - let mut ctx = make_session_context("ptc-production-e2e"); + let mut ctx = make_session_context("ptc-production-e2e").await; ctx.cwd = tmp.path().to_string_lossy().into_owned(); ctx.permission_mode = SharedPermissionMode::new(PermissionMode::Default); ctx.broker = Arc::new(RecordingApproveBroker { diff --git a/peri-acp/src/host/lifecycle.rs b/peri-acp/src/host/lifecycle.rs index 14e9edbed..debfa49a3 100644 --- a/peri-acp/src/host/lifecycle.rs +++ b/peri-acp/src/host/lifecycle.rs @@ -132,6 +132,24 @@ struct ExitRound { } impl HostExitContext { + /// 会话任务排空之后关闭会话存储:这是唯一持有部署关闭权的时点。 + /// + /// 返回 `Err(())` 表示关闭**未确认**(在途写入未结清、未决持久化仍在,或取消)。 + /// 事实已在实现内部判定并记录,这里只按未完成上报——不重试、不把未确认当成功; + /// 部署保留上下文,重复关闭会重新做一遍真实检查。 + async fn shutdown_session_store(&self) -> Result<(), ()> { + let Some(shutdown) = self.cfg.session_store_shutdown.as_ref() else { + // 未注入关闭权(会话级装配或测试宿主):这里没有部署存储可关闭。 + return Ok(()); + }; + shutdown.shutdown().await.map_err(|error| { + tracing::warn!( + kind = ?error.kind(), + "session store close was not confirmed; deployment retained for retry" + ); + }) + } + async fn finish(mut self) -> ExitRound { let resources = super::shutdown::shutdown_host( &mut self.task_owner, @@ -147,8 +165,11 @@ impl HostExitContext { .await; let report = match resources { HostTerminalShutdownReport::Incomplete { .. } => AcpHostShutdownReport::Incomplete, + // 排空完成才轮得到部署关闭:还有任何未完成的任务时保留存储原样。 HostTerminalShutdownReport::Complete { .. } => { - if let Some(owner) = self.cfg.langfuse_shutdown_owner.as_ref() { + if self.shutdown_session_store().await.is_err() { + AcpHostShutdownReport::Incomplete + } else if let Some(owner) = self.cfg.langfuse_shutdown_owner.as_ref() { match owner.shutdown().await { LangfuseShutdownReport::Complete => AcpHostShutdownReport::Complete, report => AcpHostShutdownReport::TelemetryFailed(report), diff --git a/peri-acp/src/host/mcp_v4_startup_test.rs b/peri-acp/src/host/mcp_v4_startup_test.rs index de55ff997..618f3b4ae 100644 --- a/peri-acp/src/host/mcp_v4_startup_test.rs +++ b/peri-acp/src/host/mcp_v4_startup_test.rs @@ -276,8 +276,8 @@ impl McpStartupHarness { } /// 注入 fixture pool 的 session 装配面(真实 assembler 会据此构造 McpMiddleware)。 - fn session_context(&self, session_id: &str) -> SessionContext { - let mut ctx = make_session_context(session_id); + async fn session_context(&self, session_id: &str) -> SessionContext { + let mut ctx = make_session_context(session_id).await; ctx.mcp_pool = Some(Arc::clone(&self.pool) as Arc); ctx } @@ -449,7 +449,7 @@ async fn system_mcp_transport_failure_fails_first_prompt_without_model_call() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-init-failure"), + harness.session_context("mcp-v4-init-failure").await, &sink, &model, ) @@ -485,7 +485,7 @@ async fn system_mcp_connected_without_tool_discovery_is_not_ready() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-no-discovery"), + harness.session_context("mcp-v4-no-discovery").await, &sink, &model, ) @@ -509,7 +509,7 @@ async fn system_mcp_tool_discovery_failure_is_not_an_empty_tool_list() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-list-failure"), + harness.session_context("mcp-v4-list-failure").await, &sink, &model, ) @@ -535,7 +535,12 @@ async fn system_mcp_timeout_is_fatal_not_cancelled() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); - let result = run_prompt(harness.session_context("mcp-v4-timeout"), &sink, &model).await; + let result = run_prompt( + harness.session_context("mcp-v4-timeout").await, + &sink, + &model, + ) + .await; assert_fatal_without_reason(&result, &sink, &model, "启动超时(800ms),未发布 ready"); assert_ne!( @@ -567,7 +572,12 @@ async fn system_mcp_disconnected_peer_fails_first_prompt() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); - let result = run_prompt(harness.session_context("mcp-v4-peer-exit"), &sink, &model).await; + let result = run_prompt( + harness.session_context("mcp-v4-peer-exit").await, + &sink, + &model, + ) + .await; assert_fatal_without_reason( &result, @@ -591,7 +601,7 @@ async fn system_mcp_missing_required_tool_fails_before_reason() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-missing-tool"), + harness.session_context("mcp-v4-missing-tool").await, &sink, &model, ) @@ -618,7 +628,12 @@ async fn system_mcp_ready_exposes_required_tools_on_first_model_request() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); - let result = run_prompt(harness.session_context("mcp-v4-ready-tools"), &sink, &model).await; + let result = run_prompt( + harness.session_context("mcp-v4-ready-tools").await, + &sink, + &model, + ) + .await; assert!( result.ok, @@ -668,7 +683,7 @@ async fn system_mcp_empty_required_tools_ready_without_injection() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-empty-required"), + harness.session_context("mcp-v4-empty-required").await, &sink, &model, ) @@ -709,7 +724,7 @@ async fn ordinary_mcp_pending_does_not_block_startup() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-ordinary-pending"), + harness.session_context("mcp-v4-ordinary-pending").await, &sink, &model, ) @@ -755,7 +770,7 @@ async fn ordinary_mcp_failure_does_not_block_startup() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-ordinary-failure"), + harness.session_context("mcp-v4-ordinary-failure").await, &sink, &model, ) @@ -784,7 +799,7 @@ async fn system_mcp_gate_runs_after_receive_and_before_reason() { let sink = Arc::new(MockEventSink::new()); let model = CountingModel::new(); let result = run_prompt( - harness.session_context("mcp-v4-receive-order"), + harness.session_context("mcp-v4-receive-order").await, &sink, &model, ) diff --git a/peri-acp/src/host/mod.rs b/peri-acp/src/host/mod.rs index 41ec14400..ca9574534 100644 --- a/peri-acp/src/host/mod.rs +++ b/peri-acp/src/host/mod.rs @@ -56,6 +56,10 @@ mod notify; mod oauth_delivery; mod prediction; mod prediction_projection; +mod prepared; +#[cfg(test)] +#[path = "prepared_test.rs"] +mod prepared_tests; mod prompt; mod prompt_dispatch; pub mod prompt_handle; @@ -191,7 +195,16 @@ pub struct AcpServerConfig { /// 引用 middlewares,见 `host/workflow_agent.rs`)。 pub workflow_middleware_factory: Arc, - pub thread_store: Arc, + /// 会话资源门面:协议面、Agent transcript/subagent 与 middleware 的唯一会话行为 + /// 入口(同一个库句柄、同一份 owner 登记);协议面与 Controller 都不再持有裸存储。 + pub session_resources: Arc, + /// 部署关闭权(non-Clone,装配点注入):宿主在任务排空之后用它关闭会话存储。 + /// + /// 只有部署(TUI/print/stdio 装配点)注入;会话级配置与测试为 `None`,业务侧 + /// (SessionManager/Controller/middleware)拿到的只有 `session_resources` 业务句柄, + /// 没有任何关闭全局存储的路径。 + pub(crate) session_store_shutdown: + Option>, /// Controller 层宿主:dispatch 存储操作(load/list/fork/execute-command/rewind) /// 经此访问持久化存储(ARC-BOUNDARY-001 方向,不再直操 `thread_store`); /// 3.0 批 2:事件发射(`publish_event`)/ 执行发起(`run_session`)亦经此宿主。 diff --git a/peri-acp/src/host/prediction.rs b/peri-acp/src/host/prediction.rs index ec282363b..ae75bd929 100644 --- a/peri-acp/src/host/prediction.rs +++ b/peri-acp/src/host/prediction.rs @@ -16,7 +16,7 @@ pub(super) fn spawn_prediction( let pred_session_id = prompt_session_id.to_string(); let pred_provider = cfg.provider.clone(); let pred_sessions = sessions.clone(); - let pred_thread_store = cfg.thread_store.clone(); + let pred_resources = cfg.session_resources.clone(); let pred_caps_registry = cfg.session_manager.caps_registry(); let _ = cfg.host_task_spawner.spawn( @@ -95,11 +95,17 @@ pub(super) fn spawn_prediction( } } } - // 标题变更:持久化到 thread store,并推送 session/update - // 供标题栏与外部客户端刷新(与 session/rename 行为一致) + // 标题变更:经门面定向持久化 metadata(不整份覆盖),并推送 + // session/update 供标题栏与外部客户端刷新(与 session/rename 同行为) if let Some(title) = applied_title { - if let Err(e) = pred_thread_store - .update_title(&pred_session_id, &title) + if let Err(e) = pred_resources + .update_session_meta( + &pred_session_id, + &peri_acp_types::session_resources::SessionMetaPatch { + title: Some(Some(title.clone())), + ..Default::default() + }, + ) .await { tracing::warn!( diff --git a/peri-acp/src/host/prepared.rs b/peri-acp/src/host/prepared.rs new file mode 100644 index 000000000..50da0b408 --- /dev/null +++ b/peri-acp/src/host/prepared.rs @@ -0,0 +1,193 @@ +//! 会话准备输入:lease 之前的只读定格,装配与持久化消费同一对象。 +//! +//! 与 `PreparedSession`(恢复准入结果:id/identity/read_only)不同——本结构是 +//! **输入**定格:配置、插件聚合、运行环境、frozen 字节一次产出。准备阶段不启动 +//! MCP/LSP/hook/cron,不创建 thread、不占 lease、不做 cache repair,也不写会话 +//! 数据或本机登记;装配期不再重读配置/插件,也不再各取一份日期与环境探测。 + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use peri_acp_types::plugin::PluginLoadResult; +use peri_acp_types::skills::SkillRoot; + +use crate::prompt::PromptRuntimeEnv; +use crate::provider::{ConfigSource, LlmProvider, PeriConfig}; +use crate::session::executor::FrozenSessionData; +use crate::session::frozen_snapshot::{decode_frozen_snapshot, encode_frozen_snapshot}; +use crate::transport::types::AcpError; + +use super::workspace::workspace_error; +use super::AcpServerConfig; + +/// legacy 接纳输入:新建与 fork 没有这一项。 +/// +/// 顶层字段(`config` / `plugin_data` / `frozen` / `frozen_encoded`)就是按 +/// `saved_cwd` 构建的结果——不重复存第二份,避免同源双写。 +#[derive(Debug, Clone)] +pub(crate) struct LegacyAdoptionInputs { + /// 保存的绝对执行目录(不是调用方终端的 cwd)。 + pub(crate) saved_cwd: PathBuf, +} + +/// 会话准备输入(一次准备、后续只读消费)。 +pub(crate) struct PreparedSessionInputs { + /// 规范化后的执行目录。 + pub(crate) cwd: String, + /// 从同一 `ConfigSource` 读出并合并一次的配置视图。 + pub(crate) config: Arc, + /// 路径决策事实源(后续持久化沿用,不再判定)。 + pub(crate) config_source: Arc, + /// 由同一 config 解析(或环境变量)的 provider;失败即准备失败。 + pub(crate) provider: LlmProvider, + /// 一次加载的插件聚合(roots/commands/hooks/lsp/mcp)。 + pub(crate) plugin_data: Option, + pub(crate) skill_roots: Vec, + pub(crate) agent_dirs: Vec, + pub(crate) frozen: FrozenSessionData, + /// 版本化 snapshot 字节(数据端口只存不渲染)。 + pub(crate) frozen_encoded: String, + /// 仅 legacy:取自保存的绝对 cwd。 + pub(crate) legacy: Option, +} + +/// 插件发现结果:一次加载的聚合、技能根与 agent 目录。 +type DiscoveredPlugins = (Option, Vec, Vec); + +/// frozen 的来源:新建/legacy 构建一次,fork 直接复用 source 的精确字节。 +enum FrozenSource<'a> { + Build, + Reuse(&'a str), +} + +impl PreparedSessionInputs { + /// 新建会话准备:只读,不创建 thread、不占 lease、不启动执行资源。 + pub(crate) fn prepare_new(host: &AcpServerConfig, cwd: &str) -> Result { + Self::prepare_scope(host, cwd, FrozenSource::Build) + } + + /// legacy 恢复准备:`saved_cwd` 是登记事实(保存的绝对 cwd),配置/插件/ + /// frozen 按本次解析出的执行目录 `workspace_cwd` 构建(现有兼容语义)。 + pub(crate) fn prepare_legacy( + host: &AcpServerConfig, + saved_cwd: &str, + workspace_cwd: &str, + ) -> Result { + let mut inputs = Self::prepare_scope(host, workspace_cwd, FrozenSource::Build)?; + inputs.legacy = Some(LegacyAdoptionInputs { + saved_cwd: PathBuf::from(saved_cwd), + }); + Ok(inputs) + } + + /// 普通 fork 准备:复用 source 已持久化的精确 frozen 字节,不按当前日期/ + /// 目录重冻;配置与插件按 fork 目录定格一次。 + pub(crate) fn prepare_fork( + host: &AcpServerConfig, + cwd: &str, + source_snapshot: &str, + ) -> Result { + Self::prepare_scope(host, cwd, FrozenSource::Reuse(source_snapshot)) + } + + fn prepare_scope( + host: &AcpServerConfig, + cwd: &str, + frozen_source: FrozenSource<'_>, + ) -> Result { + let (config_source, config, provider) = Self::resolve_configuration(host, cwd)?; + let (plugin_data, skill_roots, agent_dirs) = Self::discover_plugins(host, cwd)?; + // 运行环境(平台 / OS / Git)在准备阶段探测一次,随冻结渲染定格;日期由 + // `frozen.date` 固化。装配期不得重新 `detect`/`with_frozen_date` 各取一份 + // ——需要这些事实的下一批消费者应从这里提升字段,而不是各自探测。 + let runtime_env = PromptRuntimeEnv::detect(cwd); + let (frozen, frozen_encoded) = match frozen_source { + FrozenSource::Build => { + let frozen = host + .session_manager + .build_frozen_data_with_config_and_runtime( + &config, + cwd, + &skill_roots, + &agent_dirs, + &runtime_env, + ); + let encoded = encode_frozen_snapshot(&frozen).map_err(|error| { + AcpError::new(-32603, format!("Frozen snapshot encode failed: {error}")) + })?; + (frozen, encoded) + } + FrozenSource::Reuse(snapshot) => { + let frozen = decode_frozen_snapshot(snapshot).map_err(workspace_error)?; + (frozen, snapshot.to_owned()) + } + }; + Ok(Self { + cwd: cwd.to_owned(), + config, + config_source, + provider, + plugin_data, + skill_roots, + agent_dirs, + frozen, + frozen_encoded, + legacy: None, + }) + } + + /// 配置一次读出:同一 cwd 复用 host 已装配视图,不同 cwd 只读一次 + /// `ConfigSource::load_at`;provider 由该视图解析,失败即准备失败。 + fn resolve_configuration( + host: &AcpServerConfig, + cwd: &str, + ) -> Result<(Arc, Arc, LlmProvider), AcpError> { + let same_directory = host.workspace_assembly.as_ref().is_none_or(|source| { + std::fs::canonicalize(&source.startup_cwd).ok().as_deref() == Some(Path::new(cwd)) + }); + if same_directory { + return Ok(( + host.config_source.clone(), + Arc::new(host.peri_config.read().clone()), + host.provider.read().clone(), + )); + } + let source = Arc::new( + ConfigSource::load_at(Path::new(cwd), host.config_source.global_path().to_owned()) + .map_err(workspace_error)?, + ); + let config = source.loaded_merged(); + let provider = LlmProvider::from_config(&config) + .or_else(LlmProvider::from_env) + .ok_or_else(|| AcpError::new(-32603, "No provider configured for session workspace"))?; + Ok((source, Arc::new(config), provider)) + } + + /// 插件发现:session 级装配经**严格只读**入口一次加载,失败即准备失败 + /// (缺失/非法清单定位到具体插件,不生成合成清单、不写插件缓存); + /// host 级与 bare 沿用既有形状(无插件聚合)。 + fn discover_plugins(host: &AcpServerConfig, cwd: &str) -> Result { + match host.workspace_assembly.as_ref() { + None => Ok(( + None, + host.plugin_skill_roots.clone(), + host.plugin_agent_dirs.clone(), + )), + Some(source) if source.bare => Ok((None, Vec::new(), Vec::new())), + Some(_) => { + let claude_dir = dirs_next::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude"); + let data = super::assemble::discover_enabled_plugins_readonly(&claude_dir, cwd) + .map_err(|error| { + AcpError::new(-32603, format!("Plugin discovery failed: {error}")) + })?; + let skill_roots = data.all_skill_roots.clone(); + let agent_dirs = data.all_agent_dirs.clone(); + Ok((Some(data), skill_roots, agent_dirs)) + } + } + } +} diff --git a/peri-acp/src/host/prepared_test.rs b/peri-acp/src/host/prepared_test.rs new file mode 100644 index 000000000..72b47a7ad --- /dev/null +++ b/peri-acp/src/host/prepared_test.rs @@ -0,0 +1,426 @@ +//! 会话准备输入(`PreparedSessionInputs`)的目标测试: +//! 同输入复用、frozen 字节同源、按需复用 host 配置、fork 复用源字节、准备无写、 +//! 发布段只消费给定的准备对象(不重读外部输入)。 + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use peri_middlewares::permission::shared_mode::{PermissionMode, SharedPermissionMode}; +use tempfile::TempDir; + +use super::assemble::WorkspaceAssembly; +use super::prepared::PreparedSessionInputs; +use super::AcpServerConfig; +use crate::provider::{LlmProvider, ProviderConfig, ProviderModels}; +use crate::session::frozen_snapshot::{decode_frozen_snapshot, encode_frozen_snapshot}; + +/// 准备期冻结的外部输入(cwd/CLAUDE.md 内容)。准备之后改写它,用来证明发布段 +/// 不再读盘;两次内容不同使「二次构建」与「原样消费」的字节可区分。 +const FROZEN_INPUT_AT_PREPARATION: &str = "frozen-seam: content frozen at preparation\n"; +const FROZEN_INPUT_AFTER_PREPARATION: &str = "frozen-seam: rewritten after preparation\n"; + +fn make_provider() -> LlmProvider { + let mut config = crate::provider::PeriConfig::default(); + config.config.active_alias = "sonnet".to_string(); + config.config.providers = vec![ProviderConfig { + id: "prepared-test".into(), + provider_type: "anthropic".into(), + api_key: "prepared-test-placeholder".into(), + models: ProviderModels { + sonnet: "prepared-test-model".into(), + ..Default::default() + }, + ..Default::default() + }]; + LlmProvider::from_config(&config).expect("test provider config must resolve") +} + +/// 真实 SQLite 门面 + 桥的 host(`workspace_assembly` 由调用方决定)。 +async fn prepared_test_host( + tmp: &TempDir, + workspace_assembly: Option, +) -> AcpServerConfig { + // 生产同形:只注入门面(SessionManager 与 Controller 都只持它)。 + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); + let peri_config = crate::provider::PeriConfig::default(); + let provider = make_provider(); + let session_manager = crate::session::SessionManager::new( + session_resources.clone(), + provider.clone(), + Arc::new(peri_config.clone()), + SharedPermissionMode::new(PermissionMode::Bypass), + None, + None, + None, + None, + Some(Arc::new(|| { + Arc::new(peri_agent::agent::async_tasks::TaskManager::new()) + as Arc + })), + Arc::new(peri_middlewares::host_ports::SkillsProvider), + Vec::new(), + Vec::new(), + ); + let (host_task_owner, host_task_spawner) = crate::host::task_scope::HostTaskOwner::new(); + let (mcp_task_owner, _mcp_task_spawner) = peri_middlewares::mcp::McpTaskOwner::new(); + AcpServerConfig { + workspace_assembly, + host_task_owner: Some(host_task_owner), + host_task_spawner, + mcp_task_owner: Some(Box::new(mcp_task_owner)), + provider: Arc::new(parking_lot::RwLock::new(provider)), + peri_config: Arc::new(parking_lot::RwLock::new(peri_config)), + permission_mode: SharedPermissionMode::new(PermissionMode::Bypass), + cron_scheduler: None, + mcp_pool: None, + mcp_apps_relay: None, + acp_mcp: None, + dynamic_mcp: None, + oauth_event_tx: None, + oauth_event_rx: None, + channel_state: None, + plugin_skill_roots: Vec::new(), + plugin_command_entries: Vec::new(), + plugin_agent_dirs: Vec::new(), + plugin_hooks: Vec::new(), + plugin_hooks_only: Vec::new(), + plugin_loaded: Vec::new(), + hook_groups: Vec::new(), + plugin_lsp_servers: Vec::new(), + tool_search_index: Arc::new(peri_middlewares::tool_search::ToolSearchIndex::new()), + skills: Arc::new(peri_middlewares::host_ports::SkillsProvider), + plugin_manager: Arc::new(peri_middlewares::host_ports::PluginManager), + settings_hooks: Arc::new(peri_middlewares::host_ports::SettingsHooksLoader), + shared_tools: Arc::new(parking_lot::RwLock::new(BTreeMap::new())), + workflow_middleware_factory: Arc::new( + peri_middlewares::assembly::WorkflowAgentMiddlewareFactory, + ), + session_resources: session_resources.clone(), + // 测试宿主:不注入部署关闭权(没有部署生命周期)。 + session_store_shutdown: None, + controller: Arc::new(peri_controller::Controller::new(session_resources)), + langfuse_session: None, + langfuse_shutdown_owner: None, + config_source: Arc::new( + crate::provider::ConfigSource::load_at( + &tmp.path().join("empty-cwd"), + tmp.path().join("test_config.json"), + ) + .unwrap(), + ), + session_manager, + stdio_command_filter: false, + } +} + +/// 目录树快照(相对路径 + 字节内容),用于断言准备阶段没有写副作用。 +fn snapshot_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + fn walk(dir: &Path, root: &Path, out: &mut Vec<(PathBuf, Vec)>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, root, out); + } else { + let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + let content = std::fs::read(&path).unwrap_or_default(); + out.push((relative, content)); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +fn make_workspace_dir(tmp: &TempDir, name: &str) -> String { + let dir = tmp.path().join(name); + std::fs::create_dir_all(&dir).unwrap(); + dir.to_str().unwrap().to_owned() +} + +/// 会话解析后的执行目录是规范化路径(macOS 上 `/var` → `/private/var`)。 +fn canonical_workspace_dir(tmp: &TempDir, name: &str) -> String { + let dir = tmp.path().join(name); + std::fs::create_dir_all(&dir).unwrap(); + let dir = std::fs::canonicalize(dir).unwrap(); + dir.to_str().unwrap().to_owned() +} + +/// 同一输入重复准备必须得到同一 snapshot 字节,且字节与冻结数据同源。 +#[tokio::test] +async fn prepare_new_is_repeatable_and_frozen_bytes_are_single_source() { + let tmp = TempDir::new().unwrap(); + let cwd = make_workspace_dir(&tmp, "workspace"); + let host = prepared_test_host(&tmp, None).await; + + let first = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + let second = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + + assert_eq!(first.cwd, cwd); + assert_eq!( + first.frozen_encoded, second.frozen_encoded, + "同一准备输入必须产出同一 snapshot 字节" + ); + assert_eq!(first.frozen.date(), second.frozen.date()); + let decoded = decode_frozen_snapshot(&first.frozen_encoded).unwrap(); + assert_eq!( + decoded.date(), + first.frozen.date(), + "snapshot 字节与内存冻结数据必须同源(日期不各取一份)" + ); + assert_eq!(decoded.system_prompt(), first.frozen.system_prompt()); + assert_eq!(first.skill_roots.len(), second.skill_roots.len()); + assert_eq!(first.agent_dirs.len(), second.agent_dirs.len()); +} + +/// 准备阶段不写会话数据/登记:整个数据目录逐字节不变。 +#[tokio::test] +async fn prepare_new_writes_no_session_state() { + let tmp = TempDir::new().unwrap(); + let cwd = make_workspace_dir(&tmp, "workspace"); + let host = prepared_test_host(&tmp, None).await; + + let before = snapshot_tree(tmp.path()); + let prepared = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + assert!(!prepared.frozen_encoded.is_empty()); + assert_eq!( + before, + snapshot_tree(tmp.path()), + "准备阶段不得写会话数据或本机登记" + ); +} + +/// 启动目录一致时必须复用 host 已装配的配置源,不重读配置/插件。 +#[tokio::test] +async fn prepare_new_reuses_host_configuration_for_startup_directory() { + let tmp = TempDir::new().unwrap(); + let cwd = canonical_workspace_dir(&tmp, "workspace"); + let host = prepared_test_host( + &tmp, + Some(WorkspaceAssembly { + startup_cwd: cwd.clone(), + bare: true, + mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile::disabled(), + }), + ) + .await; + + let prepared = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + assert!( + Arc::ptr_eq(&prepared.config_source, &host.config_source), + "同一启动目录必须复用已装配配置源,不第二次 load_at" + ); + assert!( + prepared.plugin_data.is_none(), + "bare 工作区装配不加载插件聚合" + ); +} + +/// 普通 fork 复用 source 的精确 frozen 字节,不按当前日期/目录重冻。 +#[tokio::test] +async fn prepare_fork_reuses_source_snapshot_bytes() { + let tmp = TempDir::new().unwrap(); + let cwd = make_workspace_dir(&tmp, "workspace"); + let fork_cwd = make_workspace_dir(&tmp, "fork-workspace"); + let host = prepared_test_host(&tmp, None).await; + + let source = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + let forked = + PreparedSessionInputs::prepare_fork(&host, &fork_cwd, &source.frozen_encoded).unwrap(); + + assert_eq!( + forked.frozen_encoded, source.frozen_encoded, + "fork 必须保留 source 的精确 frozen 字节" + ); + assert_eq!(forked.frozen.date(), source.frozen.date()); + assert_eq!(forked.frozen.system_prompt(), source.frozen.system_prompt()); + assert_eq!(forked.cwd, fork_cwd); + + assert!( + PreparedSessionInputs::prepare_fork(&host, &fork_cwd, "{ not a snapshot").is_err(), + "损坏的 source 快照必须让准备失败,而不是带着空 frozen 继续" + ); +} + +/// legacy 准备按解析出的执行目录构建,并记录保存的绝对 cwd(登记事实)。 +#[tokio::test] +async fn prepare_legacy_records_saved_cwd_and_builds_from_workspace() { + let tmp = TempDir::new().unwrap(); + let registered = tmp.path().join("saved-workspace"); + std::fs::create_dir_all(®istered).unwrap(); + let registered_raw = registered.to_str().unwrap().to_owned(); + let workspace_cwd = canonical_workspace_dir(&tmp, "saved-workspace"); + let host = prepared_test_host(&tmp, None).await; + + let legacy = + PreparedSessionInputs::prepare_legacy(&host, ®istered_raw, &workspace_cwd).unwrap(); + + assert_eq!(legacy.cwd, workspace_cwd); + assert_eq!( + legacy.legacy.as_ref().unwrap().saved_cwd, + PathBuf::from(®istered_raw), + "legacy 必须记录保存的绝对 cwd(不是调用方终端的 cwd)" + ); + assert!(decode_frozen_snapshot(&legacy.frozen_encoded).is_ok()); +} + +/// new 路径的持久化属性:frozen 字节与发布到 live state 的冻结状态同源(一次写入), +/// binding 与 owner 同时成立。 +/// +/// 端到端只走生产入口(resolve → 准备一次 → 发布段),不拿第二次准备比字节:断言 +/// 的是同一次创建内部的自洽——持久化字节、live frozen 与本次工作区输入同源。 +#[tokio::test] +async fn new_session_persists_frozen_bytes_from_its_single_preparation() { + let tmp = TempDir::new().unwrap(); + let host = prepared_test_host(&tmp, None).await; + let cwd = canonical_workspace_dir(&tmp, "workspace"); + std::fs::write( + Path::new(&cwd).join("CLAUDE.md"), + FROZEN_INPUT_AT_PREPARATION, + ) + .unwrap(); + let mut sessions = std::collections::HashMap::new(); + + let response = super::requests::session_lifecycle::handle_new( + &serde_json::json!({ "cwd": cwd }), + &host, + &mut sessions, + ) + .await + .expect("session/new 必须经门面创建成功"); + + let id = response["sessionId"] + .as_str() + .expect("response carries sessionId") + .to_owned(); + assert!( + sessions[&id].execution_owner.is_some(), + "创建必须给出执行 owner" + ); + let snapshot = host + .session_resources + .load_session_snapshot(&id) + .await + .expect("created session must be readable through the facade"); + let persisted = match snapshot.frozen { + peri_acp_types::session_resources::FrozenState::Present(bytes) => bytes.into_string(), + other => panic!("frozen must be present after create_session: {other:?}"), + }; + let decoded = decode_frozen_snapshot(&persisted).expect("持久化字节必须可解码"); + assert_eq!( + decoded.claude_md(), + Some(FROZEN_INPUT_AT_PREPARATION), + "持久化字节必须来自本次工作区输入(准备期读到的 CLAUDE.md)" + ); + let live = sessions[&id] + .frozen + .clone() + .expect("创建必须发布 live frozen"); + assert_eq!( + encode_frozen_snapshot(&live).unwrap(), + persisted, + "发布到 live state 的 frozen 与持久化字节必须逐字节同源" + ); + assert!( + matches!( + snapshot.binding, + peri_acp_types::session_resources::BindingState::Bound(_) + ), + "创建必须写入不可变 binding" + ); +} + +/// 发布段只消费给定的准备对象:准备定格后改写外部输入,保存字节与 live 状态仍精确 +/// 等于准备时的字节。 +/// +/// `new_session_from_prepared` 就是 `handle_new` 准备之后的同一条生产路径。若将来有人 +/// 在这里再准备一次(重读配置/重建 frozen),`rebuilt` 哨兵与 `persisted` 断言都会 +/// 失败——本用例不靠「两次准备相等」证明同源。 +#[tokio::test] +async fn new_session_from_prepared_does_not_reread_external_frozen_inputs() { + let tmp = TempDir::new().unwrap(); + let host = prepared_test_host(&tmp, None).await; + let cwd = canonical_workspace_dir(&tmp, "workspace"); + let claude_md = Path::new(&cwd).join("CLAUDE.md"); + std::fs::write(&claude_md, FROZEN_INPUT_AT_PREPARATION).unwrap(); + + let prepared = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + assert_eq!( + prepared.frozen.claude_md(), + Some(FROZEN_INPUT_AT_PREPARATION), + "准备必须定格当时的外部输入" + ); + + // 准备之后改写外部输入:任何重读/重建都会给出不同字节。 + std::fs::write(&claude_md, FROZEN_INPUT_AFTER_PREPARATION).unwrap(); + let rebuilt = PreparedSessionInputs::prepare_new(&host, &cwd).unwrap(); + assert_eq!( + rebuilt.frozen.claude_md(), + Some(FROZEN_INPUT_AFTER_PREPARATION) + ); + assert_ne!( + rebuilt.frozen_encoded, prepared.frozen_encoded, + "哨兵:外部输入已变,二次准备必须产出不同字节——否则本用例无法证明未重读" + ); + + let workspace = host + .session_resources + .resolve_workspace(Path::new(&cwd)) + .await + .expect("工作区必须可解析"); + let mut sessions = std::collections::HashMap::new(); + let response = super::requests::session_lifecycle::new_session_from_prepared( + &host, + &workspace, + &prepared, + &mut sessions, + ) + .await + .expect("发布段必须用给定准备对象创建成功"); + + let id = response["sessionId"] + .as_str() + .expect("response carries sessionId") + .to_owned(); + assert!( + sessions[&id].execution_owner.is_some(), + "创建必须给出执行 owner" + ); + let snapshot = host + .session_resources + .load_session_snapshot(&id) + .await + .expect("created session must be readable through the facade"); + let persisted = match snapshot.frozen { + peri_acp_types::session_resources::FrozenState::Present(bytes) => bytes.into_string(), + other => panic!("frozen must be present after create_session: {other:?}"), + }; + assert_eq!( + persisted, prepared.frozen_encoded, + "保存字节必须精确等于给定准备输入的字节" + ); + let live = sessions[&id] + .frozen + .clone() + .expect("创建必须发布 live frozen"); + assert_eq!( + encode_frozen_snapshot(&live).unwrap(), + prepared.frozen_encoded, + "live frozen 必须与准备输入逐字节同源" + ); + assert_eq!( + live.claude_md(), + Some(FROZEN_INPUT_AT_PREPARATION), + "准备之后写入的外部内容不得进入发布的冻结状态" + ); +} diff --git a/peri-acp/src/host/prompt.rs b/peri-acp/src/host/prompt.rs index 87f1895f8..cbbf91874 100644 --- a/peri-acp/src/host/prompt.rs +++ b/peri-acp/src/host/prompt.rs @@ -216,7 +216,7 @@ pub(crate) async fn run_prompt( let skills = deployment.skills.clone(); let shared_tools = deployment.shared_tools.clone(); let plugin_lsp_servers = deployment.plugin_lsp_servers.as_slice(); - let thread_store = &deployment.thread_store; + let session_resources = deployment.session_resources.clone(); let controller = &deployment.controller; let langfuse_session = deployment.langfuse_session.clone(); let session_manager = deployment.session_manager.clone(); @@ -292,6 +292,7 @@ pub(crate) async fn run_prompt( incoming_recalls, workflow_middleware, lsp_pool, + execution_owner, ) = { let mut sessions = sessions.lock().await; let state = sessions @@ -312,6 +313,9 @@ pub(crate) async fn run_prompt( take_recall_for_turn(&mut state.recall_items, continuation && !managed_input), state.workflow_middleware.clone(), state.lsp_pool.clone(), + // 执行所有权投影:child 保存(save_child)需要调用方证明自己持有本会话 + // root 的活 owner;只读准入的会话为 None,那时不落任何 child。 + state.execution_owner.clone(), ) }; let broker = build_transport_broker(transport, &session_id); @@ -368,7 +372,6 @@ pub(crate) async fn run_prompt( frozen_language: frozen .as_ref() .and_then(|f| f.language().map(|s| s.to_string())), - thread_store: None, progress_tx: None, subagent_ctx_builder: None, agent_prompt_builder: crate::host::workflow_agent::build_workflow_agent_prompt_builder( @@ -508,7 +511,8 @@ pub(crate) async fn run_prompt( session_access: Some( Arc::new(session_manager) as Arc ), - thread_store: Some(Arc::clone(thread_store)), + session_resources: Some(session_resources.clone()), + execution_owner, thread_id: Some(thread_id.clone()), plugin_skill_roots: plugin_skill_roots.to_vec(), plugin_agent_dirs: plugin_agent_dirs.to_vec(), diff --git a/peri-acp/src/host/requests.rs b/peri-acp/src/host/requests.rs index 4de0394be..dcb45a9ce 100644 --- a/peri-acp/src/host/requests.rs +++ b/peri-acp/src/host/requests.rs @@ -77,18 +77,13 @@ pub(crate) async fn handle_request( super::workspace::validate_expected(cfg, session_id.expect("checked"), None).await?; } // Renaming an unloaded session is a short mutation lease; never steals a live owner. - let transient_owner = - if method == "session/rename" && session_id.is_some_and(|id| !sessions.contains_key(id)) { - Some( - cfg.controller - .sessions() - .acquire_execution_lease(&session_id.expect("checked").to_owned()) - .await - .map_err(super::workspace::workspace_error)?, - ) - } else { - None - }; + let transient_owner = if method == "session/rename" + && session_id.is_some_and(|id| !sessions.contains_key(id)) + { + Some(super::workspace::acquire_transient_owner(cfg, session_id.expect("checked")).await?) + } else { + None + }; let result = match method { "initialize" => session_lifecycle::handle_initialize(params, cfg), "session/new" => session_lifecycle::handle_new(params, cfg, sessions).await, diff --git a/peri-acp/src/host/requests/legacy_session.rs b/peri-acp/src/host/requests/legacy_session.rs index 945daf647..69710d2fe 100644 --- a/peri-acp/src/host/requests/legacy_session.rs +++ b/peri-acp/src/host/requests/legacy_session.rs @@ -1,13 +1,15 @@ //! On-demand compatibility for unbound roots. Listing and history replay do not enter here. use peri_acp_types::{ + session_resources::{BindingState, FrozenSnapshotBytes, FrozenState}, thread::ThreadMeta, workspace::{ResolvedWorkspace, WorkspaceError}, }; use std::path::Path; -use super::{decode_frozen_snapshot, encode_frozen_snapshot, AcpError, AcpServerConfig}; -use crate::host::workspace::workspace_error; +use super::{decode_frozen_snapshot, AcpError, AcpServerConfig}; +use crate::host::prepared::PreparedSessionInputs; +use crate::host::workspace::{resource_error, workspace_error}; pub(super) async fn resolve_saved_workspace( cfg: &AcpServerConfig, @@ -19,57 +21,89 @@ pub(super) async fn resolve_saved_workspace( if !Path::new(&meta.cwd).is_absolute() { return Err(workspace_error(WorkspaceError::Unavailable)); } - cfg.controller - .sessions() + cfg.session_resources .resolve_workspace(Path::new(&meta.cwd)) .await - .map_err(workspace_error) + .map_err(resource_error) } +/// 恢复前的 legacy 准备:已绑定会话返回 `None`。 +/// +/// 绑定分类与 frozen 状态来自门面的一次一致读取:只有本机确认的 legacy +/// ([`BindingState::LegacyConfirmed`])才进入接纳,外来登记、缺登记与损坏不会被 +/// 解释成「没有 legacy」。无 frozen 的 legacy 会话按保存的绝对 cwd 只读定格一份完整 +/// 准备输入——插件发现走严格只读入口,不生成合成清单、不写插件缓存。 pub(super) async fn prepare_for_restore( cfg: &AcpServerConfig, session_id: &str, expected_cwd: Option<&str>, -) -> Result<(), AcpError> { - let store = cfg.controller.sessions(); +) -> Result, AcpError> { + let resources = cfg.session_resources.clone(); let id = session_id.to_owned(); - // Errors and unsupported versions are never interpreted as legacy absence. - if store - .load_session_binding(&id) + let mut snapshot = resources + .load_session_snapshot(&id) .await - .map_err(workspace_error)? - .is_some() - { - return Ok(()); - } - let meta = store.load_meta(&id).await.map_err(workspace_error)?; - let workspace = resolve_saved_workspace(cfg, &meta).await?; + .map_err(resource_error)?; + let meta = snapshot.meta.clone(); + // 本机 legacy 的证据是「保存的绝对 cwd 落在已登记 workspace 之下」。新库/新节点 + // 上该目录尚未登记,Missing 因此先按保存路径解析登记一次再复判——不把「尚未登记」 + // 直接当成本机 legacy,已绑定会话也不为这一轮多跑发现。 + let workspace = match snapshot.binding { + BindingState::Bound(_) => return Ok(None), + BindingState::LegacyConfirmed => resolve_saved_workspace(cfg, &meta).await?, + BindingState::Missing => { + let workspace = resolve_saved_workspace(cfg, &meta).await?; + snapshot = resources + .load_session_snapshot(&id) + .await + .map_err(resource_error)?; + if !matches!(snapshot.binding, BindingState::LegacyConfirmed) { + return Err(workspace_error(WorkspaceError::Unavailable)); + } + workspace + } + // 外来会话或本机登记缺失:历史可读,但不能按 legacy 自动接纳。 + BindingState::ExternalOrUnregistered => { + return Err(workspace_error(WorkspaceError::Unavailable)) + } + }; if let Some(expected) = expected_cwd { // 与绑定比对的是「同一目录」,不需要为此再解析登记(那会多跑一轮完整发现)。 crate::host::workspace::expect_directory(expected, &workspace).await?; } - let snapshot = match store - .load_frozen_snapshot(&id) - .await - .map_err(workspace_error)? - { - Some(snapshot) => { - decode_frozen_snapshot(&snapshot).map_err(workspace_error)?; - snapshot + let (frozen, prepared) = match snapshot.frozen { + FrozenState::Present(bytes) => { + decode_frozen_snapshot(bytes.as_str()).map_err(workspace_error)?; + (bytes, None) } - None => { + FrozenState::LegacyAbsent => { // Legacy sessions never captured this state. Freeze from their saved cwd once, // as the pre-3.15 compatibility path did; never use the caller's terminal cwd. - let cwd = workspace.cwd.to_string_lossy().into_owned(); - let frozen = crate::host::assemble::build_legacy_frozen_data(cfg, &cwd) - .map_err(workspace_error)?; - encode_frozen_snapshot(&frozen).map_err(workspace_error)? + let workspace_cwd = workspace.cwd.to_string_lossy().into_owned(); + let prepared = PreparedSessionInputs::prepare_legacy(cfg, &meta.cwd, &workspace_cwd)?; + ( + FrozenSnapshotBytes::new(prepared.frozen_encoded.clone()), + Some(prepared), + ) + } + // 有快照但本构建读不懂:不是「缺失」,不能按 legacy 规则重冻覆盖既有字节。 + FrozenState::Unsupported => { + return Err(AcpError::new( + -32603, + "Session frozen snapshot is not readable by this build", + )) } }; - // One transaction publishes both pieces, so interruption cannot strand a bound - // session without its frozen state. Native bound sessions still fail on missing state. - store - .adopt_legacy_thread(&id, &meta.cwd, &workspace, &snapshot) + // 接纳在门面的一次行为里完成(binding 与缺失的 frozen 一起成立);登记的保存目录 + // 来自准备输入(legacy 只按保存的绝对 cwd 记录事实)。 + let recorded_cwd = prepared + .as_ref() + .and_then(|inputs| inputs.legacy.as_ref()) + .map(|legacy| legacy.saved_cwd.to_string_lossy().into_owned()) + .unwrap_or_else(|| meta.cwd.clone()); + resources + .adopt_legacy_session(&id, &recorded_cwd, &workspace, &frozen) .await - .map_err(workspace_error) + .map_err(resource_error)?; + Ok(prepared) } diff --git a/peri-acp/src/host/requests/rewind.rs b/peri-acp/src/host/requests/rewind.rs index 818f6ad02..0daac64e3 100644 --- a/peri-acp/src/host/requests/rewind.rs +++ b/peri-acp/src/host/requests/rewind.rs @@ -54,11 +54,13 @@ fn apply_canonical_rewind( history: &mut Vec, target_id: peri_acp_types::messages::MessageId, ) -> Result<(), AcpError> { - let target_payload_idx = history_payloads - .iter() - .position(|payload| payload.id() == target_id) - .ok_or_else(|| AcpError::new(-32603, "rewind target missing from canonical history"))?; - history_payloads.truncate(target_payload_idx); + // 用户 rewind 移除目标本身及以后(与 transcript 的 KeepThrough 语义不同)。 + let rewound = peri_acp_types::store::history::apply_rewind( + history_payloads, + peri_acp_types::session_resources::RewindBoundary::RemoveFrom(target_id), + ) + .map_err(|_| AcpError::new(-32603, "rewind target missing from canonical history"))?; + *history_payloads = rewound.kept; *history = history_payloads .iter() .filter_map(|payload| payload.as_message().cloned()) diff --git a/peri-acp/src/host/requests/session_lifecycle.rs b/peri-acp/src/host/requests/session_lifecycle.rs index c7297ef1c..a114d958d 100644 --- a/peri-acp/src/host/requests/session_lifecycle.rs +++ b/peri-acp/src/host/requests/session_lifecycle.rs @@ -10,8 +10,12 @@ use agent_client_protocol::schema::v1::{ LoadSessionResponse, NewSessionResponse, ResumeSessionResponse, SessionId, SessionNotification, }; use peri_acp_types::ports::WorkflowMiddlewarePort; -use peri_acp_types::thread::ThreadMeta; -use peri_acp_types::workspace::ReadOnlyAdmission; +use peri_acp_types::session_resources::{ + BindingRecheck, BindingState, FrozenSnapshotBytes, FrozenState, NewSession, NewSessionMeta, + SessionMetaPatch, +}; +use peri_acp_types::thread::{CancelPolicy, ThreadId}; +use peri_acp_types::workspace::{ReadOnlyAdmission, ResolvedWorkspace, SessionBinding}; use peri_acp_types::PeriCaps; use serde_json::Value; use tracing::{info, warn}; @@ -21,69 +25,49 @@ use super::super::workspace::BindingCheck; use super::super::{build_mode_state, AcpServerConfig, SessionState}; use crate::dispatch::config_update::make_config_options; use crate::dispatch::ReplaySender; -use crate::session::frozen_snapshot::{decode_frozen_snapshot, encode_frozen_snapshot}; +use crate::session::frozen_snapshot::decode_frozen_snapshot; use crate::{dispatch, transport::types::AcpError}; #[path = "legacy_session.rs"] mod legacy_session; -async fn store_frozen_snapshot( - cfg: &AcpServerConfig, - session_id: &str, - frozen_data: &crate::session::executor::FrozenSessionData, -) -> Result { - let snapshot = encode_frozen_snapshot(frozen_data).map_err(|error| { - AcpError::new(-32603, format!("Frozen snapshot encode failed: {error}")) - })?; - cfg.thread_store - .store_frozen_snapshot_if_absent(&session_id.to_string(), &snapshot) - .await - .map_err(|error| AcpError::new(-32603, format!("Frozen snapshot store failed: {error}"))) -} - -async fn store_new_frozen_snapshot_or_compensate( - cfg: &AcpServerConfig, - session_id: &str, - frozen_data: &crate::session::executor::FrozenSessionData, -) -> Result<(), AcpError> { - let stored = store_frozen_snapshot(cfg, session_id, frozen_data).await; - if !matches!(stored, Ok(true)) { - let error = match stored { - Ok(false) => AcpError::new( - -32603, - format!("Frozen snapshot already exists for new session: {session_id}"), - ), - Err(error) => error, - Ok(true) => unreachable!(), - }; - if let Err(cleanup_error) = cfg - .thread_store - .delete_thread(&session_id.to_string()) - .await - { - warn!( - session_id, - error = %cleanup_error, - "failed to compensate thread after frozen snapshot store failure" - ); - } - return Err(error); +/// fork source 读取/保存失败 → ACP 错误。 +/// +/// 门面失败保留领域分类(含只读准入与未决持久化载荷);领域与 IO 失败按 +/// workspace 语义上报,不把门面错误降级成「存储不可用」。 +fn fork_source_error(error: anyhow::Error) -> AcpError { + match error.downcast::() { + Ok(error) => super::super::workspace::resource_error(error), + Err(error) => super::super::workspace::workspace_error(error), } - Ok(()) } +/// 读取并解码 bound 会话的 frozen 数据。 +/// +/// 快照缺 frozen 是错误(bound 会话必须有),本构建读不懂也是错误——两者都不是 +/// 「没有 frozen,可以重建」:重建会把已发布的冻结输入换成当前目录/日期。 async fn load_frozen_data( cfg: &AcpServerConfig, session_id: &str, ) -> Result { let snapshot = cfg - .controller - .sessions() - .load_frozen_snapshot(&session_id.to_owned()) + .session_resources + .load_session_snapshot(&session_id.to_owned()) .await - .map_err(super::super::workspace::workspace_error)? - .ok_or_else(|| AcpError::new(-32603, "Bound session has no frozen snapshot"))?; - decode_frozen_snapshot(&snapshot).map_err(super::super::workspace::workspace_error) + .map_err(super::super::workspace::resource_error)?; + match snapshot.frozen { + FrozenState::Present(bytes) => { + decode_frozen_snapshot(bytes.as_str()).map_err(super::super::workspace::workspace_error) + } + FrozenState::LegacyAbsent => Err(AcpError::new( + -32603, + "Bound session has no frozen snapshot", + )), + FrozenState::Unsupported => Err(AcpError::new( + -32603, + "Session frozen snapshot is not readable by this build", + )), + } } /// 一次恢复准入的结果。 @@ -104,7 +88,12 @@ async fn prepare_existing( .get("sessionId") .and_then(Value::as_str) .ok_or_else(|| AcpError::new(-32602, "missing sessionId"))?; - legacy_session::prepare_for_restore(cfg, id, params.get("cwd").and_then(Value::as_str)).await?; + // legacy 无 frozen 的恢复:准备阶段按保存的 cwd 只读定格(严格只读插件、 + // 不生成合成清单),装配消费同一份输入;其余情况返回 None,装配按既有 + // 入口准备(下一批统一为单一 prepared 路径)。 + let legacy_prepared = + legacy_session::prepare_for_restore(cfg, id, params.get("cwd").and_then(Value::as_str)) + .await?; let admission = super::super::workspace::acquire_for_load( cfg, sessions, @@ -173,8 +162,17 @@ async fn prepare_existing( let (frozen, environment, workflow_middleware, lsp_pool) = match owner.as_ref() { Some(_) => { let frozen = load_frozen_data(cfg, id).await?; - let environment = - super::super::workspace::SessionEnvironment::assemble(cfg, &cwd, id).await?; + let environment = match legacy_prepared.as_ref() { + Some(inputs) => { + super::super::workspace::SessionEnvironment::assemble_prepared( + cfg, inputs, id, + ) + .await? + } + None => { + super::super::workspace::SessionEnvironment::assemble(cfg, &cwd, id).await? + } + }; let local = environment.as_ref().map(|env| &env.cfg).unwrap_or(cfg); let workflow_middleware = create_session_workflow_middleware(local, &cwd, id, &frozen); @@ -273,43 +271,12 @@ async fn response_identity( } } -pub(super) fn retain_failed_assembly( - sessions: &mut HashMap, - id: &str, - cwd: &str, - owner: Arc, - environment: Arc, -) { - sessions.insert( - id.to_owned(), - SessionState { - session_id: id.to_owned(), - thread_id: id.to_owned(), - cwd: cwd.to_owned(), - execution_owner: Some(owner), - environment: Some(environment), - closing: true, - history: Vec::new(), - history_payloads: Vec::new(), - cancel_token: None, - frozen: None, - recall_items: Vec::new(), - agent_pool: crate::session::agent_pool::AgentPool::new(), - workflow_middleware: None, - lsp_pool: None, - title: None, - tags: Vec::new(), - continuation_armed: false, - continuation_epoch: 0, - continuation_in_flight: false, - continuation_mq_steering_pending: false, - lease: super::super::lease::WriterLease::acquired("default"), - }, - ); -} - /// 绑定复核强度见 [`BindingCheck`](super::super::workspace::BindingCheck): /// 协议读请求复核完整发现快照,同一次准入内的身份读取只复核已记录证据。 +/// +/// 绑定分类走门面的轻量投影(不拉全历史);本机无法验证的绑定 +/// ([`BindingState::ExternalOrUnregistered`])不冒充「没有绑定」,其执行目录由 +/// 保存路径解析给出,执行准入另经 `check_expected` 复核。 async fn context_for_session(cfg: &AcpServerConfig, session_id: &str) -> Result { session_context_payload(cfg, session_id, BindingCheck::Full).await } @@ -324,22 +291,29 @@ async fn session_context_payload( session_id: &str, check: BindingCheck, ) -> Result { - let store = cfg.controller.sessions(); - let binding = store - .load_session_binding(&session_id.to_owned()) + let resources = cfg.controller.sessions(); + let id = ThreadId::from(session_id.to_owned()); + let state = resources + .load_session_binding(&id) .await - .map_err(super::super::workspace::workspace_error)?; - let meta = store - .load_meta(&session_id.to_owned()) + .map_err(super::super::workspace::resource_error)?; + let meta = resources + .load_session_meta(&id) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; + let binding = match &state { + BindingState::Bound(binding) => Some(binding.clone()), + _ => None, + }; let workspace = if binding.is_some() { - let id = session_id.to_owned(); - match check { - BindingCheck::Full => store.validate_session_binding(&id).await, - BindingCheck::Recorded => store.reassert_session_binding(&id).await, - } - .map_err(super::super::workspace::workspace_error)? + let recheck = match check { + BindingCheck::Full => BindingRecheck::Full, + BindingCheck::Recorded => BindingRecheck::Recorded, + }; + resources + .validate_bound_workspace(&id, recheck) + .await + .map_err(super::super::workspace::resource_error)? } else { // Resolve the saved location for a restore request; context reads never adopt it. legacy_session::resolve_saved_workspace(cfg, &meta).await? @@ -370,11 +344,10 @@ pub(crate) async fn handle_context( (Some(id), None) => context_for_session(cfg, id).await, (None, Some(cwd)) => { let workspace = cfg - .controller - .sessions() + .session_resources .resolve_workspace(std::path::Path::new(cwd)) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; Ok(serde_json::json!({ "version": 1, "workspace": workspace })) } _ => Err(AcpError::new( @@ -404,11 +377,13 @@ pub(crate) async fn handle_metadata( .and_then(Value::as_str) .ok_or_else(|| AcpError::new(-32602, "missing sessionId"))? .to_owned(); - let store = cfg.controller.sessions(); - let meta = store - .load_meta(&id) + // 轻量 metadata 投影走门面(不拉全历史);`history=true` 的历史回放经门面的 + // 完整逻辑上下文读取(继承区 + 自有 payload),不再由协议面拼祖先链。 + let meta = cfg + .session_resources + .load_session_meta(&id) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; let mut response = serde_json::json!({ "sessionId": id, "title": meta.title, "cwd": meta.cwd, "permissionMode": build_mode_state(&cfg.permission_mode).current_mode_id.to_string(), "modelAlias": cfg.peri_config.read().config.active_alias }); { let provider = cfg.provider.read(); @@ -426,10 +401,12 @@ pub(crate) async fn handle_metadata( .map(|profile| profile.provider.clone())); } if history { - let payloads = store - .load_context_payloads(&id) + let payloads = cfg + .controller + .sessions() + .load_session_history(&id) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; response["payloads"] = Value::Array( payloads .iter() @@ -440,13 +417,18 @@ pub(crate) async fn handle_metadata( .collect::, _>>() .map_err(super::super::workspace::workspace_error)?, ); - response["binding"] = serde_json::to_value( - store - .load_session_binding(&id) - .await - .map_err(super::super::workspace::workspace_error)?, - ) - .map_err(super::super::workspace::workspace_error)?; + let state = cfg + .controller + .sessions() + .load_session_binding(&id) + .await + .map_err(super::super::workspace::resource_error)?; + let binding = match &state { + BindingState::Bound(binding) => Some(binding.clone()), + _ => None, + }; + response["binding"] = + serde_json::to_value(binding).map_err(|e| AcpError::new(-32603, e.to_string()))?; } Ok(response) } @@ -522,103 +504,105 @@ pub(crate) async fn handle_new( cfg: &AcpServerConfig, sessions: &mut HashMap, ) -> Result { - let store = cfg.controller.sessions(); let requested = params.get("cwd").and_then(Value::as_str).unwrap_or("."); - let workspace = store + let workspace = cfg + .session_resources .resolve_workspace(std::path::Path::new(requested)) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; let cwd = workspace .cwd .to_str() .ok_or_else(|| AcpError::new(-32602, "Execution directory is not UTF-8"))? .to_owned(); - let thread_id = store - .create_bound_thread(ThreadMeta::new(&cwd), &workspace) - .await - .map_err(super::super::workspace::workspace_error)?; - let session_id = thread_id.clone(); - let owner = store - .acquire_execution_lease(&thread_id) + // 只读准备(lease 之前):定格配置/插件/frozen,不创建 thread、不占 lease、 + // 不启动 MCP/LSP/hooks,也不写任何会话数据或本机登记。new 路径只在这里准备 + // 一次,发布段消费同一个准备对象。 + let prepared = super::super::prepared::PreparedSessionInputs::prepare_new(cfg, &cwd)?; + new_session_from_prepared(cfg, &workspace, &prepared, sessions).await +} + +/// `session/new` 的发布段:消费**已定格**的准备输入,一次写出 meta/binding/frozen +/// 并取得执行 owner,随后复核准入、装配环境、发布 live 状态。 +/// +/// 本函数不读配置、不加载插件、不重建 frozen:保存字节与 live 状态都取自调用方 +/// 给定的 `prepared`。测试以自己定格的准备对象直接驱动本函数,因此「保存字节 == +/// 给定的 frozen 字节」是可断言的;准备之后外部输入若被改写,任何在这里重读或 +/// 重建的实现都会产出不同字节而使断言失败。 +pub(crate) async fn new_session_from_prepared( + cfg: &AcpServerConfig, + workspace: &ResolvedWorkspace, + prepared: &super::super::prepared::PreparedSessionInputs, + sessions: &mut HashMap, +) -> Result { + let resources = cfg.session_resources.clone(); + let cwd = prepared.cwd.clone(); + // 身份一次生成:meta/binding/frozen 保存、执行代际与 owner 由门面在一次创建内 + // 完成——ACP 不再分步拼 create/lease/frozen,也不做存储补偿。数据已保存但准入 + // 失败(saved_but_not_admitted)原样上报,不谎称「确定未创建」。 + let session_id = uuid::Uuid::now_v7().to_string(); + let owner = resources + .create_session(&NewSession { + thread_id: session_id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: None, + cwd: cwd.clone(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding::from_workspace(workspace), + frozen: FrozenSnapshotBytes::new(prepared.frozen_encoded.clone()), + }) .await - .map_err(super::super::workspace::workspace_error)?; - if let Err(error) = - super::super::workspace::reassert_expected(cfg, &thread_id, Some(&cwd)).await - { - store - .delete_thread(&thread_id) + .map_err(super::super::workspace::resource_error)?; + let thread_id = session_id.clone(); + // 创建后的同一次准入复核:与创建事务写入的绑定比对已记录证据。 + if let Err(error) = resources.validate_session(&session_id, workspace).await { + resources + .abandon_initialization(&session_id, &owner) .await - .map_err(super::super::workspace::workspace_error)?; - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; - return Err(error); + .map_err(super::super::workspace::resource_error)?; + return Err(super::super::workspace::resource_error(error)); } let identity = match response_identity(cfg, &session_id).await { Ok(identity) => identity, Err(error) => { - store - .delete_thread(&thread_id) + resources + .abandon_initialization(&session_id, &owner) .await - .map_err(super::super::workspace::workspace_error)?; - owner - .mark_clean() + .map_err(super::super::workspace::resource_error)?; + return Err(error); + } + }; + // 装配失败时环境尚未建立(没有对外资源需要排空),撤销未发布的创建即可。 + let environment = match super::super::workspace::SessionEnvironment::assemble_prepared( + cfg, + prepared, + &session_id, + ) + .await + { + Ok(environment) => environment, + Err(error) => { + resources + .abandon_initialization(&session_id, &owner) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; return Err(error); } }; - let environment = - match super::super::workspace::SessionEnvironment::assemble(cfg, &cwd, &session_id).await { - Ok(environment) => environment, - Err(error) => { - store - .delete_thread(&thread_id) - .await - .map_err(super::super::workspace::workspace_error)?; - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; - return Err(error); - } - }; let cfg = environment.as_ref().map(|env| &env.cfg).unwrap_or(cfg); // ── Freeze system prompt data at session creation ── // 通过 SessionManager 统一构造路径,并登记 AcpSession 记录以支撑 // cascade cancel 子 agent 与 goal_state(见 SessionManager::ensure_session)。 // GAP-05: frozen data 在 WorkflowMiddleware 创建前构建,注入到 executor。 - let frozen_data = cfg.session_manager.build_frozen_data( - &cwd, - &cfg.plugin_skill_roots, - &cfg.plugin_agent_dirs, - ); - if let Err(error) = - store_new_frozen_snapshot_or_compensate(cfg, &session_id, &frozen_data).await - { - if let Some(environment) = environment.as_ref() { - if !environment.shutdown().await { - retain_failed_assembly( - sessions, - &session_id, - &cwd, - owner.clone(), - environment.clone(), - ); - return Err(AcpError::new( - -32010, - "Session assembly cleanup incomplete; resources retained for shutdown retry", - )); - } - } - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; - return Err(error); - } + // frozen 与准备阶段同源:内容与字节都来自同一 PreparedSessionInputs + // (日期/运行环境/配置/插件 roots 均为准备阶段定格的那一份),不二次构建。 + let frozen_data = prepared.frozen.clone(); cfg.session_manager.ensure_session(&session_id, &cwd); // Create session-scoped WorkflowMiddleware at session/new (GAP-05: inject frozen data) @@ -720,13 +704,19 @@ pub(crate) async fn handle_reset_dirty( if !request.accept_risk { return Err(AcpError::new(-32602, "explicit risk acceptance required")); } - cfg.thread_store - .reset_dirty_execution(&request.target) + // 解除精确代际由门面统一承担:只解除本机 dirty,永不解除未决持久化。 + cfg.session_resources + .reset_dirty_execution(&request) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; Ok(serde_json::json!({})) } +// 曾有的「会话存储登记」两条 RPC(`peri/session_store_status` / +// `peri/session_register_store`)按用户裁决撤销:不再有本机登记、准入裁决与跨安装 +// 来源判定,配置里指到哪个 store 就直接用哪个。历史见 +// `docs/design/peri-acp-protocol.md`。 + pub(crate) async fn handle_load( params: &Value, cfg: &AcpServerConfig, @@ -831,15 +821,14 @@ pub(crate) async fn handle_list(params: &Value, cfg: &AcpServerConfig) -> Result .unwrap_or(100) .clamp(1, 500) as u32; let page = cfg - .controller - .sessions() - .list_scoped_threads(&peri_acp_types::workspace::ScopedThreadQuery { + .session_resources + .list_sessions(&peri_acp_types::workspace::ScopedThreadQuery { scope, cursor, limit, }) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; let entries = page .entries .iter() @@ -940,53 +929,58 @@ async fn close_owned_session( sessions.remove(session_id); return Ok(()); }; + // 执行资源已排空(环境 shutdown 成功)后,才请求门面结清持久化并按需删除: + // 排空确认在前、结清在最后,任一未完成都保持 Closing 与唯一 owner。 + let resources = &cfg.session_resources; + let target = session_id.to_owned(); + resources + .drain_persistence(&target) + .await + .map_err(super::super::workspace::resource_error)?; if delete { - cfg.controller - .sessions() - .delete_thread(&session_id.to_owned()) + // 删除是完整生命周期行为:数据、本机执行代际与本次持有都被门面在同一步 + // 结束(删除成功后 owner 已释放),因此这里不再重复收尾。 + resources + .delete_session_tree(&target) + .await + .map_err(super::super::workspace::resource_error)?; + } else { + owner + .mark_clean() .await .map_err(super::super::workspace::workspace_error)?; } - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; sessions.remove(session_id); } else if delete { - let store = cfg.controller.sessions(); - if store - .load_session_binding(&session_id.to_owned()) - .await - .map_err(super::super::workspace::workspace_error)? - .is_none() - { - // Missing delete remains idempotent; unresolved rows are never mutated. - let exists = store - .list_threads() - .await - .map_err(super::super::workspace::workspace_error)? - .iter() - .any(|thread| thread.id == session_id); - if !exists { + // Missing delete remains idempotent;未加载会话不因此变成可删除对象, + // 删除仍要求本次取得执行所有权(短时准入,不抢夺活 owner)。 + let resources = &cfg.session_resources; + let target = session_id.to_owned(); + match resources.load_session_meta(&target).await { + Ok(_) => { + let owner = + super::super::workspace::acquire_transient_owner(cfg, session_id).await?; + let result = resources.delete_session_tree(&target).await; + if result.is_err() { + // 删除失败:数据仍在,本次为删除临时取得的所有权按正常收尾放下 + // (代际行还在,走 clean CAS);成功时所有权已随删除结束。 + owner + .mark_clean() + .await + .map_err(super::super::workspace::workspace_error)?; + } + result.map_err(super::super::workspace::resource_error)?; + } + Err(error) + if matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + ) => + { return Ok(()); } + Err(error) => return Err(super::super::workspace::resource_error(error)), } - let owner = cfg - .controller - .sessions() - .acquire_execution_lease(&session_id.to_owned()) - .await - .map_err(super::super::workspace::workspace_error)?; - let result = cfg - .controller - .sessions() - .delete_thread(&session_id.to_owned()) - .await; - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; - result.map_err(super::super::workspace::workspace_error)?; } Ok(()) } @@ -1089,81 +1083,68 @@ pub(crate) async fn handle_fork( "Cannot fork while source execution is active", )); } - let source_frozen = source - .frozen - .clone() - .ok_or_else(|| AcpError::new(-32603, "Source frozen snapshot is missing"))?; + if source.frozen.is_none() { + return Err(AcpError::new(-32603, "Source frozen snapshot is missing")); + } let cwd_owned = workspace .cwd .to_str() .ok_or_else(|| AcpError::new(-32602, "Execution directory is not UTF-8"))? .to_owned(); let cwd = cwd_owned.as_str(); - let (new_thread_id, copied_payloads, owner) = - dispatch::fork_bound_session(cfg.controller.as_ref(), source_id, &workspace) - .await - .map_err(super::super::workspace::workspace_error)?; + // 一致 source 快照:payload/flags/binding/frozen 一次读出,ID 重映射是领域纯函数, + // 目标快照由门面一次保存(不逐条写 flags、不做存储补偿)。 + let fork_source = dispatch::load_fork_source(&cfg.session_resources, source_id) + .await + .map_err(fork_source_error)?; + // 普通 fork 复用 source 已持久化的精确 frozen 字节(内存对象只是同一次保存的 + // 解码视图),不按当前日期/目录重冻。frozen 与本次保存同源:字节来自 source + // 快照,装配消费 `prepared` 的解码视图,不二次构建。 + let prepared = super::super::prepared::PreparedSessionInputs::prepare_fork( + cfg, + cwd, + fork_source.frozen.as_str(), + )?; + let frozen_data = prepared.frozen.clone(); + let (new_thread_id, copied_payloads, owner) = dispatch::fork_bound_session( + &cfg.session_resources, + &fork_source, + &workspace, + chrono::Utc::now().to_rfc3339(), + ) + .await + .map_err(fork_source_error)?; let identity = match response_identity(cfg, &new_thread_id).await { Ok(identity) => identity, Err(error) => { - cfg.thread_store - .delete_thread(&new_thread_id) + // identity 装配失败:环境尚未建立,撤销本次未发布的创建即可。 + cfg.session_resources + .abandon_initialization(&new_thread_id, &owner) .await - .map_err(super::super::workspace::workspace_error)?; - owner - .mark_clean() + .map_err(super::super::workspace::resource_error)?; + return Err(error); + } + }; + let environment = match super::super::workspace::SessionEnvironment::assemble_prepared( + cfg, + &prepared, + &new_thread_id, + ) + .await + { + Ok(environment) => environment, + Err(error) => { + // 装配失败时环境尚未建立(没有对外资源需要排空):撤销未发布的创建。 + cfg.session_resources + .abandon_initialization(&new_thread_id, &owner) .await - .map_err(super::super::workspace::workspace_error)?; + .map_err(super::super::workspace::resource_error)?; return Err(error); } }; - let environment = - match super::super::workspace::SessionEnvironment::assemble(cfg, cwd, &new_thread_id).await - { - Ok(environment) => environment, - Err(error) => { - cfg.thread_store - .delete_thread(&new_thread_id) - .await - .map_err(super::super::workspace::workspace_error)?; - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; - return Err(error); - } - }; let cfg = environment.as_ref().map(|env| &env.cfg).unwrap_or(cfg); let new_session_id = new_thread_id.clone(); - - // Fork inherits the source session's exact frozen prefix. Rebuilding from the - // current environment would invalidate the provider cache on its first turn. - let frozen_data = source_frozen; - if let Err(error) = - store_new_frozen_snapshot_or_compensate(cfg, &new_session_id, &frozen_data).await - { - if let Some(environment) = environment.as_ref() { - if !environment.shutdown().await { - retain_failed_assembly( - sessions, - &new_session_id, - cwd, - owner.clone(), - environment.clone(), - ); - return Err(AcpError::new( - -32010, - "Fork cleanup incomplete; resources retained for shutdown retry", - )); - } - } - owner - .mark_clean() - .await - .map_err(super::super::workspace::workspace_error)?; - return Err(error); - } cfg.session_manager.ensure_session(&new_session_id, cwd); let caps = cfg.session_manager.ensure_session_caps(&new_session_id); let workflow_middleware = @@ -1238,10 +1219,17 @@ pub(super) async fn handle_rename( .and_then(|v| v.as_str()) .ok_or_else(|| AcpError::new(-32602, "missing title"))?; - cfg.thread_store - .update_title(&session_id.to_string(), title) + // 定向更新:只改标题,不整份覆盖 metadata(cwd/binding/计数/缓存的持有者是门面)。 + cfg.session_resources + .update_session_meta( + &session_id.to_owned(), + &SessionMetaPatch { + title: Some(Some(title.to_owned())), + ..Default::default() + }, + ) .await - .map_err(|e| AcpError::new(-32603, format!("Failed to rename session: {e}")))?; + .map_err(super::super::workspace::resource_error)?; // 通过 session/update 通知推送新的标题给外部客户端 super::super::notify::send_session_info_update_with_title( diff --git a/peri-acp/src/host/requests_legacy_test.rs b/peri-acp/src/host/requests_legacy_test.rs index f47ed9fe1..3a9b9cd84 100644 --- a/peri-acp/src/host/requests_legacy_test.rs +++ b/peri-acp/src/host/requests_legacy_test.rs @@ -1,12 +1,15 @@ use super::*; -async fn old_thread(cfg: &AcpServerConfig, cwd: &Path) -> String { - let id = cfg - .thread_store +/// legacy 原始事实:只有 thread 行与消息,**没有 binding、没有 frozen**。 +/// +/// 用裸句柄按原表构造——门面没有「无 binding 无 frozen」的创建入口;`bridge` 与 `cfg` +/// 的门面出自同一次打开(同一库句柄),后续经协议/门面读到的是同一份事实。 +async fn old_thread(bridge: &SqliteThreadStore, cwd: &Path) -> String { + let id = bridge .create_thread(ThreadMeta::new(cwd.to_str().unwrap())) .await .unwrap(); - cfg.thread_store + bridge .append_message( &id, peri_acp_types::messages::BaseMessage::human("legacy user message"), @@ -28,13 +31,13 @@ async fn legacy_history_context_then_load_restores_saved_cwd_and_frozen_snapshot std::fs::write(cwd.join("CLAUDE.md"), "LEGACY_PROJECT_INSTRUCTION").unwrap(); let config = make_peri_config_with_provider(make_provider_config("test", "openai", "test", "model")); - let cfg = make_server_config( + let (cfg, bridge) = make_server_config_with_bridge( config.clone(), LlmProvider::from_config(&config).unwrap(), &tmp, ) .await; - let id = old_thread(&cfg, &cwd).await; + let id = old_thread(&bridge, &cwd).await; let transport: Arc = Arc::new(MockTransport::default()); let mut sessions = HashMap::new(); let context = handle_request( @@ -48,18 +51,8 @@ async fn legacy_history_context_then_load_restores_saved_cwd_and_frozen_snapshot .unwrap(); assert_eq!(context["workspace"]["cwd"], cwd.to_str().unwrap()); assert!(context["binding"].is_null()); - assert!(cfg - .thread_store - .load_session_binding(&id) - .await - .unwrap() - .is_none()); - assert!(cfg - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .is_none()); + assert!(bridge.load_session_binding(&id).await.unwrap().is_none()); + assert!(bridge.load_frozen_snapshot(&id).await.unwrap().is_none()); handle_request( "session/load", &json!({"sessionId":id,"cwd":cwd}), @@ -79,12 +72,7 @@ async fn legacy_history_context_then_load_restores_saved_cwd_and_frozen_snapshot .claude_md() .unwrap() .contains("LEGACY_PROJECT_INSTRUCTION")); - let frozen = cfg - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .unwrap(); + let frozen = bridge.load_frozen_snapshot(&id).await.unwrap().unwrap(); handle_request( "session/close", &json!({"sessionId":id}), @@ -105,11 +93,7 @@ async fn legacy_history_context_then_load_restores_saved_cwd_and_frozen_snapshot .await .unwrap(); assert_eq!( - cfg.thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .unwrap(), + bridge.load_frozen_snapshot(&id).await.unwrap().unwrap(), frozen ); let fork = handle_request( @@ -153,13 +137,13 @@ async fn legacy_history_missing_directory_is_readable_without_adoption() { let _home = HomeDirGuard::set(tmp.path()); let config = make_peri_config_with_provider(make_provider_config("test", "openai", "test", "model")); - let cfg = make_server_config( + let (cfg, bridge) = make_server_config_with_bridge( config.clone(), LlmProvider::from_config(&config).unwrap(), &tmp, ) .await; - let id = old_thread(&cfg, &tmp.path().join("removed")).await; + let id = old_thread(&bridge, &tmp.path().join("removed")).await; let transport: Arc = Arc::new(MockTransport::default()); let mut sessions = HashMap::new(); let response = handle_request( @@ -184,18 +168,8 @@ async fn legacy_history_missing_directory_is_readable_without_adoption() { .unwrap_err(); assert!(error.message.contains("unavailable"), "{}", error.message); assert!(sessions.is_empty()); - assert!(cfg - .thread_store - .load_session_binding(&id) - .await - .unwrap() - .is_none()); - assert!(cfg - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .is_none()); + assert!(bridge.load_session_binding(&id).await.unwrap().is_none()); + assert!(bridge.load_frozen_snapshot(&id).await.unwrap().is_none()); } #[tokio::test] @@ -210,13 +184,13 @@ async fn legacy_history_rejects_wrong_directory_and_bad_frozen_without_adoption( std::fs::create_dir(&other).unwrap(); let config = make_peri_config_with_provider(make_provider_config("test", "openai", "test", "model")); - let cfg = make_server_config( + let (cfg, bridge) = make_server_config_with_bridge( config.clone(), LlmProvider::from_config(&config).unwrap(), &tmp, ) .await; - let id = old_thread(&cfg, &saved).await; + let id = old_thread(&bridge, &saved).await; let transport: Arc = Arc::new(MockTransport::default()); let mut sessions = HashMap::new(); let error = handle_request( @@ -233,21 +207,11 @@ async fn legacy_history_rejects_wrong_directory_and_bad_frozen_without_adoption( "{}", error.message ); - assert!(cfg - .thread_store - .load_session_binding(&id) - .await - .unwrap() - .is_none()); - assert!(cfg - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .is_none()); + assert!(bridge.load_session_binding(&id).await.unwrap().is_none()); + assert!(bridge.load_frozen_snapshot(&id).await.unwrap().is_none()); for snapshot in ["broken", r#"{"version":999,"data":{}}"#] { - let id = old_thread(&cfg, &saved).await; - cfg.thread_store + let id = old_thread(&bridge, &saved).await; + bridge .store_frozen_snapshot_if_absent(&id, snapshot) .await .unwrap(); @@ -265,18 +229,9 @@ async fn legacy_history_rejects_wrong_directory_and_bad_frozen_without_adoption( "{}", error.message ); - assert!(cfg - .thread_store - .load_session_binding(&id) - .await - .unwrap() - .is_none()); + assert!(bridge.load_session_binding(&id).await.unwrap().is_none()); assert_eq!( - cfg.thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .as_deref(), + bridge.load_frozen_snapshot(&id).await.unwrap().as_deref(), Some(snapshot) ); } @@ -290,19 +245,15 @@ async fn legacy_history_fix_does_not_rebuild_missing_native_snapshot() { let _home = HomeDirGuard::set(tmp.path()); let config = make_peri_config_with_provider(make_provider_config("test", "openai", "test", "model")); - let cfg = make_server_config( + let (cfg, bridge) = make_server_config_with_bridge( config.clone(), LlmProvider::from_config(&config).unwrap(), &tmp, ) .await; - let workspace = cfg - .thread_store - .resolve_workspace(tmp.path()) - .await - .unwrap(); - let id = cfg - .thread_store + // 已绑定但**缺 frozen**:门面创建要求 frozen 成立,因此夹具按原表构造。 + let workspace = bridge.resolve_workspace(tmp.path()).await.unwrap(); + let id = bridge .create_bound_thread(ThreadMeta::new(workspace.cwd.to_str().unwrap()), &workspace) .await .unwrap(); @@ -318,14 +269,9 @@ async fn legacy_history_fix_does_not_rebuild_missing_native_snapshot() { .await .unwrap_err(); assert_eq!(error.message, "Bound session has no frozen snapshot"); - assert!(cfg - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .is_none()); + assert!(bridge.load_frozen_snapshot(&id).await.unwrap().is_none()); assert!(sessions.is_empty()); - cfg.thread_store + bridge .acquire_execution_lease(&id) .await .unwrap() @@ -360,7 +306,7 @@ async fn legacy_history_freezes_saved_workspace_configuration_and_plugins() { let mut config = make_peri_config_with_provider(make_provider_config("test", "openai", "test", "model")); config.config.language = Some("en".into()); - let mut cfg = make_server_config( + let (mut cfg, bridge) = make_server_config_with_bridge( config.clone(), LlmProvider::from_config(&config).unwrap(), &tmp, @@ -373,7 +319,7 @@ async fn legacy_history_freezes_saved_workspace_configuration_and_plugins() { mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile::disabled(), }); assert!(cfg.plugin_skill_roots.is_empty()); - let id = old_thread(&cfg, &target).await; + let id = old_thread(&bridge, &target).await; let transport: Arc = Arc::new(MockTransport::default()); let mut sessions = HashMap::new(); handle_request( diff --git a/peri-acp/src/host/requests_recovery_test.rs b/peri-acp/src/host/requests_recovery_test.rs index a715a73a0..f9ed4d05f 100644 --- a/peri-acp/src/host/requests_recovery_test.rs +++ b/peri-acp/src/host/requests_recovery_test.rs @@ -1,10 +1,41 @@ use super::*; +use peri_acp_types::session_resources::{BindingRecheck, SessionResourceError}; use peri_acp_types::workspace::{ ReadOnlyAdmission, RecoveryRequiredDetails, ResetDirtyRequest, WorkspaceError, WorkspaceErrorData, }; use peri_acp_types::PeriCaps; +/// 存储层「精确代际解除」:门面入口要求显式风险接受,夹具按事实给。 +async fn reset_generation( + cfg: &AcpServerConfig, + target: &RecoveryRequiredDetails, +) -> Result<(), SessionResourceError> { + cfg.session_resources + .reset_dirty_execution(&ResetDirtyRequest { + target: target.clone(), + accept_risk: true, + }) + .await +} + +/// 门面读绑定分类(不改存储)。 +async fn binding_state(cfg: &AcpServerConfig, id: &str) -> BindingState { + cfg.session_resources + .load_session_binding(&id.to_owned()) + .await + .unwrap() +} + +/// 门面读 frozen 状态(不改存储)。 +async fn frozen_state(cfg: &AcpServerConfig, id: &str) -> FrozenState { + cfg.session_resources + .load_session_snapshot(&id.to_owned()) + .await + .unwrap() + .frozen +} + /// 读取本次准入的只读原因;`None` 表示本次取得了执行所有权。 fn read_only(response: &Value) -> Option { response @@ -108,17 +139,22 @@ impl Fixture { /// `test_unnegotiated_client_load_clears_dirty_generation_and_admits_owned`), /// 因此观测 dirty 不能借道 load。 async fn dirty_target(&self) -> RecoveryRequiredDetails { + let workspace = self + .cfg + .session_resources + .validate_bound_workspace(&self.id, BindingRecheck::Recorded) + .await + .unwrap(); let error = match self .cfg - .controller - .sessions() - .acquire_execution_lease(&self.id) + .session_resources + .acquire_execution(&self.id, &workspace) .await { Err(error) => error, Ok(_) => panic!("dirty generation refuses lease acquisition"), }; - match error.downcast_ref::() { + match error.workspace_error() { Some(WorkspaceError::RecoveryRequired(target)) => target.clone(), other => panic!("expected recovery-required lease error, got {other:?}"), } @@ -155,8 +191,8 @@ async fn test_workspace_dirty_recovery_original_load_and_frozen() { .await .unwrap(); let id = created["sessionId"].as_str().unwrap().to_owned(); - let binding = cfg.thread_store.load_session_binding(&id).await.unwrap(); - let frozen = cfg.thread_store.load_frozen_snapshot(&id).await.unwrap(); + let binding = binding_state(&cfg, &id).await; + let frozen = frozen_state(&cfg, &id).await; // 只释放 owner,不伪造正常收尾;夹具不启动外部任务。 sessions.clear(); let params = json!({"sessionId":id,"cwd":tmp.path()}); @@ -232,14 +268,8 @@ async fn test_workspace_dirty_recovery_original_load_and_frozen() { assert!(sessions.contains_key(&id)); assert!(sessions[&id].execution_owner.is_some()); assert!(sessions[&id].frozen.is_some()); - assert_eq!( - cfg.thread_store.load_session_binding(&id).await.unwrap(), - binding - ); - assert_eq!( - cfg.thread_store.load_frozen_snapshot(&id).await.unwrap(), - frozen - ); + assert_eq!(binding_state(&cfg, &id).await, binding); + assert_eq!(frozen_state(&cfg, &id).await, frozen); assert_eq!( Path::new(&sessions[&id].cwd), tmp.path().canonicalize().unwrap() @@ -269,12 +299,7 @@ async fn test_dirty_reset_without_initialize_is_rejected_without_store_effect() assert!(error.data.is_none()); // 零存储副作用:被拒的这条 RPC 没有解除任何代际——同一精确目标仍能被存储 CAS // 命中(存储层解除成功),说明 dirty 记录与代次原样保留。 - fixture - .cfg - .thread_store - .reset_dirty_execution(&target) - .await - .unwrap(); + reset_generation(&fixture.cfg, &target).await.unwrap(); } /// 未协商 `peri.sessionRecoveryV1` 的连接没有确认交互:dirty 不再把它挡在只读, @@ -307,12 +332,7 @@ async fn test_unnegotiated_client_load_clears_dirty_generation_and_admits_owned( ) .await .unwrap(); - let again = fixture - .cfg - .thread_store - .reset_dirty_execution(&target) - .await - .unwrap_err(); + let again = reset_generation(&fixture.cfg, &target).await.unwrap_err(); assert!( again.to_string().contains("dirty generation changed"), "unexpected error: {again}" @@ -359,12 +379,7 @@ async fn test_unnegotiated_client_fork_clears_source_dirty_generation_and_admits ) .await .unwrap(); - let again = fixture - .cfg - .thread_store - .reset_dirty_execution(&target) - .await - .unwrap_err(); + let again = reset_generation(&fixture.cfg, &target).await.unwrap_err(); assert!( again.to_string().contains("dirty generation changed"), "unexpected error: {again}" @@ -399,18 +414,13 @@ async fn test_negotiated_client_fork_on_dirty_source_reports_typed_recovery_requ .expect("recovery rejection carries typed data"), ) .expect("recovery rejection data is typed"); - match data { - WorkspaceErrorData::RecoveryRequired(details) => assert_eq!(details, target), - } + // 恢复所需是 workspace 错误数据的唯一形态:解出精确目标即证明载荷未被降级。 + let WorkspaceErrorData::RecoveryRequired(details) = data; + assert_eq!(details, target); // 未取得所有权:源会话即便已只读进入内存,也没有 owner;被拒的这条 fork 没有解除 // 任何代际——同一精确目标仍能被存储 CAS 命中。 assert!(fixture.sessions[&fixture.id].execution_owner.is_none()); - fixture - .cfg - .thread_store - .reset_dirty_execution(&target) - .await - .unwrap(); + reset_generation(&fixture.cfg, &target).await.unwrap(); } /// reset 成功但随后的 load 失败:存储保持 clean 且代次不变,原 ID 之后仍可恢复。 @@ -437,41 +447,15 @@ async fn test_dirty_reset_then_failing_reload_keeps_store_state_and_original_id( // 存储事实:clean 已置位且代次未推进(同代次再 reset 只能报错配)。 let again = fixture.reset(&target).await.unwrap_err(); assert!(again.message.contains("dirty generation changed")); - let binding = fixture - .cfg - .thread_store - .load_session_binding(&fixture.id) - .await - .unwrap(); - let frozen = fixture - .cfg - .thread_store - .load_frozen_snapshot(&fixture.id) - .await - .unwrap(); + let binding = binding_state(&fixture.cfg, &fixture.id).await; + let frozen = frozen_state(&fixture.cfg, &fixture.id).await; // 原 ID 用原目录仍可正常 load,并保持 binding/frozen。 let params = fixture.params(); fixture.load(¶ms).await.unwrap(); assert!(fixture.sessions.contains_key(&fixture.id)); - assert_eq!( - fixture - .cfg - .thread_store - .load_session_binding(&fixture.id) - .await - .unwrap(), - binding - ); - assert_eq!( - fixture - .cfg - .thread_store - .load_frozen_snapshot(&fixture.id) - .await - .unwrap(), - frozen - ); + assert_eq!(binding_state(&fixture.cfg, &fixture.id).await, binding); + assert_eq!(frozen_state(&fixture.cfg, &fixture.id).await, frozen); let updated = fixture.reset(&target).await.unwrap_err(); assert!( updated.data.is_none(), diff --git a/peri-acp/src/host/requests_test.rs b/peri-acp/src/host/requests_test.rs index 30cc262d3..531037fbc 100644 --- a/peri-acp/src/host/requests_test.rs +++ b/peri-acp/src/host/requests_test.rs @@ -11,11 +11,17 @@ use async_trait::async_trait; use peri_acp_types::event_data::PluginSnapshotEntry; use peri_acp_types::plugin::{InstallScope, InstalledPlugin, PluginManagerPort, PluginOrigin}; use peri_acp_types::ports::WorkflowMiddlewarePort; +use peri_acp_types::session_resources::{ + BindingRecheck, BindingState, FrozenSnapshotBytes, FrozenState, NewSession, NewSessionMeta, + SessionResources, +}; +use peri_acp_types::store::{PersistedPayload, ThreadStore}; use peri_acp_types::tasks::BgTaskKind; use peri_acp_types::thread::ThreadMeta; -use peri_agent::thread::SqliteThreadStore; +use peri_acp_types::workspace::{SessionBinding, SessionExecutionLease}; use peri_middlewares::permission::shared_mode::{PermissionMode, SharedPermissionMode}; use peri_middlewares::workflow::WorkflowMiddleware; +use peri_resources::sessions::SqliteThreadStore; use peri_workflow::protocol::{AgentRunParams, AgentRunResult, Usage}; use peri_workflow::registry::{WorkflowRun, WorkflowRunStatus, WorkflowTaskResult}; use peri_workflow::runner::AgentExecutor; @@ -111,12 +117,39 @@ async fn make_server_config( provider: LlmProvider, tmp: &tempfile::TempDir, ) -> AcpServerConfig { - let thread_store = SqliteThreadStore::new(tmp.path().join("threads.db")) - .await - .unwrap(); - let arc_thread_store: Arc = Arc::new(thread_store); + // 生产同形入口:只注入门面(协议面、Controller、SessionManager 都只持它)。 + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); + build_server_config(peri_config, provider, tmp, session_resources).await +} + +/// 门面 + 裸桥配对打开:夹具需要按 legacy/损坏事实逐条构造时用。 +/// +/// 二者出自**同一次打开**(同一库句柄、同一份 owner 登记);裸句柄只用于建事实与 +/// 直读断言,生产路径一律走注入的门面。 +async fn make_server_config_with_bridge( + peri_config: PeriConfig, + provider: LlmProvider, + tmp: &tempfile::TempDir, +) -> (AcpServerConfig, Arc) { + let (bridge, facade) = + peri_resources::sessions::open_store_and_facade_for_tests(tmp.path().join("threads.db")) + .await + .unwrap(); + let cfg = build_server_config(peri_config, provider, tmp, Arc::new(facade)).await; + (cfg, Arc::new(bridge)) +} + +async fn build_server_config( + peri_config: PeriConfig, + provider: LlmProvider, + tmp: &tempfile::TempDir, + session_resources: Arc, +) -> AcpServerConfig { let session_manager = crate::session::SessionManager::new( - arc_thread_store.clone(), + session_resources.clone(), provider.clone(), Arc::new(peri_config.clone()), SharedPermissionMode::new(PermissionMode::Bypass), @@ -167,8 +200,10 @@ async fn make_server_config( workflow_middleware_factory: Arc::new( peri_middlewares::assembly::WorkflowAgentMiddlewareFactory, ), - thread_store: arc_thread_store.clone(), - controller: Arc::new(peri_controller::Controller::new(arc_thread_store)), + session_resources: session_resources.clone(), + // 测试宿主:不注入部署关闭权(没有部署生命周期)。 + session_store_shutdown: None, + controller: Arc::new(peri_controller::Controller::new(session_resources)), langfuse_session: None, langfuse_shutdown_owner: None, config_source: Arc::new( @@ -184,34 +219,110 @@ async fn make_server_config( } } +/// 夹具建一条**已绑定**会话:门面一次保存 binding/frozen 并给出执行准入。 +/// +/// 建完即按正常收尾标 clean 并释放所有权;需要写入或执行的用例随后自行取得 +/// ([`acquire_bound_owner`])。 async fn create_bound_fixture(cfg: &AcpServerConfig, cwd: &str, id: Option<&str>) -> String { let workspace = cfg - .thread_store + .session_resources .resolve_workspace(Path::new(cwd)) .await .unwrap(); - let mut meta = ThreadMeta::new(cwd); - if let Some(id) = id { - meta.id = id.to_owned(); - } - let id = cfg - .thread_store - .create_bound_thread(meta, &workspace) - .await - .unwrap(); - let owner = cfg.thread_store.acquire_execution_lease(&id).await.unwrap(); + let thread_id = id.map(str::to_owned).unwrap_or_else(new_session_id); let frozen = cfg.session_manager.build_frozen_data( workspace.cwd.to_str().unwrap(), &cfg.plugin_skill_roots, &cfg.plugin_agent_dirs, ); let encoded = crate::session::frozen_snapshot::encode_frozen_snapshot(&frozen).unwrap(); - cfg.thread_store - .store_frozen_snapshot_if_absent(&id, &encoded) + let lease = cfg + .session_resources + .create_session(&bound_input(&thread_id, &workspace, encoded)) .await .unwrap(); - owner.mark_clean().await.unwrap(); - id + lease.mark_clean().await.unwrap(); + thread_id +} + +/// 新会话标识(与生产创建路径同源)。 +fn new_session_id() -> String { + uuid::Uuid::now_v7().to_string() +} + +/// 固定身份的已绑定会话输入(binding 由 workspace 事实构造)。 +fn bound_input( + thread_id: &str, + workspace: &peri_acp_types::workspace::ResolvedWorkspace, + frozen_encoded: String, +) -> NewSession { + NewSession { + thread_id: thread_id.to_owned(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: None, + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: peri_acp_types::thread::CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding::from_workspace(workspace), + frozen: FrozenSnapshotBytes::new(frozen_encoded), + } +} + +/// 门面取执行所有权:先按 identity 复核绑定,再准入(等价旧的 +/// `acquire_execution_lease`,但不再绕过绑定事实)。 +async fn acquire_bound_owner(cfg: &AcpServerConfig, id: &str) -> Arc { + let workspace = cfg + .session_resources + .validate_bound_workspace(&id.to_owned(), BindingRecheck::Recorded) + .await + .unwrap(); + cfg.session_resources + .acquire_execution(&id.to_owned(), &workspace) + .await + .unwrap() +} + +/// 门面追加一条 human 消息(夹具用语;写入仍受活 owner 门禁约束)。 +async fn append_human_message(cfg: &AcpServerConfig, id: &str, text: &str) { + cfg.session_resources + .append_history( + &id.to_owned(), + &[PersistedPayload::Message( + peri_acp_types::messages::BaseMessage::human(text), + )], + ) + .await + .unwrap(); +} + +/// 门面读取 frozen 字节;`None` = legacy 尚未持久化快照。 +async fn frozen_snapshot_bytes(cfg: &AcpServerConfig, id: &str) -> Option { + match cfg + .session_resources + .load_session_snapshot(&id.to_owned()) + .await + .unwrap() + .frozen + { + FrozenState::Present(bytes) => Some(bytes.into_string()), + FrozenState::LegacyAbsent => None, + FrozenState::Unsupported => { + panic!("fixture frozen snapshot must be readable by this build") + } + } +} + +/// 门面读取自有 payload(不含继承区)。 +async fn own_payloads(cfg: &AcpServerConfig, id: &str) -> Vec { + cfg.session_resources + .load_session_snapshot(&id.to_owned()) + .await + .unwrap() + .payloads } // ── 测试 ────────────────────────────────────────────────────────────────────── @@ -410,30 +521,32 @@ async fn register_session_with_history( peri_acp_types::messages::BaseMessage::ai("第一轮回答"), peri_acp_types::messages::BaseMessage::human("第二轮用户问题"), ]; - let history_payloads = history + let history_payloads: Vec = history .iter() .cloned() .map(peri_acp_types::store::PersistedPayload::Message) .collect(); let sid = "rewind-test-session".to_string(); let workspace = cfg - .thread_store + .session_resources .resolve_workspace(Path::new(cwd)) .await .unwrap(); - let mut meta = ThreadMeta::new(cwd); - meta.id = sid.clone(); - cfg.thread_store - .create_bound_thread(meta, &workspace) - .await - .unwrap(); - let owner = cfg - .thread_store - .acquire_execution_lease(&sid) + let frozen = cfg.session_manager.build_frozen_data( + workspace.cwd.to_str().unwrap(), + &cfg.plugin_skill_roots, + &cfg.plugin_agent_dirs, + ); + let encoded = crate::session::frozen_snapshot::encode_frozen_snapshot(&frozen).unwrap(); + let lease = cfg + .session_resources + .create_session(&bound_input(&sid, &workspace, encoded)) .await .unwrap(); - cfg.thread_store - .append_messages(&sid, &history) + lease.mark_clean().await.unwrap(); + let owner = acquire_bound_owner(cfg, &sid).await; + cfg.session_resources + .append_history(&sid, &history_payloads) .await .unwrap(); sessions.insert( @@ -941,20 +1054,17 @@ async fn register_session_with_workflow( cwd: &str, cfg: &AcpServerConfig, ) -> Arc { - if cfg - .thread_store - .load_session_binding(&sid.to_owned()) - .await - .unwrap() - .is_none() - { + // 会话尚未创建(NotFound)或没有本机绑定:本夹具负责建一条已绑定会话。 + let bound = matches!( + cfg.session_resources + .load_session_binding(&sid.to_owned()) + .await, + Ok(BindingState::Bound(_)) + ); + if !bound { create_bound_fixture(cfg, cwd, Some(sid)).await; } - let owner = cfg - .thread_store - .acquire_execution_lease(&sid.to_owned()) - .await - .unwrap(); + let owner = acquire_bound_owner(cfg, sid).await; let executor: Arc = Arc::new(MockWorkflowExecutor); let (notification_tx, _) = tokio::sync::broadcast::channel::(32); let mw = Arc::new(WorkflowMiddleware::new( @@ -1306,12 +1416,20 @@ async fn test_delete_removes_thread_and_active_session() { // 线程已从 store 持久化删除(元数据不存在 + 列表不再包含) assert!( - cfg.thread_store.load_meta(&sid).await.is_err(), + cfg.session_resources.load_session_meta(&sid).await.is_err(), "删除后线程元数据不应存在" ); - let remaining = cfg.thread_store.list_threads().await.unwrap(); + let remaining = cfg + .session_resources + .list_sessions(&peri_acp_types::workspace::ScopedThreadQuery { + scope: peri_acp_types::workspace::ThreadScope::All, + cursor: None, + limit: 100, + }) + .await + .unwrap(); assert!( - !remaining.iter().any(|m| m.id == sid), + !remaining.entries.iter().any(|entry| entry.thread.id == sid), "删除后 session/list 不应再包含该线程" ); } @@ -1413,7 +1531,7 @@ async fn test_rename_persists_title_and_pushes_session_info_update() { assert_eq!(resp["title"], new_title, "响应 title: {resp}"); // 持久化:load_meta 标题已更新 - let meta = cfg.thread_store.load_meta(&sid).await.unwrap(); + let meta = cfg.session_resources.load_session_meta(&sid).await.unwrap(); assert_eq!(meta.title.as_deref(), Some(new_title.as_str())); // 通知:session/update 携带 SessionInfoUpdate.title,供标题栏与外部客户端刷新 @@ -1528,7 +1646,7 @@ async fn test_delete_active_session_shuts_down_lsp_pool() { let transport: Arc = Arc::new(MockTransport::default()); let cwd = tmp.path().to_str().unwrap(); - // 真实创建线程(id 即 session id),与 delete 分支的 thread_store 删除对应 + // 真实创建会话(id 即 session id),与 delete 分支的会话树删除对应 let sid = create_bound_fixture(&cfg, cwd, None).await; let shutdown_calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); @@ -1551,12 +1669,7 @@ async fn test_delete_active_session_shuts_down_lsp_pool() { session_id: sid.clone(), thread_id: sid.clone(), cwd: cwd.to_string(), - execution_owner: Some( - cfg.thread_store - .acquire_execution_lease(&sid) - .await - .unwrap(), - ), + execution_owner: Some(acquire_bound_owner(&cfg, &sid).await), environment: None, closing: false, history: Vec::new(), @@ -1835,15 +1948,7 @@ async fn test_session_load_cold_host_restores_original_frozen_prompt() { .unwrap() .to_string(); assert!(original_claude_md.contains("FROZEN_PROMPT_V1")); - cfg.thread_store - .append_messages( - &session_id, - &[peri_acp_types::messages::BaseMessage::human( - "existing history", - )], - ) - .await - .unwrap(); + append_human_message(&cfg, &session_id, "existing history").await; handle_request( "session/close", &json!({"sessionId": session_id}), @@ -1917,8 +2022,8 @@ async fn test_session_resume_existing_empty_history_is_available_to_fork() { .unwrap(); let session_id = created["sessionId"].as_str().unwrap().to_string(); let original = BaseMessage::human("persisted while the resident session is empty"); - cfg.thread_store - .append_messages(&session_id, std::slice::from_ref(&original)) + cfg.session_resources + .append_history(&session_id, &[PersistedPayload::Message(original.clone())]) .await .unwrap(); @@ -1941,7 +2046,7 @@ async fn test_session_resume_existing_empty_history_is_available_to_fork() { .await .unwrap(); let fork_id = forked["sessionId"].as_str().unwrap().to_string(); - let persisted_fork = cfg.thread_store.load_payloads(&fork_id).await.unwrap(); + let persisted_fork = own_payloads(&cfg, &fork_id).await; assert_eq!( persisted_fork.len(), 1, @@ -1954,10 +2059,7 @@ async fn test_session_resume_existing_empty_history_is_available_to_fork() { sessions[&session_id].history_payloads[0].id(), original.id() ); - assert_eq!( - cfg.thread_store.load_payloads(&session_id).await.unwrap()[0].id(), - original.id() - ); + assert_eq!(own_payloads(&cfg, &session_id).await[0].id(), original.id()); assert_eq!( sessions[&fork_id].frozen.as_ref().unwrap().system_prompt(), sessions[&session_id] @@ -1979,32 +2081,29 @@ async fn test_session_load_future_frozen_snapshot_fails_without_overwrite() { "gpt-4o", )); let provider = LlmProvider::from_config(&peri_config).unwrap(); - let cfg = make_server_config(peri_config.clone(), provider.clone(), &tmp).await; + // 未来版本快照是「本构建读不懂的既有事实」:夹具用裸句柄按原样落库, + // 门面不给这种字节提供写入口(生产创建路径不接受写不懂的快照)。 + let (cfg, bridge) = + make_server_config_with_bridge(peri_config.clone(), provider.clone(), &tmp).await; let transport: Arc = Arc::new(MockTransport::default()); - let workspace = cfg - .thread_store - .resolve_workspace(tmp.path()) - .await - .unwrap(); - let session_id = cfg - .thread_store + let workspace = bridge.resolve_workspace(tmp.path()).await.unwrap(); + let session_id = bridge .create_bound_thread(ThreadMeta::new(tmp.path().to_str().unwrap()), &workspace) .await .unwrap(); - let owner = cfg - .thread_store - .acquire_execution_lease(&session_id) - .await - .unwrap(); + let owner = bridge.acquire_execution_lease(&session_id).await.unwrap(); let future_snapshot = r#"{"version":999,"data":{"must":"remain"}}"#; - cfg.thread_store + bridge .store_frozen_snapshot_if_absent(&session_id, future_snapshot) .await .unwrap(); owner.mark_clean().await.unwrap(); + drop(owner); drop(cfg); + drop(bridge); - let restarted = make_server_config(peri_config, provider, &tmp).await; + let (restarted, restarted_bridge) = + make_server_config_with_bridge(peri_config, provider, &tmp).await; let mut restored_sessions = HashMap::new(); let error = handle_request( "session/load", @@ -2025,8 +2124,7 @@ async fn test_session_load_future_frozen_snapshot_fails_without_overwrite() { assert!(restored_sessions.is_empty()); assert!(restarted.session_manager.get_session(&session_id).is_none()); assert_eq!( - restarted - .thread_store + restarted_bridge .load_frozen_snapshot(&session_id) .await .unwrap() @@ -2872,21 +2970,8 @@ async fn worktree_binding_hot_cold_resume_and_owner_are_consistent() { created["_meta"]["peri.sessionWorkspaceV1"]["workspace"]["cwd"], original_cwd.to_str().unwrap() ); - let frozen = cfg - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .unwrap(); - cfg.thread_store - .append_messages( - &id, - &[peri_acp_types::messages::BaseMessage::human( - "visible project session", - )], - ) - .await - .unwrap(); + let frozen = frozen_snapshot_bytes(&cfg, &id).await; + append_human_message(&cfg, &id, "visible project session").await; for method in ["session/load", "session/resume", "session/fork"] { assert!(handle_request( method, @@ -2931,28 +3016,17 @@ async fn worktree_binding_hot_cold_resume_and_owner_are_consistent() { .unwrap(); let fork_id = forked["sessionId"].as_str().unwrap().to_owned(); assert_eq!( - cfg.thread_store + cfg.session_resources .load_session_binding(&fork_id) .await .unwrap(), - cfg.thread_store.load_session_binding(&id).await.unwrap() - ); - assert_eq!( - cfg.thread_store - .load_frozen_snapshot(&fork_id) + cfg.session_resources + .load_session_binding(&id) .await .unwrap() - .unwrap(), - frozen - ); - assert_eq!( - cfg.thread_store - .load_messages(&fork_id) - .await - .unwrap() - .len(), - 1 ); + assert_eq!(frozen_snapshot_bytes(&cfg, &fork_id).await, frozen); + assert_eq!(own_payloads(&cfg, &fork_id).await.len(), 1); handle_request( "session/close", &json!({"sessionId": fork_id}), @@ -2998,15 +3072,7 @@ async fn worktree_binding_hot_cold_resume_and_owner_are_consistent() { .await .unwrap(); assert_eq!(cold[&id].cwd, original_cwd.to_str().unwrap()); - assert_eq!( - second - .thread_store - .load_frozen_snapshot(&id) - .await - .unwrap() - .unwrap(), - frozen - ); + assert_eq!(frozen_snapshot_bytes(&second, &id).await, frozen); assert!(cold[&id] .frozen .as_ref() @@ -3045,15 +3111,7 @@ async fn worktree_missing_directory_history_is_read_only_and_load_is_rejected() .await .unwrap(); let id = created["sessionId"].as_str().unwrap(); - cfg.thread_store - .append_messages( - &id.to_owned(), - &[peri_acp_types::messages::BaseMessage::human( - "saved history", - )], - ) - .await - .unwrap(); + append_human_message(&cfg, id, "saved history").await; handle_request( "session/close", &json!({"sessionId": id}), @@ -3177,10 +3235,47 @@ impl peri_acp_types::ports::McpPoolPort for RetryShutdownPool { } } +/// 测试夹具:把一次未完成排空的装配保留在会话表里,供关闭重试。 +/// +/// 原为生产侧 fork 补偿链的保留入口;该补偿链已随 fork 门面迁移删除,这里按 +/// 同一形态在测试内构造,继续锁定「Closing + 唯一 owner + 资源保留」不变量。 +fn retain_failed_assembly( + sessions: &mut HashMap, + id: &str, + cwd: &str, + owner: Arc, + environment: Arc, +) { + sessions.insert( + id.to_owned(), + SessionState { + session_id: id.to_owned(), + thread_id: id.to_owned(), + cwd: cwd.to_owned(), + execution_owner: Some(owner), + environment: Some(environment), + closing: true, + history: Vec::new(), + history_payloads: Vec::new(), + cancel_token: None, + frozen: None, + recall_items: Vec::new(), + agent_pool: crate::session::agent_pool::AgentPool::new(), + workflow_middleware: None, + lsp_pool: None, + title: None, + tags: Vec::new(), + continuation_armed: false, + continuation_epoch: 0, + continuation_in_flight: false, + continuation_mq_steering_pending: false, + lease: crate::host::lease::WriterLease::acquired("default"), + }, + ); +} + #[tokio::test] async fn worktree_failed_assembly_retains_resources_and_lease_until_cleanup_retry() { - use peri_acp_types::store::ThreadStore; - let tmp = tempfile::TempDir::new().unwrap(); let config = make_peri_config_with_provider(make_provider_config("test", "openai", "key", "model")); @@ -3193,7 +3288,7 @@ async fn worktree_failed_assembly_retains_resources_and_lease_until_cleanup_retr mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile::disabled(), }); let id = create_bound_fixture(&cfg, cwd.to_str().unwrap(), None).await; - let owner = cfg.thread_store.acquire_execution_lease(&id).await.unwrap(); + let owner = acquire_bound_owner(&cfg, &id).await; let mut environment = crate::host::workspace::SessionEnvironment::assemble(&cfg, cwd.to_str().unwrap(), &id) .await @@ -3206,7 +3301,7 @@ async fn worktree_failed_assembly_retains_resources_and_lease_until_cleanup_retr Arc::get_mut(&mut environment).unwrap().cfg.mcp_pool = Some(pool.clone()); assert!(!environment.shutdown().await); let mut sessions = HashMap::new(); - session_lifecycle::retain_failed_assembly( + retain_failed_assembly( &mut sessions, &id, cwd.to_str().unwrap(), diff --git a/peri-acp/src/host/stage_builder.rs b/peri-acp/src/host/stage_builder.rs index 3ee3fde56..7a68c65fb 100644 --- a/peri-acp/src/host/stage_builder.rs +++ b/peri-acp/src/host/stage_builder.rs @@ -226,7 +226,7 @@ pub(crate) fn build_stage_context( lsp_pool: ctx.lsp_pool.clone(), workflow_executor: ctx.workflow_executor.clone(), workflow_middleware: ctx.workflow_middleware.clone(), - thread_store: ctx.thread_store.clone(), + session_resources: ctx.session_resources.clone(), thread_id: ctx.thread_id.clone(), // 注入面 model_name: ctx.provider_model_name.clone(), diff --git a/peri-acp/src/host/stdio/mod.rs b/peri-acp/src/host/stdio/mod.rs index e04cef60f..ec527262b 100644 --- a/peri-acp/src/host/stdio/mod.rs +++ b/peri-acp/src/host/stdio/mod.rs @@ -8,14 +8,14 @@ //! transport 挂载(含 legacy `type:cancel` 全 session 兜底中断钩子)。 //! //! stdio host 位于 ACP 层(部署装配点,`docs/top-level.md` §7/§19);外部 -//! 系统通道(thread 存储)由部署单元(cli)打开后经 `thread_store` 注入, +//! 系统通道(会话资源门面)由部署单元(cli)打开后经 `session_resources` 注入, //! ACP 层不直接依赖 Resources(§0 依赖方向)。 use std::{collections::HashMap, path::PathBuf, sync::Arc}; use parking_lot::RwLock; use peri_acp_types::permission::SharedPermissionMode; -use peri_acp_types::store::ThreadStore; +use peri_acp_types::session_store::SessionStoreDeployment; use crate::provider::LlmProvider; use crate::transport::stdio::StdioTransport; @@ -26,8 +26,9 @@ use crate::transport::AcpTransport; pub struct StdioInput { pub cwd: String, pub permission_mode: Arc, - /// 显式指定 SQLite 会话数据库路径;`None` 使用默认路径,打开失败直接返回错误。 - pub db_path: Option, + /// 会话存储的部署参数(定位 + 凭证来源 + 访问意图)。装配时一次性打开; + /// ACP 层不解释 locator、不读凭证值、不选择后端。 + pub session_store: SessionStoreDeployment, } /// 启动 ACP stdio 宿主(批 3:统一宿主 `run_acp_server` 接管全部业务处理)。 @@ -118,15 +119,18 @@ async fn assemble_stdio_config(input: StdioInput) -> anyhow::Result = peri_agent::resources::open_thread_store_with(db_path) - .await - .map_err(|e| anyhow::anyhow!("无法初始化 Resources 层: {e}"))?; + // 存储经 peri-agent 工厂构造(§0:ACP 层不直接依赖 Resources;M-res 收口—— + // 存储实例化点归 Agent 层声明边)。定位参数按部署描述解析一次、打开一次: + // 协议面、Agent transcript/subagent 与 Controller 共用同一个库句柄与同一份 + // owner 登记;后续恢复会话不重新解析存储。 + let (session_resources, session_store_shutdown) = + peri_agent::resources::open_session_resources_deployment(&session_store) + .await + .map_err(|e| anyhow::anyhow!("无法初始化 Resources 层: {e}"))?; // 配置源已在 Provider 选择前按 canonical cwd 冻结,后续读写与装配复用 // 同一实例,保证配置 provenance 一致。 @@ -144,10 +148,14 @@ async fn assemble_stdio_config(input: StdioInput) -> anyhow::Result AcpServerConfig { use std::collections::BTreeMap; - let thread_store = peri_agent::thread::SqliteThreadStore::new(tmp.path().join("threads.db")) - .await - .unwrap(); - let arc_thread_store: Arc = Arc::new(thread_store); + // 生产同形入口:只注入门面(SessionManager 与 Controller 都只持它)。 + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let session_manager = crate::session::SessionManager::new( - arc_thread_store.clone(), + session_resources.clone(), provider.clone(), Arc::new(peri_config.clone()), peri_acp_types::permission::SharedPermissionMode::new( @@ -186,8 +189,10 @@ async fn make_server_config_with( workflow_middleware_factory: Arc::new( peri_middlewares::assembly::WorkflowAgentMiddlewareFactory, ), - thread_store: arc_thread_store.clone(), - controller: Arc::new(peri_controller::Controller::new(arc_thread_store)), + session_resources: session_resources.clone(), + // 测试宿主:不注入部署关闭权(没有部署生命周期)。 + session_store_shutdown: None, + controller: Arc::new(peri_controller::Controller::new(session_resources)), langfuse_session: None, langfuse_shutdown_owner: None, config_source: Arc::new( @@ -479,33 +484,37 @@ async fn await_server_exit(server_task: tokio::task::JoinHandle<()>, input: Dupl } async fn create_bound_thread_fixture(cfg: &AcpServerConfig, session_id: &str, cwd: &str) { - let mut meta = peri_acp_types::thread::ThreadMeta::new(cwd); - meta.id = session_id.to_string(); let workspace = cfg - .thread_store + .session_resources .resolve_workspace(std::path::Path::new(cwd)) .await .unwrap(); - cfg.thread_store - .create_bound_thread(meta, &workspace) - .await - .unwrap(); - let owner = cfg - .thread_store - .acquire_execution_lease(&session_id.to_owned()) - .await - .unwrap(); let frozen = cfg.session_manager.build_frozen_data( workspace.cwd.to_str().unwrap(), &cfg.plugin_skill_roots, &cfg.plugin_agent_dirs, ); let encoded = crate::session::frozen_snapshot::encode_frozen_snapshot(&frozen).unwrap(); - cfg.thread_store - .store_frozen_snapshot_if_absent(&session_id.to_owned(), &encoded) + // 门面一次完成 binding/frozen 保存与执行准入,再按正常收尾标 clean。 + let lease = cfg + .session_resources + .create_session(&peri_acp_types::session_resources::NewSession { + thread_id: session_id.to_owned(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: peri_acp_types::session_resources::NewSessionMeta { + title: None, + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: peri_acp_types::thread::CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: peri_acp_types::workspace::SessionBinding::from_workspace(&workspace), + frozen: peri_acp_types::session_resources::FrozenSnapshotBytes::new(encoded), + }) .await .unwrap(); - owner.mark_clean().await.unwrap(); + lease.mark_clean().await.unwrap(); } // ── 测试 ────────────────────────────────────────────────────────────────── @@ -916,7 +925,7 @@ async fn test_resume_creates_session_scoped_lsp_pool() { async fn test_fork_creates_session_scoped_lsp_pool() { let tmp = tempfile::TempDir::new().unwrap(); let cfg = test_config_with_lsp(&tmp, vec![make_lsp_config()]).await; - let thread_store = Arc::clone(&cfg.thread_store); + let session_resources = Arc::clone(&cfg.session_resources); let (transport, mut input_write, mut output_read) = duplex_transport(); let transport: Arc = Arc::new(transport); let sessions = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); @@ -926,16 +935,25 @@ async fn test_fork_creates_session_scoped_lsp_pool() { let source_payload = peri_acp_types::store::PersistedPayload::Message(source_message.clone()); let source_thread_id = "fork-source-session".to_string(); create_bound_thread_fixture(&cfg, &source_thread_id, tmp.path().to_str().unwrap()).await; - let owner = thread_store - .acquire_execution_lease(&source_thread_id) + let workspace = cfg + .session_resources + .validate_bound_workspace( + &source_thread_id, + peri_acp_types::session_resources::BindingRecheck::Recorded, + ) + .await + .unwrap(); + let owner = cfg + .session_resources + .acquire_execution(&source_thread_id, &workspace) .await .unwrap(); cfg.session_manager.ensure_session( &source_thread_id, std::fs::canonicalize(tmp.path()).unwrap().to_str().unwrap(), ); - thread_store - .append_payloads(&source_thread_id, std::slice::from_ref(&source_payload)) + cfg.session_resources + .append_history(&source_thread_id, std::slice::from_ref(&source_payload)) .await .unwrap(); let source_frozen = cfg.session_manager.build_frozen_data( @@ -1013,7 +1031,11 @@ async fn test_fork_creates_session_scoped_lsp_pool() { forked_message_id, "fork SessionState 必须采用持久化复制后的新消息 ID" ); - let stored_fork = thread_store.load_payloads(&forked_id).await.unwrap(); + let stored_fork = session_resources + .load_session_snapshot(&forked_id) + .await + .unwrap() + .payloads; assert_eq!(stored_fork.len(), 1); assert_eq!(stored_fork[0].id(), forked_message_id); drop(sessions); @@ -1100,7 +1122,7 @@ async fn test_new_prewarms_mcp_discovery_smoke() { async fn test_rename_over_stdio_transport() { let tmp = tempfile::TempDir::new().unwrap(); let cfg = test_config(&tmp).await; - let thread_store = cfg.thread_store.clone(); + let session_resources = Arc::clone(&cfg.session_resources); let (transport, mut input_write, mut output_read) = duplex_transport(); let transport: Arc = Arc::new(transport); let server_task = tokio::spawn(host::run_acp_server(transport, cfg)); @@ -1169,7 +1191,10 @@ async fn test_rename_over_stdio_transport() { assert_eq!(resp["result"]["title"], "stdio 命名会话", "响应: {resp}"); // 持久化:thread store 标题已更新 - let meta = thread_store.load_meta(&session_id).await.unwrap(); + let meta = session_resources + .load_session_meta(&session_id) + .await + .unwrap(); assert_eq!(meta.title.as_deref(), Some("stdio 命名会话")); // ── EOF → 宿主优雅退出 ── @@ -1182,3 +1207,6 @@ async fn test_rename_over_stdio_transport() { #[path = "langfuse_shutdown_test.rs"] mod langfuse_shutdown_tests; + +#[path = "session_store_shutdown_test.rs"] +mod session_store_shutdown_tests; diff --git a/peri-acp/src/host/stdio/session_store_shutdown_test.rs b/peri-acp/src/host/stdio/session_store_shutdown_test.rs new file mode 100644 index 000000000..d470428ae --- /dev/null +++ b/peri-acp/src/host/stdio/session_store_shutdown_test.rs @@ -0,0 +1,147 @@ +//! 部署关闭权的消费路径:宿主在**任务排空之后**关闭会话存储。 +//! +//! 关闭权(`SessionStoreShutdownPort`)不由业务持有,只有部署装配注入宿主配置。这里断言 +//! 两件可观察事实: +//! +//! 1. 还有未结束的宿主任务时**不会**动用关闭权;任务排空之后才恰好调用一次; +//! 2. 未确认的关闭不是成功:第一次关闭报 `Incomplete` 并保留上下文,重复关闭重新做 +//! 真实检查(端口被再次调用)之后才可能成立。 +//! +//! 端口替身只用于观察「宿主何时调用」;关闭本身的真实语义(结清判定、重复检查、只有 +//! 确认关闭才幂等成功)由 peri-resources 的门面测试覆盖。 + +use super::*; +use peri_acp_types::session_resources::{ + SessionResourceError, SessionResourceResult, SessionStoreShutdownPort, +}; +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// 关闭权替身:记录调用次数,可按首次必失败注入「未确认的关闭」。 +struct RecordingShutdown { + calls: AtomicUsize, + fail_first: bool, +} + +impl RecordingShutdown { + fn new(fail_first: bool) -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + fail_first, + }) + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +/// 注入宿主配置的那一份关闭权(`Box` 需要具体类型,观察点仍是同一个计数器)。 +struct RecordingShutdownPort(Arc); + +#[async_trait::async_trait] +impl SessionStoreShutdownPort for RecordingShutdownPort { + async fn shutdown(&self) -> SessionResourceResult<()> { + let call = self.0.calls.fetch_add(1, Ordering::SeqCst); + if self.0.fail_first && call == 0 { + return Err(SessionResourceError::persistence_uncertain(None)); + } + Ok(()) + } +} + +/// 装配一个带关闭权的宿主配置,并起一条「取消后仍需显式放行」的宿主任务。 +/// +/// 该任务让「排空尚未完成」成为**可观察的中间态**:宿主已开始关闭,但任务还没结束。 +async fn config_with_gated_task( + tmp: &tempfile::TempDir, + shutdown: Arc, +) -> ( + AcpServerConfig, + tokio::sync::oneshot::Sender<()>, + tokio::sync::oneshot::Receiver<()>, +) { + let mut cfg = test_config(tmp).await; + cfg.session_store_shutdown = Some(Box::new(RecordingShutdownPort(Arc::clone(&shutdown)))); + let cancellation = cfg.host_task_spawner.shutdown_token(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + cfg.host_task_spawner + .spawn( + crate::host::task_scope::HostTaskOwnerKind::Host, + crate::host::task_scope::HostTaskKind::LegacyCancelHook, + async move { + started_tx.send(()).unwrap(); + cancellation.cancelled().await; + release_rx.await.unwrap(); + }, + ) + .unwrap(); + (cfg, release_tx, started_rx) +} + +#[tokio::test] +async fn test_store_shutdown_runs_only_after_host_tasks_drain() { + let tmp = tempfile::TempDir::new().unwrap(); + let shutdown = RecordingShutdown::new(false); + let (cfg, release_tx, started_rx) = config_with_gated_task(&tmp, Arc::clone(&shutdown)).await; + let (transport, input, _output) = duplex_transport(); + let mut host = crate::host::spawn_acp_server(Arc::new(transport), cfg); + started_rx.await.unwrap(); + // 传输 EOF:宿主开始关闭,但被门控的宿主任务还没结束。 + drop(input); + let mut first = Box::pin(host.shutdown()); + std::future::poll_fn(|cx| { + assert!(first.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + assert_eq!(shutdown.calls(), 0, "任务尚未排空时不得动用部署关闭权"); + drop(first); + + release_tx.send(()).unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()) + .await + .unwrap(), + crate::host::AcpHostShutdownReport::Complete + ); + // 排空之后恰好一次;重复观察终态不再重复关闭。 + assert_eq!(shutdown.calls(), 1); + assert_eq!( + host.shutdown().await, + crate::host::AcpHostShutdownReport::Complete + ); + assert_eq!(shutdown.calls(), 1); +} + +#[tokio::test] +async fn test_unconfirmed_store_shutdown_is_incomplete_and_retry_rechecks() { + let tmp = tempfile::TempDir::new().unwrap(); + let shutdown = RecordingShutdown::new(true); + let cfg = { + let mut cfg = test_config(&tmp).await; + cfg.session_store_shutdown = Some(Box::new(RecordingShutdownPort(Arc::clone(&shutdown)))); + cfg + }; + let (transport, input, _output) = duplex_transport(); + let mut host = crate::host::spawn_acp_server(Arc::new(transport), cfg); + drop(input); + + // 未确认的关闭不是完成:报告未完成,部署保留上下文。 + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()) + .await + .unwrap(), + crate::host::AcpHostShutdownReport::Incomplete + ); + assert_eq!(shutdown.calls(), 1); + // 重复关闭重新做真实检查(端口被再次调用),这次才成立。 + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()) + .await + .unwrap(), + crate::host::AcpHostShutdownReport::Complete + ); + assert_eq!(shutdown.calls(), 2); +} diff --git a/peri-acp/src/host/workflow_agent.rs b/peri-acp/src/host/workflow_agent.rs index d6152807e..9ccd0ebb2 100644 --- a/peri-acp/src/host/workflow_agent.rs +++ b/peri-acp/src/host/workflow_agent.rs @@ -217,7 +217,6 @@ pub(crate) fn create_session_workflow_middleware( permission_mode: None, frozen_date: Some(frozen_data.date().to_string()), frozen_language: frozen_data.language().map(|s| s.to_string()), - thread_store: None, progress_tx: Some(progress_tx), subagent_ctx_builder: None, agent_prompt_builder: build_workflow_agent_prompt_builder( diff --git a/peri-acp/src/host/workspace.rs b/peri-acp/src/host/workspace.rs index 633da7fc0..cc2beb374 100644 --- a/peri-acp/src/host/workspace.rs +++ b/peri-acp/src/host/workspace.rs @@ -4,9 +4,11 @@ use std::{path::Path, sync::Arc}; use super::{assemble, task_scope, AcpServerConfig, SessionState}; use crate::transport::types::AcpError; +use peri_acp_types::session_resources::{BindingRecheck, SessionResourceError}; +use peri_acp_types::thread::ThreadId; use peri_acp_types::workspace::{ ReadOnlyAdmission, RecoveryRequiredDetails, ResolvedWorkspace, SessionExecutionLease, - WorkspaceError, + WorkspaceError, WorkspaceErrorData, }; enum SessionEndState { @@ -33,45 +35,46 @@ impl SessionEnvironment { host: &AcpServerConfig, cwd: &str, session_id: &str, + ) -> Result>, AcpError> { + if host.workspace_assembly.is_none() { + return Ok(None); + } + let inputs = super::prepared::PreparedSessionInputs::prepare_new(host, cwd)?; + Self::assemble_prepared(host, &inputs, session_id).await + } + + /// 使用已定格的准备输入装配会话环境:配置/插件/目录全部来自 `inputs`—— + /// 装配期不第二次 `ConfigSource::load_at`、不第二次加载插件。 + /// + /// MCP / LSP / hooks 与 OAuth 消费者仍在本函数内创建(顺序不变);调用方 + /// 必须已取得执行所有权,准备阶段本身不启动这些资源。 + pub(crate) async fn assemble_prepared( + host: &AcpServerConfig, + inputs: &super::prepared::PreparedSessionInputs, + session_id: &str, ) -> Result>, AcpError> { let Some(source) = host.workspace_assembly.as_ref() else { return Ok(None); }; - let same_directory = - std::fs::canonicalize(&source.startup_cwd).ok().as_deref() == Some(Path::new(cwd)); - let (config_source, peri_config, provider) = if same_directory { - ( - host.config_source.clone(), - Arc::new(parking_lot::RwLock::new(host.peri_config.read().clone())), - host.provider.read().clone(), - ) - } else { - let source = Arc::new( - crate::provider::ConfigSource::load_at( - Path::new(cwd), - host.config_source.global_path().to_owned(), - ) - .map_err(workspace_error)?, - ); - let config = source.loaded_merged(); - let provider = crate::provider::LlmProvider::from_config(&config) - .or_else(crate::provider::LlmProvider::from_env) - .ok_or_else(|| { - AcpError::new(-32603, "No provider configured for session workspace") - })?; - (source, Arc::new(parking_lot::RwLock::new(config)), provider) - }; + let cwd = inputs.cwd.clone(); let input = assemble::HostAssemblyInput { - provider, - peri_config, - config_source, + provider: inputs.provider.clone(), + peri_config: Arc::new(parking_lot::RwLock::new((*inputs.config).clone())), + config_source: inputs.config_source.clone(), permission_mode: peri_acp_types::permission::SharedPermissionMode::new( host.permission_mode.load(), ), - thread_store: host.thread_store.clone(), - cwd: cwd.to_owned(), + session_resources: host.session_resources.clone(), + // 会话级装配:这里不是部署 owner,拿不到也不持有全局关闭权。 + session_store_shutdown: None, + cwd: cwd.clone(), bare: source.bare, drive_cron_tick: false, + prepared_plugins: Some(assemble::PreparedPlugins { + data: inputs.plugin_data.clone(), + skill_roots: inputs.skill_roots.clone(), + agent_dirs: inputs.agent_dirs.clone(), + }), }; let activation = tokio_util::sync::CancellationToken::new(); let mut cfg = assemble::assemble_server_config_with_mcp_profile( @@ -115,7 +118,7 @@ impl SessionEnvironment { task_owner: tokio::sync::Mutex::new(task_owner), mcp_owner: tokio::sync::Mutex::new(mcp_owner), session_id: session_id.to_owned(), - cwd: cwd.to_owned(), + cwd, end_hooks: tokio::sync::Mutex::new(SessionEndState::Pending), cleanup_tasks: Arc::new(peri_agent::agent::async_tasks::TaskManager::new()), }))) @@ -226,18 +229,24 @@ impl SessionEnvironment { pub(crate) fn workspace_error(error: impl Into) -> AcpError { let error = error.into(); let mut response = AcpError::new(-32010, error.to_string()); - if let Some(WorkspaceError::RecoveryRequired(details)) = error.downcast_ref::() + if let Some(data) = error + .downcast_ref::() + .and_then(WorkspaceErrorData::from_workspace_error) { - response.data = Some( - serde_json::to_value( - peri_acp_types::workspace::WorkspaceErrorData::RecoveryRequired(details.clone()), - ) - .expect("recovery details serialize"), - ); + response.data = Some(serde_json::to_value(data).expect("workspace error data serializes")); } response } +/// 门面行为失败 → ACP 错误:本机 workspace 语义保留既有载荷(含恢复确认数据), +/// 其余按行为失败上报,不把失败伪装成 workspace 问题。 +pub(crate) fn resource_error(error: SessionResourceError) -> AcpError { + match error.workspace_error() { + Some(workspace) => workspace_error(workspace.clone()), + None => AcpError::new(-32010, error.to_string()), + } +} + pub(crate) fn require_owner(state: &SessionState) -> Result<(), AcpError> { if state.closing { return Err(AcpError::new(-32010, "Session is closing")); @@ -283,19 +292,24 @@ pub(crate) async fn reassert_expected( check_expected(cfg, session_id, expected, BindingCheck::Recorded).await } +/// 按会话 identity 复核绑定(门面投影,只读、不改绑、不取执行权)。 async fn check_expected( cfg: &AcpServerConfig, session_id: &str, expected: Option<&str>, check: BindingCheck, ) -> Result { - let store = cfg.controller.sessions(); - let id = session_id.to_owned(); - let workspace = match check { - BindingCheck::Full => store.validate_session_binding(&id).await, - BindingCheck::Recorded => store.reassert_session_binding(&id).await, - } - .map_err(workspace_error)?; + let id = ThreadId::from(session_id.to_owned()); + let recheck = match check { + BindingCheck::Full => BindingRecheck::Full, + BindingCheck::Recorded => BindingRecheck::Recorded, + }; + let workspace = cfg + .controller + .sessions() + .validate_bound_workspace(&id, recheck) + .await + .map_err(resource_error)?; if let Some(expected) = expected { expect_directory(expected, &workspace).await?; } @@ -320,6 +334,31 @@ pub(crate) async fn expect_directory( Ok(()) } +/// 未加载会话的短时执行准入:按保存的 cwd 解析绑定目录后取得 owner。 +/// +/// 用于 `session/rename`、`session/delete` 这类「会话不在本进程会话表里」的显式 +/// 生命周期行为:所有权是改标题/删除的前提,但不需要装配执行环境(不启动 +/// MCP/LSP/hooks)。他处持有时原样拒绝,不降级、不抢占。 +pub(crate) async fn acquire_transient_owner( + cfg: &AcpServerConfig, + session_id: &str, +) -> Result, AcpError> { + let resources = &cfg.session_resources; + let id = session_id.to_owned(); + let meta = resources + .load_session_meta(&id) + .await + .map_err(resource_error)?; + let workspace = resources + .resolve_workspace(Path::new(&meta.cwd)) + .await + .map_err(resource_error)?; + resources + .acquire_execution(&id, &workspace) + .await + .map_err(resource_error) +} + /// 一次加载准入的结果:绑定与执行目录已复核,执行所有权可能不可得。 /// /// 所有权不可得(`ExecutionBusy` / `RecoveryRequired` / `ExecutionLeaseRequired`)时 @@ -377,6 +416,9 @@ pub(crate) async fn reacquire_for_load( } /// 只读降级原因还原为错误:不接受降级的调用方(如 `session/fork`)按原语义上报。 +/// +/// 只有门面明确给出的「所有权不可得」原因才进入这里;其他失败(IO、绑定复核、 +/// schema 不支持)本来就不降级,避免把「读不了」伪装成「可以只读进入」。 pub(crate) fn read_only_error(reason: ReadOnlyAdmission) -> AcpError { let error = match reason { ReadOnlyAdmission::ExecutionBusy => WorkspaceError::ExecutionBusy, @@ -404,7 +446,7 @@ async fn acquire_for_load_with( }; let (owner, acquired_here) = match held { Some(owner) => (owner, false), - None => match acquire_lease_with_recovery(cfg, session_id).await? { + None => match acquire_lease_with_recovery(cfg, session_id, &workspace).await? { ExecutionAdmission::Owned(owner) => (owner, true), ExecutionAdmission::Unavailable(reason) => { return Ok(LoadAdmission { @@ -443,8 +485,9 @@ async fn acquire_for_load_with( async fn acquire_lease_with_recovery( cfg: &AcpServerConfig, session_id: &str, + workspace: &ResolvedWorkspace, ) -> Result { - let first = try_acquire_lease(cfg, session_id).await?; + let first = try_acquire_lease(cfg, session_id, workspace).await?; let ExecutionAdmission::Unavailable(ReadOnlyAdmission::RecoveryRequired(ref target)) = first else { return Ok(first); @@ -464,7 +507,7 @@ async fn acquire_lease_with_recovery( ); return Ok(first); } - let acquired = try_acquire_lease(cfg, session_id).await; + let acquired = try_acquire_lease(cfg, session_id, workspace).await; // 重取失败(存储故障等不可降级的原因)此前到不了这里——那时这一步只会只读返回; // 判定不变(代际已解除,没有可收敛的只读原因),只补上诊断线索。 if let Err(error) = &acquired { @@ -483,17 +526,17 @@ async fn acquire_lease_with_recovery( async fn try_acquire_lease( cfg: &AcpServerConfig, session_id: &str, + workspace: &ResolvedWorkspace, ) -> Result { match cfg - .controller - .sessions() - .acquire_execution_lease(&session_id.to_owned()) + .session_resources + .acquire_execution(&session_id.to_owned(), workspace) .await { Ok(owner) => Ok(ExecutionAdmission::Owned(owner)), - Err(error) => match read_only_reason(&error) { + Err(error) => match error.read_only_admission() { Some(reason) => Ok(ExecutionAdmission::Unavailable(reason)), - None => Err(workspace_error(error)), + None => Err(resource_error(error)), }, } } @@ -509,11 +552,14 @@ async fn clear_dirty_generation( session_id: &str, target: &RecoveryRequiredDetails, ) -> Result<(), AcpError> { - cfg.controller - .sessions() - .reset_dirty_execution(target) + cfg.session_resources + .reset_dirty_execution(&peri_acp_types::workspace::ResetDirtyRequest { + target: target.clone(), + // host 自动解除是「拿风险换可用」的既有裁决;门面要求调用方显式承担。 + accept_risk: true, + }) .await - .map_err(workspace_error)?; + .map_err(resource_error)?; tracing::warn!( session_id, thread_id = %target.thread_id, @@ -522,13 +568,3 @@ async fn clear_dirty_generation( ); Ok(()) } - -/// 取不到执行所有权的原因是否属于「所有权不可得、历史仍可读」。 -/// -/// 只有存储层明确给出的三类才降级:其他失败(IO、绑定复核、schema 不支持)仍旧 -/// 原样上报,避免把「读不了」伪装成「可以只读进入」。 -fn read_only_reason(error: &anyhow::Error) -> Option { - error - .downcast_ref::() - .and_then(ReadOnlyAdmission::from_workspace_error) -} diff --git a/peri-acp/src/prompt/mod.rs b/peri-acp/src/prompt/mod.rs index 4b2303bd6..45502fd32 100644 --- a/peri-acp/src/prompt/mod.rs +++ b/peri-acp/src/prompt/mod.rs @@ -76,6 +76,27 @@ fn detect_is_git_repo(cwd: &str) -> bool { } } +/// 运行环境取值(平台 / OS 版本 / 是否 Git 仓库)。 +/// +/// 会话准备阶段探测一次,随后由冻结输入携带;装配与渲染消费同一份, +/// 不在调用时各自 `detect`(两处取值不一致即准备结构缺陷)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptRuntimeEnv { + pub is_git_repo: bool, + pub platform: String, + pub os_version: String, +} + +impl PromptRuntimeEnv { + pub fn detect(cwd: &str) -> Self { + Self { + is_git_repo: detect_is_git_repo(cwd), + platform: std::env::consts::OS.to_string(), + os_version: os_version_string(), + } + } +} + pub struct PromptEnv { pub cwd: String, pub is_git_repo: bool, @@ -86,32 +107,30 @@ pub struct PromptEnv { impl PromptEnv { pub fn detect(cwd: &str) -> Self { - let is_git_repo = detect_is_git_repo(cwd); - let platform = std::env::consts::OS.to_string(); - let os_version = os_version_string(); + let runtime = PromptRuntimeEnv::detect(cwd); let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + Self::frozen(cwd, &date, &runtime) + } + + /// 使用冻结日期与冻结运行环境构造(跳过 `chrono::Local::now()` 与实时探测)。 + /// + /// 会话准备路径经 [`PromptRuntimeEnv`] 一次性定格;`with_frozen_date` + /// 保留给既有调用点(其内部等价于对同一 cwd 探测一次)。 + pub fn frozen(cwd: &str, frozen_date: &str, runtime: &PromptRuntimeEnv) -> Self { Self { cwd: cwd.to_string(), - is_git_repo, - platform, - os_version, - date, + is_git_repo: runtime.is_git_repo, + platform: runtime.platform.clone(), + os_version: runtime.os_version.clone(), + date: frozen_date.to_string(), } } /// 使用冻结日期构造(跳过 `chrono::Local::now()` 调用)。 - /// `is_git_repo` 仍基于 cwd 实时检查;调用方若需冻结也应缓存。 + /// `is_git_repo` / `platform` / `os_version` 仍在调用时探测一次; + /// 需要与冻结输入同源的调用方应改用 [`PromptEnv::frozen`]。 pub fn with_frozen_date(cwd: &str, frozen_date: &str) -> Self { - let is_git_repo = detect_is_git_repo(cwd); - let platform = std::env::consts::OS.to_string(); - let os_version = os_version_string(); - Self { - cwd: cwd.to_string(), - is_git_repo, - platform, - os_version, - date: frozen_date.to_string(), - } + Self::frozen(cwd, frozen_date, &PromptRuntimeEnv::detect(cwd)) } } diff --git a/peri-acp/src/session/command/compact_test.rs b/peri-acp/src/session/command/compact_test.rs index 3b9b1348a..24cf5d4a0 100644 --- a/peri-acp/src/session/command/compact_test.rs +++ b/peri-acp/src/session/command/compact_test.rs @@ -18,14 +18,19 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; +use std::collections::HashMap; + use peri_acp_types::{ command::{FeedbackChannel, FeedbackLevel}, event::ExecutorEvent, - messages::{BaseMessage, ContentBlock}, - store::ThreadStore, - thread::ThreadMeta, + messages::{BaseMessage, ContentBlock, MessageId}, + session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResources, SessionSnapshot, + }, + store::{MessageFlags, PersistedPayload}, + thread::{CancelPolicy, ThreadId}, + workspace::{SessionBinding, SessionExecutionLease}, }; -use peri_agent::thread::{FilesystemThreadStore, SqliteThreadStore}; use super::*; use crate::session::command::CommandResult; @@ -89,22 +94,24 @@ fn make_ctx( } /// 构造带 auxiliary_model 的 CommandContext(contract test 使用真实模型路径) +/// +/// 返回夹具本体:门面写入要求「本 root 有活 owner」,lease 必须活到测试结束, +/// 因此由调用方持有(`let (ctx, _session) = make_ctx_with_model(..)`)。 async fn make_ctx_with_model( sink: Arc, history: Vec, - cwd: String, model: Arc, -) -> super::super::CommandContext { - let store: Arc = Arc::new( - SqliteThreadStore::new(std::path::Path::new(&cwd).join("compact-test.db")) - .await - .expect("创建 SQLite store 失败"), +) -> (super::super::CommandContext, BoundSession) { + let session = BoundSession::open("compact-test.db").await; + let ctx = make_ctx_with_model_and_thread( + sink, + history, + session.cwd.clone(), + model, + Some(session.resources()), + Some(session.thread_id.clone()), ); - let thread_id = store - .create_thread(ThreadMeta::new(cwd.clone())) - .await - .expect("创建 thread 失败"); - make_ctx_with_model_and_thread(sink, history, cwd, model, Some(store), Some(thread_id)) + (ctx, session) } fn make_ctx_with_model_and_thread( @@ -112,11 +119,11 @@ fn make_ctx_with_model_and_thread( history: Vec, cwd: String, model: Arc, - thread_store: Option>, + session_resources: Option>, thread_id: Option, ) -> super::super::CommandContext { // Phase 2 拆层:deps 私有化后构造面封闭,core 5 字段经 new() 就位; - // 非默认旧字段显式赋值(auxiliary_model / thread_store / thread_id)。 + // 非默认旧字段显式赋值(auxiliary_model / session_resources / thread_id)。 let mut ctx = super::super::CommandContext::new( "test-session".to_string(), history, @@ -126,11 +133,123 @@ fn make_ctx_with_model_and_thread( peri_acp_types::command::DependencyBag::new(), ); ctx.auxiliary_model = Some(model); - ctx.thread_store = thread_store; + ctx.session_resources = session_resources; ctx.thread_id = thread_id; ctx } +// ── 门面夹具 ────────────────────────────────────────────────────────── +// +// 完整 compact lifecycle 必须绑定会话资源门面;门面的写入门禁要求「本 root 有活 +// owner」,所以夹具真的建立一条已绑定会话并持有它的执行所有权(与生产路径同一前置 +// 条件),不用替身假装可写。断言走同一次一致快照(payload 与 flags 同一次读取)。 + +/// 临时库上的已绑定会话 + 活跃执行所有权。 +struct BoundSession { + resources: Arc, + thread_id: ThreadId, + cwd: String, + db_path: std::path::PathBuf, + /// 持有到测试结束:owner 一旦丢弃,门面的写入按 `LeaseRequired` 真实失败。 + _lease: Arc, + _db: tempfile::TempDir, +} + +impl BoundSession { + /// 新库 + 一条已绑定会话(cwd 为临时目录解析后的路径)。 + async fn open(file: &str) -> Self { + let db = tempfile::tempdir().expect("创建临时目录失败"); + let db_path = db.path().join(file); + let resources: Arc = Arc::new( + peri_resources::sessions::SessionResourcesImpl::open(&db_path) + .await + .expect("打开会话库失败"), + ); + let workspace = resources + .resolve_workspace(db.path()) + .await + .expect("解析工作区失败"); + let thread_id: ThreadId = uuid::Uuid::now_v7().to_string(); + let lease = resources + .create_session(&NewSession { + thread_id: thread_id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: None, + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding::from_workspace(&workspace), + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"test\":true}"), + }) + .await + .expect("创建会话失败"); + let cwd = workspace.cwd.to_string_lossy().into_owned(); + Self { + resources, + thread_id, + cwd, + db_path, + _lease: lease, + _db: db, + } + } + + /// 门面句柄(交给 CommandContext / 另开只读句柄时用)。 + fn resources(&self) -> Arc { + Arc::clone(&self.resources) + } + + /// 同一库的只读句柄:数据可读、能力面为 `HistoryReadOnly`——完整 lifecycle + /// 无法完成(与迁前 FilesystemThreadStore 的能力面一致)。 + async fn open_read_only(&self) -> Arc { + Arc::new( + peri_resources::sessions::SessionResourcesImpl::open_existing_read_only(&self.db_path) + .await + .expect("只读打开会话库失败"), + ) + } + + /// 把可见 history 存进会话(与生产 append 同一行为)。 + async fn append(&self, history: &[BaseMessage]) { + let payloads: Vec = history + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect(); + self.resources + .append_history(&self.thread_id, &payloads) + .await + .expect("持久化初始 history 失败"); + } + + /// 一次一致快照。 + async fn snapshot(&self) -> SessionSnapshot { + self.resources + .load_session_snapshot(&self.thread_id) + .await + .expect("加载会话快照失败") + } + + /// 已持久化的消息本体(reminder 不进入 compact 输入,与生产同语义)。 + async fn stored_messages(&self) -> Vec { + self.snapshot() + .await + .payloads + .iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect() + } + + /// 已持久化的投影 flag。 + async fn stored_flags(&self) -> HashMap { + self.snapshot().await.flags + } +} + // ── extract_file_info 测试 ─────────────────────────────────────────── // 注意:[v2] extract_file_info / extract_skill_names 已迁移到 peri_agent::agent::compact_v2, // 通过 `use super::*` 间接可见。这里显式引用以保持独立可读。 @@ -463,36 +582,24 @@ fn make_human_with_skill_marker(skill_path: &str) -> BaseMessage { #[tokio::test] async fn test_compact_pipeline_uses_bound_sqlite_lifecycle() { - let dir = tempfile::tempdir().expect("创建临时目录失败"); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("compact-pipeline.db")) - .await - .expect("创建 SQLite store 失败"), - ); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) - .await - .expect("创建 thread 失败"); + let session = BoundSession::open("compact-pipeline.db").await; let history = vec![ BaseMessage::system("pipeline system prompt"), BaseMessage::human("pipeline user question"), BaseMessage::ai("pipeline assistant response"), ]; - store - .append_messages(&thread_id, &history) - .await - .expect("持久化初始 history 失败"); + session.append(&history).await; let sink = Arc::new(MockEventSink::new()); let ctx = make_ctx_with_model_and_thread( sink, history.clone(), - dir.path().to_string_lossy().to_string(), + session.cwd.clone(), Arc::new(MockSummaryModel::new( "PIPELINE_LIFECYCLE_MARKER", )), - Some(store.clone()), - Some(thread_id.clone()), + Some(session.resources()), + Some(session.thread_id.clone()), ); let result = execute_compact(&CompactCommand, ctx).await; @@ -505,20 +612,14 @@ async fn test_compact_pipeline_uses_bound_sqlite_lifecycle() { .any(|message| message.content().contains("PIPELINE_LIFECYCLE_MARKER")), "成功结果必须含 summary" ); - let stored_history = store - .load_messages(&thread_id) - .await - .expect("加载 SQLite history 失败"); + let stored_history = session.stored_messages().await; assert!( stored_history .iter() .any(|message| message.content().contains("PIPELINE_LIFECYCLE_MARKER")), "绑定 store 的 lifecycle 必须持久化 summary" ); - let flags = store - .load_message_flags(&thread_id) - .await - .expect("加载 SQLite flags 失败"); + let flags = session.stored_flags().await; assert!(!flags.contains_key(&history[0].id()), "System 不得被排除"); assert!(flags[&history[1].id()].excluded, "Human 必须被排除"); assert!(flags[&history[2].id()].excluded, "AI 必须被排除"); @@ -526,43 +627,28 @@ async fn test_compact_pipeline_uses_bound_sqlite_lifecycle() { #[tokio::test] async fn test_compact_pipeline_does_not_append_preexisting_history_to_bound_thread() { - let dir = tempfile::tempdir().expect("创建临时目录失败"); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("compact-existing-history.db")) - .await - .expect("创建 SQLite store 失败"), - ); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) - .await - .expect("创建 thread 失败"); + let session = BoundSession::open("compact-existing-history.db").await; let history = vec![ BaseMessage::human("already persisted user question"), BaseMessage::ai("already persisted assistant response"), ]; - store - .append_messages(&thread_id, &history) - .await - .expect("持久化初始 history 失败"); + session.append(&history).await; let ctx = make_ctx_with_model_and_thread( Arc::new(MockEventSink::new()), history.clone(), - dir.path().to_string_lossy().to_string(), + session.cwd.clone(), Arc::new(MockSummaryModel::new( "EXISTING_HISTORY_NOT_DUPLICATED", )), - Some(store.clone()), - Some(thread_id.clone()), + Some(session.resources()), + Some(session.thread_id.clone()), ); let result = execute_compact(&CompactCommand, ctx).await; assert_eq!(result.stop_reason, PromptStopReason::EndTurn); - let stored_history = store - .load_messages(&thread_id) - .await - .expect("加载 SQLite history 失败"); + let stored_history = session.stored_messages().await; assert_eq!( stored_history.len(), history.len() + 1, @@ -589,25 +675,13 @@ async fn test_compact_pipeline_does_not_append_preexisting_history_to_bound_thre #[tokio::test] async fn test_compact_pipeline_reuses_visible_result_history_for_second_bound_sqlite_lifecycle() { - let dir = tempfile::tempdir().expect("创建临时目录失败"); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("compact-second-lifecycle.db")) - .await - .expect("创建 SQLite store 失败"), - ); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) - .await - .expect("创建 thread 失败"); + let session = BoundSession::open("compact-second-lifecycle.db").await; let history = vec![ BaseMessage::system("persistent system prompt"), BaseMessage::human("first compact request"), BaseMessage::ai("first compact response"), ]; - store - .append_messages(&thread_id, &history) - .await - .expect("持久化初始 history 失败"); + session.append(&history).await; let first_sink = Arc::new(MockEventSink::new()); let first = execute_compact( @@ -615,12 +689,12 @@ async fn test_compact_pipeline_reuses_visible_result_history_for_second_bound_sq make_ctx_with_model_and_thread( first_sink.clone(), history, - dir.path().to_string_lossy().to_string(), + session.cwd.clone(), Arc::new(MockSummaryModel::new( "FIRST_COMPACT_LIFECYCLE_SUMMARY", )), - Some(store.clone()), - Some(thread_id.clone()), + Some(session.resources()), + Some(session.thread_id.clone()), ), ) .await; @@ -638,12 +712,12 @@ async fn test_compact_pipeline_reuses_visible_result_history_for_second_bound_sq make_ctx_with_model_and_thread( second_sink.clone(), first.messages, - dir.path().to_string_lossy().to_string(), + session.cwd.clone(), Arc::new(MockSummaryModel::new( "SECOND_COMPACT_LIFECYCLE_SUMMARY", )), - Some(store.clone()), - Some(thread_id.clone()), + Some(session.resources()), + Some(session.thread_id.clone()), ), ) .await; @@ -666,10 +740,7 @@ async fn test_compact_pipeline_reuses_visible_result_history_for_second_bound_sq .contains("SECOND_COMPACT_LIFECYCLE_SUMMARY")), "第二次 compact 必须返回新的 summary" ); - let stored = store - .load_messages(&thread_id) - .await - .expect("加载 SQLite history 失败"); + let stored = session.stored_messages().await; assert_eq!( stored .iter() @@ -681,41 +752,35 @@ async fn test_compact_pipeline_reuses_visible_result_history_for_second_bound_sq } #[tokio::test] -async fn test_compact_pipeline_filesystem_lifecycle_failure_preserves_durable_message_ids() { - let dir = tempfile::tempdir().expect("创建临时目录失败"); - let store: Arc = - Arc::new(FilesystemThreadStore::new(dir.path().join("threads"))); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) - .await - .expect("创建 thread 失败"); +async fn test_compact_pipeline_history_read_only_lifecycle_failure_preserves_durable_message_ids() { + // 迁前本测试用 FilesystemThreadStore(能力面 = 只读历史)。新契约下等价的能力面 + // 是只读打开的同一库:数据可读、`DataCapabilities::HistoryReadOnly`,完整 + // lifecycle 无法完成。 + let session = BoundSession::open("compact-read-only-lifecycle.db").await; let history = vec![ - BaseMessage::human("filesystem compact request"), - BaseMessage::ai("filesystem compact response"), + BaseMessage::human("read-only compact request"), + BaseMessage::ai("read-only compact response"), ]; - store - .append_messages(&thread_id, &history) - .await - .expect("持久化初始 Filesystem history 失败"); - let before_ids = store - .load_messages(&thread_id) + session.append(&history).await; + let before_ids = session + .stored_messages() .await - .expect("加载初始 Filesystem history 失败") .iter() .map(BaseMessage::id) .collect::>(); + let read_only = session.open_read_only().await; let result = execute_compact( &CompactCommand, make_ctx_with_model_and_thread( Arc::new(MockEventSink::new()), history.clone(), - dir.path().to_string_lossy().to_string(), + session.cwd.clone(), Arc::new(MockSummaryModel::new( - "FILESYSTEM_LIFECYCLE_MUST_NOT_APPEND", + "READ_ONLY_LIFECYCLE_MUST_NOT_APPEND", )), - Some(store.clone()), - Some(thread_id.clone()), + Some(read_only), + Some(session.thread_id.clone()), ), ) .await; @@ -728,33 +793,23 @@ async fn test_compact_pipeline_filesystem_lifecycle_failure_preserves_durable_me .map(BaseMessage::id) .collect::>(), history.iter().map(BaseMessage::id).collect::>(), - "Filesystem lifecycle 不受支持时必须返回原始 history" + "只读能力面下 lifecycle 不受支持时必须返回原始 history" ); assert_eq!( - store - .load_messages(&thread_id) + session + .stored_messages() .await - .expect("加载 Filesystem history 失败") .iter() .map(BaseMessage::id) .collect::>(), before_ids, - "pipeline 的 preliminary append 不得向 Filesystem store 写入重复 message IDs" + "pipeline 的 preliminary append 不得向只读后端写入重复 message IDs" ); } #[tokio::test] async fn test_compact_pipeline_rejects_incoming_history_that_differs_from_bound_thread() { - let dir = tempfile::tempdir().expect("创建临时目录失败"); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("compact-history-mismatch.db")) - .await - .expect("创建 SQLite store 失败"), - ); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) - .await - .expect("创建 thread 失败"); + let session = BoundSession::open("compact-history-mismatch.db").await; let stored_history = vec![ BaseMessage::human("stored user question"), BaseMessage::ai("stored assistant response"), @@ -774,21 +829,18 @@ async fn test_compact_pipeline_rejects_incoming_history_that_differs_from_bound_ .collect::>(), "fixture 的 stored 与 incoming history 必须具有不同 ID" ); - store - .append_messages(&thread_id, &stored_history) - .await - .expect("持久化 stored history 失败"); + session.append(&stored_history).await; let sink = Arc::new(MockEventSink::new()); let ctx = make_ctx_with_model_and_thread( sink.clone(), incoming_history.clone(), - dir.path().to_string_lossy().to_string(), + session.cwd.clone(), Arc::new(MockSummaryModel::new( "HISTORY_MISMATCH_MUST_NOT_BE_PERSISTED", )), - Some(store.clone()), - Some(thread_id.clone()), + Some(session.resources()), + Some(session.thread_id.clone()), ); let result = execute_compact(&CompactCommand, ctx).await; @@ -815,10 +867,7 @@ async fn test_compact_pipeline_rejects_incoming_history_that_differs_from_bound_ "命令自身不应发射 CompactError 事件(Phase 5 Step 4 收敛为 feedback)" ); - let persisted_history = store - .load_messages(&thread_id) - .await - .expect("加载 SQLite history 失败"); + let persisted_history = session.stored_messages().await; assert_eq!( persisted_history .iter() @@ -837,18 +886,13 @@ async fn test_compact_pipeline_rejects_incoming_history_that_differs_from_bound_ "不匹配时不得持久化 summary" ); assert!( - store - .load_message_flags(&thread_id) - .await - .expect("加载 SQLite flags 失败") - .is_empty(), + session.stored_flags().await.is_empty(), "不匹配时不得写入 message flags" ); } #[tokio::test] async fn test_compact_pipeline_without_thread_binding_returns_error_without_mutating_history() { - let dir = tempfile::tempdir().expect("创建临时目录失败"); let history = vec![ BaseMessage::human("unbound user question"), BaseMessage::ai("unbound assistant response"), @@ -857,7 +901,7 @@ async fn test_compact_pipeline_without_thread_binding_returns_error_without_muta let ctx = make_ctx_with_model_and_thread( sink.clone(), history.clone(), - dir.path().to_string_lossy().to_string(), + "/tmp".to_string(), Arc::new(MockSummaryModel::new( "UNBOUND_MUST_NOT_COMPACT", )), @@ -909,13 +953,7 @@ async fn test_contract_compact_output_starts_with_human_summary() { let sink = Arc::new(MockEventSink::new()); let model = Arc::new(MockSummaryModel::new("## 摘要\n已完成 main.rs 审查")); - let ctx = make_ctx_with_model( - sink.clone(), - history, - dir.path().to_string_lossy().to_string(), - model, - ) - .await; + let (ctx, _session) = make_ctx_with_model(sink.clone(), history, model).await; let cmd = CompactCommand; // Act @@ -972,13 +1010,7 @@ async fn test_contract_compact_output_structure_human_then_system_only() { let sink = Arc::new(MockEventSink::new()); let model = Arc::new(MockSummaryModel::new("## 摘要\n审查 lib.rs 与 tdd skill")); - let ctx = make_ctx_with_model( - sink.clone(), - history, - dir.path().to_string_lossy().to_string(), - model, - ) - .await; + let (ctx, _session) = make_ctx_with_model(sink.clone(), history, model).await; let cmd = CompactCommand; // Act @@ -1024,8 +1056,7 @@ async fn test_contract_compact_output_structure_human_then_system_only() { /// 这是一个 "negative contract":断言没有任何 System 消息的文本包含摘要内容。 #[tokio::test] async fn test_contract_summary_not_in_system_message() { - // Arrange: 简单 history - let dir = tempfile::tempdir().expect("创建临时目录失败"); + // Arrange: 简单 history(会话夹具自带临时工作区) let history = vec![ BaseMessage::system("系统提示词"), BaseMessage::human("你好"), @@ -1035,13 +1066,7 @@ async fn test_contract_summary_not_in_system_message() { let unique_marker = "UNIQUE_SUMMARY_MARKER_2026"; let sink = Arc::new(MockEventSink::new()); let model = Arc::new(MockSummaryModel::new(format!("## 摘要\n{}", unique_marker))); - let ctx = make_ctx_with_model( - sink.clone(), - history, - dir.path().to_string_lossy().to_string(), - model, - ) - .await; + let (ctx, _session) = make_ctx_with_model(sink.clone(), history, model).await; let cmd = CompactCommand; // Act @@ -1070,7 +1095,6 @@ async fn test_contract_summary_not_in_system_message() { #[tokio::test] async fn test_contract_compact_completed_event_matches_result_messages() { // Arrange - let dir = tempfile::tempdir().expect("创建临时目录失败"); let history = vec![ BaseMessage::system("系统提示词"), BaseMessage::human("你好"), @@ -1079,13 +1103,7 @@ async fn test_contract_compact_completed_event_matches_result_messages() { let sink = Arc::new(MockEventSink::new()); let model = Arc::new(MockSummaryModel::new("## 摘要\n简单对话")); - let ctx = make_ctx_with_model( - sink.clone(), - history, - dir.path().to_string_lossy().to_string(), - model, - ) - .await; + let (ctx, _session) = make_ctx_with_model(sink.clone(), history, model).await; let cmd = CompactCommand; // Act @@ -1116,8 +1134,7 @@ async fn test_contract_compact_completed_event_matches_result_messages() { /// (对应 full.rs: non_system_count == 0 分支) #[tokio::test] async fn test_contract_all_system_history_still_human_first() { - // Arrange: 全 System history - let dir = tempfile::tempdir().expect("创建临时目录失败"); + // Arrange: 全 System history(会话夹具自带临时工作区) let history = vec![ BaseMessage::system("系统提示词 1"), BaseMessage::system("系统提示词 2"), @@ -1126,13 +1143,7 @@ async fn test_contract_all_system_history_still_human_first() { let sink = Arc::new(MockEventSink::new()); // 即使 LLM 被调用返回内容,也不影响首条 Human 契约 let model = Arc::new(MockSummaryModel::new("## 摘要\n不应到达此处")); - let ctx = make_ctx_with_model( - sink.clone(), - history, - dir.path().to_string_lossy().to_string(), - model, - ) - .await; + let (ctx, _session) = make_ctx_with_model(sink.clone(), history, model).await; let cmd = CompactCommand; // Act diff --git a/peri-acp/src/session/command/rewind.rs b/peri-acp/src/session/command/rewind.rs index 6b0c629bc..bad339515 100644 --- a/peri-acp/src/session/command/rewind.rs +++ b/peri-acp/src/session/command/rewind.rs @@ -182,11 +182,13 @@ pub(crate) async fn execute_rewind( // Step 4: 验证 ToolUse/ToolResult 配对完整性 validate_tool_pairing(&retained_messages); - // Step 5: 从持久化中删除被移除的消息 + // Step 5: 从持久化中删除被移除的消息(一次门面行为,缓存/计数同事务维护)。 + // 用户 rewind 的边界是 RemoveFrom:目标消息及其之后全部移除(与 transcript 的 + // KeepThrough 不同义,两者都经同一门面的显式边界执行)。 let removed_ids: Vec = removed_messages.iter().map(|m| m.id()).collect(); - if let (Some(store), Some(tid)) = (&ctx.thread_store, &ctx.thread_id) { + if let (Some(store), Some(tid)) = (&ctx.session_resources, &ctx.thread_id) { if !removed_ids.is_empty() { - match store.delete_messages(tid, &removed_ids).await { + match store.remove_history_entries(tid, &removed_ids).await { Ok(()) => debug!(count = removed_ids.len(), "rewind: 持久化消息已删除"), Err(e) => { let msg = format!("rewind: 持久化删除失败: {e}"); diff --git a/peri-acp/src/session/frozen.rs b/peri-acp/src/session/frozen.rs index d2d33a9e0..381e9d840 100644 --- a/peri-acp/src/session/frozen.rs +++ b/peri-acp/src/session/frozen.rs @@ -39,6 +39,26 @@ impl SessionManager { cwd: &str, plugin_skill_roots: &[peri_acp_types::skills::SkillRoot], plugin_agent_dirs: &[std::path::PathBuf], + ) -> crate::session::executor::FrozenSessionData { + // 调用点未准备运行环境:在此探测一次并委托冻结渲染,装配期不再各自取一份。 + let runtime_env = crate::prompt::PromptRuntimeEnv::detect(cwd); + self.build_frozen_data_with_config_and_runtime( + config, + cwd, + plugin_skill_roots, + plugin_agent_dirs, + &runtime_env, + ) + } + + /// 会话准备路径入口:日期与运行环境由准备阶段定格,冻结渲染只消费该结果。 + pub(crate) fn build_frozen_data_with_config_and_runtime( + &self, + config: &crate::provider::PeriConfig, + cwd: &str, + plugin_skill_roots: &[peri_acp_types::skills::SkillRoot], + plugin_agent_dirs: &[std::path::PathBuf], + runtime_env: &crate::prompt::PromptRuntimeEnv, ) -> crate::session::executor::FrozenSessionData { let frozen_date = chrono::Local::now().format("%Y-%m-%d").to_string(); let frozen_language = config.config.language.clone(); @@ -70,7 +90,7 @@ impl SessionManager { let collected = build_collected_sections(&meta_harness_state, None, frozen_language.as_deref()); let template = crate::prompt::PromptTemplate::new(&meta_harness_state, &collected); - let env = crate::prompt::PromptEnv::with_frozen_date(cwd, &frozen_date); + let env = crate::prompt::PromptEnv::frozen(cwd, &frozen_date, runtime_env); let system_prompt = template.render( &env, &features, diff --git a/peri-acp/src/session/mod.rs b/peri-acp/src/session/mod.rs index 281e9193c..6ace59786 100644 --- a/peri-acp/src/session/mod.rs +++ b/peri-acp/src/session/mod.rs @@ -49,11 +49,9 @@ use peri_acp_types::command_registry::CommandRegistry; use peri_acp_types::mcp_skills::McpSkillRegistry; use peri_acp_types::messages::BaseMessage; use peri_acp_types::permission::SharedPermissionMode; +use peri_acp_types::session_resources::SessionResources; use peri_acp_types::skills::SkillRoot; -use peri_acp_types::{ - store::ThreadStore, - thread::{ThreadId, ThreadMeta}, -}; +use peri_acp_types::thread::ThreadId; use tokio_util::sync::CancellationToken; use peri_acp_types::PeriCaps; @@ -137,7 +135,8 @@ pub struct AcpSession { struct SessionManagerInner { sessions: Arc>, - thread_store: Arc, + /// 会话资源门面(会话行为唯一入口):SessionManager 只转发句柄,不另持裸存储。 + session_resources: Arc, provider: LlmProvider, peri_config: Arc, permission_mode: Arc, @@ -213,7 +212,7 @@ impl AcpSession { impl SessionManager { #[allow(clippy::too_many_arguments)] // 装配注入面:端口/工厂逐项注入,L5 装配迁出后可分组 pub fn new( - thread_store: Arc, + session_resources: Arc, provider: LlmProvider, peri_config: Arc, permission_mode: Arc, @@ -229,7 +228,7 @@ impl SessionManager { Self { inner: Arc::new(SessionManagerInner { sessions: Arc::new(DashMap::new()), - thread_store, + session_resources, provider, peri_config, permission_mode, @@ -333,8 +332,9 @@ impl SessionManager { .collect() } - pub async fn list_sessions(&self) -> anyhow::Result> { - self.inner.thread_store.list_threads().await + /// 会话资源门面句柄(AcpSession 之外的会话行为入口)。 + pub fn session_resources(&self) -> &Arc { + &self.inner.session_resources } pub fn get_session( @@ -385,10 +385,6 @@ impl SessionManager { &self.inner.permission_mode } - pub fn thread_store(&self) -> &Arc { - &self.inner.thread_store - } - pub fn agent_overrides(&self) -> Option<&AgentOverrides> { self.inner.agent_overrides.as_ref() } diff --git a/peri-acp/src/session/mod_test.rs b/peri-acp/src/session/mod_test.rs index b4571ffdf..06eeb6d45 100644 --- a/peri-acp/src/session/mod_test.rs +++ b/peri-acp/src/session/mod_test.rs @@ -16,7 +16,6 @@ use crate::provider::{ LlmProvider, PeriConfig, ProfileConfig, Profiles, ProviderConfig, ProviderModels, }; use crate::session::SessionManager; -use peri_agent::thread::FilesystemThreadStore; use peri_middlewares::prelude::{PermissionMode, SharedPermissionMode}; // ── 辅助函数 ────────────────────────────────────────────────────────────────── @@ -35,14 +34,17 @@ fn make_provider_config(id: &str, model: &str) -> ProviderConfig { } /// 构造测试用 SessionManager + 临时 thread store -fn make_session_manager(tmp: &tempfile::TempDir) -> SessionManager { - make_manager_with_cron_option(tmp, None) +async fn make_session_manager(tmp: &tempfile::TempDir) -> SessionManager { + make_manager_with_cron_option(tmp, None).await } /// 构造关闭 `SkillsMiddleware` 的 SessionManager,用于验证 MetaHarness 对 /// slash 路由的关闭面也生效,避免 `/skill` 绕过 middleware 装配。 -fn make_session_manager_skills_disabled(tmp: &tempfile::TempDir) -> SessionManager { - let thread_store = Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); +async fn make_session_manager_skills_disabled(tmp: &tempfile::TempDir) -> SessionManager { + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![make_provider_config("a", "gpt-4o")]; @@ -59,7 +61,7 @@ fn make_session_manager_skills_disabled(tmp: &tempfile::TempDir) -> SessionManag )])); let provider = LlmProvider::from_config(&peri_config).unwrap(); SessionManager::new( - thread_store, + session_resources, provider, Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -78,7 +80,7 @@ fn make_session_manager_skills_disabled(tmp: &tempfile::TempDir) -> SessionManag /// /// scheduler 的 primary tx 直接丢弃(同 TUI `cron_state.rs:13` 模式)—— /// 本测试路径不消费 primary trigger 通道,只验证 extra_trigger_txs(bridge)路径。 -fn make_session_manager_with_cron( +async fn make_session_manager_with_cron( tmp: &tempfile::TempDir, ) -> ( SessionManager, @@ -88,35 +90,38 @@ fn make_session_manager_with_cron( let scheduler = Arc::new(parking_lot::Mutex::new( peri_middlewares::cron::CronScheduler::new(tokio::sync::mpsc::unbounded_channel().0), )); - let manager = make_manager_with_cron_option(tmp, Some(scheduler.clone())); + let manager = make_manager_with_cron_option(tmp, Some(scheduler.clone())).await; let (continuation_tx, continuation_rx) = tokio::sync::mpsc::unbounded_channel(); manager.bind_cron_continuation(continuation_tx); (manager, scheduler, continuation_rx) } /// 同 make_session_manager,仅 SessionManager::new 末参按需传入 cron scheduler。 -fn make_manager_with_cron_option( +async fn make_manager_with_cron_option( tmp: &tempfile::TempDir, cron_scheduler: Option>>, ) -> SessionManager { - make_manager_inner(tmp, cron_scheduler, Vec::new()) + make_manager_inner(tmp, cron_scheduler, Vec::new()).await } /// Phase 6 B2:构造带插件命令静态条目的 SessionManager(cron 无)。 -fn make_manager_with_plugin_entries( +async fn make_manager_with_plugin_entries( tmp: &tempfile::TempDir, plugin_entries: Vec, ) -> SessionManager { - make_manager_inner(tmp, None, plugin_entries) + make_manager_inner(tmp, None, plugin_entries).await } /// 通用构造:cron scheduler + 插件命令静态条目可组合注入。 -fn make_manager_inner( +async fn make_manager_inner( tmp: &tempfile::TempDir, cron_scheduler: Option>>, plugin_entries: Vec, ) -> SessionManager { - let thread_store = Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![make_provider_config("a", "gpt-4o")]; @@ -129,7 +134,7 @@ fn make_manager_inner( }; let provider = LlmProvider::from_config(&peri_config).unwrap(); SessionManager::new( - thread_store, + session_resources, provider, Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -192,11 +197,14 @@ impl peri_acp_types::mcp::McpSubscriptionPort for FakeMcpSubscriptionPort { } /// 同 make_session_manager,仅 MCP 订阅端口参数按需注入(mcp_subscription_for 测试用)。 -fn make_manager_with_mcp_subscription( +async fn make_manager_with_mcp_subscription( tmp: &tempfile::TempDir, mcp_subscription: Option>, ) -> SessionManager { - let thread_store = Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![make_provider_config("a", "gpt-4o")]; @@ -209,7 +217,7 @@ fn make_manager_with_mcp_subscription( }; let provider = LlmProvider::from_config(&peri_config).unwrap(); SessionManager::new( - thread_store, + session_resources, provider, Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -230,7 +238,7 @@ fn make_manager_with_mcp_subscription( #[tokio::test] async fn test_ensure_session_幂等不覆盖已有记录() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; let session_id = "test-session-idempotent"; // 第一次插入 @@ -265,7 +273,7 @@ async fn test_ensure_session_幂等不覆盖已有记录() { #[tokio::test] async fn test_goal_state_for_不存在返回none() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; assert!( mgr.goal_state_for("non-existent").is_none(), "不存在的 session_id 应返回 None" @@ -276,7 +284,7 @@ async fn test_goal_state_for_不存在返回none() { #[tokio::test] async fn test_build_frozen_data_返回非空system_prompt() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; let frozen = mgr.build_frozen_data(tmp.path().to_str().unwrap(), &[], &[]); assert!( @@ -294,7 +302,7 @@ async fn test_build_frozen_data_返回非空system_prompt() { #[tokio::test] async fn test_cancel_cascade_children_for_不存在不panic() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; // 不应 panic mgr.cancel_cascade_children_for("non-existent"); } @@ -303,7 +311,7 @@ async fn test_cancel_cascade_children_for_不存在不panic() { #[tokio::test] async fn test_close_session_移除记录后goal_state返回none() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; let session_id = "test-close-session"; mgr.ensure_session(session_id, "/tmp"); @@ -319,7 +327,7 @@ async fn test_close_session_移除记录后goal_state返回none() { #[tokio::test] async fn test_pre_close_cancels_but_preserves_record_until_terminal_close() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; let session_id = "pre-close-session"; mgr.ensure_session(session_id, tmp.path().to_str().unwrap()); let cancel = mgr @@ -341,7 +349,7 @@ async fn test_pre_close_cancels_but_preserves_record_until_terminal_close() { #[tokio::test] async fn test_ensure_session_subscribes_cron_before_first_turn() { let tmp = tempfile::TempDir::new().unwrap(); - let (mgr, scheduler, mut continuation_rx) = make_session_manager_with_cron(&tmp); + let (mgr, scheduler, mut continuation_rx) = make_session_manager_with_cron(&tmp).await; let session_id = "test-cron-before-first-turn"; mgr.ensure_session(session_id, "/tmp"); @@ -371,7 +379,7 @@ async fn test_ensure_session_subscribes_cron_before_first_turn() { #[tokio::test] async fn test_cron_bridge_survives_turn_error() { let tmp = tempfile::TempDir::new().unwrap(); - let (mgr, scheduler, mut continuation_rx) = make_session_manager_with_cron(&tmp); + let (mgr, scheduler, mut continuation_rx) = make_session_manager_with_cron(&tmp).await; let session_id = "test-cron-turn-error"; mgr.ensure_session(session_id, "/tmp"); assert!(mgr.cron_bridge_for(session_id)); @@ -416,7 +424,7 @@ async fn test_cron_bridge_survives_turn_error() { #[tokio::test] async fn test_cron_bridge_idle_trigger_forwards_continuation_without_early_enqueue() { let tmp = tempfile::TempDir::new().unwrap(); - let (mgr, scheduler, mut continuation_rx) = make_session_manager_with_cron(&tmp); + let (mgr, scheduler, mut continuation_rx) = make_session_manager_with_cron(&tmp).await; let session_id = "test-cron-idle"; mgr.ensure_session(session_id, "/tmp"); assert!(mgr.cron_bridge_for(session_id)); @@ -452,7 +460,7 @@ async fn test_cron_bridge_idle_trigger_forwards_continuation_without_early_enque #[tokio::test] async fn test_pending_caps_consumed_once_second_session_gets_negotiated() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; // initialize 协商:仅部分 cap 开启 let negotiated = peri_acp_types::PeriCaps { @@ -489,7 +497,7 @@ async fn test_pending_caps_consumed_once_second_session_gets_negotiated() { #[tokio::test] async fn test_pending_caps_double_fallback_semantics() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; // 不调用 set_pending_caps(MpscTransport / TUI 内部路径,无 initialize) let consumed = mgr.consume_pending_caps("t1"); @@ -510,7 +518,7 @@ async fn test_pending_caps_double_fallback_semantics() { #[tokio::test] async fn test_effective_host_caps_requires_external_negotiation_but_preserves_internal_path() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; assert!( mgr.effective_host_caps().oauth, "未 initialize 的进程内 TUI 路径保持 all_enabled" @@ -537,7 +545,8 @@ async fn test_mcp_subscription_for_幂等注册() { let mgr = make_manager_with_mcp_subscription( &tmp, Some(port.clone() as Arc), - ); + ) + .await; let session_id = "test-mcp-sub-idempotent"; mgr.ensure_session(session_id, "/tmp"); @@ -567,7 +576,8 @@ async fn test_mcp_subscription_for_session不存在返回false() { let mgr = make_manager_with_mcp_subscription( &tmp, Some(port.clone() as Arc), - ); + ) + .await; assert!(!mgr.mcp_subscription_for("non-existent")); assert_eq!(port.inbox_count(), 0, "session 不存在时不得注册"); } @@ -580,7 +590,8 @@ async fn test_mcp_subscription_for_close_session后返回false() { let mgr = make_manager_with_mcp_subscription( &tmp, Some(port.clone() as Arc), - ); + ) + .await; let session_id = "test-mcp-sub-close"; mgr.ensure_session(session_id, "/tmp"); assert!(mgr.mcp_subscription_for(session_id)); @@ -605,7 +616,7 @@ async fn test_mcp_subscription_for_close_session后返回false() { #[tokio::test] async fn test_mcp_subscription_for未注入端口返回false() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; let session_id = "test-mcp-sub-no-port"; mgr.ensure_session(session_id, "/tmp"); assert!( @@ -620,7 +631,7 @@ async fn test_mcp_subscription_for未注入端口返回false() { #[tokio::test] async fn test_mcp_skill_registry_lifecycle_released_on_close() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; let session_id = "test-registry-lifecycle"; mgr.ensure_session(session_id, "/tmp"); @@ -783,7 +794,10 @@ async fn test_build_frozen_data_applies_meta_harness_state() { std::fs::write(meta_dir.join("01_intro.md"), "CUSTOM-INTRO-BODY").unwrap(); std::fs::write(meta_dir.join("05_using_tools.md"), "CUSTOM-TOOLS-BODY").unwrap(); - let thread_store = Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![make_provider_config("a", "gpt-4o")]; @@ -801,7 +815,7 @@ async fn test_build_frozen_data_applies_meta_harness_state() { ])); let provider = LlmProvider::from_config(&peri_config).unwrap(); let mgr = SessionManager::new( - thread_store, + session_resources, provider, Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -857,7 +871,10 @@ async fn test_frozen_data_does_not_reread_meta_docs() { std::fs::create_dir_all(&meta_dir).unwrap(); std::fs::write(meta_dir.join("01_intro.md"), "V1-BODY").unwrap(); - let thread_store = Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))); + let session_resources = + peri_agent::resources::open_session_resources_with(Some(tmp.path().join("threads.db"))) + .await + .unwrap(); let mut peri_config = PeriConfig::default(); peri_config.config.active_alias = "sonnet".to_string(); peri_config.config.providers = vec![make_provider_config("a", "gpt-4o")]; @@ -871,7 +888,7 @@ async fn test_frozen_data_does_not_reread_meta_docs() { peri_config.config.meta_harness = Some(mh_cfg(&[("01_intro", true)])); let provider = LlmProvider::from_config(&peri_config).unwrap(); let mgr = SessionManager::new( - thread_store, + session_resources, provider, Arc::new(peri_config), SharedPermissionMode::new(PermissionMode::Bypass), @@ -935,7 +952,7 @@ fn write_local_skill(cwd: &std::path::Path, dir: &str, skill_name: &str) { async fn test_session_creation_registers_local_skills_core_domain() { let tmp = tempfile::TempDir::new().unwrap(); write_local_skill(tmp.path(), "hello", "hello"); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); @@ -951,7 +968,7 @@ async fn test_session_creation_registers_local_skills_core_domain() { #[tokio::test] async fn test_session_creation_registers_builtin_skill_alias() { let tmp = tempfile::TempDir::new().unwrap(); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); @@ -967,7 +984,7 @@ async fn test_session_creation_registers_builtin_skill_alias() { async fn test_session_creation_does_not_register_skills_when_disabled() { let tmp = tempfile::TempDir::new().unwrap(); write_local_skill(tmp.path(), "hello", "hello"); - let mgr = make_session_manager_skills_disabled(&tmp); + let mgr = make_session_manager_skills_disabled(&tmp).await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); @@ -981,7 +998,7 @@ async fn test_session_creation_does_not_register_skills_when_disabled() { async fn test_session_creation_core_conflict_keeps_builtin() { let tmp = tempfile::TempDir::new().unwrap(); write_local_skill(tmp.path(), "compact", "compact"); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); @@ -1011,7 +1028,7 @@ async fn test_session_creation_core_conflict_keeps_builtin() { async fn test_session_creation_normalizes_skill_name_with_colon() { let tmp = tempfile::TempDir::new().unwrap(); write_local_skill(tmp.path(), "namespaced", "foo:bar"); - let mgr = make_session_manager(&tmp); + let mgr = make_session_manager(&tmp).await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); @@ -1042,7 +1059,7 @@ async fn test_session_creation_registers_plugin_commands() { lifecycle: CommandLifecycle::Connected, }, }; - let mgr = make_manager_with_plugin_entries(&tmp, vec![plugin_entry]); + let mgr = make_manager_with_plugin_entries(&tmp, vec![plugin_entry]).await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); @@ -1083,7 +1100,8 @@ async fn test_session_creation_register_order_builtin_skill_plugin() { lifecycle: CommandLifecycle::Connected, }, }], - ); + ) + .await; mgr.ensure_session("s1", tmp.path().to_str().unwrap()); let reg = mgr.command_registry_for("s1").expect("session 注册表存在"); diff --git a/peri-agent/src/agent/compact_v2/full.rs b/peri-agent/src/agent/compact_v2/full.rs index e64d2cafc..55a1109ac 100644 --- a/peri-agent/src/agent/compact_v2/full.rs +++ b/peri-agent/src/agent/compact_v2/full.rs @@ -23,7 +23,7 @@ use crate::error::AgentResult; use crate::messages::{BaseMessage, ContentBlock, MessageContent}; use crate::session::transcript::MessageTranscript; use crate::session::MessageFlags; -use crate::thread::CompactionLifecycle; +use crate::thread::CompactionChange; // ─── 公共常量 ────────────────────────────────────────────────────────────────── @@ -70,7 +70,7 @@ pub(super) async fn full_compact_inner( let fallback_summary = "No conversation history to compact.".to_string(); let summary_message = build_summary_message(&fallback_summary); transcript - .commit_compaction_lifecycle(CompactionLifecycle { + .commit_compaction_lifecycle(CompactionChange { flag_updates: Vec::new(), appended_messages: vec![summary_message], }) @@ -155,7 +155,7 @@ pub(super) async fn full_compact_inner( let mut appended_messages = vec![build_summary_message(&summary)]; appended_messages.extend(re_inject_result.messages); transcript - .commit_compaction_lifecycle(CompactionLifecycle { + .commit_compaction_lifecycle(CompactionChange { flag_updates, appended_messages, }) diff --git a/peri-agent/src/agent/compact_v2/full_test.rs b/peri-agent/src/agent/compact_v2/full_test.rs index 24c90c647..a384069ce 100644 --- a/peri-agent/src/agent/compact_v2/full_test.rs +++ b/peri-agent/src/agent/compact_v2/full_test.rs @@ -1,6 +1,6 @@ //! Tests for full -use std::sync::Arc; +use crate::session::test_resources::mock::MockSessionResources; use async_trait::async_trait; use peri_model::{ @@ -13,7 +13,8 @@ use super::*; use crate::agent::compact_v2::config::CompactConfig; use crate::messages::{BaseMessage, ContentBlock, ImageSource, MessageContent}; use crate::session::transcript::MessageTranscript; -use crate::thread::{FilesystemThreadStore, SqliteThreadStore, ThreadMeta, ThreadStore}; +use crate::thread::ThreadMeta; +use peri_acp_types::store::PersistedPayload; fn make_human(text: &str) -> BaseMessage { BaseMessage::human(MessageContent::text(text.to_string())) @@ -109,11 +110,7 @@ impl Model for FullLifecycleModel { // 对应 spec/issues/2026-09-10-p0-full-micro-compact-churn.md。 async fn make_audit_full_history() -> (tempfile::TempDir, MessageTranscript) { let dir = tempfile::tempdir().unwrap(); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("compact-audit.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); let mut transcript = MessageTranscript::new().with_persistence(store, thread_id); for turn in 0..4 { @@ -249,12 +246,7 @@ async fn full_compact_preserves_canonical_reminder_without_flags() { ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, ReminderSource, SystemReminder, TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, }; - let dir = tempfile::tempdir().unwrap(); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("reminder-full.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); let mut transcript = MessageTranscript::new().with_persistence(store, thread_id); transcript.append(make_human("question")); @@ -296,12 +288,7 @@ async fn full_compact_preserves_canonical_reminder_without_flags() { #[tokio::test] async fn full_excludes_loaded_root_history() { - let dir = tempfile::tempdir().unwrap(); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("loaded-root-history.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); let question = make_human("previous turn question"); let answer = make_ai("previous turn answer"); @@ -348,12 +335,7 @@ async fn full_excludes_loaded_root_history() { #[tokio::test] async fn full_affected_count_tracks_only_false_to_true_transitions() { - let dir = tempfile::tempdir().unwrap(); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("affected.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); let mut transcript = MessageTranscript::new().with_persistence(store, thread_id); transcript.append(BaseMessage::system("system")); @@ -386,11 +368,7 @@ async fn consecutive_full_requires_new_visible_read_to_reinject_file() { let dir = tempfile::tempdir().unwrap(); let file_path = dir.path().join("current.txt"); std::fs::write(&file_path, "version one").unwrap(); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("reinject.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) .await @@ -464,19 +442,14 @@ async fn test_full_compact_sqlite_persists_lifecycle_and_preserves_ancestor_and_ std::fs::write(&file_path, "pub const FULL_LIFECYCLE: bool = true;\n") .expect("写入重新注入文件失败"); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("full-lifecycle.db")) - .await - .expect("创建 SQLite store 失败"), - ); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) - .await - .expect("创建 thread 失败"); + // 真门面 + 真 SQLite + 真执行所有权(写入要求本 root 有活 owner)。 + let session = crate::session::test_resources::TestSession::open().await; + let store = session.resources(); + let thread_id = session.thread_id.clone(); let ancestor = BaseMessage::human("ancestor conversation"); store - .append_message(&thread_id, ancestor.clone()) + .append_history(&thread_id, &[PersistedPayload::Message(ancestor.clone())]) .await .expect("持久化 ancestor 失败"); @@ -532,14 +505,16 @@ async fn test_full_compact_sqlite_persists_lifecycle_and_preserves_ancestor_and_ .message() .clone(); - let stored_messages = store - .load_messages(&thread_id) + // 一次一致快照:payload 与 flags 同一次读取,不拼跨时刻结果。 + let stored = store + .load_session_snapshot(&thread_id) .await - .expect("加载 SQLite history 失败"); + .expect("加载 SQLite 快照失败"); assert_eq!( - stored_messages + stored + .payloads .iter() - .map(BaseMessage::id) + .map(PersistedPayload::id) .collect::>(), transcript .entries() @@ -548,32 +523,31 @@ async fn test_full_compact_sqlite_persists_lifecycle_and_preserves_ancestor_and_ .collect::>(), "内存与 SQLite history 必须一致" ); - assert!(stored_messages + assert!(transcript + .entries() .iter() - .any(|message| message.id() == summary.id())); - assert!(stored_messages + .any(|entry| entry.id() == summary.id())); + assert!(transcript + .entries() .iter() - .any(|message| message.id() == reinject.id())); - - let stored_flags = store - .load_message_flags(&thread_id) - .await - .expect("加载 SQLite flags 失败"); - assert!(!stored_flags.contains_key(&ancestor.id())); - assert!(!stored_flags.contains_key(&own_system)); - assert!(stored_flags[&own_human].excluded); - assert!(stored_flags[&own_ai].excluded); + .any(|entry| entry.id() == reinject.id())); + assert!(!stored.flags.contains_key(&ancestor.id())); + assert!(!stored.flags.contains_key(&own_system)); + assert!(stored.flags[&own_human].excluded); + assert!(stored.flags[&own_ai].excluded); } #[tokio::test] -async fn test_full_compact_filesystem_unsupported_lifecycle_leaves_memory_and_store_unchanged() { +async fn test_full_compact_history_read_only_backend_leaves_memory_and_store_unchanged() { let dir = tempfile::tempdir().expect("创建临时目录失败"); let file_path = dir.path().join("full-lifecycle.rs"); std::fs::write(&file_path, "pub const FULL_LIFECYCLE: bool = true;\n") .expect("写入重新注入文件失败"); - let store: Arc = - Arc::new(FilesystemThreadStore::new(dir.path().join("threads"))); + // 只读能力面(`HistoryReadOnly`):能力面可在运行期变化(权限/后端变化), + // 此后所有 mutation 必须在副作用前返回 Unsupported。夹具先按可写后端建立历史, + // 再切换到只读能力面——历史本身必须由可写句柄产生,只读句柄不接受写入。 + let store = MockSessionResources::new(); let thread_id = store .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) .await @@ -595,15 +569,16 @@ async fn test_full_compact_filesystem_unsupported_lifecycle_leaves_memory_and_st .flush_persistence() .await .expect("Full compact 前应完成持久化"); + store.restrict_to_history_read_only(); let before_entries = transcript .entries() .iter() .map(|entry| entry.id()) .collect::>(); let before_store = store - .load_messages(&thread_id) + .load_payloads(&thread_id) .await - .expect("加载初始 Filesystem history 失败"); + .expect("加载初始 history 失败"); let error = full_compact_inner( &mut transcript, @@ -612,13 +587,13 @@ async fn test_full_compact_filesystem_unsupported_lifecycle_leaves_memory_and_st &dir.path().to_string_lossy(), ) .await - .expect_err("Filesystem lifecycle 必须明确不受支持"); + .expect_err("只读能力面必须明确拒绝 lifecycle"); assert!( error .to_string() - .contains("Filesystem store does not support compaction lifecycle"), - "应返回 lifecycle 不受支持错误,实际: {error}" + .contains("compact persistence did not commit"), + "应返回 lifecycle 未提交错误,实际: {error}" ); assert_eq!( transcript @@ -635,20 +610,23 @@ async fn test_full_compact_filesystem_unsupported_lifecycle_leaves_memory_and_st assert!(!transcript.flags(own_ai).excluded); assert_eq!( store - .load_messages(&thread_id) + .load_payloads(&thread_id) .await - .expect("加载 Filesystem history 失败") + .expect("加载 history 失败") + .iter() + .map(PersistedPayload::id) + .collect::>(), + before_store .iter() - .map(BaseMessage::id) + .map(PersistedPayload::id) .collect::>(), - before_store.iter().map(BaseMessage::id).collect::>(), "失败后 store history 必须原样" ); assert!( store .load_message_flags(&thread_id) .await - .expect("加载 Filesystem flags 失败") + .expect("加载 flags 失败") .is_empty(), "失败后 store flags 必须原样" ); diff --git a/peri-agent/src/agent/compact_v2/trigger_test.rs b/peri-agent/src/agent/compact_v2/trigger_test.rs index 633dde616..00a42eff9 100644 --- a/peri-agent/src/agent/compact_v2/trigger_test.rs +++ b/peri-agent/src/agent/compact_v2/trigger_test.rs @@ -7,13 +7,13 @@ use crate::agent::compact_v2::{ }; use crate::agent::events::CompactStrategy; use crate::messages::{BaseMessage, MessageContent}; +use crate::session::test_resources::mock::MockSessionResources; use crate::session::transcript::MessageTranscript; -use crate::thread::{FilesystemThreadStore, ThreadMeta, ThreadStore}; +use crate::thread::ThreadMeta; use peri_model::{ Model, ModelCapabilities, ModelError, ModelMessage, ModelRequest, ModelResponse, ModelResult, ModelStream, StopReason, }; -use std::sync::Arc; use tokio_util::sync::CancellationToken; fn make_human(text: &str) -> BaseMessage { @@ -223,14 +223,9 @@ async fn test_micro_effective_full_overlay() { #[tokio::test] async fn test_micro_then_full_success_does_not_double_count_affected_messages() { - let store_dir = tempfile::tempdir().expect("创建临时目录失败"); - let store = Arc::new( - crate::thread::SqliteThreadStore::new(store_dir.path().join("micro-full.db")) - .await - .expect("创建 SQLite store 失败"), - ); + let store = MockSessionResources::new(); let thread_id = store - .create_thread(crate::thread::ThreadMeta::new("/tmp")) + .create_thread(ThreadMeta::new("/tmp")) .await .expect("创建 thread 失败"); let mut t = MessageTranscript::new().with_persistence(store, thread_id); @@ -263,12 +258,11 @@ async fn test_micro_then_full_success_does_not_double_count_affected_messages() #[tokio::test] async fn test_force_full_failure_preserves_persistent_excluded_flags_after_prior_failure() { let dir = tempfile::tempdir().expect("创建临时目录失败"); - let store: std::sync::Arc = - std::sync::Arc::new(FilesystemThreadStore::new(dir.path().join("threads"))); + let store = MockSessionResources::new(); let thread_id = store .create_thread(ThreadMeta::new(dir.path().to_string_lossy().to_string())) .await - .expect("创建 Filesystem thread 失败"); + .expect("创建 thread 失败"); let ancestor = make_human("ancestor must remain visible"); let own_system = BaseMessage::system("own system must remain visible"); @@ -602,15 +596,9 @@ async fn test_run_compact_smart_applied_then_full_failure_preserves_effects() { async fn test_smart_then_full_success_aggregates_metrics() { // Full compact 现在需要 persistence(commit_compaction_lifecycle 要求 store)。 // 使用临时 SQLite store 满足此约束。 - let store_dir = tempfile::tempdir().expect("创建临时目录失败"); - let db_path = store_dir.path().join("test_aggregate.db"); - let store = Arc::new( - crate::thread::SqliteThreadStore::new(db_path.to_string_lossy().to_string()) - .await - .expect("创建 SQLite store 失败"), - ); + let store = MockSessionResources::new(); let thread_id = store - .create_thread(crate::thread::ThreadMeta::new("/tmp".to_string())) + .create_thread(ThreadMeta::new("/tmp".to_string())) .await .expect("创建 thread 失败"); diff --git a/peri-agent/src/agent/stages/budget_recovery_integration_test.rs b/peri-agent/src/agent/stages/budget_recovery_integration_test.rs index f4f1fbb09..7eb68a2da 100644 --- a/peri-agent/src/agent/stages/budget_recovery_integration_test.rs +++ b/peri-agent/src/agent/stages/budget_recovery_integration_test.rs @@ -6,8 +6,9 @@ use crate::middleware::{ Middleware, }; use crate::session::store::FrozenContext; +use crate::session::test_resources::mock::MockSessionResources; use crate::session::{MessageKind, MessageQueue, MessageSource, Session}; -use crate::thread::{SqliteThreadStore, ThreadId, ThreadMeta, ThreadStore}; +use crate::thread::{ThreadId, ThreadMeta}; use peri_acp_types::store::PersistedPayload; use peri_acp_types::system_reminder::{ ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, @@ -141,7 +142,7 @@ impl peri_model::Model for CountingSummaryModel { struct BudgetScenario { _dir: tempfile::TempDir, context: StageContext, - store: Arc, + store: Arc, thread_id: ThreadId, reason_calls: Arc, compact_calls: Arc, @@ -151,11 +152,7 @@ struct BudgetScenario { async fn make_scenario(cancel_on_third: bool) -> BudgetScenario { let dir = tempfile::tempdir().unwrap(); - let store = Arc::new( - SqliteThreadStore::new(dir.path().join("budget.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store .create_thread(ThreadMeta::new(dir.path().to_string_lossy())) .await @@ -270,11 +267,7 @@ async fn test_budget_recovery_loop_stops_after_two_committed_fulls() { .persist_tx_handle() .unwrap(); MessageTranscript::flush_via_tx(&tx).await.unwrap(); - let payloads = scenario - .store - .load_payloads(&scenario.thread_id) - .await - .unwrap(); + let payloads = scenario.store.payloads(); let flags = scenario .store .load_message_flags(&scenario.thread_id) diff --git a/peri-agent/src/agent/stages/stages_test.rs b/peri-agent/src/agent/stages/stages_test.rs index bc4930254..0dd5956b4 100644 --- a/peri-agent/src/agent/stages/stages_test.rs +++ b/peri-agent/src/agent/stages/stages_test.rs @@ -4,6 +4,7 @@ use crate::messages::MessageContent; use crate::middleware::capabilities as hook_state; use crate::session::queue::MessageSource; use crate::session::store::FrozenContext; +use crate::session::test_resources::mock::MockSessionResources; use crate::session::Session; /// 构造测试用 StageContext @@ -1987,23 +1988,19 @@ impl crate::tools::BaseTool for SuccessfulFullReadTool { #[tokio::test] async fn test_run_react_loop_successful_full_replaces_history_reinjects_read_file_and_resets_usage() { - use crate::thread::{SqliteThreadStore, ThreadMeta, ThreadStore}; + use crate::thread::ThreadMeta; let dir = tempfile::tempdir().unwrap(); let file_path = dir.path().join("full-reinject-marker.txt"); let file_marker = "successful full reinjected file marker"; std::fs::write(&file_path, file_marker).unwrap(); - let store = Arc::new( - SqliteThreadStore::new(dir.path().join("successful-full.db")) - .await - .unwrap(), - ); + let store = MockSessionResources::new(); let thread_id = store .create_thread(ThreadMeta::new(dir.path().to_string_lossy())) .await .unwrap(); - let store_dyn: Arc = store.clone(); + let store_dyn: Arc = store.clone(); let session = Session::new( Arc::from(dir.path().to_string_lossy().as_ref()), FrozenContext::builder().build(), @@ -2270,13 +2267,8 @@ impl crate::tools::BaseTool for AuditAlternatingOutputTool { #[tokio::test] async fn test_run_react_loop_successful_full_does_not_recompact_excluded_history() { use crate::agent::compact_v2::{planner::plan_micro, projection, CompactOutcome}; - use crate::thread::{SqliteThreadStore, ThreadMeta, ThreadStore}; - let dir = tempfile::tempdir().unwrap(); - let store: Arc = Arc::new( - SqliteThreadStore::new(dir.path().join("audit-full-churn.db")) - .await - .unwrap(), - ); + use crate::thread::ThreadMeta; + let store = MockSessionResources::new(); let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); let session = Session::new( Arc::from("/tmp"), diff --git a/peri-agent/src/agent/state.rs b/peri-agent/src/agent/state.rs index d88683001..251f57d26 100644 --- a/peri-agent/src/agent/state.rs +++ b/peri-agent/src/agent/state.rs @@ -1,17 +1,17 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::{ - messages::BaseMessage, - session::MessageQueue, - thread::{ThreadId, ThreadStore}, -}; +use crate::{messages::BaseMessage, session::MessageQueue}; /// 基础 Agent 状态(与 TypeScript BaseAgentStateType 对齐) /// /// middleware_runner 通过 `MiddlewareState` trait 桥接 v2 stages ↔ 钩子, /// `AgentState` 直接 impl `MiddlewareState`(不再经过 `State` trait 中间层)。 +/// +/// 持久化不经本类型:会话历史的事实源是 `MessageTranscript`(绑定的是 +/// `SessionResources` 门面,见 `session/transcript.rs`),本类型只承载一轮执行内 +/// 的可见状态。 #[derive(Clone, Default, Serialize, Deserialize)] pub struct AgentState { pub cwd: String, @@ -20,26 +20,10 @@ pub struct AgentState { pub current_step: usize, pub context: HashMap, pub token_tracker: crate::agent::token::TokenTracker, - /// 可选持久化后端(绑定后 add_message 自动写入) - #[serde(skip)] - store: Option>, - /// 持久化目标 thread id - #[serde(skip)] - thread_id: Option, - /// 有序持久化通道:保证消息按 add_message 调用顺序写入 SQLite, - /// 避免 tokio::spawn 的 fire-and-forget 模式因 .await 让步导致乱序。 - #[serde(skip)] - persist_tx: Option>>, - /// 持久化 writer task 的 AbortHandle(用于 shutdown 时取消 + 检测 panic) - #[serde(skip)] - persist_handle: Option, /// 会话级 recall 缓冲区:收集运行时事件通知,executor 在构建用户消息前 drain 消费。 /// 不随 session 持久化,仅存活于当前会话生命周期内。 #[serde(skip)] recall_buffer: Vec, - /// messages[..ancestor_len] = 只读祖先消息(compact 边界标记) - #[serde(skip)] - ancestor_len: usize, /// v2 MessageQueue 句柄——middleware(goal steering / stop-hook feedback) /// 通过它向 session 级共享收件箱 push 异步消息。 /// @@ -58,8 +42,6 @@ impl std::fmt::Debug for AgentState { .field("messages", &self.messages) .field("current_step", &self.current_step) .field("context", &self.context) - .field("store", &self.store.as_ref().map(|_| "ThreadStore")) - .field("thread_id", &self.thread_id) .field("token_tracker", &self.token_tracker) .finish() } @@ -87,49 +69,6 @@ impl AgentState { self.messages } - /// 绑定持久化后端,之后每次 add_message 自动写入 - /// - /// 使用有序通道 + 专用 writer 任务替代 fire-and-forget tokio::spawn, - /// 保证消息按 add_message 调用顺序写入 SQLite, - /// 避免 spawn 任务的 .await 让步导致 rowid 乱序(#history-restore-bug)。 - pub fn with_persistence( - mut self, - store: Arc, - thread_id: impl Into, - ) -> Self { - self.store = Some(store.clone()); - self.thread_id = Some(thread_id.into()); - // [TRAP] 使用 unbounded channel 是有意为之:保证消息按 push 顺序写入 SQLite, - // 规避 rowid 与并发写入的顺序歧义。代价是 SQLite append 慢时(磁盘压力 / FSYNC - // 争用 / 长会话)会在内存中积压 BaseMessage clone。 - // (CS#2 架构审查已确认:典型会话可接受;此处加 counter + 周期性 warn! 提升可观测性) - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - self.persist_tx = Some(Arc::new(tx)); - let tid = self.thread_id.clone().unwrap(); - let handle = tokio::spawn(async move { - let mut processed: u64 = 0; - let mut last_warn_at: u64 = 0; - while let Some(msg) = rx.recv().await { - if let Err(e) = store.append_message(&tid, msg).await { - tracing::warn!("ordered persist failed: {e}"); - } - processed = processed.saturating_add(1); - // 每 1000 条记录一次进度;以幂等间距输出避免日志噪声 - let bucket = processed / 1000; - if bucket > last_warn_at { - last_warn_at = bucket; - tracing::trace!( - thread_id = %tid, - processed, - "persist writer: 已写入 {processed} 条消息" - ); - } - } - }); - self.persist_handle = Some(handle.abort_handle()); - self - } - pub fn with_context(mut self, key: impl Into, value: impl Into) -> Self { self.context.insert(key.into(), value.into()); self @@ -143,27 +82,6 @@ impl AgentState { self.context.insert(key.into(), value.into()); } - /// 使用 ThreadStore 的 load_context 构建完整上下文(含祖先快照) - pub async fn with_thread_context( - thread_id: ThreadId, - store: Arc, - ) -> anyhow::Result { - let meta = store.load_meta(&thread_id).await?; - let all_messages = store.load_context(&thread_id).await?; - let own_messages = store.load_messages(&thread_id).await?; - let ancestor_len = all_messages.len().saturating_sub(own_messages.len()); - Ok(Self::new(&meta.cwd) - .with_messages_from(all_messages) - .with_ancestor_len(ancestor_len) - .with_persistence(store, thread_id)) - } - - /// 从已有消息列表填充(内部辅助) - fn with_messages_from(mut self, messages: Vec) -> Self { - self.messages = messages; - self - } - pub fn cwd(&self) -> &str { &self.cwd } @@ -177,12 +95,6 @@ impl AgentState { } pub fn add_message(&mut self, message: BaseMessage) { - // 有序持久化:通过通道发送到专用 writer 任务,保证写入顺序 - if let Some(ref tx) = self.persist_tx { - if let Err(e) = tx.send(message.clone()) { - tracing::warn!("ordered persist send failed (channel closed): {e}"); - } - } self.messages.push(message); // 消息数量超过阈值时发出警告,提示使用 /compact 压缩上下文以降低内存占用 let count = self.messages.len(); @@ -227,40 +139,10 @@ impl AgentState { std::mem::take(&mut self.recall_buffer) } - /// messages[..ancestor_len] = 只读祖先消息 - pub fn ancestor_len(&self) -> usize { - self.ancestor_len - } - - pub fn with_ancestor_len(mut self, len: usize) -> Self { - self.ancestor_len = len; - self - } - - pub fn store(&self) -> Option<&Arc> { - self.store.as_ref() - } - - pub fn own_thread_id(&self) -> Option<&ThreadId> { - self.thread_id.as_ref() - } - /// v2 MessageQueue 句柄(共享 session 级实例) pub fn v2_queue(&self) -> &MessageQueue { &self.v2_queue } - - /// 优雅关闭持久化 writer task - pub fn shutdown_persistence(&self) { - if let Some(ref handle) = self.persist_handle { - handle.abort(); - } - } - - /// 标记已关闭,后续 add_message 不再入队 - pub fn is_persistence_shutdown(&self) -> bool { - self.persist_handle.as_ref().is_none_or(|h| h.is_finished()) - } } #[cfg(test)] diff --git a/peri-agent/src/agent/workflow/agent.rs b/peri-agent/src/agent/workflow/agent.rs index 485f50fa8..77ce1e139 100644 --- a/peri-agent/src/agent/workflow/agent.rs +++ b/peri-agent/src/agent/workflow/agent.rs @@ -99,10 +99,6 @@ pub struct WorkflowAgentContext { pub frozen_date: Option, pub frozen_language: Option, - /// ThreadStore(持久化 workflow agent 消息到统一存储)。 - /// None = 不持久化(内存中运行,当前行为)。 - pub thread_store: Option>, - /// 进度事件发送通道(None = 不发送 agent_progress 事件) pub progress_tx: Option>, @@ -182,7 +178,6 @@ pub fn create_default_executor( permission_mode: None, frozen_date: None, frozen_language: None, - thread_store: None, progress_tx: None, subagent_ctx_builder: None, agent_prompt_builder: Arc::new(|_, _, _, _| String::new()), diff --git a/peri-agent/src/resources.rs b/peri-agent/src/resources.rs index 64c733d83..8912cfdaf 100644 --- a/peri-agent/src/resources.rs +++ b/peri-agent/src/resources.rs @@ -1,66 +1,80 @@ //! Resources 层访问工厂(M-res 收口:存储实例化点归 Agent 层声明边)。 //! //! §0 声明边 `Resources --> Agent`(`docs/top-level.md`):存储具体实现 -//! (`SqliteThreadStore` / `FilesystemThreadStore`)位于 peri-resources, -//! peri-agent 经本模块提供实例化工厂,供 ACP 宿主装配面 -//! (`host/stdio/init.rs` / `host/assemble.rs`)注入 thread store—— -//! ACP 层不直接依赖 Resources。 +//! (`SessionResourcesImpl`)位于 peri-resources,peri-agent 经本模块提供实例化 +//! 工厂,供 ACP 宿主装配面(`host/stdio/init.rs` / `host/assemble.rs`)与 +//! TUI/print 入口注入会话资源门面——ACP 层不直接依赖 Resources。 //! -//! 既有例外(M-res 记录在案):TUI / print 装配点仍直连 -//! `peri_resources::Resources::open_with`(`peri-tui` app/mod.rs / -//! cli_print.rs),不走本工厂;`open_with` / `open_thread_store_with` -//! 双入口固化该不对称。 -//! -//! 实例化动作仍经 `peri_resources::Resources` 门面(M-res 验收: -//! 实例化点留在 Resources 层),本模块只做声明边转发。 +//! 门面是消费侧唯一的会话行为句柄:本模块不再提供裸 `ThreadStore` 工厂。实例化 +//! 动作仍经 `peri_resources::Resources` 门面(M-res 验收:实例化点留在 Resources +//! 层),本模块只做声明边转发。 use std::path::PathBuf; use std::sync::Arc; -use peri_acp_types::store::ThreadStore; +use peri_acp_types::session_resources::{SessionResources, SessionStoreShutdownPort}; +use peri_acp_types::session_store::SessionStoreDeployment; -/// 打开默认 thread 存储并返回共享 `ThreadStore` 句柄。 +/// 打开默认会话资源门面的业务句柄(默认路径 `~/.peri/threads/threads.db`)。 +/// +/// 写打开不可用但历史仍可读时会降级为只读打开(见 `Resources::open_with`); +/// 失败直接返回错误,不 fallback 临时目录。**不返回部署关闭权**:本入口没有部署 +/// 生命周期,连接随句柄释放;需要关闭权的部署入口见 [`open_session_resources_deployment`]。 +pub async fn open_session_resources() -> anyhow::Result> { + open_session_resources_with(None).await +} + +/// 按部署参数打开会话资源门面(D-04 各部署入口的唯一入口),返回**业务句柄 + +/// 部署关闭权**。 +/// +/// 定位、凭证来源与访问意图都是 [`SessionStoreDeployment`] 的中性事实:本函数不解释 +/// locator、不读凭证值、不选择后端——解析与后端选择只发生在 `peri_resources::Resources` +/// 门面。打开失败按原错误上抛(含配置错误、只读失败分类与远程未接线),不 fallback。 /// -/// 保持 `Resources::open()` 既有行为:默认路径 `~/.peri/threads/threads.db` -/// 打开失败时直接返回错误。 -pub async fn open_thread_store() -> anyhow::Result> { - open_thread_store_with(None).await +/// 关闭权(`Box`,non-Clone)只属于部署:调用方(stdio +/// 宿主等)把业务句柄注入业务侧,把关闭权留在宿主配置里,在任务排空之后关闭存储。 +pub async fn open_session_resources_deployment( + deployment: &SessionStoreDeployment, +) -> anyhow::Result<(Arc, Box)> { + let resources = peri_resources::Resources::open_deployment(deployment).await?; + let (business, shutdown) = resources.into_parts(); + Ok((business, Box::new(shutdown))) } -/// 按显式路径打开 thread 存储并返回共享 `ThreadStore` 句柄。 +/// 既有 `--db-path` 兼容入口:归一为显式本机路径,不在这里解释后端。 /// -/// `Some(path)` 直接使用指定 SQLite 路径,打开失败时直接报错 -/// (不 fallback 临时目录),错误携带路径;`None` 与 [`open_thread_store`] -/// 行为一致(默认路径,失败直接返回错误)。 -pub async fn open_thread_store_with( +/// `Some(path)` 直接使用指定 SQLite 路径,打开失败时直接报错(不 fallback 临时 +/// 目录),错误携带路径;`None` 与 [`open_session_resources`] 行为一致。只交业务 +/// 句柄(装配测试用),不交关闭权。 +pub async fn open_session_resources_with( db_path: Option, -) -> anyhow::Result> { +) -> anyhow::Result> { let resources = peri_resources::Resources::open_with(db_path).await?; - Ok(resources.thread_store()) + Ok(resources.into_session_resources()) } #[cfg(test)] mod tests { use tempfile::tempdir; - use super::open_thread_store_with; + use super::open_session_resources_with; /// [P1] 显式路径打开成功。 #[tokio::test] - async fn test_open_thread_store_with_explicit_path_ok() { + async fn test_open_session_resources_with_explicit_path_ok() { let dir = tempdir().unwrap(); - open_thread_store_with(Some(dir.path().join("custom/t.db"))) + open_session_resources_with(Some(dir.path().join("custom/t.db"))) .await .unwrap(); } /// [P1] 显式路径不可用(父级为普通文件)时报错,不 fallback,错误携带路径。 #[tokio::test] - async fn test_open_thread_store_with_invalid_path_errs() { + async fn test_open_session_resources_with_invalid_path_errs() { let dir = tempdir().unwrap(); let file = dir.path().join("f"); std::fs::write(&file, "not a directory").unwrap(); - let err = match open_thread_store_with(Some(file.join("t.db"))).await { + let err = match open_session_resources_with(Some(file.join("t.db"))).await { Ok(_) => panic!("父级为普通文件时应返回错误"), Err(e) => e, }; diff --git a/peri-agent/src/session/exec/compact_pipeline.rs b/peri-agent/src/session/exec/compact_pipeline.rs index 81fff9d08..200855592 100644 --- a/peri-agent/src/session/exec/compact_pipeline.rs +++ b/peri-agent/src/session/exec/compact_pipeline.rs @@ -34,6 +34,7 @@ use peri_acp_types::command::{ use peri_acp_types::compact::CompactConfig; use peri_acp_types::event::CompactTrigger; use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::DataCapabilities; use tokio_util::sync::CancellationToken as AgentCancellationToken; use tracing::{info, warn}; @@ -85,7 +86,7 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { auxiliary_model, event_sink, cancel_token, - thread_store, + session_resources, thread_id, .. } = ctx; @@ -115,8 +116,8 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { } }; - // 阶段 4: 手动 compact 必须绑定持久化 transcript,保证 Full lifecycle 可原子提交。 - let (thread_store, thread_id) = match (thread_store, thread_id) { + // 阶段 4: 手动 compact 必须绑定会话资源门面,保证 Full lifecycle 可原子提交。 + let (session_resources, thread_id) = match (session_resources, thread_id) { (Some(store), Some(thread_id)) => (store, thread_id), _ => { warn!("compact: persistence is unavailable"); @@ -128,19 +129,35 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { } }; - if !thread_store.supports_compaction_lifecycle() { - warn!("compact: persistence backend does not support lifecycle commits"); - return PipelineOutcome::EarlyReturn { - history, - stop_reason: PromptStopReason::EndTurn, - message: "compact lifecycle persistence is unavailable".to_string(), - }; + // 能力判定读领域能力面(`Complete` = 全部行为满足完整后置条件),不探测实现细节。 + match session_resources + .inspect_availability(Some(&thread_id)) + .await + { + Ok(availability) if availability.capabilities == DataCapabilities::Complete => {} + Ok(_) => { + warn!("compact: persistence backend does not support complete behavior set"); + return PipelineOutcome::EarlyReturn { + history, + stop_reason: PromptStopReason::EndTurn, + message: "compact lifecycle persistence is unavailable".to_string(), + }; + } + Err(error) => { + warn!(%error, "compact: session availability check failed"); + return PipelineOutcome::EarlyReturn { + history, + stop_reason: PromptStopReason::EndTurn, + message: "compact persistence failed".to_string(), + }; + } } - // 阶段 5: 已存在的 thread 从完整消息和 flags 重建。命令输入是可见视图, - // 因此不能将物理存储中的 excluded 原文直接与其比较。 - let persisted_history = match thread_store.load_messages(&thread_id).await { - Ok(messages) => messages, + // 阶段 5: 已存在的 thread 从**一次一致快照**重建(payload 与 flags 同一次读取, + // 不拼「先 messages 后 flags」的跨时刻结果)。命令输入是可见视图,因此不能将 + // 物理存储中的 excluded 原文直接与其比较。 + let snapshot = match session_resources.load_session_snapshot(&thread_id).await { + Ok(snapshot) => snapshot, Err(_) => { warn!("compact: failed to load persisted history"); return PipelineOutcome::EarlyReturn { @@ -150,9 +167,15 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { }; } }; + // 与迁前的 `load_messages` 同语义:只取消息本体,reminder 不进入 compact 输入。 + let persisted_history: Vec = snapshot + .payloads + .iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect(); let mut transcript = MessageTranscript::new().with_compaction_commit_state(commit_state); if persisted_history.is_empty() { - transcript = transcript.with_persistence(thread_store, thread_id); + transcript = transcript.with_persistence(session_resources, thread_id); for message in &history { transcript.append(message.clone()); } @@ -165,17 +188,7 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { }; } } else { - let persisted_flags = match thread_store.load_message_flags(&thread_id).await { - Ok(flags) => flags, - Err(_) => { - warn!("compact: failed to load persisted flags"); - return PipelineOutcome::EarlyReturn { - history, - stop_reason: PromptStopReason::EndTurn, - message: "compact persistence failed".to_string(), - }; - } - }; + let persisted_flags = snapshot.flags.clone(); for message in &persisted_history { transcript.append(message.clone()); } @@ -202,7 +215,7 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { message: "compact persistence context mismatch".to_string(), }; } - transcript = transcript.with_persistence(thread_store, thread_id); + transcript = transcript.with_persistence(session_resources, thread_id); } // 阶段 6: 发出 CompactStarted 事件 diff --git a/peri-agent/src/session/exec/executor.rs b/peri-agent/src/session/exec/executor.rs index 8fe828447..3b16e370d 100644 --- a/peri-agent/src/session/exec/executor.rs +++ b/peri-agent/src/session/exec/executor.rs @@ -484,7 +484,7 @@ pub async fn run_session_loop(ctx: SessionContext, turn: TurnInput) -> PromptRes cwd: &ctx.cwd, session_id: &ctx.session_id, cancel: &ctx.cancel, - thread_store: ctx.thread_store.clone(), + session_resources: ctx.session_resources.clone(), thread_id: ctx.thread_id.clone(), // L5:冻结数据由调用点投影为字符串字段(原 FrozenSessionData 引用) frozen_claude_md: frozen diff --git a/peri-agent/src/session/exec/executor/agent_build.rs b/peri-agent/src/session/exec/executor/agent_build.rs index 6846b946d..230ebcc64 100644 --- a/peri-agent/src/session/exec/executor/agent_build.rs +++ b/peri-agent/src/session/exec/executor/agent_build.rs @@ -160,7 +160,8 @@ pub(super) async fn build_and_execute_agent( .and_then(|sa| sa.goal_controller(session_id)); let thread_persistence = ThreadPersistence { - store: ctx.thread_store.clone(), + session_resources: ctx.session_resources.clone(), + execution_owner: ctx.execution_owner.clone(), parent_thread_id: ctx.thread_id.clone(), register_runtime, deregister_runtime, @@ -219,7 +220,7 @@ pub(super) async fn build_and_execute_agent( user_input_mailbox: ctx.user_input_mailbox.clone(), cwd: ctx.cwd.clone(), cancel: ctx.cancel.clone(), - thread_store: ctx.thread_store.clone(), + session_resources: ctx.session_resources.clone(), thread_id: ctx.thread_id.clone(), agent_input, history_payloads, diff --git a/peri-agent/src/session/exec/executor/context.rs b/peri-agent/src/session/exec/executor/context.rs index 9cce45ea7..60bfb2507 100644 --- a/peri-agent/src/session/exec/executor/context.rs +++ b/peri-agent/src/session/exec/executor/context.rs @@ -197,7 +197,11 @@ pub struct SessionContext { // ── infra: session-level infrastructure(原 session_manager/pool 端口化)─ /// 会话定位端口(ACP `SessionManager` 实现;None = print mode / 无 session)。 pub session_access: Option>, - pub thread_store: Option>, + /// 会话资源门面:transcript/subagent 的唯一会话行为入口(会话存储不再有第二个句柄)。 + pub session_resources: Option>, + /// 本会话 root 的执行所有权(ACP SessionState 投影):child 保存/认领的前置证明。 + /// 只读准入或无执行权的会话为 None——那时不得落任何 child。 + pub execution_owner: Option>, pub thread_id: Option, // ── middleware: middleware chain resources ───────────────────────────── diff --git a/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs b/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs index 4fee11900..e4c03925e 100644 --- a/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs +++ b/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs @@ -1,12 +1,21 @@ //! Manual compact must preserve the SQLite commit outcome when either select drops its future. use super::*; use crate::session::exec::compact_pipeline::execute_compact; -use crate::thread::{SqliteThreadStore, ThreadId, ThreadMeta}; +use crate::session::test_resources::git_repository; +use crate::thread::{ThreadId, ThreadMeta}; use peri_acp_types::messages::MessageId; -use peri_acp_types::store::{ - CompactionLifecycle, InheritedContext, MessageFlags, PersistedPayload, ThreadStore, +use peri_acp_types::session_resources::{ + BindingRecheck, BindingState, ChildResumeClaim, ChildSnapshot, ForkSnapshot, + FrozenSnapshotBytes, NewSession, NewSessionMeta, PersistenceRecovery, RewindBoundary, + SessionAvailability, SessionMetaPatch, SessionResourceError, SessionResourceErrorKind, + SessionResourceResult, SessionResources, SessionSnapshot, }; -use std::collections::HashMap; +use peri_acp_types::store::{CompactionChange, PersistedPayload}; +use peri_acp_types::workspace::{ + ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, SessionBinding, SessionExecutionLease, + SESSION_BINDING_VERSION, +}; +use peri_resources::sessions::SessionResourcesImpl; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; #[derive(Clone, Copy)] @@ -23,94 +32,235 @@ enum HandlerPause { FailReload, } +/// 真门面 + 提交点注入:除了 `apply_compaction` 与快照重载,其余行为逐项转发。 +/// +/// 转发而不是重实现:包装层只注入「提交点被取消 / 提交后确认丢失 / 重载失败」三种 +/// 时序,存储语义仍由真实实现提供。 struct ControlledStore { - inner: SqliteThreadStore, + inner: Arc, mode: CommitMode, cancel: AgentCancellationToken, fail_reload: AtomicBool, calls: AtomicUsize, } + #[async_trait] -impl ThreadStore for ControlledStore { - async fn create_thread(&self, meta: ThreadMeta) -> anyhow::Result { - self.inner.create_thread(meta).await +impl SessionResources for ControlledStore { + async fn inspect_availability( + &self, + session: Option<&ThreadId>, + ) -> SessionResourceResult { + self.inner.inspect_availability(session).await } - async fn append_messages(&self, id: &ThreadId, messages: &[BaseMessage]) -> anyhow::Result<()> { - self.inner.append_messages(id, messages).await + + async fn resolve_workspace( + &self, + cwd: &std::path::Path, + ) -> SessionResourceResult { + self.inner.resolve_workspace(cwd).await } - async fn load_messages(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.load_messages(id).await + + async fn validate_session( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult<()> { + self.inner.validate_session(id, workspace).await } - async fn load_payloads(&self, id: &ThreadId) -> anyhow::Result> { - if self.fail_reload.load(Ordering::SeqCst) { - anyhow::bail!("injected canonical reload failure"); - } - self.inner.load_payloads(id).await + + async fn acquire_execution( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult> { + self.inner.acquire_execution(id, workspace).await } - async fn load_inherited_context(&self, id: &ThreadId) -> anyhow::Result { - self.inner.load_inherited_context(id).await + + async fn reset_dirty_execution( + &self, + request: &peri_acp_types::workspace::ResetDirtyRequest, + ) -> SessionResourceResult<()> { + self.inner.reset_dirty_execution(request).await } - async fn load_meta(&self, id: &ThreadId) -> anyhow::Result { - self.inner.load_meta(id).await + + async fn create_session( + &self, + input: &NewSession, + ) -> SessionResourceResult> { + self.inner.create_session(input).await } - async fn update_meta(&self, id: &ThreadId, meta: ThreadMeta) -> anyhow::Result<()> { - self.inner.update_meta(id, meta).await + + async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + ) -> SessionResourceResult<()> { + self.inner.abandon_initialization(id, lease).await } - async fn list_threads(&self) -> anyhow::Result> { - self.inner.list_threads().await + + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + self.inner + .adopt_legacy_session(id, saved_cwd, workspace, frozen) + .await } - async fn delete_thread(&self, id: &ThreadId) -> anyhow::Result<()> { - self.inner.delete_thread(id).await + + async fn load_session_snapshot(&self, id: &ThreadId) -> SessionResourceResult { + if self.fail_reload.load(Ordering::SeqCst) { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Unavailable { + detail: "injected canonical reload failure".to_owned(), + }, + )); + } + self.inner.load_session_snapshot(id).await } - async fn load_context(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.load_context(id).await + + async fn load_session_binding(&self, id: &ThreadId) -> SessionResourceResult { + self.inner.load_session_binding(id).await } - async fn list_child_threads(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.list_child_threads(id).await + + async fn validate_bound_workspace( + &self, + id: &ThreadId, + check: BindingRecheck, + ) -> SessionResourceResult { + self.inner.validate_bound_workspace(id, check).await } - async fn list_session_threads(&self, id: &ThreadId) -> anyhow::Result> { - self.inner.list_session_threads(id).await + + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + self.inner.load_session_history(id).await } - async fn update_thread_status(&self, id: &ThreadId, status: &str) -> anyhow::Result<()> { - self.inner.update_thread_status(id, status).await + + async fn load_session_meta(&self, id: &ThreadId) -> SessionResourceResult { + self.inner.load_session_meta(id).await } - async fn invalidate_context_cache(&self, id: &ThreadId) -> anyhow::Result<()> { - self.inner.invalidate_context_cache(id).await + + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult { + self.inner.list_sessions(query).await } - async fn delete_messages(&self, id: &ThreadId, ids: &[MessageId]) -> anyhow::Result<()> { - self.inner.delete_messages(id, ids).await + + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult> { + self.inner.list_children(parent).await } - async fn load_message_flags( + + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult> { + self.inner.list_session_tree(root).await + } + + async fn append_history( &self, id: &ThreadId, - ) -> anyhow::Result> { - self.inner.load_message_flags(id).await + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + self.inner.append_history(id, payloads).await + } + + async fn save_fork( + &self, + fork: &ForkSnapshot, + ) -> SessionResourceResult> { + self.inner.save_fork(fork).await } - fn supports_compaction_lifecycle(&self) -> bool { - true + + async fn save_child( + &self, + child: &ChildSnapshot, + lease: &Arc, + ) -> SessionResourceResult<()> { + self.inner.save_child(child, lease).await + } + + async fn claim_child_resume( + &self, + child: &ThreadId, + root: &ThreadId, + ) -> SessionResourceResult> { + self.inner.claim_child_resume(child, root).await } - async fn commit_compaction_lifecycle( + + async fn apply_compaction( &self, id: &ThreadId, - lifecycle: &CompactionLifecycle, - ) -> anyhow::Result<()> { + change: &CompactionChange, + ) -> SessionResourceResult<()> { self.calls.fetch_add(1, Ordering::SeqCst); if matches!(self.mode, CommitMode::CancelBefore) { self.cancel.cancel(); return std::future::pending().await; } - self.inner - .commit_compaction_lifecycle(id, lifecycle) - .await?; + let result = self.inner.apply_compaction(id, change).await; match self.mode { CommitMode::CancelAfter => { self.cancel.cancel(); std::future::pending().await } - CommitMode::ErrorAfter => anyhow::bail!("injected lost COMMIT acknowledgment"), - _ => Ok(()), + CommitMode::ErrorAfter => Err(SessionResourceError::new( + SessionResourceErrorKind::Unavailable { + detail: "injected lost COMMIT acknowledgment".to_owned(), + }, + )), + _ => result, } } + + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, peri_acp_types::store::MessageFlags)], + ) -> SessionResourceResult<()> { + self.inner.apply_message_projections(id, updates).await + } + + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + self.inner.rewind_history(id, boundary).await + } + + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + self.inner.remove_history_entries(id, ids).await + } + + async fn update_session_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + self.inner.update_session_meta(id, patch).await + } + + async fn delete_session_tree(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.inner.delete_session_tree(id).await + } + + async fn recover_session_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + self.inner.recover_session_persistence(id).await + } + + async fn drain_persistence(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.inner.drain_persistence(id).await + } } struct SummaryModel; @@ -163,6 +313,9 @@ impl CommandHandler for PipelineHandler { struct Case { _dir: tempfile::TempDir, + _repo: tempfile::TempDir, + /// 持有执行所有权:门面上的写入要求本 root 有活 owner。 + _lease: Arc, store: Arc, thread_id: ThreadId, history: Vec, @@ -171,25 +324,59 @@ struct Case { } async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Case { let dir = tempfile::tempdir().unwrap(); + let repo = git_repository(); let cancel = AgentCancellationToken::new(); - let store = Arc::new(ControlledStore { - inner: SqliteThreadStore::new(dir.path().join("manual.db")) + let inner: Arc = Arc::new( + SessionResourcesImpl::open(dir.path().join("manual.db")) .await .unwrap(), + ); + let store = Arc::new(ControlledStore { + inner: Arc::clone(&inner), mode, cancel: cancel.clone(), fail_reload: AtomicBool::new(false), calls: AtomicUsize::new(0), }); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_str().unwrap())) - .await - .unwrap(); + // 真门面建会话:绑定 + frozen + 执行代际一次落盘,写入门禁才有 owner。 + let workspace = inner.resolve_workspace(repo.path()).await.unwrap(); + let thread_id = uuid::Uuid::now_v7().to_string(); + let session = NewSession { + thread_id: thread_id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: Some("manual compact".to_owned()), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + }, + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"manual\":true}"), + }; + let lease = inner.create_session(&session).await.unwrap(); let history = vec![ BaseMessage::human("old manual question"), BaseMessage::ai("old manual answer"), ]; - store.append_messages(&thread_id, &history).await.unwrap(); + inner + .append_history( + &thread_id, + &history + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect::>(), + ) + .await + .unwrap(); if pre_cancel { cancel.cancel(); } @@ -220,8 +407,8 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca &task_manager, lookup, ); - req.cwd = dir.path().to_str().unwrap(); - req.thread_store = Some(store.clone()); + req.cwd = workspace.cwd.to_str().unwrap(); + req.session_resources = Some(store.clone()); req.thread_id = Some(thread_id.clone()); req.auxiliary_model = &model; let outcome = tokio::time::timeout( @@ -235,6 +422,8 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca }; Case { _dir: dir, + _repo: repo, + _lease: lease, store, thread_id, history, @@ -243,22 +432,24 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca } } -async fn assert_durable_summary(case: &Case) { - let payloads = case - .store +/// 磁盘事实(payload/flags 同一次一致快照读取)。 +/// +/// 直读**未被注入**的真实门面:包装层注入的是「调用方看到的读失败」,验证落库事实时 +/// 不能连它一起读,否则断言会被注入本身带偏。 +async fn stored_snapshot(case: &Case) -> SessionSnapshot { + case.store .inner - .load_payloads(&case.thread_id) + .load_session_snapshot(&case.thread_id) .await - .unwrap(); + .unwrap() +} + +async fn assert_durable_summary(case: &Case) { + let payloads = stored_snapshot(case).await.payloads; assert!(payloads.iter().any(|payload| payload .as_message() .is_some_and(|message| message.content().contains("manual committed summary")))); - let flags = case - .store - .inner - .load_message_flags(&case.thread_id) - .await - .unwrap(); + let flags = stored_snapshot(case).await.flags; assert!(case .history .iter() @@ -273,21 +464,15 @@ async fn test_manual_compact_cancel_before_commit_requires_reload_without_deleti assert!(!case.result.ok); assert!(case.result.failure.is_some()); assert_eq!( - case.store - .inner - .load_messages(&case.thread_id) + stored_snapshot(&case) .await - .unwrap() - .len(), + .payloads + .iter() + .filter(|payload| payload.as_message().is_some()) + .count(), 2 ); - assert!(case - .store - .inner - .load_message_flags(&case.thread_id) - .await - .unwrap() - .is_empty()); + assert!(stored_snapshot(&case).await.flags.is_empty()); assert_eq!(case.done_count, 1); } @@ -319,12 +504,7 @@ async fn test_manual_compact_cancel_after_confirmed_pipeline_restores_canonical_ assert!(case.result.messages[0] .content() .contains("manual committed summary")); - let stored = case - .store - .inner - .load_payloads(&case.thread_id) - .await - .unwrap(); + let stored = stored_snapshot(&case).await.payloads; assert_eq!( case.result .persisted_payloads diff --git a/peri-agent/src/session/exec/executor_helpers/intercept.rs b/peri-agent/src/session/exec/executor_helpers/intercept.rs index 422642820..d719b3f5e 100644 --- a/peri-agent/src/session/exec/executor_helpers/intercept.rs +++ b/peri-agent/src/session/exec/executor_helpers/intercept.rs @@ -9,7 +9,8 @@ use peri_acp_types::{ event::{EventSink, ExecutorEvent}, messages::{BaseMessage, MessageContent}, session::{ExecutionFailure, PromptResult}, - store::{PersistedPayload, ThreadStore}, + session_resources::SessionResources, + store::PersistedPayload, tasks::TaskManager, }; use tokio_util::sync::CancellationToken; @@ -42,7 +43,7 @@ pub struct InterceptRequest<'a> { pub cwd: &'a str, pub session_id: &'a str, pub cancel: &'a CancellationToken, - pub thread_store: Option>, + pub session_resources: Option>, pub thread_id: Option, // ── 冻结数据投影(原 FrozenSessionData;ACP 调用点投影字符串)── pub frozen_claude_md: Option, @@ -216,7 +217,7 @@ pub async fn intercept_immediate_command(req: InterceptRequest<'_>) -> Intercept // 管线(McpSkillReleaser 依此放行,决策 A2;RPC 路径恒 false)。 ctx.supports_inject = true; ctx.parsed_args = parsed_args; - ctx.thread_store = req.thread_store.clone(); + ctx.session_resources = req.session_resources.clone(); ctx.thread_id = req.thread_id.clone(); ctx.task_manager = Some(req.task_manager.clone()); ctx.frozen_claude_md = req.frozen_claude_md.clone().map(Arc::new); @@ -250,8 +251,11 @@ pub async fn intercept_immediate_command(req: InterceptRequest<'_>) -> Intercept let mut history_replaced_by_compaction = false; let mut committed_payloads = None; if !persistence_inconsistent && commit_state.has_committed() { - match restore_committed_context(req.thread_store.as_ref(), req.thread_id.as_ref()) - .await + match restore_committed_context( + req.session_resources.as_ref(), + req.thread_id.as_ref(), + ) + .await { Ok((payloads, messages)) => { committed_payloads = Some(payloads); @@ -319,14 +323,20 @@ pub async fn intercept_immediate_command(req: InterceptRequest<'_>) -> Intercept } async fn restore_committed_context( - store: Option<&Arc>, + store: Option<&Arc>, thread_id: Option<&String>, ) -> anyhow::Result<(Vec, Vec)> { let (store, thread_id) = store .zip(thread_id) .ok_or_else(|| anyhow::anyhow!("compact persistence is unavailable"))?; - let inherited = store.load_inherited_context(thread_id).await?; - let own = store.load_payloads(thread_id).await?; + // 一次一致快照:inherit 区、自有 payload 与 flags 来自同一次读取,不再分三次 + // 取(分次读会把「先 payload 后 flags」的跨时刻结果当成一份历史)。 + let snapshot = store + .load_session_snapshot(thread_id) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let own = snapshot.payloads; + let inherited = snapshot.inherited; let own_ids = own .iter() .map(PersistedPayload::id) @@ -338,7 +348,7 @@ async fn restore_committed_context( { anyhow::bail!("inherited context overlaps command own history"); } - let own_flags = store.load_message_flags(thread_id).await?; + let own_flags = snapshot.flags; let mut transcript = MessageTranscript::new() .with_ancestor_payloads(inherited.payloads) .with_own_payloads(own); diff --git a/peri-agent/src/session/exec/executor_helpers/v2_execute.rs b/peri-agent/src/session/exec/executor_helpers/v2_execute.rs index f7ecd50ed..d1e6122dd 100644 --- a/peri-agent/src/session/exec/executor_helpers/v2_execute.rs +++ b/peri-agent/src/session/exec/executor_helpers/v2_execute.rs @@ -14,7 +14,7 @@ use peri_acp_types::{ messages::BaseMessage, runtime::UnstampedEvent, session::ExecutionFailure, - store::ThreadStore, + session_resources::SessionResources, tasks::{BgTaskKind, TaskManager}, }; use tokio_util::sync::CancellationToken; @@ -88,7 +88,7 @@ pub struct V2ExecuteRequest { pub user_input_mailbox: Option>, pub cwd: String, pub cancel: CancellationToken, - pub thread_store: Option>, + pub session_resources: Option>, pub thread_id: Option, pub agent_input: AgentInput, pub history_payloads: Vec, @@ -146,15 +146,18 @@ pub async fn build_and_execute_agent_v2(req: V2ExecuteRequest) -> ExecOutcome { // Restore the inherited boundary and compact state before spawning forwarders. // A failed/corrupt snapshot must not enter Reason with unclassified history. let restored_history = async { - let Some((store, tid)) = req.thread_store.as_ref().zip(req.thread_id.as_ref()) else { + let Some((store, tid)) = req.session_resources.as_ref().zip(req.thread_id.as_ref()) else { return Ok::<_, anyhow::Error>(( peri_acp_types::store::InheritedContext::default(), Default::default(), )); }; - let inherited = store.load_inherited_context(tid).await?; - let flags = store.load_message_flags(tid).await?; - Ok((inherited, flags)) + // 一次一致快照:继承区与 flags 同一次读取,避免跨时刻拼接。 + let snapshot = store + .load_session_snapshot(tid) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + Ok((snapshot.inherited, snapshot.flags)) } .await; diff --git a/peri-agent/src/session/exec/executor_helpers_test.rs b/peri-agent/src/session/exec/executor_helpers_test.rs index f1ea18d89..b3ceb5b5c 100644 --- a/peri-agent/src/session/exec/executor_helpers_test.rs +++ b/peri-agent/src/session/exec/executor_helpers_test.rs @@ -140,7 +140,7 @@ fn make_intercept_request<'a>( cwd: "/tmp", session_id, cancel, - thread_store: None, + session_resources: None, thread_id: None, frozen_claude_md: None, frozen_claude_local_md: None, diff --git a/peri-agent/src/session/exec/executor_provenance_test.rs b/peri-agent/src/session/exec/executor_provenance_test.rs index d37733cb9..80b4f9c36 100644 --- a/peri-agent/src/session/exec/executor_provenance_test.rs +++ b/peri-agent/src/session/exec/executor_provenance_test.rs @@ -3,9 +3,13 @@ use super::*; use crate::agent::stages::StageContext; use crate::session::exec::stage_builder::V2AgentOutput; use crate::session::{FrozenContext, Session}; -use crate::thread::{SqliteThreadStore, ThreadMeta, ThreadStore}; +use crate::thread::ThreadMeta; +// 测试夹具直接使用 SQLite 具体实现(显式资源测试入口):本用例断言的是库内的 +// 继承区与 flags 事实,门面不提供逐条 fixture 构造。 use peri_acp_types::event_v2::EventBus; +use peri_acp_types::store::ThreadStore; use peri_acp_types::store::{InheritedContext, MessageFlags, PersistedPayload}; +use peri_resources::sessions::SqliteThreadStore; #[tokio::test] async fn test_hidden_child_executor_restores_ancestor_and_own_flags_separately() { @@ -85,7 +89,12 @@ async fn test_hidden_child_executor_restores_ancestor_and_own_flags_separately() }); let mut context = make_session_context("hidden-child-provenance"); context.thread_id = Some(child_id.clone()); - context.thread_store = Some(store.clone()); + // 执行侧的读取入口是门面:同一库文件上的真实实现,继承区与 flags 由它回答。 + context.session_resources = Some(Arc::new( + peri_resources::sessions::SessionResourcesImpl::open(dir.path().join("child.db")) + .await + .unwrap(), + )); let mut turn = make_turn_input( Arc::new(MockEventSink::new()), MessageContent::text("continue"), diff --git a/peri-agent/src/session/exec/executor_test.rs b/peri-agent/src/session/exec/executor_test.rs index 943b67ae8..81aff3678 100644 --- a/peri-agent/src/session/exec/executor_test.rs +++ b/peri-agent/src/session/exec/executor_test.rs @@ -231,7 +231,8 @@ fn make_session_context(session_id: &str) -> SessionContext { broker: Arc::new(NoopBroker), permission_mode: SharedPermissionMode::new(PermissionMode::Bypass), session_access: None, - thread_store: None, + session_resources: None, + execution_owner: None, thread_id: None, plugin_skill_roots: vec![], plugin_agent_dirs: vec![], diff --git a/peri-agent/src/session/exec/stage_builder.rs b/peri-agent/src/session/exec/stage_builder.rs index fc1b7153c..9f07be1d3 100644 --- a/peri-agent/src/session/exec/stage_builder.rs +++ b/peri-agent/src/session/exec/stage_builder.rs @@ -37,8 +37,8 @@ use peri_acp_types::{ LspPoolPort, McpPoolPort, SessionMcpCapabilityPort, ToolSearchPort, WorkflowMiddlewarePort, }, session::{MessageQueue, SessionInbox}, + session_resources::SessionResources, skills::SkillRoot, - store::ThreadStore, tools::TodoItem, workflow::AgentExecutor, }; @@ -124,8 +124,8 @@ pub struct StageBuildInput { pub workflow_executor: Option>, /// 会话级 WorkflowMiddleware 端口 pub workflow_middleware: Option>, - /// 持久化存储(transcript persistence 激活) - pub thread_store: Option>, + /// 会话资源门面(transcript persistence 激活与 child 保存的唯一入口) + pub session_resources: Option>, /// 当前会话 thread ID pub thread_id: Option, // ── 注入面(原 ACP 特有构造)── diff --git a/peri-agent/src/session/exec/stage_builder/agent.rs b/peri-agent/src/session/exec/stage_builder/agent.rs index 1e5120bce..84a119892 100644 --- a/peri-agent/src/session/exec/stage_builder/agent.rs +++ b/peri-agent/src/session/exec/stage_builder/agent.rs @@ -235,7 +235,8 @@ fn project_assembly(input: &StageBuildInput, turn: TurnAssembly) -> AssemblyCont system_prompt_for_sub, } = turn; let ThreadPersistence { - store: thread_store, + session_resources, + execution_owner: _, parent_thread_id, register_runtime, deregister_runtime, @@ -283,7 +284,7 @@ fn project_assembly(input: &StageBuildInput, turn: TurnAssembly) -> AssemblyCont on_bg_complete, // SubAgent Langfuse bridge:注入工厂构造(采样决策继承自父 agent)。 langfuse_bridge: input.langfuse_bridge_factory.as_ref().map(|f| f()), - thread_store, + session_resources, parent_thread_id, register_runtime, deregister_runtime, diff --git a/peri-agent/src/session/exec/stage_builder/session_setup.rs b/peri-agent/src/session/exec/stage_builder/session_setup.rs index 1fdebbd89..b913cea7b 100644 --- a/peri-agent/src/session/exec/stage_builder/session_setup.rs +++ b/peri-agent/src/session/exec/stage_builder/session_setup.rs @@ -28,7 +28,7 @@ pub(super) fn build_session( ); // 激活 transcript persistence(compact flags 跨 prompt 持久化) - if let (Some(store), Some(tid)) = (input.thread_store.as_ref(), input.thread_id.as_ref()) { + if let (Some(store), Some(tid)) = (input.session_resources.as_ref(), input.thread_id.as_ref()) { let transcript_arc = session.transcript(); let mut transcript = transcript_arc.write(); let old = std::mem::take(&mut *transcript); diff --git a/peri-agent/src/session/exec/stage_builder/subagent_setup.rs b/peri-agent/src/session/exec/stage_builder/subagent_setup.rs index 004a7345f..e9efe9288 100644 --- a/peri-agent/src/session/exec/stage_builder/subagent_setup.rs +++ b/peri-agent/src/session/exec/stage_builder/subagent_setup.rs @@ -46,7 +46,8 @@ pub(super) fn attach_subagent_host( // SubAgentMiddleware 不再逐字段透传(管理权移出)。 { let host = SubagentHost { - thread_store: thread_persistence.store.clone(), + session_resources: thread_persistence.session_resources.clone(), + execution_owner: thread_persistence.execution_owner.clone(), task_manager: Some(task_manager.clone()), bg_event_sender: Some(bg_event_tx), on_bg_complete: on_bg_complete.clone(), diff --git a/peri-agent/src/session/factory.rs b/peri-agent/src/session/factory.rs index 795f3b7fb..3605fc9ae 100644 --- a/peri-agent/src/session/factory.rs +++ b/peri-agent/src/session/factory.rs @@ -182,8 +182,8 @@ use peri_acp_types::lsp::LspServerConfig; use peri_acp_types::mcp_skills::McpSkillRegistry; use peri_acp_types::plugin::LoadedPlugin; use peri_acp_types::ports::{LspPoolPort, McpPoolPort, ToolSearchPort, WorkflowMiddlewarePort}; +use peri_acp_types::session_resources::SessionResources; use peri_acp_types::skills::SkillRoot; -use peri_acp_types::store::ThreadStore; use peri_acp_types::tools::TodoItem; use peri_acp_types::workflow::AgentExecutor; use peri_acp_types::{identity::AgentId, permission::SharedPermissionMode}; @@ -320,8 +320,8 @@ pub struct AssemblyContext { /// SubAgent Langfuse bridge(由上层构造注入) pub langfuse_bridge: Option>, // ── 子 agent 持久化 ── - /// 子线程持久化存储 - pub thread_store: Option>, + /// 会话资源门面:child 保存/状态写入的唯一入口 + pub session_resources: Option>, /// 父线程 ID(子 agent 层级) pub parent_thread_id: Option, /// 子 agent 启动注册回调 diff --git a/peri-agent/src/session/mod.rs b/peri-agent/src/session/mod.rs index 8e23d2572..5d13ede24 100644 --- a/peri-agent/src/session/mod.rs +++ b/peri-agent/src/session/mod.rs @@ -35,6 +35,10 @@ pub mod store; pub mod subagent; pub mod tool_catalog; pub mod transcript; + +#[cfg(test)] +#[path = "test_resources.rs"] +pub(crate) mod test_resources; pub mod turn; pub mod user_input_mailbox; pub mod workflow_completion; diff --git a/peri-agent/src/session/subagent.rs b/peri-agent/src/session/subagent.rs index 8e10b0185..331a36ff0 100644 --- a/peri-agent/src/session/subagent.rs +++ b/peri-agent/src/session/subagent.rs @@ -61,7 +61,7 @@ use crate::middleware::chain::MiddlewareChain; #[cfg(test)] use crate::session::{FrozenContext, Session}; #[cfg(test)] -use crate::thread::{ThreadMeta, ThreadStore}; +use crate::thread::ThreadMeta; #[cfg(test)] use peri_acp_types::identity::AgentId; #[cfg(test)] diff --git a/peri-agent/src/session/subagent/background.rs b/peri-agent/src/session/subagent/background.rs index 521a346c3..7aaf818ad 100644 --- a/peri-agent/src/session/subagent/background.rs +++ b/peri-agent/src/session/subagent/background.rs @@ -20,7 +20,8 @@ use crate::agent::stages::{run_react_loop, LoopResult}; use crate::agent::subagent_event_forwarder::spawn_subagent_event_forwarder_for_completion; use crate::agent::LangfuseBridgeLike; use crate::session::factory::{DeregisterRuntimeFn, RegisterRuntimeFn}; -use crate::thread::ThreadStore; +use peri_acp_types::session_resources::{SessionMetaPatch, SessionResources}; +use peri_acp_types::thread::{AgentStatus, ThreadId}; // ─── 后台运行 ──────────────────────────────────────────────────────────────── @@ -39,7 +40,7 @@ pub(super) async fn spawn_background_subagent( Arc, >, langfuse_bridge: Option>, - thread_store: Option>, + session_resources: Option>, deregister_runtime: Option, on_subagent_start: Option, on_subagent_stop: Option, @@ -261,9 +262,20 @@ pub(super) async fn spawn_background_subagent( !result.success, ); } - if let Some(ref store) = thread_store { + if let Some(ref store) = session_resources { + let status = match status { + "error" => AgentStatus::Error, + "cancelled" => AgentStatus::Cancelled, + _ => AgentStatus::Done, + }; let _ = store - .update_thread_status(&child_thread_id_for_task, status) + .update_session_meta( + &ThreadId::from(child_thread_id_for_task.as_str()), + &SessionMetaPatch { + status: Some(status), + ..Default::default() + }, + ) .await; } diff --git a/peri-agent/src/session/subagent/factory.rs b/peri-agent/src/session/subagent/factory.rs index 071765630..500b0affb 100644 --- a/peri-agent/src/session/subagent/factory.rs +++ b/peri-agent/src/session/subagent/factory.rs @@ -11,6 +11,45 @@ pub(super) use claim::ResumeClaim; use resume::resume_subagent_impl; use spawn::spawn_subagent_impl; +/// resume 的 thread id 格式校验:非 UUID 必须在**存在性判定之前**报错。 +/// +/// 与 `ResumeClaim` 的 `validate_thread` 共用同一判据:两条报错路径(快照读取的 +/// `NotFound` 与认领前的存在性校验)不能把「格式非法」说成「不存在」。 +pub(super) fn validate_thread_id_format( + thread_id: &str, +) -> Result<(), Box> { + if uuid::Uuid::parse_str(thread_id).is_err() { + return Err(format!("resume_subagent: invalid thread id: {}", thread_id).into()); + } + Ok(()) +} + +/// 沿 parent 链解析会话树的根(child 保存与 resume 归属校验共用)。 +/// +/// 与旧实现的语义一致:显式深度上限与环检测,两者任一触发即拒绝——不能靠 +/// 「走到没有 parent 为止」把环形数据当成合法层级。 +pub(super) async fn execution_root( + store: &dyn peri_acp_types::session_resources::SessionResources, + id: &str, +) -> Result> { + let mut current = id.to_owned(); + let mut visited = std::collections::HashSet::new(); + loop { + if visited.len() >= 128 || !visited.insert(current.clone()) { + return Err(peri_acp_types::workspace::WorkspaceError::InvalidBinding.into()); + } + match store + .load_session_meta(¤t) + .await + .map_err(|error| format!("session {current} is not readable: {error}"))? + .parent_thread_id + { + Some(parent) => current = parent, + None => return Ok(current), + } + } +} + // ─── 统一入口 ──────────────────────────────────────────────────────────────── /// Agent 层 session 工厂(L3):subagent 创建统一入口命名空间。 diff --git a/peri-agent/src/session/subagent/factory/claim.rs b/peri-agent/src/session/subagent/factory/claim.rs index dec8a7fbd..7a68c7f59 100644 --- a/peri-agent/src/session/subagent/factory/claim.rs +++ b/peri-agent/src/session/subagent/factory/claim.rs @@ -1,172 +1,198 @@ -//! Ownership of resume status from its initial claim through sync finalization. +//! Ownership of resume status from its initial claim through terminal reporting. //! -//! A worker owns the active write and its compensating write. Dropping the caller -//! requests preparation rollback or running cancellation; it never cancels an -//! in-flight database write. -//! Cleanup then completes asynchronously, provided the runtime and store remain -//! available. Explicit rollback awaits the same worker and reports its failure. +//! A worker owns the claim's active write and its compensating write. Dropping the +//! caller requests preparation rollback or running cancellation; it never cancels an +//! in-flight resource write. Explicit rollback / finish await the same worker and +//! report its failure. +//! +//! 认领的状态写入与「恢复到认领前」由资源侧持有(`ChildResumeClaim`),调用方只提交 +//! 领域结果:开始运行 / 移交后台 / 准备失败 / 终止。 use std::sync::Arc; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use crate::thread::{ThreadMeta, ThreadStore}; - -/// Cross-instance claim serialization; history loading and execution stay outside. -/// As before, this lock does not coordinate separate processes. -static RESUME_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +use peri_acp_types::session_resources::{SessionMetaPatch, SessionResources}; +use peri_acp_types::thread::{AgentStatus, ThreadId, ThreadMeta}; +/// 调用方提交给认领 worker 的领域结果。 enum ClaimDecision { - Release, - Finish(&'static str), + /// 成功移交后台执行:终态状态此后由后台执行持有(仍属本次认领)。 + HandOff, + /// 同步收尾:先结清认领,再写领域终态。 + Finish(AgentStatus), } pub(in crate::session::subagent) struct ResumeClaim { - release: Option>, - running: bool, + /// 决定通道;调用方消失(Drop)时按当前阶段发送取消或直接关闭。 + decision: Option>, + /// worker 的写入不能被调用方取消:只 detach(不 abort)。 worker: JoinHandle>, + running: bool, } impl ResumeClaim { + /// 校验(只读)后由资源侧串行认领 child;认领在写侧门禁内完成「读状态 + 写 active」。 pub(super) async fn acquire( - store: Arc, + store: Arc, thread_id: String, - mut ownership: Option>, + root_id: String, + ownership: Option>, ) -> Result<(ThreadMeta, Self), Box> { let (meta_tx, meta_rx) = oneshot::channel(); - let (release, decision) = oneshot::channel(); + let (decision_tx, decision_rx) = oneshot::channel(); let worker = tokio::spawn(async move { - let result = own_claim(&store, &thread_id, meta_tx, decision).await; + let result = + own_claim(store, thread_id, root_id, meta_tx, decision_rx, ownership).await; if let Err(error) = &result { - tracing::error!(%thread_id, %error, "resume status finalization failed"); - } else if let Some(owner) = &mut ownership { - owner.confirm_stopped(); + tracing::error!(%error, "resume claim worker failed"); } result }); - // Created before the first await: even cancellation during the active - // write leaves the worker with an unambiguous rollback decision. - let owner = Self { - release: Some(release), - running: false, + // 先于第一个 await 建立:调用方在 active 写期间消失也留下明确决定。 + let claim = Self { + decision: Some(decision_tx), worker, + running: false, }; let meta = meta_rx.await.map_err(|error| { format!("resume_subagent: status claim worker ended before reporting: {error}") })??; - Ok((meta, owner)) + Ok((meta, claim)) } - /// Successful background registration now owns terminal status. - pub(super) fn release(mut self) { - if let Some(release) = self.release.take() { - let _ = release.send(ClaimDecision::Release); - } + /// 成功移交给后台执行:终态状态此后由后台执行持有。 + pub(super) async fn release(mut self) { + self.decide(ClaimDecision::HandOff); + Self::await_worker(&mut self.worker).await; } pub(in crate::session::subagent) fn mark_running(&mut self) { self.running = true; } - /// Normal sync Stop has run its hook; serialize the terminal write with the - /// original active write. Dropping this await cannot cancel that write. - pub(in crate::session::subagent) async fn finish(mut self, status: &'static str) { - if let Some(release) = self.release.take() { - let _ = release.send(ClaimDecision::Finish(status)); - } - // Preserve the existing best-effort Stop persistence contract. The - // worker reports store errors even if this await is cancelled. - if let Err(error) = (&mut self.worker).await { - tracing::error!(%error, "resume terminal status worker failed"); - } + /// 同步 Stop 已运行 hook:把领域终态交给 worker,由它先结清认领再写入。 + pub(in crate::session::subagent) async fn finish(mut self, status: AgentStatus) { + self.decide(ClaimDecision::Finish(status)); + Self::await_worker(&mut self.worker).await; } + /// 准备失败:关闭决定通道,worker 恢复到认领前的记录并报告其失败。 pub(super) async fn rollback(mut self) -> Result<(), String> { - drop(self.release.take()); + self.decision = None; (&mut self.worker) .await .map_err(|error| format!("resume status rollback worker failed: {error}"))? } + + /// 提交领域结果;已提交过(或未及提交即被取消)时不重复。 + fn decide(&mut self, decision: ClaimDecision) { + if let Some(decision_tx) = self.decision.take() { + let _ = decision_tx.send(decision); + } + } + + /// 等待 worker 完成本决定对应的写入。这里被取消只会 detach worker,不会取消写入; + /// worker 自身已记录失败原因。 + async fn await_worker(worker: &mut JoinHandle>) { + let _ = worker.await; + } } impl Drop for ResumeClaim { fn drop(&mut self) { - if self.running { - if let Some(release) = self.release.take() { - let _ = release.send(ClaimDecision::Finish("cancelled")); - } + // 运行中被取消 → 领域终态「取消」;准备阶段被取消 → 直接关闭决定通道, + // 由 worker 恢复认领前的记录。两者都不取消资源侧正在进行的写入。 + if !self.running { + return; + } + if let Some(decision_tx) = self.decision.take() { + let _ = decision_tx.send(ClaimDecision::Finish(AgentStatus::Cancelled)); } - // A preparation drop closes the sender without a decision: restore the - // previous status. Dropping JoinHandle detaches, never aborts, the worker. } } +/// 认领 worker:active 写入一旦开始,就必须由本任务驱动到完成,与调用方是否仍在无关。 async fn own_claim( - store: &Arc, - thread_id: &String, + store: Arc, + thread_id: ThreadId, + root_id: ThreadId, mut meta_tx: oneshot::Sender>, decision: oneshot::Receiver, + mut ownership: Option>, ) -> Result<(), String> { - let guard = tokio::select! { - biased; - _ = meta_tx.closed() => return Ok(()), - guard = RESUME_LOCK.lock() => guard, - }; - // Validation is read-only and may be cancelled without compensation. Once - // the active write starts below, the worker must drive it to completion. + // 校验只读且可被调用方取消;取消后不再进入写入。 let validated = tokio::select! { biased; _ = meta_tx.closed() => return Ok(()), - result = validate_thread(store, thread_id) => result, + result = validate_thread(store.as_ref(), &thread_id) => result, }; let meta = match validated { Ok(meta) => meta, Err(error) => { - let _ = meta_tx.send(Err(error)); + let _ = meta_tx.send(Err(error.to_string())); return Ok(()); } }; - if meta_tx.is_closed() { - return Ok(()); - } - let previous_status = meta.agent_status; // Never select cancellation against this write: the store may commit before - // returning Pending. Any rollback must be ordered after its completion. - if let Err(error) = store.update_thread_status(thread_id, "active").await { - let rollback = restore_status(store, thread_id, previous_status.as_str()).await; - let mut message = format!( - "resume_subagent: failed to mark thread {} active: {}", - thread_id, error - ); - if let Err(error) = &rollback { - message.push_str(&format!("; {error}")); + // returning. Any rollback must be ordered after its completion. + let handle = match store.claim_child_resume(&thread_id, &root_id).await { + Ok(handle) => handle, + Err(error) => { + let _ = meta_tx.send(Err(format!( + "resume_subagent: failed to claim thread {thread_id}: {error}" + ))); + return Ok(()); } - let _ = meta_tx.send(Err(message)); - return rollback; - } - drop(guard); - // If the receiver was dropped, its owner also closed `decision`. A success - // arriving after cancellation therefore cannot strand an unowned claim. - let _ = meta_tx.send(Ok(meta)); - let status = match decision.await { - Ok(ClaimDecision::Release) => return Ok(()), - Ok(ClaimDecision::Finish(status)) => status, - Err(_) => previous_status.as_str(), }; - let _guard = RESUME_LOCK.lock().await; - restore_status(store, thread_id, status).await + // 调用方已消失时发送失败,但认领证据已落库:下面的决定分支会给出补偿。 + let _ = meta_tx.send(Ok(meta)); + match decision.await { + Ok(ClaimDecision::HandOff) => { + handle + .hand_off_to_background() + .await + .map_err(|error| format!("resume claim hand-off failed: {error}"))?; + } + Ok(ClaimDecision::Finish(status)) => { + // 顺序不可反——结清会把记录写回认领前的值,先写终态会被它覆盖。 + handle + .mark_terminated() + .await + .map_err(|error| format!("resume claim settle failed: {error}"))?; + store + .update_session_meta( + &thread_id, + &SessionMetaPatch { + status: Some(status), + ..Default::default() + }, + ) + .await + .map_err(|error| format!("resume terminal status write failed: {error}"))?; + } + // 准备阶段调用方消失:恢复到认领前的状态,不留 active 残留。 + Err(_) => { + handle + .mark_failed() + .await + .map_err(|error| format!("resume status rollback failed: {error}"))?; + } + } + if let Some(owner) = ownership.as_mut() { + owner.confirm_stopped(); + } + Ok(()) } async fn validate_thread( - store: &Arc, - thread_id: &String, -) -> Result { - if uuid::Uuid::parse_str(thread_id).is_err() { - return Err(format!("resume_subagent: invalid thread id: {}", thread_id)); - } + store: &dyn SessionResources, + thread_id: &str, +) -> Result> { + super::validate_thread_id_format(thread_id)?; let meta = store - .load_meta(thread_id) + .load_session_meta(&thread_id.to_owned()) .await .map_err(|_| format!("resume_subagent: thread not found: {}", thread_id))?; if meta.agent_status.is_active() { @@ -175,18 +201,8 @@ async fn validate_thread( (thread 仍处于运行态: 可能仍在执行, 或上次异常退出未收尾; \ 若确认无执行中任务, 可改用 Agent(subagent_type: ...) 新建)", thread_id - )); + ) + .into()); } Ok(meta) } - -async fn restore_status( - store: &Arc, - thread_id: &String, - previous_status: &str, -) -> Result<(), String> { - store - .update_thread_status(thread_id, previous_status) - .await - .map_err(|error| format!("failed to restore thread {thread_id} status: {error}")) -} diff --git a/peri-agent/src/session/subagent/factory/context.rs b/peri-agent/src/session/subagent/factory/context.rs index bd0b972c6..0b045d831 100644 --- a/peri-agent/src/session/subagent/factory/context.rs +++ b/peri-agent/src/session/subagent/factory/context.rs @@ -12,8 +12,8 @@ use crate::agent::react::ReactLLM; use crate::agent::{CompactConfig, ContextBudget}; use crate::error_suggest::{ErrorSuggestRegistry, ToolRegistrySnapshot}; use crate::session::{FrozenContext, MessageQueue, Session}; -use crate::thread::ThreadStore; use crate::tools::{BaseTool, ToolInvocationResolver}; +use peri_acp_types::session_resources::SessionResources; // ─── 共享 session 构造(spawn / resume 共用,D1) ─────────────────────────── @@ -35,7 +35,7 @@ pub(super) fn build_subagent_session_v2( frozen: FrozenContext, cancel_token: CancellationToken, child_thread_id: String, - thread_store: Option>, + session_resources: Option>, inherited: InheritedContext, own: Vec, llm: Box, @@ -75,7 +75,7 @@ pub(super) fn build_subagent_session_v2( .with_ancestor_payloads(inherited.payloads) .with_own_payloads(own); with_ancestor.set_flags_batch(inherited.flags); - *transcript = match thread_store { + *transcript = match session_resources { Some(ref store) => { with_ancestor.with_persistence(Arc::clone(store), child_thread_id.clone()) } diff --git a/peri-agent/src/session/subagent/factory/resume.rs b/peri-agent/src/session/subagent/factory/resume.rs index 5d3ee5028..414cb01a2 100644 --- a/peri-agent/src/session/subagent/factory/resume.rs +++ b/peri-agent/src/session/subagent/factory/resume.rs @@ -66,7 +66,7 @@ pub(super) async fn resume_subagent_impl( compact_config, context_budget, compact_llm, - thread_store, + session_resources, event_handler, bg_event_sender, task_manager, @@ -85,42 +85,71 @@ pub(super) async fn resume_subagent_impl( frozen_date: frozen_date_cfg, } = config; - let workspace = if thread_store - .load_session_binding(&thread_id) - .await? - .is_some() - { - let child = thread_store.validate_session_binding(&thread_id).await?; + // 绑定校验用一次一致快照:child 的绑定必须与 owning parent 完全相同,且两者同根。 + // 存在性是本函数的第一个校验分支:快照读取报 `NotFound` 即「thread not found」, + // 与后续 `ResumeClaim` 的状态校验分开报错(不能把「不存在」说成「仍处于运行态」)。 + let child_snapshot = match session_resources.load_session_snapshot(&thread_id).await { + Ok(snapshot) => snapshot, + Err(error) + if matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + ) => + { + // 先判格式再判存在:非 UUID 不是「不存在」,与 `validate_thread` 同判据。 + super::validate_thread_id_format(&thread_id)?; + return Err(format!("resume_subagent: thread not found: {thread_id}").into()); + } + Err(error) => return Err(error.into()), + }; + let binding = match &child_snapshot.binding { + peri_acp_types::session_resources::BindingState::Bound(binding) => Some(binding.clone()), + // 无绑定(legacy / 外来登记 / 缺失)保留旧行为:不当作 bound child 处理。 + _ => None, + }; + if binding.is_some() { let parent_id = super::spawn::parent_thread_id_of(parent) .ok_or("bound child resume requires its owning parent session")?; - let current = thread_store.validate_session_binding(&parent_id).await?; - if child != current { + let parent_snapshot = session_resources.load_session_snapshot(&parent_id).await?; + let parent_binding = match &parent_snapshot.binding { + peri_acp_types::session_resources::BindingState::Bound(binding) => binding.clone(), + _ => return Err("bound subagent parent has no execution binding".into()), + }; + if binding.as_ref() != Some(&parent_binding) { return Err(peri_acp_types::workspace::WorkspaceError::ExecutionBindingMismatch.into()); } - if execution_root(thread_store.as_ref(), &thread_id).await? - != execution_root(thread_store.as_ref(), &parent_id).await? + if super::execution_root(session_resources.as_ref(), &thread_id).await? + != super::execution_root(session_resources.as_ref(), &parent_id).await? { return Err("bound subagent belongs to another root session execution owner".into()); } - Some(child) - } else { - None - }; + } let ownership = task_manager .as_ref() .map(|manager| { peri_acp_types::tasks::TaskManager::begin_external_execution(manager.as_ref()) }) .transpose()?; - let (meta, claim) = - ResumeClaim::acquire(Arc::clone(&thread_store), thread_id.clone(), ownership).await?; + let cluster_root = super::execution_root(session_resources.as_ref(), &thread_id).await?; + let (meta, claim) = ResumeClaim::acquire( + Arc::clone(&session_resources), + thread_id.clone(), + cluster_root, + ownership, + ) + .await?; // The claim remains owned across history I/O and session construction. A // dropped caller closes its decision channel, leaving ordered rollback to // the worker that performed the active write. let restored = async { - let mut inherited = thread_store.load_inherited_context(&thread_id).await?; - let own = thread_store.load_payloads(&thread_id).await?; + // 一次一致快照:inherit 区与自有 payload/flags 同一次读取(不再分三次)。 + let snapshot = session_resources + .load_session_snapshot(&thread_id) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let mut inherited = snapshot.inherited; + let own = snapshot.payloads; let ancestor_ids = inherited .payloads .iter() @@ -134,9 +163,8 @@ pub(super) async fn resume_subagent_impl( anyhow::bail!("inherited context overlaps child own history"); } inherited.flags.extend( - thread_store - .load_message_flags(&thread_id) - .await? + snapshot + .flags .into_iter() .filter(|(id, _)| own_ids.contains(id)), ); @@ -163,15 +191,9 @@ pub(super) async fn resume_subagent_impl( loaded.pop(); } - // 2. cwd 取 meta.cwd(thread 创建时固化的;进程重启后不得改用父 cwd) - let cwd = match workspace { - Some(workspace) => workspace - .cwd - .to_str() - .ok_or("session cwd is not UTF-8")? - .to_string(), - None => meta.cwd.clone(), - }; + // 2. cwd 取 meta.cwd(thread 创建时固化的;进程重启后不得改用父 cwd)。 + // 绑定身份已在上方与 owning parent 比对相等,工作区一致性随该绑定成立。 + let cwd = meta.cwd.clone(); // 3. frozen 从父 session copy(ARC-FROZEN-001:不重读磁盘;parent None 用 // config 回退,与 spawn 的父侧解析一致) @@ -210,7 +232,7 @@ pub(super) async fn resume_subagent_impl( frozen, cancel_token.clone(), thread_id.clone(), - Some(Arc::clone(&thread_store)), + Some(Arc::clone(&session_resources)), inherited, loaded, llm, @@ -270,7 +292,7 @@ pub(super) async fn resume_subagent_impl( event_handler, on_subagent_start, on_subagent_stop, - Some(Arc::clone(&thread_store)), + Some(Arc::clone(&session_resources)), register_runtime, deregister_runtime, langfuse_bridge, @@ -304,7 +326,7 @@ pub(super) async fn resume_subagent_impl( task_manager, on_bg_complete, langfuse_bridge, - Some(Arc::clone(&thread_store)), + Some(Arc::clone(&session_resources)), deregister_runtime, on_subagent_start, on_subagent_stop, @@ -315,7 +337,7 @@ pub(super) async fn resume_subagent_impl( ) .await { - Ok(()) => claim.release(), + Ok(()) => claim.release().await, Err(e) => { // review MEDIUM-1 回滚:注册失败(task_manager 缺失 / // register_with_kind 撞 per-kind 上限)时任务未执行——status @@ -339,22 +361,5 @@ pub(super) async fn resume_subagent_impl( } } -async fn execution_root( - store: &dyn crate::thread::ThreadStore, - id: &str, -) -> Result> { - let mut current = id.to_owned(); - let mut visited = std::collections::HashSet::new(); - loop { - if visited.len() >= 128 || !visited.insert(current.clone()) { - return Err(peri_acp_types::workspace::WorkspaceError::InvalidBinding.into()); - } - match store.load_meta(¤t).await?.parent_thread_id { - Some(parent) => current = parent, - None => return Ok(current), - } - } -} - /// 隐式 continue 指令(prompt 缺省时注入,issue 决策 9) const IMPLICIT_CONTINUE_PROMPT: &str = "Continue your previous task where you left off."; diff --git a/peri-agent/src/session/subagent/factory/spawn.rs b/peri-agent/src/session/subagent/factory/spawn.rs index cbc458ae6..d67b9d86f 100644 --- a/peri-agent/src/session/subagent/factory/spawn.rs +++ b/peri-agent/src/session/subagent/factory/spawn.rs @@ -15,7 +15,6 @@ use super::context::{build_subagent_session_v2, derive_cancel_token, inherited_f use crate::messages::BaseMessage; use crate::session::queue::{MessageKind, MessageSource, QueuedMessage}; use crate::session::Session; -use crate::thread::ThreadMeta; /// 父线程 ID 解析——spawn 写盘的**唯一取值点**(挂父子链): /// - 优先 parent session 的 `store().thread_id`:subagent 层 session 构造时以 @@ -77,7 +76,8 @@ pub(super) async fn spawn_subagent_impl( compact_config, context_budget, compact_llm, - thread_store, + session_resources, + execution_owner, event_handler, bg_event_sender, task_manager, @@ -136,7 +136,41 @@ pub(super) async fn spawn_subagent_impl( flags: Default::default(), }; if !inherited.payloads.is_empty() { - if let Some(parent) = parent { + if let (Some(store), Some(parent_id)) = (&session_resources, &parent_thread_id) { + // 继承来源是一次**一致快照**:canonical payload(含 reminder)与 flags 同一次 + // 读取,不再分 load_inherited_context / load_payloads / load_message_flags 三次。 + let snapshot = store.load_session_snapshot(parent_id).await?; + let mut canonical = snapshot + .inherited + .payloads + .into_iter() + .map(|payload| (payload.id(), payload)) + .collect::>(); + canonical.extend( + snapshot + .payloads + .into_iter() + .map(|payload| (payload.id(), payload)), + ); + for payload in &mut inherited.payloads { + if let Some(original) = canonical.get(&payload.id()) { + *payload = original.clone(); + } + } + let inherited_ids = inherited + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>(); + inherited.flags = snapshot.inherited.flags; + inherited.flags.extend( + snapshot + .flags + .into_iter() + .filter(|(id, _)| inherited_ids.contains(id)), + ); + } else if let Some(parent) = parent { + // 无持久化路径(测试/遗留):只能用父会话内存副本对齐 canonical 与 flags。 let transcript = parent.transcript(); let transcript = transcript.read(); let canonical = transcript @@ -158,78 +192,65 @@ pub(super) async fn spawn_subagent_impl( .map(|flags| (payload.id(), flags)) }) .collect(); - } else if let (Some(store), Some(parent_id)) = (&thread_store, &parent_thread_id) { - let parent_context = store.load_inherited_context(parent_id).await?; - let mut canonical = parent_context - .payloads - .into_iter() - .map(|payload| (payload.id(), payload)) - .collect::>(); - canonical.extend( - store - .load_payloads(parent_id) - .await? - .into_iter() - .map(|payload| (payload.id(), payload)), - ); - for payload in &mut inherited.payloads { - if let Some(original) = canonical.get(&payload.id()) { - *payload = original.clone(); - } - } - inherited.flags = parent_context.flags; - inherited - .flags - .extend(store.load_message_flags(parent_id).await?); - let ids = inherited - .payloads - .iter() - .map(PersistedPayload::id) - .collect::>(); - inherited.flags.retain(|id, _| ids.contains(id)); } } - // 4. 创建子线程(thread_store Some 时;None 跳过落库——仅测试/遗留路径) - if let Some(ref store) = thread_store { - let snapshot_id = parent_messages.last().map(|m| m.id().as_uuid().to_string()); - let mut child_meta = ThreadMeta::new(&cwd); - child_meta.id = child_thread_id.clone(); - child_meta.parent_thread_id = parent_thread_id.clone(); - child_meta.snapshot_at_message_id = snapshot_id; - child_meta.hidden = true; - child_meta.cancel_policy = cancel_policy; - child_meta.title = Some(agent_name.clone()); - let binding = match &parent_thread_id { - Some(id) => store.load_session_binding(id).await?, - None => None, + // 4. 保存 child:一次 `save_child` 落父子关系、绑定继承、frozen 原字节与继承区。 + // 失败即整体失败——不再有 create + store_inherited + delete_thread 的手工补偿链 + // (那正是「部分成功被当成已保存」的来源)。 + if let Some(ref store) = session_resources { + let parent_id = parent_thread_id + .clone() + .ok_or("spawn_subagent: 持久化 child 需要 parent thread id(无父会话则无继承来源)")?; + let snapshot = store.load_session_snapshot(&parent_id).await?; + let binding = match &snapshot.binding { + peri_acp_types::session_resources::BindingState::Bound(binding) => binding.clone(), + other => { + return Err(format!( + "spawn_subagent: parent session {parent_id} has no execution binding ({other:?}); child cannot inherit one" + ) + .into()) + } }; - if binding.is_some() { - let workspace = store - .validate_session_binding(parent_thread_id.as_ref().expect("bound parent")) - .await?; - if workspace.cwd != std::path::Path::new(&cwd) { - return Err( - peri_acp_types::workspace::WorkspaceError::ExecutionBindingMismatch.into(), - ); + // child frozen 取不可变 parent/root 的**已持久化**字节,不重扫目录、不按当前日期重冻; + // 门面会再校验它与 root 已保存快照逐字节相同。 + let frozen = match snapshot.frozen { + peri_acp_types::session_resources::FrozenState::Present(bytes) => bytes, + other => { + return Err(format!( + "spawn_subagent: parent session {parent_id} has no persisted frozen snapshot ({other:?})" + ) + .into()) } - store.create_bound_thread(child_meta, &workspace).await?; - } else { - store - .create_thread(child_meta) - .await - .map_err(|e| format!("Failed to create child thread: {}", e))?; - } - if let Err(error) = store - .store_inherited_context(&child_thread_id, &inherited) - .await - { - let cleanup = store.delete_thread(&child_thread_id).await; - return Err(format!( - "Failed to persist child inherited context: {error}; cleanup: {cleanup:?}" - ) - .into()); + }; + if snapshot.meta.cwd != cwd { + return Err(peri_acp_types::workspace::WorkspaceError::ExecutionBindingMismatch.into()); } + let root_id = super::execution_root(store.as_ref(), &parent_id).await?; + let lease = execution_owner.as_ref().ok_or( + "spawn_subagent: child 保存需要本会话 root 的执行所有权(save_child 不接受借来的所有权)", + )?; + let snapshot_id = parent_messages.last().map(|m| m.id()); + let child = peri_acp_types::session_resources::ChildSnapshot { + target: peri_acp_types::session_resources::NewSession { + thread_id: child_thread_id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: peri_acp_types::session_resources::NewSessionMeta { + title: Some(agent_name.clone()), + cwd: cwd.clone(), + parent_thread_id: Some(parent_id.clone()), + hidden: true, + cancel_policy, + snapshot_at_message_id: snapshot_id, + }, + binding, + frozen, + }, + parent_id, + root_id, + inherited: inherited.clone(), + }; + store.save_child(&child, lease).await?; } // 5. 构造子 session + 链装配 + v2_ctx(共享 helper [build_subagent_session_v2]: @@ -247,7 +268,7 @@ pub(super) async fn spawn_subagent_impl( frozen, cancel_token.clone(), child_thread_id.clone(), - thread_store.clone(), + session_resources.clone(), inherited, Vec::new(), // 新 child 没有 own history llm, @@ -308,7 +329,7 @@ pub(super) async fn spawn_subagent_impl( event_handler, on_subagent_start, on_subagent_stop, - thread_store, + session_resources, register_runtime, deregister_runtime, langfuse_bridge, @@ -339,7 +360,7 @@ pub(super) async fn spawn_subagent_impl( task_manager, on_bg_complete, langfuse_bridge, - thread_store, + session_resources, deregister_runtime, on_subagent_start, on_subagent_stop, diff --git a/peri-agent/src/session/subagent/lifecycle.rs b/peri-agent/src/session/subagent/lifecycle.rs index 3ab0c0d00..856ac701b 100644 --- a/peri-agent/src/session/subagent/lifecycle.rs +++ b/peri-agent/src/session/subagent/lifecycle.rs @@ -8,7 +8,8 @@ use crate::agent::events::ExecutorEvent; use crate::agent::events_v2::{observe_event_to_executor, EventBus}; use crate::session::factory::DeregisterRuntimeFn; use crate::session::turn::TurnId; -use crate::thread::ThreadStore; +use peri_acp_types::session_resources::{SessionMetaPatch, SessionResources}; +use peri_acp_types::thread::{AgentStatus, ThreadId}; // The loop has released its producers before this seam. A cleanup guard must // only retain a Weak, otherwise closing the stream would deadlock. @@ -127,7 +128,7 @@ impl Drop for BgCleanupGuard { /// /// 按顺序执行: /// 1. lifecycle hook (SubagentStop,经闭包) -/// 2. thread_store 状态更新(仅 sync 路径有此步骤) +/// 2. 会话状态定向更新(仅 sync 路径有此步骤) /// /// v1 SubagentStopped 协议化直发不在本函数内——由调用方在 /// `emit_subagent_stop_v2` 之后经 `forward_subagent_stop_v1` 同步映射发出 @@ -135,7 +136,7 @@ impl Drop for BgCleanupGuard { #[allow(clippy::too_many_arguments)] pub(crate) async fn on_subagent_stop_handler( on_subagent_stop: &Option, - thread_store: &Option>, + session_resources: &Option>, agent_id: &str, child_thread_id: &str, output_summary: &str, @@ -146,11 +147,21 @@ pub(crate) async fn on_subagent_stop_handler( if let Some(ref on_stop) = on_subagent_stop { on_stop(agent_id, cwd, output_summary, is_error); } - // 3. thread_store(仅 sync 路径有此步骤) - if let Some(ref store) = thread_store { - let status = if is_error { "error" } else { "done" }; + // 3. 终态状态(仅 sync 路径有此步骤):定向 patch 只写状态,不覆盖并发标题/计数。 + if let Some(ref store) = session_resources { + let status = if is_error { + AgentStatus::Error + } else { + AgentStatus::Done + }; let _ = store - .update_thread_status(&child_thread_id.to_string(), status) + .update_session_meta( + &ThreadId::from(child_thread_id), + &SessionMetaPatch { + status: Some(status), + ..Default::default() + }, + ) .await; } } diff --git a/peri-agent/src/session/subagent/provenance_test.rs b/peri-agent/src/session/subagent/provenance_test.rs index 4001d3d08..a48774a2b 100644 --- a/peri-agent/src/session/subagent/provenance_test.rs +++ b/peri-agent/src/session/subagent/provenance_test.rs @@ -4,11 +4,14 @@ use crate::agent::compact_v2::{ micro_compact, run_compact, CompactConfig, CompactOutcome, ContextPressure, }; use crate::messages::MessageId; -use crate::thread::SqliteThreadStore; use peri_acp_types::projection::{ MessageProjectionDirective, ProjectionAction, ProjectionActionEntry, ProjectionTarget, }; +use peri_acp_types::session_resources::{SessionResources, SessionStoreShutdownPort}; use peri_acp_types::store::PersistedPayload; +use peri_acp_types::workspace::{ + ResetDirtyRequest, ResolvedWorkspace, SessionExecutionLease, WorkspaceError, +}; struct SummaryModel; #[async_trait::async_trait] @@ -58,7 +61,7 @@ fn directive(id: MessageId) -> MessageProjectionDirective { } fn spawn_config( - store: Arc, + store: Arc, messages: Vec, cwd: &str, ) -> SubagentSpawnConfig { @@ -82,7 +85,8 @@ fn spawn_config( compact_config: None, context_budget: None, compact_llm: None, - thread_store: Some(store), + session_resources: Some(store), + execution_owner: None, event_handler: None, bg_event_sender: None, task_manager: None, @@ -135,16 +139,47 @@ async fn flush_session(session: &Arc) { *arc.write() = transcript; } +/// 冷重开后的所有权回收:崩溃留下的普通 dirty 必须按精确代际显式确认才可继续。 +async fn reacquire_execution( + store: &Arc, + workspace: &ResolvedWorkspace, + root: &ThreadId, +) -> Arc { + let error = match store.acquire_execution(root, workspace).await { + Ok(lease) => return lease, + Err(error) => error, + }; + let Some(WorkspaceError::RecoveryRequired(details)) = error.workspace_error() else { + panic!("冷重开应只要求解除 dirty 代际,实际: {error}"); + }; + store + .reset_dirty_execution(&ResetDirtyRequest { + target: details.clone(), + accept_risk: true, + }) + .await + .unwrap(); + store.acquire_execution(root, workspace).await.unwrap() +} + /// [回归测试] 原 parent ID 不得 append 成 child own;父 Full 之后冷恢复仍使用 spawn 时的父投影。 #[tokio::test] async fn test_sqlite_subagent_spawn_full_micro_cold_resume_preserves_provenance() { - let dir = tempfile::tempdir().unwrap(); - let cwd = dir.path().to_str().unwrap(); - let path = dir.path().join("subagent.db"); - let store = Arc::new(SqliteThreadStore::new(&path).await.unwrap()); - let parent_id = store.create_thread(ThreadMeta::new(cwd)).await.unwrap(); + let repo = crate::session::test_resources::git_repository(); + let db = tempfile::tempdir().unwrap(); + let db_path = db.path().join("provenance.db"); + // 真门面(真 SQLite):绑定、frozen 原字节、继承区与执行所有权都来自真实实现, + // 冷重开是同一库文件的第二个句柄——不是另一个空替身。 + let resources = peri_resources::Resources::open_with(Some(db_path.clone())) + .await + .unwrap(); + let (store, shutdown) = resources.into_parts(); + let workspace = store.resolve_workspace(repo.path()).await.unwrap(); + let cwd = workspace.cwd.to_string_lossy().into_owned(); + let (parent_id, parent_lease) = create_bound_root(&store, &workspace, None).await; + let parent_lease = parent_lease.expect("root 执行所有权"); let parent = Session::new( - Arc::from(cwd), + Arc::from(cwd.as_str()), FrozenContext::builder().build(), Some(parent_id.clone()), ); @@ -175,18 +210,18 @@ async fn test_sqlite_subagent_spawn_full_micro_cold_resume_preserves_provenance( transcript.flush_persistence().await.unwrap(); *arc.write() = transcript; } - let spawned = SessionFactory::spawn_subagent( - Some(&parent), - spawn_config(store.clone(), parent_messages.clone(), cwd), - ) - .await - .unwrap(); + let mut config = spawn_config(store.clone(), parent_messages.clone(), &cwd); + // child 落库要求本会话 root 的执行所有权(save_child 不接受借来的所有权)。 + config.execution_owner = Some(Arc::clone(&parent_lease)); + let spawned = SessionFactory::spawn_subagent(Some(&parent), config) + .await + .unwrap(); let child_id = spawned.child_thread_id.clone(); assert_eq!( spawned.session.transcript().read().ancestor_len(), parent_messages.len() ); - compact(&spawned.session, cwd).await; + compact(&spawned.session, &cwd).await; let own_tool = BaseMessage::tool_result("child-bash", "child-output-".repeat(1_000)); { let arc = spawned.session.transcript(); @@ -215,45 +250,57 @@ async fn test_sqlite_subagent_spawn_full_micro_cold_resume_preserves_provenance( transcript.shutdown_persistence(); *arc.write() = transcript; } - let child_flags = store.load_message_flags(&child_id).await.unwrap(); + let child_flags = store.load_session_snapshot(&child_id).await.unwrap().flags; assert!(child_flags.values().any(|flags| flags.excluded)); assert!(child_flags[&own_tool.id()].truncated); assert!(parent_messages .iter() .all(|message| !child_flags.contains_key(&message.id()))); let own_ids = store - .load_payloads(&child_id) + .load_session_snapshot(&child_id) .await .unwrap() + .payloads .iter() .map(PersistedPayload::id) .collect::>(); assert!(parent_messages .iter() .all(|message| !own_ids.contains(&message.id()))); - let parent_flags_before = store.load_message_flags(&parent_id).await.unwrap(); + let parent_flags_before = store.load_session_snapshot(&parent_id).await.unwrap().flags; assert_eq!( parent_flags_before[&parent_tool.id()].projection, Some(directive(parent_tool.id())) ); - compact(&parent, cwd).await; - assert!(store.load_message_flags(&parent_id).await.unwrap()[&parent_tool.id()].excluded); + compact(&parent, &cwd).await; + assert!( + store.load_session_snapshot(&parent_id).await.unwrap().flags[&parent_tool.id()].excluded + ); parent.transcript().read().shutdown_persistence(); drop(spawned); - drop(parent); - store.close().await; - let reopened = Arc::new(SqliteThreadStore::new(&path).await.unwrap()); + // 冷重开:先放弃本进程的 owner(模拟进程退出),再由新句柄按代际确认取回。 + drop(parent_lease); + // 关闭走部署关闭权(业务句柄没有全局关闭;这里与部署装配同形)。 + shutdown.shutdown().await.unwrap(); + let reopened_resources = peri_resources::Resources::open_with(Some(db_path.clone())) + .await + .unwrap(); + let (reopened, reopened_shutdown) = reopened_resources.into_parts(); + let reopened_workspace = reopened.resolve_workspace(repo.path()).await.unwrap(); + let _reopened_lease = reacquire_execution(&reopened, &reopened_workspace, &parent_id).await; let recording = RecordingLLM::new(); let received = recording.received.clone(); let config = resume_config_with( - reopened.clone(), + Arc::clone(&reopened), child_id.clone(), Box::new(recording), SubagentRunMode::Sync, None, None, ); - let resumed = SessionFactory::resume_subagent(None, config).await.unwrap(); + let resumed = SessionFactory::resume_subagent(Some(&parent), config) + .await + .unwrap(); { let arc = resumed.session.transcript(); let transcript = arc.read(); @@ -296,5 +343,5 @@ async fn test_sqlite_subagent_spawn_full_micro_cold_resume_preserves_provenance( flush_session(&resumed.session).await; resumed.session.transcript().read().shutdown_persistence(); drop(resumed); - reopened.close().await; + reopened_shutdown.shutdown().await.unwrap(); } diff --git a/peri-agent/src/session/subagent/run_sync.rs b/peri-agent/src/session/subagent/run_sync.rs index 605493b16..45d21d81d 100644 --- a/peri-agent/src/session/subagent/run_sync.rs +++ b/peri-agent/src/session/subagent/run_sync.rs @@ -18,7 +18,7 @@ use crate::agent::subagent_event_forwarder::spawn_subagent_event_forwarder_for_c use crate::agent::LangfuseBridgeLike; use crate::session::factory::{DeregisterRuntimeFn, RegisterRuntimeFn}; use crate::session::Session; -use crate::thread::ThreadStore; +use peri_acp_types::session_resources::SessionResources; // ─── 同步运行 ──────────────────────────────────────────────────────────────── @@ -32,7 +32,7 @@ pub(super) async fn run_sync_subagent( event_handler: Option>, on_subagent_start: Option, on_subagent_stop: Option, - thread_store: Option>, + session_resources: Option>, register_runtime: Option, deregister_runtime: Option, langfuse_bridge: Option>, @@ -62,10 +62,10 @@ pub(super) async fn run_sync_subagent( if let Some(claim) = &mut resume_claim { claim.mark_running(); } - let stop_store = if resume_claim.is_some() { - None // The claim worker serializes resumed terminal status writes. + let stop_resources = if resume_claim.is_some() { + None // 认领持有终态写入(finish 内定向写状态),此处不重复写。 } else { - thread_store + session_resources }; // lifecycle hook(SubagentStart) @@ -201,7 +201,7 @@ pub(super) async fn run_sync_subagent( // 之后经 forward_subagent_stop_v1 发出) on_subagent_stop_handler( &on_subagent_stop, - &stop_store, + &stop_resources, &agent_name, child_thread_id, &error_result, @@ -210,7 +210,9 @@ pub(super) async fn run_sync_subagent( ) .await; if let Some(claim) = resume_claim.take() { - claim.finish("error").await; + claim + .finish(peri_acp_types::thread::AgentStatus::Error) + .await; } return Err(Box::new(failure)); } @@ -223,7 +225,7 @@ pub(super) async fn run_sync_subagent( }; on_subagent_stop_handler( &on_subagent_stop, - &stop_store, + &stop_resources, &agent_name, child_thread_id, &output_summary, @@ -233,7 +235,11 @@ pub(super) async fn run_sync_subagent( .await; if let Some(claim) = resume_claim.take() { claim - .finish(if interrupted { "error" } else { "done" }) + .finish(if interrupted { + peri_acp_types::thread::AgentStatus::Error + } else { + peri_acp_types::thread::AgentStatus::Done + }) .await; } diff --git a/peri-agent/src/session/subagent/types.rs b/peri-agent/src/session/subagent/types.rs index 2fe45ed17..1e219fcb8 100644 --- a/peri-agent/src/session/subagent/types.rs +++ b/peri-agent/src/session/subagent/types.rs @@ -14,8 +14,9 @@ use crate::messages::BaseMessage; use crate::middleware::chain::MiddlewareChain; use crate::session::factory::{DeregisterRuntimeFn, RegisterRuntimeFn}; use crate::session::Session; -use crate::thread::ThreadStore; use crate::tools::{BaseTool, ToolInvocationResolver}; +use peri_acp_types::session_resources::SessionResources; +use peri_acp_types::workspace::SessionExecutionLease; // ─── 意图类型 ──────────────────────────────────────────────────────────────── @@ -96,8 +97,11 @@ pub trait SubagentChainAssembler: Send + Sync { #[derive(Clone, Default)] #[allow(clippy::type_complexity)] pub struct SubagentHost { - /// 线程持久化存储(生产路径非 None;None 仅测试/遗留路径,跳过落库) - pub thread_store: Option>, + /// 会话资源门面(生产路径非 None;None 仅测试/遗留路径,跳过落库) + pub session_resources: Option>, + /// 本会话 root 的执行所有权:`save_child` 需要调用方证明自己持有这条 owner + /// (门面据此拒绝「借别人的所有权写」)。None = 无执行权,子会话不落库。 + pub execution_owner: Option>, /// 后台任务管理器(per-session 聚合) pub task_manager: Option>, /// 后台任务完成事件通道(bg pump,独立于主 event pump) @@ -179,8 +183,10 @@ pub struct SubagentSpawnConfig { /// Full Compact 专用 LLM(None 时 Full Compact 跳过) pub compact_llm: Option>, // ── 运行时通道 ── - /// 线程持久化存储(None = 不落库,仅测试/遗留路径) - pub thread_store: Option>, + /// 会话资源门面(None = 不落库,仅测试/遗留路径) + pub session_resources: Option>, + /// 本会话 root 的执行所有权(`save_child` 的前置证明;None = 不落库) + pub execution_owner: Option>, /// 父 agent 事件 handler(同步路径事件转发 / 重试事件追踪) pub event_handler: Option>, /// bg 任务完成事件发送通道(bg pump) @@ -344,7 +350,7 @@ impl std::error::Error for SubagentFailure { /// transcript 中,重复注入会重复); /// - 无 `skill_names`(R-H1:SkillPreload 重复注入——旧 transcript 已含首轮注入 /// 的 skill 内容,恢复时恒传空); -/// - `thread_store` 必填(恢复现场的唯一来源是磁盘 thread)。 +/// - `session_resources` 必填(恢复现场的唯一来源是持久化会话快照)。 /// /// 父侧数据(cwd / parent_thread_id / frozen 回退值)在 `parent` 存在时从 parent /// Session 读取,config 中对应字段仅作 parent 缺失时的回退(与 spawn 一致)。 @@ -384,8 +390,11 @@ pub struct SubagentResumeConfig { /// Full Compact 专用 LLM(None 时 Full Compact 跳过) pub compact_llm: Option>, // ── 运行时通道 ── - /// 线程持久化存储(必填:恢复现场来源) - pub thread_store: Arc, + /// 会话资源门面(必填:恢复现场来源) + /// + /// resume 认领不需要调用方另持所有权:`claim_child_resume` 在 root 的写侧门禁内 + /// 完成「读状态 + 写 active」,调用方只提交领域结果。 + pub session_resources: Arc, /// 父 agent 事件 handler(同步路径事件转发 / 重试事件追踪) pub event_handler: Option>, /// bg 任务完成事件发送通道(bg pump) diff --git a/peri-agent/src/session/subagent_test.rs b/peri-agent/src/session/subagent_test.rs index c5816b47a..7ebfbc877 100644 --- a/peri-agent/src/session/subagent_test.rs +++ b/peri-agent/src/session/subagent_test.rs @@ -3,8 +3,6 @@ //! - C1 身份键契约测试(自 peri-middlewares v2_bridge.rs 随迁,断言语义不重写) //! - spawn_subagent 用例:thread 父子链落库、frozen copy、agent_status 收尾 -use std::collections::HashMap; -use std::str::FromStr; use std::sync::Arc; use parking_lot::RwLock; @@ -17,7 +15,12 @@ use crate::session::subagent::{ agent_id_from_child_thread, build_v2_subagent_context, ForkDirectiveKind, SessionFactory, SubagentCancelPolicy, SubagentResumeConfig, SubagentRunMode, SubagentSpawnConfig, }; +use crate::session::test_resources::mock::{MockSessionResources, ResumeLoadGate}; use crate::thread::ThreadId; +use peri_acp_types::session_resources::{ + ChildSnapshot, FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionMetaPatch, +}; +use peri_acp_types::workspace::{SessionBinding, SESSION_BINDING_VERSION}; #[test] fn subagent_failure_keeps_child_identity_and_typed_model_diagnostic() { @@ -209,200 +212,6 @@ impl crate::agent::react::ReactLLM for EchoLLM { } } -/// 内存 mock ThreadStore(断言 thread 父子链落库 + agent_status 收尾; -/// 消息存储真实化——resume 测试前置条件:append → load 往返可见) -struct ResumeLoadGate { - entered: tokio::sync::oneshot::Sender<()>, - release: tokio::sync::oneshot::Receiver<()>, - dropped: Arc, -} - -impl ResumeLoadGate { - async fn wait(self) { - struct DropProbe(Arc); - impl Drop for DropProbe { - fn drop(&mut self) { - self.0.store(true, std::sync::atomic::Ordering::SeqCst); - } - } - let _probe = DropProbe(self.dropped); - let _ = self.entered.send(()); - let _ = self.release.await; - } -} - -struct MockThreadStore { - load_gate: std::sync::Mutex>, - inherited_load_gate: std::sync::Mutex>, - flags_load_gate: std::sync::Mutex>, - active_write_gate: std::sync::Mutex>, - status_changed: tokio::sync::Notify, - threads: Arc>>, - statuses: Arc>>, - messages: Arc>>>, - inherited: RwLock>, - /// 一次性开关:置 true 后下一次 load_messages 返回 Err(重建失败回滚测试用) - fail_load_messages: std::sync::atomic::AtomicBool, -} - -impl MockThreadStore { - fn new() -> Self { - Self { - load_gate: std::sync::Mutex::new(None), - inherited_load_gate: std::sync::Mutex::new(None), - flags_load_gate: std::sync::Mutex::new(None), - active_write_gate: std::sync::Mutex::new(None), - status_changed: tokio::sync::Notify::new(), - threads: Arc::new(RwLock::new(Vec::new())), - statuses: Arc::new(RwLock::new(Vec::new())), - messages: Arc::new(RwLock::new(HashMap::new())), - inherited: RwLock::new(HashMap::new()), - fail_load_messages: std::sync::atomic::AtomicBool::new(false), - } - } -} - -#[async_trait::async_trait] -impl crate::thread::ThreadStore for MockThreadStore { - async fn create_thread(&self, meta: ThreadMeta) -> anyhow::Result { - self.threads.write().push(meta.clone()); - Ok(meta.id) - } - - async fn store_inherited_context( - &self, - id: &ThreadId, - context: &peri_acp_types::store::InheritedContext, - ) -> anyhow::Result<()> { - self.inherited.write().insert(id.clone(), context.clone()); - Ok(()) - } - - async fn load_inherited_context( - &self, - id: &ThreadId, - ) -> anyhow::Result { - let gate = { self.inherited_load_gate.lock().unwrap().take() }; - if let Some(gate) = gate { - gate.wait().await; - } - Ok(self.inherited.read().get(id).cloned().unwrap_or_default()) - } - - async fn load_message_flags( - &self, - _id: &ThreadId, - ) -> anyhow::Result> - { - let gate = { self.flags_load_gate.lock().unwrap().take() }; - if let Some(gate) = gate { - gate.wait().await; - } - Ok(HashMap::new()) - } - - async fn append_messages(&self, id: &ThreadId, msgs: &[BaseMessage]) -> anyhow::Result<()> { - self.messages - .write() - .entry(id.clone()) - .or_default() - .extend(msgs.iter().cloned()); - Ok(()) - } - - async fn load_messages(&self, id: &ThreadId) -> anyhow::Result> { - let gate = { self.load_gate.lock().unwrap().take() }; - if let Some(gate) = gate { - gate.wait().await; - } - // 一次性失败开关:重建失败回滚测试使用(模拟磁盘读取失败) - if self - .fail_load_messages - .swap(false, std::sync::atomic::Ordering::SeqCst) - { - return Err(anyhow::anyhow!("load failed (test injection)")); - } - Ok(self.messages.read().get(id).cloned().unwrap_or_default()) - } - - async fn load_meta(&self, id: &ThreadId) -> anyhow::Result { - self.threads - .read() - .iter() - .find(|t| &t.id == id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("thread not found")) - } - - async fn update_meta(&self, _id: &ThreadId, _meta: ThreadMeta) -> anyhow::Result<()> { - Ok(()) - } - - async fn list_threads(&self) -> anyhow::Result> { - Ok(self.threads.read().clone()) - } - - async fn delete_thread(&self, _id: &ThreadId) -> anyhow::Result<()> { - Ok(()) - } - - async fn load_context(&self, _thread_id: &ThreadId) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn list_child_threads(&self, parent_id: &ThreadId) -> anyhow::Result> { - Ok(self - .threads - .read() - .iter() - .filter(|t| t.parent_thread_id.as_deref() == Some(parent_id)) - .cloned() - .collect()) - } - - async fn list_session_threads(&self, _root_id: &ThreadId) -> anyhow::Result> { - Ok(self.threads.read().clone()) - } - - async fn update_thread_status(&self, id: &ThreadId, status: &str) -> anyhow::Result<()> { - // 与真实 store(filesystem.rs:235 / sqlite_store.rs:605)对齐: - // 1) 先 load_meta 存在性检查(不存在返回 Err,不静默 no-op) - // 2) 参数字符串必须经 FromStr 解析,非法值返回错误,不静默 fallback - // 3) 先 push statuses 列表,再同步更新 threads 中 ThreadMeta 的 agent_status - // (R-L2:resume 校验「非 active」依赖此读回路径) - let mut meta = self.load_meta(id).await?; - let status = AgentStatus::from_str(status) - .map_err(|e| anyhow::anyhow!("非法 agent_status 值: {:?}", e))?; - meta.agent_status = status; - self.statuses - .write() - .push((id.clone(), status.as_str().to_string())); - if let Some(meta) = self.threads.write().iter_mut().find(|t| &t.id == id) { - meta.agent_status = status; - } - self.status_changed.notify_one(); - if status == AgentStatus::Active { - let gate = { self.active_write_gate.lock().unwrap().take() }; - if let Some(gate) = gate { - gate.wait().await; - } - } - Ok(()) - } - - async fn invalidate_context_cache(&self, _thread_id: &ThreadId) -> anyhow::Result<()> { - Ok(()) - } - - async fn delete_messages( - &self, - _thread_id: &ThreadId, - _message_ids: &[crate::messages::MessageId], - ) -> anyhow::Result<()> { - Ok(()) - } -} - /// 空链装配器(测试用:无中间件) struct EmptyChainAssembler; @@ -412,10 +221,10 @@ impl SubagentChainAssembler for EmptyChainAssembler { } } -/// MockThreadStore:append → load 消息往返(resume 前置条件:磁盘 transcript 可读回) +/// MockSessionResources:append → load 消息往返(resume 前置条件:磁盘 transcript 可读回) #[tokio::test] async fn test_mock_store_append_load_roundtrip() { - let store = MockThreadStore::new(); + let store = MockSessionResources::new(); let id = "thread-1".to_string(); store .append_messages( @@ -435,10 +244,10 @@ async fn test_mock_store_append_load_roundtrip() { assert!(other.is_empty(), "未写入消息的 thread 读回空列表"); } -/// MockThreadStore:update_thread_status 同步 ThreadMeta.agent_status(R-L2) +/// MockSessionResources:update_thread_status 同步 ThreadMeta.agent_status(R-L2) #[tokio::test] async fn test_mock_store_update_status_reads_back() { - let store = MockThreadStore::new(); + let store = MockSessionResources::new(); let id = "thread-1".to_string(); let mut meta = ThreadMeta::new("/tmp"); meta.id = id.clone(); @@ -466,7 +275,9 @@ async fn test_mock_store_update_status_reads_back() { /// cancel_policy 与意图一致、thread_id = agent_id) #[tokio::test] async fn test_spawn_subagent_creates_child_thread_with_parent_link() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); + // child 落库的前置条件:父会话已绑定(有 binding 与 frozen)+ 本会话 root 的执行所有权。 + let lease = store.register_bound_session("parent-thread-1", "/tmp/work"); let parent = Session::new( Arc::from("/tmp/work"), FrozenContext::builder() @@ -497,7 +308,10 @@ async fn test_spawn_subagent_creates_child_thread_with_parent_link() { compact_config: None, context_budget: None, compact_llm: None, - thread_store: Some(Arc::clone(&store) as Arc), + session_resources: Some( + Arc::clone(&store) as Arc + ), + execution_owner: Some(lease), event_handler: None, bg_event_sender: None, task_manager: None, @@ -521,9 +335,21 @@ async fn test_spawn_subagent_creates_child_thread_with_parent_link() { .await .expect("spawn ok"); - let threads = store.threads.read(); - assert_eq!(threads.len(), 1, "必须创建 1 个 child thread"); - let meta = &threads[0]; + // 夹具另登记了父会话行:child 必须按 id 定位,不能按登记位置取。 + let threads = store.threads(); + let child = threads + .iter() + .find(|meta| meta.id == spawned.child_thread_id) + .expect("必须创建 child thread"); + assert_eq!( + threads + .iter() + .filter(|meta| meta.parent_thread_id.is_some()) + .count(), + 1, + "必须创建 1 个 child thread" + ); + let meta = child; assert_eq!(meta.id, spawned.child_thread_id, "thread_id = agent_id"); assert_eq!( meta.parent_thread_id.as_deref(), @@ -543,7 +369,7 @@ async fn test_spawn_subagent_creates_child_thread_with_parent_link() { ); // agent_status 收尾(NullReactLLM 直接完成 → done) - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -556,7 +382,8 @@ async fn test_spawn_subagent_creates_child_thread_with_parent_link() { /// 同源;resume 已不做 parent 链校验,父子链仅作落盘记录) #[tokio::test] async fn test_spawn_subagent_main_agent_via_host_writes_parent_link() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); + let lease = store.register_bound_session("main-context-thread", "/tmp/work"); // 主 agent 样子:store().thread_id = None + host.parent_thread_id = ctx.thread_id let parent = Session::new( Arc::from("/tmp/work"), @@ -588,7 +415,10 @@ async fn test_spawn_subagent_main_agent_via_host_writes_parent_link() { compact_config: None, context_budget: None, compact_llm: None, - thread_store: Some(Arc::clone(&store) as Arc), + session_resources: Some( + Arc::clone(&store) as Arc + ), + execution_owner: Some(lease), event_handler: None, bg_event_sender: None, task_manager: None, @@ -612,10 +442,21 @@ async fn test_spawn_subagent_main_agent_via_host_writes_parent_link() { .await .expect("spawn ok"); - let threads = store.threads.read(); - assert_eq!(threads.len(), 1, "必须创建 1 个 child thread"); + let threads = store.threads(); + let child = threads + .iter() + .find(|meta| meta.parent_thread_id.is_some()) + .expect("必须创建 child thread"); + assert_eq!( + threads + .iter() + .filter(|meta| meta.parent_thread_id.is_some()) + .count(), + 1, + "必须创建 1 个 child thread" + ); assert_eq!( - threads[0].parent_thread_id.as_deref(), + child.parent_thread_id.as_deref(), Some("main-context-thread"), "parent id 经 host 注入正确落库(store().thread_id 为 None 时)" ); @@ -624,7 +465,8 @@ async fn test_spawn_subagent_main_agent_via_host_writes_parent_link() { /// spawn_subagent:frozen data 从父 session copy(不重新读取磁盘) #[tokio::test] async fn test_spawn_subagent_copies_frozen_from_parent() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); + let lease = store.register_bound_session("parent-thread-2", "/tmp/work"); let parent = Session::new( Arc::from("/tmp/work"), FrozenContext::builder() @@ -655,7 +497,10 @@ async fn test_spawn_subagent_copies_frozen_from_parent() { compact_config: None, context_budget: None, compact_llm: None, - thread_store: Some(Arc::clone(&store) as Arc), + session_resources: Some( + Arc::clone(&store) as Arc + ), + execution_owner: Some(lease), event_handler: None, bg_event_sender: None, task_manager: None, @@ -708,7 +553,8 @@ async fn test_spawn_subagent_copies_frozen_from_parent() { /// spawn_subagent:parent 为 None(/bg 命令等无 session 路径)时用 config 回退值 #[tokio::test] async fn test_spawn_subagent_without_parent_uses_config_fallback() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); + let lease = store.register_bound_session("bg-parent", "/tmp/bg"); let config = SubagentSpawnConfig { agent_name: "fork".to_string(), prompt: "bg task".to_string(), @@ -729,7 +575,10 @@ async fn test_spawn_subagent_without_parent_uses_config_fallback() { compact_config: None, context_budget: None, compact_llm: None, - thread_store: Some(Arc::clone(&store) as Arc), + session_resources: Some( + Arc::clone(&store) as Arc + ), + execution_owner: Some(lease), event_handler: None, bg_event_sender: None, task_manager: None, @@ -753,17 +602,27 @@ async fn test_spawn_subagent_without_parent_uses_config_fallback() { .await .expect("spawn ok"); - let threads = store.threads.read(); - assert_eq!(threads.len(), 1); + let threads = store.threads(); + let child = threads + .iter() + .find(|meta| meta.parent_thread_id.is_some()) + .expect("必须创建 child thread"); + assert_eq!( + threads + .iter() + .filter(|meta| meta.parent_thread_id.is_some()) + .count(), + 1 + ); assert_eq!( - threads[0].parent_thread_id.as_deref(), + child.parent_thread_id.as_deref(), Some("bg-parent"), "parent 缺失时使用 config.parent_thread_id" ); let child_frozen = &spawned.session.store().frozen; assert_eq!(child_frozen.claude_md.as_ref(), "bg-claude"); assert_eq!(child_frozen.skill_summary.as_ref(), "bg-skills"); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -774,9 +633,12 @@ async fn test_spawn_subagent_without_parent_uses_config_fallback() { // ─── resume_subagent 用例(slice 4/5 重建 + 执行) ───────────────────────── /// 构造最小 resume config(默认:EchoLLM / Sync / 无 task_manager / 无 cancel_token) -fn resume_config(thread_store: Arc, thread_id: String) -> SubagentResumeConfig { +fn resume_config( + session_resources: Arc, + thread_id: String, +) -> SubagentResumeConfig { resume_config_with( - thread_store, + session_resources, thread_id, Box::new(EchoLLM), SubagentRunMode::Sync, @@ -788,7 +650,7 @@ fn resume_config(thread_store: Arc, thread_id: String) -> Subag /// 构造带自定义装配/运行参数的 resume config #[allow(clippy::too_many_arguments)] fn resume_config_with( - thread_store: Arc, + session_resources: Arc, thread_id: String, llm: Box, run_mode: SubagentRunMode, @@ -811,7 +673,8 @@ fn resume_config_with( compact_config: None, context_budget: None, compact_llm: None, - thread_store: Arc::clone(&thread_store) as Arc, + session_resources: Arc::clone(&session_resources) + as Arc, event_handler: None, bg_event_sender: None, task_manager, @@ -966,7 +829,7 @@ impl crate::agent::react::ReactLLM for CancelGateLLM { /// 预置可恢复 thread:创建 + 置非 active(status "done")。 /// 消息由各测试按需 append。 async fn preset_resumable_thread( - store: &MockThreadStore, + store: &MockSessionResources, thread_id: &str, parent_thread_id: Option<&str>, ) { @@ -993,7 +856,7 @@ async fn resume_err(parent: Option<&Arc>, config: SubagentResumeConfig) /// 重建阶段 agent_id_from_child_thread 会对非 UUID panic,入口统一拒绝) #[tokio::test] async fn test_resume_subagent_invalid_thread_id_rejected() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let config = resume_config(Arc::clone(&store), "not-a-uuid".to_string()); let err = resume_err(None, config).await; assert_eq!(err, "resume_subagent: invalid thread id: not-a-uuid"); @@ -1002,7 +865,7 @@ async fn test_resume_subagent_invalid_thread_id_rejected() { /// resume_subagent:校验分支 1——thread 不存在 → Err #[tokio::test] async fn test_resume_subagent_thread_not_found() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); // 合法 UUID 但未创建(low-1 后非 UUID 会先被格式校验拦截,测不到 not found) let id = uuid::Uuid::now_v7().to_string(); let config = resume_config(Arc::clone(&store), id.clone()); @@ -1015,7 +878,7 @@ async fn test_resume_subagent_thread_not_found() { /// 并完整执行 #[tokio::test] async fn test_resume_subagent_active_thread_rejected() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let id = uuid::Uuid::now_v7().to_string(); let mut meta = ThreadMeta::new("/tmp"); meta.id = id.clone(); @@ -1047,7 +910,7 @@ async fn test_resume_subagent_active_thread_rejected() { .expect("非 active 后可恢复"); assert_eq!(spawned.child_thread_id, id); assert!(!spawned.interrupted); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1060,7 +923,7 @@ async fn test_resume_subagent_active_thread_rejected() { /// thread_id 即恢复凭证,不做所有权校验(曾误判拒绝兄弟 subagent 恢复)。 #[tokio::test] async fn test_resume_subagent_parent_mismatch_not_rejected() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let id = uuid::Uuid::now_v7().to_string(); let mut meta = ThreadMeta::new("/tmp"); meta.id = id.clone(); @@ -1078,7 +941,7 @@ async fn test_resume_subagent_parent_mismatch_not_rejected() { .await .expect("parent 链不匹配不再拒绝恢复"); assert_eq!(spawned.child_thread_id, id, "thread_id 不变"); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1091,7 +954,7 @@ async fn test_resume_subagent_parent_mismatch_not_rejected() { /// (parent 链校验已移除,本测试保留为主 agent 路径的恢复成功回归) #[tokio::test] async fn test_resume_subagent_main_agent_via_host_parent_id() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &id, Some("main-context-thread")).await; @@ -1111,7 +974,7 @@ async fn test_resume_subagent_main_agent_via_host_parent_id() { .await .expect("主 agent 场景恢复应成功"); assert_eq!(spawned.child_thread_id, id, "thread_id 不变"); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1122,7 +985,7 @@ async fn test_resume_subagent_main_agent_via_host_parent_id() { /// resume_subagent:校验全部通过 → 重建 + 完整执行(thread_id 不变) #[tokio::test] async fn test_resume_subagent_validation_passes_and_runs() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let id = uuid::Uuid::now_v7().to_string(); let mut meta = ThreadMeta::new("/tmp"); meta.id = id.clone(); @@ -1141,7 +1004,7 @@ async fn test_resume_subagent_validation_passes_and_runs() { .expect("校验通过后恢复执行"); assert_eq!(spawned.child_thread_id, id, "thread_id 不变"); assert!(!spawned.interrupted); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1153,7 +1016,7 @@ async fn test_resume_subagent_validation_passes_and_runs() { /// status 状态机 done → active → done、cwd 取 meta.cwd、frozen 从父 copy #[tokio::test] async fn test_resume_subagent_replays_transcript_and_preserves_thread_id() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); let parent_id = "parent-thread-r1"; preset_resumable_thread(&store, &thread_id, Some(parent_id)).await; @@ -1216,7 +1079,7 @@ async fn test_resume_subagent_replays_transcript_and_preserves_thread_id() { assert_eq!(child_frozen.date.as_ref(), "2026-08-05"); // status 状态机:预置 done → 恢复置 active → 完成收尾 done - let statuses = store.statuses.read(); + let statuses = store.statuses(); let seq: Vec<&str> = statuses.iter().map(|(_, s)| s.as_str()).collect(); assert_eq!(seq, vec!["done", "active", "done"], "status 状态机完整"); } @@ -1225,7 +1088,7 @@ async fn test_resume_subagent_replays_transcript_and_preserves_thread_id() { /// 不回放进 transcript、不发给 LLM;已配对轮次保留 #[tokio::test] async fn test_resume_subagent_pops_unpaired_tool_call_ai() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; @@ -1313,7 +1176,7 @@ async fn test_resume_subagent_pops_unpaired_tool_call_ai() { /// 已完成轮次(含副作用)完整重放,避免 LLM 重复执行工具副作用 #[tokio::test] async fn test_resume_subagent_keeps_complete_tool_round() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; @@ -1361,7 +1224,7 @@ async fn test_resume_subagent_keeps_complete_tool_round() { /// (不套 fork directive),EchoLLM 消费并回显;不注入隐式 continue #[tokio::test] async fn test_resume_subagent_new_prompt_appended() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; store @@ -1398,7 +1261,7 @@ async fn test_resume_subagent_new_prompt_appended() { /// 恢复完成后写 "done";thread_id 全程不变 #[tokio::test] async fn test_resume_subagent_interrupted_then_resumed_completes() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; store @@ -1427,7 +1290,7 @@ async fn test_resume_subagent_interrupted_then_resumed_completes() { .expect("resume 1 ok(中断不是 Err)"); assert!(spawned1.interrupted, "Reason 内 cancel 必须是 Interrupted"); { - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("error"), @@ -1442,7 +1305,7 @@ async fn test_resume_subagent_interrupted_then_resumed_completes() { .expect("resume 2 ok"); assert!(!spawned2.interrupted); assert_eq!(spawned2.child_thread_id, thread_id, "thread_id 不变"); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1454,7 +1317,7 @@ async fn test_resume_subagent_interrupted_then_resumed_completes() { /// 仅一个成功进入执行(第二个在锁内看到 active 被拒) #[tokio::test] async fn test_resume_subagent_concurrent_resume_mutex() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; @@ -1477,12 +1340,7 @@ async fn test_resume_subagent_concurrent_resume_mutex() { // 等待 t1 完成「校验 → 置 active」(锁内置位;随后 t1 进入执行并被 gate 挂起) tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { - if store - .statuses - .read() - .iter() - .any(|(_, s)| s.as_str() == "active") - { + if store.statuses().iter().any(|(_, s)| s.as_str() == "active") { break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -1513,7 +1371,7 @@ async fn test_resume_subagent_concurrent_resume_mutex() { let spawned = t1.await.expect("t1 task ok").expect("t1 resume ok"); assert!(!spawned.interrupted); assert_eq!(spawned.child_thread_id, thread_id); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1525,14 +1383,13 @@ async fn test_resume_subagent_concurrent_resume_mutex() { /// (不被 active 卡死,可再次恢复) #[tokio::test] async fn test_resume_subagent_rolls_back_status_on_rebuild_failure() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; - // 注入一次性 load_messages 失败(own history 读取阶段) - store - .fail_load_messages - .store(true, std::sync::atomic::Ordering::SeqCst); + // 注入「认领后 history 装载」失败:第 1 次快照读取是认领前的绑定分类, + // 第 2 次才是 own history 装载。 + store.fail_snapshot_load_at(2); let config = resume_config(store.clone(), thread_id.clone()); let err = resume_err(None, config).await; assert!( @@ -1549,7 +1406,7 @@ async fn test_resume_subagent_rolls_back_status_on_rebuild_failure() { "重建失败必须回滚 status 至原值" ); { - let statuses = store.statuses.read(); + let statuses = store.statuses(); let seq: Vec<&str> = statuses.iter().map(|(_, s)| s.as_str()).collect(); assert_eq!(seq, vec!["done", "active", "done"], "active 后回滚原值"); } @@ -1566,7 +1423,7 @@ async fn test_resume_subagent_rolls_back_status_on_rebuild_failure() { /// (/bg 命令等路径)→ 恢复成功(无 parent 链校验,仅存在性 + status 校验) #[tokio::test] async fn test_resume_subagent_parent_none_skips_chain_check() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); // meta 声明了父链,但调用方无 parent session(/bg 命令等路径) preset_resumable_thread(&store, &thread_id, Some("orphan-parent")).await; @@ -1581,7 +1438,7 @@ async fn test_resume_subagent_parent_none_skips_chain_check() { .expect("parent None 时无 parent 链校验,恢复成功"); assert_eq!(spawned.child_thread_id, thread_id); assert!(!spawned.interrupted); - let statuses = store.statuses.read(); + let statuses = store.statuses(); assert_eq!( statuses.last().map(|(_, s)| s.as_str()), Some("done"), @@ -1593,7 +1450,7 @@ async fn test_resume_subagent_parent_none_skips_chain_check() { /// TaskManager 注册 Running、放行后完成收尾 done + registry 移除 #[tokio::test] async fn test_resume_subagent_background_mode_done() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; store @@ -1642,7 +1499,7 @@ async fn test_resume_subagent_background_mode_done() { let _ = release_tx.send(()); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { - if store.statuses.read().last().map(|(_, s)| s.as_str()) == Some("done") { + if store.statuses().last().map(|(_, s)| s.as_str()) == Some("done") { break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -1660,7 +1517,7 @@ async fn test_resume_subagent_background_mode_done() { /// bg resume cancelled 分支:Reason 内取消 → bg 中断收尾写 "cancelled" #[tokio::test] async fn test_resume_subagent_background_mode_cancelled() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; @@ -1700,7 +1557,7 @@ async fn test_resume_subagent_background_mode_cancelled() { completed.output ); assert_eq!( - store.statuses.read().last().map(|(_, s)| s.as_str()), + store.statuses().last().map(|(_, s)| s.as_str()), Some("cancelled"), "bg 中断收尾必须写 cancelled" ); @@ -1711,7 +1568,7 @@ async fn test_resume_subagent_background_mode_cancelled() { /// 原值(不被 active 卡死)+ 提供 task_manager 后可再次恢复 #[tokio::test] async fn test_resume_subagent_bg_registration_failure_rolls_back() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; @@ -1744,7 +1601,7 @@ async fn test_resume_subagent_bg_registration_failure_rolls_back() { "注册失败必须回滚 status 至原值" ); { - let statuses = store.statuses.read(); + let statuses = store.statuses(); let seq: Vec<&str> = statuses.iter().map(|(_, s)| s.as_str()).collect(); assert_eq!(seq, vec!["done", "active", "done"], "active 后回滚原值"); } @@ -1766,7 +1623,7 @@ async fn test_resume_subagent_bg_registration_failure_rolls_back() { assert!(spawned.task_id.is_some(), "bg 模式必须有 task_id"); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { - if store.statuses.read().last().map(|(_, s)| s.as_str()) == Some("done") { + if store.statuses().last().map(|(_, s)| s.as_str()) == Some("done") { break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -1781,7 +1638,7 @@ async fn test_resume_subagent_bg_registration_failure_rolls_back() { /// 丢弃/改写。 #[tokio::test] async fn test_resume_subagent_bg_beyond_previous_agent_cap() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; store @@ -1829,7 +1686,7 @@ async fn test_resume_subagent_bg_beyond_previous_agent_cap() { // 任务完成:status done;registry 收敛回 5 个占位任务(无泄漏/无丢弃) tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { - if store.statuses.read().last().map(|(_, s)| s.as_str()) == Some("done") { + if store.statuses().last().map(|(_, s)| s.as_str()) == Some("done") { break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -1864,7 +1721,7 @@ async fn test_resume_subagent_bg_scope_closed_rejected_before_claim() { use peri_acp_types::tasks::TaskManager as _; use peri_acp_types::tasks::TaskShutdownReport; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; @@ -1899,7 +1756,7 @@ async fn test_resume_subagent_bg_scope_closed_rejected_before_claim() { "执行前被拒不得改写 thread 状态" ); { - let statuses = store.statuses.read(); + let statuses = store.statuses(); let seq: Vec<&str> = statuses.iter().map(|(_, s)| s.as_str()).collect(); assert_eq!(seq, vec!["done"], "claim 之前失败不写 active"); } @@ -1968,7 +1825,7 @@ async fn cancel_resume_at_gate( config: SubagentResumeConfig, cancel: &CancellationToken, entered_rx: tokio::sync::oneshot::Receiver<()>, - store: &MockThreadStore, + store: &MockSessionResources, thread_id: &ThreadId, ) { let mut dispatch = Box::pin(dispatch_resume_fixture(config, cancel)); @@ -1994,7 +1851,7 @@ async fn cancel_resume_at_gate( } async fn wait_for_resume_status( - store: &MockThreadStore, + store: &MockSessionResources, thread_id: &ThreadId, status: AgentStatus, ) { @@ -2014,7 +1871,7 @@ async fn wait_for_resume_status( #[tokio::test] async fn test_resume_load_cancelled_by_dispatch_restores_previous_status() { use std::sync::atomic::{AtomicBool, Ordering}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); @@ -2056,7 +1913,7 @@ async fn test_resume_load_cancelled_by_dispatch_restores_previous_status() { #[tokio::test] async fn test_resume_active_write_cancelled_by_dispatch_finishes_before_rollback() { use std::sync::atomic::{AtomicBool, Ordering}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); @@ -2096,8 +1953,7 @@ async fn test_resume_active_write_cancelled_by_dispatch_finishes_before_rollback "cancelled preparation must never execute after the write resumes" ); let statuses: Vec<_> = store - .statuses - .read() + .statuses() .iter() .map(|(_, status)| status.clone()) .collect(); @@ -2129,7 +1985,7 @@ async fn test_resume_cancelled_during_assembly_never_starts_execution() { MiddlewareChain::new() } } - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let cancel = CancellationToken::new(); @@ -2213,7 +2069,7 @@ async fn test_resume_running_cancelled_by_dispatch_finalizes_claim() { crate::agent::compact_v2::projection::ProviderCapabilities::default() } } - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); @@ -2264,7 +2120,7 @@ async fn test_resume_running_cancelled_by_dispatch_finalizes_claim() { #[tokio::test] async fn test_resume_precancelled_background_still_registers_and_completes() { - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let task_manager = Arc::new(TaskManager::new()); @@ -2325,7 +2181,7 @@ async fn test_resume_precancelled_background_still_registers_and_completes() { async fn test_resume_provenance_read_cancelled_by_dispatch_restores_previous_status() { for inherited_read in [true, false] { use std::sync::atomic::{AtomicBool, Ordering}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); @@ -2382,7 +2238,7 @@ async fn test_resume_provenance_overlap_rolls_back_claim_before_retry() { use peri_acp_types::store::{InheritedContext, PersistedPayload}; use std::sync::atomic::{AtomicUsize, Ordering}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let thread_id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread(&store, &thread_id, None).await; let message = BaseMessage::human("same id must not belong to both regions"); @@ -2418,15 +2274,14 @@ async fn test_resume_provenance_overlap_rolls_back_claim_before_retry() { ); assert_eq!( store - .statuses - .read() + .statuses() .iter() .map(|(_, status)| status.as_str()) .collect::>(), ["done", "active", "done"] ); // Repair the corrupt fixture and exercise the same thread's real resume. - store.inherited.write().remove(&thread_id); + store.clear_inherited(); let resumed = SessionFactory::resume_subagent(None, resume_config(store.clone(), thread_id.clone())) .await @@ -2436,44 +2291,38 @@ async fn test_resume_provenance_overlap_rolls_back_claim_before_retry() { #[tokio::test] async fn test_bound_subagent_resume_requires_same_root_but_allows_siblings() { - let fixture = tempfile::tempdir().unwrap(); - let store = Arc::new( - peri_resources::sessions::SqliteThreadStore::new(fixture.path().join("sessions.db")) + // 真门面:绑定、父子链、执行所有权都由真实实现提供(不可用 mock 自证)。 + let repo = crate::session::test_resources::git_repository(); + let db = tempfile::tempdir().unwrap(); + let store: Arc = Arc::new( + peri_resources::sessions::SessionResourcesImpl::open(db.path().join("sessions.db")) .await .unwrap(), ); - let workspace = store.resolve_workspace(fixture.path()).await.unwrap(); + let workspace = store.resolve_workspace(repo.path()).await.unwrap(); let cwd = workspace.cwd.to_str().unwrap(); - let root_a = store - .create_bound_thread(ThreadMeta::new(cwd), &workspace) - .await - .unwrap(); - let root_b = store - .create_bound_thread(ThreadMeta::new(cwd), &workspace) - .await - .unwrap(); - let owner_a = store.acquire_execution_lease(&root_a).await.unwrap(); - let owner_b = store.acquire_execution_lease(&root_b).await.unwrap(); - let mut child = ThreadMeta::new(cwd); - child.parent_thread_id = Some(root_a.clone()); - child.agent_status = AgentStatus::Done; - let child_id = store - .create_bound_thread(child.clone(), &workspace) - .await - .unwrap(); - child.id = uuid::Uuid::now_v7().to_string(); - let sibling_id = store.create_bound_thread(child, &workspace).await.unwrap(); + let root_a = workspace.cwd.to_string_lossy().into_owned(); + let (root_a_id, owner_a) = create_bound_root(&store, &workspace, None).await; + let (root_b_id, owner_b) = create_bound_root(&store, &workspace, None).await; + // child 的 frozen 必须是 root 已保存快照的逐字节副本(门面在 save_child 内校验)。 + let frozen = FrozenSnapshotBytes::new("{\"version\":1,\"root\":true}"); + let child_id = save_bound_child(&store, &workspace, &root_a_id, &frozen, &owner_a).await; + let sibling_id = save_bound_child(&store, &workspace, &root_a_id, &frozen, &owner_a).await; let caller_b = Session::new( Arc::from(cwd), FrozenContext::builder().build(), - Some(root_b), + Some(root_b_id), ); - let mut config = resume_config(Arc::new(MockThreadStore::new()), child_id.clone()); - config.thread_store = store.clone(); + let mut config = resume_config(MockSessionResources::new(), child_id.clone()); + config.session_resources = Arc::clone(&store); let error = resume_err(Some(&caller_b), config).await; assert!(error.contains("another root session"), "{error}"); assert_eq!( - store.load_meta(&child_id).await.unwrap().agent_status, + store + .load_session_meta(&child_id) + .await + .unwrap() + .agent_status, AgentStatus::Done ); let sibling = Session::new( @@ -2481,17 +2330,111 @@ async fn test_bound_subagent_resume_requires_same_root_but_allows_siblings() { FrozenContext::builder().build(), Some(sibling_id), ); - let mut config = resume_config(Arc::new(MockThreadStore::new()), child_id.clone()); - config.thread_store = store.clone(); + let mut config = resume_config(MockSessionResources::new(), child_id.clone()); + config.session_resources = Arc::clone(&store); SessionFactory::resume_subagent(Some(&sibling), config) .await .expect("same-root siblings can resume"); assert_eq!( - store.load_meta(&child_id).await.unwrap().agent_status, + store + .load_session_meta(&child_id) + .await + .unwrap() + .agent_status, AgentStatus::Done ); - owner_a.mark_clean().await.unwrap(); - owner_b.mark_clean().await.unwrap(); + // 同根兄弟会话位于同一 root 执行代际(本机只有一条 root owner 事实)。 + let _ = root_a; + owner_a + .as_ref() + .expect("root A 仍持有执行权") + .mark_clean() + .await + .unwrap(); + owner_b + .as_ref() + .expect("root B 仍持有执行权") + .mark_clean() + .await + .unwrap(); +} + +/// 真门面:创建一条已绑定的根会话(返回身份与执行所有权)。 +async fn create_bound_root( + store: &Arc, + workspace: &peri_acp_types::workspace::ResolvedWorkspace, + frozen: Option, +) -> ( + ThreadId, + Option>, +) { + let session = bound_session(store, workspace, frozen, None); + let thread_id = session.thread_id.clone(); + let lease = store.create_session(&session).await.unwrap(); + (thread_id, Some(lease)) +} + +/// 真门面:在 root 的执行所有权下保存一条 child 会话(继承区为空)。 +async fn save_bound_child( + store: &Arc, + workspace: &peri_acp_types::workspace::ResolvedWorkspace, + root: &ThreadId, + frozen: &FrozenSnapshotBytes, + lease: &Option>, +) -> ThreadId { + let target = bound_session(store, workspace, Some(frozen.clone()), Some(root.clone())); + let child_id = target.thread_id.clone(); + store + .save_child( + &ChildSnapshot { + target, + parent_id: root.clone(), + root_id: root.clone(), + inherited: Default::default(), + }, + lease.as_ref().expect("root 执行所有权"), + ) + .await + .unwrap(); + store + .update_session_meta( + &child_id, + &SessionMetaPatch { + status: Some(AgentStatus::Done), + ..Default::default() + }, + ) + .await + .unwrap(); + child_id +} + +fn bound_session( + _store: &Arc, + workspace: &peri_acp_types::workspace::ResolvedWorkspace, + frozen: Option, + parent: Option, +) -> NewSession { + NewSession { + thread_id: uuid::Uuid::now_v7().to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: Some("bound fixture".to_owned()), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: parent, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + }, + frozen: frozen.unwrap_or_else(|| FrozenSnapshotBytes::new("{\"version\":1,\"root\":true}")), + } } #[derive(Clone, Copy)] @@ -2542,7 +2485,13 @@ impl ReactLLM for TailChunkLLM { } } -fn tail_spawn_config(store: Arc, outcome: TailOutcome) -> SubagentSpawnConfig { +fn tail_spawn_config( + store: Arc, + outcome: TailOutcome, +) -> SubagentSpawnConfig { + // child 落库要求父会话已绑定(`save_child` 继承绑定与 frozen),并需要本会话 root 的 + // 执行所有权;夹具显式构造这两项前置条件,不靠替身默认值。 + let lease = store.register_bound_session("tail-parent", "/tmp/tail-fixture"); SubagentSpawnConfig { agent_name: "tail-agent".into(), prompt: "task".into(), @@ -2563,7 +2512,8 @@ fn tail_spawn_config(store: Arc, outcome: TailOutcome) -> Subag compact_config: None, context_budget: None, compact_llm: None, - thread_store: Some(store), + session_resources: Some(store), + execution_owner: Some(lease), event_handler: None, bg_event_sender: None, task_manager: None, @@ -2576,7 +2526,7 @@ fn tail_spawn_config(store: Arc, outcome: TailOutcome) -> Subag parent_agent_id: Some(AgentId::new()), cancel_token: None, cwd: Some("/tmp/tail-fixture".into()), - parent_thread_id: None, + parent_thread_id: Some("tail-parent".into()), frozen_claude_md: None, frozen_claude_local_md: None, frozen_skill_summary: None, @@ -2607,7 +2557,7 @@ impl crate::agent::LangfuseBridgeLike for TailPanicBridge { async fn assert_background_tail_completion(outcome: TailOutcome, panic_forwarder: bool) { use crate::agent::events::{BackgroundTaskResult, ExecutorEvent}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let manager = Arc::new(TaskManager::new()); let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); let event_rx = Arc::new(parking_lot::Mutex::new(event_rx)); @@ -2705,11 +2655,7 @@ async fn assert_background_tail_completion(outcome: TailOutcome, panic_forwarder _ => "error", }; assert_eq!( - store - .statuses - .read() - .last() - .map(|(_, status)| status.as_str()), + store.statuses().last().map(|(_, status)| status.as_str()), Some(expected_status) ); if matches!(outcome, TailOutcome::ModelError) { @@ -2761,7 +2707,7 @@ async fn test_spawn_subagent_background_forwarder_panic_preserves_model_failure( #[tokio::test(flavor = "current_thread")] async fn test_spawn_subagent_sync_forwarder_panic_is_failure() { use crate::agent::events::{ExecutorEvent, FnEventHandler}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let events = Arc::new(parking_lot::Mutex::new(Vec::new())); let capture = events.clone(); let bridge_stops = Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -2790,11 +2736,7 @@ async fn test_spawn_subagent_sync_forwarder_panic_is_failure() { 1 ); assert_eq!( - store - .statuses - .read() - .last() - .map(|(_, status)| status.as_str()), + store.statuses().last().map(|(_, status)| status.as_str()), Some("error") ); } @@ -2816,7 +2758,7 @@ impl crate::agent::LangfuseBridgeLike for TerminalPanicBridge { #[tokio::test(flavor = "current_thread")] async fn test_spawn_subagent_terminal_bridge_panic_is_failure() { use crate::agent::events::{ExecutorEvent, FnEventHandler}; - let store = Arc::new(MockThreadStore::new()); + let store = MockSessionResources::new(); let events = Arc::new(parking_lot::Mutex::new(Vec::new())); let capture = events.clone(); let mut config = tail_spawn_config(store.clone(), TailOutcome::Completed); @@ -2835,11 +2777,7 @@ async fn test_spawn_subagent_terminal_bridge_panic_is_failure() { Some(ExecutorEvent::SubagentStopped { is_error: true, .. }) )); assert_eq!( - store - .statuses - .read() - .last() - .map(|(_, status)| status.as_str()), + store.statuses().last().map(|(_, status)| status.as_str()), Some("error") ); } diff --git a/peri-agent/src/session/test_resources.rs b/peri-agent/src/session/test_resources.rs new file mode 100644 index 000000000..c791ff9c2 --- /dev/null +++ b/peri-agent/src/session/test_resources.rs @@ -0,0 +1,112 @@ +//! 会话资源测试夹具:真实门面 + 临时 git 工作区 + 活跃执行所有权。 +//! +//! 不造假的存储替身:写入门禁要求「本 root 有活 owner」,所以夹具真的建立一条会话并 +//! 持有它的 lease——这正是生产路径的前置条件(只读、无主的会话在门面上本来就写不了)。 +//! 需要构造真实写入失败时,调用 [`TestSession::release_lease`]:owner 消失后写入按 +//! `LeaseRequired` 失败,而不是靠 mock 假装失败。 + +#[path = "test_resources/mock/mod.rs"] +pub(crate) mod mock; + +use std::sync::Arc; + +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResources, +}; +use peri_acp_types::thread::{CancelPolicy, ThreadId}; +use peri_acp_types::workspace::{SessionBinding, SessionExecutionLease, SESSION_BINDING_VERSION}; +use peri_resources::sessions::SessionResourcesImpl; + +/// 一条已创建、已取得执行所有权的会话(临时库 + 临时 git 工作区)。 +pub(crate) struct TestSession { + pub(crate) resources: Arc, + pub(crate) thread_id: ThreadId, + lease: Option>, + _db: tempfile::TempDir, + _repo: tempfile::TempDir, +} + +impl TestSession { + pub(crate) async fn open() -> Self { + let repo = git_repository(); + let db = tempfile::tempdir().unwrap(); + let resources: Arc = Arc::new( + SessionResourcesImpl::open(db.path().join("threads.db")) + .await + .unwrap(), + ); + let workspace = resources.resolve_workspace(repo.path()).await.unwrap(); + let thread_id = uuid::Uuid::now_v7().to_string(); + let session = NewSession { + thread_id: thread_id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: Some("test session".to_owned()), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + }, + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"test\":true}"), + }; + let lease = resources.create_session(&session).await.unwrap(); + Self { + resources, + thread_id, + lease: Some(lease), + _db: db, + _repo: repo, + } + } + + /// 同一条会话的另一个门面句柄(用于验证「写入真的落库」而不是只改了内存)。 + pub(crate) fn resources(&self) -> Arc { + Arc::clone(&self.resources) + } + + /// 丢弃执行所有权:此后本会话的写入按 `LeaseRequired` 真实失败。 + pub(crate) fn release_lease(&mut self) { + self.lease = None; + } +} + +/// 临时 git 仓库(工作区发现需要真实仓库证据)。 +pub(crate) fn git_repository() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ] { + let output = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", directory.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(directory.path()) + .args(&args) + .output() + .unwrap(); + assert!(output.status.success(), "git fixture failed"); + } + directory +} diff --git a/peri-agent/src/session/test_resources/mock/fixtures.rs b/peri-agent/src/session/test_resources/mock/fixtures.rs new file mode 100644 index 000000000..44b2f9908 --- /dev/null +++ b/peri-agent/src/session/test_resources/mock/fixtures.rs @@ -0,0 +1,117 @@ +//! 夹具便利方法:镜像迁移前 `ThreadStore` 的常用测试调用形态,让既有测试的构造与断言逐字保留; +//! 生产代码不再有这些方法,只有测试替身提供。 + +use super::*; + +/// 便利方法:镜像迁移前 `ThreadStore` 的常用测试调用形态,让既有测试的构造与断言 +/// 逐字保留(生产代码不再有这些方法,只有测试替身提供)。 +/// +/// **读侧分工**:trait 方法(`load_session_meta` / `load_session_snapshot`)按真实门面 +/// 语义对未登记 id 回答 `NotFound`;这里的方法只服务夹具——读取未登记 id 时按「空区」 +/// 回答,写入时按「写入即登记」补齐记录。需要「不存在」语义的用例请用 trait 方法。 +impl MockSessionResources { + pub(crate) async fn create_thread(&self, meta: ThreadMeta) -> Result { + let id = meta.id.clone(); + if let Some(parent) = meta.parent_thread_id.clone() { + // 生产不变量:父行先于子行存在(子会话的 root 解析沿 parent 链读取 meta)。 + // 替身按同一顺序补齐父链占位行,避免夹具出现断裂父链。 + self.ensure_parent_placeholder(&parent, &meta.cwd); + } + self.with_region(&id, |region| region.meta = Some(meta)); + Ok(id) + } + + /// 补齐父链占位行(未登记时才写,已有记录不覆盖)。 + fn ensure_parent_placeholder(&self, parent: &str, cwd: &str) { + let parent = parent.to_owned(); + if self.regions.lock().unwrap().contains_key(&parent) { + return; + } + let mut meta = ThreadMeta::new(cwd); + meta.id = parent.clone(); + self.with_region(&parent, |region| region.meta = Some(meta)); + } + + pub(crate) async fn append_messages( + &self, + id: &ThreadId, + messages: &[BaseMessage], + ) -> Result<(), anyhow::Error> { + let payloads: Vec = messages + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect(); + self.append_history(id, &payloads) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } + + pub(crate) async fn append_message( + &self, + id: &ThreadId, + message: BaseMessage, + ) -> Result<(), anyhow::Error> { + self.append_history(id, &[PersistedPayload::Message(message)]) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } + + pub(crate) async fn load_messages( + &self, + id: &ThreadId, + ) -> Result, anyhow::Error> { + Ok(self + .payloads_of(id) + .iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect()) + } + + pub(crate) async fn load_message_flags( + &self, + id: &ThreadId, + ) -> Result, anyhow::Error> { + Ok(self.flags_of(id)) + } + + pub(crate) async fn load_payloads( + &self, + id: &ThreadId, + ) -> Result, anyhow::Error> { + Ok(self.payloads_of(id)) + } + + /// 夹具读取 meta:与 trait 的 `load_session_meta` 同语义(未登记 id 报错)。 + pub(crate) async fn load_meta(&self, id: &ThreadId) -> Result { + match self.region(id).and_then(|region| region.meta) { + Some(meta) => Ok(meta), + None => anyhow::bail!("thread {id} not found"), + } + } + + pub(crate) async fn update_thread_status( + &self, + id: &ThreadId, + status: &str, + ) -> Result<(), anyhow::Error> { + // 未知状态值直接报错,不静默 fallback 成 active(与真实 store 的强类型语义一致: + // 状态只有 Done/Cancelled/Error/Active 四种,拼错必须暴露)。 + let status = match status { + "done" => peri_acp_types::thread::AgentStatus::Done, + "cancelled" => peri_acp_types::thread::AgentStatus::Cancelled, + "error" => peri_acp_types::thread::AgentStatus::Error, + "active" => peri_acp_types::thread::AgentStatus::Active, + other => anyhow::bail!("非法 agent_status: {other}"), + }; + self.update_session_meta( + id, + &SessionMetaPatch { + status: Some(status), + ..Default::default() + }, + ) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } +} diff --git a/peri-agent/src/session/test_resources/mock/mod.rs b/peri-agent/src/session/test_resources/mock/mod.rs new file mode 100644 index 000000000..f5c76eab8 --- /dev/null +++ b/peri-agent/src/session/test_resources/mock/mod.rs @@ -0,0 +1,431 @@ +//! `#[cfg(test)]` 内存门面替身:只实现测试真正观察的行为,其余行为直接 panic。 +//! +//! 用它而不是造假:`unimplemented` 的条目一旦被某个测试用到就会立刻失败,不会把 +//! 「没覆盖」伪装成「通过」。需要真实 owner/绑定/事务语义的测试请用 +//! [`TestSession`](super::test_resources::TestSession)(真实 `SessionResourcesImpl`)。 + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use peri_acp_types::messages::{BaseMessage, MessageId}; +use peri_acp_types::session_resources::{ + AccessMode, BindingRecheck, BindingState, ChildResumeClaim, ChildSnapshot, DataCapabilities, + ExecutionAvailability, ForkSnapshot, FrozenSnapshotBytes, FrozenState, NewSession, + NewSessionMeta, PersistenceRecovery, RewindBoundary, SessionAvailability, SessionMetaPatch, + SessionResourceError, SessionResourceErrorKind, SessionResourceResult, SessionResources, + SessionSnapshot, +}; +use peri_acp_types::store::{CompactionChange, InheritedContext, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{AgentStatus, ThreadId, ThreadMeta}; +use peri_acp_types::workspace::{ + ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, SessionBinding, WorkspaceError, + SESSION_BINDING_VERSION, +}; + +/// 阶段门:在 store 行为内部暂停,供「取消/丢弃发生在调用中途」的用例观察时序。 +/// +/// `entered` 在进入临界点后立刻发信号;`release` 由用例放行;等待期间 future 被丢弃时 +/// `dropped` 置位——用例据此区分「调用方未来被取消」与「调用已结清」。 +pub(crate) struct ResumeLoadGate { + pub(crate) entered: tokio::sync::oneshot::Sender<()>, + pub(crate) release: tokio::sync::oneshot::Receiver<()>, + pub(crate) dropped: Arc, +} + +impl ResumeLoadGate { + pub(crate) async fn wait(self) { + struct DropProbe(Arc); + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let _probe = DropProbe(self.dropped); + let _ = self.entered.send(()); + let _ = self.release.await; + } +} + +/// 故障注入与观察计数(只针对被测试的行为)。 +#[derive(Default)] +struct Injection { + fail_compaction: bool, + append_calls: usize, + compaction_calls: usize, + rewind_calls: usize, +} + +/// 单条会话的独立数据区。 +/// +/// 替身也按会话隔离:不同 thread 的 payload/flags/inherited/frozen/binding 互不影响, +/// 「未登记 id」在 trait 方法上按 `NotFound` 回答(真实门面语义),在夹具便利方法上 +/// 按「空区」回答(见文件末尾 `便利方法` 一节的分工说明)。 +#[derive(Default)] +struct Region { + /// `None` 表示该 id 只有数据、没有 metadata 记录(夹具写入即登记时补齐)。 + meta: Option, + payloads: Vec, + flags: HashMap, + inherited: InheritedContext, + /// `None` = `FrozenState::LegacyAbsent`(legacy 会话没有快照)。 + frozen: Option, + /// `None` = 无绑定(`BindingState::Missing`,legacy / 外来登记)。 + binding: Option, +} + +/// 夹具用默认绑定:`create_bound_thread` / `register_bound_session` 之外的 id 一律无绑定。 +#[cfg_attr(not(test), allow(dead_code))] +fn fixture_binding(cwd: &str) -> SessionBinding { + SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: peri_acp_types::workspace::ProjectId::new(), + workspace_id: peri_acp_types::workspace::WorkspaceId::new(), + cwd_relative_to_workspace: std::path::PathBuf::from(cwd), + } +} + +/// 夹具用默认 frozen 快照字节(版本化 envelope 由 ACP 拥有,替身只搬运字节)。 +fn fixture_frozen() -> String { + "{\"version\":1,\"fixture\":true}".to_owned() +} + +/// 只有数据、没有 meta 记录的 id 的默认 meta(写入即登记的替身里不会出现「无 meta」)。 +fn default_meta_for(id: &ThreadId) -> ThreadMeta { + let mut meta = ThreadMeta::new("/test"); + meta.id = id.clone(); + meta +} + +/// 由 `NewSessionMeta` 构造替身登记的 `ThreadMeta`(隐藏标记保留调用方意图)。 +fn child_meta(id: &ThreadId, meta: &NewSessionMeta, hidden: bool) -> ThreadMeta { + let mut thread = ThreadMeta::new(&meta.cwd); + thread.id = id.clone(); + thread.title = meta.title.clone(); + thread.parent_thread_id = meta.parent_thread_id.clone(); + thread.snapshot_at_message_id = meta + .snapshot_at_message_id + .map(|id| id.as_uuid().to_string()); + thread.hidden = meta.hidden && hidden; + thread.cancel_policy = meta.cancel_policy; + thread +} + +pub(crate) struct MockSessionResources { + /// 每个会话 id 的独立数据区:测试替身也按会话隔离(不同 thread 不串扰)。 + /// + /// `Arc` 共享给认领 handle:`mark_running` 等写入在 trait 方法返回后仍要落回同一份事实。 + regions: Arc>>, + /// 登记顺序(`threads()` 断言用;HashMap 无序)。 + order: Mutex>, + injection: Mutex, + /// 每条 payload 是否允许写入:`false` 时 append 真实失败(模拟后端拒绝)。 + writable: bool, + /// 状态**变更**序列(同值重复写不记录):断言「是否残留 active」用。 + /// + /// 只记录变更与真实 `threads.agent_status` 语义一致——恢复成认领前的值本身是 + /// 一次真实变更,重复写同一个值是 no-op。 + statuses: Arc>>, + pub(crate) status_changed: Arc, + pub(crate) load_gate: Mutex>, + pub(crate) inherited_load_gate: Mutex>, + pub(crate) flags_load_gate: Mutex>, + pub(crate) active_write_gate: Mutex>, + /// 认领事实:`claim_child_resume` 成功返回后置位,结清时复位。 + /// + /// 阶段门([`ResumeLoadGate`])只在**认领后**的快照读取生效:resume 的第一次读取是 + /// 认领前的绑定分类,认领后才是 history 装载——用例的门语义按后者定义。 + claimed: Arc, + /// 快照读取故障注入:在第 N 次 `load_session_snapshot`(1-based)返回 Err,0 = 关闭。 + /// + /// resume 路径有两次快照读取(认领前的绑定分类、认领后的 history 装载),用例据此 + /// 选择「失败发生在哪一次」——置 1 表示绑定读取即失败,置 2 表示认领后装载失败。 + pub(crate) fail_snapshot_at: AtomicUsize, + /// 已发生的快照读取次数(配合 `fail_snapshot_at` 定位第 N 次)。 + snapshot_reads: AtomicUsize, + /// 只读能力面:置位后 `inspect_availability` 报告 [`DataCapabilities::HistoryReadOnly`], + /// 且所有 mutation 在副作用前返回 `Unsupported`(契约要求,不是「静默 no-op」)。 + history_read_only: AtomicBool, +} + +/// 替身执行所有权:identity + `mark_clean` 置位的 clean 标记,不做 OS 预留。 +pub(crate) struct MockExecutionLease { + thread_id: ThreadId, + clean: AtomicBool, +} + +impl MockExecutionLease { + fn new(thread_id: impl Into) -> Self { + Self { + thread_id: thread_id.into(), + clean: AtomicBool::new(false), + } + } +} + +#[async_trait] +impl peri_acp_types::workspace::SessionExecutionLease for MockExecutionLease { + fn thread_id(&self) -> &ThreadId { + &self.thread_id + } + + async fn mark_clean(&self) -> anyhow::Result<()> { + self.clean.store(true, Ordering::SeqCst); + Ok(()) + } +} + +impl MockSessionResources { + pub(crate) fn new() -> Arc { + Arc::new(Self { + regions: Arc::new(Mutex::new(HashMap::new())), + order: Mutex::new(Vec::new()), + injection: Mutex::new(Injection::default()), + writable: true, + statuses: Arc::new(Mutex::new(Vec::new())), + status_changed: Arc::new(tokio::sync::Notify::new()), + load_gate: Mutex::new(None), + inherited_load_gate: Mutex::new(None), + flags_load_gate: Mutex::new(None), + active_write_gate: Mutex::new(None), + claimed: Arc::new(AtomicBool::new(false)), + fail_snapshot_at: AtomicUsize::new(0), + snapshot_reads: AtomicUsize::new(0), + history_read_only: AtomicBool::new(false), + }) + } + + /// 当前 meta 状态序列(`(thread_id, status)`),只含真实发生变更的写入。 + pub(crate) fn statuses(&self) -> Vec<(ThreadId, String)> { + self.statuses + .lock() + .unwrap() + .iter() + .map(|(id, status)| (id.clone(), status_name(*status).to_owned())) + .collect() + } + + /// 已登记 thread 快照(父子链断言用),按登记顺序。 + pub(crate) fn threads(&self) -> Vec { + let regions = self.regions.lock().unwrap(); + self.order + .lock() + .unwrap() + .iter() + .filter_map(|id| regions.get(id).and_then(|region| region.meta.clone())) + .collect() + } + + /// 清空全部会话的继承区(用例修复损坏夹具用)。 + pub(crate) fn clear_inherited(&self) { + for region in self.regions.lock().unwrap().values_mut() { + region.inherited = InheritedContext::default(); + } + } + + /// 写继承区(镜像迁移前 `store_inherited_context` 的测试用法)。 + pub(crate) async fn store_inherited_context( + &self, + id: &ThreadId, + context: &InheritedContext, + ) -> Result<(), anyhow::Error> { + self.with_region(id, |region| region.inherited = context.clone()); + Ok(()) + } + + /// 写侧数据区入口(写入即登记:替身不区分「先建 thread 再写」与直接写)。 + fn with_region(&self, id: &ThreadId, work: impl FnOnce(&mut Region) -> R) -> R { + let mut regions = self.regions.lock().unwrap(); + let region = regions.entry(id.clone()).or_default(); + let out = work(region); + drop(regions); + let mut order = self.order.lock().unwrap(); + if !order.iter().any(|known| known == id) { + order.push(id.clone()); + } + out + } + + /// 读侧数据区快照;未登记 id 返回 `None`(trait 读取据此回答 `NotFound`)。 + fn region(&self, id: &ThreadId) -> Option { + self.regions + .lock() + .unwrap() + .get(id) + .map(|region| RegionRead { + meta: region.meta.clone(), + payloads: region.payloads.clone(), + flags: region.flags.clone(), + inherited: region.inherited.clone(), + frozen: region.frozen.clone(), + binding: region.binding.clone(), + }) + } + + /// 夹具:登记一条**已绑定**会话(有 workspace 绑定与 frozen 快照),返回其执行所有权。 + /// + /// 生产前置条件是「父会话有绑定与已持久化 frozen」,而 `create_thread` 登记的是无绑定 + /// 会话(legacy 语义);需要 bound 语义的用例显式调用本方法,不靠替身默认值。 + pub(crate) fn register_bound_session( + &self, + id: &str, + cwd: &str, + ) -> Arc { + let mut meta = ThreadMeta::new(cwd); + meta.id = id.to_owned(); + self.with_region(&id.to_owned(), |region| { + region.binding = Some(fixture_binding(cwd)); + region.frozen = Some(fixture_frozen()); + region.meta = Some(meta); + }); + Arc::new(MockExecutionLease::new(id.to_owned())) + } + + /// 替身发出的执行所有权句柄(`save_child` 等需要 owner 的用例注入用)。 + pub(crate) fn lease( + &self, + id: &str, + ) -> Arc { + Arc::new(MockExecutionLease::new(id.to_owned())) + } + + /// 状态写入入口:只在值真的变化时记录并唤醒等待者。 + fn write_status(&self, id: &ThreadId, status: AgentStatus) { + write_status_shared( + &self.regions, + &self.statuses, + &self.status_changed, + id, + status, + ); + } + + /// 切换为只读能力面(测试后端能力差异用)。 + pub(crate) fn restrict_to_history_read_only(&self) { + self.history_read_only.store(true, Ordering::SeqCst); + } + + /// mutation 的能力面准入:只读能力面下必须在副作用前失败。 + fn ensure_writable(&self) -> SessionResourceResult<()> { + if self.history_read_only.load(Ordering::SeqCst) { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Unsupported, + )); + } + Ok(()) + } + + /// 取走并等待一个阶段门(未设置时立即返回)。 + async fn wait_gate(gate: &Mutex>) { + let gate = gate.lock().unwrap().take(); + if let Some(gate) = gate { + gate.wait().await; + } + } +} + +/// 只读的数据区快照(读路径不需要持有锁,替身数据量小)。 +struct RegionRead { + meta: Option, + payloads: Vec, + flags: HashMap, + inherited: InheritedContext, + frozen: Option, + binding: Option, +} + +/// 共享的状态写入:只在值变化时记录 + 唤醒(认领 handle 与门面用同一份事实)。 +fn write_status_shared( + regions: &Mutex>, + statuses: &Mutex>, + status_changed: &tokio::sync::Notify, + id: &ThreadId, + status: AgentStatus, +) { + { + let mut regions = regions.lock().unwrap(); + let region = regions.entry(id.clone()).or_default(); + if region + .meta + .as_ref() + .is_some_and(|meta| meta.agent_status == status) + { + return; + } + let mut meta = region + .meta + .clone() + .unwrap_or_else(|| ThreadMeta::new("/test")); + meta.id = id.clone(); + meta.agent_status = status; + region.meta = Some(meta); + } + statuses.lock().unwrap().push((id.clone(), status)); + status_changed.notify_waiters(); +} + +fn status_name(status: AgentStatus) -> &'static str { + status.as_str() +} + +/// `claim_child_resume` 返回的认领:语义与资源实现一致——结清时恢复到认领前的状态。 +struct MockResumeClaim { + claimed: Arc, + regions: Arc>>, + statuses: Arc>>, + status_changed: Arc, + child: ThreadId, + previous: AgentStatus, +} + +#[async_trait] +impl ChildResumeClaim for MockResumeClaim { + async fn mark_running(&self) -> SessionResourceResult<()> { + write_status_shared( + &self.regions, + &self.statuses, + &self.status_changed, + &self.child, + AgentStatus::Active, + ); + Ok(()) + } + + async fn hand_off_to_background(&self) -> SessionResourceResult<()> { + self.mark_running().await + } + + async fn mark_failed(&self) -> SessionResourceResult<()> { + self.settle(); + Ok(()) + } + + async fn mark_terminated(&self) -> SessionResourceResult<()> { + self.settle(); + Ok(()) + } +} + +impl MockResumeClaim { + /// 恢复到认领前的状态:终态写入由调用方在结清之后单独提交。 + fn settle(&self) { + self.claimed.store(false, Ordering::SeqCst); + write_status_shared( + &self.regions, + &self.statuses, + &self.status_changed, + &self.child, + self.previous, + ); + } +} + +// ── 子模块(按职责拆分;内部细节见各自文件头)──────────────────────────── +/// 夹具便利方法:镜像迁移前 `ThreadStore` 的常用测试调用形态。 +mod fixtures; +/// 故障注入与观察入口(只覆盖被测试的行为)。 +mod observe; +/// `SessionResources` 门面替身:逐个方法实现契约语义。 +mod session_resources; diff --git a/peri-agent/src/session/test_resources/mock/observe.rs b/peri-agent/src/session/test_resources/mock/observe.rs new file mode 100644 index 000000000..146fae4f2 --- /dev/null +++ b/peri-agent/src/session/test_resources/mock/observe.rs @@ -0,0 +1,57 @@ +//! 故障注入与观察:只针对被测试的行为,未覆盖的行为由门面替身按契约拒绝。 + +use super::*; + +impl MockSessionResources { + /// 在第 `nth` 次快照读取(1-based)注入失败;0 关闭注入。 + pub(crate) fn fail_snapshot_load_at(&self, nth: usize) { + self.fail_snapshot_at.store(nth, Ordering::SeqCst); + } + + pub(crate) fn fail_compaction(self: &Arc) { + self.injection.lock().unwrap().fail_compaction = true; + } + + /// 全部会话 payload 的合并视图(登记顺序),夹具断言用。 + pub(crate) fn payloads(&self) -> Vec { + let regions = self.regions.lock().unwrap(); + self.order + .lock() + .unwrap() + .iter() + .filter_map(|id| regions.get(id)) + .flat_map(|region| region.payloads.clone()) + .collect() + } + + /// 指定会话的 payload(未登记 id 按空区回答,仅夹具使用)。 + pub(crate) fn payloads_of(&self, id: &ThreadId) -> Vec { + self.region(id).map(|r| r.payloads).unwrap_or_default() + } + + pub(crate) fn flags(&self, id: &MessageId) -> MessageFlags { + self.regions + .lock() + .unwrap() + .values() + .find_map(|region| region.flags.get(id).cloned()) + .unwrap_or_default() + } + + /// 指定会话的 flags(未登记 id 按空区回答,仅夹具使用)。 + pub(crate) fn flags_of(&self, id: &ThreadId) -> HashMap { + self.region(id).map(|r| r.flags).unwrap_or_default() + } + + pub(crate) fn append_calls(&self) -> usize { + self.injection.lock().unwrap().append_calls + } + + pub(crate) fn compaction_calls(&self) -> usize { + self.injection.lock().unwrap().compaction_calls + } + + pub(crate) fn rewind_calls(&self) -> usize { + self.injection.lock().unwrap().rewind_calls + } +} diff --git a/peri-agent/src/session/test_resources/mock/session_resources.rs b/peri-agent/src/session/test_resources/mock/session_resources.rs new file mode 100644 index 000000000..12050e954 --- /dev/null +++ b/peri-agent/src/session/test_resources/mock/session_resources.rs @@ -0,0 +1,415 @@ +//! `SessionResources` 门面替身:实现契约语义,未覆盖的行为按 `Unsupported` 拒绝而不是静默 no-op。 + +use super::*; + +/// 替身的工作区投影:按保存路径造一个自洽的 `ResolvedWorkspace`。 +/// +/// 替身不建模发现与登记,只保证「路径即目录、目录即根」这一条自洽关系;真实门面 +/// 才做发现、登记与复核。 +fn doubled_workspace(cwd: &str) -> ResolvedWorkspace { + let cwd = std::path::PathBuf::from(cwd); + ResolvedWorkspace { + project_id: peri_acp_types::workspace::ProjectId::new(), + workspace_id: peri_acp_types::workspace::WorkspaceId::new(), + cwd: cwd.clone(), + root: cwd, + relative_cwd: std::path::PathBuf::new(), + } +} + +fn unsupported(behavior: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Internal { + detail: format!("{behavior} is not implemented by the in-memory test double"), + }) +} + +#[async_trait] +impl SessionResources for MockSessionResources { + async fn inspect_availability( + &self, + _session: Option<&ThreadId>, + ) -> SessionResourceResult { + Ok(SessionAvailability { + access: AccessMode::ReadWrite, + capabilities: if self.history_read_only.load(Ordering::SeqCst) { + DataCapabilities::HistoryReadOnly + } else { + DataCapabilities::Complete + }, + execution: Some(ExecutionAvailability::Available), + }) + } + + async fn resolve_workspace( + &self, + _cwd: &std::path::Path, + ) -> SessionResourceResult { + Err(unsupported("resolve_workspace")) + } + + async fn validate_session( + &self, + _id: &ThreadId, + _workspace: &ResolvedWorkspace, + ) -> SessionResourceResult<()> { + Ok(()) + } + + async fn acquire_execution( + &self, + _id: &ThreadId, + _workspace: &ResolvedWorkspace, + ) -> SessionResourceResult> { + Err(unsupported("acquire_execution")) + } + + async fn reset_dirty_execution( + &self, + _request: &peri_acp_types::workspace::ResetDirtyRequest, + ) -> SessionResourceResult<()> { + Err(unsupported("reset_dirty_execution")) + } + + async fn create_session( + &self, + _input: &NewSession, + ) -> SessionResourceResult> { + Err(unsupported("create_session")) + } + + async fn abandon_initialization( + &self, + _id: &ThreadId, + _lease: &Arc, + ) -> SessionResourceResult<()> { + Err(unsupported("abandon_initialization")) + } + + async fn adopt_legacy_session( + &self, + _id: &ThreadId, + _saved_cwd: &str, + _workspace: &ResolvedWorkspace, + _frozen: &peri_acp_types::session_resources::FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + Err(unsupported("adopt_legacy_session")) + } + + async fn load_session_snapshot(&self, id: &ThreadId) -> SessionResourceResult { + // 阶段门按「一次快照读取内部的读序」排布:先行读、继承区、flags。 + if self.claimed.load(Ordering::SeqCst) { + Self::wait_gate(&self.load_gate).await; + Self::wait_gate(&self.inherited_load_gate).await; + Self::wait_gate(&self.flags_load_gate).await; + } + let read = self.snapshot_reads.fetch_add(1, Ordering::SeqCst) + 1; + let fail_at = self.fail_snapshot_at.load(Ordering::SeqCst); + if fail_at != 0 && read == fail_at { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Internal { + detail: "failed to load messages".to_owned(), + }, + )); + } + let Some(region) = self.region(id) else { + return Err(SessionResourceError::new( + SessionResourceErrorKind::NotFound, + )); + }; + Ok(SessionSnapshot { + meta: region.meta.unwrap_or_else(|| default_meta_for(id)), + binding: match region.binding { + Some(binding) => BindingState::Bound(binding), + None => BindingState::Missing, + }, + frozen: match region.frozen { + Some(bytes) => FrozenState::Present(FrozenSnapshotBytes::new(bytes)), + None => FrozenState::LegacyAbsent, + }, + payloads: region.payloads, + flags: region.flags, + inherited: region.inherited, + }) + } + + async fn load_session_binding(&self, id: &ThreadId) -> SessionResourceResult { + // 替身不建模本机登记:已登记的会话按「有绑定」回答,未登记按真实门面语义报 + // `NotFound`(不冒充 legacy 或「没有绑定」)。 + match self.region(id).and_then(|region| region.meta) { + Some(meta) => Ok(BindingState::Bound(fixture_binding(&meta.cwd))), + None => Err(SessionResourceError::new( + SessionResourceErrorKind::NotFound, + )), + } + } + + async fn validate_bound_workspace( + &self, + id: &ThreadId, + _check: BindingRecheck, + ) -> SessionResourceResult { + match self.region(id).and_then(|region| region.meta) { + Some(meta) => Ok(doubled_workspace(&meta.cwd)), + None => Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::BindingMissing), + )), + } + } + + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + // 夹具历史不入库:已登记会话如实回答空历史,未登记报 `NotFound`。 + match self.region(id).and_then(|region| region.meta) { + Some(_) => Ok(Vec::new()), + None => Err(SessionResourceError::new( + SessionResourceErrorKind::NotFound, + )), + } + } + + async fn load_session_meta(&self, id: &ThreadId) -> SessionResourceResult { + match self.region(id).and_then(|region| region.meta) { + Some(meta) => Ok(meta), + None => Err(SessionResourceError::new( + SessionResourceErrorKind::NotFound, + )), + } + } + + async fn list_sessions( + &self, + _query: &ScopedThreadQuery, + ) -> SessionResourceResult { + Err(unsupported("list_sessions")) + } + + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult> { + Ok(self + .threads() + .into_iter() + .filter(|meta| meta.parent_thread_id.as_deref() == Some(parent.as_str())) + .collect()) + } + + async fn list_session_tree(&self, _root: &ThreadId) -> SessionResourceResult> { + Ok(Vec::new()) + } + + async fn append_history( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + if !self.writable { + return Err(SessionResourceError::new( + SessionResourceErrorKind::ReadOnlyStore, + )); + } + self.injection.lock().unwrap().append_calls += 1; + for payload in payloads { + self.with_region(id, |region| region.payloads.push(payload.clone())); + } + Ok(()) + } + + async fn save_fork( + &self, + fork: &ForkSnapshot, + ) -> SessionResourceResult> { + self.ensure_writable()?; + let target = &fork.target; + self.with_region(&target.thread_id, |region| { + region.meta = Some(child_meta(&target.thread_id, &target.meta, false)); + region.binding = Some(target.binding.clone()); + region.frozen = Some(target.frozen.as_str().to_owned()); + region.payloads = fork.payloads.clone(); + region.flags = fork.flags.clone(); + }); + Ok(self.lease(&target.thread_id)) + } + + async fn save_child( + &self, + child: &ChildSnapshot, + _lease: &Arc, + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + let target = &child.target; + // 与真实门面同构:child 的 frozen 逐字节取自 root 已保存快照、绑定继承父会话、 + // 继承区来自调用方;这里不做 SQL 层校验,但保留「一次写入成立」的形状。 + self.with_region(&target.thread_id, |region| { + region.meta = Some(child_meta(&target.thread_id, &target.meta, true)); + region.binding = Some(target.binding.clone()); + region.frozen = Some(target.frozen.as_str().to_owned()); + region.inherited = child.inherited.clone(); + }); + Ok(()) + } + + async fn claim_child_resume( + &self, + child: &ThreadId, + _root: &ThreadId, + ) -> SessionResourceResult> { + self.ensure_writable()?; + // 与资源实现同构:门禁内「读状态 + 写 active」,已有 active 时拒绝并发认领。 + let previous = self + .region(child) + .and_then(|region| region.meta) + .ok_or_else(|| SessionResourceError::new(SessionResourceErrorKind::NotFound))? + .agent_status; + if previous.is_active() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::InvalidInput { + detail: "child session is still active".to_owned(), + }, + )); + } + self.write_status(child, AgentStatus::Active); + // 阶段门在写入之后:用例据此断言「已提交的写入不会被调用方 drop 撤销」。 + Self::wait_gate(&self.active_write_gate).await; + self.claimed.store(true, Ordering::SeqCst); + Ok(Box::new(MockResumeClaim { + claimed: Arc::clone(&self.claimed), + regions: Arc::clone(&self.regions), + statuses: Arc::clone(&self.statuses), + status_changed: Arc::clone(&self.status_changed), + child: child.clone(), + previous, + })) + } + + async fn apply_compaction( + &self, + session_id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + let mut injection = self.injection.lock().unwrap(); + injection.compaction_calls += 1; + if injection.fail_compaction { + drop(injection); + return Err(SessionResourceError::new( + SessionResourceErrorKind::Internal { + detail: "injected compaction failure".to_owned(), + }, + )); + } + drop(injection); + self.with_region(session_id, |region| { + for (id, value) in &change.flag_updates { + region.flags.insert(*id, value.clone()); + } + for message in &change.appended_messages { + region + .payloads + .push(PersistedPayload::Message(message.clone())); + } + }); + Ok(()) + } + + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + // 全有或全无:单次数据区写入,不产生中途可见的中间态(门面契约的原子性)。 + self.with_region(id, |region| { + for (target, value) in updates { + region.flags.insert(*target, value.clone()); + } + }); + Ok(()) + } + + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + self.injection.lock().unwrap().rewind_calls += 1; + let target = boundary.message_id(); + let Some(region) = self.region(id) else { + return Err(SessionResourceError::new( + SessionResourceErrorKind::NotFound, + )); + }; + let keep = match boundary { + RewindBoundary::KeepThrough(_) => region + .payloads + .iter() + .position(|payload| payload.id() == target) + .map(|index| index + 1), + RewindBoundary::RemoveFrom(_) => region + .payloads + .iter() + .position(|payload| payload.id() == target), + }; + let Some(len) = keep else { + return Err(SessionResourceError::new( + SessionResourceErrorKind::NotFound, + )); + }; + let removed: Vec = region.payloads[len..] + .iter() + .map(PersistedPayload::id) + .collect(); + self.with_region(id, |region| { + region.payloads.truncate(len); + for removed_id in &removed { + region.flags.remove(removed_id); + } + }); + Ok(()) + } + + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + self.with_region(id, |region| { + region + .payloads + .retain(|payload| !ids.contains(&payload.id())); + for removed in ids { + region.flags.remove(removed); + } + }); + Ok(()) + } + + async fn update_session_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + self.ensure_writable()?; + if let Some(status) = patch.status { + self.write_status(id, status); + } + Ok(()) + } + + async fn delete_session_tree(&self, _id: &ThreadId) -> SessionResourceResult<()> { + Err(unsupported("delete_session_tree")) + } + + async fn recover_session_persistence( + &self, + _id: &ThreadId, + ) -> SessionResourceResult { + Ok(PersistenceRecovery::Recovered) + } + + async fn drain_persistence(&self, _id: &ThreadId) -> SessionResourceResult<()> { + Ok(()) + } +} diff --git a/peri-agent/src/session/transcript.rs b/peri-agent/src/session/transcript.rs index dbebb32a7..48cadf7b8 100644 --- a/peri-agent/src/session/transcript.rs +++ b/peri-agent/src/session/transcript.rs @@ -17,10 +17,24 @@ use anyhow::anyhow; use crate::agent::compact_v2::projection::MessageProjectionDirective; use crate::messages::{BaseMessage, MessageContent, MessageId}; -use crate::thread::{ThreadId, ThreadStore}; +use crate::thread::ThreadId; + +use peri_acp_types::session_resources::{RewindBoundary, SessionResources}; +use peri_acp_types::store::history; use peri_acp_types::store::{MessageFlags, PersistedPayload}; use peri_acp_types::system_reminder::{encode_system_reminder, TrustedSystemReminder}; +use persistence::{PersistenceBudget, Reservation}; + +/// 待持久化积压默认上限(条数)。 +/// +/// 覆盖 channel 队列 + writer 待批量 + in-flight 批次三处;阈值是保守上界, +/// 由 F 阶段按真实后端测量后校准(不得为了跑通而放宽到「等于没有界」)。 +pub const DEFAULT_PENDING_MAX_ITEMS: usize = 1024; + +/// 待持久化积压默认上限(字节,按 canonical payload 编码长度估算)。 +pub const DEFAULT_PENDING_MAX_BYTES: usize = 16 * 1024 * 1024; + // The command interceptor retains a clone while its cancellable pipeline owns the // transcript, so dropping that future cannot erase the persistence outcome. #[derive(Debug, Clone, Default)] @@ -77,6 +91,14 @@ impl TranscriptEntry { } /// Canonical model projection shared by normal Reason and compact rendering. + /// 转为 canonical 持久化载荷(与 `persisted_payloads` 同一映射,不另立格式)。 + pub fn into_payload(self) -> PersistedPayload { + match self { + Self::Message(message) => PersistedPayload::Message(message), + Self::Reminder { id, reminder } => PersistedPayload::SystemReminder { id, reminder }, + } + } + pub fn project_message(&self) -> anyhow::Result { match self { Self::Message(message) => { @@ -121,17 +143,31 @@ pub struct StagedData { // ─── PersistOp ──────────────────────────────────────────────────────────────── /// 持久化操作 — 通过异步通道传递富操作给 writer task +/// +/// 每个会产生写入的操作携带自己的[预算预留](Reservation):追加方在持锁时同步预留, +/// writer 在效果确定后归还。Barrier / Shutdown 不产生写入,也就不占额度。 #[derive(Debug)] pub enum PersistOp { - /// 追加新消息 - Append(TranscriptEntry), - /// Rewind 至指定 id(删除该 id 之后的所有记录) - RewindTo(MessageId), - /// 更新消息标记 - UpdateFlags(MessageId, MessageFlags), - /// 批量应用 compaction(将来实现) + /// 追加新消息(canonical payload) + Append { + payload: PersistedPayload, + reserved: Reservation, + }, + /// Transcript rewind 至指定 id(保留目标本身,删除其后记录) + RewindTo { + id: MessageId, + reserved: Reservation, + }, + /// 更新消息标记(投影变更集) + UpdateFlags { + id: MessageId, + flags: MessageFlags, + reserved: Reservation, + }, + /// 批量应用 compaction 标记变更(一次完整投影行为,缓存由资源侧维护) ApplyCompactionBatch { updates: Vec<(MessageId, MessageFlags)>, + reserved: Reservation, }, /// 确认此前所有持久化操作均已实际调用 store Barrier(tokio::sync::oneshot::Sender>), @@ -170,9 +206,12 @@ pub struct MessageTranscript { /// 取消后可能不完整的临时 transcript。 full_compaction_committed: bool, compaction_commit_state: CompactionCommitState, - /// 持久化后端引用(保留 Arc 让 store 在 transcript 存活期间不被释放, - /// spawned writer task 持有独立 clone) - store: Option>, + /// 会话资源门面引用(保留 Arc 让资源在 transcript 存活期间不被释放, + /// spawned writer task 持有独立 clone)——compact 生命周期等**需要确认结果**的 + /// 写入直接经它执行,不走普通 PersistOp 队列。 + session_resources: Option>, + /// 待持久化预算(与 writer 共享):条数/字节有界,预留失败即 sticky 失败。 + budget: Option>, } impl std::fmt::Debug for MessageTranscript { @@ -208,7 +247,8 @@ impl MessageTranscript { thread_id: None, full_compaction_committed: false, compaction_commit_state: CompactionCommitState::default(), - store: None, + session_resources: None, + budget: None, } } @@ -260,22 +300,63 @@ impl MessageTranscript { self } - /// 绑定持久化后端 + /// 绑定会话资源门面 /// - /// 绑定后 append / rewind / 标记变更自动异步写入 ThreadStore。 - /// 使用有序通道保证操作按调用顺序执行。 - pub fn with_persistence(mut self, store: Arc, thread_id: ThreadId) -> Self { + /// 绑定后 append / rewind / 投影变更自动异步写入门面(FIFO 单一 writer)。 + /// 积压有界性由 [`PersistenceBudget`] 保证:追加在持锁时同步预留,writer 在 + /// 效果确定后归还;预留失败即 sticky 失败([`Self::persistence_failure`]), + /// 调用方据此停止后续工作并重载会话。 + pub fn with_persistence(self, store: Arc, thread_id: ThreadId) -> Self { + let budget = PersistenceBudget::new(DEFAULT_PENDING_MAX_ITEMS, DEFAULT_PENDING_MAX_BYTES); + self.bind_persistence(store, thread_id, budget) + } + + /// 绑定持久化后端并显式指定待持久化预算(测试与阈值测量入口)。 + pub fn with_persistence_budget( + self, + store: Arc, + thread_id: ThreadId, + budget: Arc, + ) -> Self { + self.bind_persistence(store, thread_id, budget) + } + + fn bind_persistence( + mut self, + store: Arc, + thread_id: ThreadId, + budget: Arc, + ) -> Self { let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); self.persist_tx = Some(Arc::new(tx)); self.thread_id = Some(thread_id.clone()); - self.store = Some(store.clone()); + self.session_resources = Some(store.clone()); + self.budget = Some(Arc::clone(&budget)); - let handle = tokio::spawn(persistence::run_writer(store, thread_id, rx)); + let handle = tokio::spawn(persistence::run_writer(store, thread_id, budget, rx)); self.persist_handle = Some(handle.abort_handle()); self } + /// 待持久化失败原因(sticky):预算耗尽、writer 终态失败或写通道关闭。 + /// + /// 非 `None` 表示热态已不可信:数据可能已进内存但未落盘,调用方必须停止后续 + /// 模型/工具工作并让会话走冷重载,不得把当前快照当作已保存。 + pub fn persistence_failure(&self) -> Option { + self.budget.as_ref().and_then(|budget| budget.failure()) + } + + /// 是否已进入 sticky 持久化失败。 + pub fn has_persistence_failure(&self) -> bool { + self.persistence_failure().is_some() + } + + /// 预算句柄(测试断言积压上界用)。 + pub fn persistence_budget(&self) -> Option> { + self.budget.clone() + } + // ── 查询 ────────────────────────────────────────────────────────────────── /// 获取全部条目(不可变引用) @@ -321,7 +402,7 @@ impl MessageTranscript { self.id_index.insert(id, idx); self.entries .push(TranscriptEntry::Reminder { id, reminder }); - self.send_persist(PersistOp::Append(self.entries[idx].clone())); + self.persist_appended_entry(self.entries[idx].clone()); id } @@ -413,8 +494,8 @@ impl MessageTranscript { let idx = self.entries.len(); self.id_index.insert(id, idx); self.entries.push(TranscriptEntry::Message(message)); - // 异步持久化 - self.send_persist(PersistOp::Append(self.entries[idx].clone())); + // 异步持久化(先同步预留额度;无法预留时只留 sticky 失败,不假装已保存) + self.persist_appended_entry(self.entries[idx].clone()); id } @@ -427,7 +508,7 @@ impl MessageTranscript { self.id_index.insert(id, idx); self.entries.push(TranscriptEntry::Message(msg)); ids.push(id); - self.send_persist(PersistOp::Append(self.entries[idx].clone())); + self.persist_appended_entry(self.entries[idx].clone()); } ids } @@ -479,7 +560,7 @@ impl MessageTranscript { self.id_index.insert(ai_id, ai_idx); self.entries .push(TranscriptEntry::Message(staged.ai_message)); - self.send_persist(PersistOp::Append(self.entries[ai_idx].clone())); + self.persist_appended_entry(self.entries[ai_idx].clone()); // 写入 ToolResult 列表 for tool_result in staged.tool_results { @@ -487,7 +568,7 @@ impl MessageTranscript { let idx = self.entries.len(); self.id_index.insert(id, idx); self.entries.push(TranscriptEntry::Message(tool_result)); - self.send_persist(PersistOp::Append(self.entries[idx].clone())); + self.persist_appended_entry(self.entries[idx].clone()); } } @@ -521,7 +602,7 @@ impl MessageTranscript { } self.flags.entry(id).or_default().truncated = value; let flags = self.flags[&id].clone(); - self.send_persist(PersistOp::UpdateFlags(id, flags)); + self.persist_flags(id, flags); } /// 设置 excluded 标记(Full / Smart compact) @@ -531,7 +612,7 @@ impl MessageTranscript { } self.flags.entry(id).or_default().excluded = value; let flags = self.flags[&id].clone(); - self.send_persist(PersistOp::UpdateFlags(id, flags)); + self.persist_flags(id, flags); } /// 设置 projection directive(Micro compact) @@ -543,11 +624,10 @@ impl MessageTranscript { if !self.can_update_own_flags(id) { return; } - let entry = self.flags.entry(id).or_default(); - entry.truncated = true; - entry.projection = Some(directive); - let flags = self.flags[&id].clone(); - self.send_persist(PersistOp::UpdateFlags(id, flags)); + let existing = self.flags.get(&id).cloned().unwrap_or_default(); + let flags = history::flags_with_projection(&existing, directive); + self.flags.insert(id, flags.clone()); + self.persist_flags(id, flags); } /// 清除指定消息的所有标记 @@ -556,7 +636,7 @@ impl MessageTranscript { return; } self.flags.remove(&id); - self.send_persist(PersistOp::UpdateFlags(id, MessageFlags::default())); + self.persist_flags(id, MessageFlags::default()); } /// 批量恢复消息标记(用于 session 恢复时从持久化存储加载 flags) @@ -575,7 +655,7 @@ impl MessageTranscript { /// 仅在 store 事务成功后更新内存;事务已经持久化全部变更,不能再排队普通 PersistOp。 pub async fn commit_compaction_lifecycle( &mut self, - lifecycle: crate::thread::CompactionLifecycle, + lifecycle: crate::thread::CompactionChange, ) -> anyhow::Result<()> { if self.compaction_commit_state.is_uncertain() { return Err(anyhow!( @@ -583,7 +663,7 @@ impl MessageTranscript { )); } - let (store, thread_id) = match (&self.store, &self.thread_id) { + let (store, thread_id) = match (&self.session_resources, &self.thread_id) { (Some(store), Some(thread_id)) => (store.clone(), thread_id.clone()), _ => return Err(anyhow!("compact lifecycle requires persistence")), }; @@ -601,23 +681,27 @@ impl MessageTranscript { } } - let mut appended_ids = std::collections::HashSet::new(); - for message in &lifecycle.appended_messages { - let id = message.id(); - if self.id_index.contains_key(&id) || !appended_ids.insert(id) { - return Err(anyhow!( - "compact lifecycle appended message id {id:?} already exists in transcript" - )); - } - } + // 追加消息的 id 必须与既有历史不冲突:同一 id 已存在或批次内重复都必须在 + // 产生任何副作用之前失败(规则与 adapter 共用,见 store::history)。 + history::ensure_distinct_ids( + &history::appended_payloads(&lifecycle.appended_messages), + |id| self.id_index.contains_key(&id), + ) + .map_err(|error| anyhow!("compact lifecycle rejected: {error}"))?; // Both awaits can be cancelled, or report an error after durable effects. Only // applying the acknowledged lifecycle to memory makes this snapshot safe again. + // + // 顺序固定:先 flush 既有积压,再提交一次完整 compaction 行为(摘要、flags、 + // 计数与缓存视图同一事务),成功后才改内存。失败/未证明时保留磁盘事实并让 + // 热态保持失效(`is_uncertain`),不把内存视图推进到磁盘前面。 self.compaction_commit_state.mark_pending(); self.flush_persistence().await?; - store - .commit_compaction_lifecycle(&thread_id, &lifecycle) - .await?; + if let Err(error) = store.apply_compaction(&thread_id, &lifecycle).await { + return Err(anyhow!( + "compact persistence did not commit; reload the session to recover: {error}" + )); + } self.apply_compaction_lifecycle_memory(&lifecycle); self.compaction_commit_state.mark_committed(); @@ -625,17 +709,8 @@ impl MessageTranscript { } /// 应用已成功持久化的 compaction lifecycle,不发送普通 PersistOp。 - fn apply_compaction_lifecycle_memory( - &mut self, - lifecycle: &crate::thread::CompactionLifecycle, - ) { - for (id, flags) in &lifecycle.flag_updates { - if *flags == MessageFlags::default() { - self.flags.remove(id); - } else { - self.flags.insert(*id, flags.clone()); - } - } + fn apply_compaction_lifecycle_memory(&mut self, lifecycle: &crate::thread::CompactionChange) { + history::apply_flag_updates(&mut self.flags, &lifecycle.flag_updates); for message in &lifecycle.appended_messages { let id = message.id(); @@ -677,7 +752,8 @@ impl MessageTranscript { thread_id: self.thread_id.take(), full_compaction_committed: self.full_compaction_committed, compaction_commit_state: self.compaction_commit_state.clone(), - store: self.store.take(), + session_resources: self.session_resources.take(), + budget: self.budget.take(), } } @@ -688,11 +764,12 @@ impl MessageTranscript { /// 同步收缩索引表、清空 staging。 /// 若 id 不存在返回错误。 pub fn rewind_to(&mut self, id: MessageId) -> Result<(), anyhow::Error> { - let target_idx = self - .id_index - .get(&id) - .copied() - .ok_or_else(|| anyhow!("rewind target id {id:?} not found in transcript"))?; + let ids: Vec = self.entries.iter().map(TranscriptEntry::id).collect(); + // transcript rewind 保留目标本身(KeepThrough);用户 rewind 的 RemoveFrom + // 是另一个边界,两者共用 store::history 的边界规则。 + let keep_len = history::rewind_keep_len(&ids, RewindBoundary::KeepThrough(id)) + .map_err(|_| anyhow!("rewind target id {id:?} not found in transcript"))?; + let target_idx = keep_len - 1; // ancestor 边界保护:不能 rewind 到祖先消息内部 if target_idx < self.ancestor_len { @@ -707,13 +784,10 @@ impl MessageTranscript { self.staged = None; // 收集要移除的 id(用于清理索引和标记) - let remove_ids: Vec = self.entries[target_idx + 1..] - .iter() - .map(|e| e.id()) - .collect(); + let remove_ids: Vec = ids[keep_len..].to_vec(); // 截断 entries - self.entries.truncate(target_idx + 1); + self.entries.truncate(keep_len); // 收缩索引表 for rid in &remove_ids { @@ -721,8 +795,10 @@ impl MessageTranscript { self.flags.remove(rid); } - // 异步持久化 rewind - self.send_persist(PersistOp::RewindTo(id)); + // 异步持久化 rewind(保留目标本身的 KeepThrough 边界) + if let Some(reserved) = self.reserve_persistence(Reservation::marker(1)) { + self.send_persist(PersistOp::RewindTo { id, reserved }); + } Ok(()) } @@ -762,10 +838,56 @@ impl MessageTranscript { .map_err(|_| anyhow!("transcript persistence writer dropped barrier acknowledgement"))? } - /// 发送持久化操作到 writer task + /// 预留一笔待持久化额度:持锁期间同步完成,不 await。 + /// + /// 预算耗尽时**不发送**该操作并留下 sticky 失败——数据已进 canonical 内存但 + /// 没有落盘名额,调用方只能据 [`Self::persistence_failure`] 停止后续工作。 + fn reserve_persistence(&self, reservation: Reservation) -> Option { + let budget = self.budget.as_ref()?; + match budget.try_reserve(reservation) { + Ok(()) => Some(reservation), + Err(reason) => { + tracing::error!( + items = reservation.items, + bytes = reservation.bytes, + "transcript persistence backlog limit reached: {reason}" + ); + None + } + } + } + + /// 追加条目落盘:按 canonical payload 编码长度预留额度后投递。 + fn persist_appended_entry(&self, entry: TranscriptEntry) { + let payload = entry.into_payload(); + let Some(reserved) = self.reserve_persistence(Reservation::for_payload(&payload)) else { + return; + }; + self.send_persist(PersistOp::Append { payload, reserved }); + } + + /// 投影/flags 变更落盘:队列里只有 id 与标记,按条数预留。 + fn persist_flags(&self, id: MessageId, flags: MessageFlags) { + let Some(reserved) = self.reserve_persistence(Reservation::marker(1)) else { + return; + }; + self.send_persist(PersistOp::UpdateFlags { + id, + flags, + reserved, + }); + } + + /// 发送持久化操作到 writer task(额度已在调用点预留) fn send_persist(&self, op: PersistOp) { if let Some(ref tx) = self.persist_tx { if let Err(e) = tx.send(op) { + // 通道关闭 = 写入不会发生:与预算耗尽同样是 sticky 失败。 + if let Some(budget) = self.budget.as_ref() { + budget.mark_failed(&format!( + "transcript persistence writer channel closed: {e}" + )); + } tracing::warn!("transcript persist send failed (channel closed): {e}"); } } diff --git a/peri-agent/src/session/transcript/persistence.rs b/peri-agent/src/session/transcript/persistence.rs index ef4ed72f5..98c73fdc1 100644 --- a/peri-agent/src/session/transcript/persistence.rs +++ b/peri-agent/src/session/transcript/persistence.rs @@ -1,66 +1,216 @@ -//! Ordered transcript persistence worker: batching, barriers and terminal failure. +//! Ordered transcript persistence worker: batching, barriers, bounded backlog and terminal failure. //! The task owns its receiver and all pending payloads; no transcript guard is held //! while the store is awaited. Transcript memory/compaction ownership stays above. +//! +//! 有界性由**待持久化预算**保证,而不是把等待搬进持锁路径:追加方在持有 transcript +//! 写锁时同步预留条数/字节(通道本身 unbound,预留失败即拒收),writer 只在行为效果 +//! 确定后归还。预算覆盖三处积压——channel 队列、writer 待批量、in-flight 批次。 -use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use anyhow::anyhow; +use peri_acp_types::session_resources::{RewindBoundary, SessionResources}; use peri_acp_types::store::PersistedPayload; -use super::{PersistOp, TranscriptEntry}; -use crate::thread::{ThreadId, ThreadStore}; +use super::PersistOp; +use crate::thread::ThreadId; -/// 将积压的 Append 批量落库(单次 `append_messages` 调用 → SQLite 单事务)。 +/// 一次持久化操作的预算预留(条数 + 估算字节)。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Reservation { + pub items: usize, + pub bytes: usize, +} + +impl Reservation { + pub(super) fn for_payload(payload: &PersistedPayload) -> Self { + Self { + items: 1, + // 与落库编码同源(`serialize_persisted_payload` 的 envelope),不另立估算格式; + // 编码失败时按 0 计(该 payload 稍后必然在写路径上失败并置终态)。 + bytes: peri_acp_types::store::serialize_persisted_payload(payload) + .map(|encoded| encoded.len()) + .unwrap_or(0), + } + } + + pub(super) fn marker(items: usize) -> Self { + Self { items, bytes: 0 } + } + + pub(super) fn merge(&mut self, other: Self) { + self.items = self.items.saturating_add(other.items); + self.bytes = self.bytes.saturating_add(other.bytes); + } +} + +/// 待持久化预算:条数与字节的共同上限。 /// -/// 成功后清空积压;失败时保留 payload 与首个错误,不重试可能部分成功的批次。 +/// 语义: +/// - 预留同步完成(调用方持锁时不得 await); +/// - 归还只在**效果确定或缓冲真实释放**之后发生——取消调用方不等于释放 adapter +/// 仍持有的 payload; +/// - 预留失败与 writer 终态失败都写成 sticky 失败:热态不再可信,调用方必须停止 +/// 后续写入并重载会话。 +#[derive(Debug)] +pub struct PersistenceBudget { + max_items: usize, + max_bytes: usize, + items: AtomicUsize, + bytes: AtomicUsize, + /// 被拒收的条数(已进内存、未获准持久化):报错时必须如实计入。 + refused: AtomicUsize, + failure: Mutex>, +} + +impl PersistenceBudget { + pub fn new(max_items: usize, max_bytes: usize) -> Arc { + Arc::new(Self { + max_items, + max_bytes, + items: AtomicUsize::new(0), + bytes: AtomicUsize::new(0), + refused: AtomicUsize::new(0), + failure: Mutex::new(None), + }) + } + + /// 预留一次写入额度;失败即 sticky 失败(预算耗尽本身就是失败事实)。 + pub(super) fn try_reserve(&self, reservation: Reservation) -> Result<(), String> { + if self.is_failed() { + self.refused + .fetch_add(reservation.items.max(1), Ordering::AcqRel); + return Err(self.failure().unwrap_or_default()); + } + // 单次预留超过上限时明确拒绝,不把「超限」静默截断成「已允许」。 + if reservation.items > self.max_items || reservation.bytes > self.max_bytes { + let reason = format!( + "transcript persistence budget exhausted: single write needs {} item(s)/{} byte(s), budget is {} item(s)/{} byte(s)", + reservation.items, reservation.bytes, self.max_items, self.max_bytes + ); + self.refused + .fetch_add(reservation.items.max(1), Ordering::AcqRel); + self.mark_failed(&reason); + return Err(reason); + } + let previous_items = self.items.fetch_add(reservation.items, Ordering::AcqRel); + let previous_bytes = self.bytes.fetch_add(reservation.bytes, Ordering::AcqRel); + if previous_items + reservation.items > self.max_items + || previous_bytes + reservation.bytes > self.max_bytes + { + self.items.fetch_sub(reservation.items, Ordering::AcqRel); + self.bytes.fetch_sub(reservation.bytes, Ordering::AcqRel); + let reason = format!( + "transcript persistence backlog is full ({} item(s)/{} byte(s) outstanding); {} payload(s) entered memory without a durable slot", + self.max_items, + self.max_bytes, + self.refused.load(Ordering::Acquire) + reservation.items + ); + self.refused + .fetch_add(reservation.items.max(1), Ordering::AcqRel); + self.mark_failed(&reason); + return Err(reason); + } + Ok(()) + } + + pub(super) fn release(&self, reservation: Reservation) { + self.items.fetch_sub(reservation.items, Ordering::AcqRel); + self.bytes.fetch_sub(reservation.bytes, Ordering::AcqRel); + } + + /// 首次失败即定格(后续失败不覆盖首个原因)。 + pub(super) fn mark_failed(&self, reason: &str) { + if let Ok(mut slot) = self.failure.lock() { + if slot.is_none() { + *slot = Some(reason.to_owned()); + } + } + } + + pub fn failure(&self) -> Option { + self.failure.lock().ok().and_then(|slot| slot.clone()) + } + + pub fn is_failed(&self) -> bool { + self.failure().is_some() + } + + /// 当前未归还的积压(条数, 字节)——测试用可观察量。 + pub fn outstanding(&self) -> (usize, usize) { + ( + self.items.load(Ordering::Acquire), + self.bytes.load(Ordering::Acquire), + ) + } +} + +/// 将积压的 Append 批量落库(单次 `append_history` 调用 → 一个数据行为/一个事务)。 +/// +/// 成功后清空积压并归还预留;失败时保留 payload 与首个错误,不重试可能部分成功的批次, +/// 也不归还预留(adapter 可能仍持有这些 payload)。 async fn flush_appends( - store: &dyn ThreadStore, + store: &dyn SessionResources, tid: &ThreadId, pending: &mut Vec, + reserved: &mut Reservation, barrier_error: &mut Option, processed: &mut u64, + budget: &PersistenceBudget, ) { if pending.is_empty() { return; } + if let Some(sticky) = budget.failure() { + // 预算已定格失败:不再把 payload 交给 adapter,也不归还预留(它们确实未落盘)。 + *barrier_error = Some(sticky); + return; + } if barrier_error.is_some() { return; } - if let Err(e) = store.append_payloads(tid, pending).await { + if let Err(e) = store.append_history(tid, pending).await { tracing::warn!( pending = pending.len(), "transcript persist entered terminal failure: {e}" ); - *barrier_error = Some(e.to_string()); + let reason = format!("append_history failed: {e}"); + budget.mark_failed(&reason); + *barrier_error = Some(reason); return; } *processed = processed.saturating_add(pending.len() as u64); pending.clear(); + budget.release(*reserved); + *reserved = Reservation::default(); } pub(super) async fn run_writer( - store: Arc, + store: Arc, tid: ThreadId, + budget: Arc, mut rx: tokio::sync::mpsc::UnboundedReceiver, ) { let mut processed: u64 = 0; let mut last_warn_at: u64 = 0; - let mut barrier_error = None; + let mut barrier_error: Option = None; + // 短窗口 Append 合并:把 ≤100ms 窗口(或 ≥APPEND_BATCH_MAX 条)内的 - // Append 积压为一次 `append_messages` 批量调用(SQLite 单事务 = 一次 - // WAL fsync),消除工具消息风暴下每消息一次 fsync。 + /// Append 积压为一次 `append_history` 批量调用(单事务 = 一次 WAL fsync), + /// 消除工具消息风暴下每消息一次 fsync。 // // 可见性语义不变: // - Barrier 到达时先 flush 积压再 ack(flush_persistence 确认 = 已落库) // - 其他 op 到达时先 flush 积压,保持 FIFO 顺序 // - 通道关闭时 flush 剩余 - const FAILED_PENDING_MAX: usize = 256; - let mut dropped_after_failure = 0usize; - let mut pending_appends: Vec = Vec::new(); - let mut window_start: std::time::Instant = std::time::Instant::now(); const APPEND_BATCH_MAX: usize = 64; const APPEND_BATCH_WINDOW: std::time::Duration = std::time::Duration::from_millis(100); + let mut pending_appends: Vec = Vec::new(); + let mut pending_reserved = Reservation::default(); + let mut window_start: std::time::Instant = std::time::Instant::now(); + loop { // 失败后的积压仅用于 barrier 诊断,不能继续驱动批处理定时器。 // 等待新 op 仍允许 sticky Barrier / Shutdown 以及有界失败缓冲处理。 @@ -76,8 +226,10 @@ pub(super) async fn run_writer( store.as_ref(), &tid, &mut pending_appends, + &mut pending_reserved, &mut barrier_error, &mut processed, + &budget, ) .await; continue; @@ -86,32 +238,21 @@ pub(super) async fn run_writer( }; match op { - Some(PersistOp::Append(entry)) => { + Some(PersistOp::Append { payload, reserved }) => { if pending_appends.is_empty() { window_start = std::time::Instant::now(); } - let payload = match entry { - TranscriptEntry::Message(message) => PersistedPayload::Message(message), - TranscriptEntry::Reminder { id, reminder } => { - PersistedPayload::SystemReminder { id, reminder } - } - }; - if barrier_error.is_some() && pending_appends.len() >= FAILED_PENDING_MAX { - dropped_after_failure = dropped_after_failure.saturating_add(1); - tracing::warn!( - dropped_after_failure, - "terminal transcript persistence failure dropped payload" - ); - } else { - pending_appends.push(payload); - } + pending_reserved.merge(reserved); + pending_appends.push(payload); if pending_appends.len() >= APPEND_BATCH_MAX { flush_appends( store.as_ref(), &tid, &mut pending_appends, + &mut pending_reserved, &mut barrier_error, &mut processed, + &budget, ) .await; } @@ -122,17 +263,22 @@ pub(super) async fn run_writer( store.as_ref(), &tid, &mut pending_appends, + &mut pending_reserved, &mut barrier_error, &mut processed, + &budget, ) .await; - let result = barrier_error.as_ref().map_or(Ok(()), |error| { - Err(anyhow!( - "{error}; {} payload(s) remain unpersisted, {} dropped after terminal failure", - pending_appends.len(), - dropped_after_failure - )) - }); + let sticky = budget.failure(); + let result = barrier_error + .as_ref() + .or(sticky.as_ref()) + .map_or(Ok(()), |error| { + Err(anyhow!( + "{error}; {} item(s) remain unpersisted", + pending_reserved.items + )) + }); let _ = ack.send(result); } Some(PersistOp::Shutdown) | None => { @@ -148,8 +294,10 @@ pub(super) async fn run_writer( store.as_ref(), &tid, &mut pending_appends, + &mut pending_reserved, &mut barrier_error, &mut processed, + &budget, ) .await; break; @@ -160,37 +308,48 @@ pub(super) async fn run_writer( store.as_ref(), &tid, &mut pending_appends, + &mut pending_reserved, &mut barrier_error, &mut processed, + &budget, ) .await; - let result = if let Some(error) = barrier_error.as_ref() { - Err(anyhow!(error.clone())) + let (reservation, result) = match other { + PersistOp::RewindTo { id, reserved } => ( + reserved, + store + .rewind_history(&tid, RewindBoundary::KeepThrough(id)) + .await, + ), + PersistOp::UpdateFlags { + id, + flags, + reserved, + } => ( + reserved, + store.apply_message_projections(&tid, &[(id, flags)]).await, + ), + PersistOp::ApplyCompactionBatch { updates, reserved } => ( + reserved, + store.apply_message_projections(&tid, &updates).await, + ), + PersistOp::Append { .. } | PersistOp::Barrier(_) | PersistOp::Shutdown => { + unreachable!("handled in dedicated branches above") + } + }; + let result = if let Some(sticky) = barrier_error.as_ref() { + Err(anyhow!(sticky.clone())) } else { - match other { - PersistOp::RewindTo(id) => store.delete_messages_since(&tid, &id).await, - PersistOp::UpdateFlags(id, flags) => { - store.update_message_flags(&id, &flags).await + match result { + Ok(()) => { + // 效果确定:release 与持久化效果同步,取消调用方不算释放。 + budget.release(reservation); + Ok(()) } - PersistOp::ApplyCompactionBatch { updates } => { - let mut first_err = None; - for (id, flags) in &updates { - if let Err(err) = store.update_message_flags(id, flags).await { - if first_err.is_none() { - first_err = Some(err); - } - } - } - // 无论标记更新是否部分失败,均需使缓存失效。 - if let Err(err) = store.invalidate_context_cache(&tid).await { - if first_err.is_none() { - first_err = Some(err); - } - } - first_err.map_or(Ok(()), Err) - } - PersistOp::Append(_) | PersistOp::Barrier(_) | PersistOp::Shutdown => { - unreachable!("handled in dedicated branches above") + Err(error) => { + let reason = error.to_string(); + budget.mark_failed(&reason); + Err(anyhow!(reason)) } } }; @@ -199,19 +358,14 @@ pub(super) async fn run_writer( if barrier_error.is_none() { barrier_error = Some(e.to_string()); } + } else { + processed = processed.saturating_add(1); + if processed >= last_warn_at.saturating_add(1000) { + last_warn_at = processed; + tracing::debug!(processed, "transcript persist progress"); + } } - processed = processed.saturating_add(1); } } - - let bucket = processed / 1000; - if bucket > last_warn_at { - last_warn_at = bucket; - tracing::trace!( - thread_id = %tid, - processed, - "transcript persist writer: 已处理 {processed} 条操作" - ); - } } } diff --git a/peri-agent/src/session/transcript_test.rs b/peri-agent/src/session/transcript_test.rs index 7f72bf3d6..aa8c9efdc 100644 --- a/peri-agent/src/session/transcript_test.rs +++ b/peri-agent/src/session/transcript_test.rs @@ -110,143 +110,13 @@ fn test_system_reminder_projects_once_as_human() { ); } -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use crate::messages::MessageContent; -use crate::thread::{ - CompactionLifecycle, FilesystemThreadStore, SqliteThreadStore, ThreadId, ThreadMeta, - ThreadStore, -}; -use anyhow::Result; -use async_trait::async_trait; -use tempfile::tempdir; - -struct FaultInjectingStore { - fail_on: Vec, - fail_flag_on: Vec, - fail_invalidation: bool, - append_count: Mutex, - flag_count: Mutex, - invalidation_count: Mutex, - messages: Mutex>, -} - -impl FaultInjectingStore { - fn new(fail_on: impl IntoIterator) -> Self { - Self::with_failures(fail_on, [], false) - } - - fn with_failures( - fail_on: impl IntoIterator, - fail_flag_on: impl IntoIterator, - fail_invalidation: bool, - ) -> Self { - Self { - fail_on: fail_on.into_iter().collect(), - fail_flag_on: fail_flag_on.into_iter().collect(), - fail_invalidation, - append_count: Mutex::new(0), - flag_count: Mutex::new(0), - invalidation_count: Mutex::new(0), - messages: Mutex::new(Vec::new()), - } - } - - fn messages(&self) -> Vec { - self.messages.lock().unwrap().clone() - } -} - -#[async_trait] -impl ThreadStore for FaultInjectingStore { - async fn create_thread(&self, meta: ThreadMeta) -> Result { - Ok(meta.id) - } - - async fn append_messages(&self, _id: &ThreadId, msgs: &[BaseMessage]) -> Result<()> { - for message in msgs { - let mut append_count = self.append_count.lock().unwrap(); - *append_count += 1; - if self.fail_on.contains(&*append_count) { - anyhow::bail!("deterministic injected error on append {}", *append_count); - } - self.messages.lock().unwrap().push(message.clone()); - } - Ok(()) - } - - async fn load_messages(&self, _id: &ThreadId) -> Result> { - Ok(self.messages()) - } - - async fn load_meta(&self, _id: &ThreadId) -> Result { - Ok(ThreadMeta::new("/test")) - } - - async fn update_meta(&self, _id: &ThreadId, _meta: ThreadMeta) -> Result<()> { - Ok(()) - } - - async fn list_threads(&self) -> Result> { - Ok(Vec::new()) - } - - async fn delete_thread(&self, _id: &ThreadId) -> Result<()> { - Ok(()) - } - - async fn load_context(&self, _thread_id: &ThreadId) -> Result> { - Ok(Vec::new()) - } - - async fn list_child_threads(&self, _parent_id: &ThreadId) -> Result> { - Ok(Vec::new()) - } - - async fn list_session_threads(&self, _root_id: &ThreadId) -> Result> { - Ok(Vec::new()) - } +use peri_acp_types::store::CompactionChange; - async fn update_thread_status(&self, _id: &ThreadId, _status: &str) -> Result<()> { - Ok(()) - } - - async fn invalidate_context_cache(&self, _thread_id: &ThreadId) -> Result<()> { - let mut invalidation_count = self.invalidation_count.lock().unwrap(); - *invalidation_count += 1; - if self.fail_invalidation { - anyhow::bail!( - "deterministic injected error on cache invalidation {}", - *invalidation_count - ); - } - Ok(()) - } - - async fn update_message_flags( - &self, - _message_id: &MessageId, - _flags: &MessageFlags, - ) -> Result<()> { - let mut flag_count = self.flag_count.lock().unwrap(); - *flag_count += 1; - if self.fail_flag_on.contains(&*flag_count) { - anyhow::bail!( - "deterministic injected error on flag update {}", - *flag_count - ); - } - Ok(()) - } - - async fn delete_messages( - &self, - _thread_id: &ThreadId, - _message_ids: &[MessageId], - ) -> Result<()> { - Ok(()) - } -} +use crate::messages::MessageContent; +use crate::session::test_resources::mock::MockSessionResources; +use crate::session::test_resources::TestSession; fn make_human(text: &str) -> BaseMessage { BaseMessage::human(MessageContent::text(text.to_string())) @@ -263,371 +133,220 @@ fn make_tool_result(tool_call_id: &str, text: &str) -> BaseMessage { ) } -// ── Compaction lifecycle 原子提交 ─────────────────────────────────────────── +// ── 持久化行为(内存门面替身 + 真实 SQLite 夹具)────────────────────────── +// +// 断言以可观察结果为准:落库内容/顺序、投影与 rewind 的实际效果、失败后的 sticky +// 状态与「未保存」如实上报、预算耗尽时写入被拒。真实后端路径另有 +// [`TestSession`](crate::session::test_resources::TestSession) 覆盖(真门面 + 真库 +// + 活跃 owner,不靠替身自洽)。 + +fn mock_store() -> Arc { + MockSessionResources::new() +} + +fn persisted_messages(store: &MockSessionResources) -> Vec { + store + .payloads() + .iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect() +} +/// 追加与 flush 之后,真实 SQLite 后端必须逐条可读且顺序不变。 #[tokio::test] -async fn test_commit_compaction_lifecycle_sqlite_updates_memory_and_store_atomically() { - let dir = tempdir().unwrap(); - let store = SqliteThreadStore::new(dir.path().join("transcript-lifecycle.db")) - .await - .unwrap(); - let thread_id = store.create_thread(ThreadMeta::new("/test")).await.unwrap(); - let store: Arc = Arc::new(store); +async fn test_flush_persistence_makes_appends_visible() { + let session = TestSession::open().await; let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), thread_id.clone()); + MessageTranscript::new().with_persistence(session.resources(), session.thread_id.clone()); - let first_id = transcript.append(make_human("原始用户消息")); - let second_id = transcript.append(make_ai("原始助手回复")); + transcript.append(make_human("persisted message")); + transcript.append(make_ai("assistant reply")); transcript.flush_persistence().await.unwrap(); - let summary = make_human("压缩摘要"); - let reinject = make_human("重新注入的用户上下文"); - let summary_id = summary.id(); - let reinject_id = reinject.id(); - transcript - .commit_compaction_lifecycle(CompactionLifecycle { - flag_updates: vec![ - ( - first_id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ), - ( - second_id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ), - ], - appended_messages: vec![summary, reinject], - }) + let snapshot = session + .resources() + .load_session_snapshot(&session.thread_id) .await .unwrap(); - - assert_eq!(transcript.entries().len(), 4); - let visible = transcript.visible_messages(); - assert_eq!(visible.len(), 2); - assert_eq!(visible[0].id(), summary_id); - assert_eq!(visible[1].id(), reinject_id); - assert!(transcript.flags(first_id).excluded); - assert!(transcript.flags(second_id).excluded); - - let messages = store.load_messages(&thread_id).await.unwrap(); - assert_eq!(messages.len(), 4); - assert_eq!(messages[0].id(), first_id); - assert_eq!(messages[1].id(), second_id); - assert_eq!(messages[2].id(), summary_id); - assert_eq!(messages[3].id(), reinject_id); - let flags = store.load_message_flags(&thread_id).await.unwrap(); - assert!(flags[&first_id].excluded); - assert!(flags[&second_id].excluded); + let contents: Vec = snapshot + .payloads + .iter() + .filter_map(|payload| payload.as_message()) + .map(|message| message.content()) + .collect(); + assert_eq!(contents, vec!["persisted message", "assistant reply"]); } +/// 写入失败后 flush 如实报错,且错误是 sticky 的:后续 flush 仍报同一原因; +/// 已经进入内存但未落库的内容不会被说成已保存。 #[tokio::test] -async fn test_commit_compaction_lifecycle_waits_for_pending_sqlite_appends_in_fifo_order() { - let dir = tempdir().unwrap(); - let store = SqliteThreadStore::new(dir.path().join("transcript-lifecycle-fifo.db")) - .await - .unwrap(); - let thread_id = store.create_thread(ThreadMeta::new("/test")).await.unwrap(); - let store: Arc = Arc::new(store); +async fn test_flush_persistence_failure_is_sticky() { + let mut session = TestSession::open().await; let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), thread_id.clone()); + MessageTranscript::new().with_persistence(session.resources(), session.thread_id.clone()); + transcript.append(make_human("cannot be persisted")); + // 丢弃执行所有权:此后写入按 LeaseRequired 真实失败(不是 mock 假装失败) + session.release_lease(); - let first_id = transcript.append(make_human("FIFO 原始用户消息")); - let second_id = transcript.append(make_ai("FIFO 原始助手回复")); + let error = transcript.flush_persistence().await.unwrap_err(); + let repeated = transcript.flush_persistence().await.unwrap_err(); + assert_eq!( + repeated.to_string(), + error.to_string(), + "失败必须是 sticky 的" + ); + assert!(transcript.has_persistence_failure()); - let summary = make_human("FIFO 压缩摘要"); - let summary_id = summary.id(); - transcript - .commit_compaction_lifecycle(CompactionLifecycle { - flag_updates: vec![ - ( - first_id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ), - ( - second_id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ), - ], - appended_messages: vec![summary], - }) + let snapshot = session + .resources() + .load_session_snapshot(&session.thread_id) .await .unwrap(); - - let messages = store.load_messages(&thread_id).await.unwrap(); - assert_eq!(messages.len(), 3); - assert_eq!(messages[0].id(), first_id); - assert_eq!(messages[1].id(), second_id); - assert_eq!(messages[2].id(), summary_id); - let flags = store.load_message_flags(&thread_id).await.unwrap(); - assert!(flags[&first_id].excluded); - assert!(flags[&second_id].excluded); - - assert_eq!(transcript.entries().len(), 3); - assert!(transcript.flags(first_id).excluded); - assert!(transcript.flags(second_id).excluded); - let visible = transcript.visible_messages(); - assert_eq!(visible.len(), 1); - assert_eq!(visible[0].id(), summary_id); + assert!(snapshot.payloads.is_empty(), "写入未成立时不得留下半条"); } +/// 预算耗尽:追加在持锁时同步预留,无法预留即 sticky 失败,且**不把 payload 交给 +/// 后端**(不接受「先写再说」)。 #[tokio::test] -async fn test_commit_compaction_lifecycle_filesystem_failure_leaves_memory_and_store_unchanged() { - let dir = tempdir().unwrap(); - let store: Arc = Arc::new(FilesystemThreadStore::new(dir.path())); - let thread_id = store.create_thread(ThreadMeta::new("/test")).await.unwrap(); - let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), thread_id.clone()); - - let first_id = transcript.append(make_human("文件系统原始用户消息")); - let second_id = transcript.append(make_ai("文件系统原始助手回复")); - transcript.flush_persistence().await.unwrap(); +async fn test_persistence_budget_exhaustion_is_sticky_and_rejects_writes() { + let store = mock_store(); + let budget = super::persistence::PersistenceBudget::new(1, 8); + let mut transcript = MessageTranscript::new().with_persistence_budget( + store.clone(), + "budget-exhausted".to_string(), + budget, + ); - let summary = make_human("文件系统不应追加的摘要"); - let summary_id = summary.id(); - let error = transcript - .commit_compaction_lifecycle(CompactionLifecycle { - flag_updates: vec![ - ( - first_id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ), - ( - second_id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ), - ], - appended_messages: vec![summary], - }) - .await - .unwrap_err(); - let error_message = error.to_string().to_lowercase(); + transcript.append(make_human( + "this payload is larger than the whole byte budget", + )); assert!( - error_message.contains("filesystem") || error_message.contains("sqltiethreadstore"), - "文件系统 store 必须明确拒绝 compact lifecycle: {error}" + transcript.has_persistence_failure(), + "无法预留时必须留下 sticky 失败" ); - - assert_eq!(transcript.entries().len(), 2); - let visible = transcript.visible_messages(); - assert_eq!(visible.len(), 2); - assert_eq!(visible[0].id(), first_id); - assert_eq!(visible[1].id(), second_id); - assert_eq!(transcript.flags(first_id), MessageFlags::default()); - assert_eq!(transcript.flags(second_id), MessageFlags::default()); - - let messages = store.load_messages(&thread_id).await.unwrap(); - assert_eq!(messages.len(), 2); - assert!(messages.iter().all(|message| message.id() != summary_id)); - let flags = store.load_message_flags(&thread_id).await.unwrap(); - assert!(flags.is_empty()); + let error = transcript.flush_persistence().await.unwrap_err(); + assert!( + error.to_string().contains("budget"), + "错误必须指出预算耗尽: {error}" + ); + assert_eq!(store.append_calls(), 0, "被拒的写入不得触达后端"); + assert!(store.payloads().is_empty()); } -/// [回归测试] compact 历史必须从 canonical transcript 持久化,而非从模型投影重建。 -/// reminder 在摘要前保留或在摘要后追加,冷重载均保持同一 typed payload 和顺序。 +/// 没有绑定持久化后端时 flush 是不报错的 no-op(不制造失败,也不声称保存)。 #[tokio::test] -async fn test_compaction_reload_preserves_canonical_reminder_once() { - use peri_acp_types::system_reminder::{ - encode_system_reminder, ReminderAudience, ReminderAudiences, ReminderCategory, - ReminderDelivery, ReminderSeverity, ReminderSource, SystemReminder, - TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, - }; - let dir = tempdir().unwrap(); - for reminder_after_compact in [false, true] { - let db_path = dir - .path() - .join(format!("reminder-{reminder_after_compact}.db")); - let store: Arc = Arc::new(SqliteThreadStore::new(&db_path).await.unwrap()); - let thread_id = store.create_thread(ThreadMeta::new("/test")).await.unwrap(); - let reminder = TrustedSystemReminderFactory::for_producer() - .construct(SystemReminder { - version: SYSTEM_REMINDER_VERSION, - category: ReminderCategory::Task, - source: ReminderSource("compact_reload_test".into()), - kind: "notice".into(), - severity: ReminderSeverity::Info, - delivery: ReminderDelivery::Configurable, - audiences: ReminderAudiences(vec![ReminderAudience::Model]), - body: "canonical reminder".into(), - summary: None, - metadata: serde_json::json!({}), - }) - .unwrap(); - let encoded_reminder = encode_system_reminder(&reminder).unwrap(); - let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), thread_id.clone()); - let first_id = transcript.append(make_human("旧用户消息")); - let existing_reminder = - (!reminder_after_compact).then(|| transcript.append_system_reminder(reminder.clone())); - let second_id = transcript.append(make_ai("旧助手回答")); - let summary = make_human("压缩摘要"); - let summary_id = summary.id(); - // 不手工删行:生产 API 先排空 writer,再原子追加摘要与 excluded flags。 - transcript - .commit_compaction_lifecycle(CompactionLifecycle { - flag_updates: [first_id, second_id] - .into_iter() - .map(|id| { - ( - id, - MessageFlags { - excluded: true, - ..Default::default() - }, - ) - }) - .collect(), - appended_messages: vec![summary], - }) - .await - .unwrap(); - let reminder_id = - existing_reminder.unwrap_or_else(|| transcript.append_system_reminder(reminder)); - transcript.flush_persistence().await.unwrap(); - let canonical = transcript.persisted_payloads(); - let visible_ids = if reminder_after_compact { - vec![summary_id, reminder_id] - } else { - vec![reminder_id, summary_id] - }; - assert_eq!( - transcript - .visible_model_messages() - .unwrap() - .iter() - .map(BaseMessage::id) - .collect::>(), - visible_ids - ); - transcript.shutdown_persistence(); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while transcript.flush_persistence().await.is_ok() { - tokio::task::yield_now().await; - } - }) - .await - .expect("冷重载前 writer 必须退出"); - drop(transcript); - drop(store); - // 新建 SQLite store 和 transcript,从磁盘 payload/flags 恢复。 - let reopened = SqliteThreadStore::new(&db_path).await.unwrap(); - let loaded = reopened.load_payloads(&thread_id).await.unwrap(); - let flags = reopened.load_message_flags(&thread_id).await.unwrap(); - assert_eq!( - loaded.len(), - 4, - "compact 保留旧 canonical 行,以 flags 控制可见性" - ); - let encoded = |payloads: &[PersistedPayload]| { - payloads - .iter() - .map(|payload| { - serde_json::from_str::( - &peri_acp_types::store::serialize_persisted_payload(payload).unwrap(), - ) - .unwrap() - }) - .collect::>() - }; - assert_eq!(encoded(&loaded), encoded(&canonical)); - assert_eq!( - loaded - .iter() - .filter(|payload| payload.id() == reminder_id) - .count(), - 1 - ); - assert!(matches!( - loaded - .iter() - .find(|payload| payload.id() == reminder_id) - .unwrap(), - PersistedPayload::SystemReminder { .. } - )); - let mut restored = MessageTranscript::new().with_own_payloads(loaded); - restored.set_flags_batch(flags); - let projected = restored.visible_model_messages().unwrap(); - assert_eq!( - projected.iter().map(BaseMessage::id).collect::>(), - visible_ids - ); - assert_eq!( - projected - .iter() - .find(|message| message.id() == reminder_id) - .unwrap() - .content(), - encoded_reminder - ); - assert_eq!( - restored.visible_messages().len(), - 1, - "普通消息视图只含摘要,不将 reminder 反写为普通 human" - ); - } +async fn test_flush_persistence_without_backend_is_ok() { + MessageTranscript::new().flush_persistence().await.unwrap(); } -// ── 持久化 flush/barrier ─────────────────────────────────────────────────── - +/// 投影与 rewind 各走一次完整行为:flags 落库、rewind 截断、缓存视图由资源侧维护 +/// (不再由调用方逐条 update + 另行 invalidation)。 #[tokio::test] -async fn test_message_transcript_flush_persistence_makes_appends_visible() { - let store = Arc::new(FaultInjectingStore::new([])); +async fn test_projection_and_rewind_use_single_behaviors() { + let store = mock_store(); let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), "flush-visible".to_string()); + MessageTranscript::new().with_persistence(store.clone(), "projection".to_string()); - transcript.append(make_human("persisted message")); + let first = transcript.append(make_human("first")); + let second = transcript.append(make_ai("second")); + transcript.flush_persistence().await.unwrap(); + assert_eq!( + store.append_calls(), + 1, + "同一窗口内的追加应合并为一次批量写入" + ); + assert_eq!(store.payloads().len(), 2); + + transcript.set_excluded(first, true); transcript.flush_persistence().await.unwrap(); + assert!(store.flags(&first).excluded); - let messages = store.messages(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].content(), "persisted message"); + transcript.rewind_to(first).unwrap(); + transcript.flush_persistence().await.unwrap(); + let messages = persisted_messages(&store); + assert_eq!(messages.len(), 1, "rewind 保留目标本身(KeepThrough)"); + assert_eq!(messages[0].id(), first); + assert!(messages.iter().all(|message| message.id() != second)); + assert_eq!(store.rewind_calls(), 1); } +/// compaction 生命周期:一次完整行为提交(摘要、flags、追加消息),成功才改内存; +/// 失败时磁盘事实保留、内存视图不前进,热态标记为不确定(必须冷重载)。 #[tokio::test] -async fn test_message_transcript_flush_persistence_failure_is_sticky() { - let store = Arc::new(FaultInjectingStore::new([2])); +async fn test_commit_compaction_lifecycle_is_atomic_or_uncertain() { + let store = mock_store(); let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), "flush-error".to_string()); + MessageTranscript::new().with_persistence(store.clone(), "lifecycle".to_string()); - transcript.append(make_human("first")); - transcript.append(make_human("second")); + let first = transcript.append(make_human("原始用户消息")); + transcript.flush_persistence().await.unwrap(); - let error = transcript.flush_persistence().await.unwrap_err(); + let summary = make_human("压缩摘要"); + let summary_id = summary.id(); + transcript + .commit_compaction_lifecycle(CompactionChange { + flag_updates: vec![( + first, + MessageFlags { + excluded: true, + ..Default::default() + }, + )], + appended_messages: vec![summary], + }) + .await + .unwrap(); + assert_eq!(store.compaction_calls(), 1, "compaction 必须一次提交"); + assert!(store.flags(&first).excluded); + assert!(persisted_messages(&store) + .iter() + .any(|message| message.id() == summary_id)); + assert!(transcript.compaction_commit_state().has_committed()); + assert!(transcript.get(summary_id).is_some(), "成功后才改内存"); + + let store2 = mock_store(); + store2.fail_compaction(); + let mut failing = + MessageTranscript::new().with_persistence(store2.clone(), "lifecycle-fail".to_string()); + let id = failing.append(make_human("keep me")); + failing.flush_persistence().await.unwrap(); + let extra = make_human("never applied"); + let extra_id = extra.id(); + let result = failing + .commit_compaction_lifecycle(CompactionChange { + flag_updates: vec![( + id, + MessageFlags { + excluded: true, + ..Default::default() + }, + )], + appended_messages: vec![extra], + }) + .await; + assert!(result.is_err(), "提交失败必须如实返回错误"); + assert!(!store2.flags(&id).excluded, "失败时磁盘事实不得被改"); + assert!(persisted_messages(&store2) + .iter() + .all(|message| message.id() != extra_id)); + assert!(failing.get(extra_id).is_none(), "失败时内存不得前进"); assert!( - error - .to_string() - .contains("deterministic injected error on append 2"), - "flush 应返回 barrier 后首个写入错误: {error}" + failing.compaction_commit_state().is_uncertain(), + "失败后热态必须失效(冷重载才能恢复)" ); - assert_eq!(store.messages().len(), 1, "失败写入不应伪装为成功"); - - let repeated = transcript.flush_persistence().await.unwrap_err(); - assert_eq!(repeated.to_string(), error.to_string()); } +/// rebuild 保留持久化绑定:写入继续到同一个后端,顺序与既有历史都不丢。 +/// +/// rebuild 只替换内存视图(compact 的 canonical 变更由 `commit_compaction_lifecycle` +/// 承担),**不删除**后端已保存的历史——因此这里断言的是「旧消息仍在、新消息按顺序 +/// 追加」,而不是「旧 id 消失」。 #[tokio::test] -async fn test_message_transcript_rebuild_keeps_persistence_writer_alive() { - let store = Arc::new(FaultInjectingStore::new([])); +async fn test_rebuild_keeps_persistence_writer_alive() { + let store = mock_store(); let mut transcript = MessageTranscript::new().with_persistence(store.clone(), "rebuild-writer".to_string()); - - transcript.append(make_human("rebuild 前消息")); + let id = transcript.append(make_human("before rebuild")); transcript.flush_persistence().await.unwrap(); let entries: Vec<(BaseMessage, MessageFlags)> = transcript @@ -637,165 +356,68 @@ async fn test_message_transcript_rebuild_keeps_persistence_writer_alive() { .collect(); let mut rebuilt = transcript.rebuild(entries); - rebuilt.append(make_human("rebuild 后消息")); + rebuilt.append(make_human("appended after rebuild")); rebuilt.flush_persistence().await.unwrap(); - let messages = store.messages(); - assert_eq!(messages.len(), 2, "rebuild 后追加的消息必须持久化"); - assert_eq!(messages[1].content(), "rebuild 后消息"); -} - -#[tokio::test] -async fn test_message_transcript_flush_persistence_returns_first_of_multiple_errors() { - let store = Arc::new(FaultInjectingStore::new([2, 3])); - let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), "multiple-errors".to_string()); - - transcript.append(make_human("first")); - transcript.append(make_human("second")); - transcript.append(make_human("third")); - - let error = transcript.flush_persistence().await.unwrap_err(); - assert!( - error - .to_string() - .contains("deterministic injected error on append 2"), - "同一 barrier 前的多个写入失败必须返回第一个错误: {error}" - ); - assert_eq!(store.messages().len(), 1, "后续操作不得越过首次失败"); - - let repeated = transcript.flush_persistence().await.unwrap_err(); - assert_eq!(repeated.to_string(), error.to_string()); -} - -#[tokio::test] -async fn test_apply_compaction_batch_returns_first_flag_error_and_invalidates_cache() { - let store = Arc::new(FaultInjectingStore::with_failures([], [1, 2], false)); - let transcript = MessageTranscript::new() - .with_persistence(store.clone(), "batch-first-flag-error".to_string()); - - transcript.send_persist(PersistOp::ApplyCompactionBatch { - updates: vec![ - (MessageId::new(), MessageFlags::default()), - (MessageId::new(), MessageFlags::default()), - ], - }); - - let error = transcript.flush_persistence().await.unwrap_err(); - assert!( - error - .to_string() - .contains("deterministic injected error on flag update 1"), - "batch 必须向 barrier 暴露第一个 flag 更新失败: {error}" - ); - assert_eq!(*store.flag_count.lock().unwrap(), 2, "后续更新仍应执行"); + let messages = persisted_messages(&store); assert_eq!( - *store.invalidation_count.lock().unwrap(), - 1, - "batch 必须只失效一次缓存" + messages.len(), + 2, + "rebuild 不重写后端,追加的消息必须持久化" ); + assert_eq!(messages[0].id(), id, "既有历史必须保留原 id 与顺序"); + assert_eq!(messages[1].content(), "appended after rebuild"); } +/// 关闭语义:shutdown 先 flush 积压再退出;退出后 flush 报错(通道关闭)。 #[tokio::test] -async fn test_apply_compaction_batch_returns_cache_invalidation_error() { - let store = Arc::new(FaultInjectingStore::with_failures([], [], true)); - let transcript = - MessageTranscript::new().with_persistence(store, "batch-invalidation-error".to_string()); +async fn test_shutdown_persistence_flushes_pending_and_writer_exits() { + let store = mock_store(); + let mut transcript = + MessageTranscript::new().with_persistence(store.clone(), "shutdown-flush".to_string()); + transcript.append(make_human("last batch before shutdown")); - transcript.send_persist(PersistOp::ApplyCompactionBatch { - updates: vec![(MessageId::new(), MessageFlags::default())], - }); + transcript.shutdown_persistence(); - let error = transcript.flush_persistence().await.unwrap_err(); - assert!( - error - .to_string() - .contains("deterministic injected error on cache invalidation 1"), - "cache invalidation 失败必须向 barrier 暴露: {error}" - ); -} + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while transcript.flush_persistence().await.is_ok() { + tokio::task::yield_now().await; + } + }) + .await + .expect("shutdown 后 writer 应在超时前 flush 并退出"); -#[tokio::test] -async fn test_message_transcript_flush_persistence_without_backend_is_ok() { - MessageTranscript::new().flush_persistence().await.unwrap(); + let messages = persisted_messages(&store); + assert_eq!(messages.len(), 1, "shutdown 前积压的消息必须落库"); + assert_eq!(messages[0].content(), "last batch before shutdown"); } -// ── Drop 优雅关闭(issue 2026-08-05-transcript-drop-loses-final-messages)───────── -// -// Drop 不得 abort writer:abort 会立即取消任务,pending_appends 和通道中未处理的 -// 消息被直接丢弃。改为发送 Shutdown 信号,writer flush 剩余积压后自行退出。 -// writer 是 detached task(持有 store 的独立 Arc),测试用轮询验证最终落库。 - #[tokio::test] async fn test_drop_flushes_pending_appends_to_store() { - // 不调用 flush_persistence 直接 drop:模拟 turn 正常结束,最终回答落在 - // 100ms 批量窗口内未落库的场景。drop 后最后一批必须仍全部落库。 - let store = Arc::new(FaultInjectingStore::new([])); + let store = mock_store(); { let mut transcript = MessageTranscript::new().with_persistence(store.clone(), "drop-flush".to_string()); - transcript.append(make_human("final-answer-1")); - transcript.append(make_human("final-answer-2")); + transcript.append(make_human("flush on drop")); + // writer 持有独立 Arc;Drop 只发 Shutdown,detached 收尾 } - - // writer detached:轮询 store 直到最后一批落库(带超时防挂死) tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - if store.messages().len() >= 2 { - break; - } + while persisted_messages(&store).is_empty() { tokio::task::yield_now().await; } }) .await - .expect("drop 后 writer 应在超时前 flush 剩余消息"); + .expect("Drop 后 writer 必须完成收尾写入"); - let messages = store.messages(); - assert_eq!(messages.len(), 2, "drop 后最后一批消息必须全部落库"); - assert_eq!( - messages[0].content(), - "final-answer-1", - "落库顺序必须与 append 顺序一致" - ); - assert_eq!( - messages[1].content(), - "final-answer-2", - "落库顺序必须与 append 顺序一致" - ); + let messages = persisted_messages(&store); + assert_eq!(messages[0].content(), "flush on drop"); } #[tokio::test] async fn test_drop_without_persistence_is_noop() { - // 无持久化绑定时 drop 不应 panic / 不应有副作用 drop(MessageTranscript::new()); } -#[tokio::test] -async fn test_shutdown_persistence_flushes_pending_and_writer_exits() { - let store = Arc::new(FaultInjectingStore::new([])); - let mut transcript = - MessageTranscript::new().with_persistence(store.clone(), "shutdown-flush".to_string()); - transcript.append(make_human("last batch before shutdown")); - - transcript.shutdown_persistence(); - - // writer 收到 Shutdown 后 flush 剩余并退出;退出后通道关闭, - // flush_persistence 应返回错误(channel closed)——轮询等待该状态 - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while let Ok(()) = transcript.flush_persistence().await { - tokio::task::yield_now().await; - } - }) - .await - .expect("shutdown 后 writer 应在超时前 flush 并退出"); - - let messages = store.messages(); - assert_eq!(messages.len(), 1, "shutdown 前积压的消息必须落库"); - assert_eq!(messages[0].content(), "last batch before shutdown"); -} - -// ── 基础构造 ────────────────────────────────────────────────────────────── - #[test] fn test_new_transcript_is_empty() { let t = MessageTranscript::new(); @@ -1316,80 +938,3 @@ fn test_projection_directive_none_when_not_set() { "JSON 应不含 projection 字段(skip_serializing_if)" ); } -/// The terminal failure must disarm batching: pending payloads are retained for -/// diagnostics, but only a new channel operation may wake the failed writer. -#[tokio::test(start_paused = true)] -async fn test_failed_writer_does_not_rearm_expired_batch_deadline() { - use std::future::Future; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::task::{Context, Poll, Wake, Waker}; - - struct WakeCounter(AtomicUsize); - impl Wake for WakeCounter { - fn wake(self: Arc) { - self.0.fetch_add(1, Ordering::SeqCst); - } - fn wake_by_ref(self: &Arc) { - self.0.fetch_add(1, Ordering::SeqCst); - } - } - - let store = Arc::new(FaultInjectingStore::new([1])); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - let mut writer = Box::pin(super::persistence::run_writer( - store.clone(), - "failed-writer-idle".into(), - rx, - )); - let wakes = Arc::new(WakeCounter(AtomicUsize::new(0))); - let waker = Waker::from(Arc::clone(&wakes)); - tx.send(PersistOp::Append(TranscriptEntry::Message(make_human( - "not persisted", - )))) - .unwrap(); - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - tx.send(PersistOp::Barrier(ack_tx)).unwrap(); - // Poll the actual production future through Append -> failed flush -> Barrier. - // No executor task is attached to this waker, so an expired timer cannot - // automatically repoll the old writer into a hot loop in the test itself. - assert!(matches!( - writer.as_mut().poll(&mut Context::from_waker(&waker)), - Poll::Pending - )); - let first_error = ack_rx.await.unwrap().unwrap_err().to_string(); - assert!(first_error.contains("deterministic injected error on append 1")); - assert!(store.messages().is_empty()); - let before = wakes.0.load(Ordering::SeqCst); - - // The production deadline was registered during the first poll (<=100ms). - // Its std::Instant bookkeeping need not change: no second writer poll occurs - // before this assertion. A subsequent virtual timer ensures the time driver - // has processed the earlier deadline before we inspect the counter. - tokio::time::advance(std::time::Duration::from_millis(150)).await; - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - assert_eq!( - wakes.0.load(Ordering::SeqCst), - before, - "a terminally failed writer must wait for operations, not an expired batching timer" - ); - - // Failure remains sticky; later Barrier and Shutdown still make progress. - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - tx.send(PersistOp::Barrier(ack_tx)).unwrap(); - assert!(matches!( - writer.as_mut().poll(&mut Context::from_waker(&waker)), - Poll::Pending - )); - assert_eq!(ack_rx.await.unwrap().unwrap_err().to_string(), first_error); - assert_eq!( - *store.append_count.lock().unwrap(), - 1, - "terminal failure must not retry a possibly partial batch" - ); - tx.send(PersistOp::Shutdown).unwrap(); - assert!(matches!( - writer.as_mut().poll(&mut Context::from_waker(&waker)), - Poll::Ready(()) - )); - assert!(tx.is_closed(), "Shutdown must drop the production receiver"); -} diff --git a/peri-agent/src/thread/mod.rs b/peri-agent/src/thread/mod.rs index 4a7e90963..d811301ed 100644 --- a/peri-agent/src/thread/mod.rs +++ b/peri-agent/src/thread/mod.rs @@ -1,11 +1,14 @@ //! Thread 持久化 re-export。 //! -//! 契约类型(`ThreadStore` trait / `ThreadMeta` / `ThreadId` 等)已下沉 peri-acp-types; -//! 存储实现(`SqliteThreadStore` / `FilesystemThreadStore`)已迁入 peri-resources。 -//! 本模块仅保留 re-export,保证既有引用路径(`peri_agent::thread::*`)不变。 +//! 契约类型(`ThreadMeta` / `ThreadId` 等)与业务类型(`CompactionChange` / +//! `MessageFlags`)位于 peri-acp-types;存储实现(`SqliteThreadStore` / +//! `FilesystemThreadStore`)位于 peri-resources,本模块不再 re-export 裸存储 +//! trait 与实现——消费侧一律经 `SessionResources` 门面。 +//! +//! 仍保留本模块是因为部分 Agent 内部代码按 `crate::thread::*` 引用契约类型; +//! 这里只转发契约,不转发存储。 -pub use peri_acp_types::store::{CompactionLifecycle, MessageFlags, ThreadStore}; +pub use peri_acp_types::store::{CompactionChange, MessageFlags}; pub use peri_acp_types::thread::{ AgentStatus, CancelPolicy, ThreadId, ThreadMeta, ThreadMetaParseError, }; -pub use peri_resources::sessions::{FilesystemThreadStore, SqliteThreadStore}; diff --git a/peri-controller/src/controller.rs b/peri-controller/src/controller.rs index 9e5f24eab..0af6f131d 100644 --- a/peri-controller/src/controller.rs +++ b/peri-controller/src/controller.rs @@ -1,10 +1,10 @@ //! Controller 层控制面宿主(`docs/top-level.md` §6)。 //! -//! 控制面五步:lite params → pick Resources → pick Runtime → run Session → pop events。 +//! 控制面五步:lite params → 会话资源句柄 → pick Runtime → run Session → pop events。 //! - [`LiteParams`]:session 标识 / agent 定义引用 / cwd / 初始输入 / 初始消息与 //! 工具集装载(§6) -//! - [`Controller::pick_resources`] / [`Controller::pick_runtime`]:从注入的 -//! Resources / Runtime 取上下文(其余上下文由 Controller 从 Resources 组装注入) +//! - [`Controller::sessions`] / [`Controller::pick_runtime`]:取业务侧会话资源句柄 +//! 与 Runtime 编排器(Controller 只消费业务句柄,不持有部署关闭权) //! - [`Controller::run_session`]:经 Runtime 查映射拿 [`SessionHandle`] 发起执行 //! (Controller → Runtime 边,§6 run Session) //! - [`Controller::join_session`] / [`Controller::destroy_session`] / [`Controller::session_ids`]: @@ -27,9 +27,8 @@ use peri_acp_types::event::{EventMessage, ExecutorEvent}; use peri_acp_types::identity::{CancelRequest, EventEnvelope}; use peri_acp_types::messages::MessageContent; use peri_acp_types::runtime::UnstampedEvent; -use peri_acp_types::store::ThreadStore; +use peri_acp_types::session_resources::SessionResources; use peri_agent::tools::ToolDefinition; -use peri_resources::Resources; use peri_runtime::Runtime; use tokio::sync::{broadcast, mpsc}; @@ -161,19 +160,16 @@ impl Subscription { /// (Resources 侧打开后传入) /// - Runtime 编排器(pick Runtime 的目标源):部署装配点经 /// [`Controller::with_runtime`] 注入;缺省为空实例(生产接线随 L5 落地) -/// - Resources 门面(pick Resources 的目标源):部署装配点经 -/// [`Controller::with_resources`] 注入;缺省未注入(None) /// - 装配注入端口(pick 目标源):mcp 池 / cron 调度器 / 工具检索索引 / /// LSP 服务器配置,宿主装配点构造具体实现后 upcast 注入(3.0 批 2 波 2; /// 消费方为执行装配,随 L5 落位) /// - 事件协议化前分支(弹出队列 + 订阅广播) pub struct Controller { - /// 持久化存储通道(等价包装 `ThreadStore`,不改变其 trait 语义)。 - sessions: Arc, + /// 会话资源通道(会话行为门面):业务侧经 [`Controller::sessions`] 取得,Controller + /// 只转发句柄,不另存数据或执行注册表。 + sessions: Arc, /// 多 session 编排器;仅在消费 self 的装配阶段替换,运行时登记状态归 Runtime。 runtime: Arc, - /// 外部系统资源门面(§5;以 context 形式提供给 Controller)。 - resources: Option, /// MCP 客户端池端口(pick 目标源;缺省未注入)。 mcp_pool: Option>, /// Cron 调度器端口(pick 目标源;缺省未注入)。 @@ -195,13 +191,12 @@ impl Controller { /// /// Runtime / Resources / 装配注入端口由部署装配点(Resources 打开后、 /// Runtime 建立后)经对应 `with_*` 注入;本构造函数保持既有调用点兼容。 - pub fn new(sessions: Arc) -> Self { + pub fn new(sessions: Arc) -> Self { let (events_tx, events_rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY); let (subscribers, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); Self { sessions, runtime: Arc::new(Runtime::new()), - resources: None, mcp_pool: None, cron_scheduler: None, tool_search: None, @@ -218,13 +213,6 @@ impl Controller { self } - /// 注入 Resources 门面(pick Resources 的目标源;部署装配点在 - /// `Resources::open()` 后调用)。 - pub fn with_resources(mut self, resources: Resources) -> Self { - self.resources = Some(resources); - self - } - /// 注入 MCP 客户端池端口(pick MCP 池的目标源;宿主装配点构造具体 /// `McpClientPool` 后 upcast 注入)。 pub fn with_mcp_pool( @@ -286,19 +274,12 @@ impl Controller { /// Controller 侧 sessions 访问通道。 /// - /// 返回存储句柄供业务操作使用;语义与 `ThreadStore` 完全等价,仅改变访问路径。 - pub fn sessions(&self) -> Arc { + /// 返回会话资源门面(消费侧唯一的会话行为契约):Controller 只做转发,不解释 + /// 存储语义、不暴露事务或执行注册表。 + pub fn sessions(&self) -> Arc { Arc::clone(&self.sessions) } - /// pick Resources(控制面第二步):取注入的 Resources 门面。 - /// - /// 未注入(部署装配点尚未提供)时返回 `None`;组装注入上下文的职责 - /// 随 L5 装配落位。 - pub fn pick_resources(&self) -> Option { - self.resources.clone() - } - /// pick Runtime(控制面第三步):取注入的 Runtime 编排器引用。 pub fn pick_runtime(&self) -> Arc { Arc::clone(&self.runtime) diff --git a/peri-controller/src/controller_test.rs b/peri-controller/src/controller_test.rs index 70fd92bbf..8364ab0fd 100644 --- a/peri-controller/src/controller_test.rs +++ b/peri-controller/src/controller_test.rs @@ -16,9 +16,8 @@ use peri_acp_types::identity::{ SessionSeq, }; use peri_acp_types::messages::MessageContent; -use peri_acp_types::store::ThreadStore; +use peri_acp_types::session_resources::SessionResources; use peri_acp_types::thread::CancelPolicy; -use peri_resources::sessions::FilesystemThreadStore; use peri_runtime::{Runtime, SessionHandle, UnstampedEvent}; use super::{AgentRef, Controller, LiteParams}; @@ -117,16 +116,20 @@ fn ev(turn_id: &str, agent_id: &str) -> UnstampedEvent { } } -/// 构造临时 ThreadStore(Filesystem,与 peri-acp 既有测试同模式)。 -fn temp_store() -> Arc { +/// 门面夹具:临时目录里的真实 SQLite 门面(Controller 只转发句柄,不解释存储语义)。 +async fn temp_facade() -> Arc { let tmp = tempfile::tempdir().unwrap(); - Arc::new(FilesystemThreadStore::new(tmp.path().join("threads"))) + let facade = + peri_resources::sessions::SessionResourcesImpl::open(tmp.path().join("threads.db")) + .await + .unwrap(); + Arc::new(facade) } // ─── lite params(§6 控制面第一步) ────────────────────────────────────────── -#[test] -fn lite_params_construction() { +#[tokio::test] +async fn lite_params_construction() { let params = LiteParams::new( "session-1", AgentRef::new("default"), @@ -160,23 +163,11 @@ fn lite_params_construction() { assert_eq!(injected.tools[0].name, "search"); } -// ─── pick Resources / pick Runtime(§6 控制面第二/三步) ────────────────────── - -#[test] -fn pick_resources_none_until_injected() { - let controller = Controller::new(temp_store()); - assert!( - controller.pick_resources().is_none(), - "未注入时 Resources 为 None" - ); - - // 注入后可取(Resources 为 Clone 门面;此处用默认构造会失败——用 None 语义验证) - // 实际注入测试见 pick_runtime_and_resources_injection。 -} +// ─── 会话资源句柄 / pick Runtime(§6 控制面第二/三步) ──────────────────────── -#[test] -fn pick_runtime_injection_replaces_default() { - let controller = Controller::new(temp_store()); +#[tokio::test] +async fn pick_runtime_injection_replaces_default() { + let controller = Controller::new(temp_facade().await); let injected = Arc::new(Runtime::new()); let controller = controller.with_runtime(Arc::clone(&injected)); assert!( @@ -192,7 +183,7 @@ async fn run_session_forwards_via_runtime_to_handle() { let handle = MockHandle::new(); let runtime = Arc::new(Runtime::new()); runtime.register("s1", Arc::clone(&handle)).unwrap(); - let controller = Controller::new(temp_store()).with_runtime(runtime); + let controller = Controller::new(temp_facade().await).with_runtime(runtime); controller.run_session("s1").await.unwrap(); @@ -201,7 +192,7 @@ async fn run_session_forwards_via_runtime_to_handle() { #[tokio::test] async fn run_session_unknown_session_typed_error() { - let controller = Controller::new(temp_store()); + let controller = Controller::new(temp_facade().await); let err = controller.run_session("missing").await.unwrap_err(); assert!( matches!(&err, super::ControllerError::RunFailed(s, _) if s == "missing"), @@ -216,7 +207,7 @@ async fn cancel_forwards_triple_to_handle() { let handle = MockHandle::new(); let runtime = Arc::new(Runtime::new()); runtime.register("s1", Arc::clone(&handle)).unwrap(); - let controller = Controller::new(temp_store()).with_runtime(runtime); + let controller = Controller::new(temp_facade().await).with_runtime(runtime); let req = CancelRequest::new( AttemptIdentity::new("s1", SessionEpoch::initial(), "turn-7", AttemptId::new()), @@ -235,7 +226,7 @@ async fn cancel_forwards_triple_to_handle() { #[tokio::test] async fn cancel_unknown_session_typed_error() { - let controller = Controller::new(temp_store()); + let controller = Controller::new(temp_facade().await); let req = CancelRequest::new( AttemptIdentity::new( "missing", @@ -256,7 +247,7 @@ async fn cancel_unknown_session_typed_error() { #[tokio::test] async fn publish_pop_and_subscribe_events() { - let controller = Controller::new(temp_store()); + let controller = Controller::new(temp_facade().await); let mut sub = controller.subscribe(); let e1 = EventEnvelope::new( @@ -296,7 +287,7 @@ async fn publish_pop_and_subscribe_events() { #[tokio::test] async fn bypass_consumer_subscribes_same_branch() { - let controller = Controller::new(temp_store()); + let controller = Controller::new(temp_facade().await); // 主订阅(ACP 协议化)+ 旁路订阅(Langfuse bridge 形态:旁路消费者不参与业务链路) let mut acp = controller.subscribe(); let mut observer = controller.subscribe(); @@ -326,13 +317,13 @@ async fn bypass_consumer_subscribes_same_branch() { // ─── sessions 存储通道(既有访问路径不回归) ─────────────────────────────────── -#[test] -fn sessions_channel_preserved() { - let store = temp_store(); - let controller = Controller::new(Arc::clone(&store)); +#[tokio::test] +async fn sessions_channel_preserved() { + let facade = temp_facade().await; + let controller = Controller::new(Arc::clone(&facade)); assert!( - Arc::ptr_eq(&controller.sessions(), &store), - "sessions 通道保持同一存储" + Arc::ptr_eq(&controller.sessions(), &facade), + "sessions 通道保持同一门面句柄" ); } @@ -343,7 +334,7 @@ async fn session_enumeration_reflects_runtime_map() { let runtime = Arc::new(Runtime::new()); runtime.register("s1", MockHandle::new()).unwrap(); runtime.register("s2", MockHandle::new()).unwrap(); - let controller = Controller::new(temp_store()).with_runtime(runtime); + let controller = Controller::new(temp_facade().await).with_runtime(runtime); let mut ids = controller.session_ids(); ids.sort(); @@ -364,7 +355,7 @@ async fn join_session_forwards_deadline_result() { let handle = MockHandle::new(); let runtime = Arc::new(Runtime::new()); runtime.register("s1", Arc::clone(&handle)).unwrap(); - let controller = Controller::new(temp_store()).with_runtime(runtime); + let controller = Controller::new(temp_facade().await).with_runtime(runtime); assert!( controller @@ -391,7 +382,7 @@ async fn destroy_session_orchestrates_phases_and_publishes_drained() { let handle = MockHandle::with_drained(vec![ev("t1", "a1"), ev("t2", "a1")]); let runtime = Arc::new(Runtime::new()); runtime.register("s1", Arc::clone(&handle)).unwrap(); - let controller = Controller::new(temp_store()).with_runtime(runtime); + let controller = Controller::new(temp_facade().await).with_runtime(runtime); let mut sub = controller.subscribe(); let drained = controller @@ -431,7 +422,7 @@ async fn destroy_session_orchestrates_phases_and_publishes_drained() { #[tokio::test] async fn destroy_session_unknown_typed_error() { - let controller = Controller::new(temp_store()); + let controller = Controller::new(temp_facade().await); let err = controller .destroy_session("missing", Duration::from_secs(5)) .await @@ -444,12 +435,12 @@ async fn destroy_session_unknown_typed_error() { // ─── 消息/工具注入面(submit_input 经 Runtime 透传) ─────────────────────────── -#[test] -fn submit_input_forwards_via_runtime_to_handle() { +#[tokio::test] +async fn submit_input_forwards_via_runtime_to_handle() { let handle = MockHandle::new(); let runtime = Arc::new(Runtime::new()); runtime.register("s1", Arc::clone(&handle)).unwrap(); - let controller = Controller::new(temp_store()).with_runtime(runtime); + let controller = Controller::new(temp_facade().await).with_runtime(runtime); controller .submit_input("s1", MessageContent::text("hi")) @@ -474,7 +465,7 @@ fn submit_input_forwards_via_runtime_to_handle() { #[tokio::test] async fn subscription_register_and_unsubscribe() { - let controller = Controller::new(temp_store()); + let controller = Controller::new(temp_facade().await); let mut sub = controller.subscribe(); // 注册:收到 publish 事件 diff --git a/peri-middlewares/src/assembly_test.rs b/peri-middlewares/src/assembly_test.rs index 390731b91..0b7993743 100644 --- a/peri-middlewares/src/assembly_test.rs +++ b/peri-middlewares/src/assembly_test.rs @@ -323,7 +323,7 @@ fn base_context() -> AssemblyContext { bg_event_tx, on_bg_complete, langfuse_bridge: None, - thread_store: None, + session_resources: None, parent_thread_id: None, register_runtime: None, deregister_runtime: None, @@ -1320,7 +1320,6 @@ fn workflow_context_with_disabled(disabled: &[&str]) -> WorkflowAgentContext { permission_mode: None, frozen_date: None, frozen_language: None, - thread_store: None, progress_tx: None, subagent_ctx_builder: None, agent_prompt_builder: prompt_builder, @@ -1778,7 +1777,7 @@ async fn test_stage_completion_reminders_share_assembled_task_manager() { lsp_pool: None, workflow_executor: None, workflow_middleware: None, - thread_store: None, + session_resources: None, thread_id: None, model_name: "test".into(), provider_name: "test".into(), diff --git a/peri-middlewares/src/plugin/loader.rs b/peri-middlewares/src/plugin/loader.rs index 3b5cd79f4..ddd21bd48 100644 --- a/peri-middlewares/src/plugin/loader.rs +++ b/peri-middlewares/src/plugin/loader.rs @@ -586,14 +586,40 @@ enum McpConfigPolicy { Strict, } +/// 插件清单缺失时的处置策略。 +/// +/// 既有聚合路径允许从 marketplace 清单**生成合成 `plugin.json`**(写插件缓存目录); +/// 会话准备路径(lease 之前只读)不得产生任何写副作用——缺失即失败并定位插件, +/// 修复只发生在授权后的插件管理命令/交互路径。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManifestPolicy { + /// 既有行为:清单缺失时尝试生成合成清单(写插件缓存)。 + Repair, + /// 严格只读:清单缺失直接失败,不写、不跳过、不缓存。 + Readonly, +} + pub fn load_plugins(installed: &InstalledPlugins) -> Result, LoaderError> { - load_plugins_with_policy(installed, McpConfigPolicy::Lenient) + load_plugins_with_policies(installed, McpConfigPolicy::Lenient, ManifestPolicy::Repair) } -/// 装配已安装插件;`policy` 决定非法 MCP 配置是失败还是降级。 -fn load_plugins_with_policy( +/// 严格只读装配:清单缺失/非法一律以可定位的具体错误失败,不生成合成清单。 +pub(crate) fn load_plugins_readonly( installed: &InstalledPlugins, - policy: McpConfigPolicy, +) -> Result, LoaderError> { + load_plugins_with_policies( + installed, + McpConfigPolicy::Lenient, + ManifestPolicy::Readonly, + ) +} + +/// 装配已安装插件;`mcp_policy` 决定非法 MCP 配置是失败还是降级, +/// `manifest_policy` 决定缺失清单是否允许合成修复(写插件缓存)。 +fn load_plugins_with_policies( + installed: &InstalledPlugins, + mcp_policy: McpConfigPolicy, + manifest_policy: ManifestPolicy, ) -> Result, LoaderError> { let mut result = Vec::new(); @@ -603,9 +629,11 @@ fn load_plugins_with_policy( Err(error) => { let manifest_path = plugin_manifest_path(&plugin.install_path); // 已存在但非法的清单不允许被合成清单覆盖修复,也不当作未安装: - // 严格路径直接报错,宽容路径记录诊断后跳过该插件。 + // 严格路径(MCP 启动 / 只读准备)直接报错,宽容路径记录诊断后跳过该插件。 if manifest_path.exists() { - if policy == McpConfigPolicy::Strict { + if mcp_policy == McpConfigPolicy::Strict + || manifest_policy == ManifestPolicy::Readonly + { return Err(error); } warn!( @@ -615,8 +643,16 @@ fn load_plugins_with_policy( ); continue; } - // 清单文件缺失:允许从 marketplace manifest 生成合成清单 + // 清单文件缺失:只读准备路径以可定位的具体错误失败(不写缓存、不静默 + // 跳过);既有路径允许从 marketplace manifest 生成合成清单 // (兼容修复前安装的 LSP 插件),生成结果同样按严格语义解析。 + if manifest_policy == ManifestPolicy::Readonly { + return Err(LoaderError::ManifestLoadFailed(format!( + "{}: plugin manifest missing at {}", + plugin.name, + manifest_path.display() + ))); + } if !try_generate_synthetic_manifest_fallback( &plugin.install_path, &plugin.name, @@ -631,7 +667,7 @@ fn load_plugins_with_policy( match load_manifest(&plugin.install_path) { Ok(m) => m, Err(error) => { - if policy == McpConfigPolicy::Strict { + if mcp_policy == McpConfigPolicy::Strict { return Err(error); } warn!( @@ -650,7 +686,7 @@ fn load_plugins_with_policy( let agents_dirs = extract_agents_paths(&manifest, &plugin.install_path); let mcp_servers = match extract_mcp_servers(&manifest, &plugin.install_path) { Ok(servers) => servers, - Err(error) => match policy { + Err(error) => match mcp_policy { McpConfigPolicy::Strict => return Err(error), McpConfigPolicy::Lenient => { warn!( @@ -756,12 +792,22 @@ pub(crate) fn load_enabled_plugins_for_mcp( claude_dir: &Path, cwd: Option<&Path>, ) -> Result, LoaderError> { - load_plugins_with_policy( + load_plugins_with_policies( &select_enabled_plugins(claude_dir, cwd)?, McpConfigPolicy::Strict, + ManifestPolicy::Repair, ) } +/// 会话准备专用严格只读入口:启用选择与装配复用同一套规则,但清单缺失/非法 +/// 一律失败并定位插件——不生成合成清单、不写插件缓存、不静默跳过。 +pub(crate) fn load_enabled_plugins_readonly( + claude_dir: &Path, + cwd: Option<&Path>, +) -> Result, LoaderError> { + load_plugins_readonly(&select_enabled_plugins(claude_dir, cwd)?) +} + pub struct PluginCommandProvider { entries: Vec, } @@ -822,6 +868,25 @@ pub fn load_enabled_plugins_aggregated(claude_dir: &Path, cwd: Option<&Path>) -> } }; + aggregate_plugin_data(plugins) +} + +/// 会话准备专用只读聚合入口:形状与宽容聚合一致,但失败直接上抛 +/// (清单缺失/非法定位到具体插件),不以空结果伪装成功。 +/// +/// 准备阶段(lease 之前)只允许读——本入口不生成合成清单、不写插件缓存。 +pub fn load_enabled_plugins_aggregated_readonly( + claude_dir: &Path, + cwd: Option<&Path>, +) -> Result { + Ok(aggregate_plugin_data(load_enabled_plugins_readonly( + claude_dir, cwd, + )?)) +} + +/// 插件聚合(skills / MCP / agent / 命令 / hooks / LSP)单一实现: +/// 宽容聚合与只读聚合共用,避免两条路径各自漂移。 +fn aggregate_plugin_data(plugins: Vec) -> PluginLoadResult { let all_skill_roots: Vec = plugins .iter() .flat_map(|p| p.skills_roots.clone()) diff --git a/peri-middlewares/src/plugin/loader_test.rs b/peri-middlewares/src/plugin/loader_test.rs index 987fee756..9cb2addf7 100644 --- a/peri-middlewares/src/plugin/loader_test.rs +++ b/peri-middlewares/src/plugin/loader_test.rs @@ -1643,3 +1643,111 @@ fn test_system_mcp_plugin_lenient_aggregate_keeps_other_capabilities() { assert!(!aggregated.all_hooks.is_empty(), "插件的其它能力必须保留"); assert_eq!(aggregated.all_hooks[0].plugin_name, "lenient"); } + +/// 目录树快照(相对 root 的路径集合),用于证明只读发现没有写副作用。 +fn snapshot_paths(root: &Path) -> Vec { + fn walk(dir: &Path, root: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, root, out); + } else { + out.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf()); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out +} + +/// 只读准备发现:清单缺失必须失败并定位插件,不得生成合成清单(无写副作用)。 +#[test] +fn test_readonly_discovery_missing_manifest_fails_without_repair() { + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "ghost", None); + let manifest_path = plugin_dir.join(".claude-plugin").join("plugin.json"); + std::fs::remove_file(&manifest_path).unwrap(); + + let before = snapshot_paths(&plugin_dir); + let error = load_enabled_plugins_aggregated_readonly(&claude_home, None) + .expect_err("readonly discovery must not silently skip a plugin with a missing manifest"); + let message = error.to_string(); + assert!( + message.contains("ghost") && message.contains(&manifest_path.display().to_string()), + "error must locate the offending plugin and manifest path: {message}" + ); + assert!( + !manifest_path.exists(), + "readonly discovery must not synthesize a manifest" + ); + assert_eq!( + before, + snapshot_paths(&plugin_dir), + "readonly discovery must not write plugin files" + ); +} + +/// 同一布局下宽容聚合保持既有产品行为(跳过该插件、返回空结果),与只读路径明确分离。 +#[test] +fn test_lenient_aggregate_still_skips_missing_manifest() { + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "ghost", None); + std::fs::remove_file(plugin_dir.join(".claude-plugin").join("plugin.json")).unwrap(); + + let aggregated = load_enabled_plugins_aggregated(&claude_home, None); + assert!( + aggregated.plugins.is_empty(), + "宽容聚合仍跳过缺失清单的插件,不因准备路径严格化而改变" + ); +} + +/// 非法清单在只读路径上以可定位错误失败,不被当作用户未安装而跳过。 +#[test] +fn test_readonly_discovery_invalid_manifest_fails() { + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + let plugin_dir = install_plugin(&claude_home, "broken", None); + std::fs::write( + plugin_dir.join(".claude-plugin").join("plugin.json"), + "{ not json", + ) + .unwrap(); + + let error = load_enabled_plugins_aggregated_readonly(&claude_home, None) + .expect_err("readonly discovery must fail on an invalid manifest"); + let message = error.to_string(); + assert!( + message.contains("broken") && message.contains(&plugin_dir.display().to_string()), + "error must locate the offending plugin: {message}" + ); +} + +/// 有效插件:只读聚合成功、无写副作用,且同输入重复调用结果一致。 +#[test] +fn test_readonly_discovery_is_side_effect_free_and_repeatable() { + let dir = tempdir().unwrap(); + let claude_home = dir.path().join(".claude-test"); + install_plugin(&claude_home, "ok", None); + + let before = snapshot_paths(&claude_home); + let first = load_enabled_plugins_aggregated_readonly(&claude_home, None).unwrap(); + let second = load_enabled_plugins_aggregated_readonly(&claude_home, None).unwrap(); + + assert_eq!(first.plugins.len(), 1); + assert_eq!(first.plugins[0].name, second.plugins[0].name); + assert_eq!(first.all_commands.len(), second.all_commands.len()); + assert_eq!(first.all_skill_roots.len(), second.all_skill_roots.len()); + assert_eq!(first.all_agent_dirs.len(), second.all_agent_dirs.len()); + assert_eq!( + before, + snapshot_paths(&claude_home), + "readonly discovery must not write any file under the plugin home" + ); +} diff --git a/peri-middlewares/src/plugin/mod.rs b/peri-middlewares/src/plugin/mod.rs index 92da05862..02c49f23d 100644 --- a/peri-middlewares/src/plugin/mod.rs +++ b/peri-middlewares/src/plugin/mod.rs @@ -21,9 +21,10 @@ pub use installer::{ InstallerError, PluginUpdateInfo, }; pub use loader::{ - load_enabled_plugins, load_enabled_plugins_aggregated, plugin_route_entries, CommandEntry, - CommandProvider, CommandSource, LoadedPlugin, LoaderError, PluginCommandHandler, - PluginCommandProvider, PluginLoadResult, + load_enabled_plugins, load_enabled_plugins_aggregated, + load_enabled_plugins_aggregated_readonly, plugin_route_entries, CommandEntry, CommandProvider, + CommandSource, LoadedPlugin, LoaderError, PluginCommandHandler, PluginCommandProvider, + PluginLoadResult, }; pub use marketplace::{ parse_marketplace_input, AvailablePlugin, MarketplaceEntry, MarketplaceError, diff --git a/peri-middlewares/src/subagent/tool/configuration.rs b/peri-middlewares/src/subagent/tool/configuration.rs index 17aeff3f0..64e281972 100644 --- a/peri-middlewares/src/subagent/tool/configuration.rs +++ b/peri-middlewares/src/subagent/tool/configuration.rs @@ -118,8 +118,12 @@ impl super::SubAgentTool { self } - pub fn with_thread_store(mut self, store: Arc) -> Self { - self.host.thread_store = Some(store); + /// 注入会话资源门面(唯一会话行为入口;旧 `with_thread_store` 已随迁移退出)。 + pub fn with_session_resources( + mut self, + store: Arc, + ) -> Self { + self.host.session_resources = Some(store); self } @@ -128,6 +132,18 @@ impl super::SubAgentTool { self } + /// 注入本会话 root 的执行所有权(child 保存的前置证明)。 + /// + /// 生产路径经 `parent_session` 的 host 携带;工具自身 host 只在测试/遗留回退里 + /// 显式注入,且必须与 `session_resources`、`parent_thread_id` 出自同一条会话。 + pub fn with_execution_owner( + mut self, + lease: Arc, + ) -> Self { + self.host.execution_owner = Some(lease); + self + } + #[allow(clippy::type_complexity)] pub fn with_register_runtime( mut self, diff --git a/peri-middlewares/src/subagent/tool/define.rs b/peri-middlewares/src/subagent/tool/define.rs index 39919ff17..98f1097a8 100644 --- a/peri-middlewares/src/subagent/tool/define.rs +++ b/peri-middlewares/src/subagent/tool/define.rs @@ -22,7 +22,7 @@ const AGENT_DESCRIPTION: &str = include_str!("descriptions/agent.md"); /// /// 创建(建 thread / 建 session / 运行 / 收尾)统一经 /// [`SessionFactory::spawn_subagent`](peri_agent::session::subagent::SessionFactory::spawn_subagent)(peri-agent `SessionFactory` 统一入口)。父侧运行时通道 -/// (thread_store / task_manager / bg 事件 / register / deregister / frozen +/// (session_resources / task_manager / bg 事件 / register / deregister / frozen /// 回退值)聚合在 [`SubagentHost`];生产路径经 `parent_session` 的 host 读取 /// (builder 在主 session 创建后注入),测试/遗留路径经 `with_*` 直接注入 /// tool 的 host 回退。 @@ -174,7 +174,7 @@ impl BaseTool for SubAgentTool { is_fork, } = InvocationArgs::parse(&input, &self.parent_cwd); - // host 提前获取(resume 校验需要 thread_store;R-M2 分支优先级) + // host 提前获取(resume 校验需要会话资源门面;R-M2 分支优先级) let host = self.host(); // ── resume 分支(优先于 bg / fork / agent-def,R-M2)── @@ -281,7 +281,7 @@ impl BaseTool for SubAgentTool { let spawned = self.spawn(config).await?; // Interrupted 语义与迁移前一致;文本携带 child_thread_id——主 agent 凭此 - // 找回执行现场(thread_store 为 None 的测试路径同样带 id:spawned.child_thread_id 恒可用) + // 找回执行现场(会话资源门面为 None 的测试路径同样带 id:spawned.child_thread_id 恒可用) if spawned.interrupted { return Ok(format!( "child_thread_id: {}\nSub-agent execution was interrupted, resume with Agent(resume_thread_id: {})", @@ -289,7 +289,7 @@ impl BaseTool for SubAgentTool { )); } - if host.thread_store.is_some() { + if host.session_resources.is_some() { Ok(format!( "child_thread_id: {} {}", diff --git a/peri-middlewares/src/subagent/tool/execute_bg.rs b/peri-middlewares/src/subagent/tool/execute_bg.rs index cd49d37c6..16ea2c2d3 100644 --- a/peri-middlewares/src/subagent/tool/execute_bg.rs +++ b/peri-middlewares/src/subagent/tool/execute_bg.rs @@ -32,7 +32,7 @@ impl super::SubAgentTool { if host.task_manager.is_none() { return Err("Background tasks not available: no task manager configured".into()); } - let thread_store = host.thread_store.clone(); + let session_resources = host.session_resources.clone(); let spawned = if is_fork { // fork 路径(bg fork):父消息注入 + fork directive 包装; @@ -120,7 +120,7 @@ impl super::SubAgentTool { .task_id .clone() .unwrap_or_else(|| "bg-unknown".to_string()); - if thread_store.is_some() { + if session_resources.is_some() { Ok(format!( "Background task {} started (thread: {}). You will be notified when it completes. \ You can continue with other tasks in the meantime.", diff --git a/peri-middlewares/src/subagent/tool/execute_fork.rs b/peri-middlewares/src/subagent/tool/execute_fork.rs index 8378ac699..01d165fa3 100644 --- a/peri-middlewares/src/subagent/tool/execute_fork.rs +++ b/peri-middlewares/src/subagent/tool/execute_fork.rs @@ -66,7 +66,7 @@ impl super::SubAgentTool { )); } - // 结果格式:thread_store 存在时带 child_thread_id 前缀(与迁移前一致) + // 结果格式:会话资源门面存在时带 child_thread_id 前缀(与迁移前一致) let text = extract_last_ai_text(&spawned.session); let output = peri_agent::agent::react::AgentOutput { text, @@ -76,7 +76,7 @@ impl super::SubAgentTool { block_continue: None, }; let result_text = format_subagent_result(&output); - if host.thread_store.is_some() { + if host.session_resources.is_some() { Ok(format!( "child_thread_id: {}\n{}", spawned.child_thread_id, result_text diff --git a/peri-middlewares/src/subagent/tool/execute_resume.rs b/peri-middlewares/src/subagent/tool/execute_resume.rs index dc854612e..a819849f3 100644 --- a/peri-middlewares/src/subagent/tool/execute_resume.rs +++ b/peri-middlewares/src/subagent/tool/execute_resume.rs @@ -32,7 +32,7 @@ impl super::SubAgentTool { /// 返回文本与 spawn 路径一致: /// - Background → 启动确认(task_id 文本,execute_bg.rs 同款;thread 恒存在 → 带 thread) /// - interrupted → slice 1 格式(`child_thread_id: {id}` + 恢复提示) - /// - 完成 → `child_thread_id: {id}\n{result}`(thread_store 恒存在) + /// - 完成 → `child_thread_id: {id}\n{result}`(会话资源门面恒存在) /// - 错误 → 原样 Err(agent 层已带 `resume_subagent:` 前缀) pub(crate) async fn invoke_resume( &self, @@ -57,8 +57,8 @@ impl super::SubAgentTool { } } // 无 live receiver 时才进入磁盘恢复路径。 - let thread_store = host.thread_store.clone().ok_or( - "resume_subagent: thread store required (resume_thread_id needs a persisted thread)", + let session_resources = host.session_resources.clone().ok_or( + "resume_subagent: session resources required (resume_thread_id needs a persisted thread)", )?; // 双保险(review MEDIUM-1):bg resume 在 agent 层注册失败会回滚 status, @@ -75,8 +75,8 @@ impl super::SubAgentTool { } // 1. load_meta 取 title(决定工具集恢复路径,issue 决策 11) - let meta = thread_store - .load_meta(&thread_id) + let meta = session_resources + .load_session_meta(&thread_id) .await .map_err(|_| format!("resume_subagent: thread not found: {}", thread_id))?; if meta.agent_status.is_active() { @@ -151,7 +151,7 @@ impl super::SubAgentTool { llm, tools, tool_filter, - thread_store, + session_resources, cwd, ); diff --git a/peri-middlewares/src/subagent/tool/spawn_context.rs b/peri-middlewares/src/subagent/tool/spawn_context.rs index f15810bb5..d783f6b0b 100644 --- a/peri-middlewares/src/subagent/tool/spawn_context.rs +++ b/peri-middlewares/src/subagent/tool/spawn_context.rs @@ -1,11 +1,11 @@ //! Lifecycle adapters and creation/resume intent for the Agent-owned factory. use super::fire_subagent_lifecycle_hooks_static; use crate::tool_search::ExecuteExtraToolResolver; +use peri_acp_types::session_resources::SessionResources; use peri_agent::session::subagent::{ SessionFactory, SubagentLifecycleStart, SubagentLifecycleStop, SubagentResumeConfig, SubagentRunMode, SubagentSpawnConfig, SubagentSpawned, }; -use peri_agent::thread::ThreadStore; use peri_agent::{agent::react::ReactLLM, messages::BaseMessage, tools::BaseTool}; use std::sync::Arc; @@ -101,7 +101,8 @@ impl super::SubAgentTool { compact_config: None, context_budget: None, compact_llm: None, - thread_store: host.thread_store.clone(), + session_resources: host.session_resources.clone(), + execution_owner: host.execution_owner.clone(), event_handler: self.event_handler.clone(), bg_event_sender: host.bg_event_sender.clone(), task_manager: host.task_manager.clone(), @@ -152,7 +153,7 @@ impl super::SubAgentTool { llm: Box, tools: Vec>, tool_filter: Arc bool + Send + Sync>, - thread_store: Arc, + session_resources: Arc, cwd: String, ) -> SubagentResumeConfig { let host = self.host(); @@ -173,7 +174,7 @@ impl super::SubAgentTool { compact_config: None, context_budget: None, compact_llm: None, - thread_store, + session_resources, event_handler: self.event_handler.clone(), bg_event_sender: host.bg_event_sender.clone(), task_manager: host.task_manager.clone(), diff --git a/peri-middlewares/src/subagent/tool/tool_test.rs b/peri-middlewares/src/subagent/tool/tool_test.rs index ad68fbf57..5d29e61ae 100644 --- a/peri-middlewares/src/subagent/tool/tool_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test.rs @@ -2,6 +2,14 @@ use std::sync::Arc; use parking_lot::RwLock; use peri_acp_types::identity::AgentId; +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionMetaPatch, SessionResources, +}; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::thread::AgentStatus; +use peri_acp_types::workspace::{ + ResolvedWorkspace, SessionBinding, SessionExecutionLease, SESSION_BINDING_VERSION, +}; use peri_agent::{ agent::{ events::ExecutorEvent, @@ -10,7 +18,7 @@ use peri_agent::{ AgentCancellationToken, }, messages::BaseMessage, - thread::ThreadStore, + thread::{ThreadId, ThreadMeta}, tools::BaseTool, }; use tempfile::tempdir; @@ -533,17 +541,218 @@ async fn mcp_agent_suggestions_require_activation_and_connection() { assert!(!disconnected.contains("Available agent types")); } -/// 构造 FilesystemThreadStore(写盘即时刷新,无需 flush) -fn make_fs_store(dir: &tempfile::TempDir) -> Arc { - Arc::new(peri_agent::thread::FilesystemThreadStore::new( - dir.path().join("threads"), - )) +/// 真门面 fixture:临时 git 工作区 + 临时 SQLite + 会话执行所有权。 +/// +/// 子 agent 的 resume/spawn 路径要求「根会话有活 owner」这一真实前置条件,因此夹具 +/// 不使用存储替身:会话、消息、状态都落在真实门面上,断言读回的是真实事实。 +pub(crate) struct SessionFixture { + pub(crate) resources: Arc, + workspace: ResolvedWorkspace, + /// 执行所有权必须存活到会话生命周期结束(drop 即释放 owner)。 + leases: parking_lot::Mutex>>, +} + +impl SessionFixture { + /// 在给定目录建立真门面:目录本身即工作区(`git init` 提供仓库证据), + /// 会话 cwd 与 agent 定义查找路径因此与用例的 fixture 目录一致。 + pub(crate) async fn open_in(dir: &std::path::Path) -> Self { + git_init(dir); + let resources: Arc = Arc::new( + peri_resources::sessions::SessionResourcesImpl::open(dir.join("threads.db")) + .await + .unwrap(), + ); + let workspace = resources.resolve_workspace(dir).await.unwrap(); + Self { + resources, + workspace, + leases: parking_lot::Mutex::new(Vec::new()), + } + } + + /// 门面句柄(`.with_session_resources(...)` / `SubagentHost` 注入用)。 + pub(crate) fn facade(&self) -> Arc { + Arc::clone(&self.resources) + } + + /// 最近一次建会话的执行所有权(child 保存的前置证明)。 + /// + /// 夹具自己持有 lease 让 owner 保持活跃;调用方拿到的是同一份所有权句柄, + /// 用于 `.with_execution_owner(...)`,不产生第二个 owner。 + pub(crate) fn execution_owner(&self) -> Arc { + self.leases + .lock() + .last() + .cloned() + .expect("夹具尚未建立会话:先 create_thread") + } + + /// 夹具工作区的 canonical cwd(会话 cwd 与调用 cwd 必须一致)。 + pub(crate) fn workspace_cwd(&self) -> String { + self.workspace.cwd.to_string_lossy().into_owned() + } + + /// 建会话(真门面):绑定 + frozen + 执行代际一次落盘,owner 由夹具持有。 + pub(crate) async fn create_thread( + &self, + meta: peri_agent::thread::ThreadMeta, + ) -> Result { + let session = NewSession { + thread_id: meta.id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: meta.title.clone(), + cwd: self.workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: meta.parent_thread_id.clone(), + hidden: meta.hidden, + cancel_policy: meta.cancel_policy, + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: self.workspace.project_id, + workspace_id: self.workspace.workspace_id, + cwd_relative_to_workspace: self.workspace.relative_cwd.clone(), + }, + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"fixture\":true}"), + }; + let lease = self + .resources + .create_session(&session) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + self.leases.lock().push(lease); + Ok(meta.id) + } + + pub(crate) async fn append_messages( + &self, + id: &ThreadId, + messages: &[BaseMessage], + ) -> Result<(), anyhow::Error> { + let payloads: Vec = messages + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect(); + self.resources + .append_history(id, &payloads) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } + + pub(crate) async fn update_thread_status( + &self, + id: &ThreadId, + status: &str, + ) -> Result<(), anyhow::Error> { + let status = match status { + "done" => AgentStatus::Done, + "cancelled" => AgentStatus::Cancelled, + "error" => AgentStatus::Error, + _ => AgentStatus::Active, + }; + self.resources + .update_session_meta( + id, + &SessionMetaPatch { + status: Some(status), + ..Default::default() + }, + ) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } + + pub(crate) async fn load_meta(&self, id: &ThreadId) -> Result { + self.resources + .load_session_meta(id) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } + + pub(crate) async fn load_messages( + &self, + id: &ThreadId, + ) -> Result, anyhow::Error> { + Ok(self + .resources + .load_session_snapshot(id) + .await + .map_err(|error| anyhow::anyhow!("{error}"))? + .payloads + .into_iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect()) + } + + pub(crate) async fn list_session_threads( + &self, + id: &ThreadId, + ) -> Result, anyhow::Error> { + self.resources + .list_session_tree(id) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + } +} + +/// 把「门面 + 父会话 id + root owner」一次装到工具上。 +/// +/// child 保存/认领要求父会话真实存在、root owner 存活、调用 cwd 与父会话 cwd 一致; +/// 三者出自同一夹具。返回 canonical cwd——调用参数必须用它,否则 spawn 会按 +/// 绑定不匹配拒绝(`/var` 与 `/private/var` 之类符号链接差异也算不匹配)。 +pub(crate) async fn install_parent_session( + tool: SubAgentTool, + fixture: &SessionFixture, +) -> (SubAgentTool, String) { + let cwd = fixture.workspace_cwd(); + let parent_id = fixture + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + let tool = tool + .with_session_resources(fixture.facade()) + .with_parent_thread_id(parent_id) + .with_execution_owner(fixture.execution_owner()); + (tool, cwd) +} + +/// 让目录成为 git 工作区(工作区发现需要真实仓库证据)。 +pub(crate) fn git_init(directory: &std::path::Path) { + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ] { + let output = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", directory) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(directory) + .args(&args) + .output() + .unwrap(); + assert!(output.status.success(), "git fixture failed"); + } } /// 预置可恢复 thread:创建(title 决定工具集恢复路径)+ 写消息 + 置非 active。 -/// FilesystemThreadStore 写盘即时落库(append 后 load_messages 立即可见)。 async fn preset_resumable_thread( - store: &Arc, + fixture: &SessionFixture, id: &str, title: &str, parent_thread_id: Option<&str>, @@ -555,11 +764,11 @@ async fn preset_resumable_thread( meta.title = Some(title.to_string()); meta.parent_thread_id = parent_thread_id.map(|s| s.to_string()); meta.hidden = true; - store.create_thread(meta).await.unwrap(); + fixture.create_thread(meta).await.unwrap(); if !msgs.is_empty() { - store.append_messages(&id, &msgs).await.unwrap(); + fixture.append_messages(&id, &msgs).await.unwrap(); } - store.update_thread_status(&id, "done").await.unwrap(); + fixture.update_thread_status(&id, "done").await.unwrap(); } // 本文件经 mod.rs 的 `#[path = "tool_test.rs"]` 挂载;此路径加载方式下, diff --git a/peri-middlewares/src/subagent/tool/tool_test/active_message_test.rs b/peri-middlewares/src/subagent/tool/tool_test/active_message_test.rs index 2b4498b30..b23f7dfd9 100644 --- a/peri-middlewares/src/subagent/tool/tool_test/active_message_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test/active_message_test.rs @@ -31,7 +31,10 @@ impl ReactLLM for GatedMessageLlm { struct MessageFixture { dir: tempfile::TempDir, - store: Arc, + store: SessionFixture, + /// 本夹具父会话 id 与句柄(同库的第二个工具必须用它才不越根)。 + parent_id: String, + parent: Arc, tool: SubAgentTool, manager: Arc, calls: Arc, @@ -42,10 +45,21 @@ struct MessageFixture { } impl MessageFixture { - fn new(first_answer: Reasoning) -> Self { + async fn new(first_answer: Reasoning) -> Self { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + // 会话 cwd 与父子链:child 保存要求父会话存在且 cwd 与调用 cwd 一致。 + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let manager = Arc::new(TaskManager::new()); let calls = Arc::new(AtomicUsize::new(0)); let factories = Arc::new(AtomicUsize::new(0)); @@ -67,9 +81,12 @@ impl MessageFixture { first_answer: first_answer.clone(), }) }), - dir.path().to_str().unwrap().into(), + cwd.clone(), ) - .with_thread_store(store.clone()) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()) .with_task_manager(manager.clone()) .with_bg_event_sender(events_tx) // 冻结空摘要,避免测试读取用户目录中的指引或 skill 列表。 @@ -82,6 +99,8 @@ impl MessageFixture { Self { dir, store, + parent_id, + parent, tool, manager, calls, @@ -151,7 +170,8 @@ async fn test_active_message_reaches_next_model_request_without_resume() { let mut fixture = MessageFixture::new(Reasoning::with_tools( "", vec![ToolCall::new("probe", "Probe", serde_json::json!({}))], - )); + )) + .await; let id = fixture .start(serde_json::json!({"subagent_type": "test-agent"})) .await; @@ -217,7 +237,7 @@ async fn test_active_message_reaches_next_model_request_without_resume() { #[tokio::test] async fn test_active_message_background_fork_info_does_not_extend_final_answer() { - let mut fixture = MessageFixture::new(Reasoning::with_answer("", "finished")); + let mut fixture = MessageFixture::new(Reasoning::with_answer("", "finished")).await; let id = fixture.start(serde_json::json!({"fork": true})).await; let receipt = fixture .invoke(serde_json::json!({"resume_thread_id": id, "prompt": "supplement-one"})) @@ -242,9 +262,18 @@ async fn test_active_message_resumed_background_execution_accepts_info() { let mut fixture = MessageFixture::new(Reasoning::with_tools( "", vec![ToolCall::new("probe", "Probe", serde_json::json!({}))], - )); + )) + .await; let id = uuid::Uuid::now_v7().to_string(); - preset_resumable_thread(&fixture.store, &id, "fork", None, Vec::new()).await; + // 被恢复的 thread 必须属于夹具父会话的同一执行根,否则 resume 会被归属校验拒绝。 + preset_resumable_thread( + &fixture.store, + &id, + "fork", + Some(fixture.parent_id.as_str()), + Vec::new(), + ) + .await; let resumed_id = fixture .start(serde_json::json!({"resume_thread_id": id})) .await; @@ -265,7 +294,7 @@ async fn test_active_message_resumed_background_execution_accepts_info() { #[tokio::test] async fn test_active_message_rejects_empty_prompt_without_resuming() { - let mut fixture = MessageFixture::new(Reasoning::with_answer("", "finished")); + let mut fixture = MessageFixture::new(Reasoning::with_answer("", "finished")).await; let id = fixture.start(serde_json::json!({"fork": true})).await; for prompt in [ serde_json::Value::Null, @@ -289,10 +318,15 @@ async fn test_active_message_rejects_empty_prompt_without_resuming() { #[tokio::test] async fn test_active_message_cross_session_is_rejected_without_spawning() { - let mut fixture = MessageFixture::new(Reasoning::with_answer("", "finished")); + let mut fixture = MessageFixture::new(Reasoning::with_answer("", "finished")).await; let id = fixture.start(serde_json::json!({"fork": true})).await; + // 同库、同父会话的第二个工具实例:被拒绝的原因必须是「无活跃接收者」, + // 而不是缺父身份——否则测不到 cross-session 拒绝本身。 let stranger = make_subagent_tool(Vec::new()) - .with_thread_store(fixture.store.clone()) + .with_session_resources(fixture.store.facade()) + .with_parent_thread_id(fixture.parent_id.clone()) + .with_execution_owner(fixture.store.execution_owner()) + .with_parent_session(Arc::clone(&fixture.parent)) .with_task_manager(Arc::new(TaskManager::new())); let error = stranger .invoke( diff --git a/peri-middlewares/src/subagent/tool/tool_test/events_contract_test.rs b/peri-middlewares/src/subagent/tool/tool_test/events_contract_test.rs index 142756c18..f0c9b4fe3 100644 --- a/peri-middlewares/src/subagent/tool/tool_test/events_contract_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test/events_contract_test.rs @@ -61,20 +61,37 @@ fn start_child_agent_id(evs: &[ObserveEvent]) -> peri_acp_types::identity::Agent .expect("事件流中应有 SubagentStart") } +/// 安装父会话:真实门面 + 已建立会话(父 id 即会话 id)+ root owner + canonical cwd。 +/// +/// child 保存要求父会话存在、调用 cwd 与父会话 cwd 一致、owner 存活;三者一次建好。 +/// 夹具本体随返回值存活(drop 即释放 owner),调用方必须持有到 invoke 结束。 +async fn install_parent_session(dir: &std::path::Path) -> (SessionFixture, String, String) { + let fixture = SessionFixture::open_in(dir).await; + let cwd = fixture.workspace_cwd(); + let parent_id = fixture + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + (fixture, parent_id, cwd) +} + /// S1/T1:fork 同步路径(execute_fork.rs)—— Start/Stop 恰好一次, /// 且 child_agent_id == child_thread_id(C1 身份统一契约) #[tokio::test] async fn test_fork_path_emits_v2_start_stop_exactly_once() { let dir = tempdir().unwrap(); - // thread_store 存在时 invoke 返回携带 child_thread_id,用于身份对齐断言 + let (fixture, parent_id, cwd) = install_parent_session(dir.path()).await; + // 会话资源门面存在时 invoke 返回携带 child_thread_id,用于身份对齐断言 let (t, bridge) = make_tool_with_bridge(); - let t = t.with_thread_store(Arc::new(peri_agent::thread::FilesystemThreadStore::new( - dir.path().join("threads"), - )) as Arc); + let t = t + .with_session_resources(fixture.facade()) + .with_parent_thread_id(parent_id) + .with_execution_owner(fixture.execution_owner()); let result = t .invoke( serde_json::json!({ "fork": true, + "cwd": cwd, "prompt": "fork task" }), peri_agent::tools::ToolContext::new(&[], "."), @@ -104,15 +121,17 @@ async fn test_fork_path_emits_v2_start_stop_exactly_once() { async fn test_define_path_emits_v2_start_stop_exactly_once() { let dir = tempdir().unwrap(); write_test_agent(&dir); + let (fixture, parent_id, cwd) = install_parent_session(dir.path()).await; let (t, bridge) = make_tool_with_bridge(); - let t = t.with_thread_store(Arc::new(peri_agent::thread::FilesystemThreadStore::new( - dir.path().join("threads"), - )) as Arc); + let t = t + .with_session_resources(fixture.facade()) + .with_parent_thread_id(parent_id) + .with_execution_owner(fixture.execution_owner()); let result = t .invoke( serde_json::json!({ "subagent_type": "test-agent", - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd, "prompt": "do it" }), peri_agent::tools::ToolContext::new(&[], "."), diff --git a/peri-middlewares/src/subagent/tool/tool_test/invoke_test.rs b/peri-middlewares/src/subagent/tool/tool_test/invoke_test.rs index e1da5e248..b52a3a2aa 100644 --- a/peri-middlewares/src/subagent/tool/tool_test/invoke_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test/invoke_test.rs @@ -534,24 +534,32 @@ async fn test_agent_invoke_mcp_background_rejection_precedes_fork_fallback() { async fn test_agent_invoke_parent_host_masks_fallback_runtime_and_store() { let dir = tempdir().unwrap(); let fallback_dir = tempdir().unwrap(); - let store = make_fs_store(&dir); - let fallback_store = make_fs_store(&fallback_dir); + let store = SessionFixture::open_in(dir.path()).await; + let fallback_store = SessionFixture::open_in(fallback_dir.path()).await; + // 父 host 必须带完整父事实:门面 + root owner + 父 thread id(child 保存的前置条件)。 + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); let parent = peri_agent::session::Session::new( - Arc::from(dir.path().to_str().unwrap()), + Arc::from(cwd.as_str()), peri_agent::session::FrozenContext::builder().build(), - None, + Some(parent_id.clone()), ); parent.set_subagent_host(peri_agent::session::subagent::SubagentHost { - thread_store: Some(store.clone()), + session_resources: Some(store.facade()), + execution_owner: Some(store.execution_owner()), + parent_thread_id: Some(parent_id.clone()), ..Default::default() }); let fallback_manager = Arc::new(peri_agent::agent::async_tasks::TaskManager::new()); let tool = make_subagent_tool(vec![]) - .with_thread_store(fallback_store.clone()) + .with_session_resources(fallback_store.facade()) .with_task_manager(fallback_manager.clone()) .with_parent_session(parent); let result = tool.invoke( - serde_json::json!({"fork": true, "run_in_background": true, "prompt": "sync fallback", "cwd": dir.path().to_str().unwrap()}), + serde_json::json!({"fork": true, "run_in_background": true, "prompt": "sync fallback", "cwd": cwd.clone()}), peri_agent::tools::ToolContext::new(&[], "."), ).await.unwrap(); let thread_id = result diff --git a/peri-middlewares/src/subagent/tool/tool_test/model_tier_test.rs b/peri-middlewares/src/subagent/tool/tool_test/model_tier_test.rs index 1ca5f7ff1..5104e6090 100644 --- a/peri-middlewares/src/subagent/tool/tool_test/model_tier_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test/model_tier_test.rs @@ -290,25 +290,40 @@ async fn test_agent_model_ignored_on_fork() { async fn test_resume_thread_id_ignores_model_field() { let dir = tempdir().unwrap(); write_test_agent_with_model(&dir, "sonnet"); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread( &store, &id, "test-agent", - None, + Some(parent_id.as_str()), vec![BaseMessage::human("旧消息 1"), BaseMessage::ai("旧回答 1")], ) .await; let aliases: Arc>>> = Arc::default(); - let t = make_recording_subagent_tool(vec![], Arc::clone(&aliases)).with_thread_store(store); + let t = make_recording_subagent_tool(vec![], Arc::clone(&aliases)) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), "model": "turbo", - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) diff --git a/peri-middlewares/src/subagent/tool/tool_test/resume_integration_test.rs b/peri-middlewares/src/subagent/tool/tool_test/resume_integration_test.rs index 3cdbea50c..4694d8f2d 100644 --- a/peri-middlewares/src/subagent/tool/tool_test/resume_integration_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test/resume_integration_test.rs @@ -102,7 +102,7 @@ async fn wait_for_observe_pairs( /// 轮询等待 transcript 异步 writer 落盘(transcript.rs 批量窗口 ≤100ms; /// 执行返回后新消息可能仍在 writer 通道中) async fn wait_for_messages( - store: &Arc, + store: &SessionFixture, id: &str, n: usize, timeout_ms: u64, @@ -141,28 +141,34 @@ async fn test_resume_interrupted_then_resumed_across_instances() { ) .unwrap(); - let store = make_fs_store(&dir); - // 主 agent 会话 thread_id 固定(进程重启后 session_id 不变,R-L3) - let work = dir.path().to_str().unwrap(); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 let parent = peri_agent::session::Session::new( - Arc::from(work), + std::sync::Arc::from(cwd.as_str()), peri_agent::session::FrozenContext::builder().build(), - Some("parent-uuid".into()), + Some(parent_id.clone()), ); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); // 实例 A:spawn → LLM 首轮 Interrupted → Ok 可恢复文本带 child_thread_id 前缀 let t_a = make_interrupt_tool(Arc::clone(&calls), 1) - .with_thread_store(Arc::clone(&store) as Arc) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) .with_parent_session(parent.clone()); let interrupted = t_a .invoke( serde_json::json!({ "subagent_type": "interrupt-agent", - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), "prompt": "first task" }), - peri_agent::tools::ToolContext::new(&[], work), + peri_agent::tools::ToolContext::new(&[], cwd.as_str()), ) .await .expect("首次执行应返回可恢复的 Interrupted 文本"); @@ -177,15 +183,17 @@ async fn test_resume_interrupted_then_resumed_across_instances() { // 实例 B(同 store dir、同父 session thread_id):resume → 完成 let t_b = make_interrupt_tool(Arc::clone(&calls), 1) - .with_thread_store(Arc::clone(&store) as Arc) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) .with_parent_session(parent); let result = t_b .invoke( serde_json::json!({ "resume_thread_id": id.clone(), - "cwd": work, + "cwd": cwd.clone(), }), - peri_agent::tools::ToolContext::new(&[], work), + peri_agent::tools::ToolContext::new(&[], cwd.as_str()), ) .await .expect("resume 应成功完成(thread_id 即恢复凭证)"); @@ -204,7 +212,7 @@ async fn test_resume_interrupted_then_resumed_across_instances() { let meta = store.load_meta(&id).await.unwrap(); assert_eq!( meta.parent_thread_id.as_deref(), - Some("parent-uuid"), + Some(parent_id.as_str()), "子线程父链必须指向父 session thread_id(跨实例复用)" ); } @@ -217,18 +225,32 @@ async fn test_resume_interrupted_then_resumed_across_instances() { async fn test_resume_across_instances_replays_transcript_in_order() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let id = { // 实例 A:spawn → LLM 首轮 Interrupted(thread 与 transcript 已落盘) let t_a = make_interrupt_tool(Arc::clone(&calls), 1) - .with_thread_store(Arc::clone(&store) as Arc); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let interrupted = t_a .invoke( serde_json::json!({ "subagent_type": "test-agent", - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), "prompt": "first task" }), peri_agent::tools::ToolContext::new(&[], "."), @@ -247,12 +269,15 @@ async fn test_resume_across_instances_replays_transcript_in_order() { // 实例 B:同 store dir → resume(缺省 prompt → 隐式 continue)→ 完成 let t_b = make_interrupt_tool(Arc::clone(&calls), 1) - .with_thread_store(Arc::clone(&store) as Arc); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t_b .invoke( serde_json::json!({ "resume_thread_id": id.clone(), - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) @@ -286,17 +311,34 @@ async fn test_resume_across_instances_replays_transcript_in_order() { async fn test_resume_multiple_times_keeps_thread_id_and_completes() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); - let cwd = dir.path().to_str().unwrap().to_string(); - - // 构造带「已取消 cancel token」的实例(parent 缺席时注入的 cancel 生效 → - // run_react_loop 返回 LoopResult::Interrupted → Ok 中断文本) + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 构造「父会话已取 cancel」的实例:Cascade 策略下子 token 由父 token 派生 + // (`derive_cancel_token`:parent 优先、config 注入仅作 parent 缺席的回退), + // 因此父 session 取消才会让 run_react_loop 返回 LoopResult::Interrupted + // → Ok 中断文本。同时父会话句柄是 resume 归属校验的前置。 + let mk_parent = |cancelled: bool| { + let token = Arc::new(AgentCancellationToken::new()); + if cancelled { + token.cancel(); + } + peri_agent::session::Session::new_with_cancel( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + token, + ) + }; let mk_cancelled = || { - let cancel = AgentCancellationToken::new(); - cancel.cancel(); make_subagent_tool(vec![]) - .with_thread_store(Arc::clone(&store) as Arc) - .with_cancel(cancel) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(mk_parent(true)) }; // 1) spawn → 中断 #1(文本含 child_thread_id + resume 提示) @@ -339,9 +381,12 @@ async fn test_resume_multiple_times_keeps_thread_id_and_completes() { let id2 = extract_child_thread_id(&r2); assert_eq!(id1, id2, "多次恢复 thread_id 必须不变"); - // 3) resume → 完成(无 cancel 的新实例) - let t3 = - make_subagent_tool(vec![]).with_thread_store(Arc::clone(&store) as Arc); + // 3) resume → 完成(新实例:父会话换回未取消的 token,归属不变) + let t3 = make_subagent_tool(vec![]) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(mk_parent(false)); let r3 = t3 .invoke( serde_json::json!({ @@ -373,7 +418,18 @@ async fn test_resume_multiple_times_keeps_thread_id_and_completes() { async fn test_resume_emits_new_start_stop_pair_per_execution() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let bridge = Arc::new(RecordingBridge { observes: Arc::new(std::sync::Mutex::new(Vec::new())), }); @@ -392,8 +448,10 @@ async fn test_resume_emits_new_start_stop_pair_per_execution() { ) .with_parent_agent_id(Arc::new(RwLock::new(Some(AgentId::new())))) .with_langfuse_bridge(Arc::clone(&bridge) as Arc) - .with_thread_store(Arc::clone(&store) as Arc); - let cwd = dir.path().to_str().unwrap().to_string(); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); // 首次执行 → 中断(第 1 对 Start/Stop;LLM 返回 Interrupted → Ok 可恢复文本) let interrupted = t @@ -498,11 +556,24 @@ async fn test_resume_skill_preload_not_duplicated() { ) .unwrap(); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let t = make_interrupt_tool(Arc::clone(&calls), 1) - .with_thread_store(Arc::clone(&store) as Arc); - let cwd = dir.path().to_str().unwrap().to_string(); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); // 首次执行(agent-def 路径)→ SkillPreload 注入一套 → 中断(LLM 返回 Interrupted) let interrupted = t @@ -555,14 +626,25 @@ async fn test_resume_skill_preload_not_duplicated() { async fn test_resume_keeps_completed_tool_round_no_duplicate_execution() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); // 完整工具轮次:Ai[ToolUse] + Tool[result] 配对,末条 = Tool → 不 pop preset_resumable_thread( &store, &id, "test-agent", - None, + Some(parent_id.as_str()), vec![ BaseMessage::human("task"), BaseMessage::ai_from_blocks(vec![peri_agent::messages::ContentBlock::tool_use( @@ -606,13 +688,16 @@ async fn test_resume_keeps_completed_tool_round_no_duplicate_execution() { }), "/tmp".to_string(), ) - .with_thread_store(Arc::clone(&store) as Arc); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) @@ -671,11 +756,24 @@ async fn test_resume_skill_token_in_prompt_reinjects_once() { ) .unwrap(); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let t = make_interrupt_tool(Arc::clone(&calls), 1) - .with_thread_store(Arc::clone(&store) as Arc); - let cwd = dir.path().to_str().unwrap().to_string(); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); // 首次执行(显式声明 skills)→ 注入一套 → 中断(LLM 返回 Interrupted) let interrupted = t diff --git a/peri-middlewares/src/subagent/tool/tool_test/resume_test.rs b/peri-middlewares/src/subagent/tool/tool_test/resume_test.rs index 9f8c116bc..01a0ef67e 100644 --- a/peri-middlewares/src/subagent/tool/tool_test/resume_test.rs +++ b/peri-middlewares/src/subagent/tool/tool_test/resume_test.rs @@ -10,13 +10,14 @@ async fn test_resume_thread_id_placeholder_ignored_and_spawns_new() { for placeholder in ["", "new", "__omit__"] { let dir = tempdir().unwrap(); write_test_agent(&dir); - let t = make_subagent_tool(vec![]).with_thread_store(make_fs_store(&dir)); + let fixture = SessionFixture::open_in(dir.path()).await; + let (t, cwd) = install_parent_session(make_subagent_tool(vec![]), &fixture).await; let result = t .invoke( serde_json::json!({ "resume_thread_id": placeholder, "subagent_type": "test-agent", - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd, "prompt": "do it", }), peri_agent::tools::ToolContext::new(&[], "."), @@ -47,24 +48,39 @@ async fn test_resume_thread_id_placeholder_ignored_and_spawns_new() { async fn test_resume_thread_id_ignores_fork_field() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread( &store, &id, "test-agent", - None, + Some(parent_id.as_str()), vec![BaseMessage::human("旧消息 1"), BaseMessage::ai("旧回答 1")], ) .await; - let t = make_subagent_tool(vec![]).with_thread_store(store); + let t = make_subagent_tool(vec![]) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), "fork": true, - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) @@ -83,24 +99,39 @@ async fn test_resume_thread_id_ignores_fork_field() { async fn test_resume_thread_id_ignores_subagent_type_field() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread( &store, &id, "test-agent", - None, + Some(parent_id.as_str()), vec![BaseMessage::human("旧消息 1"), BaseMessage::ai("旧回答 1")], ) .await; - let t = make_subagent_tool(vec![]).with_thread_store(store); + let t = make_subagent_tool(vec![]) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), "subagent_type": "test-agent", - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) @@ -117,7 +148,23 @@ async fn test_resume_thread_id_ignores_subagent_type_field() { #[tokio::test] async fn test_resume_thread_id_not_found() { let dir = tempdir().unwrap(); - let t = make_subagent_tool(vec![]).with_thread_store(make_fs_store(&dir)); + let fixture = SessionFixture::open_in(dir.path()).await; + let cwd = fixture.workspace_cwd(); + let parent_id = fixture + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); + let t = make_subagent_tool(vec![]) + .with_session_resources(fixture.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(fixture.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ @@ -139,13 +186,28 @@ async fn test_resume_thread_id_not_found() { #[tokio::test] async fn test_resume_thread_id_active_rejected() { let dir = tempdir().unwrap(); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); let mut meta = peri_agent::thread::ThreadMeta::new("/tmp"); meta.id = id.clone(); meta.title = Some("fork".to_string()); store.create_thread(meta).await.unwrap(); // ThreadMeta 默认 agent_status = Active - let t = make_subagent_tool(vec![]).with_thread_store(store); + let t = make_subagent_tool(vec![]) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ @@ -162,54 +224,73 @@ async fn test_resume_thread_id_active_rejected() { ); } -/// parent 链不匹配不再拒绝(parent 链校验已移除)——meta.parent_thread_id 与 -/// 父 session thread_id 不一致时恢复仍成功(thread_id 即恢复凭证) +/// parent 链归属:child 的 `parent_thread_id` 指向**另一个真实根会话**时,即使持有 +/// child_thread_id 且绑定同一工作区,恢复仍被拒绝 +/// (`bound subagent belongs to another root session execution owner`)。 +/// +/// 本用例的前身断言「parent 链不匹配不再拒绝」,那是在存储替身下成立的行为: +/// 替身没有执行归属,也允许 `parent_thread_id` 指向不存在的 thread。换成真门面后 +/// 两个方向都必须给出一致结论——指向不存在的父会让祖先链读不出快照 +/// (`load_inherited_context_on` 对链上成员 `fetch_one`),指向另一个真实根则被 +/// 归属校验拒绝。要保护的契约是后者:child_thread_id 不是执行权凭证 +/// (见母 issue §4.1「不得仅持有 child_thread_id 推断新执行权」)。 #[tokio::test] -async fn test_resume_thread_id_parent_mismatch_not_rejected() { +async fn test_resume_thread_id_parent_mismatch_is_rejected_by_root_ownership() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + // 另一个真实根会话:child 挂在它下面,祖先链可解析但执行根不同。 + let other_root = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立另一根会话失败"); let id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread( &store, &id, "test-agent", - Some("some-other-parent"), + Some(other_root.as_str()), vec![BaseMessage::human("旧消息")], ) .await; - // 父 session:store().thread_id = "parent-uuid" ≠ meta.parent_thread_id - let work = dir.path().to_str().unwrap(); + // 调用方自己的父会话(最后一个建,夹具执行所有权就是它的) + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); let parent = peri_agent::session::Session::new( - Arc::from(work), + std::sync::Arc::from(cwd.as_str()), peri_agent::session::FrozenContext::builder().build(), - Some("parent-uuid".into()), + Some(parent_id.clone()), ); let t = make_subagent_tool(vec![]) - .with_thread_store(Arc::clone(&store) as Arc) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) .with_parent_session(parent); - let result = t + let error = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), - "cwd": work, + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) .await - .expect("parent 链不匹配不再拒绝恢复"); - assert!( - result.contains(&format!("child_thread_id: {}", id)), - "完成文本应带 child_thread_id: {}", - result + .expect_err("跨根恢复必须被拒绝"); + assert_eq!( + error.to_string(), + "bound subagent belongs to another root session execution owner", + "拒绝原因应为执行根归属,而不是「不存在」或「仍处于运行态」" ); - // 与 agent 层测试对齐:锁定完成收尾(区别于 bg 启动 / interrupted 文本也带前缀) + // 拒绝发生在任何写入之前:thread 保持原收尾状态,不留 active 残留。 let meta = store.load_meta(&id).await.unwrap(); assert_eq!( meta.agent_status, peri_agent::thread::AgentStatus::Done, - "恢复完成后收尾 done" + "被拒绝的恢复不得改动 thread 状态" ); } @@ -221,14 +302,28 @@ async fn test_resume_thread_id_background_combination() { use tokio::sync::mpsc; let dir = tempdir().unwrap(); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); - preset_resumable_thread(&store, &id, "fork", None, Vec::new()).await; + preset_resumable_thread(&store, &id, "fork", Some(parent_id.as_str()), Vec::new()).await; let registry = Arc::new(peri_agent::agent::async_tasks::TaskManager::new()); let (bg_tx, mut bg_rx) = mpsc::unbounded_channel::(); let t = make_subagent_tool(vec![]) - .with_thread_store(store) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()) .with_task_manager(Arc::clone(®istry)) .with_bg_event_sender(bg_tx); @@ -284,23 +379,38 @@ async fn test_resume_thread_id_background_combination() { async fn test_resume_thread_id_success_replays_and_completes() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); preset_resumable_thread( &store, &id, "test-agent", - None, + Some(parent_id.as_str()), vec![BaseMessage::human("旧消息 1"), BaseMessage::ai("旧回答 1")], ) .await; - let t = make_subagent_tool(vec![]).with_thread_store(store); + let t = make_subagent_tool(vec![]) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) @@ -321,9 +431,27 @@ async fn test_resume_thread_id_success_replays_and_completes() { #[tokio::test] async fn test_resume_thread_id_fork_title_uses_parent_tools_and_200_iterations() { let dir = tempdir().unwrap(); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); - preset_resumable_thread(&store, &id, "fork", None, vec![BaseMessage::human("task")]).await; + preset_resumable_thread( + &store, + &id, + "fork", + Some(parent_id.as_str()), + vec![BaseMessage::human("task")], + ) + .await; // 计数 + 工具捕获 LLM:恒请求调用不存在工具 → 循环持续到迭代上限 let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -368,7 +496,10 @@ async fn test_resume_thread_id_fork_title_uses_parent_tools_and_200_iterations() }), "/tmp".to_string(), ) - .with_thread_store(store); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( @@ -413,9 +544,27 @@ async fn test_resume_thread_id_agent_def_refilters_tools() { ) .unwrap(); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); - preset_resumable_thread(&store, &id, "resume-agent", None, Vec::new()).await; + preset_resumable_thread( + &store, + &id, + "resume-agent", + Some(parent_id.as_str()), + Vec::new(), + ) + .await; let tools_capture: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -447,13 +596,16 @@ async fn test_resume_thread_id_agent_def_refilters_tools() { }), "/tmp".to_string(), ) - .with_thread_store(store); + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = t .invoke( serde_json::json!({ "resume_thread_id": id.clone(), - "cwd": dir.path().to_str().unwrap(), + "cwd": cwd.clone(), }), peri_agent::tools::ToolContext::new(&[], "."), ) @@ -483,10 +635,25 @@ async fn test_resume_thread_id_agent_def_refilters_tools() { async fn test_resume_trimmed_id_wins_over_mcp_fork_and_invalid_model() { let dir = tempdir().unwrap(); write_test_agent(&dir); - let store = make_fs_store(&dir); + let store = SessionFixture::open_in(dir.path()).await; + let cwd = store.workspace_cwd(); + let parent_id = store + .create_thread(ThreadMeta::new(cwd.clone())) + .await + .expect("建立父会话失败"); + // 父会话句柄:resume 路径经它校验「owning parent session」 + let parent = peri_agent::session::Session::new( + std::sync::Arc::from(cwd.as_str()), + peri_agent::session::FrozenContext::builder().build(), + Some(parent_id.clone()), + ); let id = uuid::Uuid::now_v7().to_string(); - preset_resumable_thread(&store, &id, "test-agent", None, vec![]).await; - let tool = make_subagent_tool(vec![]).with_thread_store(store.clone()); + preset_resumable_thread(&store, &id, "test-agent", Some(parent_id.as_str()), vec![]).await; + let tool = make_subagent_tool(vec![]) + .with_session_resources(store.facade()) + .with_parent_thread_id(parent_id.clone()) + .with_execution_owner(store.execution_owner()) + .with_parent_session(parent.clone()); let result = tool .invoke( serde_json::json!({ @@ -495,7 +662,7 @@ async fn test_resume_trimmed_id_wins_over_mcp_fork_and_invalid_model() { "fork": true, "model": "invalid-model", "prompt": null, - "cwd": dir.path().to_str().unwrap() + "cwd": cwd.clone() }), peri_agent::tools::ToolContext::new(&[], "."), ) diff --git a/peri-resources/Cargo.toml b/peri-resources/Cargo.toml index be46c8a5b..404c17bf9 100644 --- a/peri-resources/Cargo.toml +++ b/peri-resources/Cargo.toml @@ -13,6 +13,10 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true sqlx = { workspace = true } +# 远程会话存储(Turso Database 引擎,over-the-wire)。选定依据、精确版本与已知限制见 +# src/sessions/remote/mod.rs 的模块文档(C-01 只读探测 + 官方引擎/驱动对应关系)。 +turso_serverless = "0.1.3" +url.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true @@ -25,6 +29,10 @@ peri-workflow = { path = "../peri-workflow" } [dev-dependencies] tempfile.workspace = true +# 显式 cloud 探测(src/sessions/remote/cloud_test.rs,默认 #[ignore])用只读 HTTP 读取 +# 服务端版本与参数绑定回环;仅测试目标使用,不进生产依赖图。 +reqwest.workspace = true +url.workspace = true [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/peri-resources/src/context.rs b/peri-resources/src/context.rs index b1cd6af10..a753c9891 100644 --- a/peri-resources/src/context.rs +++ b/peri-resources/src/context.rs @@ -8,15 +8,58 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; -use peri_acp_types::store::ThreadStore; +use peri_acp_types::session_resources::{ + SessionResourceError, SessionResourceErrorKind, SessionResourceResult, SessionResources, + SessionStoreShutdownPort, +}; +use peri_acp_types::session_store::SessionStoreDeployment; use peri_acp_types::workspace::WorkspaceError; -use crate::sessions::SqliteThreadStore; +use crate::sessions::{ + AccessIntent, CredentialError, LocatorError, ResolvedLocator, SessionStoreOpenRequest, +}; /// 外部系统资源门面 -#[derive(Clone)] +/// +/// 存储的构造、owner 登记与关闭都由门面内部完成,「谁持有执行权」因此只有一个真相。 +/// 本结构同时持有两类所有权,且**不可克隆**:业务句柄 +/// ([`Resources::session_resources`])可以克隆任意份交给 Agent/Controller/middleware, +/// 但部署关闭权([`SessionStoreShutdownOwner`])只有一份,只能经 +/// [`Resources::into_parts`] 连所有权一起交出去——业务侧拿不到它,因此没有任何业务 +/// 路径能关闭全局存储。 pub struct Resources { - thread_store: Arc, + session_resources: Arc, + shutdown: SessionStoreShutdownOwner, +} + +/// 部署生命周期关闭权(non-Clone):关闭整个会话存储的唯一载体。 +/// +/// 与业务句柄共享同一个门面实例,但只有本类型能调用关闭;装配点把业务句柄交给 +/// Agent/Controller/middleware,把本类型留在部署侧,在**任务排空之后**调用 +/// [`SessionStoreShutdownPort::shutdown`]。 +pub struct SessionStoreShutdownOwner { + facade: Arc, +} + +impl SessionStoreShutdownOwner { + /// 从具体门面实例取关闭权(只给 Resources 层自己的装配路径用)。 + /// + /// 可见性是关闭权的一部分:crate 外拿不到本方法,因此业务侧即使按具体类型打开了一个 + /// 实例(`SessionResourcesImpl::open*` 只服务 I/O,不含关闭),也无法把它变成部署 + /// 关闭权。crate 外交出关闭权的唯一路径是部署装配入口 + /// ([`Resources::open_deployment`] / [`Resources::open_with`])加 + /// [`Resources::into_parts`],由部署在任务排空之后消费一次。构造与关闭之间没有隐含 + /// 状态,重复取用不会产生第二份「关闭进度」。 + pub(crate) fn take(facade: Arc) -> Self { + Self { facade } + } +} + +#[async_trait::async_trait] +impl SessionStoreShutdownPort for SessionStoreShutdownOwner { + async fn shutdown(&self) -> SessionResourceResult<()> { + self.facade.close().await + } } impl Resources { @@ -25,17 +68,117 @@ impl Resources { /// 默认路径 `~/.peri/threads/threads.db` 写打开失败时会降级为只读打开, /// 见 [`Resources::open_with`]。 pub async fn open() -> Result { - Self::open_with(None).await + Self::open_request(SessionStoreOpenRequest::local( + None, + AccessIntent::ReadWrite, + )) + .await } - /// 按显式路径打开全部资源(当前为会话存储)。 + /// 既有 `--db-path` 兼容入口:归一成一个本机 locator,不在这里解释后端。 /// /// `Some(path)` 使用指定路径;`None` 使用默认路径 /// `~/.peri/threads/threads.db`。两条路径都先尝试写打开,写打开不可用时再尝试 /// 只读打开;只有两者都失败才返回包含路径的错误——不静默 fallback 到共享临时 /// 数据库。 pub async fn open_with(db_path: Option) -> Result { - Self::open_with_default(db_path, SqliteThreadStore::default_database_path).await + Self::open_request(SessionStoreOpenRequest::local( + db_path, + AccessIntent::ReadWrite, + )) + .await + } + + /// 按部署参数打开(D §3.1 的最小配置面;D-04 各部署入口的唯一装配点)。 + /// + /// [`SessionStoreDeployment`] 携带中性定位描述——已确认本机路径(`--db-path`)、 + /// 待解析 locator 原文(`--session-store`:本机路径、远程 locator 或 `env:<变量名>`) + /// 或默认本机库——另加可选的显式引擎名、凭证**来源**(环境变量名,不接受 token + /// 字面量)与访问意图。**只设置 `TURSO_URL` 不会切换后端**。 + /// + /// 部署参数在这里**一次性**转成 typed open request:locator 形态、引擎名与凭证 + /// 来源的冲突全部在进入任何 I/O(含建目录、开库、连接)之前失败,错误保持类型化 + /// (见 [`classify_open_failure`])。访问意图不是权限:能不能写由打开结果回答; + /// 只读意图绝不做写探测,也不退回写打开再降级。 + /// + /// 后端选择只发生在这里:本机 locator 走既有 SQLite 装配,远程 locator 走 + /// `sessions::open_remote`(本机登记库 + 远端数据 adapter,见 `sessions::remote`), + /// **不静默回落到本机库**。远程存储是否被本机接纳由那次装配裁决:没有登记的存储 + /// 只读历史可用、执行与写入被拒绝,而不是换一个后端继续。 + /// + /// 环境变量只在两处被读取:`--session-store env:` 形式的 locator,以及远程 adapter + /// 打开时按凭证来源取凭证值。**仅仅存在某个云 URL/token 变量不会切换后端**, + /// 也不影响默认本机库的选择;`--db-path` 的路径既不解 `env:` 也不受这些变量影响。 + pub async fn open_deployment(deployment: &SessionStoreDeployment) -> Result { + Self::open_request(SessionStoreOpenRequest::from_deployment(deployment)?).await + } + + /// 唯一的后端选择点(typed 请求版本,供 crate 内装配调用)。 + /// + /// 只读意图走独立只读 seam:不创建目录、库、锁或迁移 schema;写意图保留既有的 + /// 「写打开失败且历史仍可读时降级只读」。 + pub(crate) async fn open_request(request: SessionStoreOpenRequest) -> Result { + let read_only = request.intent().is_read_only(); + match request.resolve_locator()? { + ResolvedLocator::Default if read_only => Self::open_local_read_only(None).await, + ResolvedLocator::Default => Self::open_local(None).await, + ResolvedLocator::Local(path) if read_only => { + Self::open_local_read_only(Some(path)).await + } + ResolvedLocator::Local(path) => Self::open_local(Some(path)).await, + // 不降级:远程 locator 拿不到本机库作为替代。 + ResolvedLocator::Remote(endpoint) => Self::open_remote(&request, &endpoint).await, + } + } + + /// 远程 locator 的装配:本机登记库 + 远端数据 adapter,装配点仍是这里。 + /// + /// 本机事实(登记、未决锚点、执行代际、sidecar 锁)落在默认本机库 + /// (`~/.peri/threads/threads.db`)里——那是本机唯一的登记位置,不是会话数据的副本。 + /// 显式只读意图只读打开它(不创建文件、不升级 schema、不写登记)。 + async fn open_remote( + request: &SessionStoreOpenRequest, + endpoint: &crate::sessions::RemoteEndpoint, + ) -> Result { + let registry_path = Self::local_registry_path()?; + // 凭证解析在任何 I/O 之前:来源缺失、变量未设置、空值都在这里失败。 + let Some(source) = request.credential_source() else { + return Err(LocatorError::MissingCredentialSource.into()); + }; + let credential = source.resolve()?; + crate::sessions::open_remote(endpoint, &credential, request.access(), registry_path) + .await + .map(Self::from_facade) + .map_err(|error| error.context("无法打开远程会话存储")) + } + + /// 本机登记库位置:默认本机库(只解析,不创建)。 + fn local_registry_path() -> Result { + crate::sessions::SessionResourcesImpl::default_database_path() + } + + /// 本机 locator 的既有装配:写打开失败且历史仍可读时降级为只读打开。 + async fn open_local(db_path: Option) -> Result { + Self::open_with_default( + db_path, + crate::sessions::SessionResourcesImpl::default_database_path, + ) + .await + } + + /// 显式只读 locator:复用既有只读 seam,不开库、不建目录、不迁移 schema。 + async fn open_local_read_only(db_path: Option) -> Result { + let describe = match &db_path { + Some(path) => format!("指定 SQLite 数据库 {}", path.display()), + None => "默认 SQLite 数据库 ~/.peri/threads/threads.db".to_owned(), + }; + // 只读 seam 的错误分类(库不存在/不可读/schema 不兼容/数据损坏)在这里仍是 + // 类型化事实:路径只加在 context 上,`ReadOnlyThreadStoreError` 留在 source chain 里 + // 可按 kind 判别;元数据命令的九字段 DTO 与退出码映射保留在消费侧(D-04)。 + crate::sessions::open_session_resources_read_only(db_path) + .await + .map(Self::from_facade) + .map_err(|error| anyhow::Error::new(error).context(format!("无法只读打开{describe}"))) } /// 打开资源:写打开失败且历史仍可读时降级为只读打开。 @@ -60,8 +203,8 @@ impl Resources { "默认 SQLite 数据库 ~/.peri/threads/threads.db".to_owned(), ), }; - match SqliteThreadStore::new(path.clone()).await { - Ok(store) => Ok(Self::read_write(store)), + match crate::sessions::open_facade(path.clone()).await { + Ok(facade) => Ok(Self::new(facade)), Err(error) => { let message = format!("无法打开{describe}: {error}"); match Self::open_read_only(&path, &error).await { @@ -72,34 +215,60 @@ impl Resources { } } - fn read_write(store: SqliteThreadStore) -> Self { + fn new(facade: crate::sessions::SessionResourcesImpl) -> Self { + Self::from_facade(Arc::new(facade)) + } + + /// 同一个具体实例的两个所有权面:业务句柄 + 部署关闭权。 + fn from_facade(facade: Arc) -> Self { + let session_resources: Arc = facade.clone(); Self { - thread_store: Arc::new(store), + session_resources, + shutdown: SessionStoreShutdownOwner::take(facade), } } - /// 写打开失败后的降级:只读打开成功即返回只读资源,否则返回 `None` 让调用方上报 + /// 拆出**业务句柄**与**部署关闭权**。 + /// + /// 工厂只在这里交出所有权:业务句柄(可克隆)进入 Agent/Controller/middleware, + /// 关闭权(non-Clone)留在部署侧,由部署在任务排空之后消费一次。 + pub fn into_parts(self) -> (Arc, SessionStoreShutdownOwner) { + (self.session_resources, self.shutdown) + } + + /// 会话资源门面句柄(已迁移消费侧的唯一入口)。 + pub fn session_resources(&self) -> Arc { + self.session_resources.clone() + } + + /// 只取业务句柄,随所有权一起放弃部署关闭权。 + /// + /// 供没有部署生命周期的调用点(只读命令、测试夹具)使用:它们既不排空、也不 + /// 负责关闭全局存储,连接随句柄释放;有部署生命周期的入口一律走 + /// [`Resources::into_parts`],不能靠本方法把关闭权丢掉。 + pub fn into_session_resources(self) -> Arc { + self.session_resources + } + + /// 仅测试:crate 内装配点取具体实例(业务侧只有 `Arc`)。 + #[cfg(test)] + pub(crate) fn into_concrete_for_test(self) -> Arc { + self.shutdown.facade + } + + /// 迁移期只读打开的降级:只读打开成功即返回只读资源,否则返回 `None` 让调用方上报 /// 写打开的原错误。 async fn open_read_only(path: &Path, error: &anyhow::Error) -> Option { if !degradable_open_failure(error) { return None; } - let store = SqliteThreadStore::open_existing_read_only(path) - .await - .ok()?; + let facade = crate::sessions::open_facade_read_only(path).await.ok()?; tracing::warn!( path = %path.display(), error = %error, "session store opened read-only: writable open failed" ); - Some(Self { - thread_store: Arc::new(store), - }) - } - - /// 会话存储句柄(trait object,供 Agent/ACP/TUI 注入) - pub fn thread_store(&self) -> Arc { - self.thread_store.clone() + Some(Self::new(facade)) } } @@ -119,6 +288,78 @@ fn degradable_open_failure(error: &anyhow::Error) -> bool { ) } +/// 打开会话存储失败的稳定分类。 +/// +/// 消费侧(`peri meta session` 等只读命令)按这个分类映射退出码与 DTO 文案,不需要 +/// downcast 资源层内部类型,也不需要解析错误文本。分类只覆盖**打开阶段**: +/// 打开成功后读取单条会话的失败仍按门面的 [`peri_acp_types::session_resources::SessionResourceError`] +/// 分类。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StoreOpenFailure { + /// 部署参数/配置错误:locator 形态、引擎名、凭证来源或访问意图相互矛盾。 + NotConfigured, + /// 目标库不存在(只读打开)。 + NotFound, + /// 库存在但不可读。 + Unreadable, + /// schema 与当前构建不兼容。 + SchemaIncompatible, + /// 已打开的库内容损坏。 + Corrupt, + /// 存储后端暂时不可用(远程连接/传输/服务端错误、超时、只读拒绝)。 + Unavailable, + /// 其余内部错误。 + Internal, +} + +/// 按类型化 source chain 分类打开失败,不解析错误文本、不回显 locator 或凭证。 +pub fn classify_open_failure(error: &anyhow::Error) -> StoreOpenFailure { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.as_ref()); + while let Some(current) = source { + if current.downcast_ref::().is_some() { + return StoreOpenFailure::NotConfigured; + } + // 凭证**来源**的问题(变量名非法、未设置、空值、非 Unicode,以及直接注入的空值) + // 同样是配置错误,不是「后端不可用」也不是「内部错误」——按类型判定,不解析错误 + // 文本;`CredentialError` 只携带变量名,不带值,因此分类不会回显任何凭证内容。 + if current.downcast_ref::().is_some() { + return StoreOpenFailure::NotConfigured; + } + // 远程后端的暂时不可用(传输/忙碌/服务端错误/超时)在打开阶段同样是 + // 「后端不可用」而不是「内部错误」:分类按类型,不解析错误文本,也不回显 locator。 + if let Some(remote) = current.downcast_ref::() { + match remote.kind() { + SessionResourceErrorKind::Unavailable { .. } + | SessionResourceErrorKind::Timeout + | SessionResourceErrorKind::ReadOnlyStore => return StoreOpenFailure::Unavailable, + SessionResourceErrorKind::Corrupt { .. } => return StoreOpenFailure::Corrupt, + _ => {} + } + } + if let Some(read_only) = current.downcast_ref::() + { + return match read_only { + crate::sessions::ReadOnlyThreadStoreError::DatabaseNotFound => { + StoreOpenFailure::NotFound + } + crate::sessions::ReadOnlyThreadStoreError::DatabaseUnreadable => { + StoreOpenFailure::Unreadable + } + crate::sessions::ReadOnlyThreadStoreError::SchemaIncompatible => { + StoreOpenFailure::SchemaIncompatible + } + crate::sessions::ReadOnlyThreadStoreError::CorruptSessionData => { + StoreOpenFailure::Corrupt + } + crate::sessions::ReadOnlyThreadStoreError::SessionNotFound + | crate::sessions::ReadOnlyThreadStoreError::Internal => StoreOpenFailure::Internal, + }; + } + source = current.source(); + } + StoreOpenFailure::Internal +} + #[cfg(test)] #[path = "context_test.rs"] mod tests; diff --git a/peri-resources/src/context_test.rs b/peri-resources/src/context_test.rs index 20245b614..0bb0b6726 100644 --- a/peri-resources/src/context_test.rs +++ b/peri-resources/src/context_test.rs @@ -1,11 +1,17 @@ //! context.rs 单元测试:`Resources::open_with` 显式路径语义。 +use std::path::PathBuf; + use tempfile::tempdir; +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::AccessMode; +use peri_acp_types::store::{PersistedPayload, ThreadStore}; use peri_acp_types::thread::ThreadMeta; use sqlx::{sqlite::SqliteConnectOptions, Connection, SqliteConnection}; use super::*; +use crate::sessions::{CredentialError, SqliteThreadStore}; /// [P0] 显式路径打开成功:数据库文件被创建,且同路径二次打开幂等。 #[tokio::test] @@ -108,8 +114,15 @@ async fn test_open_with_none_uses_default_store() { .await .unwrap(); assert!(db_path.is_file(), "None 分支必须打开注入的默认存储"); - let threads = resources.thread_store().list_threads().await; - assert!(threads.is_ok(), "默认存储应可查询: {:?}", threads.err()); + let page = resources + .session_resources() + .list_sessions(&peri_acp_types::workspace::ScopedThreadQuery { + scope: peri_acp_types::workspace::ThreadScope::All, + cursor: None, + limit: u32::MAX, + }) + .await; + assert!(page.is_ok(), "默认存储应可查询: {:?}", page.err()); } /// 会话库被占(schema 锁未释放):写打开失败不再挡住进入,降级为只读打开—— @@ -123,6 +136,12 @@ async fn test_open_with_busy_schema_lock_degrades_to_read_only() { .create_thread(ThreadMeta::new("/tmp/read-only-degradation")) .await .unwrap(); + // 门面列表语义只收已有历史的会话(`message_count > 0`):裸建的空 thread 不在 + // 列表里,夹具先落一条历史,断言才是在测降级后的可见性。 + writable + .append_message(&thread, BaseMessage::human("read-only degradation history")) + .await + .unwrap(); writable.close().await; // 持住 schema 锁:写打开按「初始化被占」失败,只读打开不受影响。 @@ -141,10 +160,23 @@ async fn test_open_with_busy_schema_lock_degrades_to_read_only() { held.lock().unwrap(); let resources = Resources::open_with(Some(db_path.clone())).await.unwrap(); - let store = resources.thread_store(); - let listed = store.list_threads().await.unwrap(); + // 只读降级的「列表仍可读」由门面回答;夹具不需要裸句柄。 + let listed = resources + .session_resources() + .list_sessions(&peri_acp_types::workspace::ScopedThreadQuery { + scope: peri_acp_types::workspace::ThreadScope::All, + cursor: None, + limit: u32::MAX, + }) + .await + .unwrap() + .entries; assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, thread); + assert_eq!(listed[0].thread.id, thread); + // 只读句柄用于断言「不得假装可写」:与降级后的门面同库、同为只读连接。 + let store = SqliteThreadStore::open_existing_read_only(&db_path) + .await + .unwrap(); assert!( store.delete_thread(&thread).await.is_err(), "只读降级不得假装可写:写入必须失败" @@ -152,3 +184,244 @@ async fn test_open_with_busy_schema_lock_degrades_to_read_only() { assert!(store.load_meta(&thread).await.is_ok(), "降级后历史仍可读"); drop(held); } + +fn git_repository() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ] { + let output = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", directory.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(directory.path()) + .args(&args) + .output() + .unwrap(); + assert!(output.status.success(), "git fixture failed"); + } + directory +} + +/// [P0] 迁移桥与门面必须共享同一库句柄:桥取得的执行权要能被门面的写入准入承认。 +/// +/// 这是本阶段的前提条件——ACP 仍经桥取得 owner,Agent 已改走门面写入。两者若各自 +/// 建一份连接与 owner 登记,门面会把正在跑的会话判成「无主」而拒绝写入;因此 +/// 资源测试入口 `open_store_and_facade_for_tests` 仍提供裸句柄供夹具逐条断言。 +#[tokio::test] +async fn test_bridge_lease_is_visible_to_shared_facade() { + let repo = git_repository(); + let db_dir = tempdir().unwrap(); + let (store, facade) = + crate::sessions::open_store_and_facade_for_tests(db_dir.path().join("threads.db")) + .await + .unwrap(); + + let workspace = store.resolve_workspace(repo.path()).await.unwrap(); + let thread = store + .create_bound_thread( + ThreadMeta::new(workspace.cwd.to_string_lossy().into_owned()), + &workspace, + ) + .await + .unwrap(); + let lease = store.acquire_execution_lease(&thread).await.unwrap(); + + facade + .append_history( + &thread, + &[PersistedPayload::Message(BaseMessage::human( + "shared handle", + ))], + ) + .await + .expect("桥取得的 owner 必须被门面写入准入承认"); + + let snapshot = facade.load_session_snapshot(&thread).await.unwrap(); + assert_eq!(snapshot.payloads.len(), 1); + assert_eq!(snapshot.meta.id, thread); + drop(lease); +} + +/// 部署所有权交付:工厂只在这里交出「业务句柄 + 部署关闭权」。 +/// +/// 断言两件可观察事实:业务句柄在关闭之前完全可用;唯一关闭权被消费之后,同一业务句柄 +/// 的新写入被明确拒绝(关闭是真实的,不是形式上的所有权搬运),读取仍然可用。 +#[tokio::test] +async fn test_into_parts_hands_out_business_handle_and_deployment_close_owner() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("threads.db"); + let resources = Resources::open_with(Some(db_path)).await.unwrap(); + let (business, owner) = resources.into_parts(); + + // 关闭之前:业务句柄照常回答问题(同一次打开、同一个库句柄)。 + business + .inspect_availability(None) + .await + .expect("an open store answers availability"); + + // 唯一关闭权消费一次:确认关闭之后,同一业务句柄不再接受新写入。 + owner + .shutdown() + .await + .expect("no live session: the store must close cleanly"); + let error = business + .append_history( + &"closed-session".to_owned(), + &[PersistedPayload::Message(BaseMessage::human("after close"))], + ) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::Unavailable { .. } + )); +} + +// ─── 打开失败的分类:凭证来源问题按类型归配置错误 ────────────────────────────── + +/// 显式不会存在的凭证变量名:表达「来源已给、变量/值确实没有」。 +const ABSENT_CREDENTIAL_ENV: &str = "PERI_CONTEXT_TEST_ABSENT_TOKEN_ENV"; +/// 子进程受控 HOME 的守卫变量:只在父用例拉起时出现。 +const HOME_GUARD_ENV: &str = "PERI_CONTEXT_TEST_REMOTE_CREDENTIAL_HOME"; +/// locator 哨兵:断言错误输出不回显它。 +const LOCATOR_SENTINEL: &str = "turso://sentinel-db-sentinel-org.turso.io"; +/// 子进程跑到断言的标记(父用例据此确认证据真的产生了)。 +const CHILD_REACHED_MARKER: &str = "context-child-verified-no-side-effects"; + +/// 凭证来源的每一种问题(名字非法、未设置、空值、非 Unicode、注入空值)都按**类型**归 +/// 「配置错误」:被 context 包成 source chain 之后仍被认出,分类不解析错误文本。 +#[test] +fn test_classify_open_failure_sees_wrapped_credential_errors() { + let cases = [ + CredentialError::InvalidName, + CredentialError::Missing { + name: ABSENT_CREDENTIAL_ENV.to_owned(), + }, + CredentialError::Empty { + name: ABSENT_CREDENTIAL_ENV.to_owned(), + }, + CredentialError::NotUnicode { + name: ABSENT_CREDENTIAL_ENV.to_owned(), + }, + CredentialError::EmptyValue, + ]; + + for error in cases { + let wrapped = anyhow::Error::new(error).context("无法打开远程会话存储"); + assert!( + wrapped + .chain() + .any(|cause| cause.downcast_ref::().is_some()), + "凭证问题必须留在 source chain 上:{wrapped:?}" + ); + assert_eq!( + classify_open_failure(&wrapped), + StoreOpenFailure::NotConfigured + ); + } +} + +/// 分类不被「有 context」放大:链上没有凭证问题的失败仍是内部错误。 +#[test] +fn test_classify_open_failure_keeps_untyped_failures_internal() { + let unrelated = anyhow::anyhow!("no typed cause here").context("无法打开远程会话存储"); + assert_eq!( + classify_open_failure(&unrelated), + StoreOpenFailure::Internal + ); +} + +/// 缺凭证必须在**任何库/登记副作用之前**失败:父用例把 HOME 指到临时目录拉起子进程, +/// 子进程真跑 `Resources::open_deployment`(远程 locator、变量显式不存在、只读意图)。 +#[cfg(unix)] +#[tokio::test] +async fn test_missing_remote_credential_fails_before_registry_side_effects() { + let home = tempdir().unwrap(); + let output = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "context::tests::test_missing_remote_credential_child_process", + "--nocapture", + ]) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env(HOME_GUARD_ENV, home.path()) + .env_remove(ABSENT_CREDENTIAL_ENV) + .output() + .await + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(stdout.contains("running 1 test"), "{stdout}"); + assert!( + stdout.contains(CHILD_REACHED_MARKER), + "子进程没跑到断言,本用例没有取得证据:{stdout}" + ); + assert_eq!( + std::fs::read_dir(home.path()).unwrap().count(), + 0, + "缺凭证之前不得留下任何文件或目录" + ); +} + +/// 只在父用例的受控环境里执行;普通全量跑(无守卫变量)直接返回,不动进程环境。 +#[cfg(unix)] +#[tokio::test] +async fn test_missing_remote_credential_child_process() { + let Some(home) = std::env::var_os(HOME_GUARD_ENV) else { + return; + }; + let home = PathBuf::from(home); + // 受控环境:变量确实不存在——不读 `.env`,也不会有任何真实网络调用。 + std::env::remove_var(ABSENT_CREDENTIAL_ENV); + assert!(std::env::var_os(ABSENT_CREDENTIAL_ENV).is_none()); + // HOME 控制已生效:默认登记库位置指向临时目录(只解析,不创建)。 + assert_eq!( + Resources::local_registry_path().unwrap(), + home.join(".peri").join("threads").join("threads.db") + ); + + let deployment = SessionStoreDeployment::from_locator(LOCATOR_SENTINEL) + .with_credential_env(ABSENT_CREDENTIAL_ENV) + .with_access(AccessMode::ReadOnly); + let error = match Resources::open_deployment(&deployment).await { + Ok(_) => panic!("缺凭证必须失败"), + Err(error) => error, + }; + assert_eq!( + classify_open_failure(&error), + StoreOpenFailure::NotConfigured + ); + // 错误里只允许出现「来源名」(`CredentialError` 的类型里根本没有凭证值字段, + // `SessionStoreCredential` 也不实现 `Debug`),locator 原文与凭证值一律不得出现。 + let rendered = format!("{error} {error:?}"); + assert!( + !rendered.contains("sentinel-db-sentinel-org"), + "不回显 locator: {rendered}" + ); + assert_eq!( + std::fs::read_dir(&home).unwrap().count(), + 0, + "缺凭证必须在建目录/开库/写侧车之前失败" + ); + println!("{CHILD_REACHED_MARKER}"); +} diff --git a/peri-resources/src/lib.rs b/peri-resources/src/lib.rs index b1a75d2f2..fe58570d9 100644 --- a/peri-resources/src/lib.rs +++ b/peri-resources/src/lib.rs @@ -15,4 +15,4 @@ pub mod lsp; pub mod sessions; pub mod workflow; -pub use context::Resources; +pub use context::{classify_open_failure, Resources, SessionStoreShutdownOwner, StoreOpenFailure}; diff --git a/peri-resources/src/sessions/canonical.rs b/peri-resources/src/sessions/canonical.rs new file mode 100644 index 000000000..126e02210 --- /dev/null +++ b/peri-resources/src/sessions/canonical.rs @@ -0,0 +1,144 @@ +//! canonical 会话 schema:两种执行器共用的一份形状与一份语句来源。 +//! +//! 用户裁决的目标是「远端库完全 = 本地库的模式,两个存储模式一致」,落成工程语言就是 +//! **一份 schema、两种 SQL 执行器**。本模块是那份 schema 的唯一来源:本机 SQLite adapter +//! (`sqlite_store`)与远端 over-the-wire adapter(`remote`)把**同一份 DDL** 下发到各自的 +//! 连接上,表名、列名、列语义、排序键因此不可能各自漂移。两个 adapter 之间剩下的差别只有 +//! 执行器本身(事务与超时、失败分类、远端独有的幂等账本)与各自的机制表。 +//! +//! ## 谁的表进这份 schema +//! +//! | 归属 | 表 | 为什么 | +//! | --- | --- | --- | +//! | canonical 会话数据(两端都有) | `threads` / `messages` / `session_bindings` / `projects` / `workspaces` | 会话事实、canonical 历史、不可变执行绑定与它引用的 workspace 记录(契约 §4.1 允许数据 adapter 保存不可变 binding 记录) | +//! | 本机执行事实(只有本机) | `execution_runs` | 执行代际是设备事实;远端没有执行面,不建、也不该建 | +//! | 执行器机制(只有远端) | `peri_op_ledger` / `peri_store_meta` | 幂等资格的账本与版本标记;本机用 `PRAGMA user_version` 与本地事务表达同一件事 | +//! +//! ## 排序键是形状的一部分 +//! +//! canonical 历史顺序 = **插入顺序**,两端都由 `messages` 的隐式 `rowid` 承载:写入按批 +//! 顺序落行,读取与 rewind 一律显式 `ORDER BY rowid` / `WHERE rowid > ?`。远端曾经用一列 +//! 显式 `ordinal` 表达同一件事,那是「远端不依赖引擎隐式列」的设计选择而非实测限制—— +//! 真引擎上 `rowid` 可投影、按插入序、跨连接稳定(探测项 3a/3b/3c),因此统一到本机 +//! 形状后该列与它的索引一并删除,两种执行器的语句文本才可能逐字一致。 +//! +//! ## 列的归属细节 +//! +//! `threads.cached_context` / `threads.context_cache_epoch` 是本机读取缓存的失效位:两端 +//! 同列(形状一致),远端没有缓存消费者,因此它落库后保持缺省值、不参与远端读取。把它 +//! 从这份 schema 里剔除只会让本机 DDL 变成「canonical + 追加列」的第二份形状,与目标相反。 + +use peri_acp_types::store::PersistedPayload; + +/// 会话事实表。 +pub(super) const THREADS_TABLE: &str = "threads"; + +/// canonical 历史表。 +pub(super) const MESSAGES_TABLE: &str = "messages"; + +/// 不可变绑定引用的项目记录。 +pub(super) const PROJECTS_TABLE: &str = "projects"; + +/// 不可变绑定引用的 workspace 记录。 +pub(super) const WORKSPACES_TABLE: &str = "workspaces"; + +/// 不可变执行绑定。 +pub(super) const SESSION_BINDINGS_TABLE: &str = "session_bindings"; + +/// canonical 表清单(父表在前,与 [`CREATE_TABLES_SQL`] 的顺序一致)。 +pub(super) const CANONICAL_TABLES: &[&str] = &[ + THREADS_TABLE, + MESSAGES_TABLE, + PROJECTS_TABLE, + WORKSPACES_TABLE, + SESSION_BINDINGS_TABLE, +]; + +/// 建表语句:本机新库与远端初始化下发的**同一份清单**,一条语句一个元素。 +/// +/// 一条一个元素而不是拼成一段:远端执行器的语句单元就是一条语句(`StatementSpec`), +/// 多条语句塞进一个请求里只有第一条会被解析——形状必须按执行器的最小单位给出,本机再把 +/// 它们合成一次 `raw_sql`(本机执行器支持多语句)。 +/// +/// 带 `IF NOT EXISTS`:本机旧库已存在这些表时是空操作(列由 `sqlite_store` 的迁移路径补齐), +/// 远端重复打开时同样是空操作。`REFERENCES` 子句保留原样:本机读写在同一连接上打开 +/// `PRAGMA foreign_keys`,远端服务端不强制外键(读数恒为 0、且不可开启)——同一份 DDL 在两种 +/// 执行器上的差别是**强制与否**,不是形状。顺序即依赖顺序:父表在前。 +pub(super) const CREATE_TABLES: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, title TEXT, cwd TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, message_count INTEGER NOT NULL DEFAULT 0, + parent_thread_id TEXT, snapshot_at_message_id TEXT, hidden BOOLEAN NOT NULL DEFAULT 0, + cancel_policy TEXT NOT NULL DEFAULT 'cascade', config TEXT, cached_context TEXT, + frozen_context TEXT, inherited_context TEXT, agent_status TEXT NOT NULL DEFAULT 'active', + context_cache_epoch INTEGER NOT NULL DEFAULT 0 +)", + "CREATE TABLE IF NOT EXISTS messages ( + message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, content TEXT NOT NULL, + truncated BOOLEAN NOT NULL DEFAULT 0, excluded BOOLEAN NOT NULL DEFAULT 0, projection TEXT +)", + "CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, locator TEXT NOT NULL, object_identity TEXT NOT NULL, + UNIQUE(locator, object_identity) +)", + "CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), + root TEXT NOT NULL, root_identity TEXT NOT NULL, discovery TEXT NOT NULL, + UNIQUE(root, root_identity), UNIQUE(id, project_id) +)", + "CREATE TABLE IF NOT EXISTS session_bindings ( + thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL, + project_id TEXT NOT NULL, workspace_id TEXT NOT NULL, relative_cwd TEXT NOT NULL, + FOREIGN KEY(workspace_id, project_id) REFERENCES workspaces(id, project_id) +)", +]; + +/// canonical 索引名(与 [`CREATE_INDEXES`] 的顺序一一对应):形状核对按名字断言索引齐全。 +#[cfg(test)] +pub(super) const CANONICAL_INDEXES: &[&str] = &[ + "idx_messages_thread_id", + "idx_bindings_project", + "idx_bindings_workspace", + "idx_threads_updated", +]; + +/// 索引语句:必须在建表**与旧库补列之后**执行(`idx_threads_updated` 引用后补的列)。 +pub(super) const CREATE_INDEXES: &[&str] = &[ + "CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id)", + "CREATE INDEX IF NOT EXISTS idx_bindings_project ON session_bindings(project_id, thread_id)", + "CREATE INDEX IF NOT EXISTS idx_bindings_workspace ON session_bindings(workspace_id, relative_cwd, thread_id)", + "CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC, id DESC) WHERE hidden = 0 AND message_count > 0", +]; + +/// 删除一条 `threads` 行之前必须显式清理的子表:子表名 + 语句。 +/// +/// **为什么两端都显式删**:远端执行器提供不了级联(`PRAGMA foreign_keys` 在服务端读数为 0、 +/// 且不可开启;远端也没有 `pragma_foreign_key_check` 等价物)。一份删除逻辑跑在两种执行器上, +/// 唯一能共用的表达就是显式删除,因此本机侧也按同一份语句、同一顺序(先子后父)删。 +/// 本机 DDL 里的 `ON DELETE CASCADE` 声明保留,但已退化为空操作式安全网。 +pub(super) const THREAD_CHILD_DELETES: &[(&str, &str)] = &[ + (MESSAGES_TABLE, DELETE_MESSAGES_BY_THREAD_SQL), + (SESSION_BINDINGS_TABLE, DELETE_BINDINGS_BY_THREAD_SQL), +]; + +/// 删除一个会话的全部历史行。 +pub(super) const DELETE_MESSAGES_BY_THREAD_SQL: &str = "DELETE FROM messages WHERE thread_id = ?1"; + +/// 删除一个会话的不可变绑定行。 +pub(super) const DELETE_BINDINGS_BY_THREAD_SQL: &str = + "DELETE FROM session_bindings WHERE thread_id = ?1"; + +/// 删除 `threads` 行本身;只在 [`THREAD_CHILD_DELETES`] 之后执行(先子后父)。 +pub(super) const DELETE_THREAD_ROW_SQL: &str = "DELETE FROM threads WHERE id = ?1"; + +/// `messages.role` 的取值:canonical payload 的领域规则,两端写同一列时用同一份派生。 +/// +/// 规则本身属于领域(`BaseMessage` → 角色名),放在这里只为了不让两个 adapter 各写一份。 +pub(super) fn payload_role(payload: &PersistedPayload) -> &'static str { + match payload { + PersistedPayload::Message(message) => super::sqlite_store::role_of_message(message), + PersistedPayload::SystemReminder { .. } => "system_reminder", + } +} diff --git a/peri-resources/src/sessions/data.rs b/peri-resources/src/sessions/data.rs new file mode 100644 index 000000000..461b10fbb --- /dev/null +++ b/peri-resources/src/sessions/data.rs @@ -0,0 +1,228 @@ +//! 数据端口 — 资源模块内部行为 seam,由两个 adapter(本机 SQLite / Turso Cloud)实现。 +//! +//! 边界规则: +//! +//! - 只暴露会话行为,**不**暴露 SQL、事务、CAS、隔离级别、连接、SQL batch、底层 +//! 重试令牌或补偿协议;「整体生效或整体不生效」是行为后置条件,由 adapter 自行 +//! 选择机制实现。 +//! - 业务侧拿不到本 trait:门面(`SessionResourcesImpl`)是唯一调用方,也是唯一注入点。 +//! 本机执行授权(owner、dirty、准入、排空)属于执行面,不在数据端口里: +//! `SessionExecutionLease`、`acquire_execution` 与落地登记由执行面持有。 +//! - 调用方(门面)负责在调用前完成本机授权与未决持久化检查;adapter 只负责数据事实, +//! 不得把「没有权限」静默降级成「没有数据」。 +//! +//! 会话本机身份(store id / 安装 id)只出现在资源层与持久化记录,不进业务 DTO、 +//! 不进 ACP wire。 + +use async_trait::async_trait; +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + BindingState, ChildSnapshot, ForkSnapshot, FrozenSnapshotBytes, NewSession, + PersistenceRecovery, RewindBoundary, SessionMetaPatch, SessionResourceResult, SessionSnapshot, +}; +use peri_acp_types::store::{CompactionChange, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{ThreadId, ThreadMeta}; +use peri_acp_types::workspace::{ + ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, SessionBinding, +}; + +use super::sqlite_store::invalid_input; + +// 本机执行事实(执行代际、owner、OS 锁、工作区登记)不属于本端口:它们在 +// `super::local_port::LocalExecutionPort`。这里只保留两个 adapter 共同承担的会话数据行为。 + +/// child resume 认领的持久化事实:状态 + 是否处于认领中。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChildResumeRecord { + pub status: peri_acp_types::thread::AgentStatus, + pub claimed: bool, +} + +/// 会话数据行为端口。 +/// +/// 每个 mutation 的领域后置条件与门面一致;adapter 可以自由选择实现机制 +/// (SQLite 同库塌缩为一个事务、远程 adapter 见其自身原子保证),但不得把 +/// 「未生效」报告成成功,也不得在失败后遗留部分写入。 +#[async_trait] +pub(crate) trait SessionDataPort: Send + Sync { + /// 保存新会话:meta/binding/frozen 完整落库(本机准入由执行面另行完成)。 + /// + /// 本地塌缩把「完整数据 + 执行代际」并成一次提交,因此本机构建不经过本方法; + /// 它的生产调用方是远程组合(先 durable 保存、再本机准入),本地用它构造 + /// 「数据已保存、执行代际未写」的收敛状态。 + async fn save_new_session(&self, input: &NewSession) -> SessionResourceResult<()>; + + /// 撤销本次未发布的创建:只针对本次初始化,不修改既有 source 会话。 + async fn revoke_unpublished_session(&self, id: &ThreadId) -> SessionResourceResult<()>; + + /// 接纳 legacy 会话:binding 与缺失的 frozen 一次成立,已有值不变。 + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()>; + + /// 一致读取:meta/binding 分类/frozen 状态/own payload+flags/inherited。 + async fn load_snapshot(&self, id: &ThreadId) -> SessionResourceResult; + + /// 轻量绑定分类(不加载历史):记录事实三种——绑定与本机登记一致、绑定指向的登记 + /// 已不存在(含子会话没有绑定行)、无绑定行。`LegacyConfirmed` 由门面按本机来源 + /// 证据联合判定,不在这里冒充。 + async fn load_binding(&self, id: &ThreadId) -> SessionResourceResult; + + /// 完整逻辑上下文:继承区在前、自有 payload 在后。 + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult>; + + /// 小型 metadata 投影(不加载历史或大快照)。 + async fn load_meta(&self, id: &ThreadId) -> SessionResourceResult; + + /// 会话数据是否存在(不含终态判定)。 + async fn session_exists(&self, id: &ThreadId) -> SessionResourceResult; + + /// 已保存的绑定字节;没有绑定行时 `None`。 + /// + /// 绑定字节是**数据事实**:本机 adapter 从 `session_bindings` 读,远端 adapter 从远端 + /// 会话行自带的 `binding_*` 列读。执行面只按调用方给出的字节做本机目录复核。 + async fn binding_of(&self, id: &ThreadId) -> SessionResourceResult>; + + /// 该会话在树中的根(含自身)。 + /// + /// 父链是数据事实:未结清判定按整棵树聚合时,远端会话在本机没有 `threads` 行, + /// 因此「这条会话属于哪棵树」只能由持有 canonical 父链的一侧回答。 + async fn session_root(&self, id: &ThreadId) -> SessionResourceResult; + + /// 分页列举:过滤在数据端完成。 + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult; + + /// 直接子会话 metadata。 + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult>; + + /// 以 `root` 为根的整棵树 metadata(含自身)。 + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult>; + + /// 追加 canonical payload 批次:顺序稳定、计数与自动标题一致维护, + /// id 冲突(已存在或批次内重复)必须失败,不得静默忽略。 + async fn append_history( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()>; + + /// 保存 fork 目标快照;source 不变。 + async fn save_fork(&self, fork: &ForkSnapshot) -> SessionResourceResult<()>; + + /// 保存 child:继承区与父子关系一起成立。 + /// + /// 调用前必须先过 [`ensure_child_relation`]:父子关系在快照里出现两次(`parent_id` 与 + /// `target.meta.parent_thread_id`),不一致时落库的事实会与声明的相反。 + async fn save_child(&self, child: &ChildSnapshot) -> SessionResourceResult<()>; + + /// 读取 child resume 认领事实。 + async fn load_child_resume_record( + &self, + child: &ThreadId, + ) -> SessionResourceResult; + + /// 写入 child resume 认领事实(active 标记与终态由门面按领域结果给出)。 + async fn store_child_resume_record( + &self, + child: &ThreadId, + record: &ChildResumeRecord, + ) -> SessionResourceResult<()>; + + /// 应用一次 compaction 变更:flags、摘要追加、派生计数与缓存视图全部生效或全不生效。 + async fn apply_compaction( + &self, + id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()>; + + /// 应用投影/flags 变更集,并由数据端同步维护派生缓存视图。 + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()>; + + /// 按显式边界 rewind。 + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()>; + + /// 按 id 集合精确移除历史条目。 + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()>; + + /// 定向 metadata 更新。 + async fn update_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()>; + + /// 删除会话树:删除即删除,数据删除之后本机与远端都不再持有该会话的终态证据。 + async fn delete_tree(&self, id: &ThreadId) -> SessionResourceResult<()>; + + /// 收敛未决持久化,返回是否已可重载。 + /// + /// 本机实现在同事务内完成写入,因此没有需要收敛的中间态;远程实现的收敛由远端 + /// adapter 内部完成(见其模块文档)。本机**没有**跨进程的未决记录:那类锚点已被 + /// 移除(用户裁决不做跨安装能力),未决只在本进程的租约上表达。 + async fn recover_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult; + + /// 排空该会话已排队的持久化写入(有界等待)。 + async fn drain(&self, id: &ThreadId) -> SessionResourceResult<()>; + + /// 关闭数据端口:返回后不再接受新写入。 + async fn close(&self) -> SessionResourceResult<()>; +} + +// ─── child 快照的输入一致性:唯一一条规则 ───────────────────────────────────── + +/// child 快照自身的父子/根归属是否自洽——**唯一**一条判定,门面与两个 adapter 共用。 +/// +/// 落库的父子关系取自 `child.target.meta.parent_thread_id`,而调用方声明的关系在 +/// `child.parent_id` / `child.root_id`。两组字段必须指向同一次关系,否则会出现「准入按声明、 +/// 落库按另一套」的两种真相:声明了合法父/根、而目标 meta 里没有父的 child 会被写成一条 +/// 没有父的**独立 root**,此后它还能自己取得执行权。 +/// +/// | 拒绝理由 | 为什么不能交给别处 | +/// | --- | --- | +/// | `target.meta.parent_thread_id != Some(parent_id)`(含 `None`) | 声明不是权威,落库才是;库内看不出「声明被忽略」 | +/// | `parent_id == target.thread_id` | 自指父关系会形成环 | +/// | `root_id == target.thread_id` | 待创建的新 child 不可能已经是自己的根 | +/// +/// 判定不读任何存储、不看「库里有没有会话」,因此门面可以在**任何副作用之前**拒绝 +/// (不发门禁、不留未决证据),adapter 也能在连接或事务之前拒绝。 +pub(in crate::sessions) fn ensure_child_relation( + child: &ChildSnapshot, +) -> SessionResourceResult<()> { + if child.target.meta.parent_thread_id.as_ref() != Some(&child.parent_id) { + return Err(invalid_input( + "child snapshot parent relation disagrees with its target meta", + )); + } + if child.parent_id == child.target.thread_id { + return Err(invalid_input("child session cannot be its own parent")); + } + if child.root_id == child.target.thread_id { + return Err(invalid_input("child session cannot be its own root")); + } + Ok(()) +} diff --git a/peri-resources/src/sessions/default_path_test.rs b/peri-resources/src/sessions/default_path_test.rs index bab4a4b16..c2789e945 100644 --- a/peri-resources/src/sessions/default_path_test.rs +++ b/peri-resources/src/sessions/default_path_test.rs @@ -1,4 +1,4 @@ -use super::{default_database_path, open_thread_store_read_only, ReadOnlyStoreErrorKind}; +use super::{default_database_path, open_session_resources_read_only, ReadOnlyStoreErrorKind}; use std::path::PathBuf; #[tokio::test] @@ -36,7 +36,7 @@ async fn test_default_database_path_child_process() { let expected = home.join(".peri").join("threads").join("threads.db"); assert_eq!(default_database_path(), Some(expected)); assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0); - match open_thread_store_read_only(None).await { + match open_session_resources_read_only(None).await { Ok(_) => panic!("missing default database must not be created by read-only open"), Err(error) => assert_eq!(error.kind(), ReadOnlyStoreErrorKind::DatabaseNotFound), } diff --git a/peri-resources/src/sessions/filesystem.rs b/peri-resources/src/sessions/filesystem.rs index 01c58776f..61527d06a 100644 --- a/peri-resources/src/sessions/filesystem.rs +++ b/peri-resources/src/sessions/filesystem.rs @@ -10,7 +10,7 @@ use tokio::{fs, io::AsyncWriteExt}; use peri_acp_types::{ messages::BaseMessage, store::{ - deserialize_persisted_payload, serialize_persisted_payload, CompactionLifecycle, + deserialize_persisted_payload, serialize_persisted_payload, CompactionChange, InheritedContext, PersistedPayload, ThreadStore, }, thread::{AgentStatus, ThreadId, ThreadListEntry, ThreadMeta}, @@ -465,7 +465,7 @@ impl ThreadStore for FilesystemThreadStore { async fn commit_compaction_lifecycle( &self, thread_id: &ThreadId, - lifecycle: &CompactionLifecycle, + lifecycle: &CompactionChange, ) -> Result<()> { let _ = (thread_id, lifecycle); anyhow::bail!( diff --git a/peri-resources/src/sessions/filesystem_test.rs b/peri-resources/src/sessions/filesystem_test.rs index a1187e8a4..61ca79aa3 100644 --- a/peri-resources/src/sessions/filesystem_test.rs +++ b/peri-resources/src/sessions/filesystem_test.rs @@ -393,7 +393,7 @@ async fn test_commit_compaction_lifecycle_is_explicitly_unsupported_without_muta .unwrap(); let summary = BaseMessage::human("文件系统不应追加的摘要"); - let lifecycle = CompactionLifecycle { + let lifecycle = CompactionChange { flag_updates: vec![( original_messages[0].id(), peri_acp_types::store::MessageFlags { diff --git a/peri-resources/src/sessions/local_port.rs b/peri-resources/src/sessions/local_port.rs new file mode 100644 index 000000000..ea31d0658 --- /dev/null +++ b/peri-resources/src/sessions/local_port.rs @@ -0,0 +1,191 @@ +//! 本机执行面端口 — 门面持有的**本机执行事实**行为接缝。 +//! +//! 与 [`super::data::SessionDataPort`] 的分工是事实归属,不是实现细节: +//! +//! - 数据端口回答 canonical 会话数据(会话行、绑定字节、历史、frozen、父链); +//! - 本端口回答**只可能由本机回答**的事:工作区发现与登记证据、执行代际、OS 锁、 +//! 在途写入门禁、创建准入。lease 只在这里出现,数据端口里没有它。 +//! +//! 只有唯一实现 [`LocalExecution`](本机 SQLite):远端组合的 canonical 数据在远端, +//! 但执行事实(代际、锁、owner)仍只写在本机库。绑定字节与父链由数据端口提供——远端 +//! 组合给的是远端会话行自带的 `binding_*` 列,本机组合给的是本机 `session_bindings`。 +//! 本端口因此不查绑定行,只接受调用方给出的字节并做**本机复核**(目录证据、关系)。 +//! +//! 门面不区分后端:它按同一个端口调用,组合层决定注入哪个数据端口。公开行为仍只有 +//! 一套(门面的行为清单),没有为远程另建平行行为。 + +use std::future::Future; +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use peri_acp_types::session_resources::{NewSession, SessionResourceResult}; +use peri_acp_types::thread::ThreadId; +use peri_acp_types::workspace::{ + RecoveryRequiredDetails, ResolvedWorkspace, SessionBinding, SessionExecutionLease, +}; + +use super::sqlite_store::{ExclusiveExecutionGuard, ExecutionLease, ExecutionWriteGuard}; + +/// 一次撤销补偿(放弃未发布创建时由门面提供的唯一副作用)。 +/// +/// 调用方只给「撤销这次创建」这一件事,执行/锁顺序仍由本端口实现决定:补偿先成功, +/// 才关闭准入并释放 OS 锁。 +pub(in crate::sessions) type RevokeEffect<'a> = + Pin> + Send + 'a>>; + +/// 数据面给出的会话事实:绑定字节、这棵树有没有绑定、树根。 +/// +/// 三件都只可能由持有 canonical 数据的一侧回答([`super::data::SessionDataPort::binding_of`] / +/// [`super::data::SessionDataPort::session_root`]):远端组合里本机没有这条会话的任何行,本机 +/// 执行面因此不查本机的 `threads` / `session_bindings`,只按调用方给出的值判定。 +/// +/// 用途是确定的:绑定字节用于取得所有权前的关系复核,`bound` 区分「无绑定历史(没有可保护 +/// 的执行域)」与「有绑定但无活 owner(必须拒绝写入)」,`root` 定位子会话的 owner——子会话 +/// 由 root 的租约与它的关闭事务统一持有,自己没有租约也不写 `execution_runs`。 +pub(in crate::sessions) struct SessionFacts { + /// 这条会话自己的绑定字节;没有绑定行时 `None`。 + pub binding: Option, + /// 这棵**树**在数据面上有没有绑定:自身或 root 有绑定即算有。 + /// + /// 两种来源都要看:接纳过的 legacy root 可以有自己没有绑定行的子会话,那些子会话的写入 + /// 同样落在 root 的执行域里,不能因为「自己无绑定」就当成无主放行。 + pub bound: bool, + /// 这条会话在树中的根,含自身。 + pub root: ThreadId, +} + +/// 本机执行面端口。 +/// +/// 所有方法都是「本机事实」,没有一条会去远端写数据:远端写入由数据端口在门面编排下 +/// 完成,本端口只在写完之后建立或复核本机执行资格。 +#[async_trait] +pub(in crate::sessions) trait LocalExecutionPort: Send + Sync { + /// 本机是否只读打开(只读时任何执行资格都不可得,历史仍可读)。 + fn is_read_only(&self) -> bool; + + // ── 发现与登记 ── + + /// 解析并登记本机执行目录。 + async fn resolve_workspace(&self, cwd: &Path) -> Result; + + /// 用调用方给出的绑定字节做本机复核:目录关系、关键文件对象,`full` 为真时再叠一次 + /// 完整发现快照比对(一次准入的权威复核)。 + /// + /// 本机组合的字节来自本机 `session_bindings`,远端组合的字节来自远端会话行;两种组合 + /// 走的是同一套本机判定,因此复核结论不会因为数据在哪而不同。 + async fn validate_binding_value( + &self, + binding: &SessionBinding, + full: bool, + ) -> Result; + + /// 本机来源证据是否足以把无绑定历史表达成 legacy(远端组合由门面固定为 `false`)。 + async fn legacy_confirmed(&self, id: &ThreadId) -> Result; + + // ── owner / dirty ── + + /// 本机执行代际事实(generation, clean);不创建锁文件、不改状态。 + async fn execution_state(&self, id: &ThreadId) -> Result>; + + /// 沿 root 关系找到活 owner;`None` 表示整棵树既没有绑定也没有活 owner。 + /// + /// 树形事实由调用方从数据面给出([`SessionFacts::root`]),本机不沿自己的 `threads` 上溯。 + async fn owner_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>>; + + /// 诊断读取:本进程是否持有这棵树的 owner(没有不构成错误)。 + async fn live_owner( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>>; + + /// 本进程当前持有的全部活 owner(关闭协调用;不跨进程探测)。 + fn live_leases(&self) -> Vec>; + + /// 读侧写入准入(允许同 root 并发 mutation)。 + async fn write_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>; + + /// 写侧写入准入(把「检查 + 写入」做成不可插入的区间)。 + async fn exclusive_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>; + + /// 取得已有会话的执行所有权。 + /// + /// 只有 root 能取得所有权([`SessionFacts::root`] 就是它自己);绑定关系用调用方给出的 + /// [`SessionFacts::binding`] 字节在本机复核一次(准入内不重复完整发现)。 + async fn acquire_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>; + + /// 解除精确代际的本机 dirty(CAS 在实现内部,不跨接口传递)。 + async fn reset_dirty(&self, target: &RecoveryRequiredDetails) -> Result<()>; + + // ── 创建准入 ── + + /// 新建会话的执行准入(本地塌缩:数据与执行代际一次提交)。 + /// + /// 只有「数据与执行代际在同一个本机库」的组合调它;数据在另一端的组合先由数据端口 + /// 保存,再调 [`Self::admit_existing`](见 `SessionDataHome`)。 + async fn create_session( + &self, + input: &NewSession, + ) -> SessionResourceResult>; + + /// 为「数据已完整保存、还没有执行代际」的会话建立准入(收敛,不是重建)。 + /// + /// `binding` 是**数据面给出的绑定字节**(本机组合来自本机 `session_bindings`,远程组合 + /// 来自远端会话行)——会话是否存在由数据面回答,本机只做本机能回答的那部分:执行代际 + /// 的唯一性、sidecar 锁,以及「这组字节指向本机已登记的目录」这条复核。调用方必须先在 + /// 数据面证明会话已保存;本端口不查本机会话表来代它证明。 + async fn admit_existing( + &self, + id: &ThreadId, + binding: &SessionBinding, + ) -> SessionResourceResult>; + + /// 放弃一次未发布创建的所有权并执行补偿。 + async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + revoke: RevokeEffect<'_>, + ) -> SessionResourceResult<()>; + + // ── 删除后的收尾 ── + + /// 会话**数据已被删除**:结束这条 identity 的本机执行事实与所有权。 + /// + /// 删除是完整生命周期行为,数据消失之后本机也不该留下任何属于它的执行事实: + /// + /// 1. 删掉本机 `execution_runs` 里这条 identity 的代际行。本机组合在删数据的同一事务里 + /// 已经删过(这里是幂等的空操作);数据在**远端**的组合则靠这一步收敛——远端行消失 + /// 之后本机还留着一条代际行,那是一条没有对象的行。 + /// 2. 本进程若持有它的活 owner,按「数据已删除」的终态释放(`ExecutionLease::dispose_ownership`, + /// 不做 clean CAS)。没有活 owner 不是错误:数据面已经删干净了,本机没有可结束的所有权。 + /// + /// 只由门面在数据面删除**返回成功之后**调用:本方法不判断数据在不在,也不把「行缺失」 + /// 当成删除的证据。删除失败时门面不会调它(所有权原样保留,调用方仍可重试或显式放弃)。 + async fn dispose_execution(&self, id: &ThreadId) -> SessionResourceResult<()>; + + /// 测试用:本机 SQLite 连接池。 + #[cfg(test)] + fn sqlite_pool(&self) -> Option<&sqlx::SqlitePool> { + None + } +} diff --git a/peri-resources/src/sessions/mod.rs b/peri-resources/src/sessions/mod.rs index a4a9e5c4a..ee58ef9ab 100644 --- a/peri-resources/src/sessions/mod.rs +++ b/peri-resources/src/sessions/mod.rs @@ -4,35 +4,79 @@ //! 契约类型(`ThreadStore` trait / `ThreadMeta` / `BaseMessage` / `MessageFlags`)位于 //! peri-acp-types(接口契约归 peri-acp-types),本模块仅实现,不解释业务语义。 +mod canonical; +mod data; mod filesystem; +mod local_port; +mod open; +mod remote; +// `CredentialError` 只做 crate 内最小 re-export:分类(`classify_open_failure`)要按类型认出 +// 「凭证来源不可用」,但凭证类型不进公共 API,也不向消费侧暴露 SDK 类型或凭证值。 +pub(crate) use remote::{open_remote, CredentialError, RemoteEndpoint}; +mod resources; mod sqlite_store; pub use filesystem::FilesystemThreadStore; +pub use resources::SessionResourcesImpl; pub use sqlite_store::{ReadOnlyStoreErrorKind, ReadOnlyThreadStoreError, SqliteThreadStore}; +pub(crate) use open::{AccessIntent, LocatorError, ResolvedLocator, SessionStoreOpenRequest}; + use std::path::PathBuf; use std::sync::Arc; -use peri_acp_types::store::ThreadStore; - /// 只解析默认数据库位置;不创建目录、数据库或连接。 fn default_database_path() -> Option { dirs_next::home_dir().map(|home| home.join(".peri").join("threads").join("threads.db")) } -/// 只读打开显式路径或默认路径下已存在的 thread database。 -pub async fn open_thread_store_read_only( +/// crate 内的只读打开 seam:按显式路径或默认路径打开已存在的会话库,返回门面实例。 +/// +/// 不创建目录、库、schema 或锁文件;只读打开的事实由 +/// [`ReadOnlyThreadStoreError`] 如实报告(库不存在 / 不可读 / schema 不兼容 / 损坏)。 +/// 返回具体实例只服务 crate 内装配([`crate::context::Resources`] 的只读降级),本函数 +/// 不交关闭权:实例上没有关闭路径,部署关闭权只由 `Resources` 装配入口交出。 +pub(crate) async fn open_session_resources_read_only( db_path: Option, -) -> Result, ReadOnlyThreadStoreError> { +) -> Result, ReadOnlyThreadStoreError> { let path = match db_path { Some(path) => path, None => default_database_path().ok_or(ReadOnlyThreadStoreError::Internal)?, }; - let store = SqliteThreadStore::open_existing_read_only(path).await?; - Ok(Arc::new(store)) + let facade = SessionResourcesImpl::open_existing_read_only(path).await?; + Ok(Arc::new(facade)) +} + +/// 生产装配:打开会话资源门面(写打开,必要时原地升级已知旧 schema)。 +pub(crate) async fn open_facade( + db_path: impl Into, +) -> anyhow::Result { + SessionResourcesImpl::open(db_path).await +} + +/// [`open_facade`] 的只读版本。 +pub(crate) async fn open_facade_read_only( + db_path: &std::path::Path, +) -> Result { + SessionResourcesImpl::open_existing_read_only(db_path).await +} + +/// 资源测试入口(仅测试夹具):同一个库句柄上的裸存储与门面。 +/// +/// 夹具需要「逐条构造事实(create_thread / append …)+ 用门面消费」时配对打开; +/// 生产装配一律走 `open_facade`,不通过本函数取裸句柄——两个入口各自打开同一库 +/// 文件会得到两份 owner 登记,本函数的存在正是为了不出现那种「第二个真相」。 +pub async fn open_store_and_facade_for_tests( + db_path: impl Into, +) -> anyhow::Result<(SqliteThreadStore, SessionResourcesImpl)> { + SqliteThreadStore::open_shared(db_path).await } // dirs-next uses the Windows profile known folder, so HOME cannot isolate these tests there. #[cfg(all(test, unix))] #[path = "default_path_test.rs"] mod default_path_tests; + +#[cfg(test)] +#[path = "open_test.rs"] +mod open_tests; diff --git a/peri-resources/src/sessions/open.rs b/peri-resources/src/sessions/open.rs new file mode 100644 index 000000000..2a857fac7 --- /dev/null +++ b/peri-resources/src/sessions/open.rs @@ -0,0 +1,407 @@ +//! 会话存储定位与打开请求(D:配置与装配)。 +//! +//! 打开入口表达「会话存储在哪里、如何访问」,而不是「SQLite 文件在哪里」: +//! +//! - `--db-path` 归一出的已确认本机路径不进入 locator 解析:不解 `env:`、不判 URL、 +//! 不经字符串往返,非 UTF-8 字节原样交给本机装配; +//! - `--session-store` 原文里的本机路径保留 Windows drive/UNC 语义,`C:` 不会被当成 +//! URI scheme; +//! - `env:<变量名>` 只解引用一次、不递归,用于把 URL 之类的值放在环境变量里; +//! - 远程 locator 的引擎只能由已确认语法(`turso://` / `libsql://`)或显式选择给出, +//! `https://` 单独出现时明确要求显式选择,不按 scheme、端口或响应猜; +//! - 凭证只经 [`CredentialSource`](环境变量名)表达,本模块不读 `.env`、不搜索 cwd +//! 或父目录、不内置默认名或别名;远程模式缺少凭证来源直接报配置错误; +//! - [`AccessIntent`] 只表达「打算怎么用」,只接受确认过的拼写;参数存在不等于拿到写权限, +//! 能不能写由打开结果回答; +//! - 只有 `env:` 形式的 locator 会读进程环境:环境里存在某个云 URL 变量**不会**切换后端, +//! 也不会把默认本机库换成远程库;解析 locator 不等价于读取凭证值。 +//! +//! 本模块是纯解析与校验:不做 I/O、不连接、不创建目录或文件,也不宣告连接成功。 + +use std::fmt; +use std::path::PathBuf; + +use peri_acp_types::session_resources::AccessMode; +use peri_acp_types::session_store::{SessionStoreDeployment, SessionStoreLocator}; + +use super::remote::{ + CredentialError, CredentialSource, EndpointError, RemoteEndpoint, RemoteEngine, +}; + +/// locator 解析/校验失败。只携带**变量名**与稳定分类,不携带 locator 原文或凭证值。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LocatorError { + Empty, + /// `env:` 后面的变量名不合法。 + InvalidEnvReference, + EnvValueMissing { + name: String, + }, + EnvValueEmpty { + name: String, + }, + EnvValueNotUnicode { + name: String, + }, + /// `env:` 指向的值又是一个 `env:` 引用:只解引用一次,不递归。 + EnvValueIsReference { + name: String, + }, + Remote(EndpointError), + /// 显式引擎名不是已知取值(`turso` / `libsql`)。 + UnknownEngine, + /// 远程 locator 但没有给出凭证来源。 + MissingCredentialSource, + /// 本机 locator 却给了凭证来源:不静默忽略配置。 + CredentialSourceForLocalStore, + /// 本机 locator 却给了引擎名:不静默忽略配置。 + EngineForLocalStore, +} + +impl fmt::Display for LocatorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("session store locator is empty"), + Self::InvalidEnvReference => { + formatter.write_str("session store env reference is not a valid variable name") + } + Self::EnvValueMissing { name } => write!( + formatter, + "session store env variable {name} is not set; the locator is not defined" + ), + Self::EnvValueEmpty { name } => { + write!(formatter, "session store env variable {name} is empty") + } + Self::EnvValueNotUnicode { name } => write!( + formatter, + "session store env variable {name} is not valid unicode" + ), + Self::EnvValueIsReference { name } => write!( + formatter, + "session store env variable {name} is itself an env reference; only one level is resolved" + ), + Self::Remote(error) => write!(formatter, "{error}"), + Self::UnknownEngine => formatter.write_str( + "unknown session store engine; expected one of the confirmed engine names", + ), + Self::MissingCredentialSource => formatter.write_str( + "remote session store requires an explicit credential source; no default is assumed", + ), + Self::CredentialSourceForLocalStore => formatter.write_str( + "credential source was given for a local session store; it is not silently ignored", + ), + Self::EngineForLocalStore => formatter.write_str( + "an engine was named for a local session store; it is not silently ignored", + ), + } + } +} + +impl std::error::Error for LocatorError {} + +impl From for LocatorError { + fn from(error: EndpointError) -> Self { + Self::Remote(error) + } +} + +impl From for LocatorError { + fn from(error: CredentialError) -> Self { + match error { + CredentialError::InvalidName => Self::InvalidEnvReference, + CredentialError::Missing { name } => Self::EnvValueMissing { name }, + CredentialError::Empty { name } => Self::EnvValueEmpty { name }, + CredentialError::NotUnicode { name } => Self::EnvValueNotUnicode { name }, + CredentialError::EmptyValue => Self::MissingCredentialSource, + } + } +} + +/// 访问意图:部署参数层的纯类型,只接受确认过的两条拼写。 +/// +/// 解析成功不等于拥有写权限:真正可写由打开结果(访问模式与数据能力)回答,本类型不赋权。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AccessIntent { + ReadWrite, + ReadOnly, +} + +impl AccessIntent { + /// 部署参数层携带的访问模式直接映射([`SessionStoreDeployment`] 侧无拼写输入, + /// 因此没有可失败的解析)。 + pub(crate) fn from_access_mode(access: AccessMode) -> Self { + match access { + AccessMode::ReadWrite => Self::ReadWrite, + AccessMode::ReadOnly => Self::ReadOnly, + } + } + + /// 交给门面的访问模式(`Self::access` 的取值面)。 + pub(crate) fn mode(self) -> AccessMode { + match self { + Self::ReadWrite => AccessMode::ReadWrite, + Self::ReadOnly => AccessMode::ReadOnly, + } + } + + pub(crate) fn is_read_only(self) -> bool { + matches!(self, Self::ReadOnly) + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::ReadWrite => "read-write", + Self::ReadOnly => "read-only", + } + } +} + +impl fmt::Display for AccessIntent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// 打开请求里的 locator:延迟解析,直到真正打开时才读环境变量。 +/// +/// `Debug` 手写:远程 locator 原文含主机与库名,解析前也不进日志。 +#[derive(Clone, PartialEq, Eq)] +pub(crate) enum StorageLocator { + /// 未指定:使用默认本机库(`~/.peri/threads/threads.db`)。 + Default, + /// 已解析的本机路径(`--db-path` 等既有入口),不做 URL 重新解释。 + LocalPath(PathBuf), + /// locator 原文:本机路径或远程 URL,由 [`looks_like_remote_url`] 分流。 + Literal(String), + /// `env:<变量名>` 间接定位。 + EnvVar(String), +} + +impl StorageLocator { + /// 纯词法判断:`env:` 前缀按间接定位处理,其余保持原文。 + pub(crate) fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(LocatorError::Empty); + } + match trimmed.strip_prefix("env:") { + Some(name) => { + // 变量名用与凭证来源相同的规则校验,不另立一套。 + CredentialSource::env(name.trim())?; + Ok(Self::EnvVar(name.trim().to_owned())) + } + None => Ok(Self::Literal(trimmed.to_owned())), + } + } +} + +impl fmt::Debug for StorageLocator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Default => formatter.write_str("Default"), + Self::LocalPath(path) => formatter.debug_tuple("LocalPath").field(path).finish(), + Self::Literal(raw) if looks_like_remote_url(raw) => { + // 解析后由 `RemoteEndpoint` 的脱敏 `Debug` 给 scheme/引擎/主机家族。 + formatter.write_str("Literal()") + } + Self::Literal(raw) => formatter.debug_tuple("Literal").field(raw).finish(), + Self::EnvVar(name) => formatter.debug_tuple("EnvVar").field(name).finish(), + } + } +} + +/// 解析后的会话存储位置。`Default` 由装配层换成默认本机路径(那条路径的解析规则 +/// 与既有 `open()` 完全一致,不在本模块复制一份)。 +#[derive(Clone, Debug)] +pub(crate) enum ResolvedLocator { + Default, + Local(PathBuf), + Remote(Box), +} + +/// 打开请求:locator + 凭证来源 + 访问意图。 +/// +/// `Debug` 可以安全打印:远程端点的 `Debug` 只给 scheme/引擎/主机家族,凭证来源只给 +/// 变量名,凭证值从不进入本结构(只在打开时按需解析)。 +#[derive(Clone, Debug)] +pub(crate) struct SessionStoreOpenRequest { + input: StorageLocator, + engine: Option, + credential: Option, + intent: AccessIntent, +} + +impl SessionStoreOpenRequest { + /// 本机库打开请求:`Some(path)` 用指定路径,`None` 用默认路径。 + /// 与既有 `--db-path` 语义一致,不带凭证。 + pub(crate) fn local(path: Option, intent: AccessIntent) -> Self { + Self { + input: match path { + Some(path) => StorageLocator::LocalPath(path), + None => StorageLocator::Default, + }, + engine: None, + credential: None, + intent, + } + } + + /// 按 locator 原文构造(D §3.1 的最小配置面)。 + pub(crate) fn from_locator( + raw: &str, + engine: Option, + credential_env: Option<&str>, + intent: AccessIntent, + ) -> Result { + let input = StorageLocator::parse(raw)?; + let credential = match credential_env { + Some(name) => Some(CredentialSource::env(name)?), + None => None, + }; + Ok(Self { + input, + engine, + credential, + intent, + }) + } + + /// 从部署参数构造(D-04 的唯一一次转换):CLI 定位参数在这里变成 typed request。 + /// + /// 纯解析,不读环境变量、不连接、不创建文件:locator 形态、引擎名与凭证来源的 + /// 冲突都在这里失败;`env:` 的间接定位仍延迟到 [`Self::resolve_locator`]。 + /// + /// 两条定位入口按**类型**分派,不按字符串形态猜: + /// + /// - [`SessionStoreLocator::LocalPath`](`--db-path`)是已确认本机路径,`PathBuf` + /// 原样进入 [`StorageLocator::LocalPath`]:不解 `env:`、不判 URL、不经字符串往返, + /// 远程专属参数同时给出即在此失败; + /// - [`SessionStoreLocator::Locator`](`--session-store`)是原文,按词法解析(`env:` + /// 才是间接定位)。 + pub(crate) fn from_deployment( + deployment: &SessionStoreDeployment, + ) -> Result { + let intent = AccessIntent::from_access_mode(deployment.access()); + match deployment.locator() { + SessionStoreLocator::Default => { + // 没有 locator 时远程专用参数无处生效:不静默忽略配置。 + reject_remote_only_parameters(deployment)?; + Ok(Self::local(None, intent)) + } + SessionStoreLocator::LocalPath(path) => { + reject_remote_only_parameters(deployment)?; + Ok(Self { + input: StorageLocator::LocalPath(path.clone()), + engine: None, + credential: None, + intent, + }) + } + SessionStoreLocator::Locator(raw) => Self::from_locator( + raw, + engine_from_name(deployment.engine_name())?, + deployment.credential_env_name(), + intent, + ), + } + } + + /// 访问模式视图(等价于 `self.intent().mode()`):装配层把它交给组合层, + /// 由组合层决定数据面按只读还是读写打开。 + pub(crate) fn access(&self) -> AccessMode { + self.intent.mode() + } + + /// 部署参数层的访问意图(只读选择走独立 seam 的依据)。 + pub(crate) fn intent(&self) -> AccessIntent { + self.intent + } + + /// 凭证**来源**(环境变量名,不含值):远程装配按它取值,值从不进入本结构。 + pub(crate) fn credential_source(&self) -> Option<&CredentialSource> { + self.credential.as_ref() + } + + /// 解析最终 location;同时校验凭证来源与 locator 形态是否匹配。 + /// + /// 这是唯一读取环境变量的地方(`env:` 间接定位),只解引用一次。 + pub(crate) fn resolve_locator(&self) -> Result { + let resolved = match &self.input { + StorageLocator::Default => ResolvedLocator::Default, + StorageLocator::LocalPath(path) => ResolvedLocator::Local(path.clone()), + StorageLocator::Literal(raw) => { + if looks_like_remote_url(raw) { + ResolvedLocator::Remote(Box::new(RemoteEndpoint::parse(raw, self.engine)?)) + } else { + ResolvedLocator::Local(PathBuf::from(raw)) + } + } + StorageLocator::EnvVar(name) => { + let value = match std::env::var(name) { + Ok(value) => value, + Err(std::env::VarError::NotPresent) => { + return Err(LocatorError::EnvValueMissing { name: name.clone() }) + } + Err(std::env::VarError::NotUnicode(_)) => { + return Err(LocatorError::EnvValueNotUnicode { name: name.clone() }) + } + }; + let value = value.trim().to_owned(); + if value.is_empty() { + return Err(LocatorError::EnvValueEmpty { name: name.clone() }); + } + if value.starts_with("env:") { + return Err(LocatorError::EnvValueIsReference { name: name.clone() }); + } + if looks_like_remote_url(&value) { + ResolvedLocator::Remote(Box::new(RemoteEndpoint::parse(&value, self.engine)?)) + } else { + ResolvedLocator::Local(PathBuf::from(value)) + } + } + }; + match (&resolved, &self.credential, &self.engine) { + (ResolvedLocator::Remote(_), None, _) => Err(LocatorError::MissingCredentialSource), + (ResolvedLocator::Default | ResolvedLocator::Local(_), Some(_), _) => { + Err(LocatorError::CredentialSourceForLocalStore) + } + (ResolvedLocator::Default | ResolvedLocator::Local(_), None, Some(_)) => { + Err(LocatorError::EngineForLocalStore) + } + _ => Ok(resolved), + } + } +} + +/// 已确认的本机定位(默认库或 `--db-path` 路径)不接受远程专用参数:给出即配置错误, +/// 不静默忽略,也不因为「可能以后用得上」而保留。 +fn reject_remote_only_parameters(deployment: &SessionStoreDeployment) -> Result<(), LocatorError> { + if deployment.engine_name().is_some() { + return Err(LocatorError::EngineForLocalStore); + } + if deployment.credential_env_name().is_some() { + return Err(LocatorError::CredentialSourceForLocalStore); + } + Ok(()) +} + +/// 显式引擎名 → 已确认引擎;未知取值按配置错误处理,不猜也不轮流试。 +pub(crate) fn engine_from_name(name: Option<&str>) -> Result, LocatorError> { + match name { + Some(name) => match RemoteEngine::parse(name) { + Some(engine) => Ok(Some(engine)), + None => Err(LocatorError::UnknownEngine), + }, + None => Ok(None), + } +} + +/// 词法上是否像一个远程 URL:scheme 为 ASCII 字母数字且长度 ≥ 2。 +/// 单字符 scheme 一律当本机路径,避免把 Windows drive(`C:`)读成 URI scheme; +/// UNC 路径(`\\server\share`)不含 `://`,同样落回路径分支。 +fn looks_like_remote_url(raw: &str) -> bool { + let Some((scheme, _)) = raw.split_once("://") else { + return false; + }; + scheme.len() >= 2 && scheme.chars().all(|c| c.is_ascii_alphanumeric()) +} diff --git a/peri-resources/src/sessions/open_test.rs b/peri-resources/src/sessions/open_test.rs new file mode 100644 index 000000000..1e3cf1b5d --- /dev/null +++ b/peri-resources/src/sessions/open_test.rs @@ -0,0 +1,741 @@ +//! 打开请求的确定性测试:路径/drive/UNC、`env:` 单次解引用、引擎、凭证与访问意图校验。 +//! +//! 全部离线:不连接、不读 `.env`、不建文件。涉及环境变量的用例各用独立变量名, +//! 避免与并行测试互相干扰。 + +use std::path::PathBuf; + +use peri_acp_types::session_resources::{AccessMode, DataCapabilities, SessionResourceErrorKind}; +use peri_acp_types::session_store::SessionStoreDeployment; + +use crate::sessions::{ReadOnlyStoreErrorKind, ReadOnlyThreadStoreError}; + +use super::open::{ + AccessIntent, LocatorError, ResolvedLocator, SessionStoreOpenRequest, StorageLocator, +}; +use super::remote::{CredentialError, EndpointError, RemoteEngine}; + +const FAKE_HOST: &str = "sentinel-db-sentinel-org.turso.io"; +const FAKE_TOKEN_ENV: &str = "SENTINEL_TOKEN_ENVIRONMENT_NAME"; + +fn request( + raw: &str, + engine: Option, + token_env: Option<&str>, +) -> SessionStoreOpenRequest { + SessionStoreOpenRequest::from_locator(raw, engine, token_env, AccessIntent::ReadWrite) + .expect("locator input") +} + +/// 部署参数(CLI 层归一后的中性描述),与 `peri-tui` 的构造方式一致。 +fn deployment( + locator: Option<&str>, + engine: Option<&str>, + token_env: Option<&str>, + access: AccessMode, +) -> SessionStoreDeployment { + let base = match locator { + Some(raw) => SessionStoreDeployment::from_locator(raw), + None => SessionStoreDeployment::default_local(), + }; + let base = match engine { + Some(engine) => base.with_engine(engine), + None => base, + }; + let base = match token_env { + Some(name) => base.with_credential_env(name), + None => base, + }; + base.with_access(access) +} + +#[test] +fn default_input_resolves_to_default_local_store() { + let default = SessionStoreOpenRequest::local(None, AccessIntent::ReadWrite); + assert!(matches!( + default.resolve_locator().expect("resolve"), + ResolvedLocator::Default + )); + + let explicit = SessionStoreOpenRequest::local( + Some(PathBuf::from("/tmp/peri-threads.db")), + AccessIntent::ReadOnly, + ); + match explicit.resolve_locator().expect("resolve") { + ResolvedLocator::Local(path) => assert_eq!(path, PathBuf::from("/tmp/peri-threads.db")), + other => panic!("expected local resolution, got {other:?}"), + } + assert_eq!(explicit.intent(), AccessIntent::ReadOnly); + assert_eq!(explicit.access(), AccessMode::ReadOnly); +} + +#[test] +fn windows_drive_and_unc_stay_paths() { + for raw in [ + "C:\\work\\threads.db", + "\\\\server\\share\\threads.db", + "./threads.db", + ] { + let resolved = request(raw, None, None).resolve_locator().expect("resolve"); + match resolved { + ResolvedLocator::Local(path) => assert_eq!(path, PathBuf::from(raw)), + other => panic!("{raw} must stay a local path, got {other:?}"), + } + } +} + +#[test] +fn remote_locator_requires_confirmed_engine_and_credential_source() { + let ambiguous = request(&format!("https://{FAKE_HOST}"), None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .unwrap_err(); + assert_eq!( + ambiguous, + LocatorError::Remote(EndpointError::AmbiguousEngine) + ); + + let no_credential = request(&format!("turso://{FAKE_HOST}"), None, None) + .resolve_locator() + .unwrap_err(); + assert_eq!(no_credential, LocatorError::MissingCredentialSource); + + let resolved = request(&format!("turso://{FAKE_HOST}"), None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .expect("resolve"); + match resolved { + ResolvedLocator::Remote(endpoint) => { + assert_eq!(endpoint.engine(), RemoteEngine::Turso); + assert_eq!(endpoint.host_class(), "official_turso_cloud_domain"); + } + other => panic!("expected remote resolution, got {other:?}"), + } +} + +#[test] +fn credential_source_for_local_store_is_not_ignored() { + let error = request("/tmp/threads.db", None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .unwrap_err(); + assert_eq!(error, LocatorError::CredentialSourceForLocalStore); +} + +#[test] +fn empty_and_unknown_scheme_locators_are_rejected() { + assert_eq!( + StorageLocator::parse(" ").unwrap_err(), + LocatorError::Empty + ); + assert_eq!( + request("ftp://example.invalid/db", None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .unwrap_err(), + LocatorError::Remote(EndpointError::UnsupportedScheme) + ); +} + +#[test] +fn env_indirection_resolves_exactly_once() { + let url_var = "PERI_OPEN_TEST_STORE_URL_4a1"; + std::env::set_var(url_var, format!("turso://{FAKE_HOST}")); + + let resolved = request(&format!("env:{url_var}"), None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .expect("resolve"); + assert!(matches!(resolved, ResolvedLocator::Remote(_))); + + // 指向另一个引用:只解引用一次,不递归。 + std::env::set_var(url_var, "env:PERI_OPEN_TEST_OTHER_4a1"); + assert_eq!( + request(&format!("env:{url_var}"), None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .unwrap_err(), + LocatorError::EnvValueIsReference { + name: url_var.to_owned() + } + ); + + std::env::set_var(url_var, ""); + assert_eq!( + request(&format!("env:{url_var}"), None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .unwrap_err(), + LocatorError::EnvValueEmpty { + name: url_var.to_owned() + } + ); + + std::env::remove_var(url_var); + assert_eq!( + request(&format!("env:{url_var}"), None, Some(FAKE_TOKEN_ENV)) + .resolve_locator() + .unwrap_err(), + LocatorError::EnvValueMissing { + name: url_var.to_owned() + } + ); + + // 变量名不合法:与凭证来源同一套校验规则。 + assert_eq!( + StorageLocator::parse("env:not a name").unwrap_err(), + LocatorError::InvalidEnvReference + ); +} + +#[test] +fn request_debug_keeps_host_and_credentials_out() { + let request = request( + &format!("turso://{FAKE_HOST}"), + Some(RemoteEngine::Turso), + Some(FAKE_TOKEN_ENV), + ); + let rendered = format!("{request:?}"); + assert!(rendered.contains(FAKE_TOKEN_ENV)); // 变量名本身不是凭证 + assert_eq!( + request.credential_source().expect("source").name(), + FAKE_TOKEN_ENV + ); + assert!(!rendered.contains("sentinel-db")); + assert!(rendered.contains("remote locator redacted")); +} + +#[test] +fn engine_names_are_a_fixed_set() { + assert_eq!(RemoteEngine::parse("turso"), Some(RemoteEngine::Turso)); + assert_eq!(RemoteEngine::parse("libsql"), Some(RemoteEngine::LibSql)); + assert_eq!(RemoteEngine::parse("mysql"), None); +} + +/// 部署参数携带的访问模式直接映射为访问意图(部署面无拼写输入,因此无解析失败面)。 +#[test] +fn deployment_access_mode_maps_to_intent() { + assert_eq!( + SessionStoreOpenRequest::from_deployment(&deployment( + None, + None, + None, + AccessMode::ReadOnly + )) + .expect("default local request") + .intent(), + AccessIntent::ReadOnly + ); + assert_eq!( + SessionStoreOpenRequest::from_deployment(&deployment( + Some("/tmp/threads.db"), + None, + None, + AccessMode::ReadWrite + )) + .expect("local path request") + .intent(), + AccessIntent::ReadWrite + ); + + assert_eq!(AccessIntent::ReadOnly.mode(), AccessMode::ReadOnly); + assert_eq!(AccessIntent::ReadWrite.mode(), AccessMode::ReadWrite); + assert!(AccessIntent::ReadOnly.is_read_only()); + assert!(!AccessIntent::ReadWrite.is_read_only()); + assert_eq!(format!("{:?}", AccessIntent::ReadWrite), "ReadWrite"); + assert_eq!( + AccessIntent::from_access_mode(AccessMode::ReadOnly), + AccessIntent::ReadOnly + ); +} + +/// 部署参数到 typed request 的转换是纯解析:互相矛盾的配置在进入任何 I/O 之前失败。 +#[tokio::test] +async fn deployment_conflicts_fail_before_any_io() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("nested").join("threads.db"); + + // 未知引擎名:不猜、不轮流试,直接配置错误。 + for (name, deploy) in [ + ( + "unknown engine", + deployment( + Some(missing.to_str().unwrap()), + Some("mysql"), + None, + AccessMode::ReadWrite, + ), + ), + ( + "engine without locator", + deployment(None, Some("turso"), None, AccessMode::ReadWrite), + ), + ( + "credential without locator", + deployment(None, None, Some(FAKE_TOKEN_ENV), AccessMode::ReadWrite), + ), + ( + "credential for local path", + deployment( + Some(missing.to_str().unwrap()), + None, + Some(FAKE_TOKEN_ENV), + AccessMode::ReadWrite, + ), + ), + ( + "engine for local path", + deployment( + Some(missing.to_str().unwrap()), + Some("libsql"), + None, + AccessMode::ReadWrite, + ), + ), + ] { + let error = match crate::Resources::open_deployment(&deploy).await { + Ok(_) => panic!("{name} 必须报配置错误"), + Err(error) => error, + }; + assert!( + error.downcast_ref::().is_some(), + "{name} 必须保持类型化: {error}" + ); + assert!( + !missing.parent().expect("parent").exists(), + "{name} 不得进入任何 I/O" + ); + } +} + +/// 环境里存在云 URL/token 变量不会切换后端:默认与本机路径都仍是本机库。 +#[tokio::test] +async fn cloud_environment_variables_do_not_switch_the_backend() { + let url_var = "PERI_OPEN_TEST_CLOUD_URL_7c2"; + let token_var = "PERI_OPEN_TEST_CLOUD_TOKEN_7c2"; + std::env::set_var(url_var, format!("turso://{FAKE_HOST}")); + std::env::set_var(token_var, "sentinel-credential-222222222222"); + + // 请求层:默认路径解析成本机 Default,不因环境里的远程 URL 变成 Remote。 + assert!(matches!( + SessionStoreOpenRequest::local(None, AccessIntent::ReadWrite) + .resolve_locator() + .expect("resolve"), + ResolvedLocator::Default + )); + + // 打开层:本机路径仍打开本机 SQLite(不报远程错误、不建远程连接)。 + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("threads.db"); + let resources = crate::Resources::open_with(Some(db_path.clone())) + .await + .expect("环境变量存在不得影响本机打开"); + let availability = resources + .session_resources() + .inspect_availability(None) + .await + .expect("availability"); + assert_eq!(availability.access, AccessMode::ReadWrite); + assert!(db_path.is_file(), "本机路径请求必须打开本机库"); + + // 变量值未被任何分支读取:只有 `env:` 引用才会读环境。 + assert!(std::env::var(url_var).is_ok()); + std::env::remove_var(url_var); + std::env::remove_var(token_var); +} + +/// 带凭证的 URL 在打开入口就被拒绝:错误不回显 locator 原文与凭证值。 +#[tokio::test] +async fn url_with_embedded_secret_is_rejected_without_echo() { + let secret = "sentinel-credential-333333333333"; + let raw = format!("turso://user:{secret}@{FAKE_HOST}"); + // 请求层:带凭证 URL 在解析 locator 时按稳定分类拒绝(词法层不替它做判断)。 + let request = SessionStoreOpenRequest::from_locator( + &raw, + None, + Some(FAKE_TOKEN_ENV), + AccessIntent::ReadWrite, + ) + .expect("词法层只做 env:/原文分流"); + assert_eq!( + request.resolve_locator().unwrap_err(), + LocatorError::Remote(EndpointError::UserInfoPresent) + ); + + let error = match crate::Resources::open_deployment(&deployment( + Some(&raw), + None, + Some(FAKE_TOKEN_ENV), + AccessMode::ReadWrite, + )) + .await + { + Ok(_) => panic!("带凭证 URL 必须被拒绝"), + Err(error) => error, + }; + let rendered = format!("{error:#}"); + assert!( + rendered.contains("must not carry credentials"), + "必须给出稳定分类: {rendered}" + ); + assert!(!rendered.contains(secret), "错误不得回显凭证: {rendered}"); + assert!( + !rendered.contains("sentinel-db"), + "错误不得回显 locator 原文: {rendered}" + ); +} + +/// `file://` 不被静默当成本机路径:未确认的 scheme 直接拒绝。 +#[test] +fn file_uri_is_not_silently_read_as_a_path() { + assert_eq!( + request("file:///tmp/threads.db", None, None) + .resolve_locator() + .unwrap_err(), + LocatorError::Remote(EndpointError::UnsupportedScheme) + ); +} + +/// 显式只读不产生副作用:库不存在时按类型化错误失败,不创建父目录与库文件。 +#[tokio::test] +async fn explicit_read_only_does_not_create_anything() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("nested").join("threads.db"); + + let error = match crate::Resources::open_deployment(&deployment( + Some(missing.to_str().unwrap()), + None, + None, + AccessMode::ReadOnly, + )) + .await + { + Ok(_) => panic!("库不存在时只读打开必须失败"), + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains(&missing.display().to_string()), + "只读失败必须携带路径: {message}" + ); + // 失败分类留在 source chain:消费侧可按 kind 映射退出码(D-04),不必解析文本。 + assert_eq!( + error + .downcast_ref::() + .map(ReadOnlyThreadStoreError::kind), + Some(ReadOnlyStoreErrorKind::DatabaseNotFound) + ); + assert!(!missing.exists(), "显式只读不得创建库文件"); + assert!( + !missing.parent().expect("parent").exists(), + "显式只读不得创建父目录" + ); + + let writable = crate::Resources::open_deployment(&deployment( + Some(missing.to_str().unwrap()), + None, + None, + AccessMode::ReadWrite, + )) + .await + .expect("写意图按既有语义创建库"); + assert!(missing.is_file(), "写意图必须创建库文件"); + drop(writable); +} + +/// 显式只读打开已存在的库与普通打开共享同一后端选择点:选出的只读后端不写任何文件、 +/// 不登记本机执行身份,写入在副作用前按只读拒绝。 +#[tokio::test] +async fn explicit_read_only_shares_selection_and_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("threads.db"); + let locator = db_path.to_str().unwrap(); + drop( + crate::Resources::open_deployment(&deployment( + Some(locator), + None, + None, + AccessMode::ReadWrite, + )) + .await + .expect("写意图创建库"), + ); + + let before = listing(dir.path()); + let read_only = crate::Resources::open_deployment(&deployment( + Some(locator), + None, + None, + AccessMode::ReadOnly, + )) + .await + .expect("已存在的库可显式只读打开"); + let availability = read_only + .session_resources() + .inspect_availability(None) + .await + .expect("availability"); + assert_eq!(availability.access, AccessMode::ReadOnly); + assert_eq!(availability.capabilities, DataCapabilities::HistoryReadOnly); + assert_eq!(before, listing(dir.path()), "显式只读不得新增任何文件"); + // 写入在副作用前拒绝:不假装可写。 + let error = read_only + .session_resources() + .delete_session_tree(&"no-such-session".to_owned()) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::ReadOnlyStore + )); +} + +fn listing(dir: &std::path::Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + names.sort(); + names +} + +#[tokio::test] +async fn remote_open_is_not_silently_downgraded_to_local() { + // 远程组合已接线(`sessions::remote::composition`)。这里断言的是**配置不完整时 + // 在任何 I/O 之前失败**:凭证来源缺失、引擎名不认识,都不连接远端、不开本机库, + // 也绝不静默回落到本机存储。 + let error = match crate::Resources::open_deployment(&deployment( + Some(&format!("turso://{FAKE_HOST}")), + None, + Some(FAKE_TOKEN_ENV), + AccessMode::ReadOnly, + )) + .await + { + Ok(_) => panic!("remote store must not open as a local store"), + Err(error) => error, + }; + // 凭证来源没有设置:配置不完整必须在**任何 I/O 之前**失败(不连接远端、不打开或 + // 创建本机登记库、不建锁文件),并且保留类型化分类供消费侧映射退出码。 + // 组合层里凭证解析是第一步,这里断言失败确实停在它上面(不是被压平的字符串)。 + assert!( + error.downcast_ref::().is_some(), + "缺凭证必须是类型化配置错误: {error}" + ); + assert!( + format!("{error:#}").contains("SENTINEL_TOKEN_ENVIRONMENT_NAME"), + "错误链里只应出现变量名: {error:#}" + ); + + // 未知引擎名在解析期失败,不进入任何 I/O。 + let error = match crate::Resources::open_deployment(&deployment( + Some(&format!("turso://{FAKE_HOST}")), + Some("mysql"), + Some(FAKE_TOKEN_ENV), + AccessMode::ReadWrite, + )) + .await + { + Ok(_) => panic!("unknown engine must be a config error"), + Err(error) => error, + }; + assert!( + error.to_string().contains("unknown session store engine"), + "未知引擎名必须报配置错误: {error}" + ); + assert!( + error.downcast_ref::().is_some(), + "配置错误必须保持类型化: {error}" + ); +} + +/// 各部署入口等价:既有 `--db-path` 兼容入口(`open_with`)与部署参数入口 +/// (`open_deployment`,本机路径 / 显式 locator)只共享一个后端选择点——相同的库、 +/// 相同的访问模式与能力,不是各自解释出另一份存储。 +#[tokio::test] +async fn deployment_entries_agree_on_one_selection_point() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("threads.db"); + let raw = db_path.to_str().unwrap().to_owned(); + + // 1) 既有 `--db-path` 兼容入口。 + let legacy = crate::Resources::open_with(Some(db_path.clone())) + .await + .expect("legacy --db-path entry"); + let legacy_availability = legacy + .session_resources() + .inspect_availability(None) + .await + .expect("availability"); + drop(legacy); + + // 2) `--session-store <本机路径>` 部署参数。 + let via_locator = crate::Resources::open_deployment(&deployment( + Some(&raw), + None, + None, + AccessMode::ReadWrite, + )) + .await + .expect("--session-store entry"); + let locator_availability = via_locator + .session_resources() + .inspect_availability(None) + .await + .expect("availability"); + drop(via_locator); + + // 3) `--db-path` 归一后的部署参数(同一份 path 语义)。 + let via_path = + crate::Resources::open_deployment(&SessionStoreDeployment::local_path(db_path.clone())) + .await + .expect("deployment local path entry"); + let path_availability = via_path + .session_resources() + .inspect_availability(None) + .await + .expect("availability"); + drop(via_path); + + assert_eq!(legacy_availability.access, AccessMode::ReadWrite); + for availability in [locator_availability, path_availability] { + assert_eq!(availability.access, legacy_availability.access); + assert_eq!(availability.capabilities, legacy_availability.capabilities); + } + assert!(db_path.is_file(), "三个入口必须落在同一个库文件"); +} + +// ─── `--db-path` 归一出的是已确认本机路径,不进入 locator 解析 ────────────── + +/// 原缺陷回归:`--db-path` 归一路径曾被写进 locator 字符串、再按 `env:` 解引用, +/// 名为 `env:<名字>` 的合法文件因而打不开(报「环境变量未设置」)。 +#[tokio::test] +async fn db_path_file_named_like_an_env_reference_opens_as_a_file() { + let name = "PERI_OPEN_TEST_UNSET_9d1"; + assert!( + std::env::var_os(name).is_none(), + "用例要求 {name} 未被设置:按引用解释必然失败" + ); + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join(format!("env:{name}")); + + // 请求层:不解引用,原样是路径。 + let request = SessionStoreOpenRequest::from_deployment(&SessionStoreDeployment::local_path( + db_path.clone(), + )) + .expect("--db-path deployment"); + match request.resolve_locator().expect("resolve") { + ResolvedLocator::Local(path) => assert_eq!(path, db_path), + other => panic!("--db-path 必须保持本机路径,得到 {other:?}"), + } + + // 打开层:真的把该文件当库打开(写打开会创建它),不报配置错误。 + let resources = + crate::Resources::open_deployment(&SessionStoreDeployment::local_path(db_path.clone())) + .await + .expect("open(--db-path 形态)"); + drop(resources); + assert!(db_path.is_file(), "必须是本机文件,而不是环境变量引用"); +} + +/// Unix 非 UTF-8 文件名:字节原样到达打开层。macOS 的 syscall 拒绝非 UTF-8 路径 +/// (EILSEQ),Linux 接受并建库——两种结果都必须落在**原始字节**路径上:旧实现经 +/// `to_string_lossy` 改写后会在 U+FFFD 变体上另建一个库,静默打开错误的库。 +#[cfg(unix)] +#[tokio::test] +async fn db_path_keeps_non_utf8_bytes_end_to_end() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join(OsStr::from_bytes(b"threads-\xff\xfe.db")); + assert!(db_path.to_str().is_none(), "用例本身要求非 UTF-8 路径"); + + let request = SessionStoreOpenRequest::from_deployment(&SessionStoreDeployment::local_path( + db_path.clone(), + )) + .expect("--db-path deployment"); + match request.resolve_locator().expect("resolve") { + ResolvedLocator::Local(path) => { + assert_eq!(path.as_os_str().as_bytes(), db_path.as_os_str().as_bytes()); + } + other => panic!("非 UTF-8 路径必须保持本机路径,得到 {other:?}"), + } + + let opened = + crate::Resources::open_deployment(&SessionStoreDeployment::local_path(db_path.clone())) + .await; + #[cfg(target_os = "linux")] + { + drop(opened.expect("open(非 UTF-8 路径)")); + assert!(db_path.is_file(), "非 UTF-8 文件必须按原字节建立"); + } + #[cfg(not(target_os = "linux"))] + { + // 本机(macOS)拒绝该路径:如实失败,不改写成另一个路径后"成功"。 + drop(opened); + } + + let lossy_variant = dir + .path() + .join(String::from_utf8_lossy(b"threads-\xff\xfe.db").as_ref()); + assert!( + !lossy_variant.exists(), + "不得在 to_string_lossy 变体上建库:那等于静默打开另一个库" + ); +} + +/// `--db-path` 的 Windows drive/UNC 形状不进入 URL 判定。本机不必是 Windows:这里 +/// 只验证类型分派与字节保留,不断言这些路径在 macOS/Linux 上可打开。 +#[test] +fn db_path_windows_shapes_skip_locator_parsing() { + for raw in ["C:\\work\\threads.db", "\\\\server\\share\\threads.db"] { + let request = SessionStoreOpenRequest::from_deployment( + &SessionStoreDeployment::local_path(PathBuf::from(raw)), + ) + .expect("--db-path deployment"); + match request.resolve_locator().expect("resolve") { + ResolvedLocator::Local(path) => assert_eq!(path, PathBuf::from(raw)), + other => panic!("{raw} 必须保持本机路径,得到 {other:?}"), + } + } +} + +/// 同一段 `env:` 字面量:`--db-path` 当文件名,`--session-store` 当环境引用,两种语义 +/// 不互相渗透。 +#[test] +fn env_colon_literal_is_a_file_name_for_db_path_but_a_reference_for_session_store() { + let name = "PERI_OPEN_TEST_ENV_LITERAL_6f4"; + assert!(std::env::var_os(name).is_none(), "用例要求 {name} 未被设置"); + + let as_path = SessionStoreOpenRequest::from_deployment(&SessionStoreDeployment::local_path( + PathBuf::from(format!("env:{name}")), + )) + .expect("--db-path deployment"); + match as_path.resolve_locator().expect("resolve") { + ResolvedLocator::Local(path) => assert_eq!(path, PathBuf::from(format!("env:{name}"))), + other => panic!("--db-path 的字面名必须保持路径,得到 {other:?}"), + } + + let as_reference = SessionStoreOpenRequest::from_deployment( + &SessionStoreDeployment::from_locator(format!("env:{name}")), + ) + .expect("--session-store deployment"); + assert_eq!( + as_reference.resolve_locator().unwrap_err(), + LocatorError::EnvValueMissing { + name: name.to_owned() + } + ); +} + +/// 已确认本机路径不接受远程专用参数:给出即配置错误,不静默忽略。 +#[test] +fn remote_only_parameters_on_confirmed_local_path_fail_early() { + let base = SessionStoreDeployment::local_path(PathBuf::from("/tmp/threads.db")); + + let with_engine = base.clone().with_engine("turso"); + assert_eq!( + SessionStoreOpenRequest::from_deployment(&with_engine).unwrap_err(), + LocatorError::EngineForLocalStore + ); + + let with_credential = base.with_credential_env(FAKE_TOKEN_ENV); + assert_eq!( + SessionStoreOpenRequest::from_deployment(&with_credential).unwrap_err(), + LocatorError::CredentialSourceForLocalStore + ); +} diff --git a/peri-resources/src/sessions/remote/cloud_deployment_child_test.rs b/peri-resources/src/sessions/remote/cloud_deployment_child_test.rs new file mode 100644 index 000000000..d18412567 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_deployment_child_test.rs @@ -0,0 +1,628 @@ +//! 显式云端端到端的**子进程阶段**(父测试:[`super::cloud_deployment_tests`])。 +//! +//! 三个测试各自代表一个进程阶段:写入、冷恢复、显式只读。没有父测试放进来的标记变量时 +//! 它们直接返回,因此只有被父测试拉起时才工作;断言与安全规则见父模块文档。 +//! +//! - 写入:真实部署入口 → 创建 → 追加/排空 → compact → fork → child → 标题 A→B→A → close; +//! - 冷恢复:同一 HOME 的**新进程** → 未决收敛 → 解除 ordinary dirty → rewind → 删除; +//! - 只读:`fresh`(全新 HOME,本机无库)与 `registered`(沿用写入期 HOME)两种本机状态。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + AccessMode, DataCapabilities, ExecutionAvailability, ForkSnapshot, FrozenSnapshotBytes, + FrozenState, NewSession, NewSessionMeta, PersistenceRecovery, RewindBoundary, SessionMetaPatch, + SessionResourceErrorKind, SessionResources, +}; +use peri_acp_types::session_store::SessionStoreDeployment; +use peri_acp_types::store::{CompactionChange, InheritedContext, PersistedPayload}; +use peri_acp_types::thread::{CancelPolicy, ThreadId}; +use peri_acp_types::workspace::{ResetDirtyRequest, SessionBinding, SESSION_BINDING_VERSION}; + +use super::cloud_deployment_tests::{ + CHILD_SUFFIX, FORK_SUFFIX, HOME_ENV, READ_ONLY_ENV, ROOT_SUFFIX, RUN_ENV, WORKSPACE_ENV, +}; +use super::cloud_tests::{ + check, failure, load_env, remote_error_class, required_credential_keys, required_credentials, + CloudTarget, +}; +use crate::sessions::SessionResourcesImpl; + +/// 子进程启动上下文;没有标记变量时返回 `None`(该测试只在被父测试拉起时工作)。 +struct ChildContext { + home: PathBuf, + workspace: PathBuf, + run: String, + env: BTreeMap, +} + +fn child_context() -> Option { + let home = std::env::var_os(HOME_ENV)?; + let workspace = std::env::var_os(WORKSPACE_ENV)?; + let run = std::env::var(RUN_ENV).ok()?; + Some(ChildContext { + home: PathBuf::from(home), + workspace: PathBuf::from(workspace), + run, + env: load_env(), + }) +} + +impl ChildContext { + /// 部署参数:locator 原文 + 显式引擎名 + 凭证**来源**(环境变量名)。 + /// + /// 凭证值只在本进程内注入同名变量:部署参数按设计只接受来源名,门面再按名取值。 + fn deployment(&self, access: AccessMode) -> Result { + let (_, token_key) = required_credential_keys(); + let (url, token) = required_credentials(&self.env); + std::env::set_var(&token_key, &token); + let engine = self + .endpoint() + .map_err(|error| error.to_string())? + .engine() + .as_str() + .to_owned(); + Ok(SessionStoreDeployment::from_locator(url) + .with_engine(engine) + .with_credential_env(token_key) + .with_access(access)) + } + + /// 端点(与门面解析 locator 用的是同一段纯解析,因此引擎名与摘要一致)。 + fn endpoint(&self) -> Result { + let (url, _) = required_credentials(&self.env); + super::RemoteEndpoint::parse(&url, None) + } + + fn path(&self, suffix: &str) -> String { + format!("{}-{suffix}", self.run) + } + + fn thread(&self, suffix: &str) -> ThreadId { + self.path(suffix) + } +} +/// 页面上的只读检查结果:只带计数与布尔,不带 locator 或内容。 +fn report(out: &mut super::cloud_tests::SafeOut, lines: &[String]) { + for line in lines { + out.push(line.clone()); + } +} + +// ─── 子进程 A:写入 ──────────────────────────────────────────────────────────── + +#[tokio::test] +async fn cloud_deployment_child_writes_the_synthetic_tree() { + let Some(context) = child_context() else { + return; + }; + let target = CloudTarget::load(); + let mut out = target.out(); + let lines = write_flow(&context, &target) + .await + .unwrap_or_else(|message| panic!("{message}")); + report(&mut out, &lines); + out.push("write_phase=ok".to_owned()); + out.flush(); +} + +async fn write_flow(context: &ChildContext, target: &CloudTarget) -> Result, String> { + let mut lines = Vec::new(); + // 配置即用:配了哪个 store 就直接用,打开之前不需要任何本机登记(v10 撤销了登记链)。 + let facade = open_facade(context, AccessMode::ReadWrite, target).await?; + let workspace = facade + .resolve_workspace(&context.workspace) + .await + .map_err(failure)?; + let root = context.thread(ROOT_SUFFIX); + + // ① 远端 durable 保存 + 本机执行准入。 + let lease = facade + .create_session(&session_input(context, &root, &workspace, None)) + .await + .map_err(failure)?; + + // ② 追加 + 排空(append/flush):排空是「已排队的写入结束」的有界等待,不是成功声明。 + let first = PersistedPayload::Message(BaseMessage::human("synthetic question")); + let second = PersistedPayload::Message(BaseMessage::ai("synthetic answer")); + facade + .append_history(&root, &[first.clone(), second.clone()]) + .await + .map_err(failure)?; + facade.drain_persistence(&root).await.map_err(failure)?; + lines.push(format!( + "append_rows={}", + facade + .load_session_history(&root) + .await + .map_err(failure)? + .len() + )); + + // ③ compact:既有消息打标记 + 追加摘要,一次变更全生效。 + let summary = BaseMessage::ai("synthetic summary"); + facade + .apply_compaction( + &root, + &CompactionChange { + flag_updates: vec![( + first.id(), + peri_acp_types::store::MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + )], + appended_messages: vec![summary], + }, + ) + .await + .map_err(failure)?; + let compacted = facade.load_session_history(&root).await.map_err(failure)?; + lines.push(format!("compact_rows={}", compacted.len())); + + // ④ fork:目标快照独立落库(source 不变)。 + // + // `message_id` 是库级主键(本机与远端同一形状),复制 source 历史必须**重映射 ID**; + // 复用原 id 会撞主键。产品路径在派发层先做纯 ID 重映射,这里先证明复用被**明确拒绝** + // (不是静默丢行),再用重映射后的快照成功落库。 + let fork = context.thread(FORK_SUFFIX); + let reused = facade + .save_fork(&ForkSnapshot { + target: session_input(context, &fork, &workspace, None), + source_id: root.clone(), + payloads: compacted.clone(), + flags: std::collections::HashMap::new(), + }) + .await; + check( + matches!( + reused.as_ref().map_err(|error| error.kind()), + Err(SessionResourceErrorKind::InvalidInput { .. }) + ), + "reusing source message ids in a fork must be refused, not silently dropped", + )?; + let fork_lease = facade + .save_fork(&ForkSnapshot { + target: session_input(context, &fork, &workspace, None), + source_id: root.clone(), + payloads: remap_for_fork(&compacted), + flags: std::collections::HashMap::new(), + }) + .await + .map_err(failure)?; + + // ⑤ child:继承区 + 父子关系,沿用 root owner 与 root 的 frozen 原文。 + let child = context.thread(CHILD_SUFFIX); + let root_frozen = frozen_of(&facade, &root).await?; + check( + root_frozen == synthetic_frozen(context), + "the frozen snapshot must round-trip through the remote store", + )?; + facade + .save_child( + &peri_acp_types::session_resources::ChildSnapshot { + target: child_input(context, &child, &workspace, &root, root_frozen), + parent_id: root.clone(), + root_id: root.clone(), + inherited: InheritedContext { + payloads: compacted, + flags: std::collections::HashMap::new(), + }, + }, + &lease, + ) + .await + .map_err(failure)?; + lines.push(format!( + "tree_sessions={} children={}", + facade + .list_session_tree(&root) + .await + .map_err(failure)? + .len(), + facade.list_children(&root).await.map_err(failure)?.len() + )); + + // ⑥ 标题 A→B→A:第三次领域调用必须落地(同内容不是「历史重放」,是新的领域调用)。 + // 操作身份由每次调用铸造,不由内容派生——这里正是它的端到端回归。 + for title in ["title-a", "title-b", "title-a"] { + facade + .update_session_meta( + &root, + &SessionMetaPatch { + title: Some(Some(title.to_owned())), + ..Default::default() + }, + ) + .await + .map_err(failure)?; + } + let meta = facade.load_session_meta(&root).await.map_err(failure)?; + lines.push(format!( + "title_after_aba={}", + meta.title.as_deref().unwrap_or("-") + )); + check( + meta.title.as_deref() == Some("title-a"), + "the third update (same title as the first) must still apply", + )?; + + // ⑦ 关闭:返回后不再接受新写入。进程在这里退出,**不写 clean**:执行代际留在本机, + // 下一个进程因此必须走「先收敛未决、再解除 ordinary dirty」的正路。 + facade.close().await.map_err(failure)?; + drop((lease, fork_lease)); + Ok(lines) +} + +// ─── 子进程 B:冷恢复 → rewind/delete ───────────────────────────────────────── + +#[tokio::test] +async fn cloud_deployment_child_recovers_cold_then_rewinds_and_deletes() { + let Some(context) = child_context() else { + return; + }; + let target = CloudTarget::load(); + let mut out = target.out(); + let lines = recover_flow(&context, &target) + .await + .unwrap_or_else(|message| panic!("{message}")); + report(&mut out, &lines); + out.push("recover_phase=ok".to_owned()); + out.flush(); +} + +async fn recover_flow(context: &ChildContext, target: &CloudTarget) -> Result, String> { + let mut lines = Vec::new(); + let facade = open_facade(context, AccessMode::ReadWrite, target).await?; + let root = context.thread(ROOT_SUFFIX); + let child = context.thread(CHILD_SUFFIX); + let fork = context.thread(FORK_SUFFIX); + + // ① 未决收敛:上一个进程没有未结清的写入 ⇒ `Recovered`(不是「目标读不到就算没发生」)。 + let recovery = facade + .recover_session_persistence(&root) + .await + .map_err(failure)?; + check( + recovery == PersistenceRecovery::Recovered, + "cold recovery must converge with no unsettled operations", + )?; + + // ② 上一个进程异常退出留下的 ordinary dirty:先收敛,再按显式风险接受解除。 + let availability = facade + .inspect_availability(Some(&root)) + .await + .map_err(failure)?; + let Some(ExecutionAvailability::Dirty(details)) = availability.execution else { + return Err(format!( + "a process that exited without clean must report dirty: {:?}", + availability.execution + )); + }; + facade + .reset_dirty_execution(&ResetDirtyRequest { + target: details, + accept_risk: true, + }) + .await + .map_err(failure)?; + lines.push("dirty=reset".to_owned()); + + let workspace = facade + .resolve_workspace(&context.workspace) + .await + .map_err(failure)?; + let lease = facade + .acquire_execution(&root, &workspace) + .await + .map_err(failure)?; + + // ③ rewind:保留到第一条消息(显式边界),派生计数随之更新。 + let history = facade.load_session_history(&root).await.map_err(failure)?; + check( + history.len() >= 2, + "cold read must return the saved history", + )?; + facade + .rewind_history(&root, RewindBoundary::KeepThrough(history[0].id())) + .await + .map_err(failure)?; + let rewound = facade.load_session_history(&root).await.map_err(failure)?; + lines.push(format!("rewind_rows={}", rewound.len())); + check(rewound.len() == 1, "rewind must keep exactly one message")?; + + // ④ 删除整棵树:root 与 child 一起消失,fork 目标是另一棵树,必须仍在。 + facade.delete_session_tree(&root).await.map_err(failure)?; + check( + matches!( + facade.load_session_meta(&root).await.unwrap_err().kind(), + SessionResourceErrorKind::NotFound + ), + "the deleted root must be gone", + )?; + check( + matches!( + facade + .load_session_history(&child) + .await + .unwrap_err() + .kind(), + SessionResourceErrorKind::NotFound + ), + "the deleted tree must take its child", + )?; + check( + !facade + .load_session_history(&fork) + .await + .map_err(failure)? + .is_empty(), + "an unrelated tree must survive the deletion", + )?; + lines.push("delete=applied".to_owned()); + + // ⑤ 终态:删除留下的墓碑不阻止 clean 落盘。 + lease + .mark_clean() + .await + .map_err(|error| error.to_string())?; + facade.close().await.map_err(failure)?; + Ok(lines) +} + +// ─── 子进程 C/D:显式只读 ───────────────────────────────────────────────────── + +#[tokio::test] +async fn cloud_deployment_child_read_only_leaves_no_trace() { + let Some(context) = child_context() else { + return; + }; + let Ok(mode) = std::env::var(READ_ONLY_ENV) else { + return; + }; + let target = CloudTarget::load(); + let mut out = target.out(); + let lines = read_only_flow(&context, &mode, &target) + .await + .unwrap_or_else(|message| panic!("{message}")); + report(&mut out, &lines); + out.push(format!("read_only_{mode}=ok")); + out.flush(); +} + +async fn read_only_flow( + context: &ChildContext, + mode: &str, + target: &CloudTarget, +) -> Result, String> { + let mut lines = Vec::new(); + let before = listing(&context.home)?; + + // `fresh`:本机没有执行事实库。只读意图不许创建它,因此这次打开在建立任何远端连接 + // 之前就如实失败——与本机库「只读打开一个不存在的库」是同一个判定(`NotFound`), + // 两种存储模式下这句话必须是同一句。 + if mode == "fresh" { + let error = match open_facade_error(context, AccessMode::ReadOnly).await { + Ok(_) => { + return Err("a read-only open without local execution facts must fail".to_owned()) + } + Err(error) => error, + }; + let failure = crate::classify_open_failure(&error); + check( + failure == crate::StoreOpenFailure::NotFound, + &format!("a missing local execution face must open as NotFound, got {failure:?}"), + )?; + let after = listing(&context.home)?; + check( + before == after, + &format!( + "a refused read-only open must not create local files: before={before:?} after={after:?}" + ), + )?; + lines.push(format!("read_only_mode={mode} refusal={failure:?}")); + return Ok(lines); + } + + let facade = open_facade(context, AccessMode::ReadOnly, target).await?; + let availability = facade.inspect_availability(None).await.map_err(failure)?; + check( + availability.access == AccessMode::ReadOnly + && availability.capabilities == DataCapabilities::HistoryReadOnly, + "read-only intent must select the read-only capability face", + )?; + + let fork = context.thread(FORK_SUFFIX); + let history = facade.load_session_history(&fork).await.map_err(failure)?; + check( + !history.is_empty(), + "read-only open must still read the remote session data", + )?; + + let fork_availability = facade + .inspect_availability(Some(&fork)) + .await + .map_err(failure)?; + // 本机执行事实在,但这次是只读打开:执行权一律不可得,如实回答 `ReadOnlyStore` + // (历史照常可读)。有本机库不改变只读这件事。 + check( + fork_availability.execution == Some(ExecutionAvailability::ReadOnlyStore), + "a read-only open must report the read-only execution face", + )?; + + // 写入在副作用之前被拒绝:类型化拒绝,不假装成功。 + let error = facade + .update_session_meta( + &fork, + &SessionMetaPatch { + title: Some(Some("must not apply".to_owned())), + ..Default::default() + }, + ) + .await + .expect_err("read-only open must refuse writes"); + check( + matches!(error.kind(), SessionResourceErrorKind::ReadOnlyStore), + &format!( + "read-only refusal must be typed ({mode}): {}", + remote_error_class(&error) + ), + )?; + lines.push(format!( + "read_only_mode={mode} rows={} refusal={}", + history.len(), + remote_error_class(&error) + )); + + facade.close().await.map_err(failure)?; + // 只读打开与只读拒绝都不得在本机留下任何文件(本机库、锁)。 + let after = listing(&context.home)?; + check( + before == after, + &format!("a read-only open must not create local files: before={before:?} after={after:?}"), + )?; + Ok(lines) +} + +// ─── 共用小工具 ──────────────────────────────────────────────────────────────── + +/// 本机目录内容(含子目录文件名),用于「只读不建文件」的前后比对。 +fn listing(root: &Path) -> Result, String> { + let mut names = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path.clone()); + } + names.push(path.display().to_string()); + } + } + names.sort(); + Ok(names) +} + +/// 真实部署入口:与 CLI/TUI/print/stdio/meta 走的是同一个装配点。 +async fn open_facade( + context: &ChildContext, + access: AccessMode, + _target: &CloudTarget, +) -> Result, String> { + open_facade_error(context, access) + .await + .map_err(|error| format!("deployment open failed: {error:#}")) +} + +/// 同一个入口,保留类型化错误:拒绝的判定(`StoreOpenFailure`)只能按类型做, +/// 不解析错误文本。 +async fn open_facade_error( + context: &ChildContext, + access: AccessMode, +) -> Result, anyhow::Error> { + let deployment = context.deployment(access).map_err(anyhow::Error::msg)?; + let resources = crate::Resources::open_deployment(&deployment).await?; + Ok(resources.into_concrete_for_test()) +} + +fn binding_of(workspace: &peri_acp_types::workspace::ResolvedWorkspace) -> SessionBinding { + SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + } +} + +/// 合成会话输入:内容全部由本轮 run 派生,cwd 是本轮合成的 workspace。 +fn session_input( + context: &ChildContext, + thread: &ThreadId, + workspace: &peri_acp_types::workspace::ResolvedWorkspace, + parent: Option<&ThreadId>, +) -> NewSession { + NewSession { + thread_id: thread.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: Some(format!("synthetic {thread}")), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: parent.cloned(), + hidden: parent.is_some(), + cancel_policy: CancelPolicy::Cascade, + snapshot_at_message_id: None, + }, + binding: binding_of(workspace), + frozen: synthetic_frozen(context), + } +} + +/// fork 目标的历史:**ID 重映射**后复制(与 `peri-acp::dispatch::session_fork` 同一语义, +/// 那里是产品路径的唯一实现;adapter 不重复 fork 算法)。 +fn remap_for_fork(payloads: &[PersistedPayload]) -> Vec { + payloads + .iter() + .map(|payload| match payload { + PersistedPayload::Message(BaseMessage::Human { content, .. }) => { + PersistedPayload::Message(BaseMessage::Human { + id: MessageId::new(), + content: content.clone(), + }) + } + PersistedPayload::Message(BaseMessage::Ai { + content, + tool_calls, + .. + }) => PersistedPayload::Message(BaseMessage::Ai { + id: MessageId::new(), + content: content.clone(), + tool_calls: tool_calls.clone(), + }), + // 本轮合成历史只有 user/assistant 两类;原样复制其他形态会撞主键, + // 因此这里明确不猜(真需要时按同一规则补全重映射)。 + other => panic!("synthetic fork history has no remapping rule: {other:?}"), + }) + .collect() +} + +fn synthetic_frozen(context: &ChildContext) -> FrozenSnapshotBytes { + FrozenSnapshotBytes::new(format!("{{\"frozen\":\"{}\"}}", context.run)) +} + +/// child 目标:父子身份 + **逐字节沿用** root 已保存的 frozen 原文。 +fn child_input( + context: &ChildContext, + thread: &ThreadId, + workspace: &peri_acp_types::workspace::ResolvedWorkspace, + root: &ThreadId, + frozen: FrozenSnapshotBytes, +) -> NewSession { + NewSession { + frozen, + ..session_input(context, thread, workspace, Some(root)) + } +} + +/// root 已保存的 frozen 原文(child 必须逐字节沿用,不重新构造)。 +async fn frozen_of( + facade: &SessionResourcesImpl, + id: &ThreadId, +) -> Result { + match facade + .load_session_snapshot(id) + .await + .map_err(failure)? + .frozen + { + FrozenState::Present(bytes) => Ok(bytes), + state => Err(format!("root frozen snapshot is missing: {state:?}")), + } +} diff --git a/peri-resources/src/sessions/remote/cloud_deployment_test.rs b/peri-resources/src/sessions/remote/cloud_deployment_test.rs new file mode 100644 index 000000000..824cc5c10 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_deployment_test.rs @@ -0,0 +1,629 @@ +//! 显式云端端到端回归(默认 `#[ignore]`):**真实部署入口** `Resources::open_deployment` +//! 的完整生命周期,跨进程、冷恢复。 +//! +//! 前面几组云实验分别打的是 adapter 行为、门面行为、故障收敛;这一组打的是**装配入口本身**: +//! 入口配置(`--session-store` + `--session-store-token-env`)→ D 装配(`open_deployment`) +//! → 远程数据面 + 本机执行面 → 全生命周期行为。因此它不听任何内部端口,只用公开门面, +//! 并且每一段都在**独立进程**里跑:进程边界消失之后仍然成立的事实才是 durable 事实。 +//! +//! | 阶段 | 进程 | 断言 | +//! | --- | --- | --- | +//! | 写入 | 子进程 A(temp HOME) | 创建 → 追加 → 排空 → compact → fork → child → 标题 A→B→A → close | +//! | 冷恢复 | 子进程 B(同一 HOME,新进程) | 未决收敛 `Recovered` → 上次退出留下的 ordinary dirty → rewind → 删除 → 只读复核 | +//! | 只读(全新 HOME) | 子进程 C(全新 HOME) | 本机没有执行事实 ⇒ 打开按 `NotFound` 如实失败;HOME 一个文件都不建 | +//! | 只读(沿用 A 的 HOME) | 子进程 D(沿用 A 的 HOME) | 远端历史可读、执行权不可得;写入按 `ReadOnlyStore` 拒绝;本机状态不变 | +//! +//! 父测试只做三件事:拉起子进程、用**新连接**核对云端事实(阶段间与清理后各一次)、用 +//! **只读连接**盘点本机执行面库(阶段间各一次,见下)。 +//! +//! 本机盘点读的是真实落盘的那个文件(`registry_path(home)`,不是门面自陈):远端模式下本机 +//! 只留被授权的执行事实——workspace 证据(`projects` / `workspaces`)与执行代际 +//! (`execution_runs`)——不留任何 store 痕迹,也不留任何会话数据(`threads` / `messages` / +//! `session_bindings` 全为 0)。库里本不该有的东西一旦回来,只核对云端计数是看不见的,这条 +//! 盘点就是为它准备的。 +//! +//! 期望值全部**派生**,不另抄一份会悄悄过期的名单:真实运行的本机库与同一构建在本机模式下 +//! 新建的库逐表比对(用户裁决「远端库完全 = 本地库的模式」),v10 删掉的五张本机远程表名 +//! 则从 `sqlite_store/schema.rs` 的 `DROPPED_LOCAL_TABLES` 原文派生(见 [`dropped_local_tables`])。 +//! +//! 阶段 C 的拒绝与本机库的只读打开是同一个判定:本机执行事实(workspace 证据、执行代际、 +//! sidecar 锁)只存在本机库里,只读意图不许创建它,因此缺库时没有可用的执行面——只读打开 +//! 一个不存在的库在两种存储模式下都按 `NotFound` 拒绝,而不是把「没有事实」降级成空事实。 +//! +//! ## 事实与安全 +//! +//! - HOME、workspace、frozen、历史全部是系统临时目录里的合成内容,不含真实历史、项目 +//! 或任何仓库文件;本机执行面库因此落在系统 temp,不进仓库。 +//! - 配置即用(v10):指向哪个 store 就直接用哪个,打开之前不需要任何本机登记;子进程 C 与 +//! D 的差别只在**本机有没有库**(全新 HOME 与沿用 A 的 HOME),不在接纳语义。 +//! - 凭证:只有测试进程自己解析 `.env`(键名 + 绝对路径选择器,见 [`super::cloud_tests`]), +//! 值只在本测试进程内注入同名环境变量供部署参数按名取用(部署参数按设计只接受来源名), +//! 父进程不设该变量;不打印 URL/token,输出只走 `SafeOut`。 +//! - 只操作本轮 `run` 命名空间;结束用正常删除路径清理并复核计数为 0(共享 schema 与其他 +//! run 的收据不动)。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_deployment_ +//! ``` + +use std::path::{Path, PathBuf}; + +use peri_acp_types::session_store::SessionStoreDeployment; +use sqlx::{sqlite::SqliteConnectOptions, AssertSqlSafe, Connection}; + +use super::cloud_tests::{check, failure, run_counts, unique_run_label, with_cleanup, CloudTarget}; +use super::mutation::StoreAccess; + +/// 子进程看到的临时 HOME(本机执行面库的位置)。 +pub(super) const HOME_ENV: &str = "PERI_CLOUD_FACADE_HOME"; +/// 子进程看到的合成 workspace(git 仓库,父测试建好,不随阶段变化)。 +pub(super) const WORKSPACE_ENV: &str = "PERI_CLOUD_FACADE_WORKSPACE"; +/// 本轮 run 标签:所有合成 thread id 都由它派生。 +pub(super) const RUN_ENV: &str = "PERI_CLOUD_FACADE_RUN"; +/// 只读子进程的模式:`fresh`(全新 HOME)或 `registered`(沿用写入期 HOME)。 +pub(super) const READ_ONLY_ENV: &str = "PERI_CLOUD_FACADE_READ_ONLY"; + +/// 写入阶段创建的三个合成会话(`-<后缀>`):root 树根、它的子会话、fork 出来的独立树。 +/// +/// 子进程按这三个后缀造 id,父测试按同样的后缀核对本机执行代际——两处共用同一组常量, +/// 免得「哪条会话有本机执行事实」这件事在父测试里另写一份镜像。 +pub(super) const ROOT_SUFFIX: &str = "root"; +pub(super) const CHILD_SUFFIX: &str = "child"; +pub(super) const FORK_SUFFIX: &str = "fork"; + +/// 本机执行面库位置:部署入口只从 HOME 推导它,测试因此只隔离 HOME。 +pub(super) fn registry_path(home: &Path) -> PathBuf { + home.join(".peri").join("threads").join("threads.db") +} + +// ─── 本机执行面库:父进程侧的只读盘点 ───────────────────────────────────────── +// +// 远端模式下本机**只**留被授权的执行事实:workspace 证据(`projects` / `workspaces`)与执行 +// 代际(`execution_runs`)。会话数据(`threads` / `messages` / `session_bindings`)与任何 +// store 痕迹都不该出现在本机——本机库在远端模式下的表集合,与同一构建在本机模式下新建的库 +// 逐表相同。 +// +// 盘点一律读**真实落盘的那个文件**(只读连接,不建库、不建目录),并且都在子进程退出之后 +// 进行:那时没有写者,读到的是稳定状态,也不是任何门面的自陈。 + +/// 本机执行面库在一个时刻的盘点:版本、表集合、分组计数,以及执行代际的 `(id, clean)`。 +/// +/// 只有结构与计数,没有会话内容、没有路径——因此可以直接进断言消息与 `PROBE` 行。 +#[derive(Debug, PartialEq, Eq)] +struct LocalFace { + version: i64, + tables: Vec, + threads: i64, + messages: i64, + bindings: i64, + projects: i64, + workspaces: i64, + /// 本轮 run 命名空间之外执行代际行数(这个 HOME 里的会话都是本轮造的,应为 0)。 + other_runs: i64, + /// 本轮 run 命名空间下的执行代际行:`(thread_id, clean)`,按 id 排序。 + runs: Vec<(String, bool)>, +} + +/// 只读盘点本机执行面库;缺文件即失败(不会把「本机没有执行事实」降级成「空的执行事实」)。 +async fn read_local_face(path: &Path, run: &str) -> Result { + let options = SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .create_if_missing(false); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .map_err(|error| format!("the local execution face must open read-only: {error}"))?; + let face = collect_local_face(&mut connection, run).await; + connection + .close() + .await + .map_err(|error| format!("the local execution face must close: {error}"))?; + face +} + +async fn collect_local_face( + connection: &mut sqlx::SqliteConnection, + run: &str, +) -> Result { + let version: i64 = sqlx::query_scalar("PRAGMA user_version") + .fetch_one(&mut *connection) + .await + .map_err(|error| format!("local schema version is unreadable: {error}"))?; + let tables: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .fetch_all(&mut *connection) + .await + .map_err(|error| format!("local table set is unreadable: {error}"))?; + let threads = count_rows(connection, "threads").await?; + let messages = count_rows(connection, "messages").await?; + let bindings = count_rows(connection, "session_bindings").await?; + let projects = count_rows(connection, "projects").await?; + let workspaces = count_rows(connection, "workspaces").await?; + let runs: Vec<(String, bool)> = sqlx::query_as( + "SELECT thread_id, clean FROM execution_runs WHERE thread_id LIKE ?1 ORDER BY thread_id", + ) + .bind(format!("{run}%")) + .fetch_all(&mut *connection) + .await + .map_err(|error| format!("local execution generations are unreadable: {error}"))?; + let total_runs = count_rows(connection, "execution_runs").await?; + Ok(LocalFace { + version, + tables, + threads, + messages, + bindings, + projects, + workspaces, + other_runs: total_runs - runs.len() as i64, + runs, + }) +} + +/// 单表行数;表名来自上方静态清单,未包含外部输入。 +async fn count_rows( + connection: &mut sqlx::SqliteConnection, + table: &'static str, +) -> Result { + sqlx::query_scalar(AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))) + .fetch_one(&mut *connection) + .await + .map_err(|error| format!("local {table} count is unreadable: {error}")) +} + +/// 同一构建在**本机模式**下新建的库:本机执行面的期望形状(版本 + 表集合)。 +/// +/// 期望值不另抄一份,而是让构建自己造一个库再读回来。用户裁决「远端库完全 = 本地库的模式, +/// 两个存储模式一致」在这里是同一个断言的另一半:远端模式下本机库的形状,必须与同一构建在 +/// 本机模式下建出来的库逐表相同——schema 版本或表集合一旦分叉,这里先响。 +async fn local_mode_baseline(run: &str) -> Result { + let directory = tempfile::tempdir().map_err(|error| format!("baseline temp home: {error}"))?; + let path = directory.path().join("threads.db"); + let resources = + crate::Resources::open_deployment(&SessionStoreDeployment::local_path(path.clone())) + .await + .map_err(|error| format!("the local-mode baseline must open: {error:#}"))?; + resources + .into_concrete_for_test() + .close() + .await + .map_err(|error| format!("the local-mode baseline must close: {error}"))?; + // 基线库是刚建出来的:它没有任何执行代际,按同一 run 前缀读也只是为了让两个库用同一套读法。 + read_local_face(&path, run).await +} + +/// v10 从本机库删掉的表名,**从 schema 源码派生**(`sqlite_store/schema.rs` 的 +/// `DROPPED_LOCAL_TABLES`)。 +/// +/// 为什么不直接 `use` 那个常量:它在本机存储模块里是模块私有的 `const`(可见性止于 +/// `sqlite_store`),`sessions::remote` 读不到它,而本测试被授权的改动范围只在 remote 的两个 +/// 测试文件里。于是改为读它的**定义原文**:清单增删时这里跟着变,不会留下第二份会悄悄过期的 +/// 名单——这正是「另抄一份字面量」做不到的。 +/// +/// 声明找不到、解析不出名字、或结果不像表名时**失败**(不 `#[allow]`、不静默通过):这条 +/// 检查不许退化成「没有需要缺席的表」。 +fn dropped_local_tables() -> Result, String> { + let source = include_str!("../sqlite_store/schema.rs"); + let declaration = source + .split_once("const DROPPED_LOCAL_TABLES") + .map(|(_, rest)| rest) + .ok_or_else(|| { + "the v10 dropped-table list is gone from sqlite_store/schema.rs; re-anchor this check \ + instead of letting it pass vacuously" + .to_owned() + })?; + let names = array_element_names(declaration); + if names.is_empty() || !names.iter().all(|name| is_table_name(name)) { + return Err(format!( + "the v10 dropped-table list could not be derived from sqlite_store/schema.rs: {names:?}" + )); + } + Ok(names) +} + +/// 数组字面量里**第一层**的字符串字面量:外层 `&[ ... ]` 的元素是表名,列名在更深的 +/// `&[ ... ]` 里,因此按方括号深度筛选(行注释不参与深度计数,其中的引号也不是字面量)。 +fn array_element_names(declaration: &str) -> Vec { + let Some((_, initializer)) = declaration.split_once('=') else { + return Vec::new(); + }; + let mut names = Vec::new(); + let mut depth = 0usize; + let mut value = String::new(); + let mut in_string = false; + let mut chars = initializer.chars().peekable(); + while let Some(ch) = chars.next() { + if in_string { + match ch { + '"' => { + in_string = false; + if depth == 1 { + names.push(std::mem::take(&mut value)); + } + } + _ => value.push(ch), + } + continue; + } + match ch { + '"' => { + in_string = true; + value.clear(); + } + '/' if chars.peek() == Some(&'/') => { + for next in chars.by_ref() { + if next == '\n' { + break; + } + } + } + '[' => depth += 1, + ']' => { + if depth == 0 { + break; + } + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + names +} + +/// 表名形态:小写标识符。解析结果不像表名时宁可失败,也不把垃圾当名字去找。 +fn is_table_name(name: &str) -> bool { + !name.is_empty() + && name.starts_with(|c: char| c.is_ascii_lowercase()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +/// 远端模式下本机执行面库必须成立的共同不变量(每个阶段的盘点之后都查一遍)。 +fn check_local_face( + phase: &str, + face: &LocalFace, + baseline: &LocalFace, + dropped: &[String], +) -> Result<(), String> { + check( + face.version == baseline.version, + &format!( + "{phase}: the local execution face must carry the schema version this build writes \ + in local mode ({}), got {}", + baseline.version, face.version + ), + )?; + check( + face.tables == baseline.tables, + &format!( + "{phase}: the local execution face must have exactly the local-mode table set {:?}, \ + got {:?}", + baseline.tables, face.tables + ), + )?; + check( + dropped.iter().all(|table| !face.tables.contains(table)), + &format!( + "{phase}: the tables v10 dropped from the local store must not come back ({dropped:?}), \ + got {:?}", + face.tables + ), + )?; + check( + face.threads == 0 && face.messages == 0 && face.bindings == 0, + &format!( + "{phase}: a remote store must leave no session data in the local face: \ + threads={} messages={} session_bindings={}", + face.threads, face.messages, face.bindings + ), + )?; + check( + face.projects > 0 && face.workspaces > 0, + &format!( + "{phase}: workspace evidence is an authorized local fact and must be present: \ + projects={} workspaces={}", + face.projects, face.workspaces + ), + )?; + check( + face.other_runs == 0, + &format!( + "{phase}: every local execution generation must belong to this run's sessions: \ + foreign={}", + face.other_runs + ), + )?; + Ok(()) +} + +// ─── 父测试:拉起子进程 + 新连接核对 ────────────────────────────────────────── + +const WRITE_CHILD: &str = "sessions::remote::cloud_deployment_child_tests::cloud_deployment_child_writes_the_synthetic_tree"; +const RECOVER_CHILD: &str = "sessions::remote::cloud_deployment_child_tests::cloud_deployment_child_recovers_cold_then_rewinds_and_deletes"; +const READ_ONLY_CHILD: &str = "sessions::remote::cloud_deployment_child_tests::cloud_deployment_child_read_only_leaves_no_trace"; + +/// 端到端:真实部署入口在真引擎上的完整生命周期。 +/// +/// 父进程不碰门面:它只提供合成环境、拉起子进程,并用**新连接**在阶段之间核对云端事实 +/// (写入落库、删除生效、只读零副作用),最后按正常删除路径清理本轮命名空间。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_deployment_entry_point_full_lifecycle() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-facade"); + let home = tempfile::tempdir().expect("temp home"); + let read_only_home = tempfile::tempdir().expect("temp home for the read-only phase"); + let workspace = synthetic_workspace(); + let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + + let result = with_cleanup(&target, &run, || async { + let lines = lifecycle_flow( + &target, + &run, + home.path(), + read_only_home.path(), + workspace.path(), + ) + .await?; + *collected.lock().unwrap() = lines; + Ok(()) + }) + .await; + + match result { + Ok(()) => { + let mut out = target.out(); + for line in collected.lock().unwrap().iter() { + out.push(line.clone()); + } + out.push("deployment_lifecycle=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn lifecycle_flow( + target: &CloudTarget, + run: &str, + home: &Path, + read_only_home: &Path, + workspace: &Path, +) -> Result, String> { + let mut lines = Vec::new(); + // 期望值先派生出来:同一构建在本机模式下新建的库(形状)+ schema 源码里的 v10 删除清单。 + let baseline = local_mode_baseline(run).await?; + let dropped = dropped_local_tables()?; + + // ① 写入:新进程 + temp HOME + 合成 workspace/frozen,全部经真实部署入口。 + lines.extend(run_child(WRITE_CHILD, home, workspace, run, None)?); + let written = counts_now(target, run).await?; + check( + written.sessions == 3 && written.messages == 6, + &format!( + "the write phase must land root + child + fork on the remote store: {}/{}", + written.sessions, written.messages + ), + )?; + check( + registry_path(home).is_file(), + "the deployment must put local facts under the home it was given", + )?; + lines.push(format!( + "after_write sessions={} messages={} ledger={}", + written.sessions, written.messages, written.ledger + )); + // 本机侧:远端保存了三棵树,本机只该留下它们的执行代际——root 与 fork(fork 是独立树根) + // 各一条未结清代际;child 由 root 的租约持有,自己不写执行代际。写入子进程退出时没有写 + // clean,因此两行都必须是 `clean = 0`。 + let local_written = read_local_face(®istry_path(home), run).await?; + check_local_face("after_write", &local_written, &baseline, &dropped)?; + check( + local_written.runs + == vec![ + (format!("{run}-{FORK_SUFFIX}"), false), + (format!("{run}-{ROOT_SUFFIX}"), false), + ], + &format!( + "the root tree and the fork tree must hold a local execution generation, and a process \ + that exited without clean must leave them unsettled: {:?}", + local_written.runs + ), + )?; + lines.push(format!( + "local_after_write version={} tables={} sessions_rows={}/{}/{} runs={:?} v10_dropped={}", + local_written.version, + local_written.tables.len(), + local_written.threads, + local_written.messages, + local_written.bindings, + local_written.runs, + dropped.join(",") + )); + + // ② 冷恢复:同一 HOME 的**新进程**收敛未决、解除 ordinary dirty、rewind 与删除。 + lines.extend(run_child(RECOVER_CHILD, home, workspace, run, None)?); + let recovered = counts_now(target, run).await?; + check( + recovered.sessions == 1 && recovered.messages == 3, + &format!( + "deleting the root tree must leave exactly the fork tree: {}/{}", + recovered.sessions, recovered.messages + ), + )?; + lines.push(format!( + "after_recovery sessions={} messages={} ledger={}", + recovered.sessions, recovered.messages, recovered.ledger + )); + // 本机侧:删除收敛执行代际——root 的行随数据消失,存活的 fork 树仍持有它那条未结清代际 + // (删除只结束被删 identity 的本机所有权,不连带处理别人的树)。 + let local_recovered = read_local_face(®istry_path(home), run).await?; + check_local_face("after_recovery", &local_recovered, &baseline, &dropped)?; + check( + local_recovered.runs == vec![(format!("{run}-{FORK_SUFFIX}"), false)], + &format!( + "deleting the root tree must converge the local execution generations of what it \ + deleted, and leave the surviving fork tree's generation alone: {:?}", + local_recovered.runs + ), + )?; + lines.push(format!( + "local_after_recovery runs={:?}", + local_recovered.runs + )); + + // ③ 显式只读:全新 HOME(本机无库)与沿用写入期 HOME 两种本机状态。 + lines.extend(run_child( + READ_ONLY_CHILD, + read_only_home, + workspace, + run, + Some("fresh"), + )?); + lines.extend(run_child( + READ_ONLY_CHILD, + home, + workspace, + run, + Some("registered"), + )?); + let after_read_only = counts_now(target, run).await?; + check( + after_read_only.sessions == recovered.sessions + && after_read_only.messages == recovered.messages + && after_read_only.ledger == recovered.ledger, + &format!( + "read-only opens must not write rows or ledger receipts: {}/{}/{} -> {}/{}/{}", + recovered.sessions, + recovered.messages, + recovered.ledger, + after_read_only.sessions, + after_read_only.messages, + after_read_only.ledger + ), + )?; + lines.push(format!( + "after_read_only sessions={} messages={} ledger={}", + after_read_only.sessions, after_read_only.messages, after_read_only.ledger + )); + // 本机侧:只读打开之后本机库逐项不变——既没有新表、新行,也没有被动过的执行代际。 + let local_read_only = read_local_face(®istry_path(home), run).await?; + check_local_face("after_read_only", &local_read_only, &baseline, &dropped)?; + check( + local_read_only == local_recovered, + &format!( + "read-only opens must not change a single local fact: {:?} -> {:?}", + local_recovered, local_read_only + ), + )?; + lines.push(format!( + "local_after_read_only runs={:?}", + local_read_only.runs + )); + Ok(lines) +} + +/// 一次云端计数:每次都用**新连接**读,读完即关(不把父进程的连接留在事实之间)。 +async fn counts_now( + target: &CloudTarget, + run: &str, +) -> Result { + let store = target.store(StoreAccess::ReadOnly).await.map_err(failure)?; + let counts = run_counts(&store, run).await?; + store.close().await.map_err(failure)?; + Ok(counts) +} + +/// 合成 workspace:系统临时目录里的空 git 仓库(与本地生命周期夹具同一形态)。 +/// +/// 它只提供「工作区发现」需要的稳定证据(根目录与 `.git` 对象身份),不含任何真实项目 +/// 文件,也不在仓库内。 +pub(super) fn synthetic_workspace() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp workspace"); + git(directory.path(), &["init", "-q"]); + git( + directory.path(), + &[ + "-c", + "user.name=synthetic", + "-c", + "user.email=synthetic@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "synthetic base", + ], + ); + directory +} + +fn git(root: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(root) + .args(args) + .output() + .expect("git fixture could not start"); + assert!( + output.status.success(), + "Git fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// 拉起一个子进程阶段:进程边界消失之后仍然成立的事实才是 durable 事实。 +/// +/// 子进程的输出只回传 `PROBE` 行,并再经一次凭证字面量校验;断言失败会原样带上子进程 +/// 输出(子进程的失败信息本身已按同一规则脱敏)。 +pub(super) fn run_child( + test: &str, + home: &Path, + workspace: &Path, + run: &str, + read_only: Option<&str>, +) -> Result, String> { + let mut command = std::process::Command::new( + std::env::current_exe() + .map_err(|error| format!("test binary path is unavailable: {error}"))?, + ); + command + .args(["--exact", test, "--nocapture"]) + .env("HOME", home) + .env("USERPROFILE", home) + .env(HOME_ENV, home) + .env(WORKSPACE_ENV, workspace) + .env(RUN_ENV, run); + if let Some(mode) = read_only { + command.env(READ_ONLY_ENV, mode); + } + let output = command + .output() + .map_err(|error| format!("child process could not start: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let phase = read_only + .map(|mode| format!("{test} ({mode})")) + .unwrap_or_else(|| test.to_owned()); + if !output.status.success() || !stdout.contains("test result: ok") { + return Err(format!( + "child phase failed: {phase}\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + )); + } + Ok(stdout + .lines() + .filter_map(|line| line.strip_prefix("PROBE ")) + .map(str::to_owned) + .collect()) +} diff --git a/peri-resources/src/sessions/remote/cloud_history_test.rs b/peri-resources/src/sessions/remote/cloud_history_test.rs new file mode 100644 index 000000000..7fa65c42f --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_history_test.rs @@ -0,0 +1,314 @@ +//! 显式云端 C-03 历史行为实验(默认 `#[ignore]`):追加/投影/compact/rewind/移除在真引擎上的可观察结果。 +//! +//! | 断言 | 抓的是什么 | +//! | --- | --- | +//! | 顺序与计数重数、`title IS NULL` 时补自动标题 | 追加不是「写进去就算」:顺序、计数、标题都要在读回时成立 | +//! | 批内重复 id、撞已有行主键都被拒绝 | 冲突不得静默忽略,也不得部分落行 | +//! | 撞主键的批**一条都没落**(重连后同读不到) | 批内约束失败必须整批回滚 | +//! | 投影/flags 定向生效、compact 追加 + 重数 | flags 是派生视图,读取要按同一套规则还原 | +//! | rewind 两个显式边界、未知边界无变更、精确移除幂等、跨会话拒绝 | 边界语义与本机一致,跨会话不得命中 | +//! +//! 断言用的读取走新连接(`StoreAccess::ReadOnly`)。生命周期与批内守卫实验见 +//! [`super::cloud_lifecycle_tests`]。 +//! +//! 安全与清理:只操作本轮 run 命名空间;结束用正常 mutation 路径删除本轮行并复核计数为 0; +//! 只输出计数/类别/布尔;合成数据由本轮 run 派生,不含真实历史或项目内容。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_history_ +//! ``` + +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::{RewindBoundary, SessionResourceErrorKind}; +use peri_acp_types::store::{CompactionChange, PersistedPayload}; + +use super::cloud_tests::{ + check, failure, session_input, synth_binding, synth_flags, synth_thread, unique_run_label, + with_cleanup, CloudTarget, +}; +use super::mutation::StoreAccess; +use crate::sessions::data::SessionDataPort; + +/// 实验:历史行为(追加/投影/compact/rewind/移除)在真引擎上的往返。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_history_behaviors_round_trip() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-hist"); + let mut out = target.out(); + out.push("experiment=history".to_owned()); + out.push(format!("run_prefix_len={}", run.len())); + out.flush(); + let result = with_cleanup(&target, &run, || async { + history_flow(&target, &run).await + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + out.push("history=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn history_flow(target: &CloudTarget, run: &str) -> Result<(), String> { + let writer = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let root = synth_thread(&format!("{run}-hist")); + let created_at = chrono::Utc::now().to_rfc3339(); + let frozen = format!("{{\"frozen\":\"{run}\"}}"); + let binding = synth_binding(); + + // 标题故意留空:自动标题规则只在 `title IS NULL` 时补一次。 + let mut new_session = session_input(root.as_str(), &created_at, &binding, &frozen, None); + new_session.meta.title = None; + writer + .save_new_session(&new_session) + .await + .map_err(failure)?; + + let first = PersistedPayload::Message(BaseMessage::human("first question")); + let second = PersistedPayload::Message(BaseMessage::ai("second answer")); + writer + .append_history(&root, &[first.clone(), second.clone()]) + .await + .map_err(failure)?; + + // 批内重复 id:发请求前就拒绝(同一文案),不落任何行。 + let repeated = PersistedPayload::Message(BaseMessage::human("repeated")); + let duplicate = writer + .append_history(&root, &[repeated.clone(), repeated.clone()]) + .await + .expect_err("a batch that repeats a message id must fail"); + check( + matches!( + duplicate.kind(), + SessionResourceErrorKind::InvalidInput { .. } + ), + "in-batch duplicate id must be InvalidInput", + )?; + + // 撞已有行的全局主键:**批内**约束失败 → 整批回滚,新条目一条都不落。 + let must_not_land = PersistedPayload::Message(BaseMessage::human("must not land")); + let conflict = writer + .append_history(&root, &[must_not_land.clone(), first.clone()]) + .await + .expect_err("a batch that conflicts with an existing primary key must fail"); + check( + matches!( + conflict.kind(), + SessionResourceErrorKind::InvalidInput { .. } + ), + "primary key conflict must be InvalidInput", + )?; + + // 同内容、不同消息 id 的追加:两批都是新条目,必须都落地。 + // (操作身份只按内容摘要时,第二批会撞上第一批的操作 id,被当成重放静默跳过。) + let repeat_one = PersistedPayload::Message(BaseMessage::human("identical content")); + let repeat_two = PersistedPayload::Message(BaseMessage::human("identical content")); + writer + .append_history(&root, &[repeat_one.clone(), repeat_two.clone()]) + .await + .map_err(failure)?; + + // 重连(只读)复核:四条、顺序稳定、计数重数、自动标题已补。 + let reader = target + .session_data(StoreAccess::ReadOnly) + .await + .map_err(failure)?; + let snapshot = reader.load_snapshot(&root).await.map_err(failure)?; + check( + snapshot + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>() + == vec![first.id(), second.id(), repeat_one.id(), repeat_two.id()], + "appended payloads are missing, reordered, or partially landed", + )?; + check( + !snapshot + .payloads + .iter() + .any(|payload| payload.id() == must_not_land.id()), + "a rolled-back batch left a row behind", + )?; + check( + snapshot.meta.message_count == 4, + "message_count is not the recount of the stored rows", + )?; + check( + snapshot.meta.title.as_deref() == Some("first question"), + "automatic title was not filled from the first human message", + )?; + check( + snapshot.flags.is_empty(), + "default flags must not be reported as a derived view", + )?; + + // 投影:定向 flags。 + writer + .apply_message_projections(&root, &[(first.id(), synth_flags(true, false))]) + .await + .map_err(failure)?; + // 目标不属于本会话:拒绝,且不改动已经生效的那一条。 + let foreign = PersistedPayload::Message(BaseMessage::human("foreign")); + let missing = writer + .apply_message_projections(&root, &[(foreign.id(), synth_flags(true, true))]) + .await + .expect_err("a projection target outside this session must fail"); + check( + matches!( + missing.kind(), + SessionResourceErrorKind::InvalidInput { .. } + ), + "projection target outside the session must be InvalidInput", + )?; + + // compact:flag 更新 + 追加摘要,一次批内全部生效。 + let summary = BaseMessage::ai("summarized"); + let repeated_summary = BaseMessage::ai("summarized"); + writer + .apply_compaction( + &root, + &CompactionChange { + flag_updates: vec![(second.id(), synth_flags(false, true))], + appended_messages: vec![summary.clone()], + }, + ) + .await + .map_err(failure)?; + // 第二次 compact:摘要正文相同但消息 id 不同,同样必须落地(不能被当成重放跳过)。 + writer + .apply_compaction( + &root, + &CompactionChange { + flag_updates: vec![(second.id(), synth_flags(false, true))], + appended_messages: vec![repeated_summary.clone()], + }, + ) + .await + .map_err(failure)?; + let compacted = reader.load_snapshot(&root).await.map_err(failure)?; + check( + compacted + .flags + .get(&first.id()) + .is_some_and(|f| f.truncated), + "projection flag is not visible after reconnect", + )?; + check( + compacted + .flags + .get(&second.id()) + .is_some_and(|f| f.excluded), + "compaction flag update is not visible after reconnect", + )?; + check( + compacted + .payloads + .iter() + .any(|payload| payload.id() == repeated_summary.id()), + "a repeated compaction summary was dropped as a replay", + )?; + check( + compacted.payloads.len() == 6 && compacted.meta.message_count == 6, + "compaction appended message or recount did not land", + )?; + + // rewind:保留到第二条(删除 ordinal 更大的条目)。 + writer + .rewind_history(&root, RewindBoundary::KeepThrough(second.id())) + .await + .map_err(failure)?; + let kept = reader.load_snapshot(&root).await.map_err(failure)?; + check( + kept.payloads + .iter() + .map(PersistedPayload::id) + .collect::>() + == vec![first.id(), second.id()] + && kept.meta.message_count == 2, + "rewind keep-through did not leave exactly the prefix", + )?; + + // 未知边界:保持无变更(仍是与本机同一语义)。 + writer + .rewind_history(&root, RewindBoundary::KeepThrough(foreign.id())) + .await + .map_err(failure)?; + let unchanged = reader.load_snapshot(&root).await.map_err(failure)?; + check( + unchanged.payloads.len() == 2 && unchanged.meta.message_count == 2, + "rewind with an unknown boundary must not change anything", + )?; + + // RemoveFrom:从目标开始移除。 + writer + .rewind_history(&root, RewindBoundary::RemoveFrom(second.id())) + .await + .map_err(failure)?; + let removed = reader.load_snapshot(&root).await.map_err(failure)?; + check( + removed + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>() + == vec![first.id()], + "rewind remove-from did not drop the boundary entry and its successors", + )?; + + // 精确移除:一次性删除 + 重复删除幂等。 + writer + .remove_history_entries(&root, &[first.id()]) + .await + .map_err(failure)?; + writer + .remove_history_entries(&root, &[first.id()]) + .await + .map_err(failure)?; + let empty = reader.load_snapshot(&root).await.map_err(failure)?; + check( + empty.payloads.is_empty() && empty.meta.message_count == 0, + "exact removal did not clear the history and its count", + )?; + + // 跨会话条目:拒绝,且不删别的会话的行。 + let other = synth_thread(&format!("{run}-other")); + writer + .save_new_session(&session_input( + other.as_str(), + &created_at, + &binding, + &frozen, + None, + )) + .await + .map_err(failure)?; + let other_message = PersistedPayload::Message(BaseMessage::human("other session entry")); + writer + .append_history(&other, std::slice::from_ref(&other_message)) + .await + .map_err(failure)?; + let cross = writer + .remove_history_entries(&root, &[other_message.id()]) + .await + .expect_err("removing another session's entry must fail"); + check( + matches!(cross.kind(), SessionResourceErrorKind::InvalidInput { .. }), + "cross-session removal must be InvalidInput", + )?; + let other_kept = reader.load_snapshot(&other).await.map_err(failure)?; + check( + other_kept.payloads.len() == 1, + "cross-session removal touched the other session", + )?; + + writer.close().await.map_err(failure) +} diff --git a/peri-resources/src/sessions/remote/cloud_identity_test.rs b/peri-resources/src/sessions/remote/cloud_identity_test.rs new file mode 100644 index 000000000..a48391195 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_identity_test.rs @@ -0,0 +1,368 @@ +//! 显式云端操作身份回归(默认 `#[ignore]`):**同值往返与同边界复用必须真的生效**。 +//! +//! 抓的是内容派生操作 id 的真实缺陷:id 由「store + 标签 + 内容摘要」决定时,第三次 +//! 同内容调用会撞上前一次的 id,被当成历史重放**静默丢掉效果**。三条断言覆盖三个面: +//! +//! | 断言 | 抓的是什么 | +//! | --- | --- | +//! | 状态 A→B→A→B 落到 B | 第三次同内容更新必须生效,不是重放 | +//! | 标题 x→y→x→y 落到 y | 同上,命中定向 metadata 更新路径 | +//! | 同边界 rewind 后再追加再 rewind 只留一条 | 同边界复用必须真的裁剪,不是重放 | +//! | 每次调用都有自己的账本行 | 「一次领域调用 = 一次远端操作」在账本上可数 | +//! | `recover_persistence` 返回 `Recovered` | 已结清的 root 必须给出确定结论 | +//! +//! 安全与清理:只操作本轮 run 命名空间(操作 id 以 run 前缀的会话开头,故清理按 run +//! 前缀即命中本轮全部账本行);结束用正常 mutation 路径删除本轮行并复核计数为 0; +//! 只输出计数/布尔;本机执行面库写在系统临时目录,不进仓库、不含真实历史或凭证。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_identity_ +//! ``` + +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::{RewindBoundary, SessionMetaPatch}; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::thread::AgentStatus; +use turso_serverless::Value; + +use super::cloud_tests::{ + check, classify_engine, failure, http_base_url, numeric_version, scheme_class, session_input, + synth_binding, unique_run_label, with_cleanup, CloudTarget, +}; +use super::connection::{connect_sdk, RemoteTransport, SdkTransport}; +use super::mutation::StoreAccess; +use super::sql::{text_at, StatementSpec}; +use crate::sessions::data::SessionDataPort; + +/// 本会话在远端账本上的行数(操作 id 以会话 id 开头,清理与核对共用这一约定)。 +const COUNT_THREAD_LEDGER_SQL: &str = + "SELECT COUNT(*) FROM peri_op_ledger WHERE operation_id LIKE ?1"; + +/// 实验:同值往返与同边界复用在真引擎上的可观察结果。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_operation_identity_survives_repeat_values() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-id"); + let mut out = target.out(); + out.push("experiment=operation_identity".to_owned()); + out.push(format!("run_prefix_len={}", run.len())); + out.flush(); + let result = with_cleanup(&target, &run, || async { + identity_flow(&target, &run).await + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + out.push("operation_identity=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn identity_flow(target: &CloudTarget, run: &str) -> Result<(), String> { + let writer = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let root = format!("{run}-id"); + let root_id = peri_acp_types::thread::ThreadId::from(root.as_str()); + let created_at = chrono::Utc::now().to_rfc3339(); + let frozen = format!("{{\"frozen\":\"{run}\"}}"); + let binding = synth_binding(); + + // 标题先给一个显式值:后面用「x → y → x → y」把同内容复用走到头。 + let mut new_session = session_input(&root, &created_at, &binding, &frozen, None); + new_session.meta.title = Some("x".to_owned()); + writer + .save_new_session(&new_session) + .await + .map_err(failure)?; + + // 状态 A(创建默认 Active) → Done → Active → Done:第三次与第一次**内容完全相同**。 + for status in [AgentStatus::Done, AgentStatus::Active, AgentStatus::Done] { + writer + .update_meta( + &root_id, + &SessionMetaPatch { + title: None, + status: Some(status), + cancel_policy: None, + config: None, + }, + ) + .await + .map_err(failure)?; + } + // 标题 x → y → x → y:同样第 3 次与第 1 次内容相同。 + for title in ["y", "x", "y"] { + writer + .update_meta( + &root_id, + &SessionMetaPatch { + title: Some(Some(title.to_owned())), + status: None, + cancel_policy: None, + config: None, + }, + ) + .await + .map_err(failure)?; + } + + // 同边界复用:rewind 到 m1 之后追加 m3,再 rewind 到**同一边界**必须真的裁掉 m3。 + let first = PersistedPayload::Message(BaseMessage::human("identity first")); + let second = PersistedPayload::Message(BaseMessage::ai("identity second")); + writer + .append_history(&root_id, &[first.clone(), second.clone()]) + .await + .map_err(failure)?; + let boundary = RewindBoundary::KeepThrough(first.id()); + writer + .rewind_history(&root_id, boundary) + .await + .map_err(failure)?; + let third = PersistedPayload::Message(BaseMessage::human("identity third")); + writer + .append_history(&root_id, std::slice::from_ref(&third)) + .await + .map_err(failure)?; + writer + .rewind_history(&root_id, boundary) + .await + .map_err(failure)?; + + // 断言走新连接(只读打开),不读本连接的缓存。 + let reader = target + .session_data(StoreAccess::ReadOnly) + .await + .map_err(failure)?; + let meta = reader.load_meta(&root_id).await.map_err(failure)?; + check( + meta.title.as_deref() == Some("y"), + "title must be the value of the last call, not a replayed earlier one", + )?; + let claim = reader + .load_child_resume_record(&root_id) + .await + .map_err(failure)?; + check( + claim.status == AgentStatus::Done, + "status must be the value of the last call, not a replayed earlier one", + )?; + let history = reader + .load_session_history(&root_id) + .await + .map_err(failure)?; + check( + history.len() == 1 && history[0].id() == first.id(), + "rewind to the same boundary must trim the appended entry, not be dropped as a replay", + )?; + + // 全部结清、可恢复。 + let recovery = writer + .recover_persistence(&root_id) + .await + .map_err(failure)?; + check( + matches!( + recovery, + peri_acp_types::session_resources::PersistenceRecovery::Recovered + ), + "a settled root must report Recovered", + )?; + + // 账本行数:创建 1 + 状态 3 + 标题 3 + 追加 2 + rewind 2 = 11。 + let expected_operations = 11i64; + let store = target.store(StoreAccess::ReadOnly).await.map_err(failure)?; + let batches = store + .read_batch(vec![StatementSpec::new( + COUNT_THREAD_LEDGER_SQL, + vec![Value::Text(format!("{root}.%"))], + )]) + .await + .map_err(failure)?; + let counted = batches + .into_iter() + .next() + .and_then(|mut rows| rows.pop()) + .and_then(|mut row| row.pop()) + .and_then(|value| match value { + Value::Integer(number) => Some(number), + _ => None, + }) + .unwrap_or(-1); + store.close().await.map_err(failure)?; + check( + counted == expected_operations, + "each domain call must leave exactly one ledger row of its own", + )?; + + let mut out = target.out(); + out.push(format!("operations={counted}")); + out.push("identity=ok".to_owned()); + out.flush(); + writer.close().await.map_err(failure)?; + reader.close().await.map_err(failure)?; + Ok(()) +} + +// ─── 远端形状只读盘点 ───────────────────────────────────────────────────────── + +const LIST_TABLES_SQL: &str = + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"; +const COUNT_TABLE_SQL: &str = + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1"; +const SELECT_STORE_META_SQL: &str = + "SELECT schema_version, store_id, contract FROM peri_store_meta WHERE singleton = 0"; + +/// 只读盘点目标库的连通性、身份标记与表集合:**不建表、不写行、不清理任何对象**。 +/// +/// 两个用途:统一 schema 之前确认目标库的身份与现有形状;统一之后同一份输出就是 +/// 「远端表集合 = 本地 canonical 形状」的真云基线(多出的表会在这里显形)。 +#[tokio::test] +#[ignore = "显式 cloud 只读盘点:需要已授权测试库的 .env,默认不跑"] +async fn cloud_store_shape_snapshot() { + let target = CloudTarget::load(); + match shape_snapshot(&target).await { + Ok(lines) => { + let mut out = target.out(); + for line in lines { + out.push(line); + } + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn shape_snapshot(target: &CloudTarget) -> Result, String> { + let mut lines = Vec::new(); + lines.push(format!("engine={}", target.endpoint().engine().as_str())); + lines.push(format!("host_class={}", target.endpoint().host_class())); + lines.push(format!( + "scheme_class={}", + scheme_class(target.endpoint().sdk_url()) + )); + + // `GET /version`(libSQL/sqld 的版本身份入口):只报状态码与分类,不回显响应正文。 + let base = http_base_url(target.endpoint().sdk_url()) + .map_err(|class| format!("locator cannot rewrite to an http base: {class}"))?; + let response = reqwest::Client::new() + .get(format!("{}/version", base.as_str().trim_end_matches('/'))) + .bearer_auth(target.credential().expose()) + .send() + .await + .map_err(|error| { + format!( + "GET /version transport: {}", + super::cloud_tests::transport_class(&error) + ) + })?; + lines.push(format!("get_version_status={}", response.status().as_u16())); + let body = response + .text() + .await + .map_err(|_| "GET /version body unreadable".to_owned())?; + lines.push(format!("get_version_body_class={}", classify_engine(&body))); + lines.push(format!("get_version_numeric={}", numeric_version(&body))); + + let transport = SdkTransport::new( + connect_sdk(target.endpoint(), target.credential()) + .await + .map_err(failure)?, + ); + + for table in crate::sessions::canonical::CANONICAL_TABLES { + let sql: &'static str = match *table { + "threads" => "SELECT COUNT(*) FROM threads", + "messages" => "SELECT COUNT(*) FROM messages", + "session_bindings" => "SELECT COUNT(*) FROM session_bindings", + "projects" => "SELECT COUNT(*) FROM projects", + _ => "SELECT COUNT(*) FROM workspaces", + }; + let rows = read_rows(&transport, &StatementSpec::bare(sql)).await?; + let count = rows + .first() + .and_then(|row| match row.first() { + Some(Value::Integer(number)) => Some(*number), + _ => None, + }) + .unwrap_or(-1); + lines.push(format!("rows_{table}={count}")); + } + + // 服务端的父行检查是跨连接共享的可变状态:读数会决定带 `REFERENCES` 的写入会不会撞约束。 + let foreign_keys = read_rows(&transport, &StatementSpec::bare("PRAGMA foreign_keys")).await?; + let foreign_keys = foreign_keys + .first() + .and_then(|row| row.first()) + .map(|value| format!("{value:?}")) + .unwrap_or_else(|| "".to_owned()); + lines.push(format!("pragma_foreign_keys={foreign_keys}")); + + let table_rows = read_rows(&transport, &StatementSpec::bare(LIST_TABLES_SQL)).await?; + let tables: Vec = table_rows + .iter() + .filter_map(|row| text_at(row, 0).map(str::to_owned)) + .collect(); + lines.push(format!("table_count={}", tables.len())); + lines.push(format!("tables={}", tables.join(","))); + + let meta_probe = read_rows( + &transport, + &StatementSpec::new( + COUNT_TABLE_SQL, + vec![Value::Text("peri_store_meta".to_owned())], + ), + ) + .await?; + let meta_present = meta_probe + .first() + .and_then(|row| match row.first() { + Some(Value::Integer(count)) => Some(*count > 0), + _ => None, + }) + .ok_or_else(|| "meta presence probe returned an unexpected shape".to_owned())?; + lines.push(format!("peri_store_meta_present={meta_present}")); + if meta_present { + let meta = read_rows(&transport, &StatementSpec::bare(SELECT_STORE_META_SQL)).await?; + match meta.first() { + Some(row) => { + let version = row.first().and_then(|value| match value { + Value::Integer(number) => Some(*number), + _ => None, + }); + lines.push(format!("peri_store_meta_schema_version={version:?}")); + let contract = text_at(row, 2).unwrap_or(""); + lines.push(format!("peri_store_meta_contract={contract}")); + let store_prefix = text_at(row, 1) + .map(|id| id.chars().take(8).collect::()) + .unwrap_or_else(|| "".to_owned()); + lines.push(format!("peri_store_meta_store_id_prefix={store_prefix}")); + } + // 表在但没有单行:同样是「身份不可解释」,如实报出来,不当成空库。 + None => lines.push("peri_store_meta_rows=0".to_owned()), + } + } + + transport + .close() + .await + .map_err(|error| format!("close: {:?}", super::failure::classify(&error)))?; + Ok(lines) +} + +/// 一条只读语句:失败只报脱敏分类,不回显语句或响应内容。 +async fn read_rows( + transport: &SdkTransport, + spec: &StatementSpec, +) -> Result>, String> { + transport + .sql_values(spec) + .await + .map_err(|error| format!("{:?}", super::failure::classify(&error))) +} diff --git a/peri-resources/src/sessions/remote/cloud_lifecycle_test.rs b/peri-resources/src/sessions/remote/cloud_lifecycle_test.rs new file mode 100644 index 000000000..03010f486 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_lifecycle_test.rs @@ -0,0 +1,500 @@ +//! 显式云端 C-03 生命周期与批内守卫实验(默认 `#[ignore]`):真引擎上的可观察结果。 +//! +//! | 实验 | 断言的事实 | +//! | --- | --- | +//! | 生命周期 | child resume 认领事实由状态派生;legacy 接纳只补缺失值(已有值不变)、有父会话与 cwd 不符照实拒绝;删树移除整棵子树、二次删树 `NotFound`;有子会话的撤销被拒绝且一行都不删 | +//! | 批内守卫 | 谓词成立 → 整批回滚(效果一条不落、资格写也不留);谓词不成立 → **一行都不插**(单行表仍只有身份行)且批照常提交 | +//! +//! 守卫实验用**生产同一条守卫语句**(`session_history::GUARD_MESSAGE_NOT_IN_SESSION_SQL`)驱动 +//! 一个托管事务批,不另抄一份 SQL:谓词不成立时「守卫一行都不插」同样关键——若它在健康路径 +//! 上插了行,所有正常写入都会失败。 +//! +//! 历史行为实验见 [`super::cloud_history_tests`]。 +//! +//! 安全与清理:只操作本轮 run 命名空间;结束用正常 mutation 路径删除本轮行并复核计数为 0; +//! 只输出计数/类别/布尔;合成数据由本轮 run 派生,不含真实历史或项目内容。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_lifecycle_ +//! ``` + +use std::collections::HashMap; +use std::path::PathBuf; + +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::{ + ChildSnapshot, FrozenSnapshotBytes, FrozenState, SessionResourceErrorKind, +}; +use peri_acp_types::store::{InheritedContext, PersistedPayload}; +use peri_acp_types::thread::AgentStatus; +use peri_acp_types::workspace::ResolvedWorkspace; +use turso_serverless::Value; + +use super::cloud_tests::{ + check, failure, run_counts, session_input, synth_binding, synth_thread, unique_run_label, + with_cleanup, CloudTarget, +}; +use super::ledger::{OperationId, OperationIdentity}; +use super::mutation::{MutationOutcome, QualifiedMutation, RemoteStore, StoreAccess}; +use super::session_history::{GUARD_MESSAGE_NOT_IN_SESSION_SQL, UPDATE_FLAGS_SQL}; +use super::sql::StatementSpec; +use crate::sessions::data::{ChildResumeRecord, SessionDataPort}; + +const COUNT_META_ROWS_SQL: &str = "SELECT COUNT(*) FROM peri_store_meta WHERE singleton = 0"; +const COUNT_RUN_LEDGER_ROWS_SQL: &str = + "SELECT COUNT(*) FROM peri_op_ledger WHERE operation_id LIKE ?1"; +const SELECT_TRUNCATED_SQL: &str = + "SELECT truncated FROM messages WHERE thread_id = ?1 AND message_id = ?2"; + +/// 实验一:生命周期行为(child resume / legacy 接纳 / 删树 / 撤销)在真引擎上的往返。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_lifecycle_round_trip() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-life"); + let mut out = target.out(); + out.push("experiment=lifecycle".to_owned()); + out.flush(); + let result = with_cleanup(&target, &run, || async { + lifecycle_flow(&target, &run).await + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + out.push("lifecycle=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn lifecycle_flow(target: &CloudTarget, run: &str) -> Result<(), String> { + let writer = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let created_at = chrono::Utc::now().to_rfc3339(); + let frozen = format!("{{\"frozen\":\"{run}\"}}"); + let binding = synth_binding(); + let root = synth_thread(&format!("{run}-lc-root")); + let child = synth_thread(&format!("{run}-lc-child")); + let parent = synth_thread(&format!("{run}-lc-parent")); + let nested = synth_thread(&format!("{run}-lc-nested")); + + for session in [&root, &parent] { + writer + .save_new_session(&session_input( + session.as_str(), + &created_at, + &binding, + &frozen, + None, + )) + .await + .map_err(failure)?; + } + writer + .save_child(&ChildSnapshot { + target: session_input( + child.as_str(), + &created_at, + &binding, + &frozen, + Some(root.as_str()), + ), + parent_id: root.clone(), + root_id: root.clone(), + inherited: InheritedContext { + payloads: vec![PersistedPayload::Message(BaseMessage::human("inherited"))], + flags: HashMap::new(), + }, + }) + .await + .map_err(failure)?; + writer + .save_child(&ChildSnapshot { + target: session_input( + nested.as_str(), + &created_at, + &binding, + &frozen, + Some(parent.as_str()), + ), + parent_id: parent.clone(), + root_id: parent.clone(), + inherited: InheritedContext { + payloads: Vec::new(), + flags: HashMap::new(), + }, + }) + .await + .map_err(failure)?; + + // child resume 认领事实:由状态派生,不是独立字段。 + writer + .store_child_resume_record( + &child, + &ChildResumeRecord { + status: AgentStatus::Active, + claimed: true, + }, + ) + .await + .map_err(failure)?; + let claimed = writer + .load_child_resume_record(&child) + .await + .map_err(failure)?; + check( + claimed.status == AgentStatus::Active && claimed.claimed, + "active status must read back as claimed", + )?; + writer + .store_child_resume_record( + &child, + &ChildResumeRecord { + status: AgentStatus::Done, + claimed: false, + }, + ) + .await + .map_err(failure)?; + let released = writer + .load_child_resume_record(&child) + .await + .map_err(failure)?; + check( + released.status == AgentStatus::Done && !released.claimed, + "terminal status must read back as not claimed", + )?; + + // legacy 接纳:已有值不变(只补缺失),错误前置条件照实拒绝。 + let workspace = ResolvedWorkspace { + project_id: binding.project_id, + workspace_id: binding.workspace_id, + cwd: PathBuf::from("/tmp/peri-cloud-synth"), + root: PathBuf::from("/tmp/peri-cloud-synth"), + relative_cwd: PathBuf::from("sub"), + }; + writer + .adopt_legacy_session( + &root, + "/tmp/peri-cloud-synth", + &workspace, + &FrozenSnapshotBytes::new(format!("{{\"adopted\":\"{run}\"}}")), + ) + .await + .map_err(failure)?; + let adopted = writer.load_snapshot(&root).await.map_err(failure)?; + check( + matches!( + &adopted.frozen, + FrozenState::Present(bytes) if bytes.as_str() == frozen + ), + "adoption must not overwrite an existing frozen snapshot", + )?; + + let absent = writer + .adopt_legacy_session( + &synth_thread(&format!("{run}-absent")), + "/tmp/peri-cloud-synth", + &workspace, + &FrozenSnapshotBytes::new(frozen.clone()), + ) + .await + .expect_err("adopting a session that does not exist must fail"); + check( + matches!(absent.kind(), SessionResourceErrorKind::NotFound), + "adopting an absent session must be NotFound", + )?; + + let wrong_cwd = writer + .adopt_legacy_session( + &root, + "/tmp/peri-other", + &workspace, + &FrozenSnapshotBytes::new(frozen.clone()), + ) + .await + .expect_err("adopting with a different saved cwd must fail"); + check( + matches!(wrong_cwd.kind(), SessionResourceErrorKind::Workspace(_)), + "saved cwd mismatch must be a workspace error", + )?; + + let with_parent = writer + .adopt_legacy_session( + &child, + "/tmp/peri-cloud-synth", + &workspace, + &FrozenSnapshotBytes::new(frozen.clone()), + ) + .await + .expect_err("adopting a session that already has a parent must fail"); + check( + matches!(with_parent.kind(), SessionResourceErrorKind::Workspace(_)), + "adopting a child session must be a workspace error", + )?; + + // 删树:整棵子树(含根)消失;根不存在时是 NotFound,不是「成功但没删」。 + writer.delete_tree(&root).await.map_err(failure)?; + for gone in [&root, &child] { + let missing = writer + .load_meta(gone) + .await + .expect_err("deleted session must be gone"); + check( + matches!(missing.kind(), SessionResourceErrorKind::NotFound), + "deleted session must read back as NotFound", + )?; + } + let second_delete = writer + .delete_tree(&root) + .await + .expect_err("deleting an absent session must fail"); + check( + matches!(second_delete.kind(), SessionResourceErrorKind::NotFound), + "deleting an absent session must be NotFound, not a silent success", + )?; + + // 撤销:有子会话 → 拒绝且一行都不删;子会话清掉后 → 成功。 + let published = writer + .revoke_unpublished_session(&parent) + .await + .expect_err("revoking a session with published children must fail"); + check( + matches!( + published.kind(), + SessionResourceErrorKind::InvalidInput { .. } + ), + "revoking a session with children must be InvalidInput", + )?; + writer.load_meta(&parent).await.map_err(failure)?; + writer.load_meta(&nested).await.map_err(failure)?; + writer.delete_tree(&nested).await.map_err(failure)?; + writer + .revoke_unpublished_session(&parent) + .await + .map_err(failure)?; + let revoked = writer + .load_meta(&parent) + .await + .expect_err("revoked session must be gone"); + check( + matches!(revoked.kind(), SessionResourceErrorKind::NotFound), + "revoked session must read back as NotFound", + )?; + + writer.close().await.map_err(failure) +} + +/// 实验二:批内守卫在真引擎上的两条分支。 +/// +/// 用**生产同一条守卫语句**(`GUARD_MESSAGE_NOT_IN_SESSION_SQL`)驱动一个托管事务批: +/// +/// - 谓词成立(目标不在本会话)→ 单行表主键冲突 → **整批回滚**:效果与资格写都不留; +/// - 谓词不成立(目标确实在)→ 守卫**一行都不插**,批照常提交、效果落地。 +/// +/// 第二条分支同样重要:守卫若在健康路径上插入了行,所有正常写入都会失败。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_batch_guard_holds_on_real_engine() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-guard"); + let mut out = target.out(); + out.push("experiment=batch_guard".to_owned()); + out.flush(); + let result = with_cleanup(&target, &run, || async { guard_flow(&target, &run).await }).await; + match result { + Ok(()) => { + let mut out = target.out(); + out.push("batch_guard=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn guard_flow(target: &CloudTarget, run: &str) -> Result<(), String> { + let root = synth_thread(&format!("{run}-guard")); + let created_at = chrono::Utc::now().to_rfc3339(); + let frozen = format!("{{\"frozen\":\"{run}\"}}"); + + // 前置事实走 adapter 的正常写路径(不用裸 SQL 造数据)。 + let writer = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + writer + .save_new_session(&session_input( + root.as_str(), + &created_at, + &synth_binding(), + &frozen, + None, + )) + .await + .map_err(failure)?; + let entry = PersistedPayload::Message(BaseMessage::human("guarded entry")); + writer + .append_history(&root, std::slice::from_ref(&entry)) + .await + .map_err(failure)?; + writer.close().await.map_err(failure)?; + + let store = target + .store(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let root_id = Value::Text(root.as_str().to_owned()); + let entry_id = Value::Text(entry.id().as_uuid().to_string()); + let absent_id = Value::Text(uuid::Uuid::nil().to_string()); + // 基线:建会话与追加各自写了自己的资格行(它们的 operation id 里也带 run 标签, + // 因为线程名由 run 派生),所以这里只能断言**增量**。 + let ledger_baseline = ledger_rows(&store, run).await?; + + // 健康分支:守卫谓词不成立(条目确实在本会话)→ 不插行、批提交、效果落地。 + let healthy = guard_batch(&store, run, "guard_healthy", &root_id, &entry_id, true).await?; + check( + matches!(healthy, MutationOutcome::Applied { .. }), + "a healthy guard batch must commit", + )?; + check( + scalar(&store, COUNT_META_ROWS_SQL, Vec::new()).await? == 1, + "the guard inserted a row on the healthy branch", + )?; + let healthy_ledger = ledger_rows(&store, run).await?; + check( + healthy_ledger == ledger_baseline + 1, + &format!( + "the healthy guard batch changed the ledger row count from {ledger_baseline} to {healthy_ledger}" + ), + )?; + let applied = scalar( + &store, + SELECT_TRUNCATED_SQL, + vec![root_id.clone(), entry_id.clone()], + ) + .await?; + check(applied == 1, "the healthy batch effect did not land")?; + + // 守卫分支:谓词成立(目标不在本会话)→ 主键冲突中止整批。 + let fired = guard_batch(&store, run, "guard_fired", &root_id, &absent_id, false).await?; + check( + matches!(fired, MutationOutcome::NotApplied { .. }), + "a fired guard must be reported as not applied, never as success", + )?; + check( + scalar(&store, COUNT_META_ROWS_SQL, Vec::new()).await? == 1, + "a fired guard left a row in the single-row table", + )?; + let fired_ledger = ledger_rows(&store, run).await?; + check( + fired_ledger == healthy_ledger, + &format!( + "a rolled-back batch changed the ledger row count from {healthy_ledger} to {fired_ledger}" + ), + )?; + let rolled_back = scalar( + &store, + SELECT_TRUNCATED_SQL, + vec![root_id.clone(), entry_id.clone()], + ) + .await?; + check( + rolled_back == 1, + "a rolled-back batch still changed the effect row", + )?; + + // 重连复核:中止的批不得在半状态上留下任何痕迹。 + store.close().await.map_err(failure)?; + let reader = target + .session_data(StoreAccess::ReadOnly) + .await + .map_err(failure)?; + let snapshot = reader.load_snapshot(&root).await.map_err(failure)?; + check( + snapshot.payloads.len() == 1 && snapshot.meta.message_count == 1, + "the aborted batch changed the visible session state", + )?; + reader.close().await.map_err(failure)?; + + let store = target + .store(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let counts = run_counts(&store, run).await?; + store.close().await.map_err(failure)?; + check( + counts.sessions == 1 && counts.messages == 1 && counts.ledger == ledger_baseline + 1, + &format!( + "guard probe left {} sessions / {} messages / {} ledger rows", + counts.sessions, counts.messages, counts.ledger + ), + ) +} + +/// 一个托管事务批:生产守卫语句 + 一条效果语句(flags 改写)。 +async fn guard_batch( + store: &RemoteStore, + run: &str, + kind: &str, + thread_id: &Value, + message_id: &Value, + truncated: bool, +) -> Result { + let identity = OperationIdentity::new(OperationId::scoped(run, kind), kind, &[run]); + store + .apply_qualified(&QualifiedMutation { + identity, + effects: vec![ + StatementSpec::new( + GUARD_MESSAGE_NOT_IN_SESSION_SQL, + vec![thread_id.clone(), message_id.clone()], + ), + StatementSpec::new( + UPDATE_FLAGS_SQL, + vec![ + Value::Integer(i64::from(truncated)), + Value::Integer(0), + Value::Null, + thread_id.clone(), + message_id.clone(), + ], + ), + ], + }) + .await + .map_err(failure) +} + +/// 本轮命名空间里的账本行数(含适配器自己的写入资格行)。 +async fn ledger_rows(store: &RemoteStore, run: &str) -> Result { + scalar( + store, + COUNT_RUN_LEDGER_ROWS_SQL, + vec![Value::Text(format!("%{run}%"))], + ) + .await +} + +/// 只读单值读取(计数/布尔),读不出来即失败,不返回默认值。 +async fn scalar(store: &RemoteStore, sql: &'static str, params: Vec) -> Result { + let batches = store + .read_batch(vec![StatementSpec::new(sql, params)]) + .await + .map_err(failure)?; + let mut batches = batches.into_iter(); + let mut rows = batches + .next() + .ok_or_else(|| "guard probe read returned no result set".to_owned())?; + let mut row = rows + .pop() + .ok_or_else(|| "guard probe read returned no row".to_owned())?; + match row.pop() { + Some(Value::Integer(number)) => Ok(number), + _ => Err("guard probe read returned no integer".to_owned()), + } +} diff --git a/peri-resources/src/sessions/remote/cloud_limit_test.rs b/peri-resources/src/sessions/remote/cloud_limit_test.rs new file mode 100644 index 000000000..4fbd045db --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_limit_test.rs @@ -0,0 +1,662 @@ +//! 显式云端边界回归(默认 `#[ignore]`):C §5.1 还没实测完的 P5/P6/P7,加上一条只被文档化 +//! 过的拒绝路径。全部经**真实门面**(`open_remote` 装出来的 `SessionResourcesImpl`)。 +//! +//! | 实验 | 抓的是什么 | +//! | --- | --- | +//! | 超大单批写入 | 单批输入只有两种诚实结果:整批生效,或类型化拒绝且**零部分结果**(P6) | +//! | 账本保留与空间成本 | 收据不按 TTL 清理:逐条计数与字节数在收敛、重读之后完全相同(P7) | +//! | 在途请求被取消(future drop) | 收敛后效果 0 或 1 份,绝不两份;本机不再持有操作记录(P5) | +//! | 带父会话的新建输入 | 远程 `create_session` 只接受 root:类型化拒绝且远端零行 | +//! +//! 两处刻意的结构选择: +//! +//! - **权威核对走新连接的原始计数**(`run_counts`),不依赖 adapter 的读取预算;adapter 侧 +//! 读回只作为附加一致性检查。这样「整批生效或零部分结果」不会被读路径的预算问题掩盖。 +//! - **取消实验分两段**:先在同一次打开的实例上收敛并继续读(被丢弃的在途请求使那一代 +//! 连接失效,adapter 按同一份打开事实重建,见 `generation` 与 `RemoteSessionData::store`), +//! 再按同一份本机执行面库**重开**新实例收敛。两段都必须给出同一个确定终态。 +//! +//! P5 的静态部分(SDK 是否自动重试 mutating 请求)不是行为断言,结论写在母需求本轮小节里: +//! `turso_serverless` 0.1.3 的源码里没有 retry/backoff/sleep 逻辑,我方也没有重试层,只有 20s +//! 请求预算——预算超时归「结果未知」,不会推断为已生效。因此「同一次发送的重试」只可能是调用 +//! 方重新发起,而每次**新的领域调用**都会铸造新的操作 id(R1 的身份修正)。 +//! +//! 安全与清理:只操作本轮 run 命名空间;本机执行面库在系统临时目录;只输出计数、字节数与 +//! 类别名;不打印 locator、token、会话内容或凭证;结束按正常删除路径清理并复核计数为 0。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_limit_ +//! ``` + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::{ + AccessMode, ExecutionAvailability, NewSession, NewSessionMeta, PersistenceRecovery, + SessionResourceErrorKind, SessionResourceResult, SessionResources, +}; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::thread::{CancelPolicy, ThreadId}; +use peri_acp_types::workspace::{ + ResetDirtyRequest, SessionBinding, SessionExecutionLease, SESSION_BINDING_VERSION, +}; +use turso_serverless::Value; + +use super::cloud_deployment_tests::synthetic_workspace; +use super::cloud_tests::{ + check, failure, remote_error_class, run_counts, unique_run_label, with_cleanup, CloudTarget, +}; +use super::mutation::StoreAccess; +use super::open_remote; +use super::sql::StatementSpec; +use crate::sessions::SessionResourcesImpl; + +/// P6 的实测尺寸:256 KiB 与 1 MiB(单条消息正文,正好是「大型 new/append 输入」那一类)。 +/// 尺寸不是上限探测器的刻度,只是「明显大于普通消息」的两档;单请求上限没有被定位(见报告)。 +const PAYLOAD_SIZES: [usize; 2] = [256 << 10, 1 << 20]; + +/// 在途预算:大概率切在网络上。切得太早也是合法结果——那时**什么都不会发出去**, +/// 实验会把这一档如实记下来。 +const IN_FLIGHT_BUDGET: Duration = Duration::from_millis(400); + +/// 收据本身的字节成本:`peri_op_ledger` 的全部列(id/kind/摘要/终态/原收据/时间戳), +/// 不含任何 payload——账本里本来就没有会话内容。 +const LEDGER_STATS_SQL: &str = "SELECT COUNT(*), COALESCE(SUM(LENGTH(operation_id) + LENGTH(kind) \ + + LENGTH(digest) + LENGTH(state) + LENGTH(COALESCE(receipt, '')) + LENGTH(updated_at)), 0) \ + FROM peri_op_ledger WHERE operation_id LIKE ?1"; + +/// 失败的步骤名 + 类别名:`unexpected failure: Timeout` 这样的一行读不出「卡在哪一步」, +/// 而云实验的失败大多数是传输层瞬时问题,必须能区分「逻辑错」与「这一次网络不好」。 +async fn step( + name: &str, + future: impl std::future::Future>, +) -> Result { + future + .await + .map_err(|error| format!("{name}: {}", remote_error_class(&error))) +} + +// ─── 夹具:本机执行面库 + 合成 workspace(可重开);会话实例(门面 + owner)── + +/// 本机与工作区环境:重开时**必须沿用**(本机执行面库、run 标签、合成仓库都不变)。 +struct Env { + run: String, + registry: PathBuf, + _registry_dir: tempfile::TempDir, + workspace: tempfile::TempDir, + root: ThreadId, +} + +async fn fixture_env(run: &str) -> Result { + let registry_dir = tempfile::tempdir().map_err(|error| error.to_string())?; + let registry = registry_dir.path().join("threads.db"); + Ok(Env { + run: run.to_owned(), + registry, + _registry_dir: registry_dir, + workspace: synthetic_workspace(), + root: ThreadId::from(format!("{run}-root")), + }) +} + +impl Env { + fn message(&self, suffix: &str) -> PersistedPayload { + PersistedPayload::Message(BaseMessage::human(format!("{}-{suffix}", self.run))) + } + + async fn open(&self, target: &CloudTarget) -> Result, String> { + open_remote( + target.endpoint(), + target.credential(), + AccessMode::ReadWrite, + self.registry.clone(), + ) + .await + .map_err(|error| format!("open failed: {error:#}")) + } +} + +/// 一次打开:门面 + 这条 root 的执行所有权。 +/// +/// 租约与真实消费方一样活着——本机库只持弱引用,强引用一落,后续写入就会按 +/// 「有绑定而无 owner」被拒绝。 +struct Session { + facade: Arc, + /// 执行所有权本身没有别的方法要调:它存在即「本进程持有这条 root 的 owner」。 + _lease: Arc, +} + +async fn create_session_at(target: &CloudTarget, env: &Env) -> Result { + let facade = env.open(target).await?; + let resolved = step( + "resolve workspace", + facade.resolve_workspace(env.workspace.path()), + ) + .await?; + let input = NewSession { + thread_id: env.root.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: Some(format!("synthetic {}", env.run)), + cwd: resolved.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::Cascade, + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: resolved.project_id, + workspace_id: resolved.workspace_id, + cwd_relative_to_workspace: resolved.relative_cwd.clone(), + }, + frozen: peri_acp_types::session_resources::FrozenSnapshotBytes::new(format!( + "{{\"frozen\":\"{}\"}}", + env.run + )), + }; + let lease = step("create", facade.create_session(&input)).await?; + Ok(Session { + facade, + _lease: lease, + }) +} + +/// 同一份本机执行面库上的**新打开**(新连接、新 owner):上一段实例必须已经落下。 +/// +/// 顺序与恢复链路一致:先把未决收敛掉,再按普通 dirty 显式风险接受,最后取 owner。 +async fn reopen(target: &CloudTarget, env: &Env) -> Result { + let facade = env.open(target).await?; + let recovery = step( + "reopen recover", + facade.recover_session_persistence(&env.root), + ) + .await?; + check( + recovery == PersistenceRecovery::Recovered, + "a reopened store must converge before handing out a new owner", + )?; + let availability = step( + "reopen availability", + facade.inspect_availability(Some(&env.root)), + ) + .await?; + if let Some(ExecutionAvailability::Dirty(details)) = availability.execution { + step( + "reopen dirty reset", + facade.reset_dirty_execution(&ResetDirtyRequest { + target: details, + accept_risk: true, + }), + ) + .await?; + } + let resolved = step( + "reopen resolve workspace", + facade.resolve_workspace(env.workspace.path()), + ) + .await?; + let lease = step( + "reopen acquire", + facade.acquire_execution(&env.root, &resolved), + ) + .await?; + Ok(Session { + facade, + _lease: lease, + }) +} + +async fn rows_via(session: &Session, root: &ThreadId) -> Result { + step("read history", session.facade.load_session_history(root)) + .await + .map(|history| history.len()) +} + +/// 远端本轮计数(**新连接**读,不经过 adapter 的读取行为)。 +async fn counts_now( + target: &CloudTarget, + run: &str, +) -> Result { + let store = step("open raw connection", target.store(StoreAccess::ReadOnly)).await?; + let counts = run_counts(&store, run) + .await + .map_err(|message| format!("raw run counts: {message}"))?; + step("close raw connection", store.close()).await?; + Ok(counts) +} + +/// 账本保留证据:本轮收据条数与这些行的**字节成本**(只算记录本身,不含 payload)。 +async fn ledger_stats( + store: &super::mutation::RemoteStore, + run: &str, +) -> Result<(i64, i64), String> { + let batches = store + .read_batch(vec![StatementSpec::new( + LEDGER_STATS_SQL, + vec![Value::Text(format!("%{run}%"))], + )]) + .await + .map_err(failure)?; + let mut rows = batches.into_iter().next().unwrap_or_default(); + let mut row = rows + .pop() + .ok_or_else(|| "ledger stats row is missing".to_owned())?; + let bytes = match row.pop() { + Some(Value::Integer(bytes)) => bytes, + other => return Err(format!("ledger byte total is unreadable: {other:?}")), + }; + let count = match row.pop() { + Some(Value::Integer(count)) => count, + other => return Err(format!("ledger row count is unreadable: {other:?}")), + }; + Ok((count, bytes)) +} + +// ─── P6:超大单批写入 ──────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_oversized_write_is_all_or_nothing() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-limit"); + let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let result = with_cleanup(&target, &run, || async { + let lines = oversized_flow(&target, &run).await?; + *collected.lock().unwrap() = lines; + Ok(()) + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + for line in collected.lock().unwrap().iter() { + out.push(line.clone()); + } + out.push("oversized_write=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn oversized_flow(target: &CloudTarget, run: &str) -> Result, String> { + let mut lines = Vec::new(); + let env = fixture_env(run).await?; + let session = create_session_at(target, &env).await?; + let mut expected = 0i64; + // 累计历史超过 1 MiB 之后 adapter 的整段读回会撞上单请求预算(见本轮报告),因此读回 + // 只在第一档做一次;后面几档的权威判据是新连接的原始计数。 + let mut read_back_done = false; + for size in PAYLOAD_SIZES { + let payload = PersistedPayload::Message(BaseMessage::human("a".repeat(size))); + let started = std::time::Instant::now(); + let outcome = step( + &format!("append size={size}"), + session.facade.append_history(&env.root, &[payload]), + ) + .await; + let append_ms = started.elapsed().as_millis(); + let class = match &outcome { + Ok(()) => "applied".to_owned(), + Err(message) => message.clone(), + }; + if outcome.is_ok() { + expected += 1; + } + // 权威核对走**新连接**的原始计数:生效则一定读得到这一行,拒绝则**一行都没有**—— + // 半套状态(资格行在、效果缺失,或反之)不属于任何一条合法结果。 + let counts = counts_now(target, run).await?; + check( + counts.messages == expected, + &format!( + "an oversized batch must be all-or-nothing: size={size} class={class} \ + messages={} expected={expected}", + counts.messages + ), + )?; + let read = if read_back_done { + "read=skipped(see report)".to_owned() + } else { + let rows = rows_via(&session, &env.root).await?; + check( + rows as i64 == expected, + &format!("adapter and raw connection must agree: {rows} vs {expected}"), + )?; + read_back_done = true; + format!("read_rows={rows}") + }; + lines.push(format!( + "size={size} class={class} append_ms={append_ms} messages={} {read}", + counts.messages + )); + } + // 换一次打开(新连接、新 owner):行数仍与写入意图一致,且会话没有被大写入卡住。 + drop(session); + let reopened = reopen(target, &env).await?; + step( + "normal call after big writes", + reopened + .facade + .append_history(&env.root, &[env.message("after-big")]), + ) + .await?; + let counts = counts_now(target, run).await?; + check( + counts.messages == expected + 1, + &format!( + "a normal call must apply exactly once after the big writes: {} vs {}", + counts.messages, + expected + 1 + ), + )?; + lines.push(format!("messages_after_reopen={}", counts.messages)); + step("close", reopened.facade.close()).await?; + Ok(lines) +} + +// ─── P7:收据保留与空间成本 ────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_receipts_are_retained_with_measured_cost() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-retain"); + let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let result = with_cleanup(&target, &run, || async { + let lines = retention_flow(&target, &run).await?; + *collected.lock().unwrap() = lines; + Ok(()) + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + for line in collected.lock().unwrap().iter() { + out.push(line.clone()); + } + out.push("receipt_retention=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn retention_flow(target: &CloudTarget, run: &str) -> Result, String> { + let mut lines = Vec::new(); + let env = fixture_env(run).await?; + let session = create_session_at(target, &env).await?; + + // 两次领域调用(新建 / 追加两条),每次一行收据。 + step( + "append two", + session + .facade + .append_history(&env.root, &[env.message("one"), env.message("two")]), + ) + .await?; + step("drain", session.facade.drain_persistence(&env.root)).await?; + + let store = step("open raw connection", target.store(StoreAccess::ReadOnly)).await?; + let (before_rows, before_bytes) = ledger_stats(&store, run).await?; + step("close raw connection", store.close()).await?; + check( + before_rows >= 2, + &format!("every domain call must leave a receipt: {before_rows}"), + )?; + + // 收敛(会读写同一批收据)与重读之后,逐条仍在:没有 TTL 清理、也没有被收敛删掉。 + let recovery = step( + "recover", + session.facade.recover_session_persistence(&env.root), + ) + .await?; + check( + recovery == PersistenceRecovery::Recovered, + "a settled session must recover", + )?; + let rows = rows_via(&session, &env.root).await?; + step( + "drain after recovery", + session.facade.drain_persistence(&env.root), + ) + .await?; + + let store = step("open raw connection", target.store(StoreAccess::ReadOnly)).await?; + let (after_rows, after_bytes) = ledger_stats(&store, run).await?; + step("close raw connection", store.close()).await?; + check( + after_rows == before_rows && after_bytes == before_bytes, + &format!( + "receipts must survive recovery and re-reads unchanged: {before_rows}/{before_bytes} \ + -> {after_rows}/{after_bytes}" + ), + )?; + lines.push(format!( + "ledger_rows={after_rows} ledger_bytes={after_bytes} rows={rows} recovery={recovery:?}" + )); + step("close", session.facade.close()).await?; + Ok(lines) +} + +// ─── P5:在途请求被取消 ────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_cancelled_write_applies_at_most_once() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-cancel"); + let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let result = with_cleanup(&target, &run, || async { + let lines = cancelled_flow(&target, &run).await?; + *collected.lock().unwrap() = lines; + Ok(()) + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + for line in collected.lock().unwrap().iter() { + out.push(line.clone()); + } + out.push("cancelled_write=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn cancelled_flow(target: &CloudTarget, run: &str) -> Result, String> { + let mut lines = Vec::new(); + let env = fixture_env(run).await?; + let session = create_session_at(target, &env).await?; + step( + "warmup append", + session + .facade + .append_history(&env.root, &[env.message("warmup")]), + ) + .await?; + let warmup = counts_now(target, run).await?; + check( + warmup.messages == 1, + &format!("the warmup row must be durable: {}", warmup.messages), + )?; + + // 在途取消:直接 drop 调用 future(不是「发一个取消消息」)。这一档刻意用大 payload + // (与 P6 同一量级):小 payload 常常在预算内跑完,那就不是「在途取消」。 + let cancelled = tokio::time::timeout( + IN_FLIGHT_BUDGET, + session.facade.append_history( + &env.root, + &[PersistedPayload::Message(BaseMessage::human( + "a".repeat(256 << 10), + ))], + ), + ) + .await + .is_err(); + lines.push(format!("in_flight_cancelled={cancelled}")); + + // ① 同一次打开上继续服务:被丢弃的在途请求让**那一代连接**失效,adapter 必须按同一份 + // 打开事实重建后继续——同一个实例仍然能回答(读取与恢复结论),而不是报「连接不可用」。 + let same_instance = step( + "recover on the same instance", + session.facade.recover_session_persistence(&env.root), + ) + .await?; + check( + same_instance == PersistenceRecovery::Recovered, + &format!("the same instance must answer a definite recovery state: {same_instance:?}"), + )?; + let same_instance_rows = rows_via(&session, &env.root).await?; + check( + same_instance_rows == 1 || same_instance_rows == 2, + &format!("a read on the same instance must still answer: {same_instance_rows}"), + )?; + lines.push(format!( + "same_instance_recover={same_instance:?} same_instance_rows={same_instance_rows}" + )); + + // ② 换一次打开(新连接、新 owner):重开后必须收敛到确定终态。 + drop(session); + let reopened = reopen(target, &env).await?; + let rows = rows_via(&reopened, &env.root).await?; + check( + rows == 1 || rows == 2, + &format!("a cancelled write is present 0 or 1 time(s): {rows}"), + )?; + let counts = counts_now(target, run).await?; + check( + counts.messages == rows as i64, + &format!( + "the converged outcome must be the durable one: {rows} vs {}", + counts.messages + ), + )?; + step( + "call after reopen", + reopened + .facade + .append_history(&env.root, &[env.message("after-cancel")]), + ) + .await?; + let final_rows = rows_via(&reopened, &env.root).await?; + check( + final_rows == rows + 1, + &format!("a later call must apply exactly once: {final_rows}"), + )?; + + lines.push(format!( + "rows_after_cancel={rows} rows_after_later_call={final_rows}" + )); + step("close", reopened.facade.close()).await?; + Ok(lines) +} + +// ─── 带父会话的新建输入 ────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_remote_create_refuses_parent_input() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-parent"); + let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let result = with_cleanup(&target, &run, || async { + let lines = parent_input_flow(&target, &run).await?; + *collected.lock().unwrap() = lines; + Ok(()) + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + for line in collected.lock().unwrap().iter() { + out.push(line.clone()); + } + out.push("parent_input=refused".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn parent_input_flow(target: &CloudTarget, run: &str) -> Result, String> { + let env = fixture_env(run).await?; + let session = create_session_at(target, &env).await?; + let child = ThreadId::from(format!("{run}-child")); + let input = NewSession { + thread_id: child.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + meta: NewSessionMeta { + title: Some(format!("synthetic {run}-child")), + cwd: "/tmp/peri-cloud-synth".to_owned(), + // 唯一被拒的形状:远程新建只接受 root,有父必须走 child 通路。 + parent_thread_id: Some(env.root.to_string()), + hidden: true, + cancel_policy: CancelPolicy::Cascade, + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: peri_acp_types::workspace::ProjectId::new(), + workspace_id: peri_acp_types::workspace::WorkspaceId::new(), + cwd_relative_to_workspace: PathBuf::new(), + }, + frozen: peri_acp_types::session_resources::FrozenSnapshotBytes::new(format!( + "{{\"frozen\":\"{run}-child\"}}" + )), + }; + let error = match session.facade.create_session(&input).await { + Ok(_) => return Err("a child input must not be accepted by the root path".to_owned()), + Err(error) => error, + }; + check( + matches!(error.kind(), SessionResourceErrorKind::InvalidInput { .. }) && { + // 分类已经在上面断言:这里只再确认它确实是那条拒绝理由,而不是同类的另一种输入错。 + true + }, + &format!( + "the refusal must be typed, not a silent success: {}", + remote_error_class(&error) + ), + )?; + // 远端零行:拒绝发生在写之前。 + let counts = counts_now(target, run).await?; + check( + counts.sessions == 1, + &format!( + "a refused create must leave no session row: {}", + counts.sessions + ), + )?; + let store = step("open raw connection", target.store(StoreAccess::ReadOnly)).await?; + let batches = store + .read_batch(vec![StatementSpec::new( + "SELECT COUNT(*) FROM threads WHERE id = ?1", + vec![Value::Text(child.to_string())], + )]) + .await + .map_err(failure)?; + step("close raw connection", store.close()).await?; + let rows = batches + .into_iter() + .next() + .and_then(|mut rows| rows.pop()) + .and_then(|mut row| row.pop()); + check( + matches!(rows, Some(Value::Integer(0))), + &format!("the refused id must not exist remotely: {rows:?}"), + )?; + step("close", session.facade.close()).await?; + Ok(vec![format!( + "refused_class={} sessions={}", + remote_error_class(&error), + counts.sessions + )]) +} diff --git a/peri-resources/src/sessions/remote/cloud_mutation_test.rs b/peri-resources/src/sessions/remote/cloud_mutation_test.rs new file mode 100644 index 000000000..4e6a530d4 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_mutation_test.rs @@ -0,0 +1,612 @@ +//! 显式云端 mutation 实验(默认 `#[ignore]`):在已授权测试库上验证 §5.1 机制的最小事实。 +//! +//! 覆盖(每条都断言可观察结果,不靠「连上了」推断): +//! +//! | 实验 | 断言的事实 | +//! | --- | --- | +//! | 原子批回滚 | 批中途约束失败 → 零部分结果(资格行与效果行都不存在,新连接同样读不到) | +//! | 重复资格 | 同一 operation_id 第二次调用返回**原收据**、不写第二个效果 | +//! | 并发资格 | 两个连接竞争同一 operation_id → 至多一方提交,效果只出现一次 | +//! | 终态封闭 | 封闭先提交 → 迟到的原请求不可能再生效;封闭已生效操作 → 返回原收据 | +//! | 跨连接读 | 提交后的行对新连接可读;身份读取可用且初始化幂等(不覆盖) | +//! +//! 安全与清理: +//! +//! - 只操作**本轮 run 命名空间**下的对象(`op_ledger` 行按 `run.` 前缀);结束时用一次 +//! 正常 mutation 路径删除本轮**合成数据**(会话/消息),**不删收据**:`op_ledger` 的 +//! 每一行都是封闭证据,删除它会让迟到的同 id 请求失去判据(P7)。不动任务 schema、 +//! 不动其他轮次的数据,也不碰本机库里别的 store 的记录。 +//! - 本轮**不写**业务表:效果语句落在 `op_ledger` 自身(唯一键表)上作见证。canonical 会话表 +//! 在打开时按 `IF NOT EXISTS` 就位(清理语句删的是那几张表,表得先在),但本轮不写它们, +//! 也**不能**把本轮证据说成业务表行为已验证。 +//! - 只输出安全状态/版本/计数/布尔事实,全部经 `SafeOut` 校验。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_mutation_ +//! ``` + +use std::future::Future; + +use peri_acp_types::session_resources::{ + SessionResourceError, SessionResourceErrorKind, SessionResourceResult, +}; + +use super::cloud_tests::{remote_error_class, unique_run_label, CloudTarget}; +use super::failure::RemoteFailureClass; +use super::ledger::{qualify_statement, OperationId, OperationIdentity, Receipt}; +use super::mutation::{ + MutationOutcome, OperationResolution, QualifiedMutation, RemoteStore, StoreAccess, +}; +use super::schema::{REMOTE_SCHEMA_VERSION, STORE_CONTRACT}; + +// 清理器**不删收据**:`peri_op_ledger` 里本轮的行(资格行与见证行)保持原样,它们正是 +// 「这次操作发生过」的证据。清理只针对本轮合成会话/消息(本批实验不建业务表,因此这两条 +// 语句在这里通常是空操作——它们的意义是「清理器只删合成数据」这条契约本身)。 + +fn check(condition: bool, message: &str) -> Result<(), String> { + if condition { + Ok(()) + } else { + Err(message.to_owned()) + } +} + +fn witness(run: &str, label: &str) -> OperationIdentity { + OperationIdentity::new( + OperationId::scoped(run, label), + "probe_witness", + &[run, label], + ) +} + +fn receipt_of(outcome: &MutationOutcome) -> Result<&Receipt, String> { + match outcome { + MutationOutcome::Applied { receipt, .. } => Ok(receipt), + other => Err(format!("expected applied, got {other:?}")), + } +} + +fn mutation(identity: OperationIdentity, witnesses: &[&OperationIdentity]) -> QualifiedMutation { + QualifiedMutation { + identity, + effects: witnesses + .iter() + .map(|identity| qualify_statement(identity, "witness")) + .collect(), + } +} + +/// 跑实验体、无论成败都尝试清理本轮对象,最后一起判定。 +async fn with_cleanup(store: &RemoteStore, run: &str, body: F) -> Result<(), String> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let outcome = body().await; + let cleanup = cleanup_run(store, run).await; + match (outcome, cleanup) { + (Err(message), _) => Err(message), + (Ok(()), Ok(MutationOutcome::Applied { .. })) => Ok(()), + (Ok(()), Ok(other)) => Err(format!("cleanup did not apply: {other:?}")), + (Ok(()), Err(error)) => Err(format!("cleanup failed: {}", remote_error_class(&error))), + } +} + +/// 用正常 mutation 路径清理本轮合成数据,然后复核**收据仍在**。 +/// +/// 与 `cloud_tests::cleanup_run` 同一条契约:清理器只删合成数据,收据(本轮每一次操作的 +/// 账本行)**有意保留**——它们的空间成本由 P7 口径承担,删除它们才是缺陷。因此这里的判据 +/// 不是「本轮行归零」,而是「清理自身的收据可读回、且它没有被自己的清理语句删掉」。 +async fn cleanup_run(store: &RemoteStore, run: &str) -> SessionResourceResult { + let identity = + OperationIdentity::new(OperationId::scoped(run, "cleanup"), "probe_cleanup", &[run]); + let outcome = store + .apply_qualified(&QualifiedMutation { + identity: identity.clone(), + effects: super::cloud_tests::cleanup_effects(run), + }) + .await?; + match store.resolve_operation(&identity.operation_id).await? { + // 清理这一次操作自己的收据也必须留下:迟到请求据此不能重放。 + super::ledger::LedgerRow::Applied { .. } => Ok(outcome), + other => Err(SessionResourceError::new( + SessionResourceErrorKind::Internal { + detail: format!("cleanup receipt was not retained: {other:?}"), + }, + )), + } +} + +/// 初始化本任务 schema(授权范围内)并报告安全身份摘要。 +async fn prepare(target: &CloudTarget) -> Result<(RemoteStore, String), String> { + let store = target + .store(StoreAccess::ReadWrite) + .await + .map_err(|error| format!("connect failed: {}", remote_error_class(&error)))?; + let outcome = store + .initialize_store() + .await + .map_err(|error| format!("initialize failed: {}", remote_error_class(&error)))?; + // canonical 会话表就位(全部 `IF NOT EXISTS`,重复执行是空操作):本轮不写它们, + // 但清理语句删的正是这几张表,形状得先到——不依赖「别的用例已经建过表」。 + store + .force_parent_checks_off() + .await + .map_err(|error| format!("foreign key reset failed: {}", remote_error_class(&error)))?; + store + .apply_schema(super::session_schema::initialization_plan()) + .await + .map_err(|error| format!("session schema failed: {}", remote_error_class(&error)))?; + let run = unique_run_label("peri-mut"); + let mut out = target.out(); + out.push(format!("run_prefix={}", &run[..14.min(run.len())])); + out.push(format!("schema_version={REMOTE_SCHEMA_VERSION}")); + out.push(format!("contract_len={}", STORE_CONTRACT.len())); + out.push(format!( + "store_id_len={}", + outcome.store_id().as_str().len() + )); + // 已授权测试库早就有身份:本进程只是读回它(`Created` 只可能在空库上出现)。 + out.push(format!("initialize_verdict={}", initialize_name(&outcome))); + out.flush(); + Ok((store, run)) +} + +/// 实验一:原子批中途失败 → 零部分结果。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_mutation_atomic_batch_leaves_no_partial_results() { + let target = CloudTarget::load(); + let (store, run) = prepare(&target).await.expect("cloud target unusable"); + let result = with_cleanup(&store, &run, || async { + let identity = OperationIdentity::new( + OperationId::scoped(&run, "atomic"), + "probe_atomic", + &[&run, "atomic"], + ); + let first = witness(&run, "atomic_witness_1"); + let second = witness(&run, "atomic_witness_2"); + // 效果顺序:见证 1、见证 1 的重复插入(同主键 → 约束失败)、见证 2(永不执行)。 + let mutation = QualifiedMutation { + identity: identity.clone(), + effects: vec![ + qualify_statement(&first, "witness"), + qualify_statement(&first, "witness"), + qualify_statement(&second, "witness"), + ], + }; + let outcome = store + .apply_qualified(&mutation) + .await + .map_err(|error| format!("apply failed: {}", remote_error_class(&error)))?; + + let mut out = target.out(); + out.push(format!("atomic_batch_outcome={}", outcome_name(&outcome))); + match &outcome { + MutationOutcome::NotApplied { + class, + rejected_statement, + } => { + out.push(format!("rejected_statement={rejected_statement:?}")); + check( + *class == RemoteFailureClass::Constraint, + "expected a constraint rejection", + )?; + check( + *rejected_statement == Some(2), + "expected the duplicate effect to be rejected", + )?; + } + other => return Err(format!("expected determinate rejection, got {other:?}")), + } + + // 新连接复核:资格、见证 1、见证 2 都不存在。 + let fresh = target + .store(StoreAccess::ReadWrite) + .await + .map_err(|error| format!("second connect failed: {}", remote_error_class(&error)))?; + for (label, operation_id) in [ + ("qualification_absent", identity.operation_id.clone()), + ("witness_1_absent", first.operation_id.clone()), + ("witness_2_absent", second.operation_id.clone()), + ] { + match fresh.resolve_operation(&operation_id).await { + Ok(super::ledger::LedgerRow::Absent) => out.push(format!("{label}=true")), + Ok(other) => return Err(format!("{label} expected absent, got {other:?}")), + Err(error) => { + return Err(format!( + "{label} read failed: {}", + remote_error_class(&error) + )); + } + } + } + let _ = fresh.close().await; + out.flush(); + Ok(()) + }) + .await; + let _ = store.close().await; + result.unwrap_or_else(|message| panic!("{message}")); +} + +/// 实验二:同一 operation_id 重复调用返回原收据,且不写第二个效果。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_mutation_repeat_qualification_returns_the_original_receipt() { + let target = CloudTarget::load(); + let (store, run) = prepare(&target).await.expect("cloud target unusable"); + let result = with_cleanup(&store, &run, || async { + let identity = OperationIdentity::new( + OperationId::scoped(&run, "repeat"), + "probe_repeat", + &[&run, "repeat"], + ); + let first_effect = witness(&run, "repeat_witness_1"); + let first = store + .apply_qualified(&mutation(identity.clone(), &[&first_effect])) + .await + .map_err(|error| format!("first apply failed: {}", remote_error_class(&error)))?; + let original = receipt_of(&first)?.clone(); + + // 第二次:同一 operation_id,不同效果(模拟重试时输入摘要不同)。 + let second_effect = witness(&run, "repeat_witness_2"); + let second = store + .apply_qualified(&mutation(identity.clone(), &[&second_effect])) + .await + .map_err(|error| format!("repeat apply failed: {}", remote_error_class(&error)))?; + + let mut out = target.out(); + out.push(format!("first_outcome={}", outcome_name(&first))); + out.push(format!("repeat_outcome={}", outcome_name(&second))); + match &second { + MutationOutcome::Applied { receipt, replayed } => { + check(*replayed, "repeat must be marked as a replay")?; + check( + receipt.as_str() == original.as_str(), + "repeat must return the stored original receipt", + )?; + } + other => return Err(format!("expected replayed applied, got {other:?}")), + } + + // 原效果在、第二个效果不在。 + let state = store + .resolve_operation(&identity.operation_id) + .await + .map_err(|error| format!("resolve failed: {}", remote_error_class(&error)))?; + match state { + super::ledger::LedgerRow::Applied { receipt, .. } => check( + receipt.as_str() == original.as_str(), + "stored receipt differs from the first result", + )?, + other => return Err(format!("expected applied ledger row, got {other:?}")), + } + match store.resolve_operation(&second_effect.operation_id).await { + Ok(super::ledger::LedgerRow::Absent) => { + out.push("second_effect_absent=true".to_owned()) + } + Ok(other) => return Err(format!("second effect should not exist, got {other:?}")), + Err(error) => { + return Err(format!( + "second effect read failed: {}", + remote_error_class(&error) + )); + } + } + match store.resolve_operation(&first_effect.operation_id).await { + Ok(super::ledger::LedgerRow::Applied { .. }) => { + out.push("first_effect_present=true".to_owned()) + } + Ok(other) => return Err(format!("first effect missing, got {other:?}")), + Err(error) => { + return Err(format!( + "first effect read failed: {}", + remote_error_class(&error) + )); + } + } + out.flush(); + Ok(()) + }) + .await; + let _ = store.close().await; + result.unwrap_or_else(|message| panic!("{message}")); +} + +/// 实验三:两个连接竞争同一 operation_id → 至多一方提交。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_mutation_concurrent_qualification_applies_once() { + let target = CloudTarget::load(); + let (store, run) = prepare(&target).await.expect("cloud target unusable"); + let second_store = target + .store(StoreAccess::ReadWrite) + .await + .expect("second connection"); + let result = with_cleanup(&store, &run, || async { + let identity = OperationIdentity::new( + OperationId::scoped(&run, "race"), + "probe_race", + &[&run, "race"], + ); + let effect = witness(&run, "race_witness"); + let mutation = mutation(identity.clone(), &[&effect]); + + let (left, right) = tokio::join!( + store.apply_qualified(&mutation), + second_store.apply_qualified(&mutation) + ); + let left = left.map_err(|error| format!("left failed: {}", remote_error_class(&error)))?; + let right = + right.map_err(|error| format!("right failed: {}", remote_error_class(&error)))?; + + let mut out = target.out(); + out.push(format!("race_left={}", outcome_name(&left))); + out.push(format!("race_right={}", outcome_name(&right))); + let fresh_wins = matches!( + &left, + MutationOutcome::Applied { + replayed: false, + .. + } + ) as u8 + + matches!( + &right, + MutationOutcome::Applied { + replayed: false, + .. + } + ) as u8; + check(fresh_wins == 1, "exactly one side must commit first")?; + for outcome in [&left, &right] { + match outcome { + MutationOutcome::Applied { .. } => {} + // 写忙/超时/网络都属于「未决」,不得被误判为业务失败。 + MutationOutcome::Unknown { class } => { + out.push(format!("race_loser_unknown={}", class.as_str())); + } + other => return Err(format!("unexpected loser outcome: {other:?}")), + } + } + + // 胜者一行:效果只出现一次,且终态不是 closed。 + match store.resolve_operation(&identity.operation_id).await { + Ok(super::ledger::LedgerRow::Applied { .. }) => { + out.push("race_qualification_applied=true".to_owned()) + } + Ok(other) => return Err(format!("expected applied, got {other:?}")), + Err(error) => return Err(format!("resolve failed: {}", remote_error_class(&error))), + } + match store.resolve_operation(&effect.operation_id).await { + Ok(super::ledger::LedgerRow::Applied { .. }) => { + out.push("race_effect_applied_once=true".to_owned()) + } + Ok(other) => return Err(format!("expected effect applied, got {other:?}")), + Err(error) => { + return Err(format!( + "effect read failed: {}", + remote_error_class(&error) + )); + } + } + out.flush(); + Ok(()) + }) + .await; + let _ = second_store.close().await; + let _ = store.close().await; + result.unwrap_or_else(|message| panic!("{message}")); +} + +/// 实验四:终态封闭——封闭先提交则迟到的原请求不可能再生效;封闭已生效操作返回原收据。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_mutation_closure_beats_late_original_and_replays_applied() { + let target = CloudTarget::load(); + let (store, run) = prepare(&target).await.expect("cloud target unusable"); + let result = with_cleanup(&store, &run, || async { + let mut out = target.out(); + + // (1) 从未发送的操作被封闭:封闭胜出,迟到的原请求不能再产生数据变化。 + let ghost = OperationIdentity::new( + OperationId::scoped(&run, "ghost"), + "probe_ghost", + &[&run, "ghost"], + ); + let closure = store + .close_operation(&ghost) + .await + .map_err(|error| format!("closure failed: {}", remote_error_class(&error)))?; + out.push(format!("ghost_closure={}", resolution_name(&closure))); + check( + matches!(closure, OperationResolution::ClosedNeverApplied), + "closure of an unqualified operation must prove it never applied", + )?; + + let late_effect = witness(&run, "ghost_witness"); + let late = + OperationIdentity::new(ghost.operation_id.clone(), "probe_ghost", &[&run, "ghost"]); + let late_outcome = store + .apply_qualified(&mutation(late, &[&late_effect])) + .await + .map_err(|error| format!("late apply failed: {}", remote_error_class(&error)))?; + out.push(format!("late_original={}", outcome_name(&late_outcome))); + check( + !matches!(late_outcome, MutationOutcome::Applied { .. }), + "a closed operation must never become applied", + )?; + match store.resolve_operation(&late_effect.operation_id).await { + Ok(super::ledger::LedgerRow::Absent) => out.push("late_effect_absent=true".to_owned()), + Ok(other) => return Err(format!("late effect must be absent, got {other:?}")), + Err(error) => { + return Err(format!( + "late effect read failed: {}", + remote_error_class(&error) + )); + } + } + + // (2) 已生效的操作被封闭:返回原收据,数据不动。 + let applied = OperationIdentity::new( + OperationId::scoped(&run, "applied"), + "probe_applied", + &[&run, "applied"], + ); + let applied_effect = witness(&run, "applied_witness"); + let applied_outcome = store + .apply_qualified(&mutation(applied.clone(), &[&applied_effect])) + .await + .map_err(|error| format!("apply failed: {}", remote_error_class(&error)))?; + let original = receipt_of(&applied_outcome)?.clone(); + + let replay = store + .close_operation(&applied) + .await + .map_err(|error| format!("closure failed: {}", remote_error_class(&error)))?; + out.push(format!("applied_closure={}", resolution_name(&replay))); + match replay { + OperationResolution::Applied { receipt } => check( + receipt.as_str() == original.as_str(), + "closure of an applied operation must return the original receipt", + )?, + other => return Err(format!("expected replayed receipt, got {other:?}")), + } + match store.resolve_operation(&applied_effect.operation_id).await { + Ok(super::ledger::LedgerRow::Applied { .. }) => { + out.push("applied_effect_intact=true".to_owned()) + } + Ok(other) => return Err(format!("applied effect must stay, got {other:?}")), + Err(error) => { + return Err(format!( + "applied effect read failed: {}", + remote_error_class(&error) + )); + } + } + out.flush(); + Ok(()) + }) + .await; + let _ = store.close().await; + result.unwrap_or_else(|message| panic!("{message}")); +} + +/// 实验五:提交后的行对新连接可读;身份读取与初始化幂等(不覆盖既有身份)。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_mutation_committed_rows_are_visible_to_a_new_connection() { + let target = CloudTarget::load(); + let (store, run) = prepare(&target).await.expect("cloud target unusable"); + let result = with_cleanup(&store, &run, || async { + let mut out = target.out(); + let identity = OperationIdentity::new( + OperationId::scoped(&run, "visible"), + "probe_visible", + &[&run, "visible"], + ); + let effect = witness(&run, "visible_witness"); + let outcome = store + .apply_qualified(&mutation(identity.clone(), &[&effect])) + .await + .map_err(|error| format!("apply failed: {}", remote_error_class(&error)))?; + let original = receipt_of(&outcome)?.clone(); + + // 新连接:读回同一条事实,并确认身份读取在真实引擎上可用。 + let fresh = target + .store(StoreAccess::ReadWrite) + .await + .map_err(|error| format!("second connect failed: {}", remote_error_class(&error)))?; + match fresh.resolve_operation(&identity.operation_id).await { + Ok(super::ledger::LedgerRow::Applied { receipt, .. }) => check( + receipt.as_str() == original.as_str(), + "new connection must read the same receipt", + )?, + Ok(other) => return Err(format!("expected applied row, got {other:?}")), + Err(error) => return Err(format!("resolve failed: {}", remote_error_class(&error))), + } + out.push("cross_connection_read_after_write=true".to_owned()); + + let first_identity = store + .read_identity() + .await + .map_err(|error| format!("identity read failed: {}", remote_error_class(&error)))?; + let second_identity = fresh + .read_identity() + .await + .map_err(|error| format!("identity read failed: {}", remote_error_class(&error)))?; + match (first_identity, second_identity) { + ( + super::schema::StoreIdentityRead::Present(left), + super::schema::StoreIdentityRead::Present(right), + ) => { + check(left.matches_build(), "identity must match this build")?; + check( + left.store_id == right.store_id, + "both connections must see one store identity", + )?; + out.push(format!("schema_version={}", left.schema_version)); + } + other => return Err(format!("expected initialized identity, got {other:?}")), + } + + // 再次初始化:读回同一身份、且结论是 `Existing`(本次没有建立任何东西)。 + let again = fresh + .initialize_store() + .await + .map_err(|error| format!("re-initialize failed: {}", remote_error_class(&error)))?; + check( + initialize_name(&again) == "existing", + "re-initialize must report an existing identity, not a created one", + )?; + match fresh.read_identity().await { + Ok(super::schema::StoreIdentityRead::Present(snapshot)) => check( + snapshot.store_id == *again.store_id(), + "re-initialize must not mint a second identity", + )?, + Ok(other) => return Err(format!("expected present identity, got {other:?}")), + Err(error) => { + return Err(format!( + "identity read failed: {}", + remote_error_class(&error) + )); + } + } + out.push("initialize_is_idempotent=true".to_owned()); + let _ = fresh.close().await; + out.flush(); + Ok(()) + }) + .await; + let _ = store.close().await; + result.unwrap_or_else(|message| panic!("{message}")); +} + +/// 初始化结论的安全名字(只给类别,不给身份值)。 +fn initialize_name(outcome: &super::schema::StoreIdentityOutcome) -> &'static str { + match outcome { + super::schema::StoreIdentityOutcome::Created(_) => "created", + super::schema::StoreIdentityOutcome::Existing(_) => "existing", + } +} + +fn outcome_name(outcome: &MutationOutcome) -> &'static str { + match outcome { + MutationOutcome::Applied { + replayed: false, .. + } => "applied", + MutationOutcome::Applied { replayed: true, .. } => "applied_replayed", + MutationOutcome::ClosedNeverApplied => "closed_never_applied", + MutationOutcome::NotApplied { .. } => "not_applied", + MutationOutcome::Unknown { .. } => "unknown", + } +} + +fn resolution_name(resolution: &OperationResolution) -> &'static str { + match resolution { + OperationResolution::Applied { .. } => "applied", + OperationResolution::ClosedNeverApplied => "closed_never_applied", + OperationResolution::StillUnknown { .. } => "still_unknown", + } +} diff --git a/peri-resources/src/sessions/remote/cloud_recovery_test.rs b/peri-resources/src/sessions/remote/cloud_recovery_test.rs new file mode 100644 index 000000000..aebc5a586 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_recovery_test.rs @@ -0,0 +1,133 @@ +//! 显式云端未决收敛回归(默认 `#[ignore]`):**结果未知的一次写入**在真引擎上的最小事实。 +//! +//! 故障是**注入到真实批上的**(`FaultPlan`),不是替代返回值: +//! +//! | 实验 | 抓的是什么 | +//! | --- | --- | +//! | 响应丢失(`drop_reply`):批真的提交、调用方只看到未知 | 未知必须如实上报、远端效果真的在,收敛之后同一条会话可以继续写 | +//! +//! v10 之后本机不再持有远端操作日志(`session_remote_operations` 已删),跨进程的 +//! 「按原 id 向远端账本求证终态」这条路径不存在:`recover_persistence` 只回答本机能回答的 +//! 那部分(本机已没有可证明未结态的 durable 记录)。原先依赖那份本机记录的三组实验 +//! (发出前消失后按同一唯一键封闭、未决写按远端父链阻塞整棵树、重启后向账本求证)随之 +//! 删除:它们要注入与读回的**都是本机事实**,在新语义下没有对象。 +//! +//! 安全与清理:只操作本轮 run 命名空间;只输出计数与布尔;结束用正常删除路径清掉本轮行 +//! 并复核计数为 0;不打印 locator、token 或会话内容。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_recovery_ +//! ``` + +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::PersistenceRecovery; +use peri_acp_types::store::PersistedPayload; + +use super::cloud_tests::{ + check, failure, session_input, synth_binding, synth_thread, unique_run_label, with_cleanup, + CloudTarget, +}; +use super::mutation::{FaultPlan, StoreAccess}; +use crate::sessions::data::SessionDataPort; + +/// 实验:响应在回程丢失 —— 远端已提交、本机只能看见未知。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_lost_reply_converges_to_applied() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-rec-lost"); + let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let result = with_cleanup(&target, &run, || async { + let lines = lost_reply_flow(&target, &run).await?; + *collected.lock().unwrap() = lines; + Ok(()) + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + for line in collected.lock().unwrap().iter() { + out.push(line.clone()); + } + out.push("recovery_lost_reply=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn lost_reply_flow(target: &CloudTarget, run: &str) -> Result, String> { + let data = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let thread = synth_thread(&format!("{run}-lost")); + let frozen = format!("{{\"synth\":\"{run}\"}}"); + data.save_new_session(&session_input( + thread.as_str(), + &chrono::Utc::now().to_rfc3339(), + &synth_binding(), + &frozen, + None, + )) + .await + .map_err(failure)?; + + // 注入「批照常提交、结果按未知上报」:真实批、真实账本,只有回程被吞掉。 + data.inject_faults(FaultPlan { + drop_reply: Some("append_history".to_owned()), + drop_before_send: None, + }) + .await; + let payload = PersistedPayload::Message(BaseMessage::human("lost reply turn")); + let error = data + .append_history(&thread, &[payload]) + .await + .expect_err("a swallowed reply must not be reported as applied"); + check( + error.is_persistence_uncertain(), + "an unknown outcome must surface as persistence uncertainty", + )?; + + // 看远端:效果真的提交了,恢复不能把它当成丢失。 + let reader = target + .session_data(StoreAccess::ReadOnly) + .await + .map_err(failure)?; + let history = reader + .load_session_history(&thread) + .await + .map_err(failure)?; + check( + history.len() == 1, + "the lost-reply write is really committed on the remote side", + )?; + + // 恢复:本机已没有可证明未结态的 durable 记录,收敛结论是「可以继续」——它绝不能 + // 反过来被当成「那次写没发生」(上一步的远端计数就是反例)。 + let recovery = data.recover_persistence(&thread).await.map_err(failure)?; + check( + recovery == PersistenceRecovery::Recovered, + "a committed-but-unacknowledged write must converge to Recovered", + )?; + + // 放行后正常写入:同一条会话在恢复之后能继续写。 + data.append_history( + &thread, + &[PersistedPayload::Message(BaseMessage::human( + "after recovery", + ))], + ) + .await + .map_err(failure)?; + let history = reader + .load_session_history(&thread) + .await + .map_err(failure)?; + check(history.len() == 2, "writes must resume after recovery")?; + Ok(vec![ + "lost_reply=unknown_then_applied".to_owned(), + format!("history_after_recovery={}", history.len()), + ]) +} diff --git a/peri-resources/src/sessions/remote/cloud_session_test.rs b/peri-resources/src/sessions/remote/cloud_session_test.rs new file mode 100644 index 000000000..aa9ceb4e4 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_session_test.rs @@ -0,0 +1,429 @@ +//! 显式云端会话实验(默认 `#[ignore]`):真实写 → 重连读 → 复核 → 只清理本轮。 +//! +//! 覆盖(每条都断言可观察结果): +//! +//! | 实验 | 断言的事实 | +//! | --- | --- | +//! | 写—重连读 | 新建/fork/child 的事实在新连接(只读打开)上逐字段读回:meta、绑定、frozen 原文、payload 顺序、非默认 flags、继承区 | +//! | 列举与树 | children/tree 是父子关系事实;scoped 分页只给出带历史的会话,条目只带绑定事实不带本机根目录 | +//! | 只读与关闭 | 只读打开拒绝写入(`ReadOnlyStore`);关闭后调用明确失败 | +//! | 拒绝与重放 | child 的 frozen 不是 root 已保存的原文 → `InvalidInput` 且不落行;同一内容重试不产生第二行;同 id 不同内容 → 冲突失败且不改动已有行 | +//! +//! 安全与清理: +//! +//! - 只操作**本轮 run 命名空间**下的行(`thread_id` 以 run 前缀开头;账本行按 run 标签 +//! 匹配),结束时删除本轮行并**复核计数为 0**;不动任务 schema、不动其他轮次的数据。 +//! - 只输出安全状态/计数/布尔事实,全部经 [`super::cloud_tests::SafeOut`] 校验; +//! 本文件不打印 URL、token、SQL 参数或 SDK 原始错误,失败只给领域类别名。 +//! - 测试数据全部是本进程合成的短文本(不含真实历史、当前项目内容或凭证)。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_session_ +//! ``` + +use std::collections::HashMap; +use std::path::PathBuf; + +use super::cloud_tests::{ + check, failure, run_counts, session_input, unique_run_label, with_cleanup, CloudTarget, +}; +use super::mutation::StoreAccess; +use crate::sessions::data::SessionDataPort; +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::{ + BindingState, ChildSnapshot, ForkSnapshot, FrozenSnapshotBytes, FrozenState, SessionMetaPatch, + SessionResourceErrorKind, +}; +use peri_acp_types::store::{InheritedContext, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::AgentStatus; +use peri_acp_types::workspace::{ + ProjectId, ScopedThreadQuery, SessionBinding, ThreadScope, WorkspaceId, +}; + +/// 实验一:写 → 重连(只读打开)读回全部事实 → 列举/树 → 只读拒绝 → 关闭。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_session_write_read_reconnect_round_trip() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-sess"); + let mut out = target.out(); + out.push(format!("run_prefix_len={}", run.len())); + out.push("experiment=session_round_trip".to_owned()); + out.flush(); + let result = with_cleanup(&target, &run, || async { + session_round_trip(&target, &run).await + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + out.push("round_trip=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn session_round_trip(target: &CloudTarget, run: &str) -> Result<(), String> { + let writer = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let root = format!("{run}-root"); + let fork = format!("{run}-fork"); + let child = format!("{run}-child"); + let binding = SessionBinding { + schema_version: 1, + revision: 1, + project_id: ProjectId::new(), + workspace_id: WorkspaceId::new(), + cwd_relative_to_workspace: PathBuf::from("sub"), + }; + let frozen = format!("{{\"frozen\":\"{run}\"}}"); + let created_at = chrono::Utc::now().to_rfc3339(); + + // 新建:meta + 绑定 + frozen 一次落库。 + writer + .save_new_session(&session_input(&root, &created_at, &binding, &frozen, None)) + .await + .map_err(failure)?; + // 定向 metadata:标题与状态。 + writer + .update_meta( + &root, + &SessionMetaPatch { + title: Some(Some("renamed".to_owned())), + status: Some(AgentStatus::Done), + cancel_policy: None, + config: Some(Some("{\"k\":1}".to_owned())), + }, + ) + .await + .map_err(failure)?; + + // fork:两条 payload(领域重映射后的结果)+ 一条非默认 flag。 + let first = PersistedPayload::Message(BaseMessage::human("fork one")); + let second = PersistedPayload::Message(BaseMessage::ai("fork two")); + let mut flags = HashMap::new(); + flags.insert( + first.id(), + MessageFlags { + truncated: true, + excluded: false, + projection: None, + }, + ); + writer + .save_fork(&ForkSnapshot { + target: session_input(&fork, &created_at, &binding, &frozen, None), + source_id: root.clone(), + payloads: vec![first.clone(), second.clone()], + flags, + }) + .await + .map_err(failure)?; + + // child:继承区 + 父子/根归属 + root 的 frozen 原文。 + let inherited_payload = PersistedPayload::Message(BaseMessage::human("inherited")); + writer + .save_child(&ChildSnapshot { + target: session_input(&child, &created_at, &binding, &frozen, Some(&root)), + parent_id: root.clone(), + root_id: root.clone(), + inherited: InheritedContext { + payloads: vec![inherited_payload.clone()], + flags: HashMap::new(), + }, + }) + .await + .map_err(failure)?; + + // 重连:新连接、只读打开,逐字段复核(不靠写路径的内存状态)。 + let reader = target + .session_data(StoreAccess::ReadOnly) + .await + .map_err(failure)?; + + let root_meta = reader.load_meta(&root).await.map_err(failure)?; + check( + root_meta.title.as_deref() == Some("renamed"), + "title patch is not visible after reconnect", + )?; + check( + root_meta.agent_status == AgentStatus::Done, + "status patch is not visible after reconnect", + )?; + check( + root_meta.config.as_deref() == Some("{\"k\":1}"), + "config patch is not visible after reconnect", + )?; + check( + root_meta.message_count == 0, + "session without history must report zero messages", + )?; + check( + root_meta.cached_context.is_none(), + "remote store must not invent a materialized cache", + )?; + + let root_snapshot = reader.load_snapshot(&root).await.map_err(failure)?; + check( + root_snapshot.binding == BindingState::Bound(binding.clone()), + "root binding is not the immutable binding that was written", + )?; + check( + root_snapshot.frozen == FrozenState::Present(FrozenSnapshotBytes::new(frozen.clone())), + "root frozen bytes are not returned verbatim", + )?; + check( + root_snapshot.payloads.is_empty() && root_snapshot.flags.is_empty(), + "root session must have no own history", + )?; + + let fork_snapshot = reader.load_snapshot(&fork).await.map_err(failure)?; + check( + fork_snapshot.payloads.len() == 2 + && fork_snapshot.payloads[0].id() == first.id() + && fork_snapshot.payloads[1].id() == second.id(), + "fork payloads are not the mapped batch in order", + )?; + check( + fork_snapshot.meta.message_count == 2, + "fork derived message count is not the payload count", + )?; + check( + fork_snapshot + .flags + .get(&first.id()) + .map(|flags| flags.truncated) + == Some(true) + && !fork_snapshot.flags.contains_key(&second.id()), + "derived flags view is not the non-default set", + )?; + + let child_snapshot = reader.load_snapshot(&child).await.map_err(failure)?; + check( + child_snapshot.inherited.payloads.len() == 1 + && child_snapshot.inherited.payloads[0].id() == inherited_payload.id(), + "child inherited context is not the snapshot that was written", + )?; + check( + child_snapshot.frozen == root_snapshot.frozen, + "child frozen must be the root's saved bytes", + )?; + check( + child_snapshot.meta.parent_thread_id.as_deref() == Some(root.as_str()), + "child parent relation is not persisted", + )?; + check( + child_snapshot.binding == BindingState::Bound(binding.clone()), + "child binding is not the inherited immutable binding", + )?; + + let children = reader.list_children(&root).await.map_err(failure)?; + check( + children.len() == 1 && children[0].id == child, + "children listing is not the direct child set", + )?; + let tree = reader.list_session_tree(&root).await.map_err(failure)?; + let tree_ids: Vec<&str> = tree.iter().map(|meta| meta.id.as_str()).collect(); + check( + tree.len() == 2 && tree_ids.contains(&root.as_str()) && tree_ids.contains(&child.as_str()), + "session tree is not root plus descendants", + )?; + + // scoped 分页:只有带历史的会话出现;条目只带绑定事实,不带本机解析出的根目录。 + let page = reader + .list_sessions(&ScopedThreadQuery { + scope: ThreadScope::Project(binding.project_id), + cursor: None, + limit: 50, + }) + .await + .map_err(failure)?; + let page_ids: Vec<&str> = page + .entries + .iter() + .map(|entry| entry.thread.id.as_str()) + .collect(); + check( + page_ids.contains(&fork.as_str()) + && !page_ids.contains(&root.as_str()) + && !page_ids.contains(&child.as_str()), + "scoped listing must contain only sessions with history", + )?; + let entry = page + .entries + .iter() + .find(|entry| entry.thread.id == fork) + .ok_or("fork session is missing from its scoped listing")?; + check( + entry.binding.as_ref() == Some(&binding) && entry.workspace_root.is_none(), + "listing entries must carry binding facts only", + )?; + + // 逻辑上下文:继承区在前、自有 payload 在后。 + let fork_history = reader.load_session_history(&fork).await.map_err(failure)?; + check( + fork_history.len() == 2 && fork_history[0].id() == first.id(), + "fork logical context is not its own payload batch", + )?; + let child_history = reader.load_session_history(&child).await.map_err(failure)?; + check( + child_history.len() == 1 && child_history[0].id() == inherited_payload.id(), + "child logical context must lead with the inherited region", + )?; + + // 只读打开不写:写路径在发请求前就拒绝。 + let refused = reader + .save_new_session(&session_input( + &format!("{run}-extra"), + &created_at, + &binding, + &frozen, + None, + )) + .await; + check( + matches!(&refused, Err(error) if matches!(error.kind(), SessionResourceErrorKind::ReadOnlyStore)), + "read-only open must refuse writes", + )?; + + // 关闭后不再接受调用。 + reader.close().await.map_err(failure)?; + check( + reader.load_meta(&root).await.is_err(), + "closed port must fail loudly", + )?; + writer.close().await.map_err(failure)?; + Ok(()) +} + +/// 实验二:拒绝与重复调用都不产生第二行,也不改动已有行。 +/// +/// 注意与 C-03 第二批的差别:操作身份不再由内容派生,因此「同内容再来一次」是**新的 +/// 领域调用**(照实撞主键),而不是被当成重放静默跳过——重放语义只覆盖同一次操作。 +#[tokio::test] +#[ignore = "显式 cloud 实验:需要已授权测试库的 .env,默认不跑"] +async fn cloud_session_refusals_and_replay_leave_facts_unchanged() { + let target = CloudTarget::load(); + let run = unique_run_label("peri-sess-neg"); + let result = with_cleanup(&target, &run, || async { + session_refusals(&target, &run).await + }) + .await; + match result { + Ok(()) => { + let mut out = target.out(); + out.push("refusals_and_replay=ok".to_owned()); + out.flush(); + } + Err(message) => panic!("{message}"), + } +} + +async fn session_refusals(target: &CloudTarget, run: &str) -> Result<(), String> { + let adapter = target + .session_data(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let root = format!("{run}-root"); + let child = format!("{run}-child"); + let binding = SessionBinding { + schema_version: 1, + revision: 1, + project_id: ProjectId::new(), + workspace_id: WorkspaceId::new(), + cwd_relative_to_workspace: PathBuf::from("sub"), + }; + let frozen = format!("{{\"frozen\":\"{run}\"}}"); + let created_at = chrono::Utc::now().to_rfc3339(); + let first_input = session_input(&root, &created_at, &binding, &frozen, None); + + adapter + .save_new_session(&first_input) + .await + .map_err(failure)?; + + // 同 id、不同内容:必须是冲突失败(不能冒充「已应用」),也不得改动已有行。 + let conflicting = session_input( + &root, + &created_at, + &binding, + &format!("{{\"frozen\":\"{run}-other\"}}"), + None, + ); + let conflict = adapter.save_new_session(&conflicting).await; + check( + matches!(&conflict, Err(error) if matches!(error.kind(), SessionResourceErrorKind::InvalidInput { .. })), + "a re-create with different content must fail", + )?; + + // 同一内容再保存一次:**这是新的领域调用**,不是历史重放(操作 id 每次唯一)。 + // 它照实撞上会话主键 → 确定未生效(InvalidInput),且不改动已有行、不产生第二行。 + // 「同一次请求的重试」由 adapter 内部复用同一操作 id 承担(v10 撤销本机日志后不再有 + // 跨进程的原 id 取回路径),不靠调用方传令牌。 + let repeated = adapter.save_new_session(&first_input).await; + check( + matches!(&repeated, Err(error) if matches!(error.kind(), SessionResourceErrorKind::InvalidInput { .. })), + "re-saving the same input is a new operation and must fail on the existing row", + )?; + + // child 的 frozen 不是 root 已保存的原文:拒绝,且不落行。 + let refused_child = adapter + .save_child(&ChildSnapshot { + target: session_input( + &child, + &created_at, + &binding, + &format!("{{\"frozen\":\"{run}-foreign\"}}"), + Some(&root), + ), + parent_id: root.clone(), + root_id: root.clone(), + inherited: InheritedContext::default(), + }) + .await; + check( + matches!(&refused_child, Err(error) if matches!(error.kind(), SessionResourceErrorKind::InvalidInput { .. })), + "child frozen that is not the root's saved bytes must be refused", + )?; + adapter.close().await.map_err(failure)?; + + // 重连复核:一行 root(内容仍是第一次写入的事实)、没有 child 行。 + let reader = target + .session_data(StoreAccess::ReadOnly) + .await + .map_err(failure)?; + check( + reader.exists(&root).await.map_err(failure)?, + "root session must exist after replay", + )?; + check( + !reader.exists(&child).await.map_err(failure)?, + "refused child must not exist", + )?; + let snapshot = reader.load_snapshot(&root).await.map_err(failure)?; + check( + snapshot.frozen == FrozenState::Present(FrozenSnapshotBytes::new(frozen.clone())), + "a refused re-create must not overwrite the saved frozen bytes", + )?; + reader.close().await.map_err(failure)?; + + // 独立只读连接复核行数:本轮只有 root 一行会话、没有历史行。 + let store = target.store(StoreAccess::ReadOnly).await.map_err(failure)?; + let counts = run_counts(&store, run).await?; + store.close().await.map_err(failure)?; + check( + counts.sessions == 1 && counts.messages == 0, + "refusals and replay must leave exactly the first session row", + )?; + let mut out = target.out(); + out.push(format!( + "run_rows sessions={} messages={}", + counts.sessions, counts.messages + )); + out.flush(); + Ok(()) +} diff --git a/peri-resources/src/sessions/remote/cloud_test.rs b/peri-resources/src/sessions/remote/cloud_test.rs new file mode 100644 index 000000000..5fecc9048 --- /dev/null +++ b/peri-resources/src/sessions/remote/cloud_test.rs @@ -0,0 +1,868 @@ +//! 显式云端只读探测(默认 `#[ignore]`,不参与常规回归)。 +//! +//! 本文件是凭证进入进程的唯一入口,只在本进程内解析操作者显式给出的 `.env` +//! (`PERI_CLOUD_ENV_FILE`,默认仓库根的 `.env`):不 `source`/`eval`、不起子进程、 +//! 不把变量注入其他进程环境、不修改 `.env`。 +//! +//! 输出只允许:键名存在性、脱敏 engine/服务端特征、成功/失败分类。所有输出经 [`SafeOut`] +//! 逐行校验,凭证字面量一旦出现即拒绝输出。键名由操作者用 `PERI_CLOUD_URL_KEY` / +//! `PERI_CLOUD_TOKEN_KEY` 显式给出,本文件不内置默认名、别名或候选名。 +//! +//! 两个探测都只发只读请求:不建表、不写数据、不初始化 schema。 +//! +//! 显式执行(`--ignored`)时缺选择器或键**直接失败**,不静默跳过。 +//! +//! ```text +//! PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ +//! cargo test -p peri-resources --lib -- --ignored --nocapture cloud_ +//! ``` + +use std::collections::BTreeMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResourceError, SessionResourceResult, +}; +use peri_acp_types::store::MessageFlags; +use peri_acp_types::thread::{CancelPolicy, ThreadId}; +use peri_acp_types::workspace::{ProjectId, SessionBinding, WorkspaceId}; +use turso_serverless::Value; + +use super::ledger::{LedgerRow, OperationId, OperationIdentity}; +use super::mutation::{MutationOutcome, QualifiedMutation, RemoteStore, StoreAccess}; +use super::session_data::RemoteSessionData; +use super::sql::StatementSpec; +use super::{RemoteConnection, RemoteEndpoint, SessionStoreCredential}; + +/// URL 环境变量**键名**的选择器(不是凭证来源)。 +pub(super) const URL_KEY_SELECTOR: &str = "PERI_CLOUD_URL_KEY"; +/// 凭证环境变量**键名**的选择器。 +pub(super) const TOKEN_KEY_SELECTOR: &str = "PERI_CLOUD_TOKEN_KEY"; + +/// 输出缓冲:打印前校验每行不含凭证字面量。 +pub(super) struct SafeOut { + lines: Vec, + secrets: Vec, +} + +impl SafeOut { + pub(super) fn new(secrets: Vec) -> Self { + Self { + lines: Vec::new(), + secrets, + } + } + + pub(super) fn push(&mut self, line: impl Into) { + let line = line.into(); + for secret in &self.secrets { + if secret.len() >= 8 && line.contains(secret.as_str()) { + // 消息本身不含凭证内容。 + panic!("probe output rejected: line would contain a credential literal"); + } + } + self.lines.push(line); + } + + pub(super) fn flush(&self) { + for line in &self.lines { + println!("PROBE {line}"); + } + } +} + +fn env_file_path() -> PathBuf { + match std::env::var_os("PERI_CLOUD_ENV_FILE") { + Some(path) => PathBuf::from(path), + None => Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env"), + } +} + +/// 最小 dotenv 解析:只认 `KEY=VALUE`,剥一层对称引号;不做变量展开、不执行命令。 +fn parse_env_file(text: &str) -> BTreeMap { + let mut entries = BTreeMap::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + continue; + } + let mut value = value.trim(); + if value.len() >= 2 { + let bytes = value.as_bytes(); + let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\''); + if quoted { + value = &value[1..value.len() - 1]; + } + } + entries.insert(key.to_owned(), value.to_owned()); + } + entries +} + +pub(super) fn load_env() -> BTreeMap { + let path = env_file_path(); + let text = std::fs::read_to_string(&path).expect("cloud probe env file unreadable"); + parse_env_file(&text) +} + +/// 取操作者显式指定的两个键名对应的值。 +/// +/// 缺 selector 或缺键是**配置缺失**:显式执行时直接 panic,不静默跳过(跳过会被当成通过)。 +pub(super) fn required_credential_keys() -> (String, String) { + let url_key = std::env::var(URL_KEY_SELECTOR).unwrap_or_else(|_| { + panic!("cloud experiment requires {URL_KEY_SELECTOR} (name of the locator variable)") + }); + let token_key = std::env::var(TOKEN_KEY_SELECTOR).unwrap_or_else(|_| { + panic!("cloud experiment requires {TOKEN_KEY_SELECTOR} (name of the token variable)") + }); + (url_key, token_key) +} + +pub(super) fn required_credentials(env: &BTreeMap) -> (String, String) { + let (url_key, token_key) = required_credential_keys(); + let missing = |key: &str| { + panic!("cloud experiment requires a non-empty `{key}` entry in the env file"); + }; + let url = env + .get(&url_key) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| missing(&url_key)) + .clone(); + let token = env + .get(&token_key) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| missing(&token_key)) + .clone(); + (url, token) +} + +pub(super) fn scheme_class(raw: &str) -> &'static str { + let lower = raw.trim().to_ascii_lowercase(); + if lower.starts_with("https://") { + "https" + } else if lower.starts_with("turso://") { + "turso" + } else if lower.starts_with("libsql://") { + "libsql" + } else { + "other" + } +} + +/// 官方 HTTP 参考页:`turso://` / `libsql://` 换成 `https://` 即同一 base URL。 +pub(super) fn http_base_url(raw: &str) -> Result { + let rewritten = match scheme_class(raw) { + "https" => raw.trim().to_owned(), + "turso" => raw.trim().replacen("turso://", "https://", 1), + "libsql" => raw.trim().replacen("libsql://", "https://", 1), + _ => return Err("unsupported_scheme"), + }; + let url = url::Url::parse(&rewritten).map_err(|_| "unparsable_url")?; + if !url.username().is_empty() || url.password().is_some() { + return Err("userinfo_present"); + } + if url.query().is_some() || url.fragment().is_some() { + return Err("query_or_fragment_present"); + } + Ok(url) +} + +pub(super) fn classify_engine(body: &str) -> &'static str { + let head = body.trim(); + if head.is_empty() { + return "empty_body"; + } + let lower = head.to_ascii_lowercase(); + if lower.starts_with("sqld") { + "libsql_sqld" + } else if lower.contains("turso") { + "turso_engine" + } else if lower.contains("libsql") { + "libsql_engine_other" + } else { + "unclassified" + } +} + +/// 从任意文本里抽第一个 `x.y.z` 形态的数字版本;抽不到就报 `unrecognized`。 +pub(super) fn numeric_version(body: &str) -> String { + let mut found = String::new(); + let mut dots = 0; + for byte in body.bytes() { + let c = byte as char; + if c.is_ascii_digit() { + found.push(c); + } else if c == '.' && !found.is_empty() && dots < 2 { + found.push(c); + dots += 1; + } else { + if dots == 2 && found.ends_with(|c: char| c.is_ascii_digit()) { + return found; + } + found.clear(); + dots = 0; + } + } + if dots == 2 && found.ends_with(|c: char| c.is_ascii_digit()) { + found + } else { + "unrecognized".to_owned() + } +} + +pub(super) fn transport_class(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_redirect() { + "redirect" + } else if error.is_decode() { + "decode" + } else if error.is_request() { + "request" + } else { + "other" + } +} + +pub(super) fn unique_sentinel(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{prefix}-{nanos}") +} + +/// 本轮唯一 run 标签:只用十六进制与短横线,供本轮对象命名(不作为身份判据)。 +pub(super) fn unique_run_label(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let nonce = uuid::Uuid::new_v4().simple().to_string(); + format!("{prefix}-{nanos:x}-{}", &nonce[..8]) +} + +/// 已授权测试库的目标:端点、凭证与脱敏字面量。 +/// +/// 故意不实现 `Debug`(结构里含 locator);需要输出时只用 [`Self::out`]。 +pub(super) struct CloudTarget { + endpoint: RemoteEndpoint, + credential: SessionStoreCredential, + redactions: Vec, +} + +impl CloudTarget { + /// 载入配置:缺文件、缺选择器、缺键、locator 形状不可用都直接 panic。 + pub(super) fn load() -> Self { + let env = load_env(); + let (raw_url, token) = required_credentials(&env); + let credential = SessionStoreCredential::new(token.clone()).unwrap_or_else(|error| { + panic!("cloud experiment credential rejected: {error}"); + }); + let endpoint = RemoteEndpoint::parse(&raw_url, None).unwrap_or_else(|error| { + panic!("cloud experiment locator rejected: {error}"); + }); + Self { + endpoint, + credential, + redactions: vec![raw_url, token], + } + } + + /// 已解析的远端端点(只在本 crate 内用于装配实验;不打印原文)。 + pub(super) fn endpoint(&self) -> &RemoteEndpoint { + &self.endpoint + } + + /// 已解析的凭证值(装配实验直接注入,不经过进程环境)。 + pub(super) fn credential(&self) -> &SessionStoreCredential { + &self.credential + } + + pub(super) fn out(&self) -> SafeOut { + SafeOut::new(self.redactions.clone()) + } + + /// 连接可变路径;连接失败即失败,不降级成「跳过」。 + pub(super) async fn store(&self, access: StoreAccess) -> SessionResourceResult { + RemoteStore::connect(&self.endpoint, &self.credential, access).await + } + + /// 连接会话数据 adapter(C-03 行为):打开即读回 store 身份,未初始化时按访问模式 + /// 决定是否创建本任务 schema;失败即失败,不降级成「跳过」。 + /// + /// 打开事实只有三个:端点、凭证、访问模式。本机不再持有远端操作的日志(v10 撤销了 + /// 「发送前登记、确定终态才结清」的本机记录),adapter 因此不需要任何本机库。 + pub(super) async fn session_data( + &self, + access: StoreAccess, + ) -> SessionResourceResult { + RemoteSessionData::open(&self.endpoint, &self.credential, access) + .await + .map(|(data, _initialization)| data) + } +} + +/// 未预期失败只给类别名(`SessionResourceError` 的 detail 已在该层脱敏)。 +pub(super) fn remote_error_class(error: &SessionResourceError) -> String { + format!("{:?}", error.kind()) +} + +// ─── 本轮 run 命名空间的共用夹具 ───────────────────────────────────────────── +// +// 所有显式云实验都只操作本轮 `run` 前缀下的行:结束前删除本轮**合成数据**(会话与消息) +// 并**复核计数为 0**(复核走新连接,见 [`cleanup_run`])。语句是静态 SQL + 全绑定参数, +// `run` 只作为绑定值出现,不进入 SQL 文本。 +// +// **收据不删**:`peri_op_ledger` 的每一行都是某次操作的封闭证据(终态 + 原收据),删除它 +// 等于删掉「这件事发生过」——迟到的同 id 请求在证据不存在时无法被判为重复,而 P7 的口径 +// 正是「收据不按 TTL 清理」。因此清理器里**没有**针对该表的删除/覆盖语句,收据是有意保留的 +// 空间成本,复核时报告保留条数([`check_cleanup_kept_receipts`])。 + +pub(super) const DELETE_RUN_MESSAGES_SQL: &str = "DELETE FROM messages WHERE thread_id LIKE ?1"; +pub(super) const DELETE_RUN_BINDINGS_SQL: &str = + "DELETE FROM session_bindings WHERE thread_id LIKE ?1"; +pub(super) const DELETE_RUN_SESSIONS_SQL: &str = "DELETE FROM threads WHERE id LIKE ?1"; +pub(super) const COUNT_RUN_SESSIONS_SQL: &str = "SELECT COUNT(*) FROM threads WHERE id LIKE ?1"; +pub(super) const COUNT_RUN_MESSAGES_SQL: &str = + "SELECT COUNT(*) FROM messages WHERE thread_id LIKE ?1"; +pub(super) const COUNT_RUN_BINDINGS_SQL: &str = + "SELECT COUNT(*) FROM session_bindings WHERE thread_id LIKE ?1"; +pub(super) const COUNT_RUN_LEDGER_SQL: &str = + "SELECT COUNT(*) FROM peri_op_ledger WHERE operation_id LIKE ?1"; + +/// 清理器的效果语句:只删本轮合成数据,**不碰收据**。 +/// +/// 单独成函数是为了让「清理器不删收据」这条契约能在离线测试里被断言(见本文件的 +/// `test_cleanup_never_deletes_ledger_receipts`),而不是只靠运行时的计数观察。 +pub(super) fn cleanup_effects(run: &str) -> Vec { + let prefix = Value::Text(format!("{run}%")); + vec![ + StatementSpec::new(DELETE_RUN_MESSAGES_SQL, vec![prefix.clone()]), + StatementSpec::new(DELETE_RUN_BINDINGS_SQL, vec![prefix.clone()]), + StatementSpec::new(DELETE_RUN_SESSIONS_SQL, vec![prefix]), + ] +} + +/// 清理后的复核(纯函数,离线可测)。 +/// +/// 两件事都必须成立:本轮**合成**数据为 0;本轮**收据**只增不减(更新后的计数不小于清理前 +/// 的计数,且清理自身那次操作也会留下一条)。返回有意保留的收据条数,由调用方报告。 +pub(super) fn check_cleanup_kept_receipts( + before: &RunCounts, + after: &RunCounts, +) -> Result { + check( + after.sessions == 0 && after.messages == 0 && after.bindings == 0, + "run namespace still has rows after cleanup", + )?; + check( + before.ledger >= 0 && after.ledger >= before.ledger, + "cleanup must not delete operation receipts", + )?; + Ok(after.ledger) +} + +pub(super) fn check(condition: bool, message: &str) -> Result<(), String> { + if condition { + Ok(()) + } else { + Err(message.to_owned()) + } +} + +pub(super) fn failure(error: SessionResourceError) -> String { + format!("unexpected failure: {}", remote_error_class(&error)) +} + +/// 合成一个绑定:远端只存绑定事实,不解析本机目录。 +pub(super) fn synth_binding() -> SessionBinding { + SessionBinding { + schema_version: 1, + revision: 1, + project_id: ProjectId::new(), + workspace_id: WorkspaceId::new(), + cwd_relative_to_workspace: PathBuf::from("sub"), + } +} + +/// 合成一组 flags(实验里只关心 truncated/excluded)。 +pub(super) fn synth_flags(truncated: bool, excluded: bool) -> MessageFlags { + MessageFlags { + truncated, + excluded, + projection: None, + } +} + +pub(super) fn synth_thread(label: &str) -> ThreadId { + ThreadId::from(label) +} + +/// 合成一条会话输入:全部内容由本轮 run 派生,不含真实历史或项目数据。 +pub(super) fn session_input( + thread_id: &str, + created_at: &str, + binding: &SessionBinding, + frozen: &str, + parent: Option<&str>, +) -> NewSession { + NewSession { + thread_id: thread_id.to_owned(), + created_at: created_at.to_owned(), + meta: NewSessionMeta { + title: Some(format!( + "title-{}", + &thread_id[thread_id.len().saturating_sub(4)..] + )), + cwd: "/tmp/peri-cloud-synth".to_owned(), + parent_thread_id: parent.map(str::to_owned), + hidden: false, + cancel_policy: CancelPolicy::Cascade, + snapshot_at_message_id: None, + }, + binding: binding.clone(), + frozen: FrozenSnapshotBytes::new(frozen), + } +} + +/// 跑实验体、无论成败都清理本轮对象,最后一起判定。 +pub(super) async fn with_cleanup( + target: &CloudTarget, + run: &str, + body: F, +) -> Result<(), String> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let outcome = body().await; + let cleanup = cleanup_run(target, run).await; + match (outcome, cleanup) { + (Err(message), _) => Err(message), + (Ok(()), Err(message)) => Err(message), + (Ok(()), Ok(())) => Ok(()), + } +} + +/// 删除本轮**合成**数据(会话与消息)并复核;**收据一律保留**。 +/// +/// 不能只凭「删除返回成功」就宣称清理完成,也不能把收据算进「本轮行」:收据是封闭证据, +/// 删除它会破坏「迟到请求不得被当成新操作」的判据(见本文件顶部与 P7)。 +/// +/// 复核走**新连接**(见 [`counts_after_cleanup`]):收据「还在」只有在一次全新打开上仍可 +/// 读回才算事实,同一连接上的读回可能只是本地视图。 +pub(super) async fn cleanup_run(target: &CloudTarget, run: &str) -> Result<(), String> { + let store = target + .store(StoreAccess::ReadWrite) + .await + .map_err(failure)?; + let before = run_counts(&store, run).await?; + let identity = OperationIdentity::new( + OperationId::scoped(run, "session_cleanup"), + "session_cleanup", + &[run], + ); + let outcome = store + .apply_qualified(&QualifiedMutation { + identity: identity.clone(), + effects: cleanup_effects(run), + }) + .await + .map_err(failure)?; + if !matches!(outcome, MutationOutcome::Applied { .. }) { + return Err(format!("cleanup did not apply: {outcome:?}")); + } + store.close().await.map_err(failure)?; + let after = counts_after_cleanup(target, run, &identity).await?; + let retained = check_cleanup_kept_receipts(&before, &after)?; + // 有意保留的收据条数(安全输出:只有数字)。 + let mut out = target.out(); + out.push(format!("retained_receipts={retained}")); + out.flush(); + Ok(()) +} + +/// 清理之后在**新连接**上复核:本轮合成数据为 0,本轮收据仍在,且清理自身那张收据 +/// 仍能经 adapter 读回(`Applied`)。 +/// +/// 这条路径同时覆盖两个方向:合成数据被真的删掉(不是只在本连接上消失),以及收据**没有** +/// 被清理语句顺手带走——收据只增不减,它的空间成本由 P7 口径承担。 +async fn counts_after_cleanup( + target: &CloudTarget, + run: &str, + cleanup: &OperationIdentity, +) -> Result { + let store = target.store(StoreAccess::ReadOnly).await.map_err(failure)?; + let counts = run_counts(&store, run).await?; + let receipt = store + .resolve_operation(&cleanup.operation_id) + .await + .map_err(failure)?; + store.close().await.map_err(failure)?; + match receipt { + LedgerRow::Applied { .. } => Ok(counts), + other => Err(format!( + "cleanup receipt must stay readable on a new connection: {other:?} (counts: sessions={} messages={} bindings={} ledger={})", + counts.sessions, counts.messages, counts.bindings, counts.ledger + )), + } +} + +pub(super) struct RunCounts { + pub(super) sessions: i64, + pub(super) messages: i64, + pub(super) bindings: i64, + pub(super) ledger: i64, +} + +/// 本轮命名空间的计数(通过只读 SQL 复核,不经过 adapter 的读取行为)。 +pub(super) async fn run_counts(store: &RemoteStore, run: &str) -> Result { + let batches = store + .read_batch(vec![ + StatementSpec::new(COUNT_RUN_SESSIONS_SQL, vec![Value::Text(format!("{run}%"))]), + StatementSpec::new(COUNT_RUN_MESSAGES_SQL, vec![Value::Text(format!("{run}%"))]), + StatementSpec::new(COUNT_RUN_BINDINGS_SQL, vec![Value::Text(format!("{run}%"))]), + StatementSpec::new(COUNT_RUN_LEDGER_SQL, vec![Value::Text(format!("%{run}%"))]), + ]) + .await + .map_err(failure)?; + let mut counts = batches.into_iter().map(|mut rows| { + rows.pop() + .and_then(|mut row| row.pop()) + .and_then(|value| match value { + Value::Integer(number) => Some(number), + _ => None, + }) + .unwrap_or(-1) + }); + let (sessions, messages, bindings, ledger) = ( + counts.next().unwrap_or(-1), + counts.next().unwrap_or(-1), + counts.next().unwrap_or(-1), + counts.next().unwrap_or(-1), + ); + check( + sessions >= 0 && messages >= 0 && bindings >= 0 && ledger >= 0, + "run namespace counts are unreadable", + )?; + Ok(RunCounts { + sessions, + messages, + bindings, + ledger, + }) +} + +/// 键名存在性报告:只列与引擎相关的键名,不输出任何取值。 +#[test] +#[ignore = "显式 cloud 探测:需要已授权测试库的 .env,默认不跑"] +fn cloud_env_key_names_report() { + let env = load_env(); + let candidates: Vec<&str> = env + .keys() + .filter(|key| { + let upper = key.to_ascii_uppercase(); + upper.contains("TURSO") || upper.contains("LIBSQL") + }) + .map(String::as_str) + .collect(); + let mut out = SafeOut::new(env.values().cloned().collect()); + out.push(format!("env_keys_total={}", env.len())); + out.push(format!("engine_like_key_names={candidates:?}")); + for selector in [URL_KEY_SELECTOR, TOKEN_KEY_SELECTOR] { + out.push(format!( + "selector_present[{selector}]={}", + std::env::var_os(selector).is_some() + )); + } + out.flush(); +} + +/// 协议层只读探测:`GET /version` 与服务端特征 + `POST /v2/pipeline` 只读参数绑定回环。 +#[tokio::test] +#[ignore = "显式 cloud 探测:需要已授权测试库的 .env,默认不跑"] +async fn cloud_engine_read_only_probe() { + let env = load_env(); + let (raw_url, token) = required_credentials(&env); + let mut out = SafeOut::new(vec![raw_url.clone(), token.clone()]); + out.push(format!("url_scheme_class={}", scheme_class(&raw_url))); + + let base = match http_base_url(&raw_url) { + Ok(url) => url, + Err(class) => { + out.push(format!("url_shape={class}")); + out.flush(); + return; + } + }; + out.push("url_shape=ok".to_owned()); + out.push(format!("token_length_class={}", token.len() / 16)); + + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + { + Ok(client) => client, + Err(error) => { + out.push(format!("client_build={}", transport_class(&error))); + out.flush(); + return; + } + }; + + match base.join("version") { + Ok(endpoint) => match client.get(endpoint).bearer_auth(&token).send().await { + Ok(response) => { + out.push(format!( + "version_http_status={}", + response.status().as_u16() + )); + let body = response.text().await.unwrap_or_default(); + out.push(format!("engine_class={}", classify_engine(&body))); + out.push(format!("server_version_numeric={}", numeric_version(&body))); + } + Err(error) => out.push(format!("version_transport={}", transport_class(&error))), + }, + Err(_) => out.push("version_endpoint=join_failed".to_owned()), + } + + let sentinel = unique_sentinel("peri-probe"); + let request = serde_json::json!({ + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT ? AS probe_text, ? AS probe_int, ? AS probe_null", + "args": [ + {"type": "text", "value": sentinel}, + {"type": "integer", "value": "9007199254740993"}, + {"type": "null"} + ] + } + }, + {"type": "close"} + ] + }); + match base.join("v2/pipeline") { + Ok(endpoint) => match client + .post(endpoint) + .bearer_auth(&token) + .json(&request) + .send() + .await + { + Ok(response) => { + out.push(format!( + "pipeline_http_status={}", + response.status().as_u16() + )); + let body: serde_json::Value = + response.json().await.unwrap_or(serde_json::Value::Null); + let entry = &body["results"][0]; + let kind = entry["type"].as_str().unwrap_or("malformed"); + out.push(format!("pipeline_result_class={kind}")); + if kind == "ok" { + let row = &entry["response"]["result"]["rows"][0]; + out.push(format!( + "binding_text_roundtrip={}", + row[0]["value"].as_str() == Some(sentinel.as_str()) + )); + out.push(format!( + "binding_int64_roundtrip={}", + row[1]["value"].as_str() == Some("9007199254740993") + )); + out.push(format!( + "binding_null_roundtrip={}", + row[2]["type"].as_str() == Some("null") || row[2].is_null() + )); + } else if kind == "error" { + let code = entry["error"]["code"] + .as_str() + .filter(|code| { + code.len() <= 40 + && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) + .unwrap_or("unclassified"); + out.push(format!("pipeline_error_code={code}")); + } + } + Err(error) => out.push(format!("pipeline_transport={}", transport_class(&error))), + }, + Err(_) => out.push("pipeline_endpoint=join_failed".to_owned()), + } + + out.flush(); +} + +/// 生产连接路径只读探测:用私有 `RemoteConnection`(已选定 SDK)连接并做只读回环。 +#[tokio::test] +#[ignore = "显式 cloud 探测:需要已授权测试库的 .env,默认不跑"] +async fn cloud_sdk_connection_read_only_probe() { + let env = load_env(); + let (raw_url, token) = required_credentials(&env); + let credential = match SessionStoreCredential::new(token.clone()) { + Ok(credential) => credential, + Err(error) => { + println!("PROBE credential_error={error}"); + return; + } + }; + let mut out = SafeOut::new(vec![raw_url.clone(), token.clone()]); + let endpoint = match RemoteEndpoint::parse(&raw_url, None) { + Ok(endpoint) => endpoint, + Err(error) => { + out.push(format!("endpoint_error={error}")); + out.flush(); + return; + } + }; + out.push(format!("engine={}", endpoint.engine().as_str())); + out.push(format!("host_domain_class={}", endpoint.host_class())); + out.push(format!("token_length_class={}", credential.length_class())); + + let connection = match RemoteConnection::connect(&endpoint, &credential).await { + Ok(connection) => connection, + Err(error) => { + out.push(format!("sdk_connect_error_kind={:?}", error.kind())); + out.flush(); + return; + } + }; + out.push("sdk_connect=ok".to_owned()); + + let sentinel = unique_sentinel("peri-sdk-probe"); + match connection + .bind_roundtrip(&sentinel, 9_007_199_254_740_993) + .await + { + Ok(roundtrip) => { + out.push(format!("sdk_binding_text_ok={}", roundtrip.text_ok)); + out.push(format!("sdk_binding_int64_ok={}", roundtrip.int64_ok)); + out.push(format!("sdk_binding_null_ok={}", roundtrip.null_ok)); + } + Err(error) => out.push(format!("sdk_binding_error_kind={:?}", error.kind())), + } + + match connection.engine_read_facts().await { + Ok(facts) => out.push(format!( + "sdk_sqlite_version_numeric={}", + facts + .sqlite_version + .as_deref() + .map(numeric_version) + .unwrap_or_else(|| "unsupported".to_owned()) + )), + Err(error) => out.push(format!("sdk_read_facts_error_kind={:?}", error.kind())), + } + + match connection.close().await { + Ok(()) => out.push("sdk_close=ok".to_owned()), + Err(error) => out.push(format!("sdk_close_error_kind={:?}", error.kind())), + } + out.flush(); +} + +// ─── 清理器的离线契约测试(不联网、不需要凭证)──────────────────────────────── + +/// 清理器只删本轮**合成**数据:效果语句里不得出现收据表,也不得把 run 前缀编进 SQL 文本。 +#[test] +fn test_cleanup_never_deletes_ledger_receipts() { + let run = "peri-run-0123456789"; + let effects = cleanup_effects(run); + assert_eq!( + effects.len(), + 3, + "清理本轮合成数据的三条语句(子行先于父行)" + ); + for spec in &effects { + assert!( + !spec.sql.contains("peri_op_ledger"), + "清理器不得删除收据行:{}", + spec.sql + ); + assert!( + spec.sql.starts_with("DELETE FROM threads") + || spec.sql.starts_with("DELETE FROM messages") + || spec.sql.starts_with("DELETE FROM session_bindings"), + "清理器只删本轮合成的会话与消息:{}", + spec.sql + ); + assert!( + !spec.sql.contains(run), + "run 只作为绑定值出现,不进入 SQL 文本:{}", + spec.sql + ); + assert!( + spec.params + .iter() + .any(|value| matches!(value, Value::Text(text) if text == &format!("{run}%"))), + "本轮前缀必须是绑定参数" + ); + } +} + +/// 清理后的复核:合成数据必须为 0,收据只增不减,返回值是**有意保留**的条数。 +#[test] +fn test_cleanup_retention_check_reports_retained_receipts() { + let before = RunCounts { + sessions: 1, + messages: 3, + bindings: 1, + ledger: 2, + }; + // 清理自身这次的收据也会落在账本里:保留条数因此可以增加,但绝不减少。 + let after = RunCounts { + sessions: 0, + messages: 0, + bindings: 0, + ledger: 3, + }; + assert_eq!(check_cleanup_kept_receipts(&before, &after).unwrap(), 3); + + let lost = RunCounts { + sessions: 0, + messages: 0, + bindings: 0, + ledger: 1, + }; + assert!( + check_cleanup_kept_receipts(&before, &lost).is_err(), + "收据被删掉必须失败,不静默通过" + ); + + let dirty = RunCounts { + sessions: 1, + messages: 0, + bindings: 0, + ledger: 3, + }; + assert!( + check_cleanup_kept_receipts(&before, &dirty).is_err(), + "合成数据没删干净必须失败" + ); + + let orphan_binding = RunCounts { + sessions: 0, + messages: 0, + bindings: 1, + ledger: 3, + }; + assert!( + check_cleanup_kept_receipts(&before, &orphan_binding).is_err(), + "绑定行没删干净必须失败" + ); +} diff --git a/peri-resources/src/sessions/remote/composition.rs b/peri-resources/src/sessions/remote/composition.rs new file mode 100644 index 000000000..dadb4d23b --- /dev/null +++ b/peri-resources/src/sessions/remote/composition.rs @@ -0,0 +1,80 @@ +//! 远程组合装配:把远端数据 adapter 与本机执行面装进同一个门面。 +//! +//! 组合层是唯一知道「数据在哪、本机执行事实在哪」的地方,门面只看到两个端口: +//! +//! ```text +//! SessionResourcesImpl +//! ├── data : RemoteSessionData (canonical 会话数据,远端) +//! └── local : LocalExecution (本机 workspace 证据、代际、OS 锁) +//! ``` +//! +//! 顺序固定,每一步都不能省: +//! +//! 1. **凭证解析**:只按显式来源取环境变量;解析失败在建立任何 I/O 之前返回(不猜、不回落)。 +//! 2. **本机执行面**:写意图打开(必要时升级本机 schema);只读意图只读打开,库不存在时 +//! 如实失败(`DatabaseNotFound`,见 [`open_local_execution`])——本机执行事实只在这个 +//! 库里,没有它就没有可用的执行面,而只读意图不许把它建出来。这与本机库的只读打开是 +//! 同一个判定:两种存储模式下「只读打开一个不存在的库」都按 `NotFound` 拒绝。 +//! 3. **远端打开**:读回 store 身份(只读打开遇上未初始化的 store 直接拒绝)。 +//! 4. **门面装配**:数据端口是远端 adapter,执行面是本机执行面。 +//! +//! 远端与本地不是两套公开行为:门面(`SessionResourcesImpl`)只有一套,组合只决定注入。 +//! 配置即用:配了哪个 store 就直接用,没有「本机未登记 → 拒绝执行」这一步(v10 撤销)。 + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use peri_acp_types::session_resources::AccessMode; + +use crate::sessions::data::SessionDataPort; +use crate::sessions::local_port::LocalExecutionPort; +use crate::sessions::resources::SessionDataHome; +use crate::sessions::sqlite_store::LocalExecution; +use crate::sessions::SessionResourcesImpl; + +use super::credentials::SessionStoreCredential; +use super::endpoint::RemoteEndpoint; +use super::mutation::StoreAccess; +use super::session_data::RemoteSessionData; + +/// 打开远程会话存储并装配门面。 +/// +/// 凭证是**值**而不是来源:解析发生在 D 边界(读取进程环境的唯一位置),组合层只消费 +/// 已解析的值,因此云实验可以把进程内解析出的凭证直接注入,不必把它写进进程环境。 +/// +/// `registry_path` 是本机执行事实所在(workspace 登记、执行代际、sidecar 锁);它**不是** +/// canonical 数据的位置,因此远程组合不会把它当作会话库来读写。 +pub(crate) async fn open_remote( + endpoint: &RemoteEndpoint, + credential: &SessionStoreCredential, + access: AccessMode, + registry_path: PathBuf, +) -> Result> { + // 本机执行面先于远端打开:缺库的只读意图在这里就如实失败,不会先把远端连接建起来。 + let local = open_local_execution(access, ®istry_path).await?; + let (data, _initialization) = + RemoteSessionData::open(endpoint, credential, StoreAccess::of(access)).await?; + let data_port: Arc = Arc::new(data); + let local_port: Arc = Arc::new(local); + Ok(Arc::new(SessionResourcesImpl::from_ports( + data_port, + local_port, + SessionDataHome::RemoteStore, + ))) +} + +/// 本机执行面:写意图可以创建/升级,只读意图只读打开、不创建任何东西。 +/// +/// 本机库不存在时**如实失败**(`DatabaseNotFound`):workspace 证据、执行代际与 sidecar +/// 锁都是本机库持有的事实,没有它就没有可用的执行面。只读打开不创建文件是硬约束 +/// (不能用「补一个空库」把它变成可写打开),因此这里不回退。 +async fn open_local_execution( + access: AccessMode, + registry_path: &PathBuf, +) -> Result { + match access { + AccessMode::ReadWrite => LocalExecution::open(registry_path).await, + AccessMode::ReadOnly => Ok(LocalExecution::open_existing_read_only(registry_path).await?), + } +} diff --git a/peri-resources/src/sessions/remote/connection.rs b/peri-resources/src/sessions/remote/connection.rs new file mode 100644 index 000000000..1bb348763 --- /dev/null +++ b/peri-resources/src/sessions/remote/connection.rs @@ -0,0 +1,282 @@ +//! 远程连接:本 crate 内唯一接触 SDK 的地方。 +//! +//! 只提供连接、只读事实与只读参数绑定回环。写路径(新建/追加/compact/fork/删除) +//! 以及 C §5.1 的 P1–P7 前置条件未实测前不在这里出现——没有半成品 mutation, +//! 也没有「先连上再假装能写」的中间态。 +//! +//! 请求有界:SDK 0.1.3 公开面不提供客户端超时配置,因此本层用 `tokio::time::timeout` +//! 兜住上界;超时返回 `Timeout`,不改变远端结果的分类(§7)。 + +use std::future::Future; +use std::time::Duration; + +use async_trait::async_trait; +use peri_acp_types::session_resources::SessionResourceError; +use turso_serverless::{BatchStatement, Builder, Connection, TransactionBehavior, Value}; + +use super::credentials::SessionStoreCredential; +use super::endpoint::RemoteEndpoint; +use super::failure::{self, RemoteFailureClass}; +use super::sql::StatementSpec; + +/// 单次远程调用的时间预算;正式预算由 C-01/F 的测量确定。 +pub(super) const REQUEST_BUDGET: Duration = Duration::from_secs(20); + +/// 预算内的结果:保留 SDK 原始错误,供上层按结构分类(mutation 必须区分 +/// 「整批回滚」与「回滚失败」,折叠成类别会丢掉确定性信息)。 +pub(super) enum Budgeted { + Done(T), + Failed(turso_serverless::Error), + Exceeded, +} + +/// 预算内执行 SDK 调用;超时归 `Exceeded`,不推断远端是否生效。 +pub(super) async fn within_budget( + future: impl Future>, + budget: Duration, +) -> Budgeted { + match tokio::time::timeout(budget, future).await { + Ok(Ok(value)) => Budgeted::Done(value), + Ok(Err(error)) => Budgeted::Failed(error), + Err(_) => Budgeted::Exceeded, + } +} + +/// 建立 SDK 连接:URL 由端点定死,凭证只在此处进入 SDK 调用。 +pub(super) async fn connect_sdk( + endpoint: &RemoteEndpoint, + credential: &SessionStoreCredential, +) -> Result { + let builder = Builder::new_remote(endpoint.sdk_url()).with_auth_token(credential.expose()); + let database = bounded(builder.build()).await.map_err(into_error)?; + database + .connect() + .map_err(|error| failure::classify(&error).into_session_resource_error()) +} + +/// 参数绑定回环事实:每个布尔都是「绑定的哨兵原值读回」。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct BindRoundtrip { + pub text_ok: bool, + pub int64_ok: bool, + pub null_ok: bool, +} + +/// 只读引擎事实(不写库、不建对象)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EngineReadFacts { + /// 引擎报告的 SQLite 方言版本;引擎不支持该函数时为 `None`。 + pub sqlite_version: Option, +} + +/// 一条已确认的远程连接;不暴露底层 SDK 类型。 +pub(crate) struct RemoteConnection { + connection: Connection, +} + +impl RemoteConnection { + /// 连接:引擎/URL 已由 [`RemoteEndpoint`] 定死,凭证只在此处进入 SDK 调用。 + pub(crate) async fn connect( + endpoint: &RemoteEndpoint, + credential: &SessionStoreCredential, + ) -> Result { + let connection = connect_sdk(endpoint, credential).await?; + Ok(Self { connection }) + } + + /// 只读参数绑定回环:绑定 text / 64 位整数 / NULL 并原值读回,不做任何写入。 + pub(crate) async fn bind_roundtrip( + &self, + sentinel_text: &str, + sentinel_int: i64, + ) -> Result { + let mut rows = bounded(self.connection.query( + "SELECT ? AS probe_text, ? AS probe_int, ? AS probe_null", + (sentinel_text, sentinel_int, Option::::None), + )) + .await + .map_err(into_error)?; + let row = bounded(rows.next()) + .await + .map_err(into_error)? + .ok_or_else(|| into_error(RemoteFailureClass::NotFound))?; + Ok(BindRoundtrip { + text_ok: matches!(read(&row, 0)?, Value::Text(text) if text == sentinel_text), + int64_ok: matches!(read(&row, 1)?, Value::Integer(value) if value == sentinel_int), + null_ok: matches!(read(&row, 2)?, Value::Null), + }) + } + + /// 只读引擎事实:`sqlite_version()`。函数不被引擎支持时返回 `None` + /// (不把「不支持」伪装成版本号,也不因此判连接失败);其余失败按原分类上报。 + pub(crate) async fn engine_read_facts(&self) -> Result { + let mut rows = + match bounded(self.connection.query("SELECT sqlite_version() AS v", ())).await { + Ok(rows) => rows, + Err(RemoteFailureClass::Unsupported) => { + return Ok(EngineReadFacts { + sqlite_version: None, + }); + } + Err(class) => return Err(into_error(class)), + }; + let sqlite_version = match bounded(rows.next()).await.map_err(into_error)? { + Some(row) => match read(&row, 0)? { + Value::Text(text) => Some(text), + _ => None, + }, + None => None, + }; + Ok(EngineReadFacts { sqlite_version }) + } + + /// 关闭连接:非 `Ok` 按分类上报,不静默吞掉。 + /// + /// 事实边界(`turso_serverless` 0.1.3 源码):`Connection::close` 恒返回 `Ok(())`—— + /// 它只复位本地会话流,远端 `StreamRequest::Close` 的错误被显式忽略。因此成功只证明 + /// **本机传输面**走完了关闭,**不**证明服务端连接/流已释放,也不证明任何未知的远端写 + /// 没有执行(后者由持久化未决锚点与恢复回答)。 + pub(crate) async fn close(self) -> Result<(), SessionResourceError> { + bounded(self.connection.close()).await.map_err(into_error) + } +} + +fn read(row: &turso_serverless::Row, index: usize) -> Result { + row.get_value(index) + .map_err(|error| failure::classify(&error).into_session_resource_error()) +} + +/// 一条连接上可用的传输面:**本 crate 唯一真正调用 SDK 语句入口的地方**。 +/// +/// 结果在传输边界就解码成值矩阵(行、每段结果集、受影响行数),上层只处理确定性与事务 +/// 语义,不再接触 SDK 类型。这条边界也是故障注入的落点:假传输能在本地确定地复现 +/// 「在途请求被丢弃之后,这条连接不再可用」,而不必联网,也不必改动上层的判定逻辑。 +/// +/// 失败按 SDK 原样返回(**不**折叠成类别):mutation 必须区分「整批回滚」与「回滚失败」, +/// 折叠会丢掉确定性信息。 +#[async_trait] +pub(super) trait RemoteTransport: Send + Sync { + /// 单条语句读取:返回全部行(每行按列序原样)。 + async fn sql_values(&self, spec: &StatementSpec) -> turso_serverless::Result>>; + + /// 托管事务批(`BEGIN IMMEDIATE` … `COMMIT`):返回每条语句的受影响行数。 + async fn managed_batch( + &self, + statements: Vec, + ) -> turso_serverless::Result>; + + /// 只读一致读(同一请求内的 `BEGIN DEFERRED` … `COMMIT`):返回每段结果集的行。 + async fn consistent_read( + &self, + statements: Vec, + ) -> turso_serverless::Result>>>; + + /// 连接当前是否处于自动提交。这是驱动侧缓存的事实,不做服务端往返。 + fn is_autocommit(&self) -> turso_serverless::Result; + + /// 关闭连接:非 `Ok` 按分类上报,不静默吞掉。 + /// + /// 生产实现下 `Ok` 只代表本地传输面走完了关闭(SDK 会吞掉远端关闭错误,见 + /// `RemoteConnection::close` 的说明),上层不得把它当作服务端资源已释放的证据。 + async fn close(&self) -> turso_serverless::Result<()>; +} + +/// 生产传输:一条已确认的 SDK 连接。 +pub(super) struct SdkTransport { + connection: Connection, +} + +impl SdkTransport { + pub(super) fn new(connection: Connection) -> Self { + Self { connection } + } +} + +#[async_trait] +impl RemoteTransport for SdkTransport { + async fn sql_values(&self, spec: &StatementSpec) -> turso_serverless::Result>> { + let mut rows = self.connection.query(spec.sql, spec.params.clone()).await?; + let mut collected = Vec::new(); + while let Some(row) = rows.next().await? { + collected.push(columns_of(&row)?); + } + Ok(collected) + } + + async fn managed_batch( + &self, + statements: Vec, + ) -> turso_serverless::Result> { + let batch = build_batch(statements)?; + let results = self + .connection + .transactional_batch(batch, TransactionBehavior::Immediate) + .await?; + Ok(results + .iter() + .map(|result| result.rows_affected()) + .collect()) + } + + async fn consistent_read( + &self, + statements: Vec, + ) -> turso_serverless::Result>>> { + let batch = build_batch(statements)?; + let results = self + .connection + .transactional_batch(batch, TransactionBehavior::Deferred) + .await?; + results + .iter() + .map(|result| { + result + .rows() + .iter() + .map(columns_of) + .collect::>>() + }) + .collect() + } + + fn is_autocommit(&self) -> turso_serverless::Result { + self.connection.is_autocommit() + } + + async fn close(&self) -> turso_serverless::Result<()> { + self.connection.close().await + } +} + +/// 静态 SQL + 位置绑定参数 → SDK 批语句。 +fn build_batch(statements: Vec) -> turso_serverless::Result> { + statements + .into_iter() + .map(|spec| BatchStatement::new(spec.sql, spec.params)) + .collect() +} + +/// 一行 → 值向量(列顺序原样保留)。 +fn columns_of(row: &turso_serverless::Row) -> turso_serverless::Result> { + (0..row.column_count()) + .map(|index| row.get_value(index)) + .collect() +} + +fn into_error(class: RemoteFailureClass) -> SessionResourceError { + class.into_session_resource_error() +} + +/// 预算内执行 SDK 调用;超时归 `Timeout`,不推断远端是否生效。 +/// +/// 只读路径用这个折叠版本;mutation 路径必须用 [`within_budget`] 保留原始错误结构 +/// (见 `mutation::classify_batch_failure`)。 +async fn bounded( + future: impl Future>, +) -> Result { + match within_budget(future, REQUEST_BUDGET).await { + Budgeted::Done(value) => Ok(value), + Budgeted::Failed(sdk_error) => Err(failure::classify(&sdk_error)), + Budgeted::Exceeded => Err(RemoteFailureClass::Timeout), + } +} diff --git a/peri-resources/src/sessions/remote/connection_close_test.rs b/peri-resources/src/sessions/remote/connection_close_test.rs new file mode 100644 index 000000000..1159d412e --- /dev/null +++ b/peri-resources/src/sessions/remote/connection_close_test.rs @@ -0,0 +1,232 @@ +//! 远程 adapter 的关闭语义(离线装配,不联网;夹具见 `recovery_fixture_test.rs`)。 +//! +//! 关闭有两件事必须同时成立:**连接不再服务业务读**,以及**真实资源的关闭进度不被丢弃**。 +//! 连接一开始关闭就离开业务路径(业务读一律失败、不重连),但直到真实关闭成功返回之前它都 +//! 被保留在关闭句柄里——失败或在途关闭被丢弃都可重试,重试关的是同一条连接(不新建连接), +//! 只有成功才算确认关闭,确认之后幂等。 +//! +//! SDK 事实(`turso_serverless` 0.1.3 源码):`Connection::close` 恒返回 `Ok(())`——它只把 +//! 本地会话流标记复位,远端 `StreamRequest::Close` 的错误被显式忽略。因此这里的「关闭成功」 +//! **不能**证明服务端连接已释放,能证明的只有:本机传输面走完了关闭、且本次关闭被记为确认。 + +use std::sync::atomic::Ordering; +use std::time::Duration; + +use peri_acp_types::session_resources::SessionResourceErrorKind; +use peri_acp_types::thread::ThreadId; + +use super::mutation::StoreAccess; +use super::recovery_fixture_tests::{Harness, ABANDON_AFTER}; +use crate::sessions::data::SessionDataPort; + +#[tokio::test] +async fn a_closed_adapter_never_reconnects() { + // 确认关闭:连接被真正关闭,之后的读取既失败也不再建新连接。 + let confirmed = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + confirmed + .adapter + .close() + .await + .expect("closing the only connection"); + let closed_error = confirmed + .adapter + .load_binding(&id) + .await + .expect_err("a closed adapter has no connection to read with"); + assert!(matches!( + closed_error.kind(), + SessionResourceErrorKind::Internal { .. } + )); + assert_eq!( + confirmed.backend.connections(), + 1, + "closing is final: no replacement connection is built" + ); + assert_eq!( + confirmed.backend.close_attempts(), + vec![1], + "the real connection was closed exactly once" + ); + confirmed + .adapter + .close() + .await + .expect("a confirmed close is idempotent"); + assert_eq!( + confirmed.backend.close_attempts(), + vec![1], + "confirming again does not close the real connection a second time" + ); + + // 关闭失败(连接被保留在关闭句柄里、未确认):业务读不复活,也没有重连。 + let failed = Harness::open(StoreAccess::ReadWrite).await; + failed.backend.fail_close.store(true, Ordering::SeqCst); + assert!(failed.adapter.close().await.is_err()); + assert!(matches!( + failed + .adapter + .load_binding(&id) + .await + .expect_err("business reads are ruled out once closing started") + .kind(), + SessionResourceErrorKind::Internal { .. } + )); + assert_eq!( + failed.backend.connections(), + 1, + "a failed close never rebuilds a connection" + ); + assert_eq!(failed.backend.close_attempts(), vec![1]); + assert_eq!(failed.backend.close_successes(), 0); +} + +/// 关闭失败一次之后,重试关的是**同一条被保留的连接**,成功才算确认。 +#[tokio::test] +async fn a_failed_close_is_retried_on_the_retained_connection() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + + harness.backend.fail_close.store(true, Ordering::SeqCst); + assert!( + harness.adapter.close().await.is_err(), + "the real close failed: the close is not confirmed" + ); + assert_eq!(harness.backend.close_attempts(), vec![1]); + assert_eq!(harness.backend.close_successes(), 0); + + // 关闭中:业务读如实失败,而且不重连(连接不因关闭失败被丢掉,也不被重建)。 + assert!(matches!( + harness + .adapter + .load_binding(&id) + .await + .expect_err("business reads are ruled out once closing started") + .kind(), + SessionResourceErrorKind::Internal { .. } + )); + assert_eq!(harness.backend.connections(), 1); + + // 远端这一次真的关掉了:重试在同一第 1 条连接上成功——这才是确认关闭。 + harness.backend.fail_close.store(false, Ordering::SeqCst); + harness + .adapter + .close() + .await + .expect("the retained connection closes on retry"); + assert_eq!( + harness.backend.close_attempts(), + vec![1, 1], + "the retry closes the same retained connection, not a new one" + ); + assert_eq!(harness.backend.close_successes(), 1); + assert_eq!(harness.backend.connections(), 1); + + harness + .adapter + .close() + .await + .expect("a confirmed close stays idempotent"); + assert_eq!( + harness.backend.close_attempts(), + vec![1, 1], + "confirming again does not close anything twice" + ); + assert!(harness.adapter.load_binding(&id).await.is_err()); + assert_eq!(harness.backend.connections(), 1); +} + +/// 在途关闭被丢弃(超时/取消)不丢唯一句柄:重试继续关同一条连接,不新建连接。 +#[tokio::test] +async fn a_cancelled_close_is_retried_on_the_retained_connection() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + + // 在途关闭被丢弃:连接已经离开业务路径,但真实资源不能跟着 future 一起消失。 + harness.backend.hold_close.store(true, Ordering::SeqCst); + let cancelled = tokio::time::timeout(ABANDON_AFTER, harness.adapter.close()).await; + assert!( + cancelled.is_err(), + "the close was expected to be abandoned in flight" + ); + assert_eq!(harness.backend.close_attempts(), vec![1]); + assert_eq!(harness.backend.close_successes(), 0); + + // 关闭中:业务读如实失败,也没有为了「补一条可用连接」去重连。 + assert!(matches!( + harness + .adapter + .load_binding(&id) + .await + .expect_err("business reads are ruled out once closing started") + .kind(), + SessionResourceErrorKind::Internal { .. } + )); + assert_eq!(harness.backend.connections(), 1); + + // 放行被丢弃的那次尝试之后重试:关的仍是第 1 条被保留的连接,且没有新建连接。 + harness.backend.hold_close.store(false, Ordering::SeqCst); + harness.backend.close_release.notify_one(); + harness + .adapter + .close() + .await + .expect("the retry closes the retained connection"); + assert_eq!(harness.backend.close_attempts(), vec![1, 1]); + assert_eq!(harness.backend.close_successes(), 1); + assert_eq!(harness.backend.connections(), 1); + + harness + .adapter + .close() + .await + .expect("a confirmed close stays idempotent"); + assert_eq!(harness.backend.close_attempts(), vec![1, 1]); + assert_eq!(harness.backend.connections(), 1); +} + +/// 并发关闭:都走同一个关闭句柄,成功幂等,真实连接只被关一次;确认之后不重连。 +#[tokio::test] +async fn concurrent_closes_confirm_once_and_close_the_connection_once() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + + // 第一次真实关闭停在传输里:其余调用必须共享同一个句柄上的同一次真实关闭。 + harness.backend.hold_close.store(true, Ordering::SeqCst); + let release = async { + // 让其余调用先走到同一个关闭句柄上,再放行真实关闭。 + for _ in 0..8 { + tokio::task::yield_now().await; + } + harness.backend.hold_close.store(false, Ordering::SeqCst); + harness.backend.close_release.notify_one(); + }; + let closes = async { + tokio::join!( + harness.adapter.close(), + harness.adapter.close(), + harness.adapter.close(), + harness.adapter.close(), + release, + ) + }; + let (first, second, third, fourth, ()) = tokio::time::timeout(Duration::from_secs(5), closes) + .await + .expect("concurrent closes complete"); + for confirmed in [first, second, third, fourth] { + confirmed.expect("every concurrent close reports the confirmed shutdown"); + } + assert_eq!( + harness.backend.close_attempts(), + vec![1], + "the real connection is closed exactly once under concurrency" + ); + assert_eq!(harness.backend.close_successes(), 1); + assert_eq!(harness.backend.connections(), 1); + assert!(harness.adapter.load_binding(&id).await.is_err()); + assert_eq!( + harness.backend.connections(), + 1, + "a confirmed close never reconnects" + ); +} diff --git a/peri-resources/src/sessions/remote/connection_recovery_test.rs b/peri-resources/src/sessions/remote/connection_recovery_test.rs new file mode 100644 index 000000000..cd78586e6 --- /dev/null +++ b/peri-resources/src/sessions/remote/connection_recovery_test.rs @@ -0,0 +1,407 @@ +//! 在途请求被丢弃之后的同实例恢复(本地可控故障,不联网;夹具见 +//! `recovery_fixture_test.rs`)。 +//! +//! Fable 反例:`turso_serverless` 0.1.3 上放弃一个**已经发出**的在途请求(取消或预算超时) +//! 之后,同一条连接上的后续请求一律在传输层失败(分类后是 `Unavailable`),必须重开连接 +//! 才能继续。本文件用可控假传输在本地确定复现这条故障(命中档位的调用永远挂起,调用方只能 +//! 丢弃它;被丢弃的那条连接此后一律拒绝请求),断言 adapter 的反应: +//! +//! 1. **取消读**:同实例的下一次读取重建连接并给出正确答案(不在 → `NotFound`;在 → 那个 +//! 会话真实的绑定分类),而不是 `Unavailable`; +//! 2. **取消写**:同一条连接上已经发出的效果不因取消而改变(从未提交的仍是零、已提交的仍是 +//! 一份);重建的只是连接,**绝不自动重发**未知 mutation——重发与否是调用方的事; +//! 3. **旧代际的失效不碰新连接**:迟到任务拿旧代际号标记失效时,新连接照常服务,且不会再建; +//! 反过来,已经落下的失效事实也不会被更早的代际号撤销(否则一条无法证明的连接会被当回 +//! 可用的,下一次访问不再重建、直接在死连接上失败);失败的替换尝试也不波及装槽的那一代; +//! 4. **取守卫前复核**:重建判定与取守卫之间落下的失效事实会让本次调用如实失败(什么都不发), +//! 下一次访问才重建——不把请求发到一条已经不可证明的连接上; +//! 5. **只读重连只读**:重建不写 schema、不写账本。 + +use std::sync::atomic::Ordering; + +use peri_acp_types::session_resources::{BindingState, SessionResourceErrorKind}; +use peri_acp_types::thread::ThreadId; + +use super::mutation::StoreAccess; +use super::recovery_fixture_tests::{ + abandon_read, abandon_write, ledger_sql, Hang, Harness, ABANDON_AFTER, +}; +use super::schema; +use crate::sessions::data::SessionDataPort; + +// ─── 1. 取消读 ──────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn an_abandoned_read_is_answered_correctly_on_the_same_instance() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + + // 远端没有这个会话:正确答案是 NotFound,不是「连接不可用」。 + harness.backend.session_row.store(false, Ordering::SeqCst); + abandon_read(&harness, &id).await; + assert_eq!( + harness.backend.connections(), + 1, + "no connection yet rebuilt" + ); + let error = harness + .adapter + .load_binding(&id) + .await + .expect_err("the session does not exist remotely"); + assert!( + matches!(error.kind(), SessionResourceErrorKind::NotFound), + "expected NotFound, got {:?}", + error.kind() + ); + assert_eq!( + harness.backend.connections(), + 2, + "the abandoned generation is replaced by exactly one new connection" + ); + + // 远端有这个会话:正确答案是它真实的分类(没有绑定行的既有会话)。 + harness.backend.session_row.store(true, Ordering::SeqCst); + abandon_read(&harness, &id).await; + let state = harness + .adapter + .load_binding(&id) + .await + .expect("a recovered read reaches the remote store"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!(harness.backend.connections(), 3); +} + +// ─── 2. 取消写 ──────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn an_abandoned_write_that_never_committed_is_not_re_sent() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + + // 批到达远端但没有提交(结果未知):被丢弃的是**写**。后续访问只重建那条连接, + // 没有任何一层会替调用方重发这条无法证明终态的写。 + abandon_write(&harness, &id, false).await; + + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the next access rebuilds the connection and answers"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!( + harness.backend.executed_effect_batches(), + 0, + "the abandoned batch never applied" + ); + + let sql = ledger_sql(); + let log = harness.backend.issued(); + let qualification = log + .iter() + .filter(|(_, sql_text, _)| *sql_text == sql.qualify) + .collect::>(); + assert_eq!( + qualification.len(), + 1, + "the abandoned attempt is the only qualification write; nothing was re-sent" + ); + assert_eq!(harness.backend.connections(), 2); +} + +#[tokio::test] +async fn an_abandoned_write_that_committed_is_not_re_sent() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + + // 批真的提交了、响应丢失:后续访问重建连接,但那份已经落下的效果**不再发一次**。 + abandon_write(&harness, &id, true).await; + + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the next access rebuilds the connection and answers"); + assert!(matches!(state, BindingState::Missing)); + + let sql = ledger_sql(); + let log = harness.backend.issued(); + let qualification = log + .iter() + .filter(|(_, sql_text, _)| *sql_text == sql.qualify) + .count(); + assert_eq!(qualification, 1, "the committed batch is not re-sent"); + assert_eq!( + harness.backend.executed_effect_batches(), + 1, + "the effect count stays exactly one" + ); + assert_eq!(harness.backend.connections(), 2); +} + +// ─── 3. 旧代际的失效不碰新连接 ──────────────────────────────────────────────── + +#[tokio::test] +async fn a_late_invalidation_leaves_the_new_connection_alone() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + abandon_read(&harness, &id).await; + harness + .adapter + .load_binding(&id) + .await + .expect("a recovered read"); + assert_eq!(harness.backend.connections(), 2); + + // 迟到任务拿着第一代的守卫落下失效事实:它只作用于那一代。 + let late = harness.gate.lease(1); + assert_eq!(late.generation(), 1); + drop(late); + + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the new connection still serves"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!( + harness.backend.connections(), + 2, + "the late invalidation did not retire the new connection" + ); + let log = harness.backend.issued(); + assert_eq!(log.last().map(|(connection, _, _)| *connection), Some(2)); +} + +/// 反例:第 1 代失效 → 建第 2 代 → 第 2 代也失效 → 第 1 代的**迟到**失效。 +/// +/// 失效事实如果按「相等」记(后写覆盖),迟到的那次会把记录写回第 1 代,已经无法证明的 +/// 第 2 代就被当成可用的:下一次访问不再重建,直接在死连接上失败。 +#[tokio::test] +async fn a_stale_invalidation_does_not_revive_an_invalidated_connection() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + + // 第 1 代被放弃(失效)→ 下一次访问重建出第 2 代并正常服务。 + abandon_read(&harness, &id).await; + harness + .adapter + .load_binding(&id) + .await + .expect("a recovered read"); + assert_eq!(harness.backend.connections(), 2); + + // 第 2 代也被放弃:这一代的失效事实落下。 + abandon_read(&harness, &id).await; + + // 迟到的第 1 代失效(同一份旧守卫在更晚的时刻被丢弃)不能撤销第 2 代的失效事实。 + let late = harness.gate.lease(1); + assert_eq!(late.generation(), 1); + drop(late); + + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the invalidated generation is replaced, not reused"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!( + harness.backend.connections(), + 3, + "the invalidated generation is rebuilt once more instead of being reused" + ); + let log = harness.backend.issued(); + assert_eq!( + log.last().map(|(connection, _, _)| *connection), + Some(3), + "the read is served by the rebuilt connection" + ); +} + +/// 失败的替换尝试(重核实的读取在途被丢弃)不波及后来真正装进槽位的那一代。 +/// +/// 第 2 代从未服务过任何业务请求就退场了;它的失效事实若把水位抬到第 3 代之上, +/// 一条健康的连接会被反复误判成失效。 +#[tokio::test] +async fn a_failed_replacement_does_not_retire_the_connection_that_took_over() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + + // 第 1 代失效;随后一次重建在**重核实**阶段被丢弃:替换连接没有装回槽位。 + abandon_read(&harness, &id).await; + harness.backend.hang_next(Hang::Read); + let abandoned = tokio::time::timeout(ABANDON_AFTER, harness.adapter.load_binding(&id)).await; + assert!( + abandoned.is_err(), + "the rebuilding attempt was expected to be abandoned in flight" + ); + assert_eq!( + harness.backend.connections(), + 2, + "the replacement was built but never installed" + ); + + // 下一次访问按第 1 代的失效事实重建:第 3 代装进槽位并服务读取。 + let state = harness + .adapter + .load_binding(&id) + .await + .expect("a recovered read"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!(harness.backend.connections(), 3); + let log = harness.backend.issued(); + assert_eq!(log.last().map(|(connection, _, _)| *connection), Some(3)); + + // 迟到的第 2 代失效(失败尝试那一代)不能把服务中的第 3 代判成失效:没有多建连接。 + let stale = harness.gate.lease(2); + drop(stale); + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the serving connection stays usable"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!( + harness.backend.connections(), + 3, + "a stale attempt's invalidation does not retire the serving connection" + ); + let log = harness.backend.issued(); + assert_eq!(log.last().map(|(connection, _, _)| *connection), Some(3)); +} + +/// 并发重建:多个调用者同时看到同一条失效代际,只有一个替换连接进入槽位。 +/// +/// 中途被放弃的那一次(它的重核实读取挂在传输里)在**它自己那一代**上落下失效事实; +/// 装进槽位的那一代号更大,照常服务,且不会被多重建一次。 +#[tokio::test] +async fn concurrent_rebuilds_install_exactly_one_connection_and_keep_it_healthy() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + abandon_read(&harness, &id).await; + + // 挂起下一次读取,让两个重建尝试真正交叠:先到者挂在重核实里(它的替换连接不会装槽), + // 后到者建立自己的连接并装进槽位。 + harness.backend.hang_next(Hang::Read); + let (abandoned, served) = tokio::join!( + tokio::time::timeout(ABANDON_AFTER, harness.adapter.load_binding(&id)), + harness.adapter.load_binding(&id), + ); + assert!( + abandoned.is_err(), + "the rebuilding attempt that hit the hang was expected to be abandoned" + ); + let state = served.expect("the concurrent attempt serves the read"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!( + harness.backend.connections(), + 3, + "one replacement was installed; the other one was abandoned mid-verify" + ); + + // 装进槽位的那一代仍然健康:被放弃的那次尝试的失效事实只作用于它自己那一代。 + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the installed connection keeps serving"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!( + harness.backend.connections(), + 3, + "no extra rebuild: the abandoned attempt does not retire the installed connection" + ); + let log = harness.backend.issued(); + assert_eq!(log.last().map(|(connection, _, _)| *connection), Some(3)); +} + +#[tokio::test] +async fn a_read_only_reconnect_writes_nothing() { + let harness = Harness::open(StoreAccess::ReadOnly).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + abandon_read(&harness, &id).await; + let state = harness + .adapter + .load_binding(&id) + .await + .expect("a recovered read"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!(harness.backend.connections(), 2); + + let sql = ledger_sql(); + let log = harness.backend.issued(); + assert!( + log.iter().all(|(_, sql_text, _)| *sql_text != sql.qualify + && *sql_text != sql.closure + && *sql_text != sql.resolve + && !sql_text.starts_with("CREATE")), + "a read-only reconnect issues no DDL, no schema write and no ledger write: {:?}", + log.iter() + .map(|(_, sql_text, _)| *sql_text) + .collect::>() + ); + assert_eq!(harness.backend.executed_effect_batches(), 0); +} + +/// 重建判定与取守卫之间落下的失效事实:本次调用什么也不发,下一次访问才重建。 +/// +/// 反例:判定(`is_invalid`)与取读锁之间没有原子性,另一条在途调用在同一刻被放弃会把**当前** +/// 这一代记为失效。若守卫照旧交出去,调用方就会在一条已经不可证明的连接上发请求。 +#[tokio::test] +async fn a_generation_retired_before_the_guard_is_taken_fails_without_sending() { + let harness = Harness::open(StoreAccess::ReadWrite).await; + let id: ThreadId = "session-under-test".to_owned(); + harness.backend.session_row.store(true, Ordering::SeqCst); + + // 第 1 代被放弃(失效)→ 下一次访问会重建。让重建连接的**重核实读取**顺手把新铸的那一代 + // 记为失效:这就是那个空窗(另一条在途调用在同一刻被放弃,落下同一个事实)。 + abandon_read(&harness, &id).await; + harness.backend.retire_generation_on_next_read(2); + + let refused = harness + .adapter + .load_binding(&id) + .await + .expect_err("a connection retired before the guard is taken is never used"); + assert!( + matches!(refused.kind(), SessionResourceErrorKind::Unavailable { .. }), + "expected a conservative Unavailable, got {:?}", + refused.kind() + ); + // 那条连接只服务过只读身份重核实,没有收到任何业务请求(本调用什么都没发)。 + let identity_sql: Vec<&'static str> = schema::identity_read_plan() + .iter() + .map(|spec| spec.sql) + .collect(); + let issued = harness.backend.issued(); + assert!( + issued + .iter() + .filter(|(connection, _, _)| *connection == 2) + .all(|(_, sql, _)| identity_sql.contains(sql)), + "the retired connection never served a business request" + ); + assert_eq!( + harness.backend.connections(), + 2, + "no automatic retry: rebuilding is the next access's job" + ); + + // 下一次访问重建(第 3 条)并正常服务。 + let state = harness + .adapter + .load_binding(&id) + .await + .expect("the next read rebuilds and serves"); + assert!(matches!(state, BindingState::Missing)); + assert_eq!(harness.backend.connections(), 3); + let issued = harness.backend.issued(); + assert_eq!(issued.last().map(|(connection, _, _)| *connection), Some(3)); +} diff --git a/peri-resources/src/sessions/remote/credentials.rs b/peri-resources/src/sessions/remote/credentials.rs new file mode 100644 index 000000000..7ff9dd20b --- /dev/null +++ b/peri-resources/src/sessions/remote/credentials.rs @@ -0,0 +1,140 @@ +//! 远程凭证:来源与值分离,只接受显式注入。 +//! +//! 规则(D §4):资源库不搜索 cwd 或父目录 `.env`,不内置默认变量名、别名或候选名; +//! 变量名由调用方显式给出,缺失/空值各有类型化错误。凭证值不实现泄密的 `Debug`、 +//! 不实现 `Serialize`/`Deserialize`,只在 SDK 调用边界 [`SessionStoreCredential::expose`]。 + +use std::fmt; + +/// 凭证来源(环境变量名)或凭证值本身的问题。只携带**变量名**,不携带值。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CredentialError { + /// 变量名不是合法的 `[A-Za-z0-9_]+`。 + InvalidName, + Missing { + name: String, + }, + Empty { + name: String, + }, + NotUnicode { + name: String, + }, + /// 直接注入的凭证为空。 + EmptyValue, +} + +impl fmt::Display for CredentialError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidName => formatter.write_str("credential variable name is not valid"), + Self::Missing { name } => { + write!( + formatter, + "credential environment variable {name} is not set" + ) + } + Self::Empty { name } => { + write!(formatter, "credential environment variable {name} is empty") + } + Self::NotUnicode { name } => write!( + formatter, + "credential environment variable {name} is not valid unicode" + ), + Self::EmptyValue => formatter.write_str("injected credential is empty"), + } + } +} + +impl std::error::Error for CredentialError {} + +/// 凭证来源:只表达「从哪个环境变量取」。 +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct CredentialSource { + name: String, +} + +impl CredentialSource { + pub(crate) fn env(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(CredentialError::InvalidName); + } + Ok(Self { name }) + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// 解析凭证:只读进程环境,缺失、空值与非 Unicode 分别是类型化错误; + /// 不尝试其他变量名,也不回退到默认值。 + pub(crate) fn resolve(&self) -> Result { + match std::env::var(&self.name) { + Ok(value) if value.is_empty() => Err(CredentialError::Empty { + name: self.name.clone(), + }), + Ok(value) => Ok(SessionStoreCredential(value)), + Err(std::env::VarError::NotPresent) => Err(CredentialError::Missing { + name: self.name.clone(), + }), + Err(std::env::VarError::NotUnicode(_)) => Err(CredentialError::NotUnicode { + name: self.name.clone(), + }), + } + } +} + +impl fmt::Debug for CredentialSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CredentialSource") + .field(&format_args!("env:{}", self.name)) + .finish() + } +} + +impl fmt::Display for CredentialSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "env:{}", self.name) + } +} + +/// 凭证值:不 `Debug` 泄露、不序列化;`expose` 只给 adapter 边界调用。 +pub(crate) struct SessionStoreCredential(String); + +impl SessionStoreCredential { + /// 直接注入(云实验 runner 在受控进程内解析 `.env` 后注入)。 + pub(crate) fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(CredentialError::EmptyValue); + } + Ok(Self(value)) + } + + /// 只给 SDK 调用边界使用;调用方不得把它写进日志、错误或快照。 + pub(crate) fn expose(&self) -> &str { + &self.0 + } + + /// 复制一份凭证值:**只为让受保护的连接工厂持有**(adapter 生命周期长于一次打开, + /// 而重建必须由工厂自己完成,不能回头找调用方要凭证)。 + /// + /// 本类型刻意不实现 `Clone`:复制凭证是一次要写明的决定,不是随手可用的便利; + /// 复制只发生在进程内,两个副本都不出这条边界。 + pub(crate) fn duplicate(&self) -> Self { + Self(self.0.clone()) + } + + /// 诊断用的粗粒度长度类(值本身不泄露)。 + pub(crate) fn length_class(&self) -> usize { + self.0.len() / 16 + } +} + +impl fmt::Debug for SessionStoreCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SessionStoreCredential(redacted)") + } +} diff --git a/peri-resources/src/sessions/remote/endpoint.rs b/peri-resources/src/sessions/remote/endpoint.rs new file mode 100644 index 000000000..0354dea9c --- /dev/null +++ b/peri-resources/src/sessions/remote/endpoint.rs @@ -0,0 +1,181 @@ +//! 远程端点解析与稳定身份。 +//! +//! 引擎只能来自**已确认的 locator 语法或显式选择**,不由端口、响应或「哪个 SDK 好使」推断: +//! 官方 Rust Quickstart 与 SQL over HTTP 参考页把 `turso://` 记为 Turso 数据库、 +//! `libsql://` 记为 libSQL 数据库;两者都能换成 `https://` 访问同一服务面,所以 +//! `https://`/`http://` 单独出现时引擎不可判定,必须显式给出。 + +use std::fmt; + +use sha2::{Digest, Sha256}; +use url::Url; + +/// 远程引擎(含驱动对应关系,见模块文档)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RemoteEngine { + /// Turso 数据库引擎(驱动 `turso_serverless`)。 + Turso, + /// libSQL 引擎(驱动 `libsql` 的 remote feature)。 + LibSql, +} + +impl RemoteEngine { + /// 本机登记与 CLI 显式选择使用的稳定名字。 + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Turso => "turso", + Self::LibSql => "libsql", + } + } + + /// 解析显式引擎选择;不认识的取值返回 `None`,由调用方报类型化错误。 + pub(crate) fn parse(value: &str) -> Option { + match value { + "turso" => Some(Self::Turso), + "libsql" => Some(Self::LibSql), + _ => None, + } + } +} + +/// locator 解析失败的原因。**不携带 locator 原文**(可能含主机名或凭证)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum EndpointError { + Empty, + /// 没有 scheme:看起来是本机路径,不属于远程 locator。 + NotARemoteUrl, + UnsupportedScheme, + /// URL 里带 userinfo:凭证只经显式来源注入。 + UserInfoPresent, + /// 带 query/fragment:embedded replica / sync 形态不在首期支持面内,不做静默解释。 + QueryOrFragmentUnsupported, + /// `https://` 等无法判定引擎的形态,且未显式选择引擎。 + AmbiguousEngine, + /// 显式引擎与 locator scheme 指向不同引擎。 + EngineConflict, + UnparsableUrl, +} + +impl fmt::Display for EndpointError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::Empty => "session store locator is empty", + Self::NotARemoteUrl => "session store locator is not a remote URL", + Self::UnsupportedScheme => "session store locator scheme is unsupported", + Self::UserInfoPresent => "session store URL must not carry credentials", + Self::QueryOrFragmentUnsupported => { + "session store URL must not carry query or fragment (embedded replica and sync are unsupported)" + } + Self::AmbiguousEngine => { + "remote engine cannot be derived from this URL; select the engine explicitly" + } + Self::EngineConflict => "selected engine conflicts with the locator scheme", + Self::UnparsableUrl => "session store URL cannot be parsed", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for EndpointError {} + +/// 已确认的远程端点:URL + 引擎。`Debug` 只给 scheme、引擎与主机家族,不给主机名与路径。 +#[derive(Clone)] +pub(crate) struct RemoteEndpoint { + url: Url, + engine: RemoteEngine, +} + +impl RemoteEndpoint { + /// 解析远程 locator。`explicit_engine` 来自显式配置;为 `None` 时只有 + /// `turso://` / `libsql://` 能确定引擎。 + pub(crate) fn parse( + raw: &str, + explicit_engine: Option, + ) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(EndpointError::Empty); + } + let Some((scheme, _)) = trimmed.split_once("://") else { + return Err(EndpointError::NotARemoteUrl); + }; + let scheme = scheme.to_ascii_lowercase(); + let scheme_engine = match scheme.as_str() { + "turso" => Some(RemoteEngine::Turso), + "libsql" => Some(RemoteEngine::LibSql), + "https" | "http" => None, + _ => return Err(EndpointError::UnsupportedScheme), + }; + let url = Url::parse(trimmed).map_err(|_| EndpointError::UnparsableUrl)?; + if !url.username().is_empty() || url.password().is_some() { + return Err(EndpointError::UserInfoPresent); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(EndpointError::QueryOrFragmentUnsupported); + } + if url.host_str().is_none() { + return Err(EndpointError::UnparsableUrl); + } + let engine = match (explicit_engine, scheme_engine) { + (Some(explicit), Some(derived)) if explicit != derived => { + return Err(EndpointError::EngineConflict); + } + (Some(explicit), _) => explicit, + (None, Some(derived)) => derived, + (None, None) => return Err(EndpointError::AmbiguousEngine), + }; + Ok(Self { url, engine }) + } + + pub(crate) fn engine(&self) -> RemoteEngine { + self.engine + } + + /// 交给 SDK 的 URL 原文(不含 userinfo/query,已在解析期拒绝)。 + pub(crate) fn sdk_url(&self) -> &str { + self.url.as_str() + } + + /// 稳定别名归一:scheme 不参与身份(同一存储的 `turso://` 与 `https://` 形式必须同身份), + /// host 小写,路径去尾斜杠。 + pub(crate) fn canonical_locator(&self) -> String { + let host = self.url.host_str().unwrap_or_default().to_ascii_lowercase(); + let port = self.url.port().map(|p| format!(":{p}")).unwrap_or_default(); + let path = self.url.path().trim_end_matches('/'); + format!("https://{host}{port}{path}") + } + + /// locator 的稳定摘要:按 [`Self::canonical_locator`] 归一后的 SHA-256。 + /// + /// 它描述的是 locator 身份本身,与登记机制无关——v10 撤销本机登记表后生产路径已无消费者 + /// (原用途是按 store 区分本机登记行),当前只有测试在做「同一 locator 的不同写法摘要 + /// 相同」的比对;是否随该测试一并删除归「统一 schema」段决定。 + pub(crate) fn locator_digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.canonical_locator().as_bytes()); + format!("{:x}", hasher.finalize()) + } + + /// 诊断用主机家族,不含主机名(主机名含数据库与组织标识)。 + pub(crate) fn host_class(&self) -> &'static str { + let host = self.url.host_str().unwrap_or_default(); + if host.ends_with(".turso.io") || host == "turso.io" { + "official_turso_cloud_domain" + } else if host == "localhost" || host.starts_with("127.") { + "loopback" + } else { + "other_domain" + } + } +} + +impl fmt::Debug for RemoteEndpoint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RemoteEndpoint") + .field("scheme", &self.url.scheme()) + .field("engine", &self.engine) + .field("host_class", &self.host_class()) + .finish() + } +} diff --git a/peri-resources/src/sessions/remote/failure.rs b/peri-resources/src/sessions/remote/failure.rs new file mode 100644 index 000000000..5d1e00a2f --- /dev/null +++ b/peri-resources/src/sessions/remote/failure.rs @@ -0,0 +1,139 @@ +//! SDK 失败 → 私有分类 → 领域失败。 +//! +//! 原始 SDK 文本(`Error::Http(String)`、`Constraint(String)` 等载荷)只在本模块内用于 +//! 判别,**不进入**领域失败的 detail、日志或诊断输出:那些载荷可能包含 URL、 +//! Authorization 头或 SQL 片段。对外只给稳定分类。 + +use peri_acp_types::session_resources::{SessionResourceError, SessionResourceErrorKind}; +use turso_serverless::Error as SdkError; + +/// 远程失败的稳定分类(不含服务端文本、URL、凭证)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RemoteFailureClass { + /// 认证或授权被服务端拒绝。 + AuthRejected, + /// 目标行不存在。 + NotFound, + /// 预算内没有结果。 + Timeout, + /// 连接、DNS、TLS 或非预期状态码。 + Transport, + /// 服务端执行错误(非约束类)。 + ServerError, + /// 约束冲突(唯一键、外键等)。 + Constraint, + /// 目标库只读。 + Readonly, + /// 写冲突/快照忙:可重试,但不代表未生效。 + Busy, + /// 目标不是本驱动能读的库。 + NotAdb, + /// 数据损坏。 + Corrupt, + /// 驱动侧类型转换或用法不支持。 + Unsupported, + /// 本驱动未分类的其余失败。 + Unknown, +} + +impl RemoteFailureClass { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::AuthRejected => "auth_rejected", + Self::NotFound => "not_found", + Self::Timeout => "timeout", + Self::Transport => "transport", + Self::ServerError => "server_error", + Self::Constraint => "constraint", + Self::Readonly => "readonly", + Self::Busy => "busy", + Self::NotAdb => "not_a_database", + Self::Corrupt => "corrupt", + Self::Unsupported => "unsupported", + Self::Unknown => "unknown", + } + } + + /// 领域失败映射。 + /// + /// 注意:A 契约目前没有专门的「凭证被拒绝」变体,`AuthRejected` 暂按配置/输入类失败 + /// 上报(效果确定性为未生效);是否新增独立变体待 A 阶段确认,本模块不擅自扩展契约。 + pub(crate) fn into_session_resource_error(self) -> SessionResourceError { + match self { + Self::AuthRejected => { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: "remote session store rejected the credential".to_owned(), + }) + } + Self::NotFound => SessionResourceError::new(SessionResourceErrorKind::NotFound), + Self::Timeout => SessionResourceError::new(SessionResourceErrorKind::Timeout), + Self::Readonly => SessionResourceError::new(SessionResourceErrorKind::ReadOnlyStore), + Self::Constraint => SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: "remote constraint violation".to_owned(), + }), + Self::NotAdb | Self::Corrupt => { + SessionResourceError::new(SessionResourceErrorKind::Corrupt { + detail: "remote session store contents are unreadable".to_owned(), + }) + } + Self::Transport + | Self::ServerError + | Self::Busy + | Self::Unsupported + | Self::Unknown => SessionResourceError::new(SessionResourceErrorKind::Unavailable { + detail: format!("remote session store {}", self.as_str()), + }), + } + } +} + +/// SDK 失败 → 稳定分类。参数顺序固定,先判约束/只读这类确定性最高的失败。 +pub(crate) fn classify(error: &SdkError) -> RemoteFailureClass { + match error { + SdkError::QueryReturnedNoRows => RemoteFailureClass::NotFound, + SdkError::Constraint(_) => RemoteFailureClass::Constraint, + SdkError::Readonly(_) => RemoteFailureClass::Readonly, + SdkError::Busy(_) | SdkError::BusySnapshot(_) => RemoteFailureClass::Busy, + SdkError::NotAdb(_) => RemoteFailureClass::NotAdb, + SdkError::Corrupt(_) => RemoteFailureClass::Corrupt, + SdkError::Interrupt(_) => RemoteFailureClass::Timeout, + SdkError::Misuse(_) + | SdkError::ConversionFailure(_) + | SdkError::ToSqlConversionFailure(_) => RemoteFailureClass::Unsupported, + SdkError::Error(_) | SdkError::DatabaseFull(_) => RemoteFailureClass::ServerError, + SdkError::Http(message) => classify_http(message), + SdkError::BatchStatementFailed { error, .. } + | SdkError::BatchRollbackFailed { error, .. } => classify(error), + // `Error` 是 non_exhaustive:未来变体一律保守归入未知,不假装成功。 + _ => RemoteFailureClass::Unknown, + } +} + +/// 这一失败是否让**当前连接代际**不再可信。 +/// +/// 只有「没有拿到确定的远端回答」才算:连接/DNS/TLS/非预期状态码(`Transport`)、 +/// 预算内没有结果(`Timeout`)、驱动未分类的失败(`Unknown`)。远端明确回答的拒绝 +/// (约束、只读、缺行、鉴权、引擎错误)说明流本身仍然活着,不因此换连接—— +/// 把确定回答也当作连接失效,只会让一次普通的业务拒绝变成一次多余的重连。 +pub(crate) fn invalidates_connection(error: &SdkError) -> bool { + matches!( + classify(error), + RemoteFailureClass::Transport | RemoteFailureClass::Timeout | RemoteFailureClass::Unknown + ) +} + +/// 只读 HTTP 失败文本里的安全特征:只看关键字,不转发文本。 +fn classify_http(message: &str) -> RemoteFailureClass { + let lower = message.to_ascii_lowercase(); + if lower.contains("401") + || lower.contains("403") + || lower.contains("unauthorized") + || lower.contains("forbidden") + { + RemoteFailureClass::AuthRejected + } else if lower.contains("timed out") || lower.contains("timeout") { + RemoteFailureClass::Timeout + } else { + RemoteFailureClass::Transport + } +} diff --git a/peri-resources/src/sessions/remote/generation.rs b/peri-resources/src/sessions/remote/generation.rs new file mode 100644 index 000000000..c81f86390 --- /dev/null +++ b/peri-resources/src/sessions/remote/generation.rs @@ -0,0 +1,204 @@ +//! 连接代际与连接工厂:把「这条连接还可不可信」和「凭证在哪里」分别定死。 +//! +//! ## 为什么按代际记账 +//! +//! 放弃一个**已经发出**的在途请求(超时、取消、任务被丢弃)之后,远端流的状态无法证明: +//! 请求可能已经执行,传输层也可能停在半路。此时只有一种诚实做法——把**用到它的那一代** +//! 记为失效,下一次访问重建。代际号把「失效」限制在具体那一条连接上: +//! +//! - 失效事实由在途调用的守卫在 **Drop 点同步**落下(见 [`GenerationLease`]),不依赖任何 +//! 异步收尾任务; +//! - 失效事实按代际**单调累积**(只增不减的失效水位,判定是「代际 ≤ 水位」):一代被记为 +//! 失效之后不会被更早的迟到失效「洗白」,而迟到任务拿着旧代际号来标记失效也不会碰到 +//! 比它新的连接——新代际号晚于既有水位诞生。 +//! +//! 凭证由工厂持有:它只在 SDK 调用边界 [`expose`](super::credentials::SessionStoreCredential::expose) +//! 一次,本模块的类型都不实现 `Debug`/`Serialize`,也不把值写进日志或错误文本。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; + +use peri_acp_types::session_resources::SessionResourceResult; + +use super::connection::{connect_sdk, SdkTransport}; +use super::credentials::SessionStoreCredential; +use super::endpoint::RemoteEndpoint; +use super::mutation::{RemoteStore, StoreAccess}; + +/// 连接代际门禁:当前服务的是哪一代、失效事实已经累积到哪一代。 +/// +/// 只有两个原子量,没有可独立漂移的第二份真相:代际号由工厂铸造(单调递增),失效事实按 +/// 代际号记且**只增不减**——落下的失效不会被更早的代际号撤销。 +#[derive(Debug, Default)] +pub(super) struct ConnectionGate { + /// 失效水位:**代际 ≤ 水位**的连接都已被记为不可信;0 表示还没有失效事实(代际从 1 起)。 + invalid: AtomicU64, + /// 已经铸造出去的代际数。 + minted: AtomicU64, +} + +impl ConnectionGate { + /// 铸造一条新连接的代际号:从 1 起单调递增,只由工厂调用。 + pub(super) fn mint(&self) -> u64 { + self.minted.fetch_add(1, Ordering::Relaxed) + 1 + } + + /// 某一代是否**已知**失效:`代际 ≤ 失效水位`。 + /// + /// 两个方向由同一条判定保证: + /// + /// - 旧代际的迟失效不会波及后来重建的连接——新代际号在铸造时大于当时的水位, + /// 之后也只有**更晚**的代际的失效事实能把水位抬到它上面; + /// - 已经落下的失效事实不会被更早的迟到失效抹掉——水位只增不减,被记为失效的那一代 + /// 不会因为一次旧代际号的失效而重新变成可用。 + pub(super) fn is_invalid(&self, generation: u64) -> bool { + generation != 0 && generation <= self.invalid.load(Ordering::Acquire) + } + + /// 借用一次代际守卫;守卫被丢弃而没有被 `release`/`invalidate` 时,这一代记为失效。 + pub(super) fn lease(&self, generation: u64) -> GenerationLease<'_> { + GenerationLease { + gate: self, + generation, + armed: true, + } + } + + /// 记录一次失效:只把水位**抬高**(`fetch_max`),不覆盖。 + /// + /// 覆盖式写入会让「新一代已失效、旧一代的迟到失效随后到达」把新代的失效事实撤销, + /// 于是一条已经无法证明的连接被当回可用的。水位只增不减就没有这个方向。 + pub(super) fn invalidate(&self, generation: u64) { + self.invalid.fetch_max(generation, Ordering::AcqRel); + } +} + +/// 一次在途调用的代际守卫(RAII)。 +/// +/// 在途 future 被丢弃的机会只有一次,而且发生在 **drop 点**:`tokio::time::timeout` 到点、 +/// 外层任务取消、调用方放弃,都在那里同步落定。因此失效事实由 `Drop` 落下—— +/// 「调用方已经不管这条连接了」与「这条连接被记为失效」之间没有时间窗。 +#[derive(Debug)] +pub(super) struct GenerationLease<'a> { + gate: &'a ConnectionGate, + generation: u64, + armed: bool, +} + +impl GenerationLease<'_> { + /// 调用拿到了确定回答(成功,或远端明确的拒绝):这一代仍然可信。 + pub(super) fn release(&mut self) { + self.armed = false; + } + + /// 结果无法证明:这一代从此不可再用。 + pub(super) fn invalidate(&mut self) { + self.armed = false; + self.gate.invalidate(self.generation); + } + + /// 本守卫盯着的代际号。 + pub(super) fn generation(&self) -> u64 { + self.generation + } +} + +impl Drop for GenerationLease<'_> { + fn drop(&mut self) { + if self.armed { + // 在途调用被丢弃:这一代的命运无法证明,同步记为失效。 + self.gate.invalidate(self.generation); + } + } +} + +/// 建立新连接的工厂:**本 crate 唯一持有凭证的地方**。 +/// +/// 实现必须是纯函数式的:同样的打开事实(端点、凭证、访问意图)产生同样的连接, +/// 不缓存、不轮换、不在失败后自行改变输入。 +#[async_trait] +pub(super) trait ConnectionFactory: Send + Sync { + /// 建立一条新连接(新代际);失败即没有连接可用,不返回半个连接。 + async fn connect(&self) -> SessionResourceResult; +} + +/// 生产工厂:端点、凭证与访问意图在装配时定死,重建不改变它们。 +pub(super) struct RemoteConnectionFactory { + endpoint: RemoteEndpoint, + credential: SessionStoreCredential, + access: StoreAccess, + gate: Arc, +} + +impl RemoteConnectionFactory { + pub(super) fn new( + endpoint: RemoteEndpoint, + credential: SessionStoreCredential, + access: StoreAccess, + gate: Arc, + ) -> Self { + Self { + endpoint, + credential, + access, + gate, + } + } +} + +#[async_trait] +impl ConnectionFactory for RemoteConnectionFactory { + async fn connect(&self) -> SessionResourceResult { + let connection = connect_sdk(&self.endpoint, &self.credential).await?; + Ok(RemoteStore::new( + Arc::new(SdkTransport::new(connection)), + self.access, + self.gate.mint(), + Arc::clone(&self.gate), + )) + } +} + +#[cfg(test)] +mod tests { + use super::ConnectionGate; + + /// 失效事实单调累积:落下的失效不会被更早的代际号撤销,新代际也不受旧失效影响。 + /// + /// 反例(覆盖式写入 + 相等判定):第 1 代失效 → 建第 2 代 → 第 2 代也失效 → + /// 第 1 代的迟到失效把记录写回 1,第 2 代从此被判成「可用」——一条无法证明的连接 + /// 被当成可用的。 + #[test] + fn an_invalidation_fact_is_never_undone_by_a_stale_generation() { + let gate = ConnectionGate::default(); + let first = gate.mint(); + let second = gate.mint(); + assert!(second > first, "代际号单调递增"); + + assert!(!gate.is_invalid(first), "铸造本身不是失效"); + assert!(!gate.is_invalid(second)); + + gate.invalidate(first); + assert!(gate.is_invalid(first), "第 1 代的失效事实成立"); + assert!(!gate.is_invalid(second), "旧代的失效不波及后来建的第 2 代"); + + gate.invalidate(second); + assert!(gate.is_invalid(second), "第 2 代的失效事实成立"); + + // 迟到的第 1 代失效(同一份旧守卫在更晚的时刻被丢弃)不能撤销第 2 代的事实。 + gate.invalidate(first); + assert!( + gate.is_invalid(second), + "更早的代际号不能把已经落下的失效事实洗白" + ); + assert!(gate.is_invalid(first)); + + // 之后铸出的代际号晚于水位诞生,任何既有失效事实都不波及它。 + let third = gate.mint(); + assert!(!gate.is_invalid(third), "新代际出生时不属于任何失效水位"); + // 0 不是代际号:没有「未铸造」的失效事实。 + assert!(!gate.is_invalid(0)); + } +} diff --git a/peri-resources/src/sessions/remote/initialization_test.rs b/peri-resources/src/sessions/remote/initialization_test.rs new file mode 100644 index 000000000..09a7ea751 --- /dev/null +++ b/peri-resources/src/sessions/remote/initialization_test.rs @@ -0,0 +1,396 @@ +//! 身份初始化竞争的离线回归:**只有本事务确切插入元数据行的那一次才算创建**。 +//! +//! 共享的云测试库不能重置(`peri_store_meta` 的身份是权威事实,重建它就等于伪造历史), +//! 所以竞争在**本地真引擎**上复现:语句、事务边界、受影响行数的含义、失败分类与竞争判定 +//! 全部复用生产同一条代码(`schema::initialization_plan` / `classify_batch_failure` / +//! `initialization_evidence` / `open_step` / `open_verdict` / `existing_identity_from_read`), +//! 被替换的只有传输(本地 SQLite 而不是 SQL over HTTP)。 +//! +//! | 场景 | 期望 | +//! | --- | --- | +//! | 两个全新安装面各自读到同一个空库 | 两边都是「未初始化」(竞争窗口成立) | +//! | 两边各自初始化 | 恰好一个 `Created`;败方读回胜者身份且结论是 `Existing` | +//! | 已有数据的库被再次打开 | `Existing`(与竞败同一个结论),不自动认领、不执行 | +//! | 批的结果未知/确定未生效/无插入证据 | 不产生创建事实,也不发身份 | +//! +//! 边界:云端共享库的竞争实验不在本文件内——那需要把库重置成空(禁止操作)。云端只覆盖 +//! 「再次初始化返回既有身份」这一侧(`cloud_mutation_test.rs`,`#[ignore]` 显式执行)。 + +use std::path::Path; + +use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection, SqliteRow}; +use sqlx::{Connection, Row}; +use turso_serverless::{Error as SdkError, Value}; + +use peri_acp_types::session_resources::{ + MutationOutcome, SessionResourceErrorKind, SessionResourceResult, +}; + +use super::mutation::{ + classify_batch_failure, existing_identity_from_read, initialization_evidence, BatchFailure, + InitializationEvidence, StoreAccess, +}; +use super::schema::{ + identity_read_plan, initialization_plan, inserted_meta_row, interpret_identity_read, StoreId, + StoreIdentityOutcome, StoreIdentityRead, StoreSnapshot, META_INSERT_INDEX, + REMOTE_SCHEMA_VERSION, STORE_CONTRACT, +}; +use super::session_data::{open_step, open_verdict, OpenStep, StoreInitialization}; +use super::sql::StatementSpec; +use super::RemoteFailureClass; + +// ── 真引擎 seam:传输换成本地 SQLite,语句与判定仍是生产那一条 ───────────────────── + +/// 打开一条指向同一个库文件的连接(两个安装面对的是同一个物理存储)。 +async fn connect(path: &Path) -> SqliteConnection { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + SqliteConnection::connect_with(&options) + .await + .expect("local engine connection") +} + +/// 一条托管事务批:`BEGIN IMMEDIATE` → 逐条执行 → `COMMIT`,失败即整批回滚。 +/// +/// 对应 `RemoteStore::run_managed_batch`:受影响行数的含义相同(返回行的语句报 0,与 SDK +/// 一致),失败分类走同一个 [`classify_batch_failure`]。 +async fn run_plan( + conn: &mut SqliteConnection, + plan: &[StatementSpec], +) -> Result, BatchFailure> { + let mut counts = Vec::with_capacity(plan.len()); + let mut transaction = conn.begin_with("BEGIN IMMEDIATE").await.expect("begin"); + for (index, statement) in plan.iter().enumerate() { + match execute(&mut transaction, statement).await { + Ok(affected) => counts.push(affected), + Err(error) => { + // 托管批的自动回滚:回滚也失败时连类别都无从确定,只能判未决。 + if transaction.rollback().await.is_err() { + return Err(BatchFailure::Unknown { + class: RemoteFailureClass::Unknown, + }); + } + return Err(batch_failure_of_local_engine(index, &error)); + } + } + } + match transaction.commit().await { + Ok(()) => Ok(counts), + Err(_) => Err(BatchFailure::Unknown { + class: RemoteFailureClass::Unknown, + }), + } +} + +async fn execute( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + spec: &StatementSpec, +) -> Result { + let query = with_params(sqlx::query(spec.sql), &spec.params); + if spec.is_read_only() { + query.fetch_all(&mut **transaction).await?; + return Ok(0); + } + Ok(query.execute(&mut **transaction).await?.rows_affected()) +} + +/// 本地引擎的拒绝 → 生产同一条批失败分类。 +/// +/// 拒绝本身来自真实 SQLite(主键冲突就是它自己的结论),这里只把形状换成 SDK 的对应形态 +/// (失败语句下标 + `Constraint`)再交给生产的 [`classify_batch_failure`]——判定不在测试里 +/// 另立一套。 +fn batch_failure_of_local_engine(index: usize, error: &sqlx::Error) -> BatchFailure { + let unique_key = error + .as_database_error() + .is_some_and(|database| database.is_unique_violation()); + let inner = if unique_key { + SdkError::Constraint("local engine rejected the unique key".to_owned()) + } else { + SdkError::Error("local engine rejected the statement".to_owned()) + }; + classify_batch_failure(&SdkError::BatchStatementFailed { + index, + error: Box::new(inner), + results: Vec::new(), + }) +} + +/// 只读身份读取:生产同一条读取计划 + 同一处形状判定。 +async fn read_identity(conn: &mut SqliteConnection) -> StoreIdentityRead { + let plan = identity_read_plan(); + if fetch_rows(conn, &plan[0]).await.is_empty() { + return interpret_identity_read(false, None); + } + let row = fetch_rows(conn, &plan[1]).await.into_iter().next(); + interpret_identity_read(true, row.as_deref()) +} + +/// 生产 `RemoteStore::initialize_store` 的引擎等价物:铸造身份 → 跑真实初始化 SQL → +/// 用同一处证据判定;没有建立身份时必须读回既有身份,不得拿铸造值冒充。 +async fn initialize( + conn: &mut SqliteConnection, +) -> SessionResourceResult<(StoreId, StoreIdentityOutcome)> { + let minted = StoreId::mint(); + let plan = initialization_plan(&minted, "2026-09-26T00:00:00+00:00"); + let evidence = initialization_evidence(&run_plan(conn, &plan).await); + let outcome = match evidence { + InitializationEvidence::Created => StoreIdentityOutcome::Created(minted.clone()), + // 没有建立身份:既有身份必须读回(与生产 `initialize_store` 同一条分支)。 + InitializationEvidence::Existing => { + StoreIdentityOutcome::Existing(existing_identity_from_read(read_identity(conn).await)?) + } + // 确定未生效或无法证明:不发身份(与生产同一条分支)。 + InitializationEvidence::Failed(class) => return Err(class.into_session_resource_error()), + }; + Ok((minted, outcome)) +} + +async fn fetch_rows(conn: &mut SqliteConnection, spec: &StatementSpec) -> Vec> { + with_params(sqlx::query(spec.sql), &spec.params) + .fetch_all(&mut *conn) + .await + .expect("local engine read") + .iter() + .map(decode_row) + .collect() +} + +fn with_params<'q>( + mut query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments>, + params: &'q [Value], +) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments> { + for value in params { + query = match value { + Value::Null => query.bind(None::), + Value::Integer(number) => query.bind(*number), + Value::Real(number) => query.bind(*number), + Value::Text(text) => query.bind(text.clone()), + Value::Blob(bytes) => query.bind(bytes.clone()), + }; + } + query +} + +fn decode_row(row: &SqliteRow) -> Vec { + (0..row.len()) + .map(|index| decode_cell(row, index)) + .collect() +} + +/// 单元格 → SDK 的值(列顺序与类型原样,交给生产解码判定)。 +fn decode_cell(row: &SqliteRow, index: usize) -> Value { + use sqlx::{TypeInfo, ValueRef}; + let raw = row.try_get_raw(index).expect("cell readable"); + if raw.is_null() { + return Value::Null; + } + match raw.type_info().name() { + "INTEGER" => Value::Integer(row.get::(index)), + "REAL" => Value::Real(row.get::(index)), + "BLOB" => Value::Blob(row.get::, _>(index)), + _ => Value::Text(row.get::(index)), + } +} + +// ── 回归 ─────────────────────────────────────────────────────────────────────── + +/// 两个全新安装面对同一个空库竞争:只有胜者建立身份,败方读回的是胜者建立的权威身份。 +/// +/// 这是共享云库上无法复现的一段(重置别人的库等于伪造历史),所以在这里用真引擎跑; +/// 语句、事务边界与判定(`initialization_plan` / `open_step` / `open_verdict`)都是生产 +/// 那一条,被替换的只有传输。 +#[tokio::test] +async fn only_the_winner_of_the_identity_race_creates_the_identity() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let store_path = dir.path().join("store.db"); + + // 竞争窗口的前半段:两个安装面各自读到同一个空库。 + let mut winner_conn = connect(&store_path).await; + let mut loser_conn = connect(&store_path).await; + assert_eq!( + read_identity(&mut winner_conn).await, + StoreIdentityRead::Uninitialized + ); + assert_eq!( + read_identity(&mut loser_conn).await, + StoreIdentityRead::Uninitialized + ); + // 空库上的只读打开在建任何东西之前就被拒绝。 + let refusal = open_step(StoreIdentityRead::Uninitialized, StoreAccess::ReadOnly) + .expect_err("read-only open of an empty store is refused"); + assert!(matches!( + refusal.kind(), + SessionResourceErrorKind::Unsupported + )); + + // 胜者:本事务确切插入了元数据行。 + let (winner_minted, winner) = initialize(&mut winner_conn) + .await + .expect("winner initializes"); + assert_eq!(winner, StoreIdentityOutcome::Created(winner_minted.clone())); + + // 败方:真实唯一键冲突,读回胜者身份——结论是既有身份,不是本次创建。 + let (loser_minted, loser) = initialize(&mut loser_conn) + .await + .expect("loser reads back the winner"); + assert_eq!(loser, StoreIdentityOutcome::Existing(winner_minted.clone())); + + let (winner_id, winner_initialization) = open_verdict(winner); + let (loser_id, loser_initialization) = open_verdict(loser); + assert_eq!(winner_id, winner_minted); + assert_eq!(loser_id, winner_minted, "败方用的仍是胜者建立的权威身份"); + assert_ne!( + loser_minted, winner_minted, + "两个安装面各自铸造各自的候选身份" + ); + assert_eq!( + winner_initialization, + StoreInitialization::CreatedByThisOpen + ); + assert_eq!(loser_initialization, StoreInitialization::Existing); +} + +/// 库已有身份(与竞败同一个结论):后来者只读回既有身份,不自动认领、不重建。 +#[tokio::test] +async fn a_store_that_already_has_an_identity_is_never_rebuilt() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let store_path = dir.path().join("store.db"); + + let mut first = connect(&store_path).await; + let (minted, outcome) = initialize(&mut first).await.expect("first initializes"); + assert_eq!(outcome, StoreIdentityOutcome::Created(minted.clone())); + + // 后来者读到的是已经存在的身份:本次打开没有建立任何东西。 + let mut later = connect(&store_path).await; + let decision = open_step(read_identity(&mut later).await, StoreAccess::ReadWrite) + .expect("existing store is readable"); + assert_eq!(decision, OpenStep::Existing(minted.clone())); + let (id, initialization) = open_verdict(StoreIdentityOutcome::Existing(minted.clone())); + assert_eq!(id, minted); + assert_eq!( + initialization, + StoreInitialization::Existing, + "已有身份只能得到 Existing:没有第二次创建" + ); + + // 元数据行没有被第二次写入:仍是第一次那一个身份。 + assert_eq!( + read_identity(&mut later).await, + StoreIdentityRead::Present(StoreSnapshot { + store_id: minted, + schema_version: REMOTE_SCHEMA_VERSION, + contract: STORE_CONTRACT.to_owned(), + }), + "再次打开不得改写既有身份" + ); +} + +/// 批的结果不足以证明插入时,一律不产生创建事实,也不发身份。 +#[test] +fn unproven_results_never_create_an_identity() { + // 丢响应、超时、回滚失败:本机无从证明本事务插入过元数据行。 + for class in [ + RemoteFailureClass::Transport, + RemoteFailureClass::Timeout, + RemoteFailureClass::Unknown, + ] { + assert_eq!( + initialization_evidence(&Err(BatchFailure::Unknown { class })), + InitializationEvidence::Failed(class) + ); + // 生产路径把未知结果映射成领域失败(打开失败,不给身份、不给执行资格); + // `Failed` 不携带身份,也没有任何分支能从它发出身份。 + let error = class.into_session_resource_error(); + assert_eq!(error.effect(), MutationOutcome::NotApplied); + } + // 确定未生效:同样不发身份。 + assert_eq!( + initialization_evidence(&Err(BatchFailure::NotApplied { + class: RemoteFailureClass::Timeout, + index: None, + })), + InitializationEvidence::Failed(RemoteFailureClass::Timeout) + ); + // 批成功却没有插入证据(受影响 0 行):不认领创建,只能读回既有身份。 + assert_eq!( + initialization_evidence(&Ok(vec![0; META_INSERT_INDEX + 2])), + InitializationEvidence::Existing + ); + // 唯一键冲突:既有身份必须读回,不能拿本次铸造的候选值冒充。 + assert_eq!( + initialization_evidence(&Err(BatchFailure::NotApplied { + class: RemoteFailureClass::Constraint, + index: Some(META_INSERT_INDEX), + })), + InitializationEvidence::Existing + ); + // 创建的唯一证据是本事务在元数据 INSERT 上确切影响一行。 + let mut created = vec![0; META_INSERT_INDEX + 2]; + created[META_INSERT_INDEX] = 1; + assert!(inserted_meta_row(&created)); + assert_eq!( + initialization_evidence(&Ok(created)), + InitializationEvidence::Created + ); + assert!(!inserted_meta_row(&[0; META_INSERT_INDEX + 2])); + assert!( + !inserted_meta_row(&[1, 1, 0, 0]), + "别的语句影响行数不算插入证据" + ); +} + +/// 读不回既有身份时拒绝读回:不猜、不覆盖,也不把「读懂了但没有身份」当成空库可用。 +#[test] +fn readback_refuses_identities_it_cannot_interpret() { + assert!(existing_identity_from_read(StoreIdentityRead::Uninitialized).is_err()); + assert!(existing_identity_from_read(StoreIdentityRead::Malformed).is_err()); + let unrecognized = StoreSnapshot { + store_id: StoreId::mint(), + schema_version: REMOTE_SCHEMA_VERSION, + contract: "peri.session.store/v1".to_owned(), + }; + let error = existing_identity_from_read(StoreIdentityRead::Present(unrecognized)) + .expect_err("foreign contract is refused"); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Unsupported + )); + assert!(StoreSnapshot { + store_id: StoreId::mint(), + schema_version: REMOTE_SCHEMA_VERSION, + contract: STORE_CONTRACT.to_owned(), + } + .matches_build()); +} + +/// seam 自检:身份读取的形状判定与生产读取共用一条规则(表不存在 / 表在无行 / 形状不符)。 +#[test] +fn identity_read_shape_rules_are_shared() { + assert_eq!( + interpret_identity_read(false, None), + StoreIdentityRead::Uninitialized + ); + assert_eq!( + interpret_identity_read(true, None), + StoreIdentityRead::Uninitialized + ); + assert_eq!( + interpret_identity_read(true, Some(&[Value::Null, Value::Null, Value::Null])), + StoreIdentityRead::Malformed + ); + let store_id = StoreId::mint(); + let row = [ + Value::Integer(REMOTE_SCHEMA_VERSION), + Value::Text(store_id.as_str().to_owned()), + Value::Text(STORE_CONTRACT.to_owned()), + ]; + assert_eq!( + interpret_identity_read(true, Some(&row)), + StoreIdentityRead::Present(StoreSnapshot { + store_id, + schema_version: REMOTE_SCHEMA_VERSION, + contract: STORE_CONTRACT.to_owned(), + }) + ); +} diff --git a/peri-resources/src/sessions/remote/ledger.rs b/peri-resources/src/sessions/remote/ledger.rs new file mode 100644 index 000000000..8a90413dd --- /dev/null +++ b/peri-resources/src/sessions/remote/ledger.rs @@ -0,0 +1,230 @@ +//! 远端操作身份、收据与终态封闭(C §5.1 内部机制)。 +//! +//! 机制要点(实现不得偏离,证据见 `cloud_mutation_test.rs`): +//! +//! - **资格先于效果**:一次 mutation 是**一个原子批**,第一条语句就是资格写入 +//! (`operation_id` 主键 INSERT),其后才是业务效果;资格与效果同生共死。 +//! - **同一唯一键空间**:封闭记录与原身份记录竞争**同一张表的同一主键**。另建 +//! 「closed 表/独立索引」不构成互斥,因为串行化只保证先后顺序,不阻止原请求其后 +//! 照常提交业务变更。 +//! - **收据与操作身份只在本模块内**:`OperationId`/`Receipt` 是 `pub(super)`, +//! 组合层与业务侧拿不到;Debug 一律脱敏。 + +use std::fmt; + +use sha2::{Digest, Sha256}; +use turso_serverless::Value; + +use peri_acp_types::thread::ThreadId; + +use super::sql::{text_at, StatementSpec}; + +/// 远端 op_ledger 表:每个 operation 一行,`operation_id` 是主键(唯一键空间)。 +pub(super) const OP_LEDGER_TABLE: &str = "peri_op_ledger"; + +/// 已生效:业务效果与资格在同一事务里提交。 +pub(super) const STATE_APPLIED: &str = "applied"; +/// 终态封闭:原请求确定从未生效,且不可能再生效。 +pub(super) const STATE_CLOSED: &str = "closed"; + +pub(super) const CREATE_OP_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS peri_op_ledger ( + operation_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + digest TEXT NOT NULL, + state TEXT NOT NULL, + receipt TEXT, + updated_at TEXT NOT NULL +)"; + +const QUALIFY_SQL: &str = "INSERT INTO peri_op_ledger + (operation_id, kind, digest, state, receipt, updated_at) + VALUES (?1, ?2, ?3, 'applied', ?4, ?5)"; + +const CLOSURE_SQL: &str = "INSERT INTO peri_op_ledger + (operation_id, kind, digest, state, receipt, updated_at) + VALUES (?1, ?2, ?3, 'closed', NULL, ?4)"; + +const RESOLVE_SQL: &str = + "SELECT state, receipt, digest FROM peri_op_ledger WHERE operation_id = ?1"; + +/// 一次远端操作的内部身份;不接受外部构造。 +/// +/// **操作 id 由每次调用铸造,不由内容派生**。这一点是身份模型的要害:内容派生的 id 会把 +/// 「同内容的后一次领域调用」判成历史重放并静默丢掉效果(状态 A→B→A、标题 x→y→x、 +/// 同边界 rewind 再追加再 rewind 都会撞上)。id 唯一后,资格冲突只可能来自**同一次** +/// 操作:终态封闭写的是同一张表的**同一主键**,所以封闭与资格天然互斥(封闭先提交 ⇒ 原请求 +/// 此后不可能再生效;对方已提交 ⇒ 读回原收据)。重试令牌不经过调用方,也不由内容推导。 +/// +/// v10 撤销本机操作日志后,[`OperationId::from_record`] 在生产路径上已无消费者(原用途是 +/// 从本机记录取回原 id 参与跨进程封闭竞争);它留给测试构造「同一次操作」的等价场景, +/// 是否随该能力一并删除归「统一 schema」段决定。 +/// +/// 输入摘要([`OperationIdentity::digest`])不参与身份生成,只做**一致性校验**: +/// 同一 id 被复用时摘要必须一致,否则是身份冲突而不是重放。 +#[derive(Clone, PartialEq, Eq)] +pub(super) struct OperationId(String); + +impl OperationId { + /// 每次新的领域调用铸造一个唯一 id。 + /// + /// 形状是 `{thread}.{uuid}`:唯一性来自 uuid,thread 前缀让远端账本行仍能与会话 + /// 对应(账本无 thread 列),也便于按会话回收本轮实验对象。不是内容派生:同样的 + /// 输入连续调用两次得到两个不同 id。 + pub(super) fn mint(thread: &ThreadId) -> Self { + Self(format!( + "{}.{}", + thread.as_str(), + uuid::Uuid::new_v4().simple() + )) + } + + /// 由本机日志里的记录还原(恢复路径:复用已落盘的原 id,不重新铸造)。 + pub(super) fn from_record(value: &str) -> Self { + Self(value.to_owned()) + } + + /// 确定性 id:只在机制实验中用于**故意**制造同一个 id 的重放(生产路径不得借此 + /// 派生身份,见类型文档)。 + #[cfg(test)] + pub(super) fn scoped(scope: &str, label: &str) -> Self { + Self(format!("{scope}.{label}")) + } + + pub(super) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for OperationId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "OperationId()") + } +} + +/// 私有收据:证明「这一行对应的是那次已生效的写入」。 +#[derive(Clone, PartialEq, Eq)] +pub(super) struct Receipt(String); + +impl Receipt { + pub(super) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Receipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "Receipt()") + } +} + +/// 一次操作的完整内部身份。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct OperationIdentity { + pub(super) operation_id: OperationId, + pub(super) kind: String, + pub(super) digest: String, + pub(super) receipt: Receipt, +} + +impl OperationIdentity { + /// 组装身份:摘要取自调用方给出的输入片段,收据随机生成(不由此推导, + /// 否则「返回原收据」与「重算一个收据」无法区分)。 + pub(super) fn new(operation_id: OperationId, kind: &str, inputs: &[&str]) -> Self { + let digest = input_digest(inputs); + Self::with_digest(operation_id, kind, digest) + } + + /// 按已知摘要还原身份(恢复路径用本机记录里的摘要,不重算一份可能与原操作不同的)。 + /// + /// 收据仍是新生成的随机值:还原方在意的是「以同一身份参与唯一键竞争」,不需要原收据 + /// (真需要原收据时从远端账本读回)。 + pub(super) fn with_digest(operation_id: OperationId, kind: &str, digest: String) -> Self { + let receipt = Receipt(format!("v1:{}", uuid::Uuid::new_v4().simple())); + Self { + operation_id, + kind: kind.to_owned(), + digest, + receipt, + } + } +} + +/// 输入摘要:定长、不可逆,长度前缀避免拼接歧义;不把输入原文带进记录。 +pub(super) fn input_digest(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part.as_bytes()); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// 资格写入:必须是原子批的第一条语句。 +pub(super) fn qualify_statement(identity: &OperationIdentity, now: &str) -> StatementSpec { + StatementSpec::new( + QUALIFY_SQL, + vec![ + Value::Text(identity.operation_id.as_str().to_owned()), + Value::Text(identity.kind.clone()), + Value::Text(identity.digest.clone()), + Value::Text(identity.receipt.as_str().to_owned()), + Value::Text(now.to_owned()), + ], + ) +} + +/// 终态封闭:插入终结行,与资格写竞争同一主键。 +pub(super) fn closure_statement(identity: &OperationIdentity, now: &str) -> StatementSpec { + StatementSpec::new( + CLOSURE_SQL, + vec![ + Value::Text(identity.operation_id.as_str().to_owned()), + Value::Text(identity.kind.clone()), + Value::Text(identity.digest.clone()), + Value::Text(now.to_owned()), + ], + ) +} + +/// 只读解析:这一行现在是什么状态。 +pub(super) fn resolve_statement(operation_id: &OperationId) -> StatementSpec { + StatementSpec::new( + RESOLVE_SQL, + vec![Value::Text(operation_id.as_str().to_owned())], + ) +} + +/// 一行账本的解码结果。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum LedgerRow { + /// 已生效:附带原收据与**原操作摘要**(摘要用于判断这次冲突是不是同一次操作)。 + Applied { receipt: Receipt, digest: String }, + /// 已封闭:确定从未生效。 + Closed, + /// 没有这一行。 + Absent, + /// 行存在但形状无法解释:不猜。 + Malformed, +} + +/// 解码 `state, receipt, digest` 三列;形状不符一律 `Malformed`,不推断。 +pub(super) fn decode_row(values: &[Value]) -> LedgerRow { + let Some(state) = text_at(values, 0) else { + return LedgerRow::Malformed; + }; + match state { + STATE_APPLIED => match (text_at(values, 1), text_at(values, 2)) { + (Some(receipt), Some(digest)) => LedgerRow::Applied { + receipt: Receipt(receipt.to_owned()), + digest: digest.to_owned(), + }, + _ => LedgerRow::Malformed, + }, + STATE_CLOSED => LedgerRow::Closed, + _ => LedgerRow::Malformed, + } +} diff --git a/peri-resources/src/sessions/remote/ledger_test.rs b/peri-resources/src/sessions/remote/ledger_test.rs new file mode 100644 index 000000000..a6d413864 --- /dev/null +++ b/peri-resources/src/sessions/remote/ledger_test.rs @@ -0,0 +1,126 @@ +//! `ledger` 的离线测试:唯一键空间、参数化、身份/收据不泄露、解码不猜。全部不联网。 + +use turso_serverless::Value; + +use super::ledger::{ + closure_statement, decode_row, input_digest, qualify_statement, resolve_statement, LedgerRow, + OperationId, OperationIdentity, OP_LEDGER_TABLE, +}; + +fn identity(label: &str) -> OperationIdentity { + OperationIdentity::new( + OperationId::scoped("run-offline", label), + "append_history", + &["thread-1", label], + ) +} + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +#[test] +fn closure_competes_on_the_same_unique_key_as_qualification() { + let identity = identity("op-1"); + let qualify = qualify_statement(&identity, "now"); + let closure = closure_statement(&identity, "now"); + + // 同一张表的同一主键:另建「closed 表」不构成互斥,这里结构上排除那种写法。 + assert!(qualify.sql.starts_with("INSERT INTO peri_op_ledger")); + assert!(closure.sql.starts_with("INSERT INTO peri_op_ledger")); + assert!(qualify.sql.contains(OP_LEDGER_TABLE)); + assert!(closure.sql.contains(OP_LEDGER_TABLE)); + assert!(qualify.sql.contains("'applied'")); + assert!(closure.sql.contains("'closed'")); + // 资格写是主键身份,闭合同样带 operation_id(同一唯一键)。 + assert!(qualify + .params + .contains(&text(identity.operation_id.as_str()))); + assert!(closure + .params + .contains(&text(identity.operation_id.as_str()))); +} + +#[test] +fn qualification_is_first_statement_shaped_and_parameterized() { + let identity = identity("op-2"); + let qualify = qualify_statement(&identity, "2026-09-26T00:00:00+00:00"); + + for value in [ + identity.operation_id.as_str(), + identity.kind.as_str(), + identity.digest.as_str(), + identity.receipt.as_str(), + ] { + assert!( + !qualify.sql.contains(value), + "动态内容只能作为绑定参数出现: {value}" + ); + } + assert!(qualify.params.contains(&text(identity.receipt.as_str()))); + assert!(qualify.params.contains(&text(identity.digest.as_str()))); + + // 解析只读。 + let resolve = resolve_statement(&identity.operation_id); + assert!(resolve.is_read_only()); + assert!(!resolve.sql.contains(identity.operation_id.as_str())); + assert!(resolve + .params + .contains(&text(identity.operation_id.as_str()))); +} + +#[test] +fn operation_identity_and_receipt_do_not_debug_leak() { + let identity = identity("op-3"); + let op_debug = format!("{:?}", identity.operation_id); + let receipt_debug = format!("{:?}", identity.receipt); + assert!(!op_debug.contains(identity.operation_id.as_str())); + assert!(!receipt_debug.contains(identity.receipt.as_str())); + assert_eq!(op_debug, "OperationId()"); + assert_eq!(receipt_debug, "Receipt()"); +} + +#[test] +fn digest_is_stable_opaque_and_input_sensitive() { + let first = input_digest(&["thread-1", "hello"]); + assert_eq!(first, input_digest(&["thread-1", "hello"])); + assert_eq!(first.len(), 64); + assert!(!first.contains("hello") && !first.contains("thread-1")); + assert_ne!(first, input_digest(&["thread-1", "hellp"])); + // 长度前缀:拼接歧义必须得到不同摘要。 + assert_ne!(input_digest(&["ab", "c"]), input_digest(&["a", "bc"])); +} + +#[test] +fn decoding_never_guesses_a_state() { + // applied 行必须同时给出收据与摘要:缺一个就无法判断「这是不是同一次操作」。 + match decode_row(&[text("applied"), text("receipt-1"), text("digest-1")]) { + LedgerRow::Applied { receipt, digest } => { + assert_eq!(receipt.as_str(), "receipt-1"); + assert_eq!(digest, "digest-1"); + } + other => panic!("expected applied row, got {other:?}"), + } + assert_eq!( + decode_row(&[text("closed"), Value::Null, Value::Null]), + LedgerRow::Closed + ); + assert_eq!( + decode_row(&[text("applied"), Value::Null, text("digest-1")]), + LedgerRow::Malformed + ); + assert_eq!( + decode_row(&[text("applied"), text("receipt-1"), Value::Null]), + LedgerRow::Malformed, + "缺摘要的 applied 行无法做一致性校验,按 Unreadable 处理" + ); + assert_eq!( + decode_row(&[text("open"), text("receipt-1"), text("digest-1")]), + LedgerRow::Malformed + ); + assert_eq!( + decode_row(&[Value::Integer(1), text("r"), text("d")]), + LedgerRow::Malformed + ); + assert_eq!(decode_row(&[]), LedgerRow::Malformed); +} diff --git a/peri-resources/src/sessions/remote/mod.rs b/peri-resources/src/sessions/remote/mod.rs new file mode 100644 index 000000000..e2e78072a --- /dev/null +++ b/peri-resources/src/sessions/remote/mod.rs @@ -0,0 +1,221 @@ +//! 远程会话存储(Turso Cloud,over-the-wire)——私有实现,不进入公开 API。 +//! +//! 边界:凭证、端点、SDK 类型、请求预算与失败分类都只在本模块内出现;业务侧只看到 +//! 行为结果(A:事务、CAS、SQL batch、连接与重试令牌不出行为接口)。 +//! +//! ## 引擎与驱动选择(C-01 只读探测,2026-09-26) +//! +//! 在用户确认的测试库上只读执行(`GET /version`、`POST /v2/pipeline` 的只读 `SELECT`): +//! +//! - locator scheme 为 `turso://`,host 属于官方 Turso Cloud 域; +//! - `GET /version` 返回 **404**。该端点在官方文档里是 libSQL/sqld 的版本身份入口, +//! 但 404 **不能单独证明目标库不是 sqld**(服务端可以不暴露该路由或版本不同); +//! 引擎身份的依据是「官方驱动 ↔ 引擎对应关系 + 选定驱动上的 SQL 行为实验」,不是这个端点; +//! - `POST /v2/pipeline` 返回 200 且 `results[0].type = ok`:SQL over HTTP 可用、Bearer 认证通过; +//! - 同一请求内的参数绑定回环成立:text、64 位整数(9007199254740993,超出 f64 精确范围) +//! 与 NULL 均原值返回。 +//! +//! 与官方 Rust Quickstart 的对应关系(Turso 数据库用 `turso_serverless`,libSQL 数据库用 +//! `libsql` 的 remote feature)一致,因此选定 **`turso_serverless` 0.1.3** +//! (2026-09-04 发布;依赖 reqwest 0.13 / tokio 1 / thiserror 2,与工作区既有版本同族)。 +//! +//! ## 本模块当前能证明什么 +//! +//! 已实现(C-02 部分 + §5.1 机制):私有只读连接、参数绑定、失败分类与脱敏; +//! **可变连接**(`mutation::RemoteStore`,请求预算、只读拒绝、确定性三分类); +//! **独立 schema**(`schema`:`peri_store_meta` 单行版本/契约/store 身份,只读检查 + +//! 显式初始化竞争,未知 schema 不覆盖);**内部操作账本**(`ledger`:资格先于效果、 +//! 同唯一键空间的终态封闭、私有操作 id 与收据)。 +//! +//! ## 两种存储模式现在说同一份形状(2026-09-27 统一) +//! +//! 远端不再有自己的会话表:`threads` / `messages` / `session_bindings` / `projects` / +//! `workspaces` 与本机 SQLite 逐列一致,**DDL 与删除语句的唯一来源是 `sessions::canonical`** +//! (逐条建表/建索引清单、`THREAD_CHILD_DELETES`、`DELETE_THREAD_ROW_SQL`、`payload_role`), +//! canonical 历史顺序也统一到 `messages.rowid`。远端只剩执行器自己的机制表(`peri_op_ledger` +//! 幂等账本、`peri_store_meta` 版本标记),版本值与本机 `CURRENT_SCHEMA_VERSION` 同源。 +//! 旧形状的库(`peri_sessions` 那套,或持 `peri.session.store/v1` 契约)一律**拒绝、不迁移**; +//! `projects` / `workspaces` 在远端是**空表**(workspace 证据是本机事实,远端没有来源), +//! 所以写打开会把 `PRAGMA foreign_keys` 归位——引用完整性由显式的父子写入/删除顺序保证。 +//! +//! 已实现(C-03 第一批,`session_data` + `session_read`/`session_write`/`session_sql`/ +//! `session_codec`/`session_schema`):会话表 schema、一致读取(snapshot/meta/binding/ +//! history/flags)、scoped 分页与 children/tree、新建(meta+binding+frozen)、fork、child、 +//! 定向 metadata 更新,以及幂等 schema 补建与真正的连接关闭。 +//! +//! 已实现(C-03 第二批,`session_history` + `session_lifecycle`):追加历史(保序、批内重复 +//! id 与全局主键冲突拒绝)、message projections、compact(flags + 追加 + 重数计数)、rewind +//! (显式两边界,未知边界保持无变更)、精确移除、删除会话树、未发布撤销(有子会话拒绝)、 +//! legacy 接纳(已有值不变)、child resume 认领事实。写入统一走「一次端口调用 = 一个托管 +//! 事务批 + 批内守卫」,0 行受影响不会以成功收场;读取端对「回复不完整」按错误处理(结果集 +//! 或行数不符不会被当成「没有数据」)。 +//! +//! 已撤销(2026-09-27 用户裁决,v10 回退):**本机远端操作日志**(`session_remote_operations` +//! 与 `sqlite_store/remote_operations.rs`:操作 id、发送前的 durable 锚点、终态结清、按本机 +//! 日志向远端账本求证)、**本机登记与接纳链**(`remote/{registration,local_execution}.rs` 与 +//! `HostLocalFacts`:`StoreId → 本机安装身份 → 登记 → binding 复核 → root owner`),以及 +//! **真进程强杀演练**(`cloud_kill_*_test.rs`:死点原先就设在被删除的本机事实端口上)。 +//! +//! 现行语义是**配置即用**:门面按**两个**端口组合(`Arc` + +//! `Arc`),远程组合装配在 [`composition::open_remote`],并由 +//! `Resources::open_deployment` 在远程 locator 上真实接通(见 `context.rs`)。配了哪个 store +//! 就直接用哪个,不再有本机登记、准入裁决与启动探测;本机只留执行事实(workspace 登记、 +//! 执行代际、sidecar 锁),不在 `threads` 里为远程会话造行。没有本机锚点之后 +//! `recover_persistence` 收敛为「会话数据可读即已收敛」,未结清由门面按活跃租约的 +//! `is_uncertain` 判定。 +//! +//! 已实现(C-05 第二批,边界实验):P5/P6/P7 边界实验(`cloud_limit_test.rs`:取消在途调用、 +//! 超大单批、收据保留与空间成本)。**删除不写墓碑**:v10 撤销本机生命周期锚点后, +//! `delete_tree` 在一个托管批里对子树每个节点先清子行(`messages` → `session_bindings`) +//! 再清会话行(`threads`),整棵子树要么全在要么全不在,没有「deleting → deleted」这种 +//! 中间态(见 `session_lifecycle.rs` 的删除文档)。 +//! +//! 消费侧接入(E)已落地:TUI、print、ACP stdio 与 `peri meta session` 都经同一个装配点 +//! `Resources::open_deployment`(见 `context.rs`),真实云端端到端回归见 +//! `cloud_deployment_test.rs`。P6 的「单请求上限先拒绝」分支在实测尺寸内没有触发,上限位置 +//! 未定位(传输面实测的可用下限:2000 条语句 / 4 MiB 单行,结论见母 issue §9.28);本批没有 +//! 分块实现。 +//! +//! 已实现(连接代际与重建):放弃一个**已经发出**的在途请求(取消或 20s 预算超时)之后, +//! 用到它的那一代连接被记为失效(`generation` 的代际守卫在 future 被丢弃的 **drop 点** +//! 同步落下这个事实),会话数据 adapter 在下一次访问时按同一份打开事实重建连接: +//! 重新核实 store 身份(只读检查)、替换槽位、关闭退场连接。失效水位只增不减,旧代际的 +//! 迟到失效不会撤销已记录的新代际失效。关闭开始后不再重连;真实连接保留在关闭句柄中, +//! 失败或取消允许再次关闭同一连接,只有确认成功才成为 `Closed`。 +//! 守卫交出之前还会复核一次这一代是否已被记为失效(判定与取守卫之间没有原子性), +//! 因此一条已经不可证明的连接不会被交给调用方。 +//! +//! **关闭(shutdown)的定义**:① 本机传输面的关闭走完(连接被保留在关闭句柄里直到成功, +//! 失败或取消都可重试、不新建连接);② 门面侧的未结清检查通过——先按活跃租约等待在途写入 +//! 结束(`wait_for_in_flight`,有界),再拒绝仍为 `is_uncertain` 的租约(见 `resources.rs` +//! 的 `close`)。 +//! 两件都成立才算确认关闭。SDK 的 `Connection::close` 恒返回 `Ok(())` 并显式吞掉远端关闭 +//! 错误,因此 ① **不能**证明服务端连接已释放,也**不能**拿它证明任何未知的远端写没有执行。 +//! +//! 重建**不**自动重发任何 mutation:重建只重核实 store 身份(只读检查),不带业务写入; +//! 未决的收敛不由连接重建承担(本机锚点已随 v10 撤销)。`Unsupported` 只剩「只读打开尚未 +//! 初始化的 store」与「不认识的 store schema」两处——都是**拒绝**而不是未实现的行为。 + +// 非测试构建里的未使用项只有两类,都按「同一个交付面」标注:**显式 cloud 探测/回环面** +// (原始连接与参数绑定回环 `connection`、只读资格与终态封闭工具 `mutation::{apply_qualified, +// close_operation,resolve_operation}` 与 `ledger` 的封闭语句、store 身份的只读访问器 +// `endpoint`/`schema`/`generation`、直接注入凭证的构造 `credentials`)与**逐条断言的 SQL +// 片段常量**(`session_sql`/`session_schema`,测试按列校验投影时使用)。生产路径已全部接线 +// (adapter、组合、D 装配),所以这里不是「消费方尚未接入」的临时状态;精确到项的标注会把 +// 同一个交付面的说明打散在八个文件里,收益不清,因此留在这里。新增未使用项必须属于上面 +// 两类之一,否则应删掉(`SessionDataPort::load_flags` 与它的远端读路径就是按这条删的: +// flags 由一致快照读取,独立入口没有消费方)。 +#![cfg_attr(not(test), allow(dead_code))] + +mod composition; +mod connection; +mod credentials; +mod endpoint; +mod failure; +mod generation; +mod ledger; +mod mutation; +mod schema; +mod session_codec; +mod session_data; +mod session_history; +mod session_lifecycle; +mod session_read; +mod session_schema; +mod session_sql; +mod session_write; +mod sql; + +pub(crate) use composition::open_remote; +#[cfg(test)] +pub(crate) use connection::RemoteConnection; +#[cfg(test)] +pub(crate) use credentials::SessionStoreCredential; +pub(crate) use credentials::{CredentialError, CredentialSource}; +pub(crate) use endpoint::{EndpointError, RemoteEndpoint, RemoteEngine}; +#[cfg(test)] +pub(crate) use failure::RemoteFailureClass; + +#[cfg(test)] +#[path = "remote_test.rs"] +mod tests; + +#[cfg(test)] +#[path = "ledger_test.rs"] +mod ledger_tests; + +#[cfg(test)] +#[path = "mutation_test.rs"] +mod mutation_tests; + +#[cfg(test)] +#[path = "schema_test.rs"] +mod schema_tests; + +#[cfg(test)] +#[path = "initialization_test.rs"] +mod initialization_tests; + +#[cfg(test)] +#[path = "session_shape_test.rs"] +mod session_shape_tests; + +#[cfg(test)] +#[path = "session_child_guard_test.rs"] +mod session_child_guard_tests; + +#[cfg(test)] +#[path = "session_close_test.rs"] +mod session_close_tests; + +#[cfg(test)] +#[path = "recovery_fixture_test.rs"] +mod recovery_fixture_tests; + +#[cfg(test)] +#[path = "connection_recovery_test.rs"] +mod connection_recovery_tests; + +#[cfg(test)] +#[path = "connection_close_test.rs"] +mod connection_close_tests; + +#[cfg(test)] +#[path = "cloud_test.rs"] +mod cloud_tests; + +#[cfg(test)] +#[path = "cloud_mutation_test.rs"] +mod cloud_mutation_tests; + +#[cfg(test)] +#[path = "cloud_session_test.rs"] +mod cloud_session_tests; + +#[cfg(test)] +#[path = "cloud_history_test.rs"] +mod cloud_history_tests; + +#[cfg(test)] +#[path = "cloud_lifecycle_test.rs"] +mod cloud_lifecycle_tests; + +#[cfg(test)] +#[path = "cloud_identity_test.rs"] +mod cloud_identity_tests; + +#[cfg(test)] +#[path = "cloud_recovery_test.rs"] +mod cloud_recovery_tests; + +#[cfg(test)] +#[path = "cloud_limit_test.rs"] +mod cloud_limit_tests; + +#[cfg(test)] +#[path = "cloud_deployment_test.rs"] +mod cloud_deployment_tests; + +#[cfg(test)] +#[path = "cloud_deployment_child_test.rs"] +mod cloud_deployment_child_tests; diff --git a/peri-resources/src/sessions/remote/mutation.rs b/peri-resources/src/sessions/remote/mutation.rs new file mode 100644 index 000000000..9940aa89a --- /dev/null +++ b/peri-resources/src/sessions/remote/mutation.rs @@ -0,0 +1,780 @@ +//! 远程可变连接:请求预算、访问模式拒绝、结果确定性与终态封闭(C §5.1/§7)。 +//! +//! 本层是本 crate 里唯一发 mutation 的地方,形状由四条规矩定死: +//! +//! - **只读打开不写**:`StoreAccess::ReadOnly` 下任何写入在发请求前就拒绝。 +//! - **一次 mutation = 一个托管事务批**:资格写入是第一条语句,效果随后,全部在 +//! 同一 HTTP 请求的 `BEGIN IMMEDIATE`/`COMMIT` 里;连接已有打开事务时拒绝执行 +//! (SDK 会让批静默加入该事务,all-or-nothing 就不由我们掌控)。 +//! - **确定性只有三种**:已生效、确定未生效、无法证明。回滚失败、超时、网络失败、 +//! 写忙一律是「无法证明」,绝不折叠成「确定未生效」;未决由终态封闭收敛。 +//! - **预算只约束本机等待**:超时或取消 future 都不代表远端没执行(C §5.1)。 + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use peri_acp_types::session_resources::{ + AccessMode, SessionResourceError, SessionResourceErrorKind, SessionResourceResult, +}; +use peri_acp_types::thread::ThreadId; +use turso_serverless::{Error as SdkError, Value}; + +use super::connection::{ + connect_sdk, within_budget, Budgeted, RemoteTransport, SdkTransport, REQUEST_BUDGET, +}; +use super::credentials::SessionStoreCredential; +use super::endpoint::RemoteEndpoint; +use super::failure::{self, RemoteFailureClass}; +use super::generation::ConnectionGate; +use super::ledger::{self, LedgerRow, OperationId, OperationIdentity, Receipt}; +use super::schema::{self, StoreId, StoreIdentityOutcome, StoreIdentityRead}; +use super::sql::StatementSpec; + +/// 单次 mutation 的时间预算(与只读调用同一上界;精确值由 C-01/F 测量确定)。 +pub(super) const MUTATION_BUDGET: Duration = REQUEST_BUDGET; + +/// 打开意图:只读打开时本层不发任何写入。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum StoreAccess { + ReadWrite, + ReadOnly, +} + +impl StoreAccess { + /// 本次打开的访问意图对应的存储访问模式(两处枚举含义相同,转换是纯函数)。 + pub(super) fn of(access: AccessMode) -> Self { + match access { + AccessMode::ReadWrite => Self::ReadWrite, + AccessMode::ReadOnly => Self::ReadOnly, + } + } + + pub(super) fn ensure_writable(self) -> SessionResourceResult<()> { + match self { + Self::ReadWrite => Ok(()), + Self::ReadOnly => Err(SessionResourceError::new( + SessionResourceErrorKind::ReadOnlyStore, + )), + } + } +} + +/// 一次「资格先于效果」的 mutation 输入:身份 + 效果语句(效果可为空)。 +pub(super) struct QualifiedMutation { + pub(super) identity: OperationIdentity, + pub(super) effects: Vec, +} + +/// 一次 mutation 的结果:只表达确定性,不表达机制。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum MutationOutcome { + /// 本次已提交(`replayed` 为真表示幂等命中已生效的原操作,返回其原收据)。 + Applied { receipt: Receipt, replayed: bool }, + /// 原操作已被封闭:确定从未生效且不可能再生效。 + ClosedNeverApplied, + /// 确定未生效:远端在提交前拒绝,且托管事务批已回滚。 + NotApplied { + class: RemoteFailureClass, + /// 被拒绝的语句序号(0 是资格写);仅诊断用,不含语句内容。 + rejected_statement: Option, + }, + /// 无法证明终态:按未决处理,由终态封闭收敛。 + Unknown { class: RemoteFailureClass }, +} + +impl MutationOutcome { + /// 领域失败映射;`Applied` 返回 `None`。未决一律映射为 `PersistenceUncertain`, + /// 不允许降级成「确定未生效」。 + pub(super) fn failure_error(&self, thread: Option<&ThreadId>) -> Option { + match self { + Self::Applied { .. } => None, + Self::ClosedNeverApplied => Some(SessionResourceError::new( + SessionResourceErrorKind::InvalidInput { + detail: + "remote operation was closed before it applied; it will not take effect" + .to_owned(), + }, + )), + Self::NotApplied { class, .. } => Some(class.into_session_resource_error()), + Self::Unknown { .. } => { + Some(SessionResourceError::persistence_uncertain(thread.cloned())) + } + } + } +} + +/// 终态封闭的结论(C §5.1 私有接口,不外泄)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum OperationResolution { + /// 原操作已生效:返回原收据,不执行第二次。 + Applied { receipt: Receipt }, + /// 封闭胜出:原操作不可能再生效。 + ClosedNeverApplied, + /// 封闭也未确认:保持阻塞,不清理、不放行。 + StillUnknown { class: RemoteFailureClass }, +} + +/// 托管事务批失败的分类。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum BatchFailure { + /// 第一条(资格)语句主键冲突:该 operation 已被占用,需读回原终态。 + QualificationConflict, + /// 确定未生效:SDK 托管批已确认整批回滚。 + NotApplied { + class: RemoteFailureClass, + index: Option, + }, + /// 无法证明:回滚失败、超时、网络、写忙或未知变体。 + Unknown { class: RemoteFailureClass }, +} + +/// 批失败分类。**不**沿用只读路径的折叠规则:`BatchRollbackFailed` 内层即使是约束 +/// 冲突,也说明回滚本身失败,只能判未决。 +pub(super) fn classify_batch_failure(error: &SdkError) -> BatchFailure { + match error { + // 托管事务批:SDK 保证失败即回滚,除非被 BatchRollbackFailed 包住(下面按未决处理)。 + SdkError::BatchStatementFailed { index, error, .. } => { + let class = failure::classify(error); + if *index == 0 && class == RemoteFailureClass::Constraint { + BatchFailure::QualificationConflict + } else { + BatchFailure::NotApplied { + class, + index: Some(*index), + } + } + } + // 回滚失败:连「零部分结果」都不成立,类别本身也无从确定。 + SdkError::BatchRollbackFailed { .. } => BatchFailure::Unknown { + class: RemoteFailureClass::Unknown, + }, + _ => { + let class = failure::classify(error); + if rejected_before_commit(error) { + BatchFailure::NotApplied { class, index: None } + } else { + BatchFailure::Unknown { class } + } + } + } +} + +/// 初始化批的结果读法:本次初始化对身份做了什么。 +/// +/// 三种读法都由**本事务的结果**决定,与「打开前看到过什么」无关;真引擎 seam 与生产 +/// 初始化共用这一处判定(见 `initialization_test.rs`)。`Failed` 不携带身份:结果未知时 +/// 既没有可发的身份,也没有可发的事实。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum InitializationEvidence { + /// 批已提交且元数据 INSERT 确切插入一行:本次建立身份(胜者)。 + Created, + /// 本次没有建立身份:唯一键冲突(竞败/库已初始化),或批成功却没有插入证据。 + /// 结论到此为止,既有身份必须**读回**,不能由本次的铸造值冒充。 + Existing, + /// 确定未生效或无法证明(含丢响应、超时、回滚失败):不发创建事实,也不发身份。 + Failed(RemoteFailureClass), +} + +/// 初始化批的结果 → [`InitializationEvidence`](纯函数)。 +pub(super) fn initialization_evidence( + result: &Result, BatchFailure>, +) -> InitializationEvidence { + match result { + Ok(counts) if schema::inserted_meta_row(counts) => InitializationEvidence::Created, + // 批成功却没有插入证据:不认领创建,按既有身份读回判定(读不回来即失败)。 + Ok(_) => InitializationEvidence::Existing, + // 身份已被占用:元数据行主键冲突(建表在前,所以下标不是 0),读回胜者。 + Err(BatchFailure::QualificationConflict) + | Err(BatchFailure::NotApplied { + class: RemoteFailureClass::Constraint, + index: Some(schema::META_INSERT_INDEX), + }) => InitializationEvidence::Existing, + Err(BatchFailure::NotApplied { class, .. }) | Err(BatchFailure::Unknown { class }) => { + InitializationEvidence::Failed(*class) + } + } +} + +/// 只读读回的结果 → 既有身份(纯函数):认识不了、形状不可解释或仍为空都拒绝,绝不覆盖。 +pub(super) fn existing_identity_from_read( + read: StoreIdentityRead, +) -> SessionResourceResult { + match read { + StoreIdentityRead::Present(snapshot) if snapshot.matches_build() => Ok(snapshot.store_id), + StoreIdentityRead::Present(_) => Err(SessionResourceError::new( + SessionResourceErrorKind::Unsupported, + )), + StoreIdentityRead::Malformed | StoreIdentityRead::Uninitialized => { + Err(unreadable_metadata()) + } + } +} + +/// 没有托管包装时,只有「提交前被拒绝」的类别才可判确定未生效。 +fn rejected_before_commit(error: &SdkError) -> bool { + matches!( + error, + SdkError::ToSqlConversionFailure(_) + | SdkError::ConversionFailure(_) + | SdkError::Misuse(_) + | SdkError::Constraint(_) + | SdkError::Readonly(_) + | SdkError::QueryReturnedNoRows + ) +} + +/// 故障注入(仅测试构建):把「网络把结果吞掉」这类不可控故障变成可控的观察点。 +/// +/// 生产构建里没有这个类型,也没有它的字段——故障面只在测试里存在。 +#[cfg(test)] +#[derive(Clone, Debug, Default)] +pub(super) struct FaultPlan { + /// 命中 `kind` 的下一次批**照常提交**,但把结果按未决上报:服务端已提交、本机没收到 + /// 响应。恢复必须把它判成「已生效」。 + pub(super) drop_reply: Option, + /// 命中 `kind` 的下一次批**发出前**被丢弃:本机看到未决,远端什么也没发生。恢复必须 + /// 用同一唯一键把它封闭成「从未生效」。 + pub(super) drop_before_send: Option, +} + +/// 一条已确认的远程可变连接;不暴露底层 SDK 类型。 +/// +/// 一次在途调用被丢弃(超时、取消)之后,这条连接的状态无法证明:本类型把它记在 +/// [`ConnectionGate`] 的**代际**上,由会话数据 adapter 在下次访问时重建(见 +/// `RemoteSessionData::store`)。每一次传输调用都带一个代际守卫,守卫随被丢弃的 future +/// 同步落下失效事实——不依赖异步收尾。 +pub(super) struct RemoteStore { + transport: Arc, + access: StoreAccess, + /// 本连接的代际号(由 [`ConnectionGate`] 铸造);失效与重建都按它记账。 + generation: u64, + gate: Arc, + #[cfg(test)] + faults: std::sync::Mutex, +} + +impl RemoteStore { + /// 装配一条连接:传输面、访问意图、代际号与门禁都是既有事实。 + pub(super) fn new( + transport: Arc, + access: StoreAccess, + generation: u64, + gate: Arc, + ) -> Self { + Self { + transport, + access, + generation, + gate, + #[cfg(test)] + faults: std::sync::Mutex::new(FaultPlan::default()), + } + } + + /// 直接开一条独立连接(机制实测用:自带门禁,不挂在会话数据 adapter 上)。 + /// + /// 引擎/URL 已由 [`RemoteEndpoint`] 定死,凭证只在此处进入 SDK 调用。 + pub(super) async fn connect( + endpoint: &RemoteEndpoint, + credential: &SessionStoreCredential, + access: StoreAccess, + ) -> Result { + let connection = connect_sdk(endpoint, credential).await?; + let gate = Arc::new(ConnectionGate::default()); + Ok(Self::new( + Arc::new(SdkTransport::new(connection)), + access, + gate.mint(), + gate, + )) + } + + /// 本连接的代际号。 + pub(super) fn generation(&self) -> u64 { + self.generation + } + + /// 有预算、有代际守卫的一次传输调用。 + /// + /// 守卫随 future 一起被丢弃时(调用方超时、外层任务取消)**同步**把本代际记为失效; + /// 传输类失败同样如此——那时远端流的状态无法证明。远端明确的拒绝(约束、只读、缺行…) + /// 是确定回答,本代际保留。 + async fn guarded( + &self, + call: impl Future>, + ) -> Budgeted { + let lease = self.gate.lease(self.generation); + within_budget( + async move { + let mut lease = lease; + let outcome = call.await; + match &outcome { + Ok(_) => lease.release(), + Err(error) if !failure::invalidates_connection(error) => lease.release(), + // 连接层失败:这一代从此不可再用。 + Err(_) => lease.invalidate(), + } + outcome + }, + MUTATION_BUDGET, + ) + .await + } + + /// 不受代际守卫约束的传输调用(退场路径:连接本来就不再用)。 + async fn budgeted( + &self, + call: impl Future>, + ) -> Budgeted { + within_budget(call, MUTATION_BUDGET).await + } + + /// 装载故障计划(仅测试构建)。 + #[cfg(test)] + pub(super) fn inject_faults(&self, plan: FaultPlan) { + if let Ok(mut faults) = self.faults.lock() { + *faults = plan; + } + } + + /// 取出命中 `kind` 的一次性故障(仅测试构建)。 + #[cfg(test)] + fn take_drop_reply(&self, kind: &str) -> bool { + let Ok(mut faults) = self.faults.lock() else { + return false; + }; + if faults.drop_reply.as_deref() == Some(kind) { + faults.drop_reply = None; + true + } else { + false + } + } + + /// 同上,作用于「发出前丢弃」这一档(仅测试构建)。 + #[cfg(test)] + fn take_drop_before_send(&self, kind: &str) -> bool { + let Ok(mut faults) = self.faults.lock() else { + return false; + }; + if faults.drop_before_send.as_deref() == Some(kind) { + faults.drop_before_send = None; + true + } else { + false + } + } + + /// 只读身份读取:不建表、不写任何行。 + pub(super) async fn read_identity(&self) -> SessionResourceResult { + let plan = schema::identity_read_plan(); + let table_exists = self.fetch_row(&plan[0]).await?.is_some(); + let row = if table_exists { + self.fetch_row(&plan[1]).await? + } else { + None + }; + Ok(schema::interpret_identity_read( + table_exists, + row.as_deref(), + )) + } + + /// 显式初始化本任务 schema 并参与身份竞争,返回**竞争结论**而不是一个身份值。 + /// + /// 竞争结论是结构化事实([`StoreIdentityOutcome`]):只有本事务确切插入元数据行并提交 + /// 才是 `Created`——批成功但没有插入证据、唯一键冲突后读回胜者、结果未知(超时、丢响应) + /// 都不是创建、也都不发创建事实。已存在且形状/版本可解释时读回既有身份(不覆盖、不重造); + /// 不可解释时拒绝。调用方不得从「打开前读到空库」自行推定创建。 + pub(super) async fn initialize_store(&self) -> SessionResourceResult { + self.access.ensure_writable()?; + self.ensure_autocommit()?; + let store_id = StoreId::mint(); + let result = self + .run_managed_batch(schema::initialization_plan(&store_id, &now_stamp())) + .await; + match initialization_evidence(&result) { + InitializationEvidence::Created => Ok(StoreIdentityOutcome::Created(store_id)), + // 没有建立身份(竞败/库已初始化/批成功却无插入证据):既有身份必须读回。 + InitializationEvidence::Existing => Ok(StoreIdentityOutcome::Existing( + self.read_existing_identity().await?, + )), + // 确定未生效或无法证明:不发身份,也不发创建事实。 + InitializationEvidence::Failed(class) => Err(class.into_session_resource_error()), + } + } + + /// 读回既有身份;不认识或读不出来时拒绝,绝不覆盖。 + async fn read_existing_identity(&self) -> SessionResourceResult { + existing_identity_from_read(self.read_identity().await?) + } + + /// 资格先于效果的一次原子 mutation。 + pub(super) async fn apply_qualified( + &self, + mutation: &QualifiedMutation, + ) -> SessionResourceResult { + Ok(self.apply_qualified_reporting(mutation).await?.0) + } + + /// 同上,并读回语句级证据:每条效果语句的受影响行数,与传入顺序对齐。 + /// + /// 证据只在「本次确认提交」时存在——未提交的批没有可报告的写入数,因此失败分类下 + /// 一律给空证据,不拿上一次的结果或 0 冒充。 + pub(super) async fn apply_qualified_reporting( + &self, + mutation: &QualifiedMutation, + ) -> SessionResourceResult<(MutationOutcome, Vec)> { + self.access.ensure_writable()?; + self.ensure_autocommit()?; + #[cfg(test)] + if self.take_drop_before_send(&mutation.identity.kind) { + // 等价物:请求在发出之前消失(取消、发送前崩溃)。远端什么都没发生, + // 但调用方无法据此断言——这正是「未知」。 + return Ok(( + MutationOutcome::Unknown { + class: RemoteFailureClass::Transport, + }, + Vec::new(), + )); + } + let now = now_stamp(); + let mut statements = Vec::with_capacity(mutation.effects.len() + 1); + statements.push(ledger::qualify_statement(&mutation.identity, &now)); + statements.extend(mutation.effects.iter().cloned()); + Ok(match self.run_managed_batch(statements).await { + // 第一条是资格写入,不进效果证据。 + Ok(counts) => { + #[cfg(test)] + if self.take_drop_reply(&mutation.identity.kind) { + // 等价物:批真的提交了,响应在回程丢失。本机只能看见「未知」, + // 效果与账本行都是真的——恢复必须判成「已生效」。 + return Ok(( + MutationOutcome::Unknown { + class: RemoteFailureClass::Transport, + }, + Vec::new(), + )); + } + ( + MutationOutcome::Applied { + receipt: mutation.identity.receipt.clone(), + replayed: false, + }, + counts.into_iter().skip(1).collect(), + ) + } + Err(BatchFailure::QualificationConflict) => ( + self.resolve_after_conflict(&mutation.identity).await, + Vec::new(), + ), + Err(BatchFailure::NotApplied { class, index }) => ( + MutationOutcome::NotApplied { + class, + rejected_statement: index, + }, + Vec::new(), + ), + Err(BatchFailure::Unknown { class }) => { + (MutationOutcome::Unknown { class }, Vec::new()) + } + }) + } + + /// 终态封闭:证明原操作从未生效(或返回其原收据)。 + /// + /// 封闭失败(被拒绝、超时、写忙、回滚失败)都**不**构成「确定未生效」:原操作的 + /// 命运仍然未知,只能保持阻塞。 + pub(super) async fn close_operation( + &self, + identity: &OperationIdentity, + ) -> SessionResourceResult { + self.access.ensure_writable()?; + self.ensure_autocommit()?; + let closure = ledger::closure_statement(identity, &now_stamp()); + Ok(match self.run_managed_batch(vec![closure]).await { + Ok(_) => OperationResolution::ClosedNeverApplied, + Err(BatchFailure::QualificationConflict) => { + match self.read_ledger_row(&identity.operation_id).await { + Ok(LedgerRow::Applied { receipt, .. }) => { + OperationResolution::Applied { receipt } + } + Ok(LedgerRow::Closed) => OperationResolution::ClosedNeverApplied, + Ok(LedgerRow::Absent) | Ok(LedgerRow::Malformed) | Err(_) => { + OperationResolution::StillUnknown { + class: RemoteFailureClass::Unknown, + } + } + } + } + Err(BatchFailure::NotApplied { class, .. }) | Err(BatchFailure::Unknown { class }) => { + OperationResolution::StillUnknown { class } + } + }) + } + + /// 把这条连接的**父行检查**归位(`PRAGMA foreign_keys = OFF`)。 + /// + /// 为什么远端必须显式归位:canonical DDL 在 `session_bindings` 上声明了指向 + /// `workspaces(id, project_id)` 的复合外键,而远端**不写** `projects` / `workspaces` 行—— + /// 那是本机 workspace 证据(目录与 Git 对象身份),远端没有来源,也不得伪造。父行不在, + /// 外键就无从满足。服务端的 `PRAGMA foreign_keys` 是**跨连接共享的可变状态**(实测默认为 0, + /// 但别的连接可以打开它),所以每次写打开都归位一次,而不是假设它一直是 0。 + /// + /// 归位之后的引用完整性由数据面保证:父行先写、删除时显式先清子行 + /// ([`crate::sessions::canonical::THREAD_CHILD_DELETES`]),远端引擎本来也提供不了级联。 + /// + /// 只发一条**不在事务里**的语句(PRAGMA 在事务内不生效);失败按原分类上报。 + pub(super) async fn force_parent_checks_off(&self) -> SessionResourceResult<()> { + self.transport + .sql_values(&StatementSpec::bare("PRAGMA foreign_keys = OFF")) + .await + .map_err(|error| failure::classify(&error).into_session_resource_error())?; + Ok(()) + } + + /// 只读终态解析:不尝试写入,不推断。 + pub(super) async fn resolve_operation( + &self, + operation_id: &OperationId, + ) -> SessionResourceResult { + self.read_ledger_row(operation_id).await + } + + /// 关闭连接:失败按分类上报,不静默吞掉。 + /// + /// **借用**而不是消费:调用方(adapter 的关闭句柄)在确认关闭成功之前必须一直保留这条 + /// 连接本身,因此关闭失败或被取消之后可以在**同一条连接**上重试。不走代际守卫: + /// 这条连接无论关闭成功与否都不再服务业务读写。 + pub(super) async fn close(&self) -> SessionResourceResult<()> { + match self.budgeted(self.transport.close()).await { + Budgeted::Done(()) => Ok(()), + Budgeted::Failed(error) => Err(failure::classify(&error).into_session_resource_error()), + Budgeted::Exceeded => Err(RemoteFailureClass::Timeout.into_session_resource_error()), + } + } + + /// 资格被占用后读回原终态;读不回来就是未决,不猜。 + /// + /// 摘要必须与本次身份一致:同一个 id 却挂着不同的输入摘要,说明有人在复用 id 换了 + /// 内容,这不是重放。此时不得返回原收据(那等于把别人的效果认成自己的),也不得 + /// 声明未决(我们的批确实被拒绝了):本次提交确定未生效,按约束拒绝上报。 + async fn resolve_after_conflict(&self, identity: &OperationIdentity) -> MutationOutcome { + match self.read_ledger_row(&identity.operation_id).await { + Ok(LedgerRow::Applied { receipt, digest }) if digest == identity.digest => { + MutationOutcome::Applied { + receipt, + replayed: true, + } + } + Ok(LedgerRow::Applied { .. }) => MutationOutcome::NotApplied { + class: RemoteFailureClass::Constraint, + rejected_statement: Some(0), + }, + Ok(LedgerRow::Closed) => MutationOutcome::ClosedNeverApplied, + Ok(LedgerRow::Absent) | Ok(LedgerRow::Malformed) | Err(_) => MutationOutcome::Unknown { + class: RemoteFailureClass::Unknown, + }, + } + } + + async fn read_ledger_row( + &self, + operation_id: &OperationId, + ) -> SessionResourceResult { + Ok( + match self + .fetch_row(&ledger::resolve_statement(operation_id)) + .await? + { + Some(values) => ledger::decode_row(&values), + None => LedgerRow::Absent, + }, + ) + } + + /// 幂等 schema 批(建表/建索引):一个托管事务批,失败整批回滚。 + /// + /// 与业务 mutation 的区别是**没有账本资格写**:DDL 没有「同一操作重试」的语义, + /// 全部语句都是 `IF NOT EXISTS`,重复执行不改变结果。因此它也不产生收据。 + /// 失败一律按原分类上报:DDL 未提交即未生效,不做未决判定(没有业务效果可证明)。 + pub(super) async fn apply_schema( + &self, + statements: Vec, + ) -> SessionResourceResult<()> { + self.access.ensure_writable()?; + self.ensure_autocommit()?; + match self.run_managed_batch(statements).await { + Ok(_) => Ok(()), + // 批内没有资格写,主键冲突只可能来自并发建同名对象;如实按约束失败上报。 + Err(BatchFailure::QualificationConflict) => { + Err(RemoteFailureClass::Constraint.into_session_resource_error()) + } + Err(BatchFailure::NotApplied { class, .. }) | Err(BatchFailure::Unknown { class }) => { + Err(class.into_session_resource_error()) + } + } + } + + /// 只读一致读:同一请求内的 `BEGIN DEFERRED` … `COMMIT`,多段读取落在同一快照上。 + /// + /// 只读打开([`StoreAccess::ReadOnly`])也可用:批内只有 SELECT,不申请写锁。 + /// 读没有副作用,因此不套用 mutation 的确定性三分类——失败只表示「这次没读到」。 + pub(super) async fn read_batch( + &self, + statements: Vec, + ) -> SessionResourceResult>>> { + self.ensure_autocommit()?; + let expected = statements.len(); + match self + .guarded(self.transport.consistent_read(statements)) + .await + { + // 回复少了结果集时,调用方绝不能把它读成「这段查询没有数据」,所以在唯一的 + // 读取出口处校验一次形状,三条语句与四条语句的读取都走这条检查。 + Budgeted::Done(results) => { + ensure_result_sets(expected, results.len())?; + Ok(results) + } + Budgeted::Failed(error) => Err(read_batch_error(&error)), + Budgeted::Exceeded => Err(RemoteFailureClass::Timeout.into_session_resource_error()), + } + } + + /// 恰好两段结果的一致读取(例如「会话事实行 + 自有 payload 行集」)。 + /// + /// [`Self::read_batch`] 已经校验过结果集数量;这里仍然显式取两次而不是用默认值兜底: + /// 拿不到就不返回快照,避免把不完整的回复降级成「没有历史」。 + pub(super) async fn read_pair( + &self, + facts: StatementSpec, + rows: StatementSpec, + ) -> SessionResourceResult<(Vec>, Vec>)> { + let mut batches = self.read_batch(vec![facts, rows]).await?.into_iter(); + let facts = batches + .next() + .ok_or_else(|| incomplete_reply("expected 2 result sets, got 1"))?; + let rows = batches + .next() + .ok_or_else(|| incomplete_reply("expected 2 result sets, got 1"))?; + Ok((facts, rows)) + } + + /// 一个托管事务批:`BEGIN IMMEDIATE` … `COMMIT`,失败整批回滚。 + /// + /// 返回每条语句的受影响行数(与传入顺序对齐),供上层核对「恰好一行」这类后置条件。 + /// 语句构造失败与远端拒绝走同一分类(都是 `Failed`),不因为失败发生在本地就降级成 + /// 「确定未生效」。 + async fn run_managed_batch( + &self, + statements: Vec, + ) -> Result, BatchFailure> { + match self.guarded(self.transport.managed_batch(statements)).await { + Budgeted::Done(counts) => Ok(counts), + Budgeted::Failed(error) => Err(classify_batch_failure(&error)), + Budgeted::Exceeded => Err(BatchFailure::Unknown { + class: RemoteFailureClass::Timeout, + }), + } + } + + /// 只读单行:只取第一行,行内所有列原样取出(调用方决定怎么解释)。 + pub(super) async fn fetch_row( + &self, + spec: &StatementSpec, + ) -> SessionResourceResult>> { + Ok(self.rows(spec).await?.into_iter().next()) + } + + /// 只读多行:按语句的 `ORDER BY` 顺序返回。 + pub(super) async fn fetch_rows( + &self, + spec: &StatementSpec, + ) -> SessionResourceResult>> { + self.rows(spec).await + } + + /// 单条语句的全部行(值矩阵在传输边界已经解码)。 + async fn rows(&self, spec: &StatementSpec) -> SessionResourceResult>> { + match self.guarded(self.transport.sql_values(spec)).await { + Budgeted::Done(rows) => Ok(rows), + Budgeted::Failed(error) => Err(failure::classify(&error).into_session_resource_error()), + Budgeted::Exceeded => Err(RemoteFailureClass::Timeout.into_session_resource_error()), + } + } + + /// 托管事务批在「连接已有打开事务」时会静默加入该事务(SDK `run_batch` 的 wrap 判定), + /// 那时 all-or-nothing 不由我们掌控,因此显式拒绝而不是顺手执行。 + fn ensure_autocommit(&self) -> SessionResourceResult<()> { + match self.transport.is_autocommit() { + Ok(true) => Ok(()), + Ok(false) => Err(internal( + "remote mutation refused: connection has an open transaction", + )), + Err(_) => Err(internal( + "remote mutation refused: transaction state is unreadable", + )), + } + } +} + +/// 时间戳只作诊断(记录空间与封闭时间),不作任何判据。 +fn now_stamp() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// 只读批失败 → 领域失败:直接按分类上报(读没有副作用,不存在「不确定生效」)。 +fn read_batch_error(error: &SdkError) -> SessionResourceError { + match classify_batch_failure(error) { + BatchFailure::NotApplied { class, .. } | BatchFailure::Unknown { class } => { + class.into_session_resource_error() + } + BatchFailure::QualificationConflict => { + RemoteFailureClass::Constraint.into_session_resource_error() + } + } +} + +/// 回复形状不符:结果集少了、或单行查询多给了行。 +/// +/// 分类是 `Internal`,理由是这三个「不是」:不是存储数据损坏(`Corrupt` 说的是记录本身读不 +/// 出来)、不是没有这一行(用 `NotFound` 会把不完整回复伪装成缺失、把「读不到」变成 +/// 「不存在」)、也不是能力缺失(`Unsupported`)。`detail` 只写条数,不带会话内容或绑定值。 +pub(super) fn incomplete_reply(detail: &str) -> SessionResourceError { + internal(&format!("read reply is incomplete: {detail}")) +} + +/// 请求了几条语句就必须拿到几段结果。 +pub(super) fn ensure_result_sets(expected: usize, actual: usize) -> SessionResourceResult<()> { + if expected == actual { + Ok(()) + } else { + Err(incomplete_reply(&format!( + "expected {expected} result set(s), got {actual}" + ))) + } +} + +/// 至多一行(主键查询)的结果:多给一行同样是无法解释的回复,不静默取其中一行。 +pub(super) fn sole_row(mut rows: Vec>) -> SessionResourceResult>> { + match rows.len() { + 0 => Ok(None), + 1 => Ok(rows.pop()), + actual => Err(incomplete_reply(&format!( + "single-row read returned {actual} rows" + ))), + } +} + +fn internal(detail: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Internal { + detail: detail.to_owned(), + }) +} + +fn unreadable_metadata() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Corrupt { + detail: "remote session store metadata is not interpretable".to_owned(), + }) +} diff --git a/peri-resources/src/sessions/remote/mutation_test.rs b/peri-resources/src/sessions/remote/mutation_test.rs new file mode 100644 index 000000000..90e97e491 --- /dev/null +++ b/peri-resources/src/sessions/remote/mutation_test.rs @@ -0,0 +1,200 @@ +//! `mutation` 的离线测试:确定性分类、只读拒绝与未决映射。全部不联网。 + +use peri_acp_types::session_resources::{ + MutationOutcome as DomainOutcome, SessionResourceErrorKind, +}; +use peri_acp_types::thread::ThreadId; +use turso_serverless::Error as SdkError; +use turso_serverless::Value; + +use super::failure::RemoteFailureClass; +use super::mutation::{ + classify_batch_failure, ensure_result_sets, sole_row, BatchFailure, MutationOutcome, + StoreAccess, +}; + +fn managed_failure(index: usize, error: SdkError) -> SdkError { + SdkError::BatchStatementFailed { + index, + error: Box::new(error), + results: Vec::new(), + } +} + +fn rollback_failure(inner: SdkError) -> SdkError { + SdkError::BatchRollbackFailed { + error: Box::new(inner), + rollback_error: Box::new(SdkError::Http("transport".to_owned())), + } +} + +#[test] +fn read_only_access_refuses_before_any_request() { + let refuse = StoreAccess::ReadOnly.ensure_writable().unwrap_err(); + assert!(matches!( + refuse.kind(), + SessionResourceErrorKind::ReadOnlyStore + )); + assert_eq!(refuse.effect(), DomainOutcome::NotApplied); + assert!(StoreAccess::ReadWrite.ensure_writable().is_ok()); +} + +#[test] +fn managed_batch_failure_is_determinate_not_applied() { + let failure = classify_batch_failure(&managed_failure( + 2, + SdkError::Constraint("unique".to_owned()), + )); + assert_eq!( + failure, + BatchFailure::NotApplied { + class: RemoteFailureClass::Constraint, + index: Some(2), + } + ); + + // 资格写(第 0 条)自身失败:整批同样回滚,且能指出是哪条被拒。 + let qualification = classify_batch_failure(&managed_failure( + 0, + SdkError::Error("no such table".to_owned()), + )); + assert_eq!( + qualification, + BatchFailure::NotApplied { + class: RemoteFailureClass::ServerError, + index: Some(0), + } + ); +} + +#[test] +fn qualification_conflict_is_only_recognized_on_the_first_statement() { + let conflict = classify_batch_failure(&managed_failure( + 0, + SdkError::Constraint("primary key".to_owned()), + )); + assert_eq!(conflict, BatchFailure::QualificationConflict); + + // 效果语句上的唯一键冲突是业务冲突,不是资格占用。 + let effect = classify_batch_failure(&managed_failure( + 1, + SdkError::Constraint("primary key".to_owned()), + )); + assert_eq!( + effect, + BatchFailure::NotApplied { + class: RemoteFailureClass::Constraint, + index: Some(1), + } + ); +} + +#[test] +fn rollback_failure_is_never_reported_as_not_applied() { + let failure = classify_batch_failure(&rollback_failure(managed_failure( + 0, + SdkError::Constraint("primary key".to_owned()), + ))); + assert_eq!( + failure, + BatchFailure::Unknown { + class: RemoteFailureClass::Unknown, + } + ); +} + +#[test] +fn transport_busy_and_timeout_stay_unresolved() { + for error in [ + SdkError::Http("connection reset".to_owned()), + SdkError::Busy("locked".to_owned()), + SdkError::BusySnapshot("snapshot".to_owned()), + SdkError::Interrupt("interrupted".to_owned()), + ] { + match classify_batch_failure(&error) { + BatchFailure::Unknown { .. } => {} + other => panic!("expected unresolved for {error:?}, got {other:?}"), + } + } +} + +#[test] +fn client_side_rejection_is_not_applied() { + let failure = classify_batch_failure(&SdkError::ToSqlConversionFailure(Box::new( + std::io::Error::other("bad param"), + ))); + assert_eq!( + failure, + BatchFailure::NotApplied { + class: RemoteFailureClass::Unsupported, + index: None, + } + ); +} + +#[test] +fn unresolved_mutation_maps_to_persistence_uncertain() { + let thread = ThreadId::from("s-uncertain"); + let error = MutationOutcome::Unknown { + class: RemoteFailureClass::Transport, + } + .failure_error(Some(&thread)) + .expect("unknown must surface as an error"); + match error.kind() { + SessionResourceErrorKind::PersistenceUncertain { thread_id } => { + assert_eq!(thread_id.as_ref(), Some(&thread)); + } + other => panic!("unresolved mutation must be uncertain, got {other:?}"), + } + assert_eq!(error.effect(), DomainOutcome::Unknown); + + // 确定未生效与封闭都不得升级成未决。 + for outcome in [ + MutationOutcome::NotApplied { + class: RemoteFailureClass::Constraint, + rejected_statement: Some(1), + }, + MutationOutcome::ClosedNeverApplied, + ] { + let error = outcome.failure_error(Some(&thread)).expect("not applied"); + assert_eq!(error.effect(), DomainOutcome::NotApplied); + } +} + +/// 回复少了结果集是**错误**,不是「这段查询没有数据」:分类必须是 `Internal`(既不是 +/// `NotFound` 那种「确实没有这一行」,也不是 `Corrupt` 那种「记录读不出来」)。 +#[test] +fn missing_result_set_is_an_error_not_absence() { + assert!(ensure_result_sets(2, 2).is_ok()); + for (expected, actual) in [(2usize, 1usize), (1, 0), (1, 2)] { + let error = ensure_result_sets(expected, actual).unwrap_err(); + match error.kind() { + SessionResourceErrorKind::Internal { detail } => { + assert!(detail.contains(&expected.to_string())); + assert!(detail.contains(&actual.to_string())); + } + other => panic!("incomplete reply must be Internal, got {other:?}"), + } + assert!(!matches!( + error.kind(), + SessionResourceErrorKind::NotFound + | SessionResourceErrorKind::Corrupt { .. } + | SessionResourceErrorKind::Unsupported + )); + } +} + +/// 主键查询至多一行:多给一行同样是无法解释的回复,不静默取其中一行。 +#[test] +fn single_row_reads_do_not_pick_a_row_from_many() { + assert!(sole_row(Vec::new()).unwrap().is_none()); + + let one = sole_row(vec![vec![Value::Integer(7)]]).unwrap(); + assert_eq!(one, Some(vec![Value::Integer(7)])); + + let error = sole_row(vec![vec![Value::Integer(1)], vec![Value::Integer(2)]]).unwrap_err(); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Internal { .. } + )); +} diff --git a/peri-resources/src/sessions/remote/recovery_fixture_test.rs b/peri-resources/src/sessions/remote/recovery_fixture_test.rs new file mode 100644 index 000000000..ecff2661c --- /dev/null +++ b/peri-resources/src/sessions/remote/recovery_fixture_test.rs @@ -0,0 +1,474 @@ +//! 连接重建/关闭测试共用的**可控假远端**夹具(离线,不联网)。 +//! +//! 这里只有装配与观察点,没有断言。假传输与生产传输在同一个 trait 上([`RemoteTransport`]), +//! 重建走同一条 `ConnectionFactory` 路径,因此两个测试模块 +//! (`connection_recovery_test.rs` 重建、`connection_close_test.rs` 关闭)断言的是生产判定, +//! 不是另一份实现。 +//! +//! 观察点分三类:服务端事实(身份、账本、已提交的批、收到的请求)、故障档位(挂起下一次 +//! 读取/批、关闭失败或挂起、把某一代记为失效)、以及每次真实关闭尝试的连接号记录。 + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use peri_acp_types::session_resources::SessionResourceResult; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::thread::ThreadId; +use turso_serverless::{Error as SdkError, Value}; + +use super::connection::RemoteTransport; +use super::generation::{ConnectionFactory, ConnectionGate}; +use super::ledger::{self, OperationId, OperationIdentity}; +use super::mutation::{RemoteStore, StoreAccess}; +use super::schema::{self, StoreId, REMOTE_SCHEMA_VERSION, STORE_CONTRACT}; +use super::session_data::RemoteSessionData; +use super::session_sql; +use super::sql::{text_at, StatementSpec}; +use crate::sessions::data::SessionDataPort; + +pub(super) const ABANDON_AFTER: Duration = Duration::from_millis(50); + +// ─── 假远端事实 ─────────────────────────────────────────────────────────────── + +/// 挂起档位:读,或批(`committed` = 批已在远端提交、响应在回程丢失)。 +#[derive(Clone, Copy)] +pub(super) enum Hang { + Read, + Batch { committed: bool }, +} + +/// 跨连接共享的「服务端」事实:脚本、账本与请求日志。 +#[derive(Default)] +pub(super) struct FakeBackend { + /// store 身份(重建时重核实用)。 + store_id: Mutex>, + /// 会话事实行在不在(决定「正确回答」是 `NotFound` 还是那个会话的分类)。 + pub(super) session_row: AtomicBool, + /// 下一次命中档位的调用永远挂起。 + hang: Mutex>, + /// 已经建立过多少条连接。 + pub(super) connections: AtomicU64, + /// 假账本:operation id → (state, receipt, digest)。 + ledger: Mutex>, + /// 服务端真正提交了的托管批(含效果语句数)。 + executed: Mutex>, + /// 服务端收到的请求(连接号 + SQL + 参数)。 + issued: Mutex>, + /// 关闭是否失败(「关闭失败后不复活」的反例开关)。 + pub(super) fail_close: AtomicBool, + /// 下一次传输读取顺手把这一代记为失效(「检查与取守卫之间又落下失效事实」的可控复现)。 + retire_next_generation: Mutex>, + /// 本夹具的代际门禁(装配时挂上:失效档位必须走真实门禁,不另造一份判定)。 + gate: Mutex>>, + /// 在途关闭是否挂起(「关闭被取消」的反例开关):挂起时只有测试放行才返回。 + pub(super) hold_close: AtomicBool, + /// 放行挂起的在途关闭。 + pub(super) close_release: tokio::sync::Notify, + /// 每一次真实关闭尝试的连接号(按调用顺序):「重试关的是同一条连接」的证据。 + close_attempts: Mutex>, + /// 真实关闭**成功**的次数。 + close_successes: AtomicU64, +} + +/// 假账本的一行:状态、收据与输入摘要。 +#[derive(Clone)] +struct FakeLedgerRow { + state: String, + receipt: Option, + digest: String, +} + +struct Executed { + effects: usize, +} + +struct Issued { + connection: u64, + sql: &'static str, + params: Vec, +} + +impl FakeBackend { + fn new(store_id: &str) -> Self { + Self { + store_id: Mutex::new(Some(store_id.to_owned())), + ..Self::default() + } + } + + /// 挂上本夹具的代际门禁(装配时一次)。 + fn attach_gate(&self, gate: &Arc) { + *self.gate.lock().unwrap() = Some(Arc::clone(gate)); + } + + /// 让**下一次**传输读取顺手把指定代际记为失效。 + pub(super) fn retire_generation_on_next_read(&self, generation: u64) { + *self.retire_next_generation.lock().unwrap() = Some(generation); + } + + /// 取出并落下这次失效(没有档位时什么也不做)。 + fn retire_queued_generation(&self) { + let Some(generation) = self.retire_next_generation.lock().unwrap().take() else { + return; + }; + let gate = { + let attached = self.gate.lock().unwrap(); + Arc::clone(attached.as_ref().expect("fixture gate is attached")) + }; + // 丢弃一次守卫=落下一次失效事实(与在途调用被放弃同一路径)。 + drop(gate.lease(generation)); + } + + pub(super) fn hang_next(&self, hang: Hang) { + *self.hang.lock().unwrap() = Some(hang); + } + + fn take_read_hang(&self) -> Option { + self.take_hang(|hang| matches!(hang, Hang::Read)) + } + + fn take_batch_hang(&self) -> Option { + self.take_hang(|hang| matches!(hang, Hang::Batch { .. })) + .map(|hang| matches!(hang, Hang::Batch { committed: true })) + } + + fn take_hang(&self, wanted: impl Fn(&Hang) -> bool) -> Option { + let mut hang = self.hang.lock().unwrap(); + if hang.as_ref().is_some_and(wanted) { + hang.take() + } else { + None + } + } + + pub(super) fn connections(&self) -> u64 { + self.connections.load(Ordering::SeqCst) + } + + /// 按调用顺序记录的真实关闭尝试(每条是被关的连接号)。 + pub(super) fn close_attempts(&self) -> Vec { + self.close_attempts.lock().unwrap().clone() + } + + pub(super) fn close_successes(&self) -> u64 { + self.close_successes.load(Ordering::SeqCst) + } + + pub(super) fn issued(&self) -> Vec<(u64, &'static str, Vec)> { + self.issued + .lock() + .unwrap() + .iter() + .map(|issue| (issue.connection, issue.sql, issue.params.clone())) + .collect() + } + + /// 提交一批:账本行按真实语句的参数落盘,效果语句计数。 + fn execute(&self, statements: &[StatementSpec]) { + let sql = ledger_sql(); + let mut effects = 0; + for spec in statements { + if spec.sql == sql.qualify { + let id = text_at(&spec.params, 0).unwrap_or_default().to_owned(); + let digest = text_at(&spec.params, 2).unwrap_or_default().to_owned(); + let receipt = text_at(&spec.params, 3).map(str::to_owned); + self.ledger.lock().unwrap().insert( + id.clone(), + FakeLedgerRow { + state: "applied".to_owned(), + receipt, + digest, + }, + ); + } else if spec.sql == sql.closure { + let id = text_at(&spec.params, 0).unwrap_or_default().to_owned(); + let digest = text_at(&spec.params, 2).unwrap_or_default().to_owned(); + self.ledger.lock().unwrap().insert( + id.clone(), + FakeLedgerRow { + state: "closed".to_owned(), + receipt: None, + digest, + }, + ); + } else { + effects += 1; + } + } + self.executed.lock().unwrap().push(Executed { effects }); + } + + /// 已提交的**业务效果批**数(账本行不算效果):数据至多一份就是它 ≤ 1。 + pub(super) fn executed_effect_batches(&self) -> usize { + self.executed + .lock() + .unwrap() + .iter() + .filter(|batch| batch.effects > 0) + .count() + } + + /// 一条只读语句的假回答。 + fn answer_read(&self, spec: &StatementSpec) -> Vec> { + let identity = schema::identity_read_plan(); + if spec.sql == identity[0].sql { + return vec![vec![Value::Text("peri_store_meta".to_owned())]]; + } + if spec.sql == identity[1].sql { + let store_id = self.store_id.lock().unwrap().clone().unwrap_or_default(); + return vec![vec![ + Value::Integer(REMOTE_SCHEMA_VERSION), + Value::Text(store_id), + Value::Text(STORE_CONTRACT.to_owned()), + ]]; + } + if spec.sql == ledger_sql().resolve { + let id = text_at(&spec.params, 0).unwrap_or_default().to_owned(); + let row = self.ledger.lock().unwrap().get(&id).cloned(); + return match row { + Some(row) => vec![vec![ + Value::Text(row.state), + row.receipt.map_or(Value::Null, Value::Text), + Value::Text(row.digest), + ]], + None => Vec::new(), + }; + } + if spec.sql == session_sql::select_meta_statement("probe").sql { + return if self.session_row.load(Ordering::SeqCst) { + vec![vec![Value::Null; session_sql::META_AGENT_STATUS + 1]] + } else { + Vec::new() + }; + } + if spec.sql == session_sql::select_session_statement("probe").sql { + return if self.session_row.load(Ordering::SeqCst) { + vec![vec![Value::Null; session_sql::FACT_COLUMN_TOTAL]] + } else { + Vec::new() + }; + } + // 其余读取(root 上溯等)一律「没有这一行」:调用方按事实不完整处理。 + Vec::new() + } +} + +/// 一次在途调用被丢弃之后,那一代连接作废(复现 SDK 的毒化),后续请求在连接层失败。 +struct FakeConnection { + backend: Arc, + number: u64, + dead: AtomicBool, +} + +impl FakeConnection { + /// 与 SDK 观察到的形状一致:被丢弃的在途请求之后,这条连接上的请求在连接层失败。 + fn reject_if_dead(&self) -> turso_serverless::Result<()> { + if self.dead.load(Ordering::Acquire) { + return Err(SdkError::Http( + "fake connection: stream is unusable after an abandoned request".to_owned(), + )); + } + Ok(()) + } + + fn record(&self, spec: &StatementSpec) { + self.backend.issued.lock().unwrap().push(Issued { + connection: self.number, + sql: spec.sql, + params: spec.params.clone(), + }); + } + + /// 命中挂起档位就永远挂起:只有调用方丢弃这个 future 才能脱身,丢弃即本连接作废。 + async fn hang_forever(&self) { + let _guard = DeadOnDrop { dead: &self.dead }; + std::future::pending::<()>().await; + } +} + +/// 被丢弃时把连接记为不可用。 +struct DeadOnDrop<'a> { + dead: &'a AtomicBool, +} + +impl Drop for DeadOnDrop<'_> { + fn drop(&mut self) { + self.dead.store(true, Ordering::Release); + } +} + +#[async_trait] +impl RemoteTransport for FakeConnection { + async fn sql_values(&self, spec: &StatementSpec) -> turso_serverless::Result>> { + self.reject_if_dead()?; + self.backend.retire_queued_generation(); + self.record(spec); + if self.backend.take_read_hang().is_some() { + self.hang_forever().await; + } + Ok(self.backend.answer_read(spec)) + } + + async fn managed_batch( + &self, + statements: Vec, + ) -> turso_serverless::Result> { + self.reject_if_dead()?; + for spec in &statements { + self.record(spec); + } + if let Some(committed) = self.backend.take_batch_hang() { + if committed { + // 批真的提交了,响应在回程丢失:账本行是远端事实,恢复必须读回去。 + self.backend.execute(&statements); + } + self.hang_forever().await; + } + self.backend.execute(&statements); + Ok(vec![1; statements.len()]) + } + + async fn consistent_read( + &self, + statements: Vec, + ) -> turso_serverless::Result>>> { + self.reject_if_dead()?; + self.backend.retire_queued_generation(); + let mut sets = Vec::with_capacity(statements.len()); + for spec in &statements { + self.record(spec); + sets.push(self.backend.answer_read(spec)); + } + Ok(sets) + } + + fn is_autocommit(&self) -> turso_serverless::Result { + Ok(true) + } + + async fn close(&self) -> turso_serverless::Result<()> { + self.backend + .close_attempts + .lock() + .unwrap() + .push(self.number); + // 在途关闭可以被挂起:调用方丢弃 future 时,唯一句柄必须还在 adapter 的关闭句柄里。 + let release = self.backend.close_release.notified(); + if self.backend.hold_close.load(Ordering::SeqCst) { + release.await; + } + if self.backend.fail_close.load(Ordering::SeqCst) { + return Err(SdkError::Http("fake close failed".to_owned())); + } + self.backend.close_successes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// 假工厂:每调用一次建立一条新的假连接(与生产工厂同一条重建路径)。 +struct FakeFactory { + backend: Arc, + gate: Arc, + access: StoreAccess, +} + +#[async_trait] +impl ConnectionFactory for FakeFactory { + async fn connect(&self) -> SessionResourceResult { + Ok(fake_store(&self.backend, &self.gate, self.access)) + } +} + +fn fake_store( + backend: &Arc, + gate: &Arc, + access: StoreAccess, +) -> RemoteStore { + let number = backend.connections.fetch_add(1, Ordering::SeqCst) + 1; + RemoteStore::new( + Arc::new(FakeConnection { + backend: Arc::clone(backend), + number, + dead: AtomicBool::new(false), + }), + access, + gate.mint(), + Arc::clone(gate), + ) +} + +/// 账本三类语句的真实 SQL 文本(不在测试里另抄一份)。 +pub(super) struct LedgerSql { + pub(super) qualify: &'static str, + pub(super) closure: &'static str, + pub(super) resolve: &'static str, +} + +pub(super) fn ledger_sql() -> LedgerSql { + let identity = OperationIdentity::new(OperationId::from_record("probe"), "probe", &[]); + LedgerSql { + qualify: ledger::qualify_statement(&identity, "0").sql, + closure: ledger::closure_statement(&identity, "0").sql, + resolve: ledger::resolve_statement(&identity.operation_id).sql, + } +} + +// ─── 装配 ───────────────────────────────────────────────────────────────────── + +pub(super) struct Harness { + /// 被测 adapter(`Arc`:门面装配测试与业务侧指向同一份事实)。 + pub(super) adapter: Arc, + pub(super) backend: Arc, + pub(super) gate: Arc, +} + +impl Harness { + pub(super) async fn open(access: StoreAccess) -> Self { + let store_id = StoreId::mint(); + let store_key = store_id.as_str().to_owned(); + let gate = Arc::new(ConnectionGate::default()); + let backend = Arc::new(FakeBackend::new(&store_key)); + backend.attach_gate(&gate); + let connection = fake_store(&backend, &gate, access); + let factory: Arc = Arc::new(FakeFactory { + backend: Arc::clone(&backend), + gate: Arc::clone(&gate), + access, + }); + let adapter = RemoteSessionData::with_connection_for_test( + store_id, + connection, + factory, + Arc::clone(&gate), + ); + Self { + adapter: Arc::new(adapter), + backend, + gate, + } + } +} + +/// 丢掉一次在途读取:请求已经发出、答案没有回来。 +pub(super) async fn abandon_read(harness: &Harness, id: &ThreadId) { + harness.backend.hang_next(Hang::Read); + let abandoned = tokio::time::timeout(ABANDON_AFTER, harness.adapter.load_binding(id)).await; + assert!(abandoned.is_err(), "the read was expected to be abandoned"); +} + +/// 丢掉一次在途写入:批已经发出(`committed` 决定远端有没有提交),响应没有回来。 +pub(super) async fn abandon_write(harness: &Harness, id: &ThreadId, committed: bool) { + harness.backend.hang_next(Hang::Batch { committed }); + let payloads = vec![PersistedPayload::Message( + peri_acp_types::messages::BaseMessage::human("abandoned"), + )]; + let abandoned = + tokio::time::timeout(ABANDON_AFTER, harness.adapter.append_history(id, &payloads)).await; + assert!( + abandoned.is_err(), + "the write was expected to be abandoned in flight" + ); +} diff --git a/peri-resources/src/sessions/remote/remote_test.rs b/peri-resources/src/sessions/remote/remote_test.rs new file mode 100644 index 000000000..d4ed8e9c9 --- /dev/null +++ b/peri-resources/src/sessions/remote/remote_test.rs @@ -0,0 +1,181 @@ +//! 远程模块的确定性测试:解析、身份归一、Debug 脱敏、失败分类与脱敏。 +//! +//! 全部离线:不连接任何远程服务,不读 `.env`,不依赖网络。 + +use turso_serverless::Error as SdkError; + +use super::credentials::{CredentialError, CredentialSource, SessionStoreCredential}; +use super::endpoint::{EndpointError, RemoteEndpoint, RemoteEngine}; +use super::failure; +use super::RemoteFailureClass; + +const FAKE_SECRET: &str = "sentinel-credential-000000000000"; +const FAKE_HOST: &str = "sentinel-db-sentinel-org.turso.io"; + +#[test] +fn scheme_aliases_share_one_identity() { + let turso = + RemoteEndpoint::parse(&format!("turso://{FAKE_HOST}"), None).expect("turso locator"); + let https = RemoteEndpoint::parse(&format!("https://{FAKE_HOST}/"), Some(RemoteEngine::Turso)) + .expect("https locator with explicit engine"); + assert_eq!(turso.engine(), RemoteEngine::Turso); + assert_eq!(turso.locator_digest(), https.locator_digest()); +} + +#[test] +fn engine_comes_from_confirmed_syntax_only() { + let from_scheme = + RemoteEndpoint::parse(&format!("libsql://{FAKE_HOST}"), None).expect("libsql locator"); + assert_eq!(from_scheme.engine(), RemoteEngine::LibSql); + assert_eq!(from_scheme.engine().as_str(), "libsql"); + assert_eq!(RemoteEngine::parse("turso"), Some(RemoteEngine::Turso)); + assert_eq!(RemoteEngine::parse("mysql"), None); + + // https 无法判定引擎:必须显式选择,不轮流试两种 SDK。 + assert_eq!( + RemoteEndpoint::parse(&format!("https://{FAKE_HOST}"), None).unwrap_err(), + EndpointError::AmbiguousEngine + ); + assert_eq!( + RemoteEndpoint::parse(&format!("turso://{FAKE_HOST}"), Some(RemoteEngine::LibSql)) + .unwrap_err(), + EndpointError::EngineConflict + ); +} + +#[test] +fn locator_rejects_credentials_and_sync_forms() { + assert_eq!( + RemoteEndpoint::parse(&format!("turso://user:{FAKE_SECRET}@{FAKE_HOST}"), None) + .unwrap_err(), + EndpointError::UserInfoPresent + ); + assert_eq!( + RemoteEndpoint::parse(&format!("turso://{FAKE_HOST}?sync_interval=1"), None).unwrap_err(), + EndpointError::QueryOrFragmentUnsupported + ); + assert_eq!( + RemoteEndpoint::parse("/tmp/threads.db", None).unwrap_err(), + EndpointError::NotARemoteUrl + ); + assert_eq!( + RemoteEndpoint::parse("ftp://example.invalid/db", None).unwrap_err(), + EndpointError::UnsupportedScheme + ); +} + +#[test] +fn endpoint_debug_keeps_host_and_path_out() { + let endpoint = RemoteEndpoint::parse( + &format!("turso://{FAKE_HOST}/sessions"), + Some(RemoteEngine::Turso), + ) + .expect("endpoint"); + let rendered = format!("{endpoint:?}"); + assert!(rendered.contains("official_turso_cloud_domain")); + assert!(!rendered.contains("sentinel-db")); + assert!(!rendered.contains("sessions")); +} + +#[test] +fn credential_value_never_debug_leaks() { + let credential = SessionStoreCredential::new(FAKE_SECRET).expect("credential"); + let rendered = format!("{credential:?}"); + assert!(!rendered.contains(FAKE_SECRET)); + assert_eq!(credential.expose(), FAKE_SECRET); + assert_eq!( + SessionStoreCredential::new("").unwrap_err(), + CredentialError::EmptyValue + ); +} + +#[test] +fn credential_source_is_explicit_and_not_aliased() { + assert_eq!( + CredentialSource::env("not a name").unwrap_err(), + CredentialError::InvalidName + ); + assert_eq!( + CredentialSource::env("").unwrap_err(), + CredentialError::InvalidName + ); + let source = CredentialSource::env("PERI_PROBE_ABSENT_VARIABLE_9f3").expect("source"); + assert_eq!(source.name(), "PERI_PROBE_ABSENT_VARIABLE_9f3"); + assert_eq!( + source.resolve().unwrap_err(), + CredentialError::Missing { + name: "PERI_PROBE_ABSENT_VARIABLE_9f3".to_owned() + } + ); + assert_eq!( + format!("{source:?}"), + "CredentialSource(env:PERI_PROBE_ABSENT_VARIABLE_9f3)" + ); +} + +#[test] +fn empty_environment_variable_is_a_typed_error() { + // 变量名唯一,避免与并行测试互相干扰。 + let name = "PERI_PROBE_EMPTY_VARIABLE_7c1"; + std::env::set_var(name, ""); + let source = CredentialSource::env(name).expect("source"); + assert_eq!( + source.resolve().unwrap_err(), + CredentialError::Empty { + name: name.to_owned() + } + ); + std::env::remove_var(name); +} + +#[test] +fn sdk_failures_map_to_stable_classes() { + assert_eq!( + failure::classify(&SdkError::Constraint("UNIQUE".to_owned())), + RemoteFailureClass::Constraint + ); + assert_eq!( + failure::classify(&SdkError::Readonly("readonly".to_owned())), + RemoteFailureClass::Readonly + ); + assert_eq!( + failure::classify(&SdkError::Busy("locked".to_owned())), + RemoteFailureClass::Busy + ); + assert_eq!( + failure::classify(&SdkError::Http( + "HTTP status 401 for https://db.turso.io".to_owned() + )), + RemoteFailureClass::AuthRejected + ); + assert_eq!( + failure::classify(&SdkError::Http("request timed out".to_owned())), + RemoteFailureClass::Timeout + ); + assert_eq!( + failure::classify(&SdkError::Http("connection refused".to_owned())), + RemoteFailureClass::Transport + ); +} + +#[test] +fn domain_failure_keeps_sdk_text_out() { + // SDK 载荷可能含 URL/凭证/SQL:领域失败只能带稳定分类文本。 + let raw = format!("HTTP status 403 for https://user:{FAKE_SECRET}@{FAKE_HOST}/v2/pipeline"); + let mapped = failure::classify(&SdkError::Http(raw.clone())).into_session_resource_error(); + let rendered = format!("{mapped}"); + assert!(!rendered.contains(FAKE_SECRET)); + assert!(!rendered.contains(FAKE_HOST)); + assert!(!rendered.contains("403")); + assert!(rendered.contains("rejected the credential")); +} + +#[test] +fn timeout_class_is_not_an_unavailable_detail() { + let mapped = RemoteFailureClass::Timeout.into_session_resource_error(); + assert!(matches!( + mapped.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::Timeout + )); + assert_eq!(RemoteFailureClass::Timeout.as_str(), "timeout"); +} diff --git a/peri-resources/src/sessions/remote/schema.rs b/peri-resources/src/sessions/remote/schema.rs new file mode 100644 index 000000000..e32243c83 --- /dev/null +++ b/peri-resources/src/sessions/remote/schema.rs @@ -0,0 +1,241 @@ +//! 远程版本标记、store 身份与只读检查(C §4、§6)。 +//! +//! 「两个存储模式一致」之后,远端会话表**就是**本地形状(`sessions::canonical` 是那份 DDL +//! 的唯一来源),所以版本号也同源:`REMOTE_SCHEMA_VERSION` 直接取本机的 +//! `CURRENT_SCHEMA_VERSION`。两者仍有一处执行器差异,且只有这一处——远端写不了 +//! `PRAGMA user_version`(服务端直接拒绝,实测见母 issue §9.8 探测项 5b),版本标记只能落在 +//! 普通表的单行事实里([`STORE_META_TABLE`])。差异是**载体**,不是版本代数。 +//! +//! 契约标签 [`STORE_CONTRACT`] 在统一时推进到 `v2`:形状变了(表名、列名、绑定所在表、 +//! 历史顺序的载体),拿着 v1 标签的库会被 [`acceptance`] / `matches_build` 判为不认识—— +//! 这是有意的 fail-closed。旧形状的库**不迁移、不覆盖**(新库无历史数据,迁移路径没有被 +//! 需求),具体拒绝点见 `session_data` 的旧形状探测。 +//! +//! 三条硬规则: +//! +//! - **读不写**:身份/版本读取只有 SELECT(`identity_read_plan` 可离线断言)。 +//! - **未知不覆盖**:版本高于本构建、契约不符或形状不可解释时一律拒绝,不做 DDL、 +//! 不猜列形状;初始化只在「确定不存在」时创建,已存在时读回而不改写。 +//! - **唯一身份**:`store_id` 由首次初始化竞争产生(元数据行主键),失败者读胜者, +//! 不各造一个 StoreId;竞争结论是**结构化的**([`StoreIdentityOutcome`]:胜者 `Created`、 +//! 败者与读已有库同为 `Existing`),首次登记资格只跟 `Created` 走,不由「打开前看到空库」 +//! 或「读回里现在有身份」推导。只有本事务确切插入元数据行并提交才算创建——结果未知 +//! (丢响应、超时)不产生创建事实。 + +use std::fmt; + +use turso_serverless::Value; + +use super::sql::{int_at, text_at, StatementSpec}; + +/// 本构建写入并接受的远端 schema 版本:**与本机 `CURRENT_SCHEMA_VERSION` 同一个常量**。 +/// +/// 两端形状相同,版本就不该各自一份;任何一侧推进版本,另一侧跟着走。 +pub(super) const REMOTE_SCHEMA_VERSION: i64 = crate::sessions::sqlite_store::CURRENT_SCHEMA_VERSION; + +/// 远程存储契约标签:形状 + 语义代数,和版本一起决定「这是不是我们认识的那个库」。 +/// +/// `v2` = 远端会话表改为 canonical 形状(统一前是 `peri_sessions` 的混合形状)。 +pub(super) const STORE_CONTRACT: &str = "peri.session.store/v2"; + +/// 统一之前的远端会话表:出现它们说明这是一个**旧形状的库**(不是空库)。 +/// +/// 只在「写打开 + 尚未初始化」这条路径上探测一次;命中即拒绝,不迁移也不覆盖。 +pub(super) const LEGACY_SHAPE_TABLES: &[&str] = &["peri_sessions", "peri_session_messages"]; + +/// 旧形状探测(只读、全绑定):库里有几张本构建不认识的旧会话表。 +pub(super) const COUNT_LEGACY_TABLES_SQL: &str = "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name IN (?1, ?2)"; + +/// 单行元数据表:`singleton` 固定 0,主键即身份竞争的同一唯一键空间。 +pub(super) const STORE_META_TABLE: &str = "peri_store_meta"; + +const TABLE_EXISTS_SQL: &str = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1"; + +const SELECT_META_SQL: &str = + "SELECT schema_version, store_id, contract FROM peri_store_meta WHERE singleton = 0"; + +pub(super) const CREATE_STORE_META_SQL: &str = "CREATE TABLE IF NOT EXISTS peri_store_meta ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 0), + schema_version INTEGER NOT NULL, + store_id TEXT NOT NULL, + contract TEXT NOT NULL, + created_at TEXT NOT NULL +)"; + +const INSERT_STORE_META_SQL: &str = "INSERT INTO peri_store_meta + (singleton, schema_version, store_id, contract, created_at) + VALUES (0, ?1, ?2, ?3, ?4)"; + +/// 远端持久化的存储身份(权威来源是远端 `peri_store_meta`,不是 URL 别名)。 +#[derive(Clone, PartialEq, Eq)] +pub(super) struct StoreId(String); + +impl StoreId { + /// 首次初始化时铸造:随机、无外部输入、可安全记录(身份,不是凭证)。 + pub(super) fn mint() -> Self { + Self(uuid::Uuid::new_v4().simple().to_string()) + } + + pub(super) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for StoreId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "StoreId({})", self.0) + } +} + +/// 已初始化的远端存储事实。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct StoreSnapshot { + pub(super) store_id: StoreId, + pub(super) schema_version: i64, + pub(super) contract: String, +} + +impl StoreSnapshot { + /// 本构建是否认识这个库:契约一致且版本可接受。 + pub(super) fn matches_build(&self) -> bool { + self.contract == STORE_CONTRACT + && matches!(acceptance(self.schema_version), SchemaAcceptance::Accept) + } +} + +/// 版本判定(纯函数)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SchemaAcceptance { + /// 本构建可读写。 + Accept, + /// 高于本构建:拒绝,不迁移、不降级写入。 + TooNew, + /// 低于本构建或非法:拒绝,不猜。 + Unusable, +} + +pub(super) fn acceptance(version: i64) -> SchemaAcceptance { + if version == REMOTE_SCHEMA_VERSION { + SchemaAcceptance::Accept + } else if version > REMOTE_SCHEMA_VERSION { + SchemaAcceptance::TooNew + } else { + SchemaAcceptance::Unusable + } +} + +/// 只读身份读取的结果。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum StoreIdentityRead { + /// 本任务 schema 尚未初始化(表不存在,或表存在但没有元数据行)。 + Uninitialized, + /// 已初始化。 + Present(StoreSnapshot), + /// 表与行都在,但形状无法解释:不猜、不覆盖。 + Malformed, +} + +/// 身份竞争的结论:**谁**在这次竞争里建立了身份。 +/// +/// 与 [`StoreIdentityRead`] 的区别是时点与主语:读取回答「库里现在有什么」,本类型回答 +/// 「本次初始化自己做了什么」。竞败方读回的身份与胜者相同(唯一键决定只有一个身份), +/// 但结论是 [`Existing`](Self::Existing)——首次登记资格只属于胜者,不能靠读回结果推定。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum StoreIdentityOutcome { + /// 本事务确切插入了元数据行并提交:本次是胜者,这个身份由本次打开建立。 + Created(StoreId), + /// 元数据唯一键已被占用:库里已有身份,本次没有建立任何东西。 + Existing(StoreId), +} + +impl StoreIdentityOutcome { + /// 权威身份:胜者是本次铸造值,败者与读已有库是读回的既有值。 + pub(super) fn store_id(&self) -> &StoreId { + match self { + Self::Created(store_id) | Self::Existing(store_id) => store_id, + } + } +} + +/// 初始化批的结果是否证明**本事务插入了**元数据行。 +/// +/// 只有 `META_INSERT_INDEX` 那条语句受影响行数为 1 才算:批成功但该语句没有插入任何行 +/// (行已存在、语句被跳过)不构成创建;批失败(唯一键冲突、超时、丢响应)时根本没有这个 +/// 结果,也无从证明。这是 created 的唯一证据,不由「打开前看到空库」推导。 +pub(super) fn inserted_meta_row(counts: &[u64]) -> bool { + counts.get(META_INSERT_INDEX) == Some(&1) +} + +/// 只读读取的结果判定(纯函数):表是否存在 + 元数据行原样交给它。 +/// +/// 表不存在与「表在但行不在」都是未初始化;行在但形状不可解释是 `Malformed`——不猜身份, +/// 也不把读不懂当成「空库可用」。真引擎 seam 与生产读取共用这一处判定。 +pub(super) fn interpret_identity_read( + table_exists: bool, + row: Option<&[Value]>, +) -> StoreIdentityRead { + if !table_exists { + return StoreIdentityRead::Uninitialized; + } + match row { + Some(values) => match decode_identity(values) { + Some(snapshot) => StoreIdentityRead::Present(snapshot), + None => StoreIdentityRead::Malformed, + }, + None => StoreIdentityRead::Uninitialized, + } +} + +/// 只读身份读取的语句计划:全部是 SELECT。 +/// +/// `sqlite_master` 只用来判断表是否存在(其可用性由 cloud 实验断言,不作为身份判据); +/// 表存在时再读元数据行,缺列等形状问题会以读失败上报,绝不会触发写。 +pub(super) fn identity_read_plan() -> Vec { + vec![ + StatementSpec::new( + TABLE_EXISTS_SQL, + vec![Value::Text(STORE_META_TABLE.to_owned())], + ), + StatementSpec::bare(SELECT_META_SQL), + ] +} + +/// 初始化计划里元数据 INSERT 的下标:身份竞争发生在这一条。 +/// +/// 与 [`initialization_plan`] 的语句顺序绑定(建表在前,所以它不是 0),由离线测试守住; +/// 初始化遇到该下标的唯一键冲突时读回胜者,不覆盖。 +pub(super) const META_INSERT_INDEX: usize = 2; + +/// 初始化计划:同一原子批内建表、写入本机铸造的身份、读回。 +/// +/// 语句顺序固定:两条 `CREATE TABLE IF NOT EXISTS`(已存在即 no-op,绝不改写)→ +/// 元数据行 INSERT(主键冲突即失去身份竞争)→ SELECT 读回本次写入的事实。 +pub(super) fn initialization_plan(store_id: &StoreId, now: &str) -> Vec { + vec![ + StatementSpec::bare(CREATE_STORE_META_SQL), + StatementSpec::bare(super::ledger::CREATE_OP_LEDGER_SQL), + StatementSpec::new( + INSERT_STORE_META_SQL, + vec![ + Value::Integer(REMOTE_SCHEMA_VERSION), + Value::Text(store_id.as_str().to_owned()), + Value::Text(STORE_CONTRACT.to_owned()), + Value::Text(now.to_owned()), + ], + ), + StatementSpec::bare(SELECT_META_SQL), + ] +} + +/// 解码元数据行(`schema_version, store_id, contract`);形状不符即 `None`。 +pub(super) fn decode_identity(values: &[Value]) -> Option { + let schema_version = int_at(values, 0)?; + let store_id = text_at(values, 1)?; + let contract = text_at(values, 2)?; + Some(StoreSnapshot { + store_id: StoreId(store_id.to_owned()), + schema_version, + contract: contract.to_owned(), + }) +} diff --git a/peri-resources/src/sessions/remote/schema_test.rs b/peri-resources/src/sessions/remote/schema_test.rs new file mode 100644 index 000000000..d9440e8c9 --- /dev/null +++ b/peri-resources/src/sessions/remote/schema_test.rs @@ -0,0 +1,124 @@ +//! `schema` 的离线测试:只读性、版本判定、参数化与形状拒绝。全部不联网。 + +use turso_serverless::Value; + +use super::schema::{ + acceptance, decode_identity, identity_read_plan, initialization_plan, SchemaAcceptance, + StoreId, StoreSnapshot, META_INSERT_INDEX, REMOTE_SCHEMA_VERSION, STORE_CONTRACT, + STORE_META_TABLE, +}; + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +#[test] +fn identity_read_plan_is_read_only() { + let plan = identity_read_plan(); + assert_eq!(plan.len(), 2); + for spec in &plan { + assert!( + spec.is_read_only(), + "身份读取计划里出现非只读语句: {:?}", + spec + ); + } + // 第一条只问表存在性(参数化),第二条才读元数据行。 + assert!(plan[0].sql.contains("sqlite_master")); + assert!(plan[0].params.contains(&text(STORE_META_TABLE))); + assert!(plan[1].sql.contains(STORE_META_TABLE)); +} + +#[test] +fn acceptance_rejects_unknown_versions() { + assert_eq!(acceptance(REMOTE_SCHEMA_VERSION), SchemaAcceptance::Accept); + assert_eq!( + acceptance(REMOTE_SCHEMA_VERSION + 1), + SchemaAcceptance::TooNew + ); + assert_eq!(acceptance(0), SchemaAcceptance::Unusable); + assert_eq!(acceptance(-1), SchemaAcceptance::Unusable); +} + +#[test] +fn snapshot_must_match_contract_and_version() { + let build = StoreSnapshot { + store_id: StoreId::mint(), + schema_version: REMOTE_SCHEMA_VERSION, + contract: STORE_CONTRACT.to_owned(), + }; + assert!(build.matches_build()); + + // 统一之前的形状代数(`peri_sessions` 那套):契约不认识就拒绝,不尝试迁移。 + let pre_unification = StoreSnapshot { + contract: "peri.session.store/v1".to_owned(), + ..build.clone() + }; + assert!(!pre_unification.matches_build()); + + let newer = StoreSnapshot { + schema_version: REMOTE_SCHEMA_VERSION + 1, + ..build.clone() + }; + assert!(!newer.matches_build()); +} + +#[test] +fn initialization_plan_parameterizes_identity_and_never_overwrites() { + let store_id = StoreId::mint(); + let plan = initialization_plan(&store_id, "2026-09-26T00:00:00+00:00"); + assert_eq!(plan.len(), 4); + + // 建表只用 IF NOT EXISTS:已存在即 no-op,绝不改写既有形状。 + for spec in &plan[..2] { + assert!( + spec.sql.contains("CREATE TABLE IF NOT EXISTS"), + "{:?}", + spec + ); + assert!(spec.params.is_empty()); + } + // 元数据行是 INSERT(主键竞争),不是 UPSERT/UPDATE;竞争下标与计划顺序绑定。 + assert_eq!(META_INSERT_INDEX, 2); + assert!(plan[META_INSERT_INDEX] + .sql + .starts_with("INSERT INTO peri_store_meta")); + assert!(plan[2].params.contains(&text(store_id.as_str()))); + assert!( + !plan[2].sql.contains(store_id.as_str()), + "store id 只能作为绑定参数出现" + ); + assert!( + !plan[2].sql.contains("2026-09-26"), + "时间戳只能作为绑定参数出现" + ); + // 最后一步读回本次写入的事实。 + assert!(plan[3].is_read_only()); +} + +#[test] +fn minted_store_ids_are_opaque_hex_and_unique() { + let first = StoreId::mint(); + let second = StoreId::mint(); + assert_ne!(first, second); + assert_eq!(first.as_str().len(), 32); + assert!(first.as_str().chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn identity_decoding_rejects_shapes_it_cannot_explain() { + let ok = decode_identity(&[ + Value::Integer(REMOTE_SCHEMA_VERSION), + text("store-abc"), + text(STORE_CONTRACT), + ]) + .expect("well-formed row"); + assert_eq!(ok.store_id.as_str(), "store-abc"); + assert!(ok.matches_build()); + + // 列缺失、类型不符、空行:一律拒绝,不猜。 + assert!(decode_identity(&[Value::Integer(1), text("s")]).is_none()); + assert!(decode_identity(&[Value::Real(1.0), text("s"), text(STORE_CONTRACT)]).is_none()); + assert!(decode_identity(&[Value::Null, Value::Null, Value::Null]).is_none()); + assert!(decode_identity(&[]).is_none()); +} diff --git a/peri-resources/src/sessions/remote/session_child_guard_test.rs b/peri-resources/src/sessions/remote/session_child_guard_test.rs new file mode 100644 index 000000000..10131961e --- /dev/null +++ b/peri-resources/src/sessions/remote/session_child_guard_test.rs @@ -0,0 +1,153 @@ +//! child 快照输入一致性与「新建只接受 root」的**远程入口**回归(不连网)。 +//! +//! 判定本身是纯函数(`data::ensure_child_relation`、`write_new_session` 的 root 校验),门面、 +//! 本机 adapter 与远程 adapter 引用的是同一份代码——「只有一条规则」由编译期引用保证,不靠 +//! 各处各写一遍。这里补的是远程入口自己的两件事: +//! +//! - 输入不自洽时 `save_child` 在**任何远端读取之前**就拒绝:本机一侧没有可写的记录(v10 +//! 之后本机也不持有远端操作日志),拒绝因此不带任何副作用; +//! - 带父的 `save_new_session` 同样在**任何远端读取之前**被拒绝:远程新建只造 root,有父必须 +//! 走 `save_child`; +//! - 同一入口对自洽的输入不设额外门槛:它照常走到连接(本装配下连接已关闭,因此失败于 +//! `Internal` 而不是 `InvalidInput`)。 +//! +//! 边界:远端数据事实(不落行、原 root 不变)无法在这套离线装配上断言——那需要真实云库, +//! 而共享库的身份不可重置。远端落库语句里的父子列与 `target.meta` 同源由 `session_sql` +//! 与 `session_shape_test` 覆盖;本文件能证明的是「校验先于 I/O,被拒时不发任何请求」。 +//! 真云对照:`cloud_limit_test.rs::cloud_remote_create_refuses_parent_input`(类型化拒绝 + +//! 远端零行 + 远端零 child 行)。 + +use peri_acp_types::session_resources::{ + ChildSnapshot, FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResourceErrorKind, +}; +use peri_acp_types::store::InheritedContext; + +use super::cloud_tests::{synth_binding, synth_thread}; +use super::schema::StoreId; +use super::session_data::RemoteSessionData; +use crate::sessions::data::SessionDataPort; + +/// 连接已关闭的远程 adapter(与 `close` 之后的状态同一个形状)。 +struct ClosedRemote { + adapter: RemoteSessionData, +} + +impl ClosedRemote { + async fn open() -> Self { + Self { + adapter: RemoteSessionData::closed_for_test(StoreId::mint()), + } + } +} + +/// 合成一份新建输入:默认是 root(`parent_thread_id: None`),由调用方按需改成带父。 +fn root_session(thread: &str) -> NewSession { + NewSession { + thread_id: synth_thread(thread), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("session {thread}")), + cwd: "/tmp/peri-child-guard".to_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: synth_binding(), + frozen: FrozenSnapshotBytes::new(r#"{"frozen":"guard"}"#), + } +} + +/// 合成一份 child 快照:`meta_parent` 是落库用的那一份父子关系。 +fn child_snapshot( + child: &str, + parent: &str, + root: &str, + meta_parent: Option<&str>, +) -> ChildSnapshot { + ChildSnapshot { + target: NewSession { + thread_id: synth_thread(child), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("child {child}")), + cwd: "/tmp/peri-child-guard".to_owned(), + parent_thread_id: meta_parent.map(synth_thread), + hidden: true, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: synth_binding(), + frozen: FrozenSnapshotBytes::new(r#"{"frozen":"guard"}"#), + }, + parent_id: synth_thread(parent), + root_id: synth_thread(root), + inherited: InheritedContext::default(), + } +} + +#[tokio::test] +async fn test_remote_child_entry_rejects_a_disagreeing_relation_before_any_io() { + let remote = ClosedRemote::open().await; + + // 合法父/根,但目标 meta 里没有父(旧行为:写成一条独立 root);以及把自己当根。 + let refused = [ + ( + "target without a parent", + child_snapshot("rel-child", "rel-root", "rel-root", None), + ), + ( + "own root", + child_snapshot("rel-child", "rel-root", "rel-child", Some("rel-root")), + ), + ]; + for (label, snapshot) in &refused { + let error = remote.adapter.save_child(snapshot).await.unwrap_err(); + assert!( + matches!(error.kind(), SessionResourceErrorKind::InvalidInput { .. }), + "{label}: expected InvalidInput, got {error:?}" + ); + } + // 拒绝发生在任何远端读取之前:本机不再留任何记录(v10 之后也没有可留的记录)。 + // 自洽的输入不被额外拦住:同一入口照常走到连接(连接已关闭,因此是 Internal)。 + let legal = child_snapshot("rel-child", "rel-root", "rel-root", Some("rel-root")); + let error = remote.adapter.save_child(&legal).await.unwrap_err(); + assert!( + matches!(error.kind(), SessionResourceErrorKind::Internal { .. }), + "a consistent child must reach the store path, got {error:?}" + ); +} + +/// 远程新建只接受 root:带父的输入在**任何远端读取之前**被类型化拒绝。 +/// +/// 与 `save_child` 的输入一致性判定同一性质——只看纯输入,因此拒绝不带任何副作用。放行会在 +/// 远端写出一条**没有经过 child 通路判定**的父关系(父子/根归属、root owner 门禁、frozen +/// 继承全都没走)。这条判定原先挂在已撤销的远程执行面上,v10 撤销时连同文件一起被删掉了。 +#[tokio::test] +async fn test_remote_root_entry_rejects_a_parent_before_any_io() { + let remote = ClosedRemote::open().await; + + let mut with_parent = root_session("root-with-parent"); + with_parent.meta.parent_thread_id = Some(synth_thread("root-parent")); + let error = remote + .adapter + .save_new_session(&with_parent) + .await + .unwrap_err(); + assert!( + matches!(error.kind(), SessionResourceErrorKind::InvalidInput { .. }), + "a parent-bearing input must be refused as input, got {error:?}" + ); + + // 判定不读存储:父会话根本不必存在,也不会有任何远端读取。 + // 同一入口对 root 输入不设额外门槛:照常走到连接(连接已关闭,因此是 Internal)。 + let error = remote + .adapter + .save_new_session(&root_session("root-plain")) + .await + .unwrap_err(); + assert!( + matches!(error.kind(), SessionResourceErrorKind::Internal { .. }), + "a root input must reach the store path, got {error:?}" + ); +} diff --git a/peri-resources/src/sessions/remote/session_close_test.rs b/peri-resources/src/sessions/remote/session_close_test.rs new file mode 100644 index 000000000..63b0ddb16 --- /dev/null +++ b/peri-resources/src/sessions/remote/session_close_test.rs @@ -0,0 +1,29 @@ +//! 远程 adapter 的关闭语义(离线装配,不连网)。 +//! +//! 门面在确认未结清事实之前不会再调用数据面的 `close`,但确认之后的收尾仍可能失败。 +//! 关闭**失败或取消**都不是确认关闭:连接被保留在 adapter 的关闭句柄里(业务读不复活、 +//! 不重连),重试关闭的是同一条真实连接。 +//! +//! 本文件只覆盖**从来没有过连接**的装配(关闭态装配,与「关闭过一次但没成功」不同: +//! 那种情况下连接仍在关闭句柄里,关闭可以重试):这里没有可关闭的真实资源,因此 +//! **不能**把「没有连接」当成「已经干净关闭」,重复关闭也不谎报成功。 + +use peri_acp_types::session_resources::SessionResourceErrorKind; + +use super::schema::StoreId; +use super::session_data::RemoteSessionData; +use crate::sessions::data::SessionDataPort; + +#[tokio::test] +async fn close_without_a_connection_is_not_reported_as_success() { + // 关闭态装配:连接从来没有过(没有任何真实资源可关,也没有关闭句柄可用)。 + let adapter = RemoteSessionData::closed_for_test(StoreId::mint()); + + let error = adapter.close().await.unwrap_err(); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Internal { .. } + )); + // 幂等成功只属于确认关闭;这里既没有连接也没有关闭进度,重复关闭不谎报成功。 + assert!(adapter.close().await.is_err()); +} diff --git a/peri-resources/src/sessions/remote/session_codec.rs b/peri-resources/src/sessions/remote/session_codec.rs new file mode 100644 index 000000000..4dbe510ba --- /dev/null +++ b/peri-resources/src/sessions/remote/session_codec.rs @@ -0,0 +1,262 @@ +//! 远程行值 ↔ 领域值的编解码:纯函数,可离线断言。 +//! +//! 三条规则: +//! +//! - **只做形状转换**,不改写领域语义:payload 走 `peri_acp_types::store` 的 JSON envelope +//! (`serialize_persisted_payload` / `deserialize_persisted_payload`)、继承区走 +//! `InheritedContext::to_json/from_json`,远端不另写一份编码格式。 +//! - **读不出来就是损坏**:形状不符(缺列、负数计数、非法枚举、时间戳不可解析、行内 ID 与 +//! 主键不一致)一律 `Corrupt`,不猜、不用默认值顶替,也不静默丢行。 +//! - **写之前先定型**:不可序列化的 payload、越界的 flags 引用在发请求前拒绝。 + +use std::path::PathBuf; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use peri_acp_types::session_resources::{ + SessionResourceError, SessionResourceErrorKind, SessionResourceResult, +}; +use peri_acp_types::store::{ + deserialize_persisted_payload, serialize_persisted_payload, InheritedContext, MessageFlags, + PersistedPayload, +}; +use peri_acp_types::thread::{AgentStatus, CancelPolicy, ThreadMeta}; +use peri_acp_types::workspace::{ProjectId, SessionBinding, WorkspaceId}; +use turso_serverless::Value; + +use super::session_sql::{ + META_AGENT_STATUS, META_CANCEL_POLICY, META_CONFIG, META_CONTENT_SIZE, META_CREATED_AT, + META_CWD, META_HIDDEN, META_ID, META_MESSAGE_COUNT, META_PARENT, META_SNAPSHOT_AT, META_TITLE, + META_UPDATED_AT, +}; +use super::sql::{int_at, text_at}; +use crate::sessions::canonical; + +// ─── 写侧编码 ───────────────────────────────────────────────────────────────── + +pub(super) fn int_value(value: i64) -> Value { + Value::Integer(value) +} + +/// `Some` 写文本、`None` 写 NULL(NULL 在这套 schema 里是「没有这个事实」,不是空字符串)。 +pub(super) fn optional_text(value: Option<&str>) -> Value { + match value { + Some(text) => Value::Text(text.to_owned()), + None => Value::Null, + } +} + +/// 一条历史行的插入参数:列顺序即 `messages` 的 canonical 列序 +/// (`message_id, thread_id, role, content, truncated, excluded, projection`)。 +/// +/// `role` 走 [`canonical::payload_role`]:两端写同一列时用同一份领域派生,不各写一份。 +pub(super) fn payload_params( + thread_id: &str, + payload: &PersistedPayload, + flags: Option<&MessageFlags>, +) -> SessionResourceResult> { + let flags = flags.cloned().unwrap_or_default(); + let projection = flags + .projection + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|_| corrupt("message projection is not serializable"))?; + Ok(vec![ + Value::Text(payload.id().as_uuid().to_string()), + Value::Text(thread_id.to_owned()), + Value::Text(canonical::payload_role(payload).to_owned()), + Value::Text( + serialize_persisted_payload(payload) + .map_err(|_| corrupt("history entry is not serializable"))?, + ), + int_value(i64::from(flags.truncated)), + int_value(i64::from(flags.excluded)), + optional_text(projection.as_deref()), + ]) +} + +/// 继承区 JSON:序列化后立即按同一套规则读回,引用边界不成立就不落库。 +pub(super) fn inherited_json(context: &InheritedContext) -> SessionResourceResult { + let json = context + .to_json() + .map_err(|_| corrupt("inherited context is not serializable"))?; + InheritedContext::from_json(&json) + .map_err(|_| corrupt("inherited context has invalid message references"))?; + Ok(json) +} + +// ─── 读侧解码 ───────────────────────────────────────────────────────────────── + +/// 单条会话 metadata(事实行或列表行的前 [`super::session_sql::META_COLUMN_COUNT`] 列)。 +pub(super) fn decode_meta(values: &[Value]) -> SessionResourceResult { + let id = text_field(values, META_ID, "session id")?; + let message_count = int_field(values, META_MESSAGE_COUNT, "message_count")?; + let content_size = int_field(values, META_CONTENT_SIZE, "content_size")?; + let cancel_policy = text_field(values, META_CANCEL_POLICY, "cancel_policy")?; + let agent_status = text_field(values, META_AGENT_STATUS, "agent_status")?; + Ok(ThreadMeta { + id, + title: optional_text_field(values, META_TITLE), + cwd: text_field(values, META_CWD, "cwd")?, + created_at: timestamp_field(values, META_CREATED_AT, "created_at")?, + updated_at: timestamp_field(values, META_UPDATED_AT, "updated_at")?, + message_count: usize::try_from(message_count) + .map_err(|_| corrupt("message_count is negative"))?, + content_size: u64::try_from(content_size) + .map_err(|_| corrupt("content_size is negative"))?, + parent_thread_id: optional_text_field(values, META_PARENT), + snapshot_at_message_id: optional_text_field(values, META_SNAPSHOT_AT), + hidden: bool_field(values, META_HIDDEN, "hidden")?, + // 关键约束:列值必须经 FromStr 解析为强类型枚举;非法值不静默 fallback。 + cancel_policy: CancelPolicy::from_str(&cancel_policy) + .map_err(|_| corrupt("cancel_policy is not a known value"))?, + config: optional_text_field(values, META_CONFIG), + // 远端不保存物化缓存:这里没有第二份真相可返回。 + cached_context: None, + agent_status: AgentStatus::from_str(&agent_status) + .map_err(|_| corrupt("agent_status is not a known value"))?, + }) +} + +/// 绑定列:四列全 NULL = 无绑定行;部分 NULL 或形状不可解释 = 损坏(不猜「有一半绑定」)。 +/// +/// 版本列是 INTEGER,其余三列是 TEXT:类型不符即形状不符。 +pub(super) fn decode_binding( + values: &[Value], + base: usize, +) -> SessionResourceResult> { + let present = (0..4).any(|offset| { + values + .get(base + offset) + .is_some_and(|value| *value != Value::Null) + }); + if !present { + return Ok(None); + } + let version = int_at(values, base) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| corrupt("session binding version is not a positive integer"))?; + let project = + text_at(values, base + 1).ok_or_else(|| corrupt("session binding row is incomplete"))?; + let workspace = + text_at(values, base + 2).ok_or_else(|| corrupt("session binding row is incomplete"))?; + let relative = + text_at(values, base + 3).ok_or_else(|| corrupt("session binding row is incomplete"))?; + let project = ProjectId::from_str(project) + .map_err(|_| corrupt("session binding project id is unreadable"))?; + let workspace = WorkspaceId::from_str(workspace) + .map_err(|_| corrupt("session binding workspace id is unreadable"))?; + let relative_cwd = PathBuf::from(relative); + if relative_cwd.is_absolute() { + return Err(corrupt("session binding cwd is not relative")); + } + Ok(Some(SessionBinding { + schema_version: version, + // 不可变绑定的协议字段恒为 1;它不是持久化事实,因此不从列里读。 + revision: 1, + project_id: project, + workspace_id: workspace, + cwd_relative_to_workspace: relative_cwd, + })) +} + +/// 自有 payload 行:`message_id, content, truncated, excluded, projection`。 +pub(super) fn decode_message_row(values: &[Value]) -> SessionResourceResult { + let row_id = text_field(values, 0, "message id")?; + let content = text_field(values, 1, "message content")?; + decode_payload_text(&content, &row_id) +} + +/// 历史行里的 flags 部分:`message_id, content, truncated, excluded, projection`。 +pub(super) fn decode_message_flags_row(values: &[Value]) -> SessionResourceResult { + decode_flags(values, 2, 3, 4) +} + +/// 单条 payload 文本 → 领域值;行内 ID 必须与主键一致(与本地读取同一复核)。 +pub(super) fn decode_payload_text( + content: &str, + row_id: &str, +) -> SessionResourceResult { + let payload = deserialize_persisted_payload(content) + .map_err(|_| corrupt("history entry is not a readable persisted payload"))?; + if payload.id().as_uuid().to_string() != row_id { + return Err(corrupt( + "persisted payload message id does not match its row", + )); + } + Ok(payload) +} + +/// 继承区 JSON → 领域值。 +pub(super) fn decode_inherited(text: Option<&str>) -> SessionResourceResult { + match text { + Some(json) => InheritedContext::from_json(json) + .map_err(|_| corrupt("inherited context is not readable")), + None => Ok(InheritedContext::default()), + } +} + +/// flags 是否为默认值(派生视图只返回非默认标记)。 +pub(super) fn flags_are_default(flags: &MessageFlags) -> bool { + !flags.truncated && !flags.excluded && flags.projection.is_none() +} + +fn decode_flags( + values: &[Value], + truncated: usize, + excluded: usize, + projection: usize, +) -> SessionResourceResult { + let projection = match text_at(values, projection) { + Some(json) => Some( + serde_json::from_str(json) + .map_err(|_| corrupt("message projection is not a readable directive"))?, + ), + None => None, + }; + Ok(MessageFlags { + truncated: bool_field(values, truncated, "truncated")?, + excluded: bool_field(values, excluded, "excluded")?, + projection, + }) +} + +pub(super) fn corrupt(detail: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Corrupt { + detail: detail.to_owned(), + }) +} + +fn text_field(values: &[Value], index: usize, what: &str) -> SessionResourceResult { + text_at(values, index) + .map(str::to_owned) + .ok_or_else(|| corrupt(&format!("{what} is missing or not text"))) +} + +fn optional_text_field(values: &[Value], index: usize) -> Option { + text_at(values, index).map(str::to_owned) +} + +fn int_field(values: &[Value], index: usize, what: &str) -> SessionResourceResult { + int_at(values, index).ok_or_else(|| corrupt(&format!("{what} is missing or not an integer"))) +} + +fn bool_field(values: &[Value], index: usize, what: &str) -> SessionResourceResult { + match int_at(values, index) { + Some(0) => Ok(false), + Some(1) => Ok(true), + _ => Err(corrupt(&format!("{what} is not a boolean flag"))), + } +} + +fn timestamp_field( + values: &[Value], + index: usize, + what: &str, +) -> SessionResourceResult> { + text_at(values, index) + .and_then(|text| text.parse::>().ok()) + .ok_or_else(|| corrupt(&format!("{what} is not an RFC3339 timestamp"))) +} diff --git a/peri-resources/src/sessions/remote/session_data.rs b/peri-resources/src/sessions/remote/session_data.rs new file mode 100644 index 000000000..9f32a9ca0 --- /dev/null +++ b/peri-resources/src/sessions/remote/session_data.rs @@ -0,0 +1,760 @@ +//! 远程会话数据 adapter:把 [`SessionDataPort`] 的会话行为落到远端 schema 上。 +//! +//! ## 本阶段落地范围(C-03 第一、二批) +//! +//! | 行为 | 状态 | +//! | --- | --- | +//! | `load_snapshot` / `load_meta` / `load_binding` / `load_session_history` | 已实现(一致读取;flags 由一致快照一并返回) | +//! | `list_sessions`(scoped 分页)/ `list_children` / `list_session_tree` | 已实现 | +//! | `save_new_session` | 已实现(meta + 绑定 + frozen 一次落库) | +//! | `save_fork` | 已实现(映射后的 payload + flags,source 不变) | +//! | `save_child` | 已实现(父子/根归属 + 继承区 + root frozen 原文) | +//! | `update_meta`(title/status/cancel_policy/config 定向更新) | 已实现 | +//! | `append_history` / `apply_message_projections` / `apply_compaction` | 已实现([`super::session_history`]:批内守卫,整批生效或整批不生效) | +//! | `rewind_history`(显式两边界)/ `remove_history_entries` | 已实现(同上;未知截止点保持无变更语义) | +//! | `delete_tree` / `revoke_unpublished_session` / `adopt_legacy_session` | 已实现([`super::session_lifecycle`];远端无墓碑/执行行,删除是刻意删除数据事实) | +//! | `load_child_resume_record` / `store_child_resume_record` | 已实现(`agent_status` + 由状态派生的认领标记) | +//! | `drain` | 已实现为「无队列可排空,但未结清不算已排空」(见方法文档) | +//! | `close` | 已实现(真正关闭连接,之后写入明确失败) | +//! | `recover_persistence` | 已实现:本机已无可求证的 durable 记录,会话数据可读即 `Recovered`(跨进程未决判定随 v10 撤销) | +//! +//! 写入路径:**每次调用唯一操作 id + 资格先于效果**(远端账本 `peri_op_ledger` 的资格写入 +//! 与业务效果在同一个托管批里同生共死)。操作 id 不由内容派生,因此同内容的第二次、第三次 +//! 领域调用都是新操作(状态 A→B→A、标题 x→y→x 不再被当成重放丢弃);输入摘要只用于一致性 +//! 校验。v10 撤销本机操作日志后,不再有「发送前本机落盘、确定终态才结清」这一步,未结清只 +//! 在活跃租约上表达(见 `recover_persistence` 的方法文档)。等价的公开行为仍只有门面暴露的 +//! 那 33 条——adapter 不另立一套平行行为。 +//! +//! 本机执行事实不在本模块:workspace 证据、执行代际与 OS 锁由本机 `LocalExecution` 持有 +//! (见 `sessions::local_port`)。adapter 只回答 canonical 数据事实。 +//! +//! 打开的两种访问模式: +//! +//! - 可写打开:读回身份(未初始化则先建身份),再执行一次幂等 DDL 补齐会话表形状; +//! - 只读打开:只读回身份,不写任何东西;若 store 尚无本任务 schema(身份或会话表缺失), +//! 直接 `Unsupported`——没有 schema 就没有会话事实可读,也不越权建表。 +//! +//! ## 边界(远程不做本机的事) +//! +//! - 不持有本机执行事实:workspace 证据与执行代际只在本机库,远端没有这些事实; +//! - 不解析本机目录、不发执行资格、不判定 legacy:`LegacyConfirmed` 与执行准入由门面与 +//! 执行面按本机证据判定(这也是 `load_binding` 只回答绑定事实的原因); +//! - 不保存派生缓存:`threads.cached_context` / `context_cache_epoch` 与本机同列(同一份 DDL, +//! 形状不能各自漂移),但远端没有「读缓存」这个消费者——远端不读它,只在历史变更时按同一份 +//! 语句把它归位(`session_history::REFRESH_COUNTS_SQL`)。 + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + BindingState, ChildSnapshot, ForkSnapshot, FrozenSnapshotBytes, NewSession, + PersistenceRecovery, RewindBoundary, SessionMetaPatch, SessionResourceError, + SessionResourceErrorKind, SessionResourceResult, SessionSnapshot, +}; +use peri_acp_types::store::{CompactionChange, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{ThreadId, ThreadMeta}; +use peri_acp_types::workspace::{ + ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, SessionBinding, +}; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::sessions::data::{ChildResumeRecord, SessionDataPort}; + +use super::credentials::SessionStoreCredential; +use super::endpoint::RemoteEndpoint; +use super::generation::{ConnectionFactory, ConnectionGate, RemoteConnectionFactory}; +use super::mutation::{incomplete_reply, RemoteStore, StoreAccess}; +use super::schema::{self, StoreId, StoreIdentityOutcome, StoreIdentityRead}; +use super::session_schema; +use super::sql::{int_at, StatementSpec}; +use turso_serverless::Value; + +/// 本次打开对远端 store 身份做了什么:首次登记资格的唯一证据。 +/// +/// 「远端此前为空」只由**本次打开建立身份**证明;读到既有身份说明这个 store 已经有数据, +/// 无论本机是否第一次见到它,都不构成自动认领的理由。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum StoreInitialization { + /// 远端此前已有本任务 schema(本次只读回身份)。 + Existing, + /// 远端此前明确为空,本次打开建立了 store 身份。 + CreatedByThisOpen, +} + +/// 只读身份读取之后的下一步(纯函数结论)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum OpenStep { + /// 已有本构建认识的身份:本次打开没有建立任何东西。 + Existing(StoreId), + /// 明确为空:写打开在这里才继续初始化;只读打开到这一步就拒绝。 + NeedsInitialization, +} + +/// 身份读取 + 访问意图 → 打开的下一步(纯函数,真引擎 seam 与生产共用)。 +/// +/// 三条拒绝路径都不猜:版本/契约不认识、元数据形状不可解释、只读打开遇上尚未初始化的 +/// store(没有 schema 就没有会话事实可读,也不越权建表)。 +pub(super) fn open_step( + read: StoreIdentityRead, + access: StoreAccess, +) -> SessionResourceResult { + match read { + StoreIdentityRead::Present(snapshot) if snapshot.matches_build() => { + Ok(OpenStep::Existing(snapshot.store_id)) + } + StoreIdentityRead::Present(_) => { + Err(unsupported_behavior("unrecognized remote store schema")) + } + StoreIdentityRead::Malformed => Err(super::session_codec::corrupt( + "remote session store metadata is not interpretable", + )), + StoreIdentityRead::Uninitialized => match access { + StoreAccess::ReadOnly => Err(unsupported_behavior( + "read-only open of an uninitialized remote store", + )), + StoreAccess::ReadWrite => Ok(OpenStep::NeedsInitialization), + }, + } +} + +/// 身份竞争的结论 → 权威身份 + 本次打开的初始化事实(首次登记资格的唯一映射)。 +/// +/// `CreatedByThisOpen` 只能由 `Created` 产生:竞败方读回的胜者身份也是 `Existing`, +/// 所以败方没有首次登记资格,但它用的仍是同一个权威身份。 +pub(super) fn open_verdict(outcome: StoreIdentityOutcome) -> (StoreId, StoreInitialization) { + match outcome { + StoreIdentityOutcome::Created(store_id) => { + (store_id, StoreInitialization::CreatedByThisOpen) + } + StoreIdentityOutcome::Existing(store_id) => (store_id, StoreInitialization::Existing), + } +} + +/// 旧形状探测(只读、单条 SELECT):写打开遇上未初始化的库时,先问一次「这里有没有 +/// 统一之前的远端会话表」。 +/// +/// 命中即拒绝(`Unsupported`):这不是空库,而是一个本构建不认识的旧形状库。 +/// **不自动迁移**——迁移要重命名表并搬运每一行,而本段的运行前提是新库没有历史数据; +/// **也不覆盖**——覆盖等于替使用者丢掉他看不见的数据。只发一条只读语句,不建表、不写行。 +async fn refuse_legacy_shape(store: &RemoteStore) -> SessionResourceResult<()> { + let row = store + .fetch_row(&StatementSpec::new( + schema::COUNT_LEGACY_TABLES_SQL, + schema::LEGACY_SHAPE_TABLES + .iter() + .map(|table| Value::Text((*table).to_owned())) + .collect(), + )) + .await?; + // 读不到计数不是「没有旧表」:形状不完整的读取按未决上报,不放行初始化。 + let count = row + .as_ref() + .and_then(|values| int_at(values, 0)) + .ok_or_else(|| incomplete_reply("legacy shape probe returned no count"))?; + if count > 0 { + return Err(unsupported_behavior( + "remote store has the pre-unification session tables; it is not migrated automatically", + )); + } + Ok(()) +} + +/// 远端会话数据 adapter:一个已初始化(或已读回身份)的远程 store 上的会话行为。 +/// +/// 连接在关闭**确认**之前一直活着:服务期间留在槽里;关闭一开始就离开业务路径、移进关闭 +/// 句柄([`ClosingConnection`]),由它保留真实资源并允许重试,`close` 成功返回才算确认。 +/// 一次已经发出的在途请求被丢弃(超时、取消)之后,用到它的那一代连接被记为失效, +/// **下一次访问按同一份打开事实重建**(见 [`Self::store`])。关闭开始之后(无论上一次成功 +/// 与否)再没有重连:槽里没有可服务的连接时如实返回关闭错误,绝不用一次重连把关闭事实盖掉。 +/// +/// 本机**不再**持有远端操作的日志(v10 删除了 `session_remote_operations`):远端账本 +/// (`peri_op_ledger`)仍按「资格先于效果」写,但它是**远端**事实,本机不复制。跨进程重启 +/// 后没有「按原 id 求证终态」这条路径,未结清只在本进程的租约上表达。 +pub(super) struct RemoteSessionData { + /// 连接的生命周期槽位:服务中,或关闭中(含已确认关闭)。 + slot: RwLock, + /// 建立连接的地方(本 crate 唯一持有凭证处);重建不改变打开事实。 + factory: Arc, + /// 连接代际门禁:哪一代已经不可信。 + gate: Arc, + store_id: StoreId, + /// thread → root 解析缓存:父关系创建后不变(本 adapter 不提供改父行为),因此 + /// 同一条会话只需一次远端上溯;解析失败不缓存,避免把网络失败固化成事实。 + roots: RwLock>, +} + +impl RemoteSessionData { + /// 打开远程会话数据:读回 store 身份;未初始化时只在可写打开下创建本任务 schema。 + /// + /// 返回值里的 [`StoreInitialization`] 是「首次登记」的唯一证据,且只有一个来源: + /// [`open_verdict`] 对身份竞争结论([`StoreIdentityOutcome`])的映射——只有本事务确切 + /// 插入元数据行并提交(`Created`)才是 `CreatedByThisOpen`。初始化结果未知(丢响应、 + /// 超时)会让本次打开直接失败:既不发身份,也不发创建事实。 + /// + /// 三条拒绝路径都不猜:版本/契约不认识、元数据形状不可解释、只读打开遇上尚未初始化的 + /// store(没有 schema 就没有会话事实可读,也不越权建表)。 + pub(super) async fn open( + endpoint: &RemoteEndpoint, + credential: &SessionStoreCredential, + access: StoreAccess, + ) -> SessionResourceResult<(Self, StoreInitialization)> { + let gate = Arc::new(ConnectionGate::default()); + let factory: Arc = Arc::new(RemoteConnectionFactory::new( + endpoint.clone(), + credential.duplicate(), + access, + Arc::clone(&gate), + )); + let store = factory.connect().await?; + let (store_id, initialization) = match open_step(store.read_identity().await?, access)? { + OpenStep::Existing(store_id) => (store_id, StoreInitialization::Existing), + // 空库:写打开在这里才参与身份竞争,结论由本事务的结果给出(见 [`open_verdict`]), + // 不由「刚才读到空库」推定创建。 + OpenStep::NeedsInitialization => { + // 空库与「旧形状的库」在身份读取上都读作未初始化:先问一次后者,命中即拒绝。 + refuse_legacy_shape(&store).await?; + open_verdict(store.initialize_store().await?) + } + }; + // 可写打开时补齐本构建的会话表形状:DDL 全部 `IF NOT EXISTS`,既有对象不改写、 + // 不覆盖,已初始化的 store 上是一次幂等的空操作。这一步不能只在「身份刚建立」时 + // 跑——身份早于会话表建立的 store(例如只做过机制实测的库)同样需要补齐。 + if access == StoreAccess::ReadWrite { + // 父行检查先归位:canonical 形状里的外键在远端没有可满足的父行(见方法文档)。 + store.force_parent_checks_off().await?; + store + .apply_schema(session_schema::initialization_plan()) + .await?; + } + Ok(( + Self { + slot: RwLock::new(ConnectionSlot::serving(store)), + factory, + gate, + store_id, + roots: RwLock::new(HashMap::new()), + }, + initialization, + )) + } + + /// 操作所属 root:已有会话沿父链上溯(带缓存),解析失败退回自身。 + /// + /// 退回自身只影响未决阻塞的范围(这一条会话而不是整棵树),不会把未知写当成已知: + /// 该记录仍然是未结清状态,门禁照常阻塞该会话。失败结果**不**进缓存。 + pub(super) async fn root_for(&self, id: &ThreadId) -> ThreadId { + if let Some(root) = self.roots.read().await.get(id) { + return root.clone(); + } + match self.root_of(id).await { + Ok(root) => { + self.roots.write().await.insert(id.clone(), root.clone()); + root + } + Err(_) => id.clone(), + } + } + + /// 取当前连接(已就绪的借用)。 + /// + /// 只有「槽里还有一条**服务中**的连接、但这一代已被记为失效」才重建,而且**只重建一次**: + /// 失败如实上报,下一次调用再试。槽里没有可服务的连接(关闭已经开始、关闭已确认,或 + /// 关闭态装配)一律返回关闭错误——一次重连绝不能把「已经开始的关闭」变回来。 + /// + /// 重建判定与取守卫之间还有一个空窗:另一条在途调用被放弃(超时、取消)会把**当前**这一代 + /// 记为失效。拿到读锁之后再复核一次,就不会把已知失效的连接交出去——本调用什么都没发, + /// 只如实失败([`Unavailable`](SessionResourceErrorKind::Unavailable)),下一次访问按它重建。 + /// 不做自动重试:重建是下一次访问的事,本次绝不把请求发到一条已经不可证明的连接上。 + pub(super) async fn store(&self) -> SessionResourceResult> { + self.reconnect_if_retired().await?; + let slot = self.slot.read().await; + let store = RwLockReadGuard::try_map(slot, ConnectionSlot::serving_store) + .map_err(|_| connection_closed())?; + if self.gate.is_invalid(store.generation()) { + return Err(retired_connection()); + } + Ok(store) + } + + /// 失效代际的重建:建立新连接、重核实身份、替换槽位、关闭退场连接。 + async fn reconnect_if_retired(&self) -> SessionResourceResult<()> { + let Some(retired) = self.retired_generation().await else { + return Ok(()); + }; + let replacement = self.factory.connect().await?; + if let Err(error) = self.verify_reconnect(&replacement).await { + // 没能证明「还是这个库」的新连接不装回槽里:关闭它,按原错误上报。 + retire(replacement).await; + return Err(error); + } + let replaced = { + let mut slot = self.slot.write().await; + match slot.serving_store().map(RemoteStore::generation) { + // 槽在重建期间失去服务中的连接(关闭开始把连接移进关闭句柄):不复活,不装回去。 + None => { + drop(slot); + retire(replacement).await; + return Err(connection_closed()); + } + // 别的调用已经重建好了:自己这条一次请求都没发过,丢弃即可。 + Some(current) if current != retired => { + drop(slot); + retire(replacement).await; + return Ok(()); + } + Some(_) => slot.replace_serving(replacement), + } + }; + // 退场连接的关闭在锁外做,而且不阻塞其它访问:重建路径不能因为一次关闭把别人堵住。 + if let Some(replaced) = replaced { + retire(replaced).await; + } + Ok(()) + } + + /// 当前**服务中**的连接是否已被记为失效;返回那一代的代际号。 + /// + /// 关闭中的槽没有服务中的连接,也就没有「重建」这回事(返回 `None`:业务读如实失败, + /// 不重连)。 + async fn retired_generation(&self) -> Option { + let slot = self.slot.read().await; + let store = slot.serving_store()?; + self.gate + .is_invalid(store.generation()) + .then(|| store.generation()) + } + + /// 重建后的重核实:新连接必须就是**同一个** store,且本构建认识它。 + /// + /// 只读检查——不建表、不初始化、不登记:重连不改变任何事实,只证明「还是这个库」。 + /// 只读打开因此与可写打开走同一条重建路径,且不产生任何写入。 + async fn verify_reconnect(&self, store: &RemoteStore) -> SessionResourceResult<()> { + match store.read_identity().await? { + StoreIdentityRead::Present(snapshot) if !snapshot.matches_build() => Err( + unsupported_behavior("unrecognized remote store schema after reconnect"), + ), + StoreIdentityRead::Present(snapshot) + if snapshot.store_id.as_str() != self.store_id.as_str() => + { + Err(unsupported_behavior( + "reconnect reached a different remote store", + )) + } + StoreIdentityRead::Present(_) => Ok(()), + StoreIdentityRead::Malformed => Err(super::session_codec::corrupt( + "remote session store metadata is not interpretable after reconnect", + )), + StoreIdentityRead::Uninitialized => Err(unsupported_behavior( + "reconnected remote store has no session schema", + )), + } + } + + /// 装载故障计划(仅测试构建):把「响应丢失」「发出前丢弃」变成可控观察点, + /// 走的是同一套真实批、真实账本与真实恢复路径。 + #[cfg(test)] + pub(super) async fn inject_faults(&self, plan: super::mutation::FaultPlan) { + if let Some(store) = self.slot.read().await.serving_store() { + store.inject_faults(plan); + } + } + + /// 测试装配:连接已关闭的 adapter(不连网,与 `close` 之后的状态同一个形状)。 + /// + /// 这种装配下任何一次 store 访问都只会失败,所以「输入不自洽时仍然拿到 `InvalidInput`」 + /// 就证明判定发生在取连接之前、也没有写下任何本机记录。 + /// + /// 注意这与「关闭过一次但没成功」**不同**:那种情况下连接仍被保留在关闭句柄里, + /// 关闭可以重试(见 [`RemoteSessionData::close`]);这里从来没有过连接可关。 + #[cfg(test)] + pub(super) fn closed_for_test(store_id: StoreId) -> Self { + Self { + slot: RwLock::new(ConnectionSlot::default()), + factory: Arc::new(NoConnectionFactory), + gate: Arc::new(ConnectionGate::default()), + store_id, + roots: RwLock::new(HashMap::new()), + } + } + + /// 测试装配:连接由调用方给定的工厂与首条连接构成(故障可控,不连网)。 + /// + /// 首条连接与工厂共用同一份代际门禁:失效、重建与「迟到任务不碰新连接」的判定与生产 + /// 完全一致,测试只是把传输面换成能确定复现故障的实现。 + #[cfg(test)] + pub(super) fn with_connection_for_test( + store_id: StoreId, + connection: RemoteStore, + factory: Arc, + gate: Arc, + ) -> Self { + Self { + slot: RwLock::new(ConnectionSlot::serving(connection)), + factory, + gate, + store_id, + roots: RwLock::new(HashMap::new()), + } + } +} + +/// 关闭态装配用的连接工厂:任何一次重建都明确失败(关闭的实例不重连)。 +#[cfg(test)] +struct NoConnectionFactory; + +#[cfg(test)] +#[async_trait] +impl ConnectionFactory for NoConnectionFactory { + async fn connect(&self) -> SessionResourceResult { + Err(connection_closed()) + } +} + +/// 退场连接的收尾:尝试关闭并记下结果。 +/// +/// 关闭失败**不**掩盖本次重建的结论:这一代本来就已经不可信,重连的意义是让后续访问有路 +/// 可走;但也不能悄悄丢掉——失败按诊断记录,不含凭证、URL 或语句内容。 +async fn retire(store: RemoteStore) { + if store.close().await.is_err() { + tracing::debug!("retired remote connection did not close cleanly"); + } +} + +/// 连接的生命周期槽位:**服务中**,或**关闭中**(含已确认关闭)。 +/// +/// 两个字段互斥,翻转点只有一个([`Self::begin_close`]):取走 `serving`、装上 `closing`。 +/// 空槽(两者皆空)只属于「从来没有过连接」的装配。 +struct ConnectionSlot { + /// 服务中的连接;关闭开始后为空——业务读不再经过它,也不会因此重连。 + serving: Option, + /// 关闭句柄;关闭开始后一直保留(含已确认关闭),真实资源的关闭进度在它里面。 + closing: Option>, +} + +impl ConnectionSlot { + /// 装配一条服务中的连接。 + fn serving(store: RemoteStore) -> Self { + Self { + serving: Some(store), + closing: None, + } + } + + /// 服务中的连接(关闭中、空槽为 `None`)。 + fn serving_store(&self) -> Option<&RemoteStore> { + self.serving.as_ref() + } + + /// 换掉服务中的连接,返回退场的那一条;只在确认槽里有服务中的连接时调用。 + fn replace_serving(&mut self, store: RemoteStore) -> Option { + self.serving.replace(store) + } + + /// 开始关闭(幂等):把服务中的连接移进关闭句柄;已经在关闭中时复用同一个句柄。 + /// + /// `None` 只出现在空槽(从来没有过连接):这与「关闭失败」**不同**——失败时句柄已经被 + /// 保留下来,重试关闭的是同一条真实连接。 + fn begin_close(&mut self) -> Option> { + if let Some(closing) = &self.closing { + return Some(Arc::clone(closing)); + } + let store = self.serving.take()?; + let closing = Arc::new(ClosingConnection::new(store)); + self.closing = Some(Arc::clone(&closing)); + Some(closing) + } +} + +impl Default for ConnectionSlot { + /// 空槽:连接从来没有过(关闭态装配)。 + fn default() -> Self { + Self { + serving: None, + closing: None, + } + } +} + +/// 关闭句柄:连接退出业务路径之后,真实资源的关闭进度被保留在这里。 +/// +/// 唯一句柄在**句柄里**而不在关闭 future 里:调用方超时、取消或整个 future 被丢弃都不会丢 +/// 连接。关闭失败或被取消都不确认([`CloseProgress::Unconfirmed`]),下一次调用在**同一条 +/// 连接**上继续关——不新建连接,也不把一次未确认的失败固化成永久错误。 +struct ClosingConnection { + /// 待关闭的连接:确认关闭成功之前一直保留。 + connection: RemoteStore, + /// 关闭尝试的串行点:并发关闭共享同一次真实关闭的结论,成功只发生一次。 + progress: tokio::sync::Mutex, +} + +/// 真实关闭的进度:只有「已确认成功」会让关闭不再重做。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CloseProgress { + /// 尚未成功(从未尝试,或上一次失败/被取消):允许重试。 + Unconfirmed, + /// 已确认成功:不再重复关闭同一条连接,后续关闭幂等成功。 + Confirmed, +} + +impl ClosingConnection { + fn new(connection: RemoteStore) -> Self { + Self { + connection, + progress: tokio::sync::Mutex::new(CloseProgress::Unconfirmed), + } + } + + /// 真实关闭;并发调用被串行化,成功只发生一次。 + /// + /// 失败如实上报且**不**确认:连接仍在句柄里,下一次调用在同一条连接上继续关闭。 + /// 调用 future 被丢弃只放弃这一次尝试——句柄与进度都不在 future 里,因此什么也不会丢。 + async fn shutdown(&self) -> SessionResourceResult<()> { + let mut progress = self.progress.lock().await; + if *progress == CloseProgress::Confirmed { + return Ok(()); + } + self.connection.close().await?; + *progress = CloseProgress::Confirmed; + Ok(()) + } +} + +/// 槽里没有可服务的连接(关闭已经开始、关闭已确认,或从来没有过连接):不是「没有连接就没问题」。 +fn connection_closed() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Internal { + detail: "remote session store connection is closed".to_owned(), + }) +} + +/// 这一代在守卫交出去之前就被记为失效:本调用**没有发出任何请求**。 +/// +/// 保守归类为 `Unavailable`(连接这一刻不可用),而不是「关闭」或「契约」类错误:这不是 +/// 会话数据的结论,也不代表远端拒绝;下一次访问会按同一份打开事实重建。 +fn retired_connection() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Unavailable { + detail: "remote session store connection was retired before use".to_owned(), + }) +} + +pub(super) fn not_found() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::NotFound) +} + +pub(super) fn invalid_input(detail: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: detail.to_owned(), + }) +} + +/// 本阶段尚未落地的行为:明确失败,并留下行为名便于诊断(不含任何会话内容)。 +fn unsupported_behavior(behavior: &'static str) -> SessionResourceError { + tracing::debug!(behavior, "remote session data behavior is not implemented"); + SessionResourceError::new(SessionResourceErrorKind::Unsupported) +} + +#[async_trait] +impl SessionDataPort for RemoteSessionData { + async fn save_new_session(&self, input: &NewSession) -> SessionResourceResult<()> { + self.write_new_session(input).await + } + + async fn revoke_unpublished_session(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.revoke_unpublished(id).await + } + + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + self.adopt_legacy(id, saved_cwd, workspace, frozen).await + } + + async fn load_snapshot(&self, id: &ThreadId) -> SessionResourceResult { + self.read_snapshot(id).await + } + + async fn load_binding(&self, id: &ThreadId) -> SessionResourceResult { + self.read_binding_state(id).await + } + + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + self.read_history(id).await + } + + async fn load_meta(&self, id: &ThreadId) -> SessionResourceResult { + self.read_meta(id).await + } + + async fn session_exists(&self, id: &ThreadId) -> SessionResourceResult { + self.exists(id).await + } + + async fn binding_of(&self, id: &ThreadId) -> SessionResourceResult> { + self.binding_of(id).await + } + + async fn session_root(&self, id: &ThreadId) -> SessionResourceResult { + // 远端父链的根;上溯失败按「解析不出」退回自身(范围变窄,但不会把未知当成已知)。 + Ok(self.root_for(id).await) + } + + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult { + self.read_page(query).await + } + + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult> { + self.read_children(parent).await + } + + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult> { + self.read_tree(root).await + } + + async fn append_history( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + self.write_history_append(id, payloads).await + } + + async fn save_fork(&self, fork: &ForkSnapshot) -> SessionResourceResult<()> { + self.write_fork(fork).await + } + + async fn save_child(&self, child: &ChildSnapshot) -> SessionResourceResult<()> { + self.write_child(child).await + } + + async fn load_child_resume_record( + &self, + child: &ThreadId, + ) -> SessionResourceResult { + self.read_child_resume(child).await + } + + async fn store_child_resume_record( + &self, + child: &ThreadId, + record: &ChildResumeRecord, + ) -> SessionResourceResult<()> { + self.write_child_resume(child, record).await + } + + async fn apply_compaction( + &self, + id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()> { + self.write_compaction_change(id, change).await + } + + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()> { + self.write_projections(id, updates).await + } + + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + self.write_rewind(id, boundary).await + } + + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + self.write_history_removal(id, ids).await + } + + async fn update_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + self.write_meta(id, patch).await + } + + async fn delete_tree(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.write_tree_deletion(id).await + } + + /// 远端未决收敛:拿本机日志里的操作 id 向远端账本求证终态(C §5.1 第 4 步)。 + /// + /// 每条未结清记录只有三种结论,且都由**写确认过的证据**给出,不靠空查询或超时推断: + /// + /// - 账本行已生效 → 结清 `applied`(原操作生效了,不是丢失); + /// - 账本行已封闭 → 结清 `never_applied`; + /// - 账本行不存在 → 用**同一 id** 做终态封闭竞争:封闭先提交则原请求此后不可能再生效 + /// (结清 `never_applied`);竞争发现对方已提交则读回收据(结清 `applied`); + /// 封闭本身未确认则保持未结清。 + /// + /// 账本行存在但不可解释时保持未结清:无法证明就不是收敛。全部结清后才看本机还有没有 + /// 别的未决事实,两者都为空才报告 `Recovered`。 + /// + /// **封闭与提交互斥**由唯一键给出,不由时序给出:封闭写的是与资格写**同一个主键**, + /// 本机**没有**远端操作的日志(v10 删除了 `session_remote_operations`,用户裁决不做 + /// 跨安装能力),因此没有「按本机记录里的原操作 id 逐条向远端求证终态」这条路径可走。 + /// + /// 本方法只回答本机能回答的那部分:本机已没有可证明未结态的 durable 记录,会话数据 + /// 仍可读即可重载。**影响面**:进程崩溃前发出的远端请求若结果未知,本机无法再判定它 + /// 是否生效——这是被撤销的能力,不是遗漏;未结清因此只在活跃租约上表达,崩溃后的未 + /// 结清代际由 `execution_runs.clean = 0` 走既有的显式恢复流程。 + /// + /// 若将来重新引入跨进程未决判定,移除条件是:本机重新持有「发送前登记、确定终态才 + /// 结清」的记录,并且远端账本的终态封闭仍按同一唯一键空间竞争。 + async fn recover_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + // 只读事实:会话数据不存在时不能宣告「已收敛、可重载」。 + self.load_meta(id).await?; + Ok(PersistenceRecovery::Recovered) + } + + /// 远端没有异步写入队列,本机也没有未结清记录可供等待,因此没有可排空的东西。 + /// + /// 在途请求的等待由门面按活跃租约完成(`drain_persistence` 先等 `wait_for_in_flight` + /// 并检查 `is_uncertain`),adapter 自己不做时序假设。 + async fn drain(&self, _id: &ThreadId) -> SessionResourceResult<()> { + Ok(()) + } + + /// 关闭连接:把服务中的连接移进关闭句柄,再真正关闭;之后任何调用都明确失败。 + /// + /// 幂等只适用于**确认关闭**:只有真实关闭成功返回过,后续调用才是幂等成功。关闭一开始 + /// (无论成功与否)连接就离开业务路径且不再重连;失败或取消都不丢句柄——重试关闭的是 + /// **同一条被保留的连接**,不新建连接,也不把「没有连接可用」当成「已经干净关闭」。 + /// + /// 「确认」的范围是**本机传输面关闭成功**(见 [`RemoteStore::close`] 与 `remote` 模块 + /// 文档的 shutdown 定义):它不证明服务端连接已释放,也不证明任何未知的远端写没有生效—— + /// v10 撤销本机操作日志后,本机已没有可以向远端账本求证的 durable 锚点,这条判定只剩下 + /// 「活跃租约上的未结清标记」与「崩溃后 `execution_runs.clean = 0` 的显式恢复」两条路。 + async fn close(&self) -> SessionResourceResult<()> { + // 唯一的翻转点:取走服务中的连接(已经在关闭中时复用同一个句柄)。 + let closing = { self.slot.write().await.begin_close() }; + // 从来没有过连接(关闭态装配):不谎报成功,也不建连接。 + let Some(closing) = closing else { + return Err(connection_closed()); + }; + // 真实关闭:成功才确认(进度记在句柄里,不在这个 future 里);失败如实上报,可重试。 + closing.shutdown().await + } +} diff --git a/peri-resources/src/sessions/remote/session_history.rs b/peri-resources/src/sessions/remote/session_history.rs new file mode 100644 index 000000000..04c75fdcb --- /dev/null +++ b/peri-resources/src/sessions/remote/session_history.rs @@ -0,0 +1,632 @@ +//! 远程会话历史写入:追加、投影/flags、compact、rewind、精确移除。 +//! +//! 形状与 [`super::session_write`] 一致:**一次端口调用 = 一个托管事务批**(资格先于效果), +//! 因此不存在「一半历史生效」的中间可见状态。 +//! +//! ## 批内守卫:为什么不能只靠「受影响行数」 +//! +//! 远端语句是静态 SQL + 绑定参数,引擎不会因为 `UPDATE ... WHERE` 匹配 0 行而失败—— +//! 只看提交后的行数就等于让「目标不存在」以成功收场。这里的做法是**批内守卫语句**: +//! `peri_store_meta` 是单行表(`CHECK (singleton = 0)`),谓词成立时这条 +//! `INSERT ... SELECT` 命中主键冲突,整批随之回滚。它不新增表、不新增失败类别, +//! 也不留下可观察的半状态。 +//! +//! 守卫负责「整批不生效」,**错误类别**按本机 adapter 的归类给出: +//! 前置一致读把「不属于本会话」「不存在」「会话不存在」分成不同结果,守卫保证读与写之间 +//! 有第三方改动时仍然不半成功(那一类会以 `Unknown`/未决上报,不降级成成功)。 +//! +//! ## 派生规则 +//! +//! `message_count` 是存储列,按本机同一规则**重数**(不是自增);`content_size` 不落列, +//! 读取时由投影现算。远端没有 `cached_context`/`context_cache_epoch`:那是本机读取缓存, +//! 远端没有这个消费者,因此历史变更不产生缓存失效动作。 + +use std::collections::HashSet; + +use peri_acp_types::messages::{BaseMessage, MessageId}; +use peri_acp_types::session_resources::{RewindBoundary, SessionResourceResult}; +use peri_acp_types::store::{ + deserialize_persisted_payload, serialize_persisted_payload, CompactionChange, MessageFlags, + PersistedPayload, +}; +use peri_acp_types::thread::ThreadId; +use turso_serverless::Value; + +use super::session_codec as codec; +use super::session_data::{invalid_input, not_found, RemoteSessionData}; +use super::sql::{int_at, StatementSpec}; +use crate::sessions::canonical; +use crate::sessions::sqlite_store::role_of_message; + +// ─── 批内守卫 ───────────────────────────────────────────────────────────────── + +/// 会话不存在:中止整批(归 `NotFound`)。 +const GUARD_SESSION_ABSENT_SQL: &str = "INSERT INTO peri_store_meta(singleton) + SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM threads WHERE id = ?1)"; + +/// 目标条目不属于本会话(不存在,或属于别的会话):中止整批。 +/// +/// `pub(super)` 只为了让云端机制实验用**生产同一条语句**验证守卫在真引擎上的两条分支 +/// (谓词成立 → 整批回滚;不成立 → 一行都不插)。 +pub(super) const GUARD_MESSAGE_NOT_IN_SESSION_SQL: &str = "INSERT INTO peri_store_meta(singleton) + SELECT 0 WHERE NOT EXISTS ( + SELECT 1 FROM messages WHERE thread_id = ?1 AND message_id = ?2)"; + +/// 条目存在但属于别的会话:中止整批(「精确移除」不得跨会话命中)。 +const GUARD_MESSAGE_FOREIGN_SQL: &str = "INSERT INTO peri_store_meta(singleton) + SELECT 0 WHERE EXISTS ( + SELECT 1 FROM messages WHERE message_id = ?1 AND thread_id <> ?2)"; + +// ─── 效果语句 ───────────────────────────────────────────────────────────────── + +/// 追加一条历史行:列清单与 `session_sql::INSERT_MESSAGE_SQL`(以及本机 `messages` 的插入) +/// 同形,`role` 由 canonical payload 派生。 +/// +/// canonical 顺序由 `rowid` 承载:同批内语句按顺序执行,插入序即历史序,不需要 +/// 「先读序号再写」的往返,也不需要远端曾经那列显式 `ordinal`。 +const APPEND_MESSAGE_SQL: &str = "INSERT INTO messages + (message_id, thread_id, role, content, truncated, excluded, projection) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"; + +/// 改写一条条目的 flags(投影与 compact 的 flag 更新共用同一形状)。 +pub(super) const UPDATE_FLAGS_SQL: &str = "UPDATE messages + SET truncated = ?1, excluded = ?2, projection = ?3 + WHERE thread_id = ?4 AND message_id = ?5"; + +/// 重数派生计数并推进 `updated_at`(与本机 `refresh_history_derivations` 同一规则, +/// 含两个缓存失效位:同一条语句在两种执行器上执行)。 +const REFRESH_COUNTS_SQL: &str = "UPDATE threads SET updated_at = ?1, + message_count = (SELECT COUNT(*) FROM messages WHERE thread_id = ?2), + cached_context = NULL, + context_cache_epoch = context_cache_epoch + 1 + WHERE id = ?2"; + +/// 自动标题:只在标题仍缺失时补一次(本机同一规则:`title IS NULL` 才写)。 +const SET_TITLE_IF_ABSENT_SQL: &str = + "UPDATE threads SET title = ?1 WHERE id = ?2 AND title IS NULL"; + +/// rewind 两个显式边界:比较按 `rowid`(canonical 历史顺序的载体,与本机同一句形态)。 +const REWIND_KEEP_THROUGH_SQL: &str = "DELETE FROM messages WHERE thread_id = ?1 AND rowid > ?2"; +const REWIND_REMOVE_FROM_SQL: &str = "DELETE FROM messages WHERE thread_id = ?1 AND rowid >= ?2"; + +/// 精确移除单条条目。 +const REMOVE_MESSAGE_SQL: &str = "DELETE FROM messages WHERE thread_id = ?1 AND message_id = ?2"; + +// ─── 前置一致读(只读) ──────────────────────────────────────────────────────── + +const SELECT_MESSAGE_OWNER_SQL: &str = "SELECT thread_id FROM messages WHERE message_id = ?1"; +const SELECT_ROWID_SQL: &str = + "SELECT rowid FROM messages WHERE thread_id = ?1 AND message_id = ?2"; + +impl RemoteSessionData { + /// 追加 canonical payload 批次:顺序稳定、计数重数、自动标题按本机同一规则补齐。 + /// + /// 冲突语义与本机一致:批次内重复 id 在发请求前拒绝(`InvalidInput`,同一文案); + /// 与已有行(含别会话的行)撞全局主键由约束拒绝整批,同样归 `InvalidInput` + /// (本机走唯一键冲突映射)。会话不存在则 `NotFound`。 + pub(super) async fn write_history_append( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + if payloads.is_empty() { + return Ok(()); + } + let mut seen = HashSet::with_capacity(payloads.len()); + for payload in payloads { + if !seen.insert(payload.id()) { + return Err(invalid_input("history batch repeats a message id")); + } + } + if !self.exists(id).await? { + return Err(not_found()); + } + let contents = payloads + .iter() + .map(payload_content) + .collect::>>()?; + let entries = payloads + .iter() + .zip(&contents) + .map(|(payload, content)| (payload.id(), content.clone())) + .collect::>(); + let mut effects = Vec::with_capacity(payloads.len() + 2); + for (payload, (message, content)) in payloads.iter().zip(&entries) { + effects.push(append_statement( + id, + *message, + content, + canonical::payload_role(payload), + )); + } + if let Some(title) = title_statement(id, payloads) { + effects.push(title); + } + effects.push(refresh_statement(id, ×tamp())); + let mut inputs = vec![id.as_str().to_owned()]; + inputs.extend(entry_inputs(&entries)); + self.commit_effects("append_history", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 应用投影/flags 变更集:整批生效或整批不生效。 + /// + /// 目标必须全部是本会话的条目(本机同一归类:`InvalidInput`),一个都不改一半。 + pub(super) async fn write_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()> { + if updates.is_empty() { + return Ok(()); + } + let messages = updates + .iter() + .map(|(message, _)| *message) + .collect::>(); + if !self + .missing_session_messages(id, &messages) + .await? + .is_empty() + { + return Err(invalid_input( + "projection target is not a history entry of this session", + )); + } + let mut effects = Vec::with_capacity(updates.len() * 2 + 1); + let mut inputs = vec![id.as_str().to_owned()]; + for (message, flags) in updates { + effects.push(guard_message_statement(id, *message)); + effects.push(flag_statement(id, *message, flags)); + inputs.push(flags_label(*message, flags)); + } + effects.push(refresh_statement(id, ×tamp())); + self.commit_effects("apply_message_projections", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 应用一次 compaction 变更:flags、追加条目与派生计数一起生效或一起不生效。 + /// + /// `flag_updates` 的目标必须都在本会话里。本机把「目标不在会话内」折成 `Corrupt` + /// (`write_failure` 的兜底归类),这里**照实对齐**,不在远端自创类别:若要改归类, + /// 应同时改两个 adapter,而不是让远端先分叉。 + pub(super) async fn write_compaction_change( + &self, + id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()> { + let flagged = change + .flag_updates + .iter() + .map(|(message, _)| *message) + .collect::>(); + if !self + .missing_session_messages(id, &flagged) + .await? + .is_empty() + { + return Err(codec::corrupt( + "compaction flag target is not a history entry of this session", + )); + } + let appended = change + .appended_messages + .iter() + .map(appended_content) + .collect::>>()?; + let mut effects = Vec::with_capacity(change.flag_updates.len() * 2 + appended.len() + 2); + // 计数重数与标题补齐都作用在会话行上:会话不存在要明确失败,不让 0 行更新成成功。 + effects.push(guard_session_statement(id)); + let mut inputs = vec![id.as_str().to_owned()]; + for (message, flags) in &change.flag_updates { + effects.push(flag_statement(id, *message, flags)); + inputs.push(flags_label(*message, flags)); + } + let entries = change + .appended_messages + .iter() + .zip(&appended) + .map(|(message, content)| (message.id(), content.clone())) + .collect::>(); + for (message, (message_id, content)) in change.appended_messages.iter().zip(&entries) { + effects.push(append_statement( + id, + *message_id, + content, + role_of_message(message), + )); + } + inputs.extend(entry_inputs(&entries)); + effects.push(refresh_statement(id, ×tamp())); + self.commit_effects("apply_compaction", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 按显式边界 rewind。 + /// + /// 边界不在本会话里时**保持无变更并成功**——这是本机的既有语义(未知截止点不动历史), + /// 不是「静默忽略失败」。边界存在时以它的 `rowid` 为界:`KeepThrough` 保留到该条为止, + /// `RemoveFrom` 从该条起删除,随后重数计数。 + pub(super) async fn write_rewind( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + let message = boundary.message_id(); + let Some(rowid) = self.boundary_rowid(id, message).await? else { + return Ok(()); + }; + let (sql, direction) = match boundary { + RewindBoundary::KeepThrough(_) => (REWIND_KEEP_THROUGH_SQL, "keep_through"), + RewindBoundary::RemoveFrom(_) => (REWIND_REMOVE_FROM_SQL, "remove_from"), + }; + let effects = vec![ + StatementSpec::new( + sql, + vec![Value::Text(id.as_str().to_owned()), codec::int_value(rowid)], + ), + refresh_statement(id, ×tamp()), + ]; + // 方向进摘要:同一个边界上的 `KeepThrough` 与 `RemoveFrom` 是**两个操作**, + // 只按 (会话, 边界, rowid) 摘要会让后者撞上前者的操作 id,被当成重放静默跳过。 + let inputs = vec![ + id.as_str().to_owned(), + direction.to_owned(), + message_label(message), + rowid.to_string(), + ]; + self.commit_effects("rewind_history", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 按 id 集合精确移除历史条目。 + /// + /// 与本机同一组语义:不存在的条目是**幂等删除**(不报错),属于别的会话的条目是 + /// `InvalidInput`(不静默跳过,也不跨会话删除);去重后一次成批。 + pub(super) async fn write_history_removal( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + if ids.is_empty() { + return Ok(()); + } + let mut seen = HashSet::with_capacity(ids.len()); + let unique = ids + .iter() + .copied() + .filter(|message| seen.insert(*message)) + .collect::>(); + let owners = self.message_owners(&unique).await?; + for (_, owner) in unique.iter().zip(&owners) { + if owner.as_deref().is_some_and(|owner| owner != id.as_str()) { + return Err(invalid_input("history entry belongs to another session")); + } + } + let mut effects = Vec::with_capacity(unique.len() * 2 + 1); + let mut inputs = vec![id.as_str().to_owned()]; + for message in &unique { + effects.push(StatementSpec::new( + GUARD_MESSAGE_FOREIGN_SQL, + vec![ + Value::Text(message_label(*message)), + Value::Text(id.as_str().to_owned()), + ], + )); + effects.push(StatementSpec::new( + REMOVE_MESSAGE_SQL, + vec![ + Value::Text(id.as_str().to_owned()), + Value::Text(message_label(*message)), + ], + )); + inputs.push(message_label(*message)); + } + effects.push(refresh_statement(id, ×tamp())); + self.commit_effects("remove_history_entries", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 本会话里缺失的消息 id(不存在,或属于别的会话)。 + async fn missing_session_messages( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let owners = self.message_owners(ids).await?; + Ok(ids + .iter() + .zip(&owners) + .filter(|(_, owner)| owner.as_deref() != Some(id.as_str())) + .map(|(message, _)| *message) + .collect()) + } + + /// 一次只读请求读回每个消息 id 的归属会话。 + async fn message_owners( + &self, + ids: &[MessageId], + ) -> SessionResourceResult>> { + let statements = ids + .iter() + .map(|message| { + StatementSpec::new( + SELECT_MESSAGE_OWNER_SQL, + vec![Value::Text(message_label(*message))], + ) + }) + .collect::>(); + let store = self.store().await?; + let batches = store.read_batch(statements).await?; + Ok(batches + .into_iter() + .map(|mut rows| { + rows.pop() + .and_then(|mut row| row.pop()) + .and_then(|value| match value { + Value::Text(owner) => Some(owner), + _ => None, + }) + }) + .collect()) + } + + /// 边界条目在本会话内的 `rowid`;不存在时为 `None`。 + async fn boundary_rowid( + &self, + id: &ThreadId, + message: MessageId, + ) -> SessionResourceResult> { + let store = self.store().await?; + let row = store + .fetch_row(&StatementSpec::new( + SELECT_ROWID_SQL, + vec![ + Value::Text(id.as_str().to_owned()), + Value::Text(message_label(message)), + ], + )) + .await?; + Ok(row.and_then(|values| int_at(&values, 0))) + } +} + +// ─── 语句组装 ───────────────────────────────────────────────────────────────── + +/// 追加语句:`?1` 消息 id、`?2` 会话 id、`?3` role、`?4` 内容、`?5..?7` 默认 flags。 +fn append_statement(id: &ThreadId, message: MessageId, content: &str, role: &str) -> StatementSpec { + StatementSpec::new( + APPEND_MESSAGE_SQL, + vec![ + Value::Text(message_label(message)), + Value::Text(id.as_str().to_owned()), + Value::Text(role.to_owned()), + Value::Text(content.to_owned()), + codec::int_value(0), + codec::int_value(0), + Value::Null, + ], + ) +} + +/// flags 更新语句(`projection` 为已序列化 JSON 或 NULL)。 +fn flag_statement(id: &ThreadId, message: MessageId, flags: &MessageFlags) -> StatementSpec { + StatementSpec::new( + UPDATE_FLAGS_SQL, + vec![ + codec::int_value(i64::from(flags.truncated)), + codec::int_value(i64::from(flags.excluded)), + codec::optional_text(encode_projection(flags).as_deref()), + Value::Text(id.as_str().to_owned()), + Value::Text(message_label(message)), + ], + ) +} + +fn guard_session_statement(id: &ThreadId) -> StatementSpec { + StatementSpec::new( + GUARD_SESSION_ABSENT_SQL, + vec![Value::Text(id.as_str().to_owned())], + ) +} + +fn guard_message_statement(id: &ThreadId, message: MessageId) -> StatementSpec { + StatementSpec::new( + GUARD_MESSAGE_NOT_IN_SESSION_SQL, + vec![ + Value::Text(id.as_str().to_owned()), + Value::Text(message_label(message)), + ], + ) +} + +fn refresh_statement(id: &ThreadId, now: &str) -> StatementSpec { + StatementSpec::new( + REFRESH_COUNTS_SQL, + vec![ + Value::Text(now.to_owned()), + Value::Text(id.as_str().to_owned()), + ], + ) +} + +/// 自动标题语句:没有可用的首条 Human 文本时返回 `None`(不写、也不假装写入)。 +/// +/// 提取规则直接复用本机 adapter 的同一份纯规则(`extract_title`),不在这里重写一遍。 +fn title_statement(id: &ThreadId, payloads: &[PersistedPayload]) -> Option { + let messages = payloads + .iter() + .filter_map(PersistedPayload::as_message) + .cloned() + .collect::>(); + // 领域纯规则:直接调用本机 adapter 用的同一份 `extract_title`,不复制一份到远端。 + let title = crate::sessions::sqlite_store::row_mapping::extract_title(&messages)?; + Some(StatementSpec::new( + SET_TITLE_IF_ABSENT_SQL, + vec![Value::Text(title), Value::Text(id.as_str().to_owned())], + )) +} + +// ─── 编码与标签 ─────────────────────────────────────────────────────────────── + +/// 追加内容的持久化形态:复用 `peri_acp_types::store` 的 envelope,远端不另写格式。 +fn payload_content(payload: &PersistedPayload) -> SessionResourceResult { + serialize_persisted_payload(payload).map_err(|_| corrupt_payload()) +} + +/// compact 追加的是领域消息:先按同一 envelope 定型,读不回来就在发请求前拒绝。 +fn appended_content(message: &BaseMessage) -> SessionResourceResult { + let json = serde_json::to_string(message).map_err(|_| corrupt_payload())?; + deserialize_persisted_payload(&json).map_err(|_| corrupt_payload())?; + Ok(json) +} + +fn corrupt_payload() -> peri_acp_types::session_resources::SessionResourceError { + codec::corrupt("history entry is not serializable") +} + +fn encode_projection(flags: &MessageFlags) -> Option { + flags + .projection + .as_ref() + .and_then(|projection| serde_json::to_string(projection).ok()) +} + +/// 操作摘要里的 flags 标签:保证「同一内容重试命中同一操作 id」。 +fn flags_label(message: MessageId, flags: &MessageFlags) -> String { + let bits = format!( + "{}{}{}", + u8::from(flags.truncated), + u8::from(flags.excluded), + u8::from(flags.projection.is_some()) + ); + format!("{}:flags:{bits}", message_label(message)) +} + +fn message_label(message: MessageId) -> String { + message.as_uuid().to_string() +} + +/// 历史条目的操作身份输入:**消息 id 与内容都要进摘要**。 +/// +/// 只按内容摘要会出现「同内容的两批追加撞同一个操作 id」:第二次会被当成重放静默跳过, +/// 而追加本来不是幂等操作(每次追加都是新的条目)。id 进摘要后,重试同一批(同一批 +/// payload)仍然命中同一个操作 id,重放语义不受影响。 +fn entry_inputs(entries: &[(MessageId, String)]) -> Vec { + let mut inputs = Vec::with_capacity(entries.len() * 2); + for (message, content) in entries { + inputs.push(message_label(*message)); + inputs.push(content.clone()); + } + inputs +} + +fn timestamp() -> String { + chrono::Utc::now().to_rfc3339() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 每条语句的占位符个数必须与它绑定的参数个数一致:这是「静态 SQL + 全绑定」的可核对形式。 + fn placeholders(sql: &str) -> usize { + sql.matches('?').count() + } + + /// 值只能走绑定参数:SQL 文本里不得出现字符串字面量(那才是把内容拼进语句)。 + fn has_no_inline_literal(sql: &str) -> bool { + !sql.contains('\'') + } + + #[test] + fn every_statement_is_static_and_fully_bound() { + let cases: [(&str, usize); 10] = [ + (GUARD_SESSION_ABSENT_SQL, 1), + (GUARD_MESSAGE_NOT_IN_SESSION_SQL, 2), + (GUARD_MESSAGE_FOREIGN_SQL, 2), + // 追加语句里 `?2`(会话 id)被主查询与取序号的子查询共用,因此是 7 个占位符。 + (APPEND_MESSAGE_SQL, 7), + (UPDATE_FLAGS_SQL, 5), + // 同一次「重数 + 推进时间戳」里 `?2` 出现两次。 + (REFRESH_COUNTS_SQL, 3), + (SET_TITLE_IF_ABSENT_SQL, 2), + (REWIND_KEEP_THROUGH_SQL, 2), + (REMOVE_MESSAGE_SQL, 2), + (SELECT_ROWID_SQL, 2), + ]; + for (sql, expected) in cases { + assert_eq!(placeholders(sql), expected, "绑定量与占位符不一致: {sql}"); + assert!(has_no_inline_literal(sql), "语句里出现了字面量: {sql}"); + } + } + + /// 守卫必须落在**单行表**上:`peri_store_meta` 的主键冲突才是「整批回滚」的触发点。 + #[test] + fn guards_abort_the_whole_batch_via_single_row_primary_key() { + for sql in [ + GUARD_SESSION_ABSENT_SQL, + GUARD_MESSAGE_NOT_IN_SESSION_SQL, + GUARD_MESSAGE_FOREIGN_SQL, + ] { + assert!( + sql.starts_with("INSERT INTO peri_store_meta(singleton)"), + "{sql}" + ); + assert!(sql.contains("SELECT 0 WHERE"), "{sql}"); + } + // 会话缺失:谓词是「不存在」;跨会话命中:谓词是「存在且属于别人」。 + assert!(GUARD_SESSION_ABSENT_SQL.contains("NOT EXISTS")); + assert!(GUARD_MESSAGE_NOT_IN_SESSION_SQL.contains("thread_id = ?1 AND message_id = ?2")); + assert!(GUARD_MESSAGE_FOREIGN_SQL.contains("thread_id <> ?2")); + } + + /// 两个 rewind 边界只差一个比较符:`KeepThrough` 保留到该条、`RemoveFrom` 从该条起删。 + #[test] + fn rewind_boundaries_differ_only_in_the_comparison() { + assert!(REWIND_KEEP_THROUGH_SQL.ends_with("rowid > ?2")); + assert!(REWIND_REMOVE_FROM_SQL.ends_with("rowid >= ?2")); + } + + /// 操作身份必须区分「同内容的两批追加」:只按内容摘要会让第二批被当成重放静默跳过。 + #[test] + fn append_identity_inputs_include_message_ids() { + let first = MessageId::new(); + let second = MessageId::new(); + let left = entry_inputs(&[(first, "same content".to_owned())]); + let right = entry_inputs(&[(second, "same content".to_owned())]); + assert_ne!(left, right, "同内容不同消息 id 必须是不同的操作身份"); + assert!(left.contains(&message_label(first))); + assert!(left.contains(&"same content".to_owned())); + } + + /// 追加语句与本机 `messages` 的插入同形:列清单一致、`role` 显式写入, + /// 顺序交给 `rowid`(不再有显式序号列,也就不需要「先读序号再写」)。 + #[test] + fn append_matches_the_canonical_message_insert() { + assert!(APPEND_MESSAGE_SQL.contains("(message_id, thread_id, role, content")); + assert!(!APPEND_MESSAGE_SQL.contains("ordinal")); + assert!(!APPEND_MESSAGE_SQL.contains("MAX(")); + } + + /// 计数是**重数**而不是自增:任何一条历史路径都不会把计数带偏。 + #[test] + fn counts_are_recomputed_not_incremented() { + assert!(REFRESH_COUNTS_SQL.contains("message_count = (SELECT COUNT(*)")); + assert!(!REFRESH_COUNTS_SQL.contains("message_count + 1")); + } + + /// 自动标题只在缺失时补齐:已有标题永远不会被后来的追加改写。 + #[test] + fn title_is_only_written_when_absent() { + assert!(SET_TITLE_IF_ABSENT_SQL.contains("AND title IS NULL")); + } +} diff --git a/peri-resources/src/sessions/remote/session_lifecycle.rs b/peri-resources/src/sessions/remote/session_lifecycle.rs new file mode 100644 index 000000000..45eb409c5 --- /dev/null +++ b/peri-resources/src/sessions/remote/session_lifecycle.rs @@ -0,0 +1,452 @@ +//! 远程会话生命周期写入:legacy 接纳、未发布撤销、删除会话树、child resume 认领事实。 +//! +//! ## 远端没有本机生命周期锚点 +//! +//! 本机侧这些行为会同时写执行行(`execution_runs`)并在删除路径上显式结束所有权:那些是 +//! **本机事实**(同步、回收、执行代际的判据),远端没有也**不得**新增——远端没有第二个副本, +//! 删除就是删除,不存在「删除被复制回来」的路径。所以这里的删除是**刻意删除数据事实本身**, +//! 不是把本机的墓碑语义搬过来。v10 撤销本机登记与未决锚点后,本机也不再持有 +//! `session_lifecycle_commitments` 这类跨进程生命周期表——刻意删除由本机执行面显式结束 +//! 所有权来表达。 +//! +//! ## 一处有意的偏离(已记录,需在门面侧统一) +//! +//! 本机对「目标行不存在」的若干写入路径(`delete_tree` 之外的生命周期 UPDATE)只按 +//! `UPDATE` 是否报错判断,0 行受影响也可能返回成功。远端按端口契约**不把未生效报告成 +//! 成功**:批内守卫会把「会话/子会话不存在」变成明确的 `NotFound`。若要让两个 adapter +//! 在这一路径上完全一致,应改本机侧,而不是让远端退回「0 行也算成功」。 + +use std::str::FromStr; + +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, SessionResourceError, SessionResourceErrorKind, SessionResourceResult, +}; +use peri_acp_types::thread::{AgentStatus, ThreadId}; +use peri_acp_types::workspace::{ResolvedWorkspace, SessionBinding, WorkspaceError}; +use turso_serverless::Value; + +use super::mutation::incomplete_reply; +use super::session_codec as codec; +use super::session_data::{invalid_input, not_found, RemoteSessionData}; +use super::session_sql::binding_relative_text; +use super::sql::{int_at, text_at, StatementSpec}; +use crate::sessions::canonical; +use crate::sessions::data::ChildResumeRecord; + +// ─── 批内守卫 ───────────────────────────────────────────────────────────────── + +const GUARD_SESSION_ABSENT_SQL: &str = "INSERT INTO peri_store_meta(singleton) + SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM threads WHERE id = ?1)"; + +// ─── 效果语句 ───────────────────────────────────────────────────────────────── + +/// 子树 id(含根自身);只读,用于确认删除范围。 +const SELECT_TREE_IDS_SQL: &str = "WITH RECURSIVE tree(id) AS ( + SELECT id FROM threads WHERE id = ?1 + UNION ALL + SELECT s.id FROM threads s JOIN tree t ON s.parent_thread_id = t.id +) SELECT id FROM tree"; + +/// 直接子会话计数(撤销必须拒绝「已有子会话」的 identity,否则子会话会指向不存在的父)。 +const COUNT_CHILDREN_SQL: &str = "SELECT COUNT(*) FROM threads WHERE parent_thread_id = ?1"; + +/// 接纳依据:保存的绝对 cwd 与父关系。 +const SELECT_ADOPT_FACTS_SQL: &str = "SELECT cwd, parent_thread_id FROM threads WHERE id = ?1"; + +/// 删除整段会话历史的全部条目(与 `canonical::THREAD_CHILD_DELETES` 同一份语句)。 +const DELETE_SESSION_MESSAGES_SQL: &str = canonical::DELETE_MESSAGES_BY_THREAD_SQL; + +/// 删除会话的不可变绑定行(统一后绑定住在 `session_bindings`,不再是会话行上的扁平列)。 +const DELETE_SESSION_BINDINGS_SQL: &str = canonical::DELETE_BINDINGS_BY_THREAD_SQL; + +/// 删除会话行(与 `canonical::DELETE_THREAD_ROW_SQL` 同一份语句)。 +const DELETE_SESSION_SQL: &str = canonical::DELETE_THREAD_ROW_SQL; + +/// 补 frozen:已有值不变(`IS NULL` 谓词即本机「已有值不变」的同一语义)。 +const ADOPT_FROZEN_SQL: &str = "UPDATE threads SET frozen_context = ?2 + WHERE id = ?1 AND frozen_context IS NULL"; + +/// 补不可变绑定:只在 `session_bindings` 里还没有这一行时写入,之后任何行为都不改写它 +/// (本机同一判定:先读已有绑定,没有才 INSERT)。 +const ADOPT_BINDING_SQL: &str = "INSERT INTO session_bindings + (thread_id, schema_version, project_id, workspace_id, relative_cwd) + SELECT ?1, ?2, ?3, ?4, ?5 + WHERE NOT EXISTS (SELECT 1 FROM session_bindings WHERE thread_id = ?1)"; + +/// child resume 认领事实:状态 + 更新时间(`claimed` 由状态派生,不是独立列)。 +const UPDATE_AGENT_STATUS_SQL: &str = + "UPDATE threads SET agent_status = ?1, updated_at = ?2 WHERE id = ?3"; + +const SELECT_AGENT_STATUS_SQL: &str = "SELECT agent_status FROM threads WHERE id = ?1"; + +impl RemoteSessionData { + /// 接纳 legacy 会话:binding 与缺失的 frozen 一次成立,已有值不变。 + /// + /// 本机在这一步还要核对本机 workspace 登记(`workspaces` 表)——那是**本机证据**, + /// 远端没有也不得伪造:绑定由调用方在本机解析后给出,远端只负责把事实写下去。 + pub(super) async fn adopt_legacy( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + if !std::path::Path::new(saved_cwd).is_absolute() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::Unavailable), + )); + } + let store = self.store().await?; + let facts = store + .fetch_row(&StatementSpec::new( + SELECT_ADOPT_FACTS_SQL, + vec![Value::Text(id.as_str().to_owned())], + )) + .await? + .ok_or_else(not_found)?; + let cwd = text_at(&facts, 0).ok_or_else(|| codec::corrupt("session cwd is unreadable"))?; + // 保存的绝对 cwd 是接纳依据;调用方不能借接纳顺手改绑,也不能接纳 child。 + if cwd != saved_cwd || text_at(&facts, 1).is_some() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBindingMismatch), + )); + } + // 下面两次读取自己取连接:借用不能跨过去(重连要拿写锁,同任务里握着读锁会自锁)。 + drop(store); + let binding_present = self.binding_of(id).await?.is_some(); + let frozen_present = self.frozen_of(id).await?.is_some(); + if binding_present && frozen_present { + // 已经接纳过:不写、也不假装写入什么。 + return Ok(()); + } + let mut effects = vec![guard_session_statement(id)]; + if !frozen_present { + effects.push(StatementSpec::new( + ADOPT_FROZEN_SQL, + vec![ + Value::Text(id.as_str().to_owned()), + Value::Text(frozen.as_str().to_owned()), + ], + )); + } + let binding = SessionBinding::from_workspace(workspace); + if !binding_present { + let relative = binding_relative_text(&binding)?; + effects.push(StatementSpec::new( + ADOPT_BINDING_SQL, + vec![ + Value::Text(id.as_str().to_owned()), + codec::int_value(i64::from(binding.schema_version)), + Value::Text(binding.project_id.to_string()), + Value::Text(binding.workspace_id.to_string()), + Value::Text(relative), + ], + )); + } + let inputs = vec![ + format!("id:{}", id.as_str()), + format!("cwd:{saved_cwd}"), + format!("frozen:{}", frozen.as_str()), + format!("workspace:{}", workspace.workspace_id), + ]; + self.commit_effects("adopt_legacy_session", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 撤销本次未发布的创建:有子会话就拒绝,否则删除该会话的历史与会话行。 + /// + /// 与本机同一判据(`parent_thread_id` 计数 > 0 → `InvalidInput`);会话行本来就不在时 + /// 是幂等删除(本机同一语义:补偿路径把「已经不在了」当成目标已达成)。 + /// 远端不留 `creation_intent` 锚点:那本机事实用于判定「同一 identity 不被复活」, + /// 远端没有第二个副本,也就没有需要锚定的复活路径。 + pub(super) async fn revoke_unpublished(&self, id: &ThreadId) -> SessionResourceResult<()> { + let store = self.store().await?; + let children = store + .fetch_row(&StatementSpec::new( + COUNT_CHILDREN_SQL, + vec![Value::Text(id.as_str().to_owned())], + )) + .await?; + // 子会话数与后面的写入各取一次连接:借用不跨过去(见上)。 + drop(store); + revocation_gate(children.as_ref().and_then(|values| int_at(values, 0)))?; + let effects = vec![ + StatementSpec::new( + DELETE_SESSION_MESSAGES_SQL, + vec![Value::Text(id.as_str().to_owned())], + ), + StatementSpec::new( + DELETE_SESSION_BINDINGS_SQL, + vec![Value::Text(id.as_str().to_owned())], + ), + StatementSpec::new( + DELETE_SESSION_SQL, + vec![Value::Text(id.as_str().to_owned())], + ), + ]; + self.commit_effects( + "revoke_unpublished_session", + &[format!("id:{}", id.as_str())], + effects, + id, + ) + .await + .map(|_| ()) + } + + /// 删除会话树:子树(含根)的历史与会话行在同一批里消失。 + /// + /// 删除是刻意行为:没有墓碑、没有执行行清理(远端都没有这些事实),但**不留半棵**—— + /// 子树 id 先只读确认,删除在同一托管批内完成。 + /// + /// 根是否存在**只由这次子树读取决定**(不再先问一次 `exists`,两次读取之间的空档会让 + /// 「刚被删掉的根」既非存在也非不存在):`SELECT_TREE_IDS_SQL` 的递归从根行出发,所以 + /// 空子树等价于「根不存在」→ `NotFound`,与本机 `delete_tree` 同一结果。反过来,空结果 + /// **不能**当成「没有东西要删」而报成功——那会在没删任何行的情况下返回 `Ok`。 + pub(super) async fn write_tree_deletion(&self, id: &ThreadId) -> SessionResourceResult<()> { + let store = self.store().await?; + let rows = store + .fetch_rows(&StatementSpec::new( + SELECT_TREE_IDS_SQL, + vec![Value::Text(id.as_str().to_owned())], + )) + .await?; + // 树上的 id 读完再写:借用不跨到后面的写入路径(见上)。 + drop(store); + let tree = tree_ids(&rows)?; + // 删除不新增墓碑:树上的每个会话先清子行(messages → session_bindings, + // 与 `canonical::THREAD_CHILD_DELETES` 同一份语句与顺序)再清会话行, + // 全部在同一个批里。 + let mut effects = Vec::with_capacity(tree.len() * 3); + for statement in [DELETE_SESSION_MESSAGES_SQL, DELETE_SESSION_BINDINGS_SQL] { + for thread in &tree { + effects.push(StatementSpec::new( + statement, + vec![Value::Text(thread.clone())], + )); + } + } + for thread in &tree { + effects.push(StatementSpec::new( + DELETE_SESSION_SQL, + vec![Value::Text(thread.clone())], + )); + } + // 删除的摘要输入 = 目标本身 + 这次实际命中的子树(同一根下删掉了哪些会话构成这次操作)。 + let mut inputs = vec![format!("id:{}", id.as_str())]; + inputs.extend(tree.iter().cloned()); + self.commit_effects("delete_session_tree", &inputs, effects, id) + .await + .map(|_| ()) + } + + /// 读取 child resume 认领事实:状态 + 是否仍在认领中。 + /// + /// `claimed` 与本机同源:`agent_status` 处于 active 即「正在被认领」,不是独立列。 + pub(super) async fn read_child_resume( + &self, + child: &ThreadId, + ) -> SessionResourceResult { + let store = self.store().await?; + let row = store + .fetch_row(&StatementSpec::new( + SELECT_AGENT_STATUS_SQL, + vec![Value::Text(child.as_str().to_owned())], + )) + .await? + .ok_or_else(not_found)?; + let status = text_at(&row, 0) + .and_then(|text| AgentStatus::from_str(text).ok()) + .ok_or_else(|| codec::corrupt("agent_status is not a known value"))?; + Ok(ChildResumeRecord { + status, + claimed: status.is_active(), + }) + } + + /// 写入 child resume 认领事实(状态与终态由门面按领域结果给出)。 + /// + /// 会话不存在时明确失败(见模块文档里记录的那处有意偏离:不把 0 行更新报告成成功)。 + pub(super) async fn write_child_resume( + &self, + child: &ThreadId, + record: &ChildResumeRecord, + ) -> SessionResourceResult<()> { + let effects = vec![ + guard_session_statement(child), + StatementSpec::new( + UPDATE_AGENT_STATUS_SQL, + vec![ + Value::Text(record.status.as_str().to_owned()), + Value::Text(timestamp()), + Value::Text(child.as_str().to_owned()), + ], + ), + ]; + self.commit_effects( + "store_child_resume_record", + &[ + format!("child:{}", child.as_str()), + format!("status:{}", record.status.as_str()), + ], + effects, + child, + ) + .await + .map(|_| ()) + } +} + +fn guard_session_statement(id: &ThreadId) -> StatementSpec { + StatementSpec::new( + GUARD_SESSION_ABSENT_SQL, + vec![Value::Text(id.as_str().to_owned())], + ) +} + +fn timestamp() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// 子树读取 → 会话 id 列表。 +/// +/// 空结果**不是**「没有东西要删」:递归从根行开始,读不到根就是根不存在 → `NotFound` +/// (与本机 `delete_tree` 对不存在会话的同一结果),而不是一次「成功但什么都没删」。 +fn tree_ids(rows: &[Vec]) -> SessionResourceResult> { + if rows.is_empty() { + return Err(not_found()); + } + rows.iter() + .map(|row| { + text_at(row, 0) + .map(str::to_owned) + .ok_or_else(|| codec::corrupt("session tree row is not a session id")) + }) + .collect() +} + +/// 撤销前的子会话判据:只有**明确读到 0** 才继续。 +/// +/// `COUNT(*)` 必定返回恰好一行,读不出来(没有行或不是整数)说明这次回复不完整。此时继续 +/// 删除等于用不可证明的证据做破坏性决定,因此拒绝并报错,而不是当作「没有子会话」。 +fn revocation_gate(children: Option) -> SessionResourceResult<()> { + match children { + Some(0) => Ok(()), + Some(_) => Err(invalid_input( + "session has published children and cannot be revoked", + )), + None => Err(incomplete_reply( + "child session count row is missing or not an integer", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn placeholders(sql: &str) -> usize { + sql.matches('?').count() + } + + #[test] + fn every_statement_is_static_and_fully_bound() { + let cases: [(&str, usize); 8] = [ + (GUARD_SESSION_ABSENT_SQL, 1), + (SELECT_TREE_IDS_SQL, 1), + (COUNT_CHILDREN_SQL, 1), + (SELECT_ADOPT_FACTS_SQL, 1), + (DELETE_SESSION_MESSAGES_SQL, 1), + (DELETE_SESSION_BINDINGS_SQL, 1), + (DELETE_SESSION_SQL, 1), + (UPDATE_AGENT_STATUS_SQL, 3), + ]; + for (sql, expected) in cases { + assert_eq!(placeholders(sql), expected, "绑定量与占位符不一致: {sql}"); + assert!(!sql.contains('\''), "语句里出现了字面量: {sql}"); + } + } + + /// 接纳只补缺失的事实:`IS NULL` 谓词就是本机「已有值不变」的同一语义。 + #[test] + fn adopt_only_fills_missing_facts() { + assert!(ADOPT_FROZEN_SQL.contains("AND frozen_context IS NULL")); + assert!(ADOPT_BINDING_SQL.contains("WHERE NOT EXISTS (SELECT 1 FROM session_bindings")); + // 不可变绑定没有「改绑」路径:接纳只在缺行时插入一次,任何路径都不 UPDATE 它。 + assert!(!ADOPT_BINDING_SQL.contains("UPDATE")); + } + + /// 删除范围是整棵子树(含根),且删除语句不含任何计算——范围完全由绑定参数给出。 + #[test] + fn tree_scope_is_the_whole_subtree() { + assert!(SELECT_TREE_IDS_SQL.starts_with("WITH RECURSIVE tree(id) AS (")); + assert!(SELECT_TREE_IDS_SQL.contains("UNION ALL")); + assert!(SELECT_TREE_IDS_SQL + .trim_end() + .ends_with("SELECT id FROM tree")); + assert_eq!(DELETE_SESSION_SQL, "DELETE FROM threads WHERE id = ?1"); + assert_eq!( + DELETE_SESSION_MESSAGES_SQL, + "DELETE FROM messages WHERE thread_id = ?1" + ); + assert_eq!( + DELETE_SESSION_BINDINGS_SQL, + "DELETE FROM session_bindings WHERE thread_id = ?1" + ); + } + + /// 撤销必须先问「有没有子会话」:有子会话的 identity 被补偿掉会让子会话指向空父节点。 + #[test] + fn revocation_checks_published_children_first() { + assert!(COUNT_CHILDREN_SQL.contains("parent_thread_id = ?1")); + } + + /// 撤销门槛只有三种落点,且「读不到计数」不是「没有子会话」。 + #[test] + fn revocation_gate_requires_a_proven_zero() { + assert!(revocation_gate(Some(0)).is_ok()); + let published = revocation_gate(Some(2)).unwrap_err(); + assert!(matches!( + published.kind(), + SessionResourceErrorKind::InvalidInput { .. } + )); + // 计数行缺失/不是整数:拒绝(Internal),而不是放行删除。 + let unreadable = revocation_gate(None).unwrap_err(); + assert!(matches!( + unreadable.kind(), + SessionResourceErrorKind::Internal { .. } + )); + assert!(!matches!( + unreadable.kind(), + SessionResourceErrorKind::NotFound | SessionResourceErrorKind::Corrupt { .. } + )); + } + + /// 空子树是「根不存在」(`NotFound`),不是「成功但没删任何行」。 + #[test] + fn empty_subtree_is_not_found_and_rows_must_be_ids() { + let absent = tree_ids(&[]).unwrap_err(); + assert!(matches!(absent.kind(), SessionResourceErrorKind::NotFound)); + + let malformed = tree_ids(&[vec![Value::Integer(1)]]).unwrap_err(); + assert!(matches!( + malformed.kind(), + SessionResourceErrorKind::Corrupt { .. } + )); + + let ids = tree_ids(&[ + vec![Value::Text("root".to_owned())], + vec![Value::Text("child".to_owned())], + ]) + .unwrap(); + assert_eq!(ids, vec!["root".to_owned(), "child".to_owned()]); + } + + /// 认领标记由状态派生,不是独立列:写入只动 `agent_status` 与更新时间。 + #[test] + fn child_resume_claim_is_derived_from_status() { + assert!(UPDATE_AGENT_STATUS_SQL.contains("agent_status = ?1")); + assert!(!UPDATE_AGENT_STATUS_SQL.contains("claimed")); + } +} diff --git a/peri-resources/src/sessions/remote/session_read.rs b/peri-resources/src/sessions/remote/session_read.rs new file mode 100644 index 000000000..ead1096c8 --- /dev/null +++ b/peri-resources/src/sessions/remote/session_read.rs @@ -0,0 +1,257 @@ +//! 远程会话读取行为:一致快照、绑定分类、历史、metadata、分页与树。 +//! +//! 读取不写、不改写、不发 DDL;`load_snapshot` 这类需要多段事实来自同一时刻的读取走 +//! [`super::mutation::RemoteStore::read_batch`](同一请求内的 `BEGIN DEFERRED` 只读事务), +//! 不把多次独立查询的结果拼成「一致快照」。回复形状不符(结果集或行数不对)在这里是 +//! **错误**:不完整的读取绝不能降级成「这个会话没有历史」或「没有这一行」。 +//! +//! 分类规则与本机 adapter 同一套(绑定行在/不在、子会话无绑定、无绑定无 legacy 证据), +//! 差别只有一处:远端没有本机 workspace 登记,因此 `LegacyConfirmed` 不在数据端冒充—— +//! 它由门面按本机来源证据联合判定;远端只回答「绑定事实是否完整存在」。 + +use std::collections::HashMap; +use std::path::PathBuf; + +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + BindingState, FrozenSnapshotBytes, FrozenState, SessionResourceResult, SessionSnapshot, +}; +use peri_acp_types::store::{MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{ThreadId, ThreadListEntry, ThreadMeta}; +use peri_acp_types::workspace::{ + ScopedThreadEntry, ScopedThreadPage, ScopedThreadQuery, SessionBinding, ThreadListCursor, +}; +use turso_serverless::Value; + +use super::mutation::sole_row; +use super::session_codec as codec; +use super::session_data::{not_found, RemoteSessionData}; +use super::session_sql::{self, PAGE_BINDING_VERSION}; +use super::sql::text_at; + +impl RemoteSessionData { + /// 一致读取:会话事实行与自有 payload 在同一只读事务里取出。 + pub(super) async fn read_snapshot( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + let store = self.store().await?; + let (facts, messages) = store + .read_pair( + session_sql::select_session_statement(id), + session_sql::select_messages_statement(id), + ) + .await?; + let facts = sole_row(facts)?.ok_or_else(not_found)?; + let meta = codec::decode_meta(&facts)?; + let binding = codec::decode_binding(&facts, session_sql::FACT_BINDING_VERSION)?; + let frozen = match text_at(&facts, session_sql::FACT_FROZEN) { + Some(bytes) => FrozenState::Present(FrozenSnapshotBytes::new(bytes)), + // 远端没有 legacy 历史:没有快照就是没有,不猜来源。 + None => FrozenState::LegacyAbsent, + }; + let inherited = codec::decode_inherited(text_at(&facts, session_sql::FACT_INHERITED))?; + let (payloads, flags) = decode_history(&messages)?; + Ok(SessionSnapshot { + meta, + binding: classify_binding(binding, &facts), + frozen, + payloads, + flags, + inherited, + }) + } + + /// 轻量绑定分类(只读会话事实行,不加载历史)。 + pub(super) async fn read_binding_state( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + let store = self.store().await?; + let facts = store + .fetch_row(&session_sql::select_session_statement(id)) + .await? + .ok_or_else(not_found)?; + let binding = codec::decode_binding(&facts, session_sql::FACT_BINDING_VERSION)?; + Ok(classify_binding(binding, &facts)) + } + + /// 完整逻辑上下文:继承区在前、自有 payload 在后(继承快照是权威值,父会话之后的 + /// compact/rewind 不改变它)。 + pub(super) async fn read_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + let store = self.store().await?; + let (facts, messages) = store + .read_pair( + session_sql::select_session_statement(id), + session_sql::select_messages_statement(id), + ) + .await?; + let facts = sole_row(facts)?.ok_or_else(not_found)?; + let inherited = codec::decode_inherited(text_at(&facts, session_sql::FACT_INHERITED))?; + let (own, _) = decode_history(&messages)?; + let mut payloads = inherited.payloads; + payloads.extend(own); + Ok(payloads) + } + + /// 小型 metadata 投影(不含 frozen/继承区正文)。 + pub(super) async fn read_meta(&self, id: &ThreadId) -> SessionResourceResult { + let store = self.store().await?; + let row = store + .fetch_row(&session_sql::select_meta_statement(id)) + .await? + .ok_or_else(not_found)?; + codec::decode_meta(&row) + } + + /// 分页列举:过滤(scope、hidden、消息数)与游标都在数据端完成。 + /// + /// `effective_cwd` 给的是记录下来的创建目录,`workspace_root` 是 `None`:远端没有本机 + /// 登记,给不出已解析的根目录,也不拿别名顶替。要执行加载的调用方必须在本机重新校验。 + pub(super) async fn read_page( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult { + let limit = query.limit.clamp(1, 200) as usize; + let store = self.store().await?; + let rows = store + .fetch_rows(&session_sql::page_statement(query)?) + .await?; + let mut entries = Vec::with_capacity(rows.len()); + for row in &rows { + let meta = codec::decode_meta(row)?; + let binding = codec::decode_binding(row, PAGE_BINDING_VERSION)?; + let effective_cwd = PathBuf::from(&meta.cwd); + entries.push(ScopedThreadEntry { + thread: ThreadListEntry { + id: meta.id, + title: meta.title, + cwd: meta.cwd, + message_count: meta.message_count, + updated_at: meta.updated_at, + }, + binding, + effective_cwd, + workspace_root: None, + }); + } + let has_more = entries.len() > limit; + entries.truncate(limit); + let next_cursor = if has_more { + entries.last().map(|entry| ThreadListCursor { + updated_at: entry.thread.updated_at, + thread_id: entry.thread.id.clone(), + }) + } else { + None + }; + Ok(ScopedThreadPage { + entries, + next_cursor, + }) + } + + /// 直接子会话 metadata(按创建时间定序)。 + pub(super) async fn read_children( + &self, + parent: &ThreadId, + ) -> SessionResourceResult> { + let store = self.store().await?; + let rows = store + .fetch_rows(&session_sql::select_children_statement(parent)) + .await?; + rows.iter().map(|row| codec::decode_meta(row)).collect() + } + + /// 以 `root` 为根的整棵树 metadata(含自身)。 + pub(super) async fn read_tree( + &self, + root: &ThreadId, + ) -> SessionResourceResult> { + let store = self.store().await?; + let rows = store + .fetch_rows(&session_sql::select_tree_statement(root)) + .await?; + rows.iter().map(|row| codec::decode_meta(row)).collect() + } + + /// 会话是否存在(写入路径用它把「来源/父会话缺失」与「外键拒绝」分开报告)。 + pub(super) async fn exists(&self, id: &ThreadId) -> SessionResourceResult { + let store = self.store().await?; + Ok(store + .fetch_row(&session_sql::select_meta_statement(id)) + .await? + .is_some()) + } + + /// 已保存的 frozen 快照原文(child 必须逐字节复用 root 的那一份)。 + pub(super) async fn frozen_of(&self, id: &ThreadId) -> SessionResourceResult> { + let store = self.store().await?; + let facts = store + .fetch_row(&session_sql::select_session_statement(id)) + .await? + .ok_or_else(not_found)?; + Ok(text_at(&facts, session_sql::FACT_FROZEN).map(str::to_owned)) + } + + /// 会话的不可变绑定(用于 child 与父会话绑定一致性核对)。 + pub(super) async fn binding_of( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + let store = self.store().await?; + let facts = store + .fetch_row(&session_sql::select_session_statement(id)) + .await? + .ok_or_else(not_found)?; + codec::decode_binding(&facts, session_sql::FACT_BINDING_VERSION) + } + + /// 沿父链上溯到根(一次递归查询,不在本层手动循环)。 + pub(super) async fn root_of(&self, id: &ThreadId) -> SessionResourceResult { + let store = self.store().await?; + let row = store + .fetch_row(&session_sql::select_root_statement(id)) + .await?; + match row.as_deref().and_then(|values| text_at(values, 0)) { + Some(root) => Ok(root.to_owned()), + // 链上没有根:父行缺失或出现环,事实不完整,不猜一个根出来。 + None => Err(codec::corrupt("session parent chain has no root")), + } + } +} + +/// 消息行 → payload + 非默认 flags(flags 是派生视图,默认值不入映射)。 +fn decode_history( + rows: &[Vec], +) -> SessionResourceResult<(Vec, HashMap)> { + let mut payloads = Vec::with_capacity(rows.len()); + let mut flags = HashMap::new(); + for row in rows { + let payload = codec::decode_message_row(row)?; + let row_flags = codec::decode_message_flags_row(row)?; + if !codec::flags_are_default(&row_flags) { + flags.insert(payload.id(), row_flags); + } + payloads.push(payload); + } + Ok((payloads, flags)) +} + +/// 绑定列 + 父关系 → 绑定分类。 +/// +/// 「绑定的本机登记已不存在」在远端无从判断(登记只在本机):远端只回答绑定事实是否 +/// 完整存在,登记一致性与 legacy 判定留给门面按本机证据联合判定。 +fn classify_binding(binding: Option, facts: &[Value]) -> BindingState { + match binding { + Some(binding) => BindingState::Bound(binding), + // 子会话没有绑定行:与本机同一分类(不得当作 legacy 自动接纳)。 + None if text_at(facts, session_sql::META_PARENT).is_some() => { + BindingState::ExternalOrUnregistered + } + None => BindingState::Missing, + } +} diff --git a/peri-resources/src/sessions/remote/session_schema.rs b/peri-resources/src/sessions/remote/session_schema.rs new file mode 100644 index 000000000..e7e614d7c --- /dev/null +++ b/peri-resources/src/sessions/remote/session_schema.rs @@ -0,0 +1,40 @@ +//! 远端会话 schema:直接说 canonical 形状(`sessions::canonical` 是唯一来源)。 +//! +//! 远端不再有自己的会话表形状:`peri_sessions` / `peri_session_messages` 以及「meta 列 + +//! 扁平绑定列」的混合形状随统一删除。远端下发的是与**本机 SQLite 逐字同一份** DDL +//! ([`CREATE_TABLES_SQL`] / [`CREATE_INDEXES_SQL`]),因此两个 adapter 之间不再有表名或 +//! 列名映射,canonical 历史顺序也与本机一致地由 `messages.rowid` 承载(原先的 `ordinal` +//! 列与它的索引一并删除)。 +//! +//! 远端仍独有的是**执行器机制表**(不属于 canonical 会话 schema,本机不建): +//! +//! | 表 | 承载 | +//! | --- | --- | +//! | `peri_store_meta` | 远端版本标记(服务端拒绝写 `PRAGMA user_version`,见 [`super::schema`]) | +//! | `peri_op_ledger` | 幂等资格账本(本机用本地事务表达同一件事) | +//! +//! 初始化语句全部 `IF NOT EXISTS`,可重复执行;调用方只在只读身份读取判定「未初始化或 +//! 形状已知」时执行,已初始化时读回而不改写任何行。 + +use super::sql::StatementSpec; +use crate::sessions::canonical::{CREATE_INDEXES, CREATE_TABLES}; + +/// canonical 索引清单的远端重导出;用途同上。 +#[cfg(test)] +pub(super) use crate::sessions::canonical::CANONICAL_INDEXES; +/// canonical 表清单的远端重导出:形状测试按它核对远端表集合(生产路径按名建表,不需要它)。 +#[cfg(test)] +pub(super) use crate::sessions::canonical::CANONICAL_TABLES; + +/// 初始化本任务 schema 的语句集:建表段 + 索引段,**一条语句一个 spec**。 +/// +/// 远端执行器的语句单元就是一条语句,因此清单直接逐条展开,不把多句拼进一个请求。 +/// 两段的顺序有意义:`idx_threads_updated` 引用 `threads` 的列,索引必须晚于建表; +/// 与本机 `sqlite_store::schema` 的「建表 → 补列 → 建索引」顺序同源。 +pub(super) fn initialization_plan() -> Vec { + CREATE_TABLES + .iter() + .chain(CREATE_INDEXES) + .map(|sql| StatementSpec::bare(sql)) + .collect() +} diff --git a/peri-resources/src/sessions/remote/session_shape_test.rs b/peri-resources/src/sessions/remote/session_shape_test.rs new file mode 100644 index 000000000..e110895b8 --- /dev/null +++ b/peri-resources/src/sessions/remote/session_shape_test.rs @@ -0,0 +1,687 @@ +//! 远程会话层离线断言(不连网):投影与下标一致、编解码往返、语句形状与绑定。 +//! +//! 这里断言的是**离线可判定**的事实:投影文本与解码下标同源、非法形状被判损坏、 +//! SQL 文本静态(两次不同输入得到同一文本、只有参数不同)、读语句只读。真实读写与 +//! 重连读在 `cloud_session_test.rs`(默认 `#[ignore]`)。 + +use std::collections::HashMap; +use std::path::PathBuf; + +use peri_acp_types::messages::{BaseMessage, MessageContent, MessageId}; +use peri_acp_types::projection::{ + MessageProjectionDirective, ProjectionAction, ProjectionActionEntry, ProjectionTarget, +}; +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionMetaPatch, SessionResourceErrorKind, +}; +use peri_acp_types::store::{InheritedContext, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{AgentStatus, CancelPolicy}; +use peri_acp_types::workspace::{ + ProjectId, ScopedThreadQuery, SessionBinding, ThreadScope, WorkspaceId, +}; +use turso_serverless::Value; + +use super::session_codec as codec; +use super::session_sql::{ + self, FACT_BINDING_RELATIVE_CWD, FACT_BINDING_VERSION, PAGE_BINDING_VERSION, +}; +use super::sql::StatementSpec; + +// ─── 投影与下标同源 ─────────────────────────────────────────────────────────── + +/// 从投影文本抽出列名(content_size 子查询按一列计)。 +fn projected_columns(projection: &str) -> Vec { + let mut columns = Vec::new(); + for line in projection.lines() { + let line = line.trim(); + if line.starts_with("(SELECT") { + columns.push("content_size".to_owned()); + continue; + } + for part in line.split(',') { + let name = part + .split_whitespace() + .next() + .unwrap_or("") + .trim_end_matches(','); + if !name.is_empty() { + columns.push(name.to_owned()); + } + } + } + columns +} + +/// 事实行与列表行的后缀列(在 meta 投影之后的追加列)。 +fn appended_columns(projection: &str) -> Vec { + projection[session_sql::META_PROJECTION.len()..] + .split(',') + .map(|part| part.split_whitespace().next().unwrap_or("").to_owned()) + .filter(|name| !name.is_empty()) + .collect() +} + +#[test] +fn meta_projection_matches_decoding_indices() { + let columns = projected_columns(session_sql::META_PROJECTION); + assert_eq!( + columns, + [ + "s.id", + "s.title", + "s.cwd", + "s.created_at", + "s.updated_at", + "s.message_count", + "content_size", + "s.parent_thread_id", + "s.snapshot_at_message_id", + "s.hidden", + "s.cancel_policy", + "s.config", + "s.agent_status", + ] + ); + assert_eq!(columns.len(), session_sql::META_COLUMN_COUNT); +} + +#[test] +fn bigger_projections_extend_the_same_meta_source() { + assert!(session_sql::FACT_PROJECTION.starts_with(session_sql::META_PROJECTION)); + assert!(session_sql::PAGE_PROJECTION.starts_with(session_sql::META_PROJECTION)); + assert_eq!( + appended_columns(session_sql::FACT_PROJECTION), + [ + "s.frozen_context", + "s.inherited_context", + "b.schema_version", + "b.project_id", + "b.workspace_id", + "b.relative_cwd", + ] + ); + assert_eq!( + appended_columns(session_sql::PAGE_PROJECTION), + [ + "b.schema_version", + "b.project_id", + "b.workspace_id", + "b.relative_cwd", + ] + ); + // 下标常量与投影顺序一致:绑定四列连续且起点相同。 + assert_eq!(session_sql::FACT_FROZEN, session_sql::META_COLUMN_COUNT); + assert_eq!(session_sql::FACT_INHERITED, session_sql::FACT_FROZEN + 1); + assert_eq!(FACT_BINDING_VERSION, session_sql::FACT_INHERITED + 1); + assert_eq!(session_sql::FACT_BINDING_PROJECT, FACT_BINDING_VERSION + 1); + assert_eq!( + session_sql::FACT_BINDING_WORKSPACE, + session_sql::FACT_BINDING_PROJECT + 1 + ); + assert_eq!(FACT_BINDING_RELATIVE_CWD, FACT_BINDING_VERSION + 3); + assert_eq!( + session_sql::FACT_COLUMN_TOTAL, + FACT_BINDING_RELATIVE_CWD + 1 + ); + assert_eq!(PAGE_BINDING_VERSION, session_sql::META_COLUMN_COUNT); + assert_eq!(session_sql::PAGE_BINDING_PROJECT, PAGE_BINDING_VERSION + 1); + assert_eq!( + session_sql::PAGE_BINDING_WORKSPACE, + session_sql::PAGE_BINDING_PROJECT + 1 + ); + assert_eq!( + session_sql::PAGE_BINDING_RELATIVE_CWD, + session_sql::PAGE_BINDING_WORKSPACE + 1 + ); + assert_eq!( + session_sql::FACT_COLUMN_TOTAL, + session_sql::META_COLUMN_COUNT + 6 + ); + assert_eq!( + session_sql::PAGE_COLUMN_TOTAL, + session_sql::META_COLUMN_COUNT + 4 + ); +} + +#[test] +fn schema_ddl_matches_the_canonical_shape() { + let plan = super::session_schema::initialization_plan(); + // 一条语句一个 spec:远端执行器的语句单元就是一条语句,多句拼一个请求只会执行第一条。 + assert_eq!( + plan.len(), + super::session_schema::CANONICAL_TABLES.len() + + super::session_schema::CANONICAL_INDEXES.len(), + "远端下发的 canonical DDL:逐条建表 + 逐条建索引" + ); + for spec in &plan { + assert!( + spec.sql.contains("IF NOT EXISTS"), + "初始化 DDL 必须幂等: {}", + spec.sql + ); + assert!(spec.params.is_empty(), "DDL 不带绑定参数"); + assert!( + !spec.sql.contains(';'), + "一个 spec 只能是一条语句: {}", + spec.sql + ); + } + // 远端建的是本机那一份 canonical 表(清单来自同一处,不另抄一遍)。 + for table in super::session_schema::CANONICAL_TABLES { + assert!( + plan.iter().any(|spec| spec.sql.contains(table)), + "canonical 表必须有建表语句: {table}" + ); + } + // 索引段必须晚于建表(`idx_threads_updated` 引用建表时的列):清单顺序就是执行顺序。 + let first_index = plan + .iter() + .position(|spec| spec.sql.starts_with("CREATE INDEX")) + .expect("索引段存在"); + assert_eq!( + first_index, + super::session_schema::CANONICAL_TABLES.len(), + "建表段在前、索引段在后" + ); + for index in super::session_schema::CANONICAL_INDEXES { + assert!( + plan.iter() + .any(|spec| spec.sql.contains(&format!(" {index} "))), + "缺索引: {index}" + ); + } + // 会话表主键是会话 id(canonical 列名),历史表主键是消息 id(同一条消息不属于两个会话)。 + let sessions = plan + .iter() + .find(|spec| spec.sql.contains("CREATE TABLE IF NOT EXISTS threads")) + .expect("会话表建表语句"); + assert!(sessions.sql.contains("id TEXT PRIMARY KEY")); + let messages = plan + .iter() + .find(|spec| spec.sql.contains("CREATE TABLE IF NOT EXISTS messages")) + .expect("历史表建表语句"); + assert!(messages.sql.contains("message_id TEXT PRIMARY KEY")); + // 统一之后不再有远端自己的会话表名。 + for spec in &plan { + assert!(!spec.sql.contains("peri_sessions")); + assert!(!spec.sql.contains("peri_session_messages")); + } +} + +// ─── 解码 ───────────────────────────────────────────────────────────────────── + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +/// 一行会话事实(列顺序 = `FACT_PROJECTION`)。 +fn fact_row() -> Vec { + vec![ + text("session-1"), + text("title"), + text("/home/u/project"), + text("2026-09-26T10:00:00+00:00"), + text("2026-09-26T11:00:00+00:00"), + Value::Integer(2), + Value::Integer(42), + Value::Null, + Value::Null, + Value::Integer(0), + text("cascade"), + Value::Null, + text("active"), + text("{\"frozen\":true}"), + Value::Null, + Value::Integer(1), + text("11111111-1111-1111-1111-111111111111"), + text("22222222-2222-2222-2222-222222222222"), + text("sub"), + ] +} + +#[test] +fn meta_row_decodes_field_by_field() { + let row = fact_row(); + let meta = codec::decode_meta(&row).expect("row is a valid meta row"); + assert_eq!(meta.id, "session-1"); + assert_eq!(meta.title.as_deref(), Some("title")); + assert_eq!(meta.cwd, "/home/u/project"); + assert_eq!(meta.message_count, 2); + assert_eq!(meta.content_size, 42); + assert_eq!(meta.parent_thread_id, None); + assert!(!meta.hidden); + assert_eq!(meta.cancel_policy, CancelPolicy::Cascade); + assert_eq!(meta.agent_status, AgentStatus::Active); + // 远端不保存物化缓存:没有第二份真相可返回。 + assert!(meta.cached_context.is_none()); +} + +#[test] +fn binding_decodes_from_the_appended_columns() { + let row = fact_row(); + let binding = codec::decode_binding(&row, FACT_BINDING_VERSION) + .expect("binding columns are readable") + .expect("binding is present"); + assert_eq!( + binding.project_id, + ProjectId::from_str_expect("11111111-1111-1111-1111-111111111111") + ); + assert_eq!(binding.cwd_relative_to_workspace, PathBuf::from("sub")); + assert_eq!(binding.schema_version, 1); + assert_eq!(binding.revision, 1, "不可变绑定的协议字段恒为 1"); + + // 四列全 NULL = 无绑定行(不是「有一半绑定」)。 + let mut absent = fact_row(); + for offset in 0..4 { + absent[FACT_BINDING_VERSION + offset] = Value::Null; + } + assert!(codec::decode_binding(&absent, FACT_BINDING_VERSION) + .expect("absent binding is not an error") + .is_none()); + + // 部分 NULL 或非法值 = 损坏,不猜。 + let mut partial = fact_row(); + partial[FACT_BINDING_VERSION] = Value::Null; + assert!(matches!( + codec::decode_binding(&partial, FACT_BINDING_VERSION), + Err(error) if matches!(error.kind(), SessionResourceErrorKind::Corrupt { .. }) + )); + + let mut absolute = fact_row(); + absolute[FACT_BINDING_RELATIVE_CWD] = text("/etc"); + assert!(matches!( + codec::decode_binding(&absolute, FACT_BINDING_VERSION), + Err(error) if matches!(error.kind(), SessionResourceErrorKind::Corrupt { .. }) + )); +} + +#[test] +fn broken_meta_cells_are_corrupt_not_defaulted() { + let mut negative = fact_row(); + negative[session_sql::META_MESSAGE_COUNT] = Value::Integer(-1); + assert!(matches!( + codec::decode_meta(&negative), + Err(error) if matches!(error.kind(), SessionResourceErrorKind::Corrupt { .. }) + )); + + let mut bad_policy = fact_row(); + bad_policy[session_sql::META_CANCEL_POLICY] = text("whatever"); + assert!(codec::decode_meta(&bad_policy).is_err()); + + let mut bad_time = fact_row(); + bad_time[session_sql::META_UPDATED_AT] = text("yesterday"); + assert!(codec::decode_meta(&bad_time).is_err()); + + let mut bad_flag = fact_row(); + bad_flag[session_sql::META_HIDDEN] = Value::Integer(7); + assert!(codec::decode_meta(&bad_flag).is_err()); +} + +// ─── 历史行编解码 ───────────────────────────────────────────────────────────── + +fn projection_directive() -> MessageProjectionDirective { + MessageProjectionDirective { + policy_version: 3, + entries: vec![ProjectionActionEntry { + message_id: MessageId::new(), + target: ProjectionTarget::Message, + action: ProjectionAction::CompactText { max_chars: 120 }, + }], + } +} + +#[test] +fn payload_row_round_trips_through_binding_parameters() { + let payload = PersistedPayload::Message(BaseMessage::human("hello remote")); + let flags = MessageFlags { + truncated: true, + excluded: false, + projection: Some(projection_directive()), + }; + let params = codec::payload_params("session-1", &payload, Some(&flags)).expect("encodable"); + // 行形状:message_id, thread_id, role, content, truncated, excluded, projection + assert_eq!(params.len(), 7); + assert_eq!(params[2], Value::Text("user".to_owned())); + let row = vec![ + params[0].clone(), + params[3].clone(), + params[4].clone(), + params[5].clone(), + params[6].clone(), + ]; + let decoded = codec::decode_message_row(&row).expect("decodable"); + assert_eq!(decoded.id(), payload.id()); + match decoded { + PersistedPayload::Message(BaseMessage::Human { content, .. }) => { + assert_eq!(content, MessageContent::Text("hello remote".to_owned())); + } + other => panic!("unexpected payload shape: {other:?}"), + } + assert!(!codec::flags_are_default( + &codec::decode_message_flags_row(&row).expect("flags decode") + )); + assert_eq!( + codec::decode_message_flags_row(&row).expect("flags decode"), + flags + ); +} + +#[test] +fn history_row_id_must_match_its_payload() { + let payload = PersistedPayload::Message(BaseMessage::human("mismatch")); + let params = codec::payload_params("session-1", &payload, None).expect("encodable"); + let mut row = vec![ + text("33333333-3333-3333-3333-333333333333"), + params[3].clone(), + params[4].clone(), + params[5].clone(), + params[6].clone(), + ]; + assert!(codec::decode_message_row(&row).is_err()); + row[0] = text(&payload.id().as_uuid().to_string()); + assert!(codec::decode_message_row(&row).is_ok()); + + // 默认 flags 不进派生视图;非法 projection 是损坏。 + let flags_row = |truncated: Value, excluded: Value, projection: Value| { + vec![ + row[0].clone(), + row[1].clone(), + truncated, + excluded, + projection, + ] + }; + let default_flags = codec::decode_message_flags_row(&flags_row( + Value::Integer(0), + Value::Integer(0), + Value::Null, + )) + .expect("decodable"); + assert!(codec::flags_are_default(&default_flags)); + assert!( + codec::decode_message_flags_row(&flags_row( + Value::Integer(0), + Value::Integer(0), + text("{"), + )) + .is_err(), + "非法 projection 必须报损坏" + ); +} + +#[test] +fn inherited_context_round_trips_and_rejects_broken_references() { + let payload = PersistedPayload::Message(BaseMessage::ai("inherited")); + let mut flags = HashMap::new(); + flags.insert( + payload.id(), + MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + ); + let context = InheritedContext { + payloads: vec![payload.clone()], + flags, + }; + let json = codec::inherited_json(&context).expect("encodable"); + let decoded = codec::decode_inherited(Some(&json)).expect("decodable"); + assert_eq!(decoded.payloads.len(), 1); + assert_eq!(decoded.payloads[0].id(), payload.id()); + assert_eq!(decoded.flags.len(), 1); + + // 引用不存在的消息 id:发布前就被拒绝(与本地 adapter 同一规则)。 + let orphan = format!( + "{{\"version\":1,\"payloads\":[\"{}\"],\"flags\":{{\"{}\":{{\"truncated\":false,\"excluded\":true}}}}}}", + json_escape(&json), + "44444444-4444-4444-4444-444444444444" + ); + assert!(codec::decode_inherited(Some(&orphan)).is_err()); + assert!(codec::decode_inherited(None) + .expect("absent is empty") + .payloads + .is_empty()); +} + +/// 最小 JSON 字符串转义:只处理引号与反斜杠(测试数据里没有别的特殊字符)。 +fn json_escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +// ─── 语句形状 ───────────────────────────────────────────────────────────────── + +fn binding(relative: &str) -> SessionBinding { + SessionBinding { + schema_version: 1, + revision: 1, + project_id: ProjectId::from_str_expect("11111111-1111-1111-1111-111111111111"), + workspace_id: WorkspaceId::from_str_expect("22222222-2222-2222-2222-222222222222"), + cwd_relative_to_workspace: PathBuf::from(relative), + } +} + +fn new_session(thread_id: &str, snapshot_at: Option) -> NewSession { + NewSession { + thread_id: thread_id.to_owned(), + created_at: "2026-09-26T10:00:00+00:00".to_owned(), + meta: NewSessionMeta { + title: Some("t".to_owned()), + cwd: "/home/u/project".to_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::Cascade, + snapshot_at_message_id: snapshot_at, + }, + binding: binding("sub"), + frozen: FrozenSnapshotBytes::new("{\"frozen\":true}"), + } +} + +#[test] +fn write_sql_is_static_and_all_values_are_bound() { + let first = new_session("session-a", None); + let second = new_session("session-b", Some(MessageId::new())); + let one = session_sql::insert_session_statements(&session_sql::session_insert(&first, 0, None)) + .expect("encodable"); + let two = + session_sql::insert_session_statements(&session_sql::session_insert(&second, 3, None)) + .expect("encodable"); + // 创建路径就是两条语句:canonical `threads` 行 + `session_bindings` 行。 + assert_eq!(one.len(), 2); + assert_eq!(one[0].sql, two[0].sql); + assert_eq!(one[1].sql, two[1].sql); + assert_ne!(one[0].params, two[0].params); + assert!(one[0].sql.starts_with("INSERT INTO threads")); + assert!(one[1].sql.starts_with("INSERT INTO session_bindings")); + assert_eq!(one[0].params.len(), 13); + assert_eq!(one[1].params.len(), 5); + // 全部动态内容都出现在参数里,不出现在 SQL 文本里。 + for secret in ["session-a", "session-b", "/home/u/project"] { + assert!(!one[0].sql.contains(secret)); + assert!(!one[1].sql.contains(secret)); + } + // 会话 id 与绑定身份落到参数位置。 + assert_eq!(one[0].params[0], Value::Text("session-a".to_owned())); + assert_eq!(one[1].params[0], Value::Text("session-a".to_owned())); + // 绑定相对 cwd 是绑定语句的最后一位参数。 + assert_eq!(one[1].params[4], Value::Text("sub".to_owned())); + // 创建路径 agent_status 起步为 active;快照截止点写成文本。 + assert_eq!(one[0].params[12], Value::Text("active".to_owned())); + assert_eq!(one[0].params[7], Value::Null); + assert_eq!( + two[0].params[7], + Value::Text( + second + .meta + .snapshot_at_message_id + .expect("present") + .as_uuid() + .to_string() + ) + ); + // child 多一条继承区写入:与本机 child 路径同一句形态。 + let child = session_sql::insert_session_statements(&session_sql::session_insert( + &first, + 0, + Some("{\"inherited\":true}"), + )) + .expect("encodable"); + assert_eq!(child.len(), 3); + assert!(child[2] + .sql + .starts_with("UPDATE threads SET inherited_context")); +} + +#[test] +fn binding_cwd_must_be_relative_and_textual() { + let mut session = new_session("session-a", None); + session.binding.cwd_relative_to_workspace = PathBuf::from("/etc"); + let error = + session_sql::insert_session_statements(&session_sql::session_insert(&session, 0, None)) + .expect_err("absolute binding cwd is refused"); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::InvalidInput { .. } + )); +} + +#[test] +fn read_statements_are_read_only_and_parameterized() { + let single_id_reads = [ + session_sql::select_session_statement("s"), + session_sql::select_meta_statement("s"), + session_sql::select_children_statement("s"), + session_sql::select_tree_statement("s"), + session_sql::select_messages_statement("s"), + session_sql::select_root_statement("s"), + ]; + for spec in &single_id_reads { + assert!( + spec.is_read_only(), + "read path must not write: {}", + spec.sql + ); + assert_eq!(spec.params.len(), 1, "单会话读取只绑定会话 id"); + } + // 树查询是递归 CTE,不是「查一层再查一层」。 + assert!(single_id_reads[3].sql.starts_with("WITH RECURSIVE")); + + let page = session_sql::page_statement(&query(ThreadScope::All, None)).expect("bindable"); + assert!(page.is_read_only(), "分页列举不得写: {}", page.sql); + assert_eq!(page.params.len(), 7, "scope/游标/上限都是绑定参数"); +} + +#[test] +fn page_statement_binds_scope_cursor_and_limit() { + let project = ProjectId::from_str_expect("11111111-1111-1111-1111-111111111111"); + let all = query(ThreadScope::All, None); + let spec = session_sql::page_statement(&all).expect("bindable"); + assert_eq!(spec.params[0], Value::Integer(0)); + assert_eq!(spec.params[1], Value::Null); + assert_eq!(spec.params[3], Value::Integer(0), "无游标时不带比较值"); + + let scoped = query(ThreadScope::Project(project), None); + let spec = session_sql::page_statement(&scoped).expect("bindable"); + assert_eq!(spec.params[0], Value::Integer(1)); + assert_eq!(spec.params[1], Value::Text(project.to_string())); + + let cursor = peri_acp_types::workspace::ThreadListCursor { + updated_at: "2026-09-26T10:00:00+00:00".parse().expect("rfc3339"), + thread_id: "session-9".to_owned(), + }; + let paged = query( + ThreadScope::ExactDirectory { + workspace_id: WorkspaceId::from_str_expect("22222222-2222-2222-2222-222222222222"), + relative_cwd: PathBuf::from("sub"), + }, + Some(cursor), + ); + let spec = session_sql::page_statement(&paged).expect("bindable"); + assert_eq!(spec.params[0], Value::Integer(3)); + assert_eq!(spec.params[2], Value::Text("sub".to_owned())); + assert_eq!(spec.params[3], Value::Integer(1)); + assert_eq!( + spec.params[4], + Value::Text("2026-09-26T10:00:00+00:00".to_owned()) + ); + assert_eq!(spec.params[5], Value::Text("session-9".to_owned())); + assert_eq!( + spec.params[6], + Value::Integer(26), + "limit 25 多取一行判有无下一页" + ); + + // 上限被夹到 200:请求 1000 行时仍然只取 201 行。 + let mut oversized = query(ThreadScope::All, None); + oversized.limit = 1000; + let spec = session_sql::page_statement(&oversized).expect("bindable"); + assert_eq!(spec.params[6], Value::Integer(201)); + + // 两次不同 scope 的 SQL 文本相同:过滤值全在参数里。 + assert_eq!( + session_sql::page_statement(&all).expect("bindable").sql, + session_sql::page_statement(&scoped).expect("bindable").sql + ); +} + +#[test] +fn update_meta_distinguishes_keep_clear_and_set() { + let patch = SessionMetaPatch { + title: Some(None), + status: Some(AgentStatus::Done), + cancel_policy: None, + config: Some(Some("{\"k\":1}".to_owned())), + }; + let spec = session_sql::update_meta_statement("session-1", &patch, "2026-09-26T12:00:00+00:00"); + assert_eq!(spec.params[1], Value::Integer(1), "title 有意图"); + assert_eq!(spec.params[2], Value::Null, "Some(None) 是清除"); + assert_eq!(spec.params[3], Value::Integer(1)); + assert_eq!(spec.params[4], Value::Text("done".to_owned())); + assert_eq!(spec.params[5], Value::Integer(0), "None 是保持不变"); + assert_eq!(spec.params[8], Value::Text("{\"k\":1}".to_owned())); + assert_eq!(spec.params[9], Value::Text("session-1".to_owned())); + + let untouched = SessionMetaPatch::default(); + let spec = session_sql::update_meta_statement("session-1", &untouched, "now"); + assert_eq!(spec.params[1], Value::Integer(0)); + assert_eq!(spec.params[9], Value::Text("session-1".to_owned())); +} + +fn query( + scope: ThreadScope, + cursor: Option, +) -> ScopedThreadQuery { + ScopedThreadQuery { + scope, + cursor, + limit: 25, + } +} + +/// `ProjectId` / `WorkspaceId` 的测试构造:解析失败即测试数据有误。 +trait OpaqueIdExt: Sized { + fn from_str_expect(value: &str) -> Self; +} + +impl OpaqueIdExt for ProjectId { + fn from_str_expect(value: &str) -> Self { + use std::str::FromStr; + Self::from_str(value).expect("test project id is a uuid") + } +} + +impl OpaqueIdExt for WorkspaceId { + fn from_str_expect(value: &str) -> Self { + use std::str::FromStr; + Self::from_str(value).expect("test workspace id is a uuid") + } +} + +/// 语句形状断言不依赖客户端:`StatementSpec` 的 Debug 不打印绑定值。 +#[test] +fn statement_debug_does_not_leak_bound_values() { + let spec: StatementSpec = session_sql::select_meta_statement("session-secret"); + let rendered = format!("{spec:?}"); + assert!(!rendered.contains("session-secret")); +} diff --git a/peri-resources/src/sessions/remote/session_sql.rs b/peri-resources/src/sessions/remote/session_sql.rs new file mode 100644 index 000000000..66c565b9b --- /dev/null +++ b/peri-resources/src/sessions/remote/session_sql.rs @@ -0,0 +1,442 @@ +//! 远程会话语句:静态 SQL 文本 + 全绑定参数,**表名与列名就是本机形状**。 +//! +//! 统一之后远端不再有自己的会话表:语句里的 `threads` / `messages` / `session_bindings` +//! 与 `sqlite_store` 说的是同一份 canonical schema(`sessions::canonical` 是那份 DDL 的 +//! 唯一来源)。列语义、`messages.rowid` 承载的 canonical 历史顺序、绑定四列所在的表都逐项 +//! 对应;远端独有的只剩执行器机制(托管批与幂等账本),不体现在会话形状上。 +//! +//! 规则与 [`super::sql`] 一致,只是范围更大:SQL 文本在编译期定型(`concat!` + `const`), +//! 会话 id、绑定、时间戳、分页游标、scope 过滤值全部走绑定参数。列投影只有一处来源 +//! (宏 `meta_columns!`),事实投影与前缀投影由它拼出,避免「同一列清单手抄三遍」漂移。 +//! +//! 解码用的列下标与投影同处一个模块,紧邻各自常量:投影改了,下标跟着改,测试拿真实 +//! 列文本核对前缀关系。 + +use peri_acp_types::session_resources::{ + NewSession, SessionMetaPatch, SessionResourceError, SessionResourceErrorKind, + SessionResourceResult, +}; +use peri_acp_types::store::{MessageFlags, PersistedPayload}; +use peri_acp_types::thread::AgentStatus; +use peri_acp_types::workspace::{ScopedThreadQuery, SessionBinding, ThreadScope}; +use turso_serverless::Value; + +use super::session_codec::{int_value, optional_text, payload_params}; +use super::sql::StatementSpec; + +/// 会话行投影的公共前缀:一列一行,顺序即解码下标。 +macro_rules! meta_columns { + () => { + "s.id, s.title, s.cwd, s.created_at, s.updated_at, s.message_count, + (SELECT COALESCE(SUM(LENGTH(m.content)), 0) FROM messages m WHERE m.thread_id = s.id), + s.parent_thread_id, s.snapshot_at_message_id, s.hidden, s.cancel_policy, s.config, s.agent_status" + }; +} + +/// 绑定四列:统一之后它们住在 `session_bindings` 表里(与本地同表同列),需要它们的投影 +/// 通过 `LEFT JOIN session_bindings b ON b.thread_id = s.id` 取,不再是事实行上的扁平列。 +macro_rules! binding_columns { + () => { + "b.schema_version, b.project_id, b.workspace_id, b.relative_cwd" + }; +} + +/// 事实行投影 = meta 投影 + frozen/继承区 + 绑定四列(只在单会话读取时用)。 +macro_rules! fact_columns { + () => { + concat!( + meta_columns!(), + ", s.frozen_context, s.inherited_context, ", + binding_columns!() + ) + }; +} + +/// 列表投影 = meta 投影 + 绑定四列(分页列举需要绑定分类,但不要 frozen/继承区正文)。 +macro_rules! page_columns { + () => { + concat!(meta_columns!(), ", ", binding_columns!()) + }; +} + +// ─── 列下标(由投影顺序决定,测试核对) ──────────────────────────────────────── + +pub(super) const META_COLUMN_COUNT: usize = 13; +pub(super) const META_ID: usize = 0; +pub(super) const META_TITLE: usize = 1; +pub(super) const META_CWD: usize = 2; +pub(super) const META_CREATED_AT: usize = 3; +pub(super) const META_UPDATED_AT: usize = 4; +pub(super) const META_MESSAGE_COUNT: usize = 5; +pub(super) const META_CONTENT_SIZE: usize = 6; +pub(super) const META_PARENT: usize = 7; +pub(super) const META_SNAPSHOT_AT: usize = 8; +pub(super) const META_HIDDEN: usize = 9; +pub(super) const META_CANCEL_POLICY: usize = 10; +pub(super) const META_CONFIG: usize = 11; +pub(super) const META_AGENT_STATUS: usize = 12; + +pub(super) const FACT_FROZEN: usize = 13; +pub(super) const FACT_INHERITED: usize = 14; +pub(super) const FACT_BINDING_VERSION: usize = 15; +pub(super) const FACT_BINDING_PROJECT: usize = 16; +pub(super) const FACT_BINDING_WORKSPACE: usize = 17; +pub(super) const FACT_BINDING_RELATIVE_CWD: usize = 18; +pub(super) const FACT_COLUMN_TOTAL: usize = 19; + +pub(super) const PAGE_BINDING_VERSION: usize = 13; +pub(super) const PAGE_BINDING_PROJECT: usize = 14; +pub(super) const PAGE_BINDING_WORKSPACE: usize = 15; +pub(super) const PAGE_BINDING_RELATIVE_CWD: usize = 16; +pub(super) const PAGE_COLUMN_TOTAL: usize = 17; + +/// 会话事实行(单会话读取)的投影。 +pub(super) const META_PROJECTION: &str = meta_columns!(); +pub(super) const FACT_PROJECTION: &str = fact_columns!(); +pub(super) const PAGE_PROJECTION: &str = page_columns!(); + +// ─── 读取语句 ───────────────────────────────────────────────────────────────── + +const SELECT_SESSION_SQL: &str = concat!( + "SELECT ", + fact_columns!(), + " FROM threads s LEFT JOIN session_bindings b ON b.thread_id = s.id WHERE s.id = ?1" +); + +const SELECT_META_SQL: &str = concat!( + "SELECT ", + meta_columns!(), + " FROM threads s WHERE s.id = ?1" +); + +const SELECT_CHILDREN_SQL: &str = concat!( + "SELECT ", + meta_columns!(), + " FROM threads s WHERE s.parent_thread_id = ?1 ORDER BY s.created_at ASC, s.id ASC" +); + +/// 以 `?1` 为根的整棵树(含自身)。CTE 名与投影别名同为 `s`,投影常量因此可复用 +/// (内层 `SELECT *` 的形状与表一致,列名即投影里的 `s.`)。 +const SELECT_TREE_SQL: &str = concat!( + "WITH RECURSIVE s AS ( + SELECT * FROM threads WHERE id = ?1 + UNION ALL + SELECT p.* FROM threads p INNER JOIN s ON p.parent_thread_id = s.id + ) + SELECT ", + meta_columns!(), + " FROM s ORDER BY s.created_at ASC, s.id ASC" +); + +const SELECT_MESSAGES_SQL: &str = + "SELECT m.message_id, m.content, m.truncated, m.excluded, m.projection + FROM messages m WHERE m.thread_id = ?1 ORDER BY m.rowid ASC"; + +/// 沿父链上溯到的根(含自身即根的情形);链上没有根时不返回行,由调用方判为事实不完整。 +const SELECT_ROOT_SQL: &str = "WITH RECURSIVE a AS ( + SELECT s.id, s.parent_thread_id FROM threads s WHERE s.id = ?1 + UNION ALL + SELECT p.id, p.parent_thread_id FROM threads p INNER JOIN a ON p.id = a.parent_thread_id +) SELECT a.id FROM a WHERE a.parent_thread_id IS NULL"; + +/// 父链根。 +pub(super) fn select_root_statement(id: &str) -> StatementSpec { + StatementSpec::new(SELECT_ROOT_SQL, vec![Value::Text(id.to_owned())]) +} + +/// 分页列举:scope 过滤、游标与上限全部是绑定参数,SQL 文本静态。 +/// +/// scope 用整数判别位 + 绑定值表达(`?1`):0 = 全部、1 = project、2 = workspace、 +/// 3 = 精确目录。没有绑定行的会话(远端不应存在,本机 legacy 才有的形状)不匹配任何 +/// 带 scope 的查询——远端不借用本机 workspace 登记,也就不假装能做 legacy 路径归属。 +const SELECT_PAGE_SQL: &str = concat!( + "SELECT ", + page_columns!(), + " FROM threads s LEFT JOIN session_bindings b ON b.thread_id = s.id + WHERE s.hidden = 0 AND s.message_count > 0 + AND (?1 = 0 + OR (?1 = 1 AND b.project_id = ?2) + OR (?1 = 2 AND b.workspace_id = ?2) + OR (?1 = 3 AND b.workspace_id = ?2 AND b.relative_cwd = ?3)) + AND (?4 = 0 OR (s.updated_at, s.id) < (?5, ?6)) + ORDER BY s.updated_at DESC, s.id DESC LIMIT ?7" +); + +/// 会话事实行(含绑定、frozen、继承区)。 +pub(super) fn select_session_statement(id: &str) -> StatementSpec { + StatementSpec::new(SELECT_SESSION_SQL, vec![Value::Text(id.to_owned())]) +} + +/// 会话 metadata 行(不含大字段)。 +pub(super) fn select_meta_statement(id: &str) -> StatementSpec { + StatementSpec::new(SELECT_META_SQL, vec![Value::Text(id.to_owned())]) +} + +/// 直接子会话 metadata。 +pub(super) fn select_children_statement(parent: &str) -> StatementSpec { + StatementSpec::new(SELECT_CHILDREN_SQL, vec![Value::Text(parent.to_owned())]) +} + +/// 整棵树 metadata(含根自身)。 +pub(super) fn select_tree_statement(root: &str) -> StatementSpec { + StatementSpec::new(SELECT_TREE_SQL, vec![Value::Text(root.to_owned())]) +} + +/// 自有 payload 行(按 canonical 插入序,即 `rowid`)。 +pub(super) fn select_messages_statement(id: &str) -> StatementSpec { + StatementSpec::new(SELECT_MESSAGES_SQL, vec![Value::Text(id.to_owned())]) +} + +/// 分页列举语句;scope 里的相对路径必须能作为文本绑定,否则拒绝(不猜路径相等, +/// 也不把「无法比较」静默当成「匹配为空」)。 +pub(super) fn page_statement(query: &ScopedThreadQuery) -> SessionResourceResult { + let (kind, scope_id, relative) = match &query.scope { + ThreadScope::All => (0_i64, Value::Null, Value::Null), + ThreadScope::Project(project) => (1, Value::Text(project.to_string()), Value::Null), + ThreadScope::Workspace(workspace) => (2, Value::Text(workspace.to_string()), Value::Null), + ThreadScope::ExactDirectory { + workspace_id, + relative_cwd, + } => { + let relative = relative_cwd.to_str().ok_or_else(|| { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: "scope cwd is not valid UTF-8".to_owned(), + }) + })?; + ( + 3, + Value::Text(workspace_id.to_string()), + Value::Text(relative.to_owned()), + ) + } + }; + let (cursor_flag, cursor_at, cursor_id) = match &query.cursor { + Some(cursor) => ( + 1_i64, + Value::Text(cursor.updated_at.to_rfc3339()), + Value::Text(cursor.thread_id.clone()), + ), + None => (0, Value::Null, Value::Null), + }; + let limit = i64::from(query.limit.clamp(1, 200)) + 1; + Ok(StatementSpec::new( + SELECT_PAGE_SQL, + vec![ + int_value(kind), + scope_id, + relative, + int_value(cursor_flag), + cursor_at, + cursor_id, + int_value(limit), + ], + )) +} + +// ─── 写入语句 ───────────────────────────────────────────────────────────────── + +/// 会话行插入:与本机 `sqlite_store/session_rows.rs::insert_thread_row` 同一列清单(含 +/// `cached_context` / `context_cache_epoch` 的缺省起点——那两列是本机读取缓存的失效位,远端 +/// 与本地一样从缺省值起步)。 +const INSERT_THREAD_SQL: &str = + "INSERT INTO threads (id, title, cwd, created_at, updated_at, message_count, + parent_thread_id, snapshot_at_message_id, hidden, cancel_policy, config, cached_context, + frozen_context, agent_status, context_cache_epoch) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, ?12, ?13, 0)"; + +/// 不可变绑定行插入:与本机 `session_rows.rs::insert_binding_row` 同一份语句,绑定住在 +/// `session_bindings` 表里(不再是会话行上的四个扁平列)。 +const INSERT_BINDING_SQL: &str = + "INSERT INTO session_bindings (thread_id, schema_version, project_id, workspace_id, relative_cwd) + VALUES (?1, ?2, ?3, ?4, ?5)"; + +/// 继承区写入:与本机 child 路径同一份语句(创建行之后单独写一次,INSERT 不多带一列)。 +const UPDATE_INHERITED_SQL: &str = "UPDATE threads SET inherited_context = ?1 WHERE id = ?2"; + +/// 历史行插入:列清单与本机 `messages` 一致(含 `role`),顺序由 `rowid` 承载。 +const INSERT_MESSAGE_SQL: &str = "INSERT INTO messages ( + message_id, thread_id, role, content, truncated, excluded, projection) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"; + +/// 定向更新:`Some(None)` 清除、`None` 保持不变、`None` 之外的值照写。 +/// +/// 用「标志位 + 值」的 CASE 表达,SQL 文本保持静态;没有字段要改时调用方不该调用它 +/// (门面与本地 adapter 同一约定:不写、也不假装更新了时间戳)。 +const UPDATE_META_SQL: &str = "UPDATE threads SET + updated_at = ?1, + title = CASE WHEN ?2 = 1 THEN ?3 ELSE title END, + agent_status = CASE WHEN ?4 = 1 THEN ?5 ELSE agent_status END, + cancel_policy = CASE WHEN ?6 = 1 THEN ?7 ELSE cancel_policy END, + config = CASE WHEN ?8 = 1 THEN ?9 ELSE config END + WHERE id = ?10"; + +/// 一行新会话的写入参数;派生计数与时间戳由调用方给定,本层不读时钟。 +pub(super) struct SessionInsert<'a> { + pub(super) thread_id: &'a str, + pub(super) title: Option<&'a str>, + pub(super) cwd: &'a str, + pub(super) created_at: &'a str, + /// 创建路径为 0;fork 目标为映射后的 payload 数。 + pub(super) message_count: i64, + pub(super) parent_thread_id: Option<&'a str>, + /// 快照截止消息 id(文本形式;由调用方从领域 id 定型)。 + pub(super) snapshot_at_message_id: Option, + pub(super) hidden: bool, + pub(super) cancel_policy: &'a str, + /// 会话创建时冻结的上下文快照字节(不解释内容)。 + pub(super) frozen: &'a str, + /// child 的继承区 JSON;创建与 fork 目标为 `None`。 + pub(super) inherited: Option<&'a str>, + pub(super) binding: &'a SessionBinding, + /// 已生效会话的 agent 状态;新行一律 `active`。 + pub(super) agent_status: &'a str, +} + +/// 由 [`NewSession`] 组装一行插入参数(创建/fork/child 三条路径共用同一列形状)。 +/// +/// `snapshot_at_message_id` 在这里定型为文本:它是记录事实,不是可再推导的引用。 +pub(super) fn session_insert<'a>( + input: &'a NewSession, + message_count: i64, + inherited: Option<&'a str>, +) -> SessionInsert<'a> { + SessionInsert { + thread_id: &input.thread_id, + title: input.meta.title.as_deref(), + cwd: &input.meta.cwd, + created_at: &input.created_at, + message_count, + parent_thread_id: input.meta.parent_thread_id.as_deref(), + snapshot_at_message_id: input + .meta + .snapshot_at_message_id + .map(|id| id.as_uuid().to_string()), + hidden: input.meta.hidden, + cancel_policy: input.meta.cancel_policy.as_str(), + frozen: input.frozen.as_str(), + inherited, + binding: &input.binding, + agent_status: AgentStatus::Active.as_str(), + } +} + +/// 一条新会话的落库语句集:`threads` 行 + `session_bindings` 行(child 另加继承区写入)。 +/// +/// `updated_at` 从 `created_at` 起步(创建时刻即最后更新时刻);顺序即批内执行顺序, +/// 父行先于引用它的绑定行。继承区单独写一次,与本机 child 路径同一节奏。 +pub(super) fn insert_session_statements( + row: &SessionInsert<'_>, +) -> SessionResourceResult> { + let relative = binding_relative_text(row.binding)?; + let mut statements = vec![ + StatementSpec::new( + INSERT_THREAD_SQL, + vec![ + Value::Text(row.thread_id.to_owned()), + optional_text(row.title), + Value::Text(row.cwd.to_owned()), + Value::Text(row.created_at.to_owned()), + Value::Text(row.created_at.to_owned()), + int_value(row.message_count), + optional_text(row.parent_thread_id), + optional_text(row.snapshot_at_message_id.as_deref()), + int_value(i64::from(row.hidden)), + Value::Text(row.cancel_policy.to_owned()), + Value::Null, + Value::Text(row.frozen.to_owned()), + Value::Text(row.agent_status.to_owned()), + ], + ), + StatementSpec::new( + INSERT_BINDING_SQL, + vec![ + Value::Text(row.thread_id.to_owned()), + int_value(i64::from(row.binding.schema_version)), + Value::Text(row.binding.project_id.to_string()), + Value::Text(row.binding.workspace_id.to_string()), + Value::Text(relative), + ], + ), + ]; + if let Some(inherited) = row.inherited { + statements.push(StatementSpec::new( + UPDATE_INHERITED_SQL, + vec![ + Value::Text(inherited.to_owned()), + Value::Text(row.thread_id.to_owned()), + ], + )); + } + Ok(statements) +} + +/// 历史行的插入语句:flags 与内容一次写入(fork 目标由门面完成 ID 重映射,本层不重跑算法)。 +pub(super) fn insert_message_statement( + thread_id: &str, + payload: &PersistedPayload, + flags: Option<&MessageFlags>, +) -> SessionResourceResult { + Ok(StatementSpec::new( + INSERT_MESSAGE_SQL, + payload_params(thread_id, payload, flags)?, + )) +} + +/// 定向 metadata 更新语句(`None` 不动、`Some(None)` 清除)。 +pub(super) fn update_meta_statement( + id: &str, + patch: &SessionMetaPatch, + now: &str, +) -> StatementSpec { + let (title_flag, title) = match &patch.title { + Some(value) => (1_i64, optional_text(value.as_deref())), + None => (0, Value::Null), + }; + let (status_flag, status) = match &patch.status { + Some(status) => (1_i64, Value::Text(status.as_str().to_owned())), + None => (0, Value::Null), + }; + let (policy_flag, policy) = match &patch.cancel_policy { + Some(policy) => (1_i64, Value::Text(policy.as_str().to_owned())), + None => (0, Value::Null), + }; + let (config_flag, config) = match &patch.config { + Some(value) => (1_i64, optional_text(value.as_deref())), + None => (0, Value::Null), + }; + StatementSpec::new( + UPDATE_META_SQL, + vec![ + Value::Text(now.to_owned()), + int_value(title_flag), + title, + int_value(status_flag), + status, + int_value(policy_flag), + policy, + int_value(config_flag), + config, + Value::Text(id.to_owned()), + ], + ) +} + +/// 绑定里的相对 cwd 必须能作为文本存储,且形状上确实是「相对 workspace 的路径」。 +/// +/// 这里只做形状校验:目录是否存在、是否与已登记 workspace 一致由本机 workspace/门面判定, +/// 云端没有目录可查,也不承担那个判断。 +pub(super) fn binding_relative_text(binding: &SessionBinding) -> SessionResourceResult { + let path = &binding.cwd_relative_to_workspace; + if path.is_absolute() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::InvalidInput { + detail: "session binding cwd must be relative to its workspace".to_owned(), + }, + )); + } + path.to_str().map(str::to_owned).ok_or_else(|| { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: "session binding cwd is not valid UTF-8".to_owned(), + }) + }) +} diff --git a/peri-resources/src/sessions/remote/session_write.rs b/peri-resources/src/sessions/remote/session_write.rs new file mode 100644 index 000000000..da4ecb8de --- /dev/null +++ b/peri-resources/src/sessions/remote/session_write.rs @@ -0,0 +1,382 @@ +//! 远程会话写入行为:新建、fork、child、定向 metadata。 +//! +//! 写入只有一种形状:**一次 `SessionDataPort` mutation = 一个托管事务批**,批内第一条 +//! 是操作资格写入(见 [`super::ledger`]),其后才是业务效果。由此得到三条性质: +//! +//! - **整体生效或整体不生效**:中途失败整批回滚,不留半条会话; +//! - **重试不产生第二次效果**:操作 id 由 store 身份 + 语义标签 + 内容摘要定死,同一内容 +//! 再次提交命中同一 id,读回原收据;不同内容不会互相冒充(摘要进了 id); +//! - **未决不降级**:超时、网络失败、写忙都是「无法证明」,按未决上报而不是「未生效」。 +//! +//! 写入前的核对(来源存在、父链根、root frozen 原文、父绑定一致)都是**不可变事实**: +//! 绑定创建时写一次、frozen 只有创建路径写、父关系创建后不再变,而本 adapter 不提供任何 +//! 改绑/改父/改 frozen/删除的行为。因此这些读取不会在「读—写」之间失效,也不需要把核对 +//! 塞进同一个事务来换一致性(远端也没有第二个写者能改写它们)。 + +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + ChildSnapshot, ForkSnapshot, NewSession, SessionMetaPatch, SessionResourceError, + SessionResourceErrorKind, SessionResourceResult, +}; +use peri_acp_types::store::MessageFlags; +use peri_acp_types::thread::ThreadId; +use peri_acp_types::workspace::{SessionBinding, WorkspaceError}; + +use crate::sessions::data::ensure_child_relation; + +use super::ledger::{OperationId, OperationIdentity}; +use super::mutation::QualifiedMutation; +use super::session_codec::{self as codec, corrupt}; +use super::session_data::{invalid_input, not_found, RemoteSessionData}; +use super::session_sql::{self, SessionInsert}; +use super::sql::StatementSpec; + +impl RemoteSessionData { + /// 保存新会话:meta + 不可变绑定 + frozen 完整落库(执行准入由本机执行面另行完成)。 + pub(super) async fn write_new_session(&self, input: &NewSession) -> SessionResourceResult<()> { + // 远程新建只接受 **root**:带父的会话必须走 `save_child`,那里才有父子/根归属判定 + // (`data::ensure_child_relation`)、root owner 门禁与 frozen 继承。 + // + // 这条判定原先挂在已撤销的远程执行面(`remote/local_execution.rs`)上,v10 撤销把 + // 那个文件连同它一起删掉了(真云回归因此转红:`cloud_limit_test.rs` 的 + // `cloud_remote_create_refuses_parent_input`)。判定只看纯输入、不读存储也不发请求, + // 所以它必须在取连接之前给出——放行会往远端写一条**没有经过 child 通路判定**的父关系 + // (`session_insert` 照抄 `meta.parent_thread_id`,远端父链没有外键可依赖)。 + // + // 注意这是**远程侧**的规则,不上升成门面的通用输入校验:本机 `save_new_session` 一直 + // 接受带父的目标,`sqlite_store` 侧的夹具依赖这一点(见本文件顶部的模块文档)。 + if input.meta.parent_thread_id.is_some() { + return Err(invalid_input( + "child sessions must be saved through the child path", + )); + } + let statements = + session_sql::insert_session_statements(&session_sql::session_insert(input, 0, None))?; + let inputs = session_inputs(input); + self.commit_effects("create_session", &inputs, statements, &input.thread_id) + .await + .map(|_| ()) + } + + /// 保存 fork:source 不变,目标带映射后的 payload 与 flags。 + pub(super) async fn write_fork(&self, fork: &ForkSnapshot) -> SessionResourceResult<()> { + if fork.target.thread_id == fork.source_id { + return Err(invalid_input("fork target must differ from its source")); + } + // flags 必须落在本批 payload 上,且 payload 内 id 不得重复:两者都在发请求前拒绝, + // 因此批内不需要「更新了几行」式的后续核对。 + let mut ids: Vec = Vec::with_capacity(fork.payloads.len()); + for payload in &fork.payloads { + if ids.contains(&payload.id()) { + return Err(invalid_input("history batch repeats a message id")); + } + ids.push(payload.id()); + } + for message_id in fork.flags.keys() { + if !ids.contains(message_id) { + return Err(invalid_input("fork flags reference an unknown message id")); + } + } + if !self.exists(&fork.source_id).await? { + return Err(not_found()); + } + let insert = session_sql::session_insert(&fork.target, fork.payloads.len() as i64, None); + let mut statements = session_sql::insert_session_statements(&insert)?; + for payload in &fork.payloads { + statements.push(session_sql::insert_message_statement( + &fork.target.thread_id, + payload, + fork.flags.get(&payload.id()), + )?); + } + let mut inputs = session_inputs(&fork.target); + inputs.push(format!("source:{}", fork.source_id.as_str())); + let id_list = ids + .iter() + .map(|id| id.as_uuid().to_string()) + .collect::>() + .join(","); + inputs.push(id_list); + // flags 进摘要必须带上投影内容:同一 message 的两种投影是两个不同的领域意图。 + let flag_list = fork + .flags + .iter() + .map(|(id, flags)| flags_label(*id, flags)) + .collect::>() + .join(","); + inputs.push(flag_list); + self.commit_effects("fork_session", &inputs, statements, &fork.target.thread_id) + .await + .map(|_| ()) + } + + /// 保存 child:父子/根归属 + 继承区成立,frozen 逐字节取自 root 已保存的快照。 + pub(super) async fn write_child(&self, child: &ChildSnapshot) -> SessionResourceResult<()> { + // 与门面、本机 adapter 共用同一条输入规则;它在任何远端读取之前生效,因此一次 + // 被拒的 child 既不发批、也不在本机日志里留下任何收据。 + ensure_child_relation(child)?; + if !self.exists(&child.parent_id).await? { + return Err(not_found()); + } + let root = self.root_of(&child.parent_id).await?; + if root != child.root_id { + return Err(invalid_input("child root does not match its parent chain")); + } + if self.frozen_of(&child.root_id).await?.as_deref() != Some(child.target.frozen.as_str()) { + return Err(invalid_input( + "child frozen snapshot must be the root's saved snapshot", + )); + } + // 子会话继承父会话的执行绑定身份,不另立 workspace 归属。 + if let Some(parent_binding) = self.binding_of(&child.parent_id).await? { + if parent_binding != child.target.binding { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBindingMismatch), + )); + } + } + let inherited = codec::inherited_json(&child.inherited)?; + let insert: SessionInsert<'_> = + session_sql::session_insert(&child.target, 0, Some(inherited.as_str())); + let statements = session_sql::insert_session_statements(&insert)?; + let mut inputs = session_inputs(&child.target); + inputs.push(format!("parent:{}", child.parent_id.as_str())); + inputs.push(format!("root:{}", child.root_id.as_str())); + inputs.push(inherited); + // 子会话的根是显式给定的(已与父链核对一致),不需要再解析一次。 + self.commit_effects( + "child_session", + &inputs, + statements, + &child.target.thread_id, + ) + .await + .map(|_| ()) + } + + /// 定向 metadata 更新:没有字段要改时不写、也不假装更新了时间戳。 + pub(super) async fn write_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + if patch.title.is_none() + && patch.status.is_none() + && patch.cancel_policy.is_none() + && patch.config.is_none() + { + return Ok(()); + } + let now = chrono::Utc::now().to_rfc3339(); + let title_input = patch_input(&patch.title); + let status_input = patch + .status + .map(|status| status.as_str().to_owned()) + .unwrap_or_default(); + let policy_input = patch + .cancel_policy + .map(|policy| policy.as_str().to_owned()) + .unwrap_or_default(); + let config_input = patch_input(&patch.config); + let inputs = vec![ + format!("id:{}", id.as_str()), + format!("title:{title_input}"), + format!("status:{status_input}"), + format!("cancel_policy:{policy_input}"), + format!("config:{config_input}"), + ]; + let statements = vec![session_sql::update_meta_statement(id, patch, &now)]; + let counts = self + .commit_effects("update_session_meta", &inputs, statements, id) + .await?; + match counts.first() { + // 重放:原操作已生效,效果落在第一次提交里。 + None => Ok(()), + Some(1) => Ok(()), + Some(0) => Err(not_found()), + Some(_) => Err(corrupt( + "session metadata update did not apply to exactly one row", + )), + } + } + + /// 一次「资格先于效果」的写入,返回效果语句的受影响行数。 + /// + /// 顺序固定,且每一步都不能省: + /// + /// 1. **铸造身份**:本层给出一个全新的操作 id(不由内容派生,见 [`OperationIdentity`]), + /// 输入摘要只用于事后一致性校验; + /// 2. **发送**:一次托管事务批(资格先于效果),资格与效果同生共死; + /// 3. **回报确定性**:已生效 / 确定未生效 / 无法证明三种结果原样上报,未知一律映射为 + /// `PersistenceUncertain`,绝不折叠成「确定未生效」。 + /// + /// 本机**不再**为这些操作留日志(v10 删除了 `session_remote_operations`,用户裁决不做 + /// 跨安装能力):跨进程重启后没有「按原 id 向远端求证」这条路径,未结清只在本进程的 + /// 租约上表达,进程崩溃的未结清代际由 `execution_runs.clean = 0` 表达。 + pub(super) async fn commit_effects( + &self, + behavior: &str, + inputs: &[String], + effects: Vec, + thread: &ThreadId, + ) -> SessionResourceResult> { + let identity = mint_identity(behavior, thread, inputs); + let store = self.store().await?; + let (outcome, counts) = store + .apply_qualified_reporting(&QualifiedMutation { identity, effects }) + .await?; + match outcome.failure_error(Some(thread)) { + Some(error) => Err(error), + None => Ok(counts), + } + } +} + +/// 操作身份:每次调用唯一的 id,输入摘要只进身份做一致性校验。 +/// +/// 不复用调用方给的任何重试令牌,也不由内容派生 id:同一次领域调用连续发生两次, +/// 即使输入完全相同也是两次操作(状态 A→B→A、标题 x→y→x 都必须真的生效)。重试是 +/// 新的一次操作,没有「按已落盘的原 id 恢复」这条路径(本机日志已随 v10 删除)。 +fn mint_identity(behavior: &str, thread: &ThreadId, inputs: &[String]) -> OperationIdentity { + OperationIdentity::new(OperationId::mint(thread), behavior, &input_labels(inputs)) +} + +fn input_labels(inputs: &[String]) -> Vec<&str> { + inputs.iter().map(String::as_str).collect() +} + +/// 定向更新的摘要输入:区分「不动」与「清除」,否则两种不同意图会撞同一个操作 id。 +fn patch_input(slot: &Option>) -> String { + match slot { + None => "keep".to_owned(), + Some(None) => "clear".to_owned(), + Some(Some(value)) => format!("set:{value}"), + } +} + +/// 新建类输入(new / fork / child 的目标)的**完整**摘要输入。 +/// +/// 身份不由内容派生(id 每次唯一),摘要只用于事后一致性校验;但校验要成立,摘要就必须 +/// 覆盖领域输入的全部字段:漏掉 binding/metadata 时,两次「内容摘要相同、实际写入不同」 +/// 的操作会被判成同一次,那正是身份模型出错的表现,不能靠事后补字段掩盖。 +fn session_inputs(input: &NewSession) -> Vec { + let meta = &input.meta; + vec![ + format!("thread:{}", input.thread_id.as_str()), + format!("created_at:{}", input.created_at), + format!("title:{}", meta.title.as_deref().unwrap_or("")), + format!("cwd:{}", meta.cwd), + format!( + "parent:{}", + meta.parent_thread_id.as_deref().unwrap_or("") + ), + format!("hidden:{}", u8::from(meta.hidden)), + format!("cancel_policy:{}", meta.cancel_policy.as_str()), + format!( + "snapshot_at:{}", + meta.snapshot_at_message_id + .as_ref() + .map(|id| id.as_uuid().to_string()) + .unwrap_or_else(|| "".to_owned()) + ), + binding_input(&input.binding), + format!("frozen:{}", input.frozen.as_str()), + ] +} + +/// 绑定进摘要:project/workspace/相对目录与绑定版本都给全,避免不同绑定的会话被判成同一操作。 +fn binding_input(binding: &SessionBinding) -> String { + format!( + "binding:{}:{}:{}:{}", + binding.schema_version, + binding.project_id, + binding.workspace_id, + binding.cwd_relative_to_workspace.display() + ) +} + +/// flags 进摘要:位与投影内容都要(同一 message 的不同投影是不同意图)。 +pub(super) fn flags_label(message: MessageId, flags: &MessageFlags) -> String { + format!( + "{}:flags:{}{}{}:{}", + message.as_uuid(), + u8::from(flags.truncated), + u8::from(flags.excluded), + u8::from(flags.projection.is_some()), + flags + .projection + .as_ref() + .and_then(|projection| serde_json::to_string(projection).ok()) + .unwrap_or_default() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use peri_acp_types::session_resources::{NewSession, NewSessionMeta}; + use peri_acp_types::thread::CancelPolicy; + use peri_acp_types::workspace::{ProjectId, SessionBinding, WorkspaceId}; + + fn session(title: &str, workspace: WorkspaceId) -> NewSession { + NewSession { + thread_id: "thread-x".to_owned(), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(title.to_owned()), + cwd: "/tmp/synth".to_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::Cascade, + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: 1, + revision: 1, + project_id: ProjectId::new(), + workspace_id: workspace, + cwd_relative_to_workspace: std::path::PathBuf::from("sub"), + }, + frozen: peri_acp_types::session_resources::FrozenSnapshotBytes::new("{}"), + } + } + + /// 同内容连续三次领域调用必须得到三个不同操作 id(A→B→A、x→y→x 的根因)。 + #[test] + fn identical_inputs_mint_distinct_operations() { + let thread = ThreadId::from("thread-x"); + let inputs = vec!["status:done".to_owned()]; + let first = mint_identity("update_session_meta", &thread, &inputs); + let second = mint_identity("update_session_meta", &thread, &inputs); + let third = mint_identity("update_session_meta", &thread, &inputs); + for (left, right) in [(&first, &second), (&second, &third), (&first, &third)] { + assert_ne!(left.operation_id.as_str(), right.operation_id.as_str()); + } + // 摘要只做一致性校验:同输入同摘要,异输入异摘要。 + assert_eq!(first.digest, third.digest); + let other = mint_identity( + "update_session_meta", + &thread, + &["status:active".to_owned()], + ); + assert_ne!(first.digest, other.digest); + // 身份不带内容原文,只带会话定位前缀。 + assert!(!first.operation_id.as_str().contains("done")); + assert!(first.operation_id.as_str().starts_with("thread-x.")); + } + + /// 新建摘要必须覆盖 binding 与 metadata:只差其中一个字段就是两次不同的操作。 + #[test] + fn session_inputs_cover_binding_and_metadata() { + let base = session("title-a", WorkspaceId::new()); + let other_title = session("title-b", base.binding.workspace_id); + let mut other_binding = base.clone(); + other_binding.binding.workspace_id = WorkspaceId::new(); + + let base_inputs = session_inputs(&base); + let title_inputs = session_inputs(&other_title); + let binding_inputs = session_inputs(&other_binding); + assert_ne!(base_inputs.join(""), title_inputs.join("")); + assert_ne!(base_inputs.join(""), binding_inputs.join("")); + } +} diff --git a/peri-resources/src/sessions/remote/sql.rs b/peri-resources/src/sessions/remote/sql.rs new file mode 100644 index 000000000..3f2c6ce0d --- /dev/null +++ b/peri-resources/src/sessions/remote/sql.rs @@ -0,0 +1,66 @@ +//! 参数化语句与取值解码的共用原语。 +//! +//! 远程路径只有一种语句形态:**静态 SQL + 全部值走绑定参数**。SQL 文本是 `&'static str`, +//! 动态内容(会话 id、store id、收据、时间戳)只能出现在 `params` 里;表名等无法绑定的 +//! 位置不得由外部输入拼出。这条约束由 `StatementSpec` 的形状和离线测试共同保证。 + +use std::fmt; + +use turso_serverless::Value; + +/// 一条参数化语句。 +/// +/// `sql` 是静态文本(不含插值),`params` 只按位置绑定;无参数语句用空 `Vec`。 +#[derive(Clone, PartialEq)] +pub(super) struct StatementSpec { + pub(super) sql: &'static str, + pub(super) params: Vec, +} + +/// 手写 Debug:只给语句头与参数个数,不把绑定值(可能含会话内容)带进日志。 +impl fmt::Debug for StatementSpec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let head: String = self.sql.chars().take(32).collect(); + write!( + formatter, + "StatementSpec({head}…, {} params)", + self.params.len() + ) + } +} + +impl StatementSpec { + pub(super) fn new(sql: &'static str, params: Vec) -> Self { + Self { sql, params } + } + + pub(super) fn bare(sql: &'static str) -> Self { + Self { + sql, + params: Vec::new(), + } + } + + /// 该语句是否只读(只用于离线断言:读路径不得出现写入语句)。 + #[cfg(test)] + pub(super) fn is_read_only(&self) -> bool { + let head = self.sql.trim_start().to_ascii_uppercase(); + head.starts_with("SELECT") || head.starts_with("WITH") + } +} + +/// 第 `index` 列是 TEXT 时返回其值。 +pub(super) fn text_at(values: &[Value], index: usize) -> Option<&str> { + match values.get(index) { + Some(Value::Text(text)) => Some(text.as_str()), + _ => None, + } +} + +/// 第 `index` 列是 INTEGER 时返回其值。 +pub(super) fn int_at(values: &[Value], index: usize) -> Option { + match values.get(index) { + Some(Value::Integer(value)) => Some(*value), + _ => None, + } +} diff --git a/peri-resources/src/sessions/resources.rs b/peri-resources/src/sessions/resources.rs new file mode 100644 index 000000000..c9c2fdd0a --- /dev/null +++ b/peri-resources/src/sessions/resources.rs @@ -0,0 +1,818 @@ +//! 会话资源门面实现:把本机执行面与数据面组合成消费侧唯一入口。 +//! +//! 职责分工(B §2):门面持有**两类事实**——数据面(`SessionDataPort` 的 SQLite 实现) +//! 与本机执行面([`LocalExecution`]:发现、登记、owner、dirty、准入);业务侧只看到本 +//! 门面。数据端口是 `crate::sessions` 内的可见类型,其他 crate 与资源层其他模块都拿 +//! 不到裸写句柄,本门面也不导出任何无 guard 的写入路径。 +//! +//! 四条贯穿全部 mutation 的规则: +//! +//! 1. **统一准入**:能力/权限 → 未决持久化 → 本 root 有效 owner,检查在门面内部完成 +//! ([`MutationGate`]),不靠调用方先查。 +//! 2. **效果结清**:只有 `Applied | NotApplied` 才释放写入准入;`Unknown`(取消、 +//! 提交未确认)把范围交给 `Drop`,在租约上留下未决证据。 +//! 3. **只读不退化**:已有会话上的写入在只读打开时返回 `ReadOnlyStore`(历史可读、 +//! 执行权不可得,与既有只读降级路径一致);需要登记新身份/新绑定的写入返回 +//! `Workspace(ReadOnlyStore)`(连会话都还没有,没有可降级的对象)。 +//! 4. **诚实失败**:数据已完整保存但准入未成立时返回 `saved_but_not_admitted`, +//! 不谎称「确定未创建」,也不让调用方据此删数据。 +//! +//! SQLite 的本地塌缩(新建时数据与执行代际同一事务)见 [`LocalExecution::create_with_lease`]; +//! 远程是「durable 数据 + 本机准入」两步,不是分布式事务。 + +mod claim; +mod gate; +mod lifecycle; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + AccessMode, BindingRecheck, BindingState, ChildResumeClaim, ChildSnapshot, DataCapabilities, + ExecutionAvailability, ForkSnapshot, FrozenSnapshotBytes, NewSession, PersistenceRecovery, + RewindBoundary, SessionAvailability, SessionMetaPatch, SessionResourceError, + SessionResourceErrorKind, SessionResourceResult, SessionResources, SessionSnapshot, +}; +use peri_acp_types::store::{CompactionChange, MessageFlags, PersistedPayload}; +use peri_acp_types::thread::{AgentStatus, ThreadId, ThreadMeta}; +use peri_acp_types::workspace::{ + RecoveryRequiredDetails, ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, + SessionBinding, SessionExecutionLease, WorkspaceError, +}; + +use super::data::{ensure_child_relation, ChildResumeRecord, SessionDataPort}; +use super::local_port::LocalExecutionPort; +use super::sqlite_store::{ + execution_failure, invalid_input, lease_required, not_found, same_lease, LocalExecution, + ReadOnlyThreadStoreError, +}; + +use claim::ChildResumeClaimHandle; +use gate::MutationGate; +use lifecycle::{Lifecycle, LifecycleState}; + +/// 排空与关闭的有界等待。 +/// +/// 本机写入是短事务(单条 SQL 或一个 `BEGIN IMMEDIATE`),超过这个预算说明有写入卡住; +/// 此时报告未结清,而不是无限期等待一个外部 future。 +const SETTLE_WAIT: Duration = Duration::from_secs(10); + +/// 会话数据的存放位置 — 由组合层在装配时确定,门面不做后端推断。 +/// +/// 它只决定 `create_session` 的提交次数,不改变任何公开行为: +/// +/// - 数据与执行代际在**同一个本机库**时是一次提交(本地塌缩,数据与代际同生共死); +/// - 数据在**远端**时是两步(先由数据端口保存 canonical 数据,再取本机执行准入)。 +/// 两步之间没有分布式事务,因此保存成功而准入失败时只能如实报告 +/// `saved_but_not_admitted`(历史可读,执行资格不可得)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::sessions) enum SessionDataHome { + LocalLibrary, + RemoteStore, +} + +/// 会话资源门面(生产实现)。 +/// +/// 构造即确定访问模式与后端:[`Self::open`] 写打开(必要时原地升级 schema), +/// [`Self::open_existing_read_only`] 只读打开(不建库、不建表、不建锁文件)。 +pub struct SessionResourcesImpl { + gate: MutationGate, + /// 关闭生命周期与 [`MutationGate`] 共享:`Closing` 起停止新写入,只有真实检查全部 + /// 结清并关闭数据面之后才确认 `Closed`(见 [`Self::close`])。 + lifecycle: Lifecycle, + /// 串行化关闭确认:并发关闭必须依次看到真实结论,不能两个都「从头开始」而重复关闭 + /// 同一个连接,也不能把另一个调用的未确认状态当成成功。 + close_confirm: tokio::sync::Mutex<()>, + /// 会话数据的存放位置:决定 `create_session` 是几次提交(见 [`SessionDataHome`])。 + home: SessionDataHome, +} + +impl SessionResourcesImpl { + /// 打开或创建会话库,原地升级已知旧 schema 并保留历史数据。 + pub async fn open(db_path: impl Into) -> anyhow::Result { + Ok(Self::from_local(LocalExecution::open(db_path).await?)) + } + + /// 以只读方式打开已存在的会话库;不创建目录、库、schema 或锁文件。 + pub async fn open_existing_read_only( + db_path: impl AsRef, + ) -> Result { + Ok(Self::from_local( + LocalExecution::open_existing_read_only(db_path).await?, + )) + } + + /// 默认数据库位置 `~/.peri/threads/threads.db`;不创建目录、数据库或连接。 + pub fn default_database_path() -> anyhow::Result { + LocalExecution::default_database_path() + } + + /// 与迁移桥共享同一个库句柄(迁移期唯一装配点 `SqliteThreadStore::open_shared*` 使用)。 + /// + /// 本机组合:数据面与执行面由同一个库句柄回答(同一条连接真相),两个端口因此只是 + /// 同一实现的两张面孔。 + pub(in crate::sessions) fn from_local(local: LocalExecution) -> Self { + let data = Arc::new(local.data_port()); + Self::from_ports(data, Arc::new(local), SessionDataHome::LocalLibrary) + } + + /// 组合层装配点:数据面 + 本机执行面,两者由调用方决定指向哪个后端。 + /// + /// 门面不持有任何后端判断,只按 `home`(组合层给出的**事实**)决定 `create_session` + /// 是一步还是两步;公开行为仍只有 [`SessionResources`] 这一套。 + pub(in crate::sessions) fn from_ports( + data: Arc, + local: Arc, + home: SessionDataHome, + ) -> Self { + let lifecycle = Lifecycle::new(); + let gate = MutationGate::new(data, local, lifecycle.clone()); + Self { + gate, + lifecycle, + close_confirm: tokio::sync::Mutex::new(()), + home, + } + } + + /// 会话级执行资格:只读事实、未决持久化与代际事实分开表达。 + /// + /// 本方法不取得所有权、不创建锁文件:跨进程的持有者只能由 + /// [`SessionResources::acquire_execution`] 的稳定锁判定。 + async fn execution_availability( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + let local = self.gate.local(); + if local.is_read_only() { + return Ok(ExecutionAvailability::ReadOnlyStore); + } + if !self.gate.data().session_exists(id).await? { + return Err(not_found()); + } + // 数据面事实一次取齐:活 owner 判定要树根,绑定复核要绑定字节。两者都不是本机事实, + // 远端组合里本机没有这条会话的行。 + let facts = self.gate.session_facts(id).await?; + // 活 owner 优先于代际事实:正在运行的会话必然是 `clean = 0`,那是「有主」而不是 + // 「需要恢复」。`OwnedElsewhere` 因此表达「执行权已在某处且活跃,本次不能再次取得」。 + if local + .live_owner(id, &facts) + .await + .map_err(execution_failure)? + .is_some_and(|lease| lease.is_active()) + { + return Ok(ExecutionAvailability::OwnedElsewhere); + } + if let Some((generation, false)) = + local.execution_state(id).await.map_err(execution_failure)? + { + return Ok(ExecutionAvailability::Dirty(RecoveryRequiredDetails { + thread_id: id.clone(), + generation, + })); + } + match self.recheck_binding(facts.binding.as_ref(), false).await { + Ok(_) => Ok(ExecutionAvailability::Available), + Err(error) + if matches!( + error.workspace_error(), + Some(WorkspaceError::BindingMissing) + ) => + { + Ok(ExecutionAvailability::BindingMissing) + } + Err(_) => Ok(ExecutionAvailability::WorkspaceUnavailable), + } + } + + /// 用 canonical 绑定字节做本机复核(`full` 为真时叠一次完整发现快照比对)。 + /// + /// 绑定字节来自数据面:本机组合是本机 `session_bindings`,远程组合是远端会话行。 + /// 没有绑定行时按 `BindingMissing` 如实失败——执行资格需要一个可复核的绑定。 + async fn recheck_binding( + &self, + binding: Option<&SessionBinding>, + full: bool, + ) -> SessionResourceResult { + let binding = binding.ok_or_else(|| { + SessionResourceError::new(SessionResourceErrorKind::Workspace( + WorkspaceError::BindingMissing, + )) + })?; + self.gate + .local() + .validate_binding_value(binding, full) + .await + .map_err(execution_failure) + } + + /// 按 id 取数据面绑定字节后复核(只回答「绑定向哪里」的调用方用这个)。 + async fn recheck_binding_of( + &self, + id: &ThreadId, + full: bool, + ) -> SessionResourceResult { + let binding = self.gate.data().binding_of(id).await?; + self.recheck_binding(binding.as_ref(), full).await + } + + /// `Missing` 与 `LegacyConfirmed` 的差别是本机来源证据:无绑定、无父会话、无执行 + /// 代际,且保存的绝对 cwd 落在本机已登记工作区内,才表达为 legacy 历史;其余的无 + /// 绑定状态(外来会话、登记缺失)不冒充 legacy。 + /// + /// 这份证据**只属于本机组合**:`legacy_confirmed` 读的是本机 `threads` / `session_bindings` + /// / `workspaces`,远端组合里会话行与它的 cwd 都在远端,本机根本没有可读的来源证据。 + /// 因此远端组合由门面**固定为 false**(端口文档同此),而不是去本机表里碰运气: + /// 本机恰有一条同 id 的行就会把远端会话判成 legacy。 + async fn classify_binding( + &self, + id: &ThreadId, + state: BindingState, + ) -> SessionResourceResult { + if !matches!(state, BindingState::Missing) { + return Ok(state); + } + if self.home == SessionDataHome::RemoteStore { + return Ok(state); + } + if self + .gate + .local() + .legacy_confirmed(id) + .await + .map_err(execution_failure)? + { + return Ok(BindingState::LegacyConfirmed); + } + Ok(state) + } + + /// 「数据已完整保存、执行代际未写」的收敛。 + /// + /// 前提(又是同一次创建、工作区证据仍然一致)成立时补上准入;否则保留 identity 并 + /// 如实报告「已保存、未准入」——既不重造 binding/frozen,也不谎称「确定未创建」。 + async fn admit_saved_creation( + &self, + id: &ThreadId, + binding: &SessionBinding, + ) -> SessionResourceResult> { + let local = self.gate.local(); + if local + .execution_state(id) + .await + .map_err(execution_failure)? + .is_some() + { + return Err(invalid_input("session identity already exists")); + } + let saved = self.gate.data().binding_of(id).await?; + let premise_holds = saved.as_ref() == Some(binding) + && self.recheck_binding(saved.as_ref(), false).await.is_ok(); + if !premise_holds { + return Err(SessionResourceError::saved_but_not_admitted(id.clone())); + } + match local.admit_existing(id, binding).await { + Ok(lease) => Ok(lease), + Err(error) if error.is_persistence_uncertain() => Err(error), + Err(_) => Err(SessionResourceError::saved_but_not_admitted(id.clone())), + } + } + + fn workspace_mismatch() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Workspace( + WorkspaceError::ExecutionBindingMismatch, + )) + } + + /// 测试用:本机组合背后的 SQLite 连接池(逐条构造事实的夹具使用)。 + #[cfg(test)] + pub(super) fn local_pool(&self) -> &sqlx::SqlitePool { + self.gate + .local() + .sqlite_pool() + .expect("local composition always has a SQLite pool") + } +} + +// ─── 门面实现 ───────────────────────────────────────────────────────────────── + +#[async_trait] +impl SessionResources for SessionResourcesImpl { + // ── 能力与准入 ── + + async fn inspect_availability( + &self, + session: Option<&ThreadId>, + ) -> SessionResourceResult { + let read_only = self.gate.local().is_read_only(); + let access = if read_only { + AccessMode::ReadOnly + } else { + AccessMode::ReadWrite + }; + let capabilities = if read_only { + DataCapabilities::HistoryReadOnly + } else { + DataCapabilities::Complete + }; + let execution = match session { + None => None, + Some(id) => Some(self.execution_availability(id).await?), + }; + Ok(SessionAvailability { + access, + capabilities, + execution, + }) + } + + async fn resolve_workspace(&self, cwd: &Path) -> SessionResourceResult { + self.gate.ensure_registration_write()?; + self.gate + .local() + .resolve_workspace(cwd) + .await + .map_err(execution_failure) + } + + async fn validate_session( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult<()> { + // 一次准入的权威复核:关系、关键文件对象加一次完整发现快照比对。 + let resolved = self.recheck_binding_of(id, true).await?; + if &resolved != workspace { + return Err(Self::workspace_mismatch()); + } + Ok(()) + } + + async fn acquire_execution( + &self, + id: &ThreadId, + workspace: &ResolvedWorkspace, + ) -> SessionResourceResult> { + self.gate.ensure_session_write()?; + // 先复核再取锁:binding 不可变,先复核不与取得所有权竞争,且失败时不会留下 + // 一个新的 dirty 代际。 + let facts = self.gate.session_facts(id).await?; + let resolved = self.recheck_binding(facts.binding.as_ref(), true).await?; + if &resolved != workspace { + return Err(Self::workspace_mismatch()); + } + self.gate + .local() + .acquire_lease(id, &facts) + .await + .map_err(execution_failure) + } + + async fn reset_dirty_execution( + &self, + request: &peri_acp_types::workspace::ResetDirtyRequest, + ) -> SessionResourceResult<()> { + self.gate.ensure_session_write()?; + // 显式风险接受是这条行为的领域前提,不能只由协议层把关。 + if !request.accept_risk { + return Err(invalid_input("explicit risk acceptance required")); + } + self.gate + .local() + .reset_dirty(&request.target) + .await + .map_err(execution_failure) + } + + // ── 创建与接纳 ── + + async fn create_session( + &self, + input: &NewSession, + ) -> SessionResourceResult> { + self.gate.ensure_registration_write()?; + let local = self.gate.local(); + if self.gate.data().session_exists(&input.thread_id).await? { + return self + .admit_saved_creation(&input.thread_id, &input.binding) + .await; + } + // 提交次数由数据位置决定(见 [`SessionDataHome`]):同一个本机库时数据与执行代际 + // 一次提交;数据在别处时先由数据端口保存,再取本机执行准入。门面不做后端推断。 + match self.home { + SessionDataHome::LocalLibrary => local.create_session(input).await, + SessionDataHome::RemoteStore => { + self.gate.data().save_new_session(input).await?; + match local.admit_existing(&input.thread_id, &input.binding).await { + Ok(lease) => Ok(lease), + Err(error) if error.is_persistence_uncertain() => Err(error), + Err(_) => Err(SessionResourceError::saved_but_not_admitted( + input.thread_id.clone(), + )), + } + } + } + } + + async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + ) -> SessionResourceResult<()> { + self.gate.ensure_session_write()?; + let data = self.gate.data().clone(); + self.gate + .local() + .abandon_initialization( + id, + lease, + Box::pin(async move { data.revoke_unpublished_session(id).await }), + ) + .await + } + + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + self.gate.ensure_session_write()?; + // 接纳在数据面的一次写事务内完成:保存路径一致、登记关系一致、既有绑定只校验 + // 不覆盖、有执行行时拒绝(不借接纳绕过 dirty)。 + self.gate + .data() + .adopt_legacy_session(id, saved_cwd, workspace, frozen) + .await + } + + // ── 读取 ── + + async fn load_session_snapshot(&self, id: &ThreadId) -> SessionResourceResult { + let mut snapshot = self.gate.data().load_snapshot(id).await?; + snapshot.binding = self.classify_binding(id, snapshot.binding).await?; + Ok(snapshot) + } + + async fn load_session_binding(&self, id: &ThreadId) -> SessionResourceResult { + let state = self.gate.data().load_binding(id).await?; + self.classify_binding(id, state).await + } + + async fn validate_bound_workspace( + &self, + id: &ThreadId, + check: BindingRecheck, + ) -> SessionResourceResult { + // 复核不改绑、不取执行权:与 `execution_availability` 走同一对本机原语, + // 因此「能不能执行」与「绑定向哪里」不会出现两套结论。 + self.recheck_binding_of(id, matches!(check, BindingRecheck::Full)) + .await + } + + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + self.gate.data().load_session_history(id).await + } + + async fn load_session_meta(&self, id: &ThreadId) -> SessionResourceResult { + self.gate.data().load_meta(id).await + } + + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult { + self.gate.data().list_sessions(query).await + } + + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult> { + self.gate.data().list_children(parent).await + } + + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult> { + self.gate.data().list_session_tree(root).await + } + + // ── 写入 ── + + async fn append_history( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + self.gate + .with_mutation(id, || self.gate.data().append_history(id, payloads)) + .await + } + + async fn save_fork( + &self, + fork: &ForkSnapshot, + ) -> SessionResourceResult> { + self.gate.ensure_registration_write()?; + let local = self.gate.local(); + let data = self.gate.data(); + if !data.session_exists(&fork.source_id).await? { + return Err(not_found()); + } + // 目标已存在:与 create 同一条判定——已有执行代际是 identity 冲突,数据已保存 + // 而未准入则收敛(同一次尝试的幂等重试不会重复写历史)。 + if data.session_exists(&fork.target.thread_id).await? { + return self + .admit_saved_creation(&fork.target.thread_id, &fork.target.binding) + .await; + } + // 目标快照先完整落库(数据面一次事务),再建立执行准入;准入失败时数据已保存, + // 如实报告「已保存、未准入」,不重造目标快照。 + // + // 这里不取租约门禁:目标是**新 identity**,此刻既没有 root 也没有 owner 可挂; + // 落库结果自描述(`threads` 行在、`execution_runs` 无),重试按「已保存、未准入」 + // 收敛,因此不需要在别处留下未决证据。与 child 的差别在于 child 的写入归属 + // root 执行域,没有自己的 identity 可解释残留。 + self.gate.data().save_fork(fork).await?; + match local + .admit_existing(&fork.target.thread_id, &fork.target.binding) + .await + { + Ok(lease) => Ok(lease), + Err(error) if error.is_persistence_uncertain() => Err(error), + Err(_) => Err(SessionResourceError::saved_but_not_admitted( + fork.target.thread_id.clone(), + )), + } + } + + async fn save_child( + &self, + child: &ChildSnapshot, + lease: &Arc, + ) -> SessionResourceResult<()> { + // 输入自洽先于一切副作用:快照里的父子关系出现两次(`parent_id` 与 target meta), + // 落库用的是后者。不一致的快照必须先被拒绝——此时还什么都没写、也没有在 root 的 + // 租约上留下未决证据;只靠调用点自觉会让直接调用门面的一方写出「独立 root」。 + ensure_child_relation(child)?; + self.gate.ensure_registration_write()?; + let local = self.gate.local(); + // root 的事实由数据面回答(子会话在本机可能一行都没有);root 自己也可能是别处的 + // 子会话,因此按「root 的 root + root 有没有绑定」问一次。 + let facts = self.gate.session_facts(&child.root_id).await?; + let owned = local + .owner_lease(&child.root_id, &facts) + .await + .map_err(execution_failure)? + .ok_or_else(lease_required)?; + // child 沿用 root owner:传入的必须是这条 root 的活 owner,不能借别人的所有权写。 + if !owned.is_active() || !same_lease(&owned, lease) { + return Err(lease_required()); + } + // child 沿用 root owner,写入门禁也必须挂在 root 上:新 child 自己既没有 identity 也没有 + // 执行代际,用 target id 解析只会得到「链上无 owner」而不设门禁,取消/超时就无法在 + // root 的租约上留下未决证据(B §4.1.3「guard 覆盖真正的 adapter 工作完成」)。 + self.gate + .with_mutation(&child.root_id, || self.gate.data().save_child(child)) + .await + } + + async fn claim_child_resume( + &self, + child: &ThreadId, + root: &ThreadId, + ) -> SessionResourceResult> { + self.gate.ensure_session_write()?; + if self.gate.data().session_root(child).await? != *root { + return Err(invalid_input( + "child session does not belong to the claimed root", + )); + } + // 认领在写侧门禁内完成「读状态 + 写 active」:并发认领里只有一个能看到非 active。 + let previous = self + .gate + .with_exclusive(root, || async { + let previous = self.gate.data().load_child_resume_record(child).await?; + if previous.status.is_active() { + return Err(invalid_input("child session is still active")); + } + self.gate + .data() + .store_child_resume_record( + child, + &ChildResumeRecord { + status: AgentStatus::Active, + claimed: true, + }, + ) + .await?; + Ok(previous) + }) + .await?; + Ok(Box::new(ChildResumeClaimHandle::new( + self.gate.clone(), + child.clone(), + previous, + ))) + } + + async fn apply_compaction( + &self, + id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()> { + self.gate + .with_mutation(id, || self.gate.data().apply_compaction(id, change)) + .await + } + + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()> { + self.gate + .with_mutation(id, || { + self.gate.data().apply_message_projections(id, updates) + }) + .await + } + + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + self.gate + .with_mutation(id, || self.gate.data().rewind_history(id, boundary)) + .await + } + + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + self.gate + .with_mutation(id, || self.gate.data().remove_history_entries(id, ids)) + .await + } + + async fn update_session_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + self.gate + .with_mutation(id, || self.gate.data().update_meta(id, patch)) + .await + } + + async fn delete_session_tree(&self, id: &ThreadId) -> SessionResourceResult<()> { + // 删除是显式生命周期行为:门面确认有效 owner 后才动数据;删除即删除——v10 之后 + // 本机不再留删除锚点(用户裁决不做跨安装的终态判定),远端删除也不再需要先把 + // 「正在删除」写回本机。 + self.gate + .with_mutation(id, || self.gate.data().delete_tree(id)) + .await?; + // 数据消失之后,本机执行事实也随之结束:这条 identity 的代际行与本进程持有的 + // owner 在同一步收尾(见 `LocalExecutionPort::dispose_execution`)。放在删除**之后** + // 是必须的——所有权要在数据被删的整个过程中保持;删除失败时提前返回,所有权原样 + // 保留,调用方仍可重试或显式放弃。 + self.gate.local().dispose_execution(id).await + } + + /// 未决持久化的收敛:不需要调用方提供任何令牌或操作 id。 + /// + /// 收谁的口供由数据面决定(本机写入同事务完成,因此本机组合只需确认锚点已清;远程组合 + /// 按本机日志里的**原操作 id** 逐条向远端账本求证终态)。门面在这里只做两件事: + /// + /// 1. **不与自己在途的请求抢判定权**:本进程还持有这条 root 的活 owner 时,先等已准入 + /// 的写入结束(有界),再让数据面判定。取消/超时之后的真实写仍会留在日志里,因此 + /// 等待只是把先后顺序摆正,不是判定依据; + /// 2. 把结论原样转达(`Recovered` 才代表本机已无可证明未结态的写入),失败不降级。 + /// + /// 只读打开时本方法只读不写:回答的是「能否重载」,不是「已经收敛」。 + /// + /// 关闭流程(`Closing`)下仍可调用:第一次关闭因未结清失败之后,收敛必须还有入口。 + async fn recover_session_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + self.gate.ensure_recovery_permitted()?; + // 这是先后顺序而不是判定依据:等待失败(例如本机父链损坏导致诊断读取失败)不构成 + // 「不能判定」,收敛结论仍只由数据面给出,不在这里用兜底把未知当已知。 + let facts = self.gate.session_facts(id).await?; + let owner = self + .gate + .local() + .live_owner(id, &facts) + .await + .ok() + .flatten(); + if let Some(lease) = owner { + if lease.is_active() { + tokio::time::timeout(SETTLE_WAIT, lease.wait_for_in_flight()) + .await + .map_err(|_| SessionResourceError::new(SessionResourceErrorKind::Timeout))?; + } + } + self.gate.data().recover_persistence(id).await + } + + async fn drain_persistence(&self, id: &ThreadId) -> SessionResourceResult<()> { + // 与恢复同样在 `Closing` 下放行:关闭前的排空必须能重做。 + self.gate.ensure_recovery_permitted()?; + let local = self.gate.local(); + let facts = self.gate.session_facts(id).await?; + // 排空等待的是**本进程**已准入的写入:他处持有的所有权不归本次等待, + // 因此这里用诊断查询,不把「本进程没有 owner」当成错误。 + if let Some(lease) = local + .live_owner(id, &facts) + .await + .map_err(execution_failure)? + { + if lease.is_active() { + // 有界等待已准入写入结清;超时说明有写入卡住,报告未结清而不是无限等。 + tokio::time::timeout(SETTLE_WAIT, lease.wait_for_in_flight()) + .await + .map_err(|_| SessionResourceError::new(SessionResourceErrorKind::Timeout))?; + } + if lease.is_uncertain() { + return Err(SessionResourceError::persistence_uncertain(Some( + id.clone(), + ))); + } + } + self.gate.data().drain(id).await + } +} + +// ─── 部署关闭 ───────────────────────────────────────────────────────────────── + +impl SessionResourcesImpl { + /// 关闭整个存储(部署生命周期行为,不属于业务行为面)。 + /// + /// 完成条件有两条,缺一不算确认:**本机传输面的关闭走完**(数据面 `close` 成功返回), + /// 以及**这个 store 的持久化未决已结清**——未结清判定按整 store 作用域提问(见下), + /// 不缩到活跃租约或活跃 root:租约已经 drop、durable 锚点仍在的写入同样挡住关闭。 + /// 未结清时保持 `Closing`(恢复入口仍可用),重复关闭重新做一遍真实检查。 + /// + /// 权限不由本方法决定而由**谁能拿到实例**决定:业务侧只持有 + /// `Arc`([`SessionResources`] 已不含关闭),本方法只对 + /// 具体实例可见,取用点只有部署 owner [`SessionStoreShutdownOwner`]。 + /// + /// [`SessionStoreShutdownOwner`]: crate::context::SessionStoreShutdownOwner + pub(crate) async fn close(&self) -> SessionResourceResult<()> { + // 关闭确认串行化:后来者要么看到确认关闭(幂等成功),要么重新做一遍真实检查, + // 不会因为「上一次调用过」或「正好并发」而绕过未结清事实。 + let _confirm = self.close_confirm.lock().await; + if self.lifecycle.state() == LifecycleState::Closed { + return Ok(()); + } + // 停止新写入不可逆;`Closing` 不是 `Closed`:未结清事实仍可收敛(恢复/排空在 + // `Closing` 下继续可用,见 `gate::ensure_recovery_permitted`)。 + self.lifecycle.begin_closing(); + + // 每次调用都重新做真实检查,不复用上一次的失败结论。 + // + // 等待与判定分开:等待建立先后顺序(在途写入在关闭前结束),判定只认 `is_uncertain` + // 与未决锚点;超时说明写入卡住,报告未结清而不是无限期等待外部 future。 + for lease in self.gate.local().live_leases() { + if !lease.is_active() { + continue; + } + if tokio::time::timeout(SETTLE_WAIT, lease.wait_for_in_flight()) + .await + .is_err() + { + return Err(SessionResourceError::new(SessionResourceErrorKind::Timeout)); + } + if lease.is_uncertain() { + return Err(SessionResourceError::persistence_uncertain(Some( + lease.thread_id().clone(), + ))); + } + } + // 未结清事实只在活租约上(本机没有再留 durable 锚点):租约 drop 之后那次写入的 + // 终态由 `execution_runs.clean = 0` 表达,不挡关闭——那是「需要恢复」而不是 + // 「本次关闭不能确认」。 + // + // 恢复所需的证据已确认结清之后才关闭数据面:提前取走连接(远程 adapter 的唯一 + // 连接句柄)会让「未确认」的未决事实失去收敛路径,而重复关闭恰恰要能重做检查。 + self.gate.data().close().await?; + // 只有到这里才是确认关闭(并发调用由串行化保证只有一个走到这里;即便迁移已由 + // 别处完成,结论也相同)。本机数据面的 `close` 只停止它自己的写入入口(连接池由 + // 共享库句柄所有);clean 由各 owner 自己写(`SessionExecutionLease::mark_clean`), + // 门面不代写、也不替它们宣告会话已结清。 + self.lifecycle.confirm_closed(); + Ok(()) + } +} + +#[cfg(test)] +#[path = "resources_test.rs"] +mod tests; diff --git a/peri-resources/src/sessions/resources/claim.rs b/peri-resources/src/sessions/resources/claim.rs new file mode 100644 index 000000000..77e7d3552 --- /dev/null +++ b/peri-resources/src/sessions/resources/claim.rs @@ -0,0 +1,138 @@ +//! child resume 认领 handle:认领期间的状态写入与恢复由资源内部持有。 +//! +//! 调用方只报告领域结果(开始运行 / 移交后台 / 准备失败 / 终止),不拼补偿写入、 +//! 不接触事务或重试令牌。handle 保存认领前的记录,终态方法把它写回去—— +//! 「恢复到认领前」这件事只有一个实现。 + +use std::sync::Mutex; + +use async_trait::async_trait; +use peri_acp_types::session_resources::{ + ChildResumeClaim, SessionResourceError, SessionResourceErrorKind, SessionResourceResult, +}; +use peri_acp_types::thread::{AgentStatus, ThreadId}; + +use super::gate::MutationGate; +use crate::sessions::data::ChildResumeRecord; + +/// 一次认领所处的阶段。 +enum ClaimPhase { + /// 认领已写入 active,准备阶段尚未结束。 + Preparing, + /// 调用方已声明认领成功并开始运行。 + Running, + /// 已移交后台执行:终态状态由后台持有,本 handle 不再改写它。 + HandedOff, + /// 已按领域结果收尾(失败或终止),恢复了认领前的记录。 + Settled, +} + +pub(super) struct ChildResumeClaimHandle { + gate: MutationGate, + child: ThreadId, + previous: ChildResumeRecord, + phase: Mutex, +} + +impl ChildResumeClaimHandle { + pub(super) fn new(gate: MutationGate, child: ThreadId, previous: ChildResumeRecord) -> Self { + Self { + gate, + child, + previous, + phase: Mutex::new(ClaimPhase::Preparing), + } + } + + /// 认领期间的写入走同一套准入检查(能力/未决持久化/root owner)。 + async fn write(&self, record: &ChildResumeRecord) -> SessionResourceResult<()> { + self.gate + .with_mutation(&self.child, || { + self.gate + .data() + .store_child_resume_record(&self.child, record) + }) + .await + } + + fn phase(&self) -> Result, SessionResourceError> { + self.phase.lock().map_err(|_| { + SessionResourceError::new(SessionResourceErrorKind::Internal { + detail: "child resume claim state is poisoned".to_owned(), + }) + }) + } + + fn already_settled() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: "child resume claim is already settled".to_owned(), + }) + } + + fn handed_off() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: "child resume claim was handed off to background execution".to_owned(), + }) + } +} + +#[async_trait] +impl ChildResumeClaim for ChildResumeClaimHandle { + async fn mark_running(&self) -> SessionResourceResult<()> { + { + let mut phase = self.phase()?; + match *phase { + ClaimPhase::Preparing => *phase = ClaimPhase::Running, + ClaimPhase::Running => {} + ClaimPhase::HandedOff | ClaimPhase::Settled => return Err(Self::already_settled()), + } + } + // 运行态在持久化里就是「active 且已认领」:再确认一次,让记录与调用方声明一致。 + self.write(&ChildResumeRecord { + status: AgentStatus::Active, + claimed: true, + }) + .await + } + + async fn hand_off_to_background(&self) -> SessionResourceResult<()> { + { + let mut phase = self.phase()?; + match *phase { + ClaimPhase::Preparing | ClaimPhase::Running => *phase = ClaimPhase::HandedOff, + ClaimPhase::HandedOff => {} + ClaimPhase::Settled => return Err(Self::already_settled()), + } + } + self.write(&ChildResumeRecord { + status: AgentStatus::Active, + claimed: true, + }) + .await + } + + async fn mark_failed(&self) -> SessionResourceResult<()> { + self.settle().await + } + + async fn mark_terminated(&self) -> SessionResourceResult<()> { + self.settle().await + } +} + +impl ChildResumeClaimHandle { + /// 收尾:恢复到认领前的记录,不留 active 残留。 + /// + /// 已移交后台时拒绝改写——后台执行才是终态的持有者,前台的终止声明不能覆盖它。 + async fn settle(&self) -> SessionResourceResult<()> { + { + let mut phase = self.phase()?; + match *phase { + ClaimPhase::Preparing | ClaimPhase::Running => *phase = ClaimPhase::Settled, + ClaimPhase::Settled => {} + ClaimPhase::HandedOff => return Err(Self::handed_off()), + } + } + self.write(&self.previous.clone()).await + } +} diff --git a/peri-resources/src/sessions/resources/gate.rs b/peri-resources/src/sessions/resources/gate.rs new file mode 100644 index 000000000..0852c24ce --- /dev/null +++ b/peri-resources/src/sessions/resources/gate.rs @@ -0,0 +1,212 @@ +//! 写入准入闸门:门面与它发出的认领 handle 共用同一套 mutation 检查。 +//! +//! 顺序固定为「能力/权限 → 本 root owner」,检查在门面内部完成,不靠调用方先查。 +//! 效果结清只认确定性:`Applied | NotApplied` 才释放准入,`Unknown`(含取消与提交 +//! 未确认)把范围留给 `Drop`,由租约留下未决证据。 +//! +//! 未决持久化**只在进程内的租约上**表达:v10 移除了本机 durable 锚点(登记、未决写、 +//! 远端操作日志),因为用户裁决不做跨安装/跨 store 的能力。文件里因此没有「查表问未决」 +//! 这一步——`WriteScope::settle` 的 `Drop` 语义与 `is_uncertain` 读取仍覆盖在途写入; +//! 进程崩溃后的未结清代际由 `execution_runs` 的 `clean = 0` 表达(`Dirty` 分类)。 + +use std::sync::Arc; + +use peri_acp_types::session_resources::{ + MutationOutcome, SessionResourceError, SessionResourceErrorKind, SessionResourceResult, +}; +use peri_acp_types::thread::ThreadId; +use peri_acp_types::workspace::WorkspaceError; + +use crate::sessions::data::SessionDataPort; +use crate::sessions::local_port::{LocalExecutionPort, SessionFacts}; +use crate::sessions::resources::lifecycle::{Lifecycle, LifecycleState}; +use crate::sessions::sqlite_store::{ + execution_failure, lease_required, read_only_store, unavailable, ExclusiveExecutionGuard, + ExecutionWriteGuard, +}; + +/// 一次写入准入持有的范围。 +pub(super) enum WriteScope { + /// 读侧门禁:允许同 root 的多个 mutation 并发。 + Concurrent(Option), + /// 写侧门禁:检查与写入之间不允许插入其他 mutation。 + Exclusive(Option), +} + +impl WriteScope { + /// 按效果结清:只有证明「已生效」或「未生效」才 `finish`;`Unknown` 直接丢弃, + /// 由 `Drop` 在租约上留下未决证据(之后的写入与 clean 都会被拒绝)。 + pub(super) fn settle(self, result: &SessionResourceResult) { + let determinate = match result { + Ok(_) => true, + Err(error) => error.effect() != MutationOutcome::Unknown, + }; + if !determinate { + return; + } + match self { + Self::Concurrent(Some(guard)) => guard.finish(), + Self::Exclusive(Some(guard)) => guard.finish(), + Self::Concurrent(None) | Self::Exclusive(None) => {} + } + } +} + +/// 写入准入闸门。 +/// +/// 两个端口各持一种事实:`data` 是 canonical 会话数据(本机 SQLite 或远端 adapter)、 +/// `local` 是本机执行面(发现、owner、代际、锁)。组合层决定两者指向哪个后端, +/// 闸门自己不做后端判断,也不持有任何 store 身份——v10 之后没有「本次服务哪个 store」 +/// 这回事。 +#[derive(Clone)] +pub(super) struct MutationGate { + data: Arc, + local: Arc, + lifecycle: Lifecycle, +} + +impl MutationGate { + pub(super) fn new( + data: Arc, + local: Arc, + lifecycle: Lifecycle, + ) -> Self { + Self { + data, + local, + lifecycle, + } + } + + pub(super) fn data(&self) -> &Arc { + &self.data + } + + pub(super) fn local(&self) -> &Arc { + &self.local + } + + /// 新写入是否仍被接纳:只有 `Open` 放行。 + /// + /// `Closing` 与 `Closed` 都拒绝——「停止新写入」从进入关闭流程起就不可逆; + /// 恢复与排空不走这里(见 [`Self::ensure_recovery_permitted`])。 + pub(super) fn ensure_open(&self) -> SessionResourceResult<()> { + if self.lifecycle.state() != LifecycleState::Open { + return Err(unavailable("session resources are closed")); + } + Ok(()) + } + + /// 恢复与排空的门禁:`Closing` 仍放行。 + /// + /// 关闭过程本身要先收敛未结清事实(在途写入的屏障、租约上的未决标记),发起收敛的 + /// owner 也必须能在第一次关闭失败后继续推进;只有确认关闭(`Closed`)之后资源才 + /// 不再服务这些收敛行为。 + pub(super) fn ensure_recovery_permitted(&self) -> SessionResourceResult<()> { + if self.lifecycle.state() == LifecycleState::Closed { + return Err(unavailable("session resources are closed")); + } + Ok(()) + } + + /// 已有会话上的写入:只读打开让执行权不可得(历史仍可读)。 + pub(super) fn ensure_session_write(&self) -> SessionResourceResult<()> { + self.ensure_open()?; + if self.local.is_read_only() { + return Err(read_only_store()); + } + Ok(()) + } + + /// 需要登记新身份或新绑定的写入:只读打开连会话都还没有,没有可降级的对象。 + pub(super) fn ensure_registration_write(&self) -> SessionResourceResult<()> { + self.ensure_open()?; + if self.local.is_read_only() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ReadOnlyStore), + )); + } + Ok(()) + } + + /// 完整准入:能力/权限 → 本 root owner。 + /// + /// 传给执行面的是**数据面事实**:调用方给的 `id` 可能是子会话,树根要在这里解析, + /// 「有没有绑定」也只能由数据面回答(远端组合里本机没有这条会话的任何行)。 + pub(super) async fn admit(&self, id: &ThreadId) -> SessionResourceResult { + self.ensure_session_write()?; + let facts = self.session_facts(id).await?; + let guard = self + .local + .write_guard(id, &facts) + .await + .map_err(execution_failure)?; + Ok(WriteScope::Concurrent(guard)) + } + + /// 统一写入:准入 → 执行 → 按效果结清。 + pub(super) async fn with_mutation( + &self, + id: &ThreadId, + work: F, + ) -> SessionResourceResult + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + let scope = self.admit(id).await?; + let result = work().await; + scope.settle(&result); + result + } + + /// 排他写入:与 [`Self::with_mutation`] 相同的准入判定,但「检查 + 写入」之间不允许 + /// 插入其他 mutation;因此要求存在活 owner(没有 owner 时不能承诺串行)。 + pub(super) async fn with_exclusive( + &self, + id: &ThreadId, + work: F, + ) -> SessionResourceResult + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + self.ensure_session_write()?; + let facts = self.session_facts(id).await?; + let guard = self + .local + .exclusive_guard(id, &facts) + .await + .map_err(execution_failure)?; + let Some(guard) = guard else { + return Err(lease_required()); + }; + let scope = WriteScope::Exclusive(Some(guard)); + let result = work().await; + scope.settle(&result); + result + } + + /// 执行面判定要用的数据面事实(绑定字节、这棵树有没有绑定、树根)。 + /// + /// 三件事都由数据端口回答:本机组合来自本机 `session_bindings` 与 `threads`,远端组合来自 + /// 远端会话行自带的绑定列与远端父链。执行面**不**查本机会话表——远端会话在本机没有行。 + /// + /// 绑定取**这条会话自己的**(取得所有权时要在本机复核它的字节);「这棵树有没有绑定」在 + /// 自身无绑定时再看 root 的——接纳过的 legacy root 可以有自己没有绑定行的子会话,那些子 + /// 会话的写入同样落在 root 的执行域里,不能因为「自己没有绑定」就当成无主放行。 + pub(super) async fn session_facts(&self, id: &ThreadId) -> SessionResourceResult { + let binding = self.data.binding_of(id).await?; + let root = self.data.session_root(id).await?; + let bound = match &binding { + Some(_) => true, + None if root == *id => false, + None => self.data.binding_of(&root).await?.is_some(), + }; + Ok(SessionFacts { + binding, + bound, + root, + }) + } +} diff --git a/peri-resources/src/sessions/resources/lifecycle.rs b/peri-resources/src/sessions/resources/lifecycle.rs new file mode 100644 index 000000000..b210703ed --- /dev/null +++ b/peri-resources/src/sessions/resources/lifecycle.rs @@ -0,0 +1,64 @@ +//! 关闭生命周期:`Open → Closing → Closed`,只有**确认关闭**才是 `Closed`。 +//! +//! 关闭有三件必须分开的事实: +//! +//! 1. **停止新写入不可逆**:进入 `Closing` 后不再接受新的 mutation 准入(`ensure_open` +//! 按「只有 `Open` 放行」判定),但这不是「已关闭」; +//! 2. **未结清事实仍要能收敛**:`Closing` 保留恢复与排空权限(见 +//! [`super::gate::MutationGate::ensure_recovery_permitted`]),否则第一次关闭失败就把 +//! 唯一能推进未决的路径也关掉了; +//! 3. **`Closed` 只是确认的结果**:只有在真实检查(在途写入、未决锚点)全部结清、并且 +//! 数据面确实关闭之后才允许迁移。失败或取消的关闭停在 `Closing`,重复关闭必须重新 +//! 检查,不允许用「曾经调用过」冒充幂等成功。 + +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::Arc; + +const OPEN: u8 = 0; +const CLOSING: u8 = 1; +const CLOSED: u8 = 2; + +/// 资源生命周期状态。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum LifecycleState { + Open, + Closing, + Closed, +} + +/// 门面与写入闸门共享的生命周期事实。 +#[derive(Clone)] +pub(super) struct Lifecycle { + state: Arc, +} + +impl Lifecycle { + pub(super) fn new() -> Self { + Self { + state: Arc::new(AtomicU8::new(OPEN)), + } + } + + pub(super) fn state(&self) -> LifecycleState { + match self.state.load(Ordering::Acquire) { + CLOSING => LifecycleState::Closing, + CLOSED => LifecycleState::Closed, + _ => LifecycleState::Open, + } + } + + /// 停止新写入(不可逆)。`Open → Closing`;已在 `Closing`/`Closed` 的原样返回。 + pub(super) fn begin_closing(&self) -> LifecycleState { + let _ = self + .state + .compare_exchange(OPEN, CLOSING, Ordering::AcqRel, Ordering::Acquire); + self.state() + } + + /// 确认关闭:只有 `Closing` 能成为 `Closed`,返回本次是否完成了迁移。 + pub(super) fn confirm_closed(&self) -> bool { + self.state + .compare_exchange(CLOSING, CLOSED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } +} diff --git a/peri-resources/src/sessions/resources_test.rs b/peri-resources/src/sessions/resources_test.rs new file mode 100644 index 000000000..12c0b6b82 --- /dev/null +++ b/peri-resources/src/sessions/resources_test.rs @@ -0,0 +1,1915 @@ +//! 会话资源门面行为测试(本机 SQLite)。 +//! +//! 断言以可观察结果为准:写入准入是否统一、效果结清是否只认确定性、创建/撤销/认领的 +//! 后置条件、删除与未决证据在级联之后是否仍可判定。构造「数据已保存、执行代际未写」 +//! 这类崩溃态时直接经数据端口写入——那正是远程保存或上次进程留下的状态。 + +use super::*; +use crate::sessions::local_port::SessionFacts; +use crate::sessions::sqlite_store::{commit_failure, write_failure}; +use crate::SessionStoreShutdownOwner; +use peri_acp_types::session_resources::{ + FrozenState, NewSessionMeta, SessionResourceResult, SessionResources, SessionStoreShutdownPort, +}; +use peri_acp_types::workspace::{ResetDirtyRequest, SESSION_BINDING_VERSION}; +use tempfile::TempDir; + +fn git(root: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "Git fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn repository() -> TempDir { + let directory = tempfile::tempdir().unwrap(); + git(directory.path(), &["init", "-q"]); + git( + directory.path(), + &[ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ); + directory +} + +struct Fixture { + /// 具体实例:`Arc` 让业务侧与部署 owner 指向同一份事实(生产装配同形)。 + facade: Arc, + repo: TempDir, + _db: TempDir, +} + +impl Fixture { + async fn new() -> Self { + let repo = repository(); + let db = tempfile::tempdir().unwrap(); + let facade = Arc::new( + SessionResourcesImpl::open(db.path().join("threads.db")) + .await + .unwrap(), + ); + Self { + facade, + repo, + _db: db, + } + } + + /// 部署 owner 的关闭路径:装配点交出的唯一关闭权(门面自身不再对业务暴露关闭)。 + async fn shutdown(&self) -> SessionResourceResult<()> { + SessionStoreShutdownOwner::take(Arc::clone(&self.facade)) + .shutdown() + .await + } + + async fn workspace(&self) -> ResolvedWorkspace { + self.facade + .resolve_workspace(self.repo.path()) + .await + .unwrap() + } + + fn binding(workspace: &ResolvedWorkspace) -> SessionBinding { + SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + } + } + + fn session(&self, id: &str, workspace: &ResolvedWorkspace, frozen: &str) -> NewSession { + NewSession { + thread_id: id.to_owned(), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("session {id}")), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: Self::binding(workspace), + frozen: FrozenSnapshotBytes::new(frozen.to_owned()), + } + } + + /// 建一个完整会话并返回 owner。 + async fn create(&self, id: &str) -> Arc { + let workspace = self.workspace().await; + let input = self.session(id, &workspace, &format!(r#"{{"v":1,"id":"{id}"}}"#)); + self.facade.create_session(&input).await.unwrap() + } + + /// 直接经数据面落一份「数据已保存、执行代际未写」的会话(远程保存或崩溃留下的状态)。 + async fn save_without_admission(&self, id: &str, workspace: &ResolvedWorkspace) { + let input = self.session(id, workspace, &format!(r#"{{"v":1,"id":"{id}"}}"#)); + self.facade + .gate + .data() + .save_new_session(&input) + .await + .unwrap(); + } + + async fn count_threads(&self, id: &str) -> i64 { + self.count("SELECT COUNT(*) FROM threads WHERE id = ?1", id) + .await + } + + async fn count_messages(&self, id: &str) -> i64 { + self.count("SELECT COUNT(*) FROM messages WHERE thread_id = ?1", id) + .await + } + + async fn count_bindings(&self, id: &str) -> i64 { + self.count( + "SELECT COUNT(*) FROM session_bindings WHERE thread_id = ?1", + id, + ) + .await + } + + async fn count_execution_runs(&self, id: &str) -> i64 { + self.count( + "SELECT COUNT(*) FROM execution_runs WHERE thread_id = ?1", + id, + ) + .await + } + + /// 本机执行代际行(`None` 表示这条 identity 没有行)。 + async fn execution_row(&self, id: &str) -> Option<(i64, bool)> { + sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = ?1") + .bind(id) + .fetch_optional(self.facade.local_pool()) + .await + .unwrap() + } + + async fn count(&self, sql: &'static str, id: &str) -> i64 { + let row: (i64,) = sqlx::query_as(sql) + .bind(id) + .fetch_one(self.facade.local_pool()) + .await + .unwrap(); + row.0 + } +} + +fn payload(text: &str) -> PersistedPayload { + PersistedPayload::Message(peri_acp_types::messages::BaseMessage::human(text)) +} + +fn error_kind(error: &SessionResourceError) -> &SessionResourceErrorKind { + error.kind() +} + +// ─── 创建:完整数据 + owner 一次成立 ─────────────────────────────────────────── + +#[tokio::test] +async fn test_create_session_saves_complete_data_and_owner_in_one_step() { + let fixture = Fixture::new().await; + let workspace = fixture.workspace().await; + let lease = fixture.create("s-new").await; + + // 完整数据:metadata、binding、frozen 都已可读。 + let snapshot = fixture + .facade + .load_session_snapshot(&"s-new".to_owned()) + .await + .unwrap(); + assert_eq!(snapshot.meta.cwd, workspace.cwd.to_string_lossy()); + assert_eq!( + snapshot.binding, + BindingState::Bound(Fixture::binding(&workspace)) + ); + assert_eq!( + snapshot.frozen, + FrozenState::Present(FrozenSnapshotBytes::new(r#"{"v":1,"id":"s-new"}"#)) + ); + + // 执行代际:一次提交里就带着 owner 事实(未结清)。 + assert_eq!( + fixture + .facade + .gate + .local() + .execution_state(&"s-new".to_owned()) + .await + .unwrap(), + Some((1, false)) + ); + assert_eq!(lease.thread_id(), &"s-new".to_owned()); + // 活 owner 在册:本次不能再次取得执行权(「有主」不是「需要恢复」)。 + assert_eq!( + fixture + .facade + .inspect_availability(Some(&"s-new".to_owned())) + .await + .unwrap() + .execution, + Some(ExecutionAvailability::OwnedElsewhere) + ); + // owner 消失(崩溃等价)后剩下的才是代际事实:精确代际的普通 dirty。 + drop(lease); + assert_eq!( + fixture + .facade + .inspect_availability(Some(&"s-new".to_owned())) + .await + .unwrap() + .execution, + Some(ExecutionAvailability::Dirty(RecoveryRequiredDetails { + thread_id: "s-new".to_owned(), + generation: 1, + })) + ); +} + +#[tokio::test] +async fn test_create_session_collapses_data_and_generation_into_one_commit() { + let fixture = Fixture::new().await; + let workspace = fixture.workspace().await; + // 用一条会失败的输入(binding 指向未登记工作区)证明失败时什么都不留: + // 事务整体回滚,不会留下没有执行代际的会话行。 + let mut input = fixture.session("s-fail", &workspace, r#"{"v":1}"#); + input.binding.workspace_id = peri_acp_types::workspace::WorkspaceId::new(); + let error = match fixture.facade.create_session(&input).await { + Ok(_) => panic!("expected create to fail"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::InvalidBinding) + )); + assert_eq!(fixture.count_threads("s-fail").await, 0); + assert_eq!(fixture.count_execution_runs("s-fail").await, 0); +} + +#[tokio::test] +async fn test_create_session_rejects_a_reused_identity() { + let fixture = Fixture::new().await; + let _lease = fixture.create("s-dup").await; + let workspace = fixture.workspace().await; + let input = fixture.session("s-dup", &workspace, r#"{"v":1}"#); + let error = match fixture.facade.create_session(&input).await { + Ok(_) => panic!("expected create to fail"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + )); +} + +#[tokio::test] +async fn test_create_session_converges_when_data_was_saved_without_admission() { + let fixture = Fixture::new().await; + let workspace = fixture.workspace().await; + // 崩在「数据已保存、执行代际未写」之间:下次同一个 ThreadId 的创建收敛准入, + // 不重造 binding/frozen,也不报「已存在」。 + fixture + .save_without_admission("s-converge", &workspace) + .await; + let input = fixture.session("s-converge", &workspace, r#"{"v":1,"id":"s-converge"}"#); + let lease = fixture.facade.create_session(&input).await.unwrap(); + assert_eq!(lease.thread_id(), &"s-converge".to_owned()); + assert_eq!( + fixture + .facade + .gate + .local() + .execution_state(&"s-converge".to_owned()) + .await + .unwrap(), + Some((1, false)) + ); + assert_eq!(fixture.count_threads("s-converge").await, 1); + assert_eq!(fixture.count_bindings("s-converge").await, 1); + drop(lease); +} + +#[tokio::test] +async fn test_create_session_reports_saved_but_not_admitted_when_premise_changed() { + let fixture = Fixture::new().await; + let workspace = fixture.workspace().await; + fixture + .save_without_admission("s-premise", &workspace) + .await; + // 同 identity 但换了一份绑定:数据已保存这一事实不变,准入前提不再成立。 + let mut input = fixture.session("s-premise", &workspace, r#"{"v":1}"#); + input.binding.workspace_id = peri_acp_types::workspace::WorkspaceId::new(); + + let error = match fixture.facade.create_session(&input).await { + Ok(_) => panic!("expected create to fail"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::SavedButNotAdmitted { .. } + )); + // 效果是「已生效」:数据仍在,不得被调用方据此删除。 + assert_eq!( + error.effect(), + peri_acp_types::session_resources::MutationOutcome::Applied + ); + assert_eq!(fixture.count_threads("s-premise").await, 1); +} + +// ─── 统一写入准入 ───────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_read_only_store_refuses_registration_and_session_writes() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-readonly").await; + lease.mark_clean().await.unwrap(); + drop(lease); + let db_path = fixture._db.path().join("threads.db"); + + let before: Vec = std::fs::read_dir(fixture._db.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let read_only = SessionResourcesImpl::open_existing_read_only(&db_path) + .await + .unwrap(); + // 登记新身份:连会话都还没有,没有可降级的对象。 + let error = match read_only.resolve_workspace(fixture.repo.path()).await { + Ok(_) => panic!("expected resolve_workspace to fail"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ReadOnlyStore) + )); + // 已有会话上的写入:历史可读、执行权不可得。 + let error = read_only + .append_history(&"s-readonly".to_owned(), &[payload("late")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::ReadOnlyStore + )); + let error = match read_only + .acquire_execution( + &"s-readonly".to_owned(), + &read_only_workspace(&fixture).await, + ) + .await + { + Ok(_) => panic!("expected acquire_execution to fail on a read-only store"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::ReadOnlyStore + )); + // 只读路径不创建任何文件:目录内容与只读打开之前逐项一致(包括不建锁文件)。 + let after: Vec = std::fs::read_dir(fixture._db.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(before, after); + assert!(!db_path + .with_file_name("threads.db.execution-locks") + .join("new.lock") + .exists()); + // 读取仍然成立。 + let meta = read_only + .load_session_meta(&"s-readonly".to_owned()) + .await + .unwrap(); + assert_eq!(meta.title.as_deref(), Some("session s-readonly")); +} + +async fn read_only_workspace(fixture: &Fixture) -> ResolvedWorkspace { + fixture.workspace().await +} + +#[tokio::test] +async fn test_mutation_without_live_owner_is_rejected() { + let fixture = Fixture::new().await; + let workspace = fixture.workspace().await; + // 有绑定但没有活 owner(数据面保存过的会话):不能因为没有 owner 就免授权。 + fixture.save_without_admission("s-owner", &workspace).await; + let error = fixture + .facade + .append_history(&"s-owner".to_owned(), &[payload("no owner")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); + assert_eq!( + fixture + .facade + .inspect_availability(Some(&"s-owner".to_owned())) + .await + .unwrap() + .execution, + // 有绑定、无活 owner、无 dirty:执行所有权可以取得,这就是「可用」。 + Some(ExecutionAvailability::Available) + ); +} + +/// 一次「已准入、效果无法证明」的写入:阻塞面与唯一的出路。 +/// +/// 未决证据只在**进程内的租约**上(v10 删除了本机 durable 锚点:不做跨安装的终态判定)。 +/// 因此这里用真实的准入 + 未知效果驱动同一条 `Drop` 语义,再验证后续写入/删除/排空/clean +/// 被拒绝、读取面不受影响、被拒的写入没有留下效果;owner 消失之后,durable 事实只剩 +/// `execution_runs` 的未结清代际,由显式风险接受收敛,下一代 owner 从头开始。 +#[tokio::test] +async fn test_unresolved_write_blocks_until_explicit_recovery() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-unsure").await; + let id = "s-unsure".to_owned(); + fixture + .facade + .append_history(&id, &[payload("first turn")]) + .await + .unwrap(); + + // 提交阶段的失败与取消走同一条路径:准入范围按 Unknown 结清,未决证据留在租约上。 + let scope = fixture.facade.gate.admit(&id).await.unwrap(); + scope.settle(&Err::<(), _>(SessionResourceError::persistence_uncertain( + Some(id.clone()), + ))); + + // 后续写入与删除都被拒绝:不能在一个结果未知的写入之后继续写。 + let error = fixture + .facade + .append_history(&id, &[payload("blocked")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(_)) + )); + let error = fixture.facade.delete_session_tree(&id).await.unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(_)) + )); + // 未结清不能被伪装成已排空,也不能被当成干净收尾。 + let error = fixture.facade.drain_persistence(&id).await.unwrap_err(); + assert!(error.is_persistence_uncertain()); + assert!(lease.mark_clean().await.is_err()); + // 被拒的写入没有留下效果:历史仍是第一轮那一条。 + assert_eq!(fixture.count_messages(&id).await, 1); + // 读取面不受未决写影响:历史可读性与执行资格分开表达。 + assert!(fixture.facade.load_session_meta(&id).await.is_ok()); + let page = fixture + .facade + .list_sessions(&peri_acp_types::workspace::ScopedThreadQuery { + scope: peri_acp_types::workspace::ThreadScope::All, + cursor: None, + limit: 10, + }) + .await + .unwrap(); + assert!(page.entries.iter().any(|entry| entry.thread.id == id)); + + // owner 消失(进程结束等价):剩下的 durable 事实只有未结清的代际本身。 + drop(lease); + let error = match fixture + .facade + .acquire_execution(&id, &fixture.workspace().await) + .await + { + Ok(_) => panic!("expected the unresolved generation to block a fresh owner"), + Err(error) => error, + }; + let SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(details)) = + error_kind(&error) + else { + panic!("expected a dirty generation, got: {error:?}"); + }; + assert_eq!(details.generation, 1); + // 显式风险接受是唯一出路:结清的是代际事实,下一代 owner 从 generation 2 开始。 + fixture + .facade + .reset_dirty_execution(&ResetDirtyRequest { + target: RecoveryRequiredDetails { + thread_id: id.clone(), + generation: details.generation, + }, + accept_risk: true, + }) + .await + .unwrap(); + assert_eq!(fixture.execution_row(&id).await, Some((1, true))); + let next = fixture + .facade + .acquire_execution(&id, &fixture.workspace().await) + .await + .unwrap(); + assert_eq!(next.thread_id(), &"s-unsure".to_owned()); + next.mark_clean().await.unwrap(); +} + +// ─── guard:只有效果确定才结清 ───────────────────────────────────────────────── + +#[tokio::test] +async fn test_write_scope_settles_only_on_determinate_effect() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-settle").await; + let id = "s-settle".to_owned(); + + // 已证明未生效(NotApplied):结清,后续写入仍然可以进入。 + let scope = fixture.facade.gate.admit(&id).await.unwrap(); + let not_applied: SessionResourceResult<()> = Err(SessionResourceError::new( + SessionResourceErrorKind::InvalidInput { + detail: "rejected before side effects".to_owned(), + }, + )); + scope.settle(¬_applied); + fixture + .facade + .append_history(&id, &[payload("after not applied")]) + .await + .unwrap(); + + // 无法证明终态(Unknown):不结清,同根后续写入与 clean 都被拒绝。 + let scope = fixture.facade.gate.admit(&id).await.unwrap(); + let unknown: SessionResourceResult<()> = Err(SessionResourceError::persistence_uncertain( + Some(id.clone()), + )); + scope.settle(&unknown); + let error = fixture + .facade + .append_history(&id, &[payload("after unknown")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(_)) + )); + assert!(lease.mark_clean().await.is_err()); + // dirty 代际保持在库内:未结清的写入不会被当成干净收尾。 + assert_eq!( + fixture + .facade + .gate + .local() + .execution_state(&id) + .await + .unwrap(), + Some((1, false)) + ); + drop(lease); +} + +/// 提交阶段的失败必须被报成 Unknown:`NotApplied` 会让 `settle` 释放写入范围, +/// 而「提交是否落盘」在这一刻无从证明。这里用数据面提交映射经 `anyhow` 传播后的 +/// 真实产物驱动准入(compaction 事务与本机执行面正是这条链路)。 +#[tokio::test] +async fn test_commit_stage_failure_blocks_writes_and_clean() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-commit").await; + let id = "s-commit".to_owned(); + + let error = write_failure(anyhow::Error::new(commit_failure(Some(id.clone())))); + assert!(error.is_persistence_uncertain()); + let scope = fixture.facade.gate.admit(&id).await.unwrap(); + scope.settle(&Err::<(), _>(error)); + + // Unknown 不结清范围:同根后续写入与 clean 都被拒绝,库内代际仍是 dirty。 + let blocked = fixture + .facade + .append_history(&id, &[payload("after commit failure")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&blocked), + SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(_)) + )); + assert!(lease.mark_clean().await.is_err()); + assert_eq!( + fixture + .facade + .gate + .local() + .execution_state(&id) + .await + .unwrap(), + Some((1, false)) + ); + drop(lease); +} + +#[tokio::test] +async fn test_cancelled_mutation_leaves_uncertain_lease_and_blocks_clean() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-cancel").await; + let id = "s-cancel".to_owned(); + { + // 丢弃准入范围而不结清,等价于写入 future 在提交边界被取消。 + let _scope = fixture.facade.gate.admit(&id).await.unwrap(); + } + assert!(lease.mark_clean().await.is_err()); + let error = fixture + .facade + .append_history(&id, &[payload("after cancel")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(_)) + )); + // 排空同样不得把未决写入当成已结清。 + let error = fixture.facade.drain_persistence(&id).await.unwrap_err(); + assert!(error.is_persistence_uncertain()); + drop(lease); +} + +// ─── 撤销未发布创建 ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_abandon_initialization_revokes_data_and_allows_a_fresh_retry() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-abandon").await; + let other = fixture.create("s-other").await; + let id = "s-abandon".to_owned(); + + // 别的 owner 不能替它承担补偿:撤销会删执行行,必须由持有它的同一所有权发起。 + let error = fixture + .facade + .abandon_initialization(&id, &other) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); + assert_eq!(fixture.count_threads(&id).await, 1); + + fixture + .facade + .abandon_initialization(&id, &lease) + .await + .unwrap(); + // 数据与执行代际行一起撤销,本机不留第二份痕迹:撤销判定只依据现有数据事实 + // (v10 删掉了「初始化被放弃」的终态锚点表)。 + assert_eq!(fixture.count_threads(&id).await, 0); + assert_eq!(fixture.count_bindings(&id).await, 0); + assert_eq!(fixture.count_execution_runs(&id).await, 0); + // 补偿走的是放弃所有权,不是 clean:这里不会写出一条假的 clean 记录。 + lease.mark_clean().await.unwrap(); + assert_eq!( + fixture + .facade + .gate + .local() + .execution_state(&id) + .await + .unwrap(), + None + ); + // 同一 identity 可以重来:这条创建从没发布过,客户端按同一个 id 重试是正常动作 + // (删除则更彻底——它删的是已发布会话的数据,见 `test_delete_ends_data_execution_facts_and_ownership`)。 + let workspace = fixture.workspace().await; + let input = fixture.session(&id, &workspace, r#"{"v":1,"id":"s-abandon"}"#); + let fresh = fixture.facade.create_session(&input).await.unwrap(); + assert_eq!(fresh.thread_id().as_str(), id); + assert_eq!( + fixture.execution_row(&id).await, + Some((1, false)), + "重试建出的是全新会话:代际从 1 开始且未结清" + ); + drop(other); +} + +// ─── child:沿用 root owner ─────────────────────────────────────────────────── + +impl Fixture { + /// 在 root 之下建一个 child(frozen 取自 root 的已保存快照)。 + async fn child( + &self, + child_id: &str, + root: &str, + root_lease: &Arc, + ) -> ChildSnapshot { + let snapshot = self.child_snapshot(child_id, root).await; + self.facade.save_child(&snapshot, root_lease).await.unwrap(); + snapshot + } + + /// 只构造 child 快照、不保存:用于在准入被占用时观察写入是否真的等门禁。 + async fn child_snapshot(&self, child_id: &str, root: &str) -> ChildSnapshot { + let workspace = self.workspace().await; + let root_frozen = self + .facade + .load_session_snapshot(&root.to_owned()) + .await + .unwrap() + .frozen; + let FrozenState::Present(root_frozen) = root_frozen else { + panic!("root frozen snapshot is missing"); + }; + ChildSnapshot { + target: NewSession { + thread_id: child_id.to_owned(), + created_at: "2026-09-26T00:00:01Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("child {child_id}")), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: Some(root.to_owned()), + hidden: true, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: Fixture::binding(&workspace), + frozen: root_frozen, + }, + parent_id: root.to_owned(), + root_id: root.to_owned(), + inherited: peri_acp_types::store::InheritedContext { + payloads: Vec::new(), + flags: std::collections::HashMap::new(), + }, + } + } +} + +#[tokio::test] +async fn test_save_child_requires_the_root_owner_and_shares_its_gate() { + let fixture = Fixture::new().await; + let root_lease = fixture.create("s-child-root").await; + let foreign = fixture.create("s-child-foreign").await; + let workspace = fixture.workspace().await; + let root_frozen = match fixture + .facade + .load_session_snapshot(&"s-child-root".to_owned()) + .await + .unwrap() + .frozen + { + FrozenState::Present(frozen) => frozen, + other => panic!("root frozen snapshot is missing: {other:?}"), + }; + let snapshot = ChildSnapshot { + target: NewSession { + thread_id: "s-child".to_owned(), + created_at: "2026-09-26T00:00:01Z".to_owned(), + meta: NewSessionMeta { + title: Some("child".to_owned()), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: Some("s-child-root".to_owned()), + hidden: true, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: Fixture::binding(&workspace), + frozen: root_frozen, + }, + parent_id: "s-child-root".to_owned(), + root_id: "s-child-root".to_owned(), + inherited: peri_acp_types::store::InheritedContext { + payloads: Vec::new(), + flags: std::collections::HashMap::new(), + }, + }; + // 别的 root 的 owner 不能借来写这条 child。 + let error = fixture + .facade + .save_child(&snapshot, &foreign) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); + assert_eq!(fixture.count_threads("s-child").await, 0); + + fixture + .facade + .save_child(&snapshot, &root_lease) + .await + .unwrap(); + // 子会话没有自己的执行代际:写入落在 root 的 owner 上。 + assert_eq!(fixture.count_execution_runs("s-child").await, 0); + fixture + .facade + .append_history(&"s-child".to_owned(), &[payload("child turn")]) + .await + .unwrap(); + // root owner 关闭后,child 的写入同样被拒绝。 + root_lease.mark_clean().await.unwrap(); + drop(root_lease); + let error = fixture + .facade + .append_history(&"s-child".to_owned(), &[payload("after clean")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); + drop(foreign); +} + +/// child 的写入归属 root 执行域:它必须与 root 的写入共享同一条门禁,而不是因为 +/// 「child 自己还没有 identity/owner」就免于门禁(B §4.1.3)。 +#[tokio::test] +async fn test_save_child_write_waits_for_the_root_gate() { + let fixture = Fixture::new().await; + let root_lease = fixture.create("s-gate-root").await; + let snapshot = fixture.child_snapshot("s-gate-child", "s-gate-root").await; + + // 占住 root 的写侧门禁(等价于该 owner 上另一次 mutation 正在检查+写入之间)。 + let facts = facts_of(&fixture.facade, "s-gate-root").await; + let held = fixture + .facade + .gate + .local() + .exclusive_guard(&"s-gate-root".to_owned(), &facts) + .await + .unwrap() + .expect("the root owner is alive"); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(200), + fixture.facade.save_child(&snapshot, &root_lease), + ) + .await + .is_err(), + "child save must wait for the root's write gate" + ); + assert_eq!( + fixture.count_threads("s-gate-child").await, + 0, + "no child data may be written while the root gate is held" + ); + held.finish(); + + // 门禁释放后同一次保存成立,且没有把 root 标成未决(结清只认确定性)。 + fixture + .facade + .save_child(&snapshot, &root_lease) + .await + .unwrap(); + assert_eq!(fixture.count_threads("s-gate-child").await, 1); + root_lease.mark_clean().await.unwrap(); + drop(root_lease); +} + +/// 父子关系在 child 快照里出现两次:`parent_id`(声明)与 `target.meta.parent_thread_id` +/// (落库用的那一份)。只校验前者、照后者落库,会把「声明了合法父/根」的 child 写成一条 +/// **没有父的独立 root**——此后它还能自己取得执行权。门面必须在任何副作用之前拒绝, +/// 并且拒绝不留痕(零行、无 lease、root 原样)。 +#[tokio::test] +async fn test_save_child_refuses_a_snapshot_that_disagrees_with_its_parent_relation() { + let fixture = Fixture::new().await; + let root_lease = fixture.create("s-rel-root").await; + let legal = fixture.child_snapshot("s-rel-child", "s-rel-root").await; + + // 目标 meta 里没有父;父与声明不同;自指父关系;把自己当根。 + let mut without_parent = legal.clone(); + without_parent.target.meta.parent_thread_id = None; + let mut other_parent = legal.clone(); + other_parent.target.meta.parent_thread_id = Some("s-rel-other".to_owned()); + let mut self_parent = legal.clone(); + self_parent.parent_id = "s-rel-child".to_owned(); + self_parent.target.meta.parent_thread_id = Some("s-rel-child".to_owned()); + let mut own_root = legal.clone(); + own_root.root_id = "s-rel-child".to_owned(); + + for (label, snapshot) in [ + ("target without a parent", &without_parent), + ("target under another parent", &other_parent), + ("self parent", &self_parent), + ("own root", &own_root), + ] { + let error = fixture + .facade + .save_child(snapshot, &root_lease) + .await + .unwrap_err(); + assert!( + matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + ), + "{label}: expected InvalidInput, got {error:?}" + ); + // 零行:没有会话、没有绑定、没有执行代际。 + assert_eq!(fixture.count_threads("s-rel-child").await, 0, "{label}"); + assert_eq!(fixture.count_bindings("s-rel-child").await, 0, "{label}"); + assert_eq!( + fixture.count_execution_runs("s-rel-child").await, + 0, + "{label}" + ); + // 无 lease:这条 identity 不存在,也就没有独立 root 可取得执行权。 + let facts = facts_of(&fixture.facade, "s-rel-child").await; + assert!( + fixture + .facade + .gate + .local() + .owner_lease(&"s-rel-child".to_owned(), &facts) + .await + .unwrap() + .is_none(), + "{label}" + ); + } + // 原 root 不变:仍是独立 root、树里只有自己、children 为空,且它的 owner 照常可写。 + let root_meta = fixture + .facade + .load_session_meta(&"s-rel-root".to_owned()) + .await + .unwrap(); + assert_eq!(root_meta.parent_thread_id, None); + assert_eq!( + fixture + .facade + .list_session_tree(&"s-rel-root".to_owned()) + .await + .unwrap() + .len(), + 1 + ); + assert!(fixture + .facade + .list_children(&"s-rel-root".to_owned()) + .await + .unwrap() + .is_empty()); + fixture + .facade + .append_history(&"s-rel-root".to_owned(), &[payload("root turn")]) + .await + .unwrap(); + + // 合法的 child 仍然成立,且仍然挂在 root 之下(不是独立 root,也没有自己的执行代际)。 + fixture + .facade + .save_child(&legal, &root_lease) + .await + .unwrap(); + assert_eq!(fixture.count_threads("s-rel-child").await, 1); + assert_eq!(fixture.count_execution_runs("s-rel-child").await, 0); + assert_eq!( + fixture + .facade + .load_session_meta(&"s-rel-child".to_owned()) + .await + .unwrap() + .parent_thread_id + .as_deref(), + Some("s-rel-root") + ); + assert_eq!( + fixture + .facade + .list_children(&"s-rel-root".to_owned()) + .await + .unwrap() + .len(), + 1 + ); + // 子会话没有自己的执行代际:它解析到的是 root 的那条 owner,而不是自己当 root。 + let facts = facts_of(&fixture.facade, "s-rel-child").await; + let owned = fixture + .facade + .gate + .local() + .owner_lease(&"s-rel-child".to_owned(), &facts) + .await + .unwrap() + .expect("the child belongs to the root's execution domain"); + assert!(owned.is_active()); + assert_eq!(owned.thread_id(), &"s-rel-root".to_owned()); + root_lease.mark_clean().await.unwrap(); + drop(root_lease); +} + +#[tokio::test] +async fn test_claim_child_resume_serializes_and_restores_previous_state() { + let fixture = Fixture::new().await; + let root_lease = fixture.create("s-claim-root").await; + let child = fixture + .child("s-claim-child", "s-claim-root", &root_lease) + .await; + let child_id = child.target.thread_id.clone(); + let root_id = child.root_id.clone(); + // 认领前的状态:Done(非 active),用于观察恢复是否真的发生了。 + fixture + .facade + .update_session_meta( + &child_id, + &SessionMetaPatch { + status: Some(AgentStatus::Done), + ..Default::default() + }, + ) + .await + .unwrap(); + + let claim = fixture + .facade + .claim_child_resume(&child_id, &root_id) + .await + .unwrap(); + let record = fixture + .facade + .gate + .data() + .load_child_resume_record(&child_id) + .await + .unwrap(); + assert_eq!(record.status, AgentStatus::Active); + assert!(record.claimed); + // 仍在 active:并发/重复认领被拒绝,不会有两个执行者。 + let error = match fixture.facade.claim_child_resume(&child_id, &root_id).await { + Ok(_) => panic!("expected the active child to refuse a second claim"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + )); + // 准备失败:恢复到认领前,不留 active 残留。 + claim.mark_failed().await.unwrap(); + let record = fixture + .facade + .gate + .data() + .load_child_resume_record(&child_id) + .await + .unwrap(); + assert_eq!(record.status, AgentStatus::Done); + assert!(!record.claimed); + + // 移交后台后,前台的终止声明不能覆盖后台持有的终态。 + let claim = fixture + .facade + .claim_child_resume(&child_id, &root_id) + .await + .unwrap(); + claim.hand_off_to_background().await.unwrap(); + let error = claim.mark_terminated().await.unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + )); + + // root owner 不在本进程时不能认领。 + drop(root_lease); + let error = match fixture.facade.claim_child_resume(&child_id, &root_id).await { + Ok(_) => panic!("expected claim without a live root owner to fail"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); +} + +// ─── 删除与恢复证据 ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_delete_ends_data_execution_facts_and_ownership() { + let fixture = Fixture::new().await; + let root_lease = fixture.create("s-del-root").await; + let _child = fixture + .child("s-del-child", "s-del-root", &root_lease) + .await; + + fixture + .facade + .delete_session_tree(&"s-del-root".to_owned()) + .await + .unwrap(); + // 删除即删除:整棵树的数据、绑定、执行代际行都不在,也没有第二份「被删过」的痕迹 + // (v10 之后本机不为终止状态留锚点——删除的对象是数据,不是身份)。 + assert_eq!(fixture.count_threads("s-del-root").await, 0); + assert_eq!(fixture.count_threads("s-del-child").await, 0); + assert_eq!(fixture.count_bindings("s-del-root").await, 0); + assert_eq!(fixture.count_execution_runs("s-del-root").await, 0); + assert_eq!(fixture.count_execution_runs("s-del-child").await, 0); + // 删除同时结束本次所有权:owner 不再接收写入(下面按「没有活 owner」被拒),锁也已 + // 释放——这一点由本测试末尾用同一 identity 重新创建证明:重建要重新取得同一把 + // sidecar 锁,锁没释放就会是 `ExecutionBusy`。 + root_lease.mark_clean().await.unwrap(); + assert_eq!(fixture.count_execution_runs("s-del-root").await, 0); + // 收敛读取没有对象:会话不存在,就没有「可重载」这回事。 + let error = fixture + .facade + .recover_session_persistence(&"s-del-root".to_owned()) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::NotFound + )); + // 收尾中的 owner 仍在册:此刻的写入按「没有活 owner」被拒绝——所有权事实优先于 + // 数据事实,重试收尾不会被悄悄放行。 + let error = fixture + .facade + .delete_session_tree(&"s-del-root".to_owned()) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); + drop(root_lease); + // owner 释放后,剩下的结论才是数据事实:这条会话已不存在。 + let error = fixture + .facade + .delete_session_tree(&"s-del-root".to_owned()) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::NotFound + )); + // 同一 identity 可以重新创建:删除的对象是这条会话的数据与执行事实,不是这个名字。 + // 没有 durable 痕迹时不留「不许再用」的封印——那需要一张跨进程存活的表,而本机 + // 不再有那样的表(见 schema v10 的删除清单)。 + let workspace = fixture.workspace().await; + let input = fixture.session("s-del-root", &workspace, r#"{"v":1}"#); + let fresh = fixture.facade.create_session(&input).await.unwrap(); + assert_eq!(fresh.thread_id().as_str(), "s-del-root"); + assert_eq!( + fixture.execution_row("s-del-root").await, + Some((1, false)), + "重新创建是全新的一条会话,代际从 1 开始且未结清" + ); +} + +// ─── 排空与关闭 ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_close_stops_new_writes_and_reports_unsettled_owners() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-close").await; + let id = "s-close".to_owned(); + fixture.facade.drain_persistence(&id).await.unwrap(); + + fixture.shutdown().await.unwrap(); + // 关闭后不再接受新写入;读取仍然可用(历史可解释性不受影响)。 + let error = fixture + .facade + .append_history(&id, &[payload("after close")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Unavailable { .. } + )); + assert!(fixture.facade.load_session_meta(&id).await.is_ok()); + // 重复关闭是幂等成功。 + fixture.shutdown().await.unwrap(); + // 收尾仍由 owner 完成:关闭不等于替 owner 写完 clean。 + lease.mark_clean().await.unwrap(); + drop(lease); + + // 未结清的写入存在时,关闭不宣告完成。 + let fixture = Fixture::new().await; + let lease = fixture.create("s-close-uncertain").await; + { + let _scope = fixture + .facade + .gate + .admit(&"s-close-uncertain".to_owned()) + .await + .unwrap(); + } + let error = fixture.shutdown().await.unwrap_err(); + assert!(error.is_persistence_uncertain()); + drop(lease); +} + +// ─── 跨进程:他处所有权、崩溃后的 dirty 与排空 ───────────────────────────────── + +/// 子进程入口:按环境变量在**另一进程**里执行同一门面动作。 +/// +/// 没有环境变量时直接返回,因此它只在被父测试拉起时工作。 +#[tokio::test] +async fn test_facade_child_process() { + let Ok(db) = std::env::var("PERI_TEST_FACADE_DB") else { + return; + }; + let id = std::env::var("PERI_TEST_FACADE_ID").unwrap(); + let repo = std::env::var("PERI_TEST_FACADE_REPO").unwrap(); + let expected = std::env::var("PERI_TEST_FACADE_EXPECT").unwrap(); + let facade = SessionResourcesImpl::open(db).await.unwrap(); + let workspace = facade.resolve_workspace(Path::new(&repo)).await.unwrap(); + match expected.as_str() { + // 他处持有 owner:本进程只能报忙,不能取得所有权。 + "busy" => { + let error = match facade.acquire_execution(&id, &workspace).await { + Ok(_) => panic!("acquired ownership while another process holds it"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBusy) + )); + } + // 崩溃:取得所有权后直接退出,不写 clean,锁由 OS 释放。 + "crash" => { + let _lease = facade.acquire_execution(&id, &workspace).await.unwrap(); + std::process::exit(0); + } + other => panic!("unknown expected child result: {other}"), + } +} + +fn facade_process(db: &Path, repo: &Path, id: &str, expected: &str) { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "sessions::resources::tests::test_facade_child_process", + "--nocapture", + ]) + .env("PERI_TEST_FACADE_DB", db) + .env("PERI_TEST_FACADE_REPO", repo) + .env("PERI_TEST_FACADE_ID", id) + .env("PERI_TEST_FACADE_EXPECT", expected) + .output() + .unwrap(); + assert!( + output.status.success(), + "facade child failed ({expected}): {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("running 1 test")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_facade_owner_competes_across_processes_and_crash_stays_dirty() { + let fixture = Fixture::new().await; + let id = "s-xproc".to_owned(); + let lease = fixture.create(&id).await; + let db = fixture._db.path().join("threads.db"); + let repo = fixture.repo.path(); + + // 本进程持有 owner:另一进程的取得所有权必须失败,且不产生第二代。 + facade_process(&db, repo, &id, "busy"); + assert_eq!( + fixture + .facade + .gate + .local() + .execution_state(&id) + .await + .unwrap(), + Some((1, false)) + ); + + // 干净收尾后释放所有权,另一进程取得第二代并崩溃:脏代际跨进程保留。 + lease.mark_clean().await.unwrap(); + drop(lease); + facade_process(&db, repo, &id, "crash"); + let error = match fixture + .facade + .acquire_execution(&id, &fixture.workspace().await) + .await + { + Ok(_) => panic!("expected recovery to be required after the other process crashed"), + Err(error) => error, + }; + let SessionResourceErrorKind::Workspace(WorkspaceError::RecoveryRequired(details)) = + error_kind(&error) + else { + panic!("expected a dirty generation, got: {error:?}"); + }; + assert_eq!(details.generation, 2); + // 崩溃留下的只是普通 dirty:排空不会把它当成未决写入,解除仍要显式接受风险。 + fixture.facade.drain_persistence(&id).await.unwrap(); + let error = fixture + .facade + .reset_dirty_execution(&ResetDirtyRequest { + target: RecoveryRequiredDetails { + thread_id: id.clone(), + generation: 2, + }, + accept_risk: false, + }) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + )); + fixture + .facade + .reset_dirty_execution(&ResetDirtyRequest { + target: RecoveryRequiredDetails { + thread_id: id.clone(), + generation: 2, + }, + accept_risk: true, + }) + .await + .unwrap(); + // 精确解除后同一 identity 仍可正常取得所有权并收尾。 + let next = fixture + .facade + .acquire_execution(&id, &fixture.workspace().await) + .await + .unwrap(); + assert_eq!(next.thread_id(), &id); + next.mark_clean().await.unwrap(); +} + +// ─── fork 的收敛与撤销边界 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_save_fork_converges_when_the_target_was_saved_without_admission() { + let fixture = Fixture::new().await; + let source_lease = fixture.create("s-fork-source").await; + let workspace = fixture.workspace().await; + let fork = ForkSnapshot { + target: fixture.session( + "s-fork-target", + &workspace, + r#"{"v":1,"id":"s-fork-source"}"#, + ), + source_id: "s-fork-source".to_owned(), + payloads: vec![payload("forked turn")], + flags: std::collections::HashMap::new(), + }; + // 数据先落库、准入未成立(远程保存或上次进程在准入前结束),再重试同一次 fork: + // 收敛准入,不重复写历史,也不把「已保存」报成「已存在」。 + fixture.facade.gate.data().save_fork(&fork).await.unwrap(); + let lease = fixture.facade.save_fork(&fork).await.unwrap(); + assert_eq!(lease.thread_id(), &"s-fork-target".to_owned()); + assert_eq!(fixture.count_execution_runs("s-fork-target").await, 1); + assert_eq!(fixture.count_messages("s-fork-target").await, 1); + // 数据已保存但前提变化时才报「已保存、未准入」。 + let mut changed = fork.clone(); + changed.target.binding.workspace_id = peri_acp_types::workspace::WorkspaceId::new(); + let error = match fixture.facade.save_fork(&changed).await { + Ok(_) => panic!("expected the changed premise to refuse admission"), + Err(error) => error, + }; + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + )); + drop(lease); + drop(source_lease); +} + +#[tokio::test] +async fn test_revoke_refuses_a_session_that_already_has_children() { + let fixture = Fixture::new().await; + let root_lease = fixture.create("s-revoke-root").await; + let _child = fixture + .child("s-revoke-child", "s-revoke-root", &root_lease) + .await; + + // 已派生过子会话的 identity 不能被补偿掉:否则子会话会指向不存在的父节点。 + let error = fixture + .facade + .abandon_initialization(&"s-revoke-root".to_owned(), &root_lease) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::InvalidInput { .. } + )); + assert_eq!(fixture.count_threads("s-revoke-root").await, 1); + assert_eq!(fixture.count_threads("s-revoke-child").await, 1); + assert_eq!(fixture.count_execution_runs("s-revoke-root").await, 1); + // 失败不留半撤销状态:owner 与两条会话都仍然可用。 + fixture + .facade + .append_history(&"s-revoke-child".to_owned(), &[payload("still usable")]) + .await + .unwrap(); + root_lease.mark_clean().await.unwrap(); +} + +#[tokio::test] +async fn test_cancelled_close_does_not_become_success() { + let fixture = Fixture::new().await; + let lease = fixture.create("s-close-cancel").await; + let id = "s-close-cancel".to_owned(); + // 一条已准入、未结清的写入:关闭必须先等它结束,而不是宣告完成。 + let scope = fixture.facade.gate.admit(&id).await.unwrap(); + + // 第一次关闭在等待中被取消(调用方超时放弃)。 + let cancelled = tokio::time::timeout(Duration::from_millis(50), fixture.shutdown()).await; + assert!( + cancelled.is_err(), + "close must wait for the in-flight write" + ); + // 取消不构成任何确认:下一次关闭仍要真实等待,不能直接成功。 + let again = tokio::time::timeout(Duration::from_millis(50), fixture.shutdown()).await; + assert!( + again.is_err(), + "a cancelled close must not be recorded as closed" + ); + + // 在途写入结清之后关闭才成立;此时重复关闭才是幂等成功。 + scope.settle(&Ok::<(), SessionResourceError>(())); + fixture.shutdown().await.unwrap(); + fixture.shutdown().await.unwrap(); + drop(lease); +} + +// ─── 双库:数据面在别处(远程组合的离线等价物) ─────────────────────────────── + +/// 执行面事实:与门面内部(`MutationGate::session_facts`)用的是同一个取法。 +/// +/// 直接驱动本机执行面的测试必须按同一组事实判定:「绑定/树根由数据面回答」这条契约不能只 +/// 在门面里成立,否则测试锁的会是「本机恰好查得到自己那张表」这个实现细节。 +async fn facts_of(facade: &SessionResourcesImpl, id: &str) -> SessionFacts { + facade.gate.session_facts(&id.to_owned()).await.unwrap() +} + +/// 数据面与本机执行面在**两个**库里的门面:远程组合的离线等价物。 +/// +/// 装配点与远程组合相同(`SessionResourcesImpl::from_ports` + `SessionDataHome::RemoteStore`), +/// 只是数据面用另一个真 sqlite 顶替远端 adapter:本机执行面库因此**没有**这条会话的任何 +/// 会话表行(`threads` / `session_bindings`),与远端会话在本机的处境逐条相同,从而可以在 +/// 离线环境里证明执行面只按数据面给出的事实判定。 +struct DoubleDbFixture { + facade: Arc, + repo: TempDir, + /// 数据面所在的库(远端 store 的等价物)。 + data: LocalExecution, + /// 本机执行面所在的库(workspace 登记、执行代际、sidecar 锁)。 + local: LocalExecution, + _dirs: (TempDir, TempDir), +} + +impl DoubleDbFixture { + async fn new() -> Self { + let repo = repository(); + let data_dir = tempfile::tempdir().unwrap(); + let local_dir = tempfile::tempdir().unwrap(); + let data = LocalExecution::open(data_dir.path().join("remote.db")) + .await + .unwrap(); + let local = LocalExecution::open(local_dir.path().join("threads.db")) + .await + .unwrap(); + let facade = Arc::new(SessionResourcesImpl::from_ports( + Arc::new(data.data_port()), + Arc::new(local.clone()), + SessionDataHome::RemoteStore, + )); + Self { + facade, + repo, + data, + local, + _dirs: (data_dir, local_dir), + } + } + + /// 工作区登记是**本机**执行事实:解析只落在本机库里。 + /// + /// 数据面库需要同一份登记,只是因为这里用本机 adapter 顶替远端 store 而远端 store 根本 + /// 没有登记表(绑定直接落在会话行上):复制的是**同一份**登记(同 id、同快照字节), + /// 不是第二份证据。这样两边的绑定指向同一个 workspace,测试打的仍然是「数据面在别的库」 + /// 这件事本身。 + async fn workspace(&self) -> ResolvedWorkspace { + let workspace = self + .facade + .resolve_workspace(self.repo.path()) + .await + .unwrap(); + self.mirror_registration(&workspace).await; + workspace + } + + /// 把本机库里的登记原样复制到数据面库(见 [`Self::workspace`])。 + /// + /// 两个库是两条独立连接,不能跨库 `INSERT ... SELECT`,因此逐行读出再写入。 + async fn mirror_registration(&self, workspace: &ResolvedWorkspace) { + let project: (String, String, String) = + sqlx::query_as("SELECT id, locator, object_identity FROM projects WHERE id = ?1") + .bind(workspace.project_id.to_string()) + .fetch_one(self.local.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT OR IGNORE INTO projects (id, locator, object_identity) VALUES (?1, ?2, ?3)", + ) + .bind(&project.0) + .bind(&project.1) + .bind(&project.2) + .execute(self.data.pool()) + .await + .unwrap(); + let row: (String, String, String, String, String) = sqlx::query_as( + "SELECT id, project_id, root, root_identity, discovery FROM workspaces WHERE id = ?1 AND project_id = ?2", + ) + .bind(workspace.workspace_id.to_string()) + .bind(workspace.project_id.to_string()) + .fetch_one(self.local.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT OR IGNORE INTO workspaces (id, project_id, root, root_identity, discovery) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(&row.0) + .bind(&row.1) + .bind(&row.2) + .bind(&row.3) + .bind(&row.4) + .execute(self.data.pool()) + .await + .unwrap(); + } + + fn binding(workspace: &ResolvedWorkspace) -> SessionBinding { + SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + } + } + + fn session(&self, id: &str, workspace: &ResolvedWorkspace, parent: Option<&str>) -> NewSession { + NewSession { + thread_id: id.to_owned(), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("session {id}")), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: parent.map(str::to_owned), + hidden: parent.is_some(), + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: Self::binding(workspace), + frozen: FrozenSnapshotBytes::new(format!(r#"{{"v":1,"id":"{id}"}}"#)), + } + } + + /// 远程组合的创建:数据面 durable 保存 + 本机执行准入两步。 + async fn create( + &self, + id: &str, + workspace: &ResolvedWorkspace, + ) -> Arc { + self.facade + .create_session(&self.session(id, workspace, None)) + .await + .unwrap() + } + + /// child:数据面写继承区与父子关系,沿用 root owner(child 自己没有执行代际)。 + async fn save_child( + &self, + child: &str, + root: &str, + workspace: &ResolvedWorkspace, + root_lease: &Arc, + ) { + let root_frozen = match self + .facade + .load_session_snapshot(&root.to_owned()) + .await + .unwrap() + .frozen + { + FrozenState::Present(frozen) => frozen, + other => panic!("root frozen must be present: {other:?}"), + }; + let mut target = self.session(child, workspace, Some(root)); + target.frozen = root_frozen; + self.facade + .save_child( + &ChildSnapshot { + target, + parent_id: root.to_owned(), + root_id: root.to_owned(), + inherited: peri_acp_types::store::InheritedContext { + payloads: Vec::new(), + flags: std::collections::HashMap::new(), + }, + }, + root_lease, + ) + .await + .unwrap(); + } + + /// 数据面上只有会话行、没有绑定行的历史会话(远端 store 里的 legacy 历史)。 + async fn save_bindingless_session(&self, id: &str, workspace: &ResolvedWorkspace) { + sqlx::query( + "INSERT INTO threads (id, title, cwd, created_at, updated_at, message_count, agent_status) + VALUES (?1, ?2, ?3, ?4, ?4, 0, 'active')", + ) + .bind(id) + .bind(format!("legacy {id}")) + .bind(workspace.cwd.to_string_lossy().into_owned()) + .bind("2026-09-26T00:00:00Z") + .execute(self.data.pool()) + .await + .unwrap(); + } + + /// 本机库里恰好有一条同 id 的行(cwd 落在已登记工作区内):远端会话不能被它冒充。 + async fn save_local_lookalike_row(&self, id: &str, workspace: &ResolvedWorkspace) { + sqlx::query( + "INSERT INTO threads (id, title, cwd, created_at, updated_at, message_count, agent_status) + VALUES (?1, ?2, ?3, ?4, ?4, 0, 'active')", + ) + .bind(id) + .bind(format!("lookalike {id}")) + .bind(workspace.cwd.to_string_lossy().into_owned()) + .bind("2026-09-26T00:00:00Z") + .execute(self.local.pool()) + .await + .unwrap(); + } + + async fn count(pool: &sqlx::SqlitePool, sql: &'static str, id: &str) -> i64 { + let row: (i64,) = sqlx::query_as(sql).bind(id).fetch_one(pool).await.unwrap(); + row.0 + } + + /// 本机会话表行数:远端会话在本机必须一行都没有。 + async fn local_session_rows(&self, id: &str) -> (i64, i64) { + ( + Self::count( + self.local.pool(), + "SELECT COUNT(*) FROM threads WHERE id = ?1", + id, + ) + .await, + Self::count( + self.local.pool(), + "SELECT COUNT(*) FROM session_bindings WHERE thread_id = ?1", + id, + ) + .await, + ) + } + + async fn local_execution_row(&self, id: &str) -> Option<(i64, bool)> { + sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = ?1") + .bind(id) + .fetch_optional(self.local.pool()) + .await + .unwrap() + } + + async fn data_rows(&self, id: &str) -> (i64, i64, i64) { + ( + Self::count( + self.data.pool(), + "SELECT COUNT(*) FROM threads WHERE id = ?1", + id, + ) + .await, + Self::count( + self.data.pool(), + "SELECT COUNT(*) FROM session_bindings WHERE thread_id = ?1", + id, + ) + .await, + Self::count( + self.data.pool(), + "SELECT COUNT(*) FROM messages WHERE thread_id = ?1", + id, + ) + .await, + ) + } + + async fn availability(&self, id: &str) -> Option { + self.facade + .inspect_availability(Some(&id.to_owned())) + .await + .unwrap() + .execution + } +} + +/// 数据面在**别的**库时,冷恢复必须成立:绑定与树根由数据面回答,本机执行面不去本机会话表 +/// 找它们。修复前这条路径在 `acquire_execution` 处按 `Workspace(BindingMissing)` 失败。 +#[tokio::test] +async fn test_double_db_cold_recovery_acquires_execution_from_data_plane_facts() { + let fixture = DoubleDbFixture::new().await; + let workspace = fixture.workspace().await; + let id = "r-cold-root".to_owned(); + + // ① 远程组合的创建是两步:数据面 durable 保存,本机执行面再建立准入(数据与代际不在 + // 同一个库,因此没有「一次提交」可塌缩)。 + let lease = fixture.create(&id, &workspace).await; + assert_eq!(lease.thread_id(), &id); + // 本机只留执行事实:没有会话行、没有绑定行、没有历史。 + assert_eq!(fixture.local_session_rows(&id).await, (0, 0)); + assert_eq!(fixture.local_execution_row(&id).await, Some((1, false))); + // 数据面有完整数据(会话行 + 绑定行)。 + assert_eq!(fixture.data_rows(&id).await, (1, 1, 0)); + // 有主不是「需要恢复」。 + assert_eq!( + fixture.availability(&id).await, + Some(ExecutionAvailability::OwnedElsewhere) + ); + // 活 owner 上仍可正常写入:门禁挂在数据面给出的 root 上。 + fixture + .facade + .append_history(&id, &[payload("first turn")]) + .await + .unwrap(); + assert_eq!(fixture.data_rows(&id).await, (1, 1, 1)); + + // ② 冷进程等价物:上一个进程退出而没有写 clean,本机剩下的只有未结清的代际。 + drop(lease); + assert_eq!( + fixture.availability(&id).await, + Some(ExecutionAvailability::Dirty(RecoveryRequiredDetails { + thread_id: id.clone(), + generation: 1, + })) + ); + fixture + .facade + .reset_dirty_execution(&ResetDirtyRequest { + target: RecoveryRequiredDetails { + thread_id: id.clone(), + generation: 1, + }, + accept_risk: true, + }) + .await + .unwrap(); + + // ③ 取得所有权:绑定复核用数据面的字节,root-only 判定用数据面给出的树根。 + let recovered = fixture + .facade + .acquire_execution(&id, &workspace) + .await + .unwrap(); + assert_eq!(recovered.thread_id(), &id); + assert_eq!( + fixture.availability(&id).await, + Some(ExecutionAvailability::OwnedElsewhere) + ); + // ④ 返回的租约可用:clean 落在**本机**执行代际上(数据面不写执行事实)。 + recovered.mark_clean().await.unwrap(); + assert_eq!(fixture.local_execution_row(&id).await, Some((2, true))); + assert_eq!(fixture.local_session_rows(&id).await, (0, 0)); +} + +/// child 的写入归属 root 执行域:数据面在别的库时它必须仍然拿到**真**门禁,且 owner 关闭后 +/// 按既有语义被拒绝。修复前 `bound` 恒为 false ⇒ `WriteScope::Concurrent(None)` ⇒ 静默放行。 +#[tokio::test] +async fn test_double_db_child_mutation_holds_the_root_owner_and_fails_without_it() { + let fixture = DoubleDbFixture::new().await; + let workspace = fixture.workspace().await; + let root = "r-owner-root".to_owned(); + let child = "r-owner-child".to_owned(); + let root_lease = fixture.create(&root, &workspace).await; + fixture + .save_child(&child, &root, &workspace, &root_lease) + .await; + // child 在本机同样一行都没有:它的写入归属只能由数据面回答(root 在远端父链上)。 + assert_eq!(fixture.local_session_rows(&child).await, (0, 0)); + assert_eq!(fixture.local_execution_row(&child).await, None); + + // 有活 owner:child 的 mutation 落在 root 的门禁上,而不是无门禁放行。 + let root_facts = facts_of(&fixture.facade, &root).await; + let held = fixture + .facade + .gate + .local() + .exclusive_guard(&root, &root_facts) + .await + .unwrap() + .expect("the root owner is alive"); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(200), + fixture.facade.append_history(&child, &[payload("blocked")]), + ) + .await + .is_err(), + "a child mutation must wait for the root's write gate" + ); + assert_eq!(fixture.data_rows(&child).await.2, 0); + held.finish(); + fixture + .facade + .append_history(&child, &[payload("child turn")]) + .await + .unwrap(); + assert_eq!(fixture.data_rows(&child).await.2, 1); + + // owner 关闭(clean 已落地但本进程仍看得见这条租约)后,同一调用按既有语义失败。 + root_lease.mark_clean().await.unwrap(); + let error = fixture + .facade + .append_history(&child, &[payload("after clean")]) + .await + .unwrap_err(); + assert!(matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + )); + // 租约消失(进程结束等价)后同样被拒绝:有绑定而没有 owner 不是「无主」,不能免授权。 + drop(root_lease); + for id in [&child, &root] { + let error = fixture + .facade + .append_history(id, &[payload("after drop")]) + .await + .unwrap_err(); + assert!( + matches!( + error_kind(&error), + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionLeaseRequired) + ), + "{id}: expected ExecutionLeaseRequired, got {error:?}" + ); + } + // 被拒的写入没有留下效果。 + assert_eq!(fixture.data_rows(&child).await.2, 1); +} + +/// `execution_availability` 在「有活 owner / ordinary dirty / 绑定缺失」三种情形下的结论与 +/// 本机组合一致;「绑定缺失」也不会被本机恰好存在的同 id 行冒充成 legacy。 +#[tokio::test] +async fn test_double_db_execution_availability_matches_the_local_verdicts() { + let fixture = DoubleDbFixture::new().await; + let workspace = fixture.workspace().await; + + // 有活 owner:有主不是「需要恢复」。 + let owned = "r-avail-owned".to_owned(); + let lease = fixture.create(&owned, &workspace).await; + assert_eq!( + fixture.availability(&owned).await, + Some(ExecutionAvailability::OwnedElsewhere) + ); + + // ordinary dirty:owner 消失后剩下的只有精确代际的未结清事实。 + let dirty = "r-avail-dirty".to_owned(); + let dirty_lease = fixture.create(&dirty, &workspace).await; + drop(dirty_lease); + assert_eq!( + fixture.availability(&dirty).await, + Some(ExecutionAvailability::Dirty(RecoveryRequiredDetails { + thread_id: dirty.clone(), + generation: 1, + })) + ); + + // 绑定缺失:数据面上只有会话行(远端 store 里的历史会话),本机没有任何执行事实。 + let missing = "r-avail-missing".to_owned(); + fixture.save_bindingless_session(&missing, &workspace).await; + assert_eq!( + fixture.availability(&missing).await, + Some(ExecutionAvailability::BindingMissing) + ); + // 本机恰好有一条同 id、cwd 落在已登记工作区里的无绑定行:那是**本机** legacy 的来源证据, + // 远端会话不看它(否则远端历史会被判成 legacy)。 + assert_eq!( + fixture.facade.load_session_binding(&missing).await.unwrap(), + BindingState::Missing + ); + fixture.save_local_lookalike_row(&missing, &workspace).await; + assert_eq!( + fixture.facade.load_session_binding(&missing).await.unwrap(), + BindingState::Missing + ); + assert_eq!( + fixture.availability(&missing).await, + Some(ExecutionAvailability::BindingMissing) + ); + + drop(lease); +} diff --git a/peri-resources/src/sessions/sqlite_inherited_context_test.rs b/peri-resources/src/sessions/sqlite_inherited_context_test.rs index 5ac322bee..82d79efdc 100644 --- a/peri-resources/src/sessions/sqlite_inherited_context_test.rs +++ b/peri-resources/src/sessions/sqlite_inherited_context_test.rs @@ -92,7 +92,7 @@ async fn test_inherited_context_rejects_future_corrupt_and_foreign_flags_without sqlx::query("UPDATE threads SET inherited_context = ?1 WHERE id = ?2") .bind(snapshot) .bind(&id) - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); let error = store.load_inherited_context(&id).await.unwrap_err(); @@ -107,7 +107,7 @@ async fn test_inherited_context_rejects_future_corrupt_and_foreign_flags_without .is_err()); let raw: (String,) = sqlx::query_as("SELECT inherited_context FROM threads WHERE id = ?1") .bind(&id) - .fetch_one(&store.pool) + .fetch_one(&store.database.pool) .await .unwrap(); assert_eq!(raw.0, snapshot); diff --git a/peri-resources/src/sessions/sqlite_store.rs b/peri-resources/src/sessions/sqlite_store.rs index 65235d2b2..77a89c0c9 100644 --- a/peri-resources/src/sessions/sqlite_store.rs +++ b/peri-resources/src/sessions/sqlite_store.rs @@ -1,13 +1,23 @@ -//! SQLite ThreadStore 的唯一 pool owner 与契约实现。 -//! 连接、行映射、上下文和 compaction 事务由私有模块负责。 +//! SQLite 会话库:数据面与执行/登记面共用同一 pool 与同一库。 +//! +//! [`SqliteThreadStore`] 是消费侧迁移期间的**桥**:它把旧 `ThreadStore` 的调用转发 +//! 到共享库句柄 [`database::SqliteSessionDatabase`];新行为一律加到 +//! [`data::SessionDataPort`](super::data::SessionDataPort) 的 SQLite 实现 +//! (`session_data`),不在本文件扩展。E 阶段 `ThreadStore` 退出时本类型一并删除。 +//! 连接、行映射、上下文、compaction 与 schema 事务由私有模块负责。 mod compaction; mod connection; mod context; +mod database; mod discovery; mod execution; -mod row_mapping; +mod failure; +mod local; +pub(crate) mod row_mapping; mod schema; +mod session_data; +mod session_rows; mod workspace; use anyhow::{Context, Result}; @@ -17,34 +27,117 @@ pub use connection::{ReadOnlyStoreErrorKind, ReadOnlyThreadStoreError}; use peri_acp_types::{ messages::BaseMessage, store::{ - deserialize_persisted_payload, serialize_persisted_payload, CompactionLifecycle, + deserialize_persisted_payload, serialize_persisted_payload, CompactionChange, InheritedContext, MessageFlags, PersistedPayload, ThreadStore, }, thread::{AgentStatus, ThreadId, ThreadListEntry, ThreadMeta}, }; +/// `messages.role` 的领域派生:canonical schema 的写入原语两端共用同一份 +/// (见 `sessions::canonical::payload_role`)。 +pub(in crate::sessions) use row_mapping::role_of as role_of_message; use row_mapping::{ extract_title, meta_from_row, role_of, ThreadRow, THREAD_COLUMNS, THREAD_META_COLUMNS, }; -use sqlx::{AssertSqlSafe, SqlitePool}; -use std::{collections::HashMap, str::FromStr}; +use sqlx::AssertSqlSafe; +use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; + +use super::resources::SessionResourcesImpl; +use database::SqliteSessionDatabase; +use execution::TransactionEffect; +pub(in crate::sessions) use execution::{ + ExclusiveExecutionGuard, ExecutionLease, ExecutionWriteGuard, +}; +/// 提交阶段/领域失败映射:由门面测试驱动真实写入准入,生产路径在 `session_data` +/// 与 `compaction` 内部直接引用。 +#[cfg(test)] +pub(in crate::sessions) use failure::{commit_failure, write_failure}; +pub(in crate::sessions) use failure::{ + execution_failure, invalid_input, is_persistence_uncertain, lease_required, not_found, + read_only_store, unavailable, +}; +pub(in crate::sessions) use local::{same_lease, LocalExecution}; +/// 本机 schema 版本:canonical 形状的版本号,远端 `peri_store_meta.schema_version` 与它同源。 +pub(in crate::sessions) use schema::CURRENT_SCHEMA_VERSION; +/// 数据面实现:生产组合从 [`LocalExecution::data_port`] 取得它,本重导出供测试夹具直接命名。 +#[cfg(test)] +pub(crate) use session_data::SqliteSessionData; #[cfg(test)] use connection::{classify_shape_probe_failure, REQUIRED_MESSAGE_COLUMNS, REQUIRED_THREAD_COLUMNS}; #[cfg(test)] use sqlx::sqlite::SqliteConnectOptions; -/// 基于 SQLite 的 ThreadStore 实现 +/// 基于 SQLite 的 ThreadStore 实现(迁移桥) /// -/// 使用 WAL 模式提升并发读性能,sqlx SqlitePool 连接池管理并发。 +/// 使用 WAL 模式提升并发读性能,sqlx SqlitePool 连接池管理并发。连接池、canonical +/// 路径与执行租约登记都归共享库句柄所有:数据面与执行面不会各自持有一条连接真相。 pub struct SqliteThreadStore { - pool: SqlitePool, - read_only: bool, - db_path: std::path::PathBuf, - execution_leases: - std::sync::Mutex>>, + database: Arc, +} + +impl SqliteThreadStore { + /// 打开或创建会话数据库,原地升级已知旧 schema 并保留历史数据。 + pub async fn new(db_path: impl Into) -> Result { + Ok(Self { + database: Arc::new(SqliteSessionDatabase::open(db_path).await?), + }) + } + + /// 以 SQLite read-only capability 打开已存在的数据库;不创建目录、库或 schema。 + pub async fn open_existing_read_only( + db_path: impl AsRef, + ) -> std::result::Result { + Ok(Self { + database: Arc::new(SqliteSessionDatabase::open_existing_read_only(db_path).await?), + }) + } + + /// 默认数据库位置 `~/.peri/threads/threads.db`;不创建目录、数据库或连接。 + pub fn default_database_path() -> Result { + SqliteSessionDatabase::default_database_path() + } + + /// 使用默认路径 `~/.peri/threads/threads.db` 创建 + pub async fn default_path() -> Result { + Self::new(Self::default_database_path()?).await + } + + /// 关闭连接池并等待全部连接释放(见 `SqliteSessionDatabase::close`)。 + pub async fn close(&self) { + self.database.close().await; + } + + /// 夹具装配(唯一调用点 `open_store_and_facade_for_tests`):迁移桥与门面共用 + /// **同一个库句柄**(同一 pool、同一 owner 登记表)。 + /// + /// 两面对同一个库既有两种句柄,又不能各开一条连接真相:桥侧的 + /// `acquire_execution_lease` 与门面的 owner 校验必须落在同一份登记表上,否则同一个 + /// 进程里会互相判成「无主」。E 阶段桥退出后,本函数一并删除,只留门面构造。 + pub(in crate::sessions) async fn open_shared( + db_path: impl Into, + ) -> Result<(Self, SessionResourcesImpl)> { + let database = Arc::new(SqliteSessionDatabase::open(db_path).await?); + Ok(( + Self { + database: Arc::clone(&database), + }, + SessionResourcesImpl::from_local(LocalExecution::from_shared_database(database)), + )) + } + + /// 本桥的写入准入:先按**本机库自己的**读法取执行面要用的会话事实(绑定字节、这棵树 + /// 有没有绑定、树根),再按同一套 root-only 判定取门禁。 + /// + /// 桥与本机数据面共用同一个库句柄(同一份连接真相),因此这里用本机数据面的读法 + /// ([`SqliteSessionDatabase::local_session_facts`])构造事实,而不是再写一份读法。 + /// 远端组合不经过本桥:那时本机没有会话行,事实只能由门面从数据端口取。 + async fn write_guard(&self, id: &ThreadId) -> Result> { + let facts = self.database.local_session_facts(id).await?; + self.database.require_execution_lease(id, &facts).await + } } -// ── ThreadStore impl ─────────────────────────────────────────────────────────── +// ── ThreadStore impl(迁移桥转发) ───────────────────────────────────────────── #[async_trait] impl ThreadStore for SqliteThreadStore { @@ -52,32 +145,34 @@ impl ThreadStore for SqliteThreadStore { &self, cwd: &std::path::Path, ) -> Result { - self.resolve_workspace_impl(cwd).await + self.database.resolve_workspace_impl(cwd).await } async fn create_bound_thread( &self, meta: ThreadMeta, workspace: &peri_acp_types::workspace::ResolvedWorkspace, ) -> Result { - self.create_bound_thread_impl(meta, workspace).await + self.database + .create_bound_thread_impl(meta, workspace) + .await } async fn load_session_binding( &self, id: &ThreadId, ) -> Result> { - self.load_session_binding_impl(id).await + self.database.load_session_binding_impl(id).await } async fn validate_session_binding( &self, id: &ThreadId, ) -> Result { - self.validate_session_binding_impl(id).await + self.database.validate_session_binding_impl(id).await } async fn reassert_session_binding( &self, id: &ThreadId, ) -> Result { - self.reassert_session_binding_impl(id).await + self.database.reassert_session_binding_impl(id).await } async fn adopt_legacy_thread( &self, @@ -86,27 +181,29 @@ impl ThreadStore for SqliteThreadStore { workspace: &peri_acp_types::workspace::ResolvedWorkspace, frozen_snapshot: &str, ) -> Result<()> { - self.adopt_legacy_thread_impl(id, saved_cwd, workspace, frozen_snapshot) + self.database + .adopt_legacy_thread_impl(id, saved_cwd, workspace, frozen_snapshot) .await } async fn list_scoped_threads( &self, query: &peri_acp_types::workspace::ScopedThreadQuery, ) -> Result { - self.list_scoped_threads_impl(query).await + self.database.list_scoped_threads_impl(query).await } async fn acquire_execution_lease( &self, id: &ThreadId, ) -> Result> { - self.acquire_execution_lease_impl(id).await + let facts = self.database.local_session_facts(id).await?; + self.database.acquire_execution_lease_impl(id, &facts).await } async fn reset_dirty_execution( &self, target: &peri_acp_types::workspace::RecoveryRequiredDetails, ) -> Result<()> { - self.reset_dirty_execution_impl(target).await + self.database.reset_dirty_execution_impl(target).await } async fn create_thread(&self, meta: ThreadMeta) -> Result { @@ -129,7 +226,7 @@ impl ThreadStore for SqliteThreadStore { .bind(&meta.config) .bind(&meta.cached_context) .bind(meta.agent_status.as_str()) - .execute(&self.pool) + .execute(&self.database.pool) .await?; Ok(id) } @@ -147,7 +244,7 @@ impl ThreadStore for SqliteThreadStore { let rows: Vec<(String,)> = sqlx::query_as("SELECT content FROM messages WHERE thread_id = ?1 ORDER BY rowid") .bind(id.as_str()) - .fetch_all(&self.pool) + .fetch_all(&self.database.pool) .await?; rows.into_iter() @@ -160,12 +257,12 @@ impl ThreadStore for SqliteThreadStore { } async fn append_payloads(&self, id: &ThreadId, payloads: &[PersistedPayload]) -> Result<()> { - let write_guard = self.require_execution_lease(id).await?; + let write_guard = self.write_guard(id).await?; let result = async { if payloads.is_empty() { return Ok(()); } - let mut tx = self.pool.begin().await?; + let mut tx = self.database.pool.begin().await?; for payload in payloads { let message_id = payload.id().as_uuid().to_string(); let role = payload @@ -216,21 +313,7 @@ impl ThreadStore for SqliteThreadStore { } async fn load_payloads(&self, id: &ThreadId) -> Result> { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT message_id, content FROM messages WHERE thread_id = ?1 ORDER BY rowid", - ) - .bind(id.as_str()) - .fetch_all(&self.pool) - .await?; - rows.into_iter() - .map(|(row_id, content)| { - let payload = deserialize_persisted_payload(&content)?; - if payload.id().as_uuid().to_string() != row_id { - anyhow::bail!("persisted payload message id mismatch"); - } - Ok(payload) - }) - .collect() + self.database.load_payloads(id).await } async fn load_meta(&self, id: &ThreadId) -> Result { @@ -238,11 +321,11 @@ impl ThreadStore for SqliteThreadStore { "SELECT {THREAD_COLUMNS} FROM threads t WHERE t.id = ?1" ))) .bind(id.as_str()) - .fetch_one(&self.pool) + .fetch_one(&self.database.pool) .await { Ok(row) => row, - Err(error) if self.read_only => { + Err(error) if self.database.read_only => { let kind = if matches!(error, sqlx::Error::RowNotFound) { ReadOnlyStoreErrorKind::SessionNotFound } else if matches!( @@ -262,7 +345,7 @@ impl ThreadStore for SqliteThreadStore { row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, row.12, row.13, ); - if self.read_only { + if self.database.read_only { result.map_err(|_| { ReadOnlyThreadStoreError::from_kind(ReadOnlyStoreErrorKind::CorruptSessionData) .into() @@ -273,13 +356,13 @@ impl ThreadStore for SqliteThreadStore { } async fn update_meta(&self, id: &ThreadId, meta: ThreadMeta) -> Result<()> { - let write_guard = self.require_execution_lease(id).await?; + let write_guard = self.write_guard(id).await?; let result = async { - if self.load_session_binding_impl(id).await?.is_some() { + if self.database.load_session_binding_impl(id).await?.is_some() { let original: (String, Option) = sqlx::query_as("SELECT cwd, parent_thread_id FROM threads WHERE id = ?") .bind(id) - .fetch_one(&self.pool) + .fetch_one(&self.database.pool) .await?; if original.0 != meta.cwd || original.1 != meta.parent_thread_id { return Err( @@ -305,7 +388,7 @@ impl ThreadStore for SqliteThreadStore { .bind(&meta.cached_context) .bind(meta.agent_status.as_str()) .bind(id.as_str()) - .execute(&self.pool) + .execute(&self.database.pool) .await?; Ok(()) } @@ -320,20 +403,20 @@ impl ThreadStore for SqliteThreadStore { let row: (Option,) = sqlx::query_as("SELECT frozen_context FROM threads WHERE id = ?1") .bind(id.as_str()) - .fetch_one(&self.pool) + .fetch_one(&self.database.pool) .await?; Ok(row.0) } async fn store_frozen_snapshot_if_absent(&self, id: &ThreadId, snapshot: &str) -> Result { - let write_guard = self.require_execution_lease(id).await?; + let write_guard = self.write_guard(id).await?; let result = async { let result = sqlx::query( "UPDATE threads SET frozen_context = ?1 WHERE id = ?2 AND frozen_context IS NULL", ) .bind(snapshot) .bind(id.as_str()) - .execute(&self.pool) + .execute(&self.database.pool) .await?; if result.rows_affected() == 1 { return Ok(true); @@ -341,7 +424,7 @@ impl ThreadStore for SqliteThreadStore { let row: Option<(Option,)> = sqlx::query_as("SELECT frozen_context FROM threads WHERE id = ?1") .bind(id.as_str()) - .fetch_optional(&self.pool) + .fetch_optional(&self.database.pool) .await?; match row { Some((Some(_),)) => Ok(false), @@ -362,7 +445,7 @@ impl ThreadStore for SqliteThreadStore { let rows: Vec = sqlx::query_as(AssertSqlSafe(format!( "SELECT {THREAD_META_COLUMNS} FROM threads t WHERE t.hidden = 0 ORDER BY t.updated_at DESC" ))) - .fetch_all(&self.pool) + .fetch_all(&self.database.pool) .await?; rows.into_iter() @@ -383,7 +466,7 @@ impl ThreadStore for SqliteThreadStore { ORDER BY updated_at DESC", ) .bind(cwd) - .fetch_all(&self.pool) + .fetch_all(&self.database.pool) .await?; rows.into_iter() @@ -400,12 +483,13 @@ impl ThreadStore for SqliteThreadStore { } async fn delete_thread(&self, id: &ThreadId) -> Result<()> { - let write_guard = self.require_execution_lease(id).await?; + let write_guard = self.write_guard(id).await?; + let mut effect = TransactionEffect::new(); + let mut deleted: Vec = Vec::new(); let result = async { - let mut tx = self.pool.begin().await?; + let mut tx = self.database.pool.begin().await?; // 级联删除整个线程树:hidden 子 agent 线程沿 parent_thread_id 挂链, - // 若不递归删除会留下永远无法通过 UI/协议访问的孤儿数据(messages 表 - // 依赖 threads 行 FK ON DELETE CASCADE 一并清除)。 + // 若不递归删除会留下永远无法通过 UI/协议访问的孤儿数据。 let mut to_delete = vec![id.as_str().to_string()]; let mut idx = 0; while idx < to_delete.len() { @@ -418,30 +502,45 @@ impl ThreadStore for SqliteThreadStore { idx += 1; } for tid in &to_delete { - sqlx::query("DELETE FROM threads WHERE id = ?1") + // v7 起 `execution_runs` 不再有 `threads` 外键:不显式删除就会留下 + // 永不收敛的孤儿执行行。v10 起本机也不再写删除墓碑——删除即删除, + // 没有「这条 identity 被刻意终止」的另一份本机证据。 + sqlx::query("DELETE FROM execution_runs WHERE thread_id = ?1") + .bind(tid) + .execute(&mut *tx) + .await?; + // `messages` 与 `session_bindings` 同样显式删除,**不再**依赖 + // `ON DELETE CASCADE`:那份级联只在 SQLite 上存在,远端执行器没有 + // (见 `session_rows::THREAD_CHILD_DELETES`)。先子后父,顺序与数据面 + // 的 delete_tree 及远端一致。 + session_rows::delete_thread_child_rows(&mut tx, tid).await?; + sqlx::query(session_rows::DELETE_THREAD_SQL) .bind(tid) .execute(&mut *tx) .await?; } + effect.enter_commit(); tx.commit().await?; + effect.commit_succeeded(); + deleted = to_delete; Ok(()) } .await; - if let Some(guard) = write_guard { - guard.finish(); - } + // 只有效果确定才结清写入准入:提交自身的失败落在证明之外,交给 Drop 留下未决证据。 + effect.settle(write_guard); + let _ = deleted; result } async fn update_title(&self, id: &ThreadId, title: &str) -> Result<()> { - let write_guard = self.require_execution_lease(id).await?; + let write_guard = self.write_guard(id).await?; let result = async { let now = Utc::now().to_rfc3339(); sqlx::query("UPDATE threads SET title = ?1, updated_at = ?2 WHERE id = ?3") .bind(title) .bind(&now) .bind(id.as_str()) - .execute(&self.pool) + .execute(&self.database.pool) .await?; Ok(()) } @@ -457,9 +556,13 @@ impl ThreadStore for SqliteThreadStore { thread_id: &ThreadId, inherited: &InheritedContext, ) -> Result<()> { - let write_guard = self.require_execution_lease(thread_id).await?; - let result = - async { context::store_inherited_context(self, thread_id, inherited).await }.await; + let write_guard = self.write_guard(thread_id).await?; + let result = async { + self.database + .store_inherited_context(thread_id, inherited) + .await + } + .await; if let Some(guard) = write_guard { guard.finish(); } @@ -467,27 +570,27 @@ impl ThreadStore for SqliteThreadStore { } async fn load_inherited_context(&self, thread_id: &ThreadId) -> Result { - context::load_inherited_context(self, thread_id).await + self.database.load_inherited_context(thread_id).await } async fn load_context_payloads(&self, thread_id: &ThreadId) -> Result> { - context::load_context_payloads(self, thread_id).await + self.database.load_context_payloads(thread_id).await } async fn load_context(&self, thread_id: &ThreadId) -> Result> { - context::load_context(self, thread_id).await + self.database.load_context(thread_id).await } async fn list_child_threads(&self, parent_id: &ThreadId) -> Result> { - context::list_child_threads(self, parent_id).await + self.database.list_child_threads(parent_id).await } async fn list_session_threads(&self, root_id: &ThreadId) -> Result> { - context::list_session_threads(self, root_id).await + self.database.list_session_threads(root_id).await } async fn update_thread_status(&self, id: &ThreadId, status: &str) -> Result<()> { - let write_guard = self.require_execution_lease(id).await?; + let write_guard = self.write_guard(id).await?; let result = async { // 关键约束:参数字符串必须经 FromStr 解析,非法值直接返回错误,不静默 fallback let status = AgentStatus::from_str(status) @@ -497,7 +600,7 @@ impl ThreadStore for SqliteThreadStore { .bind(status.as_str()) .bind(&now) .bind(id.as_str()) - .execute(&self.pool) + .execute(&self.database.pool) .await?; Ok(()) } @@ -509,8 +612,8 @@ impl ThreadStore for SqliteThreadStore { } async fn invalidate_context_cache(&self, thread_id: &ThreadId) -> Result<()> { - let write_guard = self.require_execution_lease(thread_id).await?; - let result = async { context::invalidate_context_cache(self, thread_id).await }.await; + let write_guard = self.write_guard(thread_id).await?; + let result = async { self.database.invalidate_context_cache(thread_id).await }.await; if let Some(guard) = write_guard { guard.finish(); } @@ -518,7 +621,7 @@ impl ThreadStore for SqliteThreadStore { } async fn get_context_cache_epoch(&self, thread_id: &ThreadId) -> Result { - context::get_context_cache_epoch(self, thread_id).await + self.database.get_context_cache_epoch(thread_id).await } async fn delete_messages( @@ -526,11 +629,15 @@ impl ThreadStore for SqliteThreadStore { thread_id: &ThreadId, message_ids: &[peri_acp_types::messages::MessageId], ) -> Result<()> { - let write_guard = self.require_execution_lease(thread_id).await?; + let write_guard = self.write_guard(thread_id).await?; let result = - async { compaction::delete_messages(self, thread_id, message_ids).await }.await; + async { compaction::delete_messages(&self.database, thread_id, message_ids).await } + .await; if let Some(guard) = write_guard { - guard.finish(); + // 提交阶段的失败是未决持久化:范围留给 `Drop`,不在这里结清。 + if !result.as_ref().err().is_some_and(is_persistence_uncertain) { + guard.finish(); + } } result } @@ -543,15 +650,16 @@ impl ThreadStore for SqliteThreadStore { let owner: Option<(String,)> = sqlx::query_as("SELECT thread_id FROM messages WHERE message_id = ?") .bind(message_id.as_uuid().to_string()) - .fetch_optional(&self.pool) + .fetch_optional(&self.database.pool) .await?; let write_guard = match owner { - Some((id,)) => self.require_execution_lease(&id).await?, + Some((id,)) => self.write_guard(&id).await?, None => None, }; let result = - async { compaction::update_message_flags(self, message_id, flags).await }.await; + async { compaction::update_message_flags(&self.database, message_id, flags).await } + .await; if let Some(guard) = write_guard { guard.finish(); } @@ -565,14 +673,18 @@ impl ThreadStore for SqliteThreadStore { async fn commit_compaction_lifecycle( &self, thread_id: &ThreadId, - lifecycle: &CompactionLifecycle, + lifecycle: &CompactionChange, ) -> Result<()> { - let write_guard = self.require_execution_lease(thread_id).await?; - let result = - async { compaction::commit_compaction_lifecycle(self, thread_id, lifecycle).await } - .await; + let write_guard = self.write_guard(thread_id).await?; + let result = async { + compaction::commit_compaction_lifecycle(&self.database, thread_id, lifecycle).await + } + .await; if let Some(guard) = write_guard { - guard.finish(); + // 提交阶段的失败是未决持久化:范围留给 `Drop`,不在这里结清。 + if !result.as_ref().err().is_some_and(is_persistence_uncertain) { + guard.finish(); + } } result } @@ -581,7 +693,7 @@ impl ThreadStore for SqliteThreadStore { &self, thread_id: &ThreadId, ) -> Result> { - compaction::load_message_flags(self, thread_id).await + compaction::load_message_flags(&self.database, thread_id).await } async fn delete_messages_since( @@ -589,11 +701,16 @@ impl ThreadStore for SqliteThreadStore { thread_id: &ThreadId, message_id: &peri_acp_types::messages::MessageId, ) -> Result<()> { - let write_guard = self.require_execution_lease(thread_id).await?; - let result = - async { compaction::delete_messages_since(self, thread_id, message_id).await }.await; + let write_guard = self.write_guard(thread_id).await?; + let result = async { + compaction::delete_messages_since(&self.database, thread_id, message_id).await + } + .await; if let Some(guard) = write_guard { - guard.finish(); + // 提交阶段的失败是未决持久化:范围留给 `Drop`,不在这里结清。 + if !result.as_ref().err().is_some_and(is_persistence_uncertain) { + guard.finish(); + } } result } @@ -610,3 +727,19 @@ mod inherited_context_tests; #[cfg(test)] #[path = "sqlite_store/legacy_test.rs"] mod legacy_tests; + +#[cfg(test)] +#[path = "sqlite_store/session_data_test.rs"] +mod session_data_tests; + +#[cfg(test)] +#[path = "sqlite_store/thread_child_delete_test.rs"] +mod thread_child_delete_tests; + +#[cfg(test)] +#[path = "sqlite_store/schema_v7_test.rs"] +mod schema_v7_tests; + +#[cfg(test)] +#[path = "sqlite_store/schema_v10_test.rs"] +mod schema_v10_tests; diff --git a/peri-resources/src/sessions/sqlite_store/compaction.rs b/peri-resources/src/sessions/sqlite_store/compaction.rs index fde683cbf..7c4bca5b2 100644 --- a/peri-resources/src/sessions/sqlite_store/compaction.rs +++ b/peri-resources/src/sessions/sqlite_store/compaction.rs @@ -1,23 +1,25 @@ //! 消息生命周期持久化:删除、flags、回滚与原子 compaction 事务。 -use super::{row_mapping::role_of, SqliteThreadStore}; +use super::failure::commit_failure; +use super::{database::SqliteSessionDatabase, row_mapping::role_of}; use anyhow::Result; use chrono::Utc; use peri_acp_types::{ - store::{CompactionLifecycle, MessageFlags}, + store::{CompactionChange, MessageFlags}, thread::ThreadId, }; +use sqlx::SqliteConnection; use std::collections::HashMap; pub(super) async fn delete_messages( - store: &SqliteThreadStore, + database: &SqliteSessionDatabase, thread_id: &ThreadId, message_ids: &[peri_acp_types::messages::MessageId], ) -> Result<()> { if message_ids.is_empty() { return Ok(()); } - let mut tx = store.pool.begin().await?; + let mut tx = database.pool.begin().await?; for mid in message_ids { let uuid_str = mid.as_uuid().to_string(); sqlx::query("DELETE FROM messages WHERE message_id = ?1 AND thread_id = ?2") @@ -36,13 +38,15 @@ pub(super) async fn delete_messages( .bind(thread_id.as_str()) .execute(&mut *tx) .await?; - tx.commit().await?; - super::context::invalidate_context_cache(store, thread_id).await?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(thread_id.clone())))?; + database.invalidate_context_cache(thread_id).await?; Ok(()) } pub(super) async fn update_message_flags( - store: &SqliteThreadStore, + database: &SqliteSessionDatabase, message_id: &peri_acp_types::messages::MessageId, flags: &MessageFlags, ) -> Result<()> { @@ -59,28 +63,28 @@ pub(super) async fn update_message_flags( .bind(flags.excluded) .bind(&projection_json) .bind(&id_str) - .execute(&store.pool) + .execute(&database.pool) .await?; // 消息可见性变更(truncation/excluded/projection)影响上下文视图,失效 cached_context let thread_id: Option<(String,)> = sqlx::query_as("SELECT thread_id FROM messages WHERE message_id = ?1") .bind(&id_str) - .fetch_optional(&store.pool) + .fetch_optional(&database.pool) .await?; if let Some((tid,)) = thread_id { - super::context::invalidate_context_cache(store, &tid).await?; + database.invalidate_context_cache(&tid).await?; } Ok(()) } pub(super) async fn commit_compaction_lifecycle( - store: &SqliteThreadStore, + database: &SqliteSessionDatabase, thread_id: &ThreadId, - lifecycle: &CompactionLifecycle, + lifecycle: &CompactionChange, ) -> Result<()> { - let mut tx = store.pool.begin().await?; + let mut tx = database.pool.begin().await?; for (message_id, flags) in &lifecycle.flag_updates { let projection_json = flags @@ -137,12 +141,26 @@ pub(super) async fn commit_compaction_lifecycle( .execute(&mut *tx) .await?; - tx.commit().await?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(thread_id.clone())))?; Ok(()) } pub(super) async fn load_message_flags( - store: &SqliteThreadStore, + database: &SqliteSessionDatabase, + thread_id: &ThreadId, +) -> Result> { + let mut connection = database.pool.acquire().await?; + load_flags_on(&mut connection, thread_id).await +} + +/// flags 是派生视图:只返回非默认标记。 +/// +/// 损坏的 `message_id` 或无法解释的 projection 是数据故障,不是「没有标记」: +/// 这里直接失败,避免调用方把损坏当成空值继续覆盖写入。 +pub(super) async fn load_flags_on( + connection: &mut SqliteConnection, thread_id: &ThreadId, ) -> Result> { let rows: Vec<(String, bool, bool, Option)> = sqlx::query_as( @@ -150,28 +168,31 @@ pub(super) async fn load_message_flags( WHERE thread_id = ?1 AND (truncated = 1 OR excluded = 1 OR projection IS NOT NULL)", ) .bind(thread_id.as_str()) - .fetch_all(&store.pool) + .fetch_all(&mut *connection) .await?; let mut flags = HashMap::with_capacity(rows.len()); for (id_str, truncated, excluded, projection_json) in rows { - if let Ok(uid) = uuid::Uuid::parse_str(&id_str) { - let projection = projection_json.and_then(|json| serde_json::from_str(&json).ok()); - flags.insert( - uid.into(), - MessageFlags { - truncated, - excluded, - projection, - }, - ); - } + let id = uuid::Uuid::parse_str(&id_str) + .map_err(|_| anyhow::anyhow!("stored message id is not a uuid"))?; + let projection = projection_json + .map(|json| serde_json::from_str(&json)) + .transpose() + .map_err(|_| anyhow::anyhow!("stored message projection is not readable"))?; + flags.insert( + id.into(), + MessageFlags { + truncated, + excluded, + projection, + }, + ); } Ok(flags) } pub(super) async fn delete_messages_since( - store: &SqliteThreadStore, + database: &SqliteSessionDatabase, thread_id: &ThreadId, message_id: &peri_acp_types::messages::MessageId, ) -> Result<()> { @@ -180,11 +201,11 @@ pub(super) async fn delete_messages_since( sqlx::query_as("SELECT rowid FROM messages WHERE thread_id = ?1 AND message_id = ?2") .bind(thread_id.as_str()) .bind(message_id.as_uuid().to_string()) - .fetch_optional(&store.pool) + .fetch_optional(&database.pool) .await?; if let Some((rowid,)) = target_rowid { - let mut tx = store.pool.begin().await?; + let mut tx = database.pool.begin().await?; sqlx::query("DELETE FROM messages WHERE thread_id = ?1 AND rowid > ?2") .bind(thread_id.as_str()) .bind(rowid) @@ -200,8 +221,10 @@ pub(super) async fn delete_messages_since( .bind(thread_id.as_str()) .execute(&mut *tx) .await?; - tx.commit().await?; - super::context::invalidate_context_cache(store, thread_id).await?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(thread_id.clone())))?; + database.invalidate_context_cache(thread_id).await?; } Ok(()) } diff --git a/peri-resources/src/sessions/sqlite_store/connection.rs b/peri-resources/src/sessions/sqlite_store/connection.rs index 04b196959..223e11a98 100644 --- a/peri-resources/src/sessions/sqlite_store/connection.rs +++ b/peri-resources/src/sessions/sqlite_store/connection.rs @@ -1,6 +1,6 @@ //! SQLite 连接、只读 schema 探测、单库升级与安全错误分类。 -use super::SqliteThreadStore; +use super::database::SqliteSessionDatabase; use anyhow::{Context, Result}; use peri_acp_types::workspace::WorkspaceError; use sqlx::{ @@ -111,9 +111,9 @@ pub(super) fn classify_shape_probe_failure(error: &sqlx::Error) -> ReadOnlyThrea } } -impl SqliteThreadStore { +impl SqliteSessionDatabase { /// 打开或创建会话数据库,原地升级已知旧 schema 并保留历史数据。 - pub async fn new(db_path: impl Into) -> Result { + pub(super) async fn open(db_path: impl Into) -> Result { let db_path = db_path.into(); // 确保父目录存在 if let Some(parent) = db_path.parent() { @@ -148,17 +148,13 @@ impl SqliteThreadStore { .max_connections(5) .connect_with(options) .await?; - let store = Self { - pool, - read_only: false, - db_path: tokio::fs::canonicalize(&db_path).await?, - execution_leases: Default::default(), - }; - if let Err(error) = store.init_schema().await { - store.close().await; + let db_path = tokio::fs::canonicalize(&db_path).await?; + let database = Self::new(pool, false, db_path); + if let Err(error) = database.init_schema().await { + database.close().await; return Err(error); } - Ok(store) + Ok(database) } /// 关闭连接池并等待全部连接释放。 @@ -166,7 +162,7 @@ impl SqliteThreadStore { /// `Drop` 返回时不等待连接关闭完成,最后一次连接关闭触发的 WAL checkpoint 与 /// `-wal`/`-shm` 清理因此可能晚于 `Drop` 返回,并与并发只读打开重叠。需要确定性 /// 收尾时调用本方法:返回后本进程不再持有该数据库的连接,且侧车文件已完成收尾。 - pub async fn close(&self) { + pub(super) async fn close(&self) { self.pool.close().await; } @@ -185,7 +181,7 @@ impl SqliteThreadStore { /// 以 SQLite read-only capability 打开已存在的数据库。 /// /// 该路径不创建目录、数据库或 schema,也不执行 migration。 - pub async fn open_existing_read_only( + pub(super) async fn open_existing_read_only( db_path: impl AsRef, ) -> std::result::Result { let db_path = db_path.as_ref(); @@ -214,12 +210,7 @@ impl SqliteThreadStore { .map_err(|_| { ReadOnlyThreadStoreError::from_kind(ReadOnlyStoreErrorKind::DatabaseUnreadable) })?; - let store = Self { - pool, - read_only: true, - db_path: db_path.to_path_buf(), - execution_leases: Default::default(), - }; + let store = Self::new(pool, true, db_path.to_path_buf()); store.probe_load_meta_shape().await?; Ok(store) } @@ -249,11 +240,6 @@ impl SqliteThreadStore { pub(crate) fn default_database_path() -> Result { super::super::default_database_path().context("无法获取 home 目录") } - - /// 使用默认路径 `~/.peri/threads/threads.db` 创建 - pub async fn default_path() -> Result { - Self::new(Self::default_database_path()?).await - } } /// SQLite's initial journal-mode switch can return BUSY despite busy_timeout when diff --git a/peri-resources/src/sessions/sqlite_store/context.rs b/peri-resources/src/sessions/sqlite_store/context.rs index 9f6d43bc0..cbfee41a3 100644 --- a/peri-resources/src/sessions/sqlite_store/context.rs +++ b/peri-resources/src/sessions/sqlite_store/context.rs @@ -1,75 +1,63 @@ //! 祖先 payload 边界、上下文缓存与线程树读取。 +//! +//! 读取分两层:`*_on(connection, …)` 是连接作用域原语,供需要「一次读取视图」的 +//! 调用方(一致 snapshot、事务内复核)使用;`impl SqliteSessionDatabase` 上的方法 +//! 自行取一条连接,供单条读取使用。 use super::{ + database::SqliteSessionDatabase, row_mapping::{meta_from_row, ThreadRow, THREAD_META_COLUMNS}, - SqliteThreadStore, }; use anyhow::Result; use chrono::Utc; use peri_acp_types::{ messages::BaseMessage, - store::{deserialize_persisted_payload, InheritedContext, PersistedPayload, ThreadStore}, + store::{deserialize_persisted_payload, InheritedContext, PersistedPayload}, thread::{ThreadId, ThreadMeta}, }; -use sqlx::AssertSqlSafe; +use sqlx::{AssertSqlSafe, SqliteConnection}; use std::collections::HashSet; -impl SqliteThreadStore { - /// 沿 parent_thread_id 链向上回溯,返回从根到自身的有序列表 - async fn resolve_ancestor_chain(&self, thread_id: &ThreadId) -> Result> { - let mut chain = vec![thread_id.clone()]; - let mut current = thread_id.clone(); - loop { - let row: Option<(Option,)> = - sqlx::query_as("SELECT parent_thread_id FROM threads WHERE id = ?1") - .bind(current.as_str()) - .fetch_optional(&self.pool) - .await?; - match row { - Some((Some(parent),)) => { - if chain.contains(&parent) { - anyhow::bail!("cyclic thread ancestry"); - } - chain.push(parent.clone()); - current = parent; - } - _ => break, - } - } - chain.reverse(); - Ok(chain) +impl SqliteSessionDatabase { + /// 小型 metadata 投影:不含 `cached_context`(派生缓存不是 metadata 事实)。 + pub(super) async fn load_meta(&self, id: &ThreadId) -> Result { + let mut connection = self.pool.acquire().await?; + load_meta_on(&mut connection, id).await + } + + pub(super) async fn load_payloads(&self, id: &ThreadId) -> Result> { + let mut connection = self.pool.acquire().await?; + load_payloads_on(&mut connection, id).await } - async fn load_payloads_up_to( + /// 视图读取:继承区在前、自有 payload 在后。 + pub(super) async fn load_context_payloads( &self, thread_id: &ThreadId, - message_id: &str, ) -> Result> { - let target_row: Option<(i64,)> = - sqlx::query_as("SELECT rowid FROM messages WHERE thread_id = ?1 AND message_id = ?2") - .bind(thread_id.as_str()) - .bind(message_id) - .fetch_optional(&self.pool) - .await?; - let Some((target_rowid,)) = target_row else { - return Ok(vec![]); - }; - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT message_id, content FROM messages WHERE thread_id = ?1 AND rowid <= ?2 ORDER BY rowid", - ) - .bind(thread_id.as_str()) - .bind(target_rowid) - .fetch_all(&self.pool) - .await?; - rows.into_iter() - .map(|(row_id, content)| { - let payload = deserialize_persisted_payload(&content)?; - if payload.id().as_uuid().to_string() != row_id { - anyhow::bail!("persisted payload message id mismatch"); - } - Ok(payload) - }) - .collect() + let mut connection = self.pool.acquire().await?; + load_context_payloads_on(&mut connection, thread_id).await + } + + pub(super) async fn load_inherited_context( + &self, + thread_id: &ThreadId, + ) -> Result { + let mut connection = self.pool.acquire().await?; + load_inherited_context_on(&mut connection, thread_id).await + } + + pub(super) async fn load_context(&self, thread_id: &ThreadId) -> Result> { + let messages = self + .load_context_payloads(thread_id) + .await? + .into_iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect::>(); + if !messages.is_empty() { + self.save_context_cache(thread_id, &messages).await?; + } + Ok(messages) } /// 将消息序列化为 JSON 并保存到 cached_context 列 @@ -92,65 +80,152 @@ impl SqliteThreadStore { .await?; Ok(()) } + + pub(super) async fn list_child_threads(&self, parent_id: &ThreadId) -> Result> { + let rows: Vec = + sqlx::query_as(AssertSqlSafe(format!( + "SELECT {THREAD_META_COLUMNS} FROM threads t WHERE t.parent_thread_id = ?1 ORDER BY t.created_at ASC" + ))) + .bind(parent_id.as_str()) + .fetch_all(&self.pool) + .await?; + + decode_meta_rows(rows) + } + + pub(super) async fn list_session_threads(&self, root_id: &ThreadId) -> Result> { + let rows: Vec = sqlx::query_as(AssertSqlSafe(format!( + "WITH RECURSIVE session_tree AS ( + SELECT * FROM threads WHERE id = ?1 + UNION ALL + SELECT t.* FROM threads t + INNER JOIN session_tree st ON t.parent_thread_id = st.id + ) + SELECT {THREAD_META_COLUMNS} FROM session_tree t ORDER BY t.created_at ASC" + ))) + .bind(root_id.as_str()) + .fetch_all(&self.pool) + .await?; + + decode_meta_rows(rows) + } + + pub(super) async fn invalidate_context_cache(&self, thread_id: &ThreadId) -> Result<()> { + sqlx::query("UPDATE threads SET cached_context = NULL WHERE id = ?1") + .bind(thread_id.as_str()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(super) async fn get_context_cache_epoch(&self, thread_id: &ThreadId) -> Result { + let row: Option<(i64,)> = + sqlx::query_as("SELECT context_cache_epoch FROM threads WHERE id = ?1") + .bind(thread_id.as_str()) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(e,)| e as u64).unwrap_or(0)) + } } -pub(super) async fn store_inherited_context( - store: &SqliteThreadStore, - thread_id: &ThreadId, - context: &InheritedContext, -) -> Result<()> { - let snapshot = context.to_json()?; - // Validate before publishing, including the message/flag reference boundary. - InheritedContext::from_json(&snapshot)?; - let result = sqlx::query( - "UPDATE threads SET inherited_context = ?1 WHERE id = ?2 AND inherited_context IS NULL", +fn decode_meta_rows(rows: Vec) -> Result> { + rows.into_iter() + .map(|row| { + meta_from_row( + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, + row.11, row.12, row.13, + ) + }) + .collect() +} + +/// 单条 metadata 读取(不含 `cached_context`)。 +pub(super) async fn load_meta_on( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> Result { + let row: ThreadRow = sqlx::query_as(AssertSqlSafe(format!( + "SELECT {THREAD_META_COLUMNS} FROM threads t WHERE t.id = ?1" + ))) + .bind(id.as_str()) + .fetch_one(&mut *connection) + .await?; + meta_from_row( + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, + row.12, row.13, + ) +} + +/// 自有 payload 读取:`rowid` 顺序即 canonical 顺序,并复核行内 ID 与主键一致。 +pub(super) async fn load_payloads_on( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> Result> { + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT message_id, content FROM messages WHERE thread_id = ?1 ORDER BY rowid", ) - .bind(snapshot) - .bind(thread_id) - .execute(&store.pool) + .bind(id.as_str()) + .fetch_all(&mut *connection) .await?; - if result.rows_affected() != 1 { - anyhow::bail!("inherited context already exists or thread is missing"); - } - Ok(()) + rows.into_iter() + .map(|(row_id, content)| { + let payload = deserialize_persisted_payload(&content)?; + if payload.id().as_uuid().to_string() != row_id { + anyhow::bail!("persisted payload message id mismatch"); + } + Ok(payload) + }) + .collect() } -pub(super) async fn load_inherited_context( - store: &SqliteThreadStore, +pub(super) async fn load_context_payloads_on( + connection: &mut SqliteConnection, + thread_id: &ThreadId, +) -> Result> { + let mut payloads = load_inherited_context_on(connection, thread_id) + .await? + .payloads; + payloads.extend(load_payloads_on(connection, thread_id).await?); + Ok(payloads) +} + +pub(super) async fn load_inherited_context_on( + connection: &mut SqliteConnection, thread_id: &ThreadId, ) -> Result { - let row: (Option,) = + // 已保存的继承快照是权威值:父会话之后的 compact/rewind 都不改变它。 + let stored: (Option,) = sqlx::query_as("SELECT inherited_context FROM threads WHERE id = ?1") .bind(thread_id) - .fetch_one(&store.pool) + .fetch_one(&mut *connection) .await?; - if let Some(snapshot) = row.0 { + if let Some(snapshot) = stored.0 { return InheritedContext::from_json(&snapshot); } - let chain = store.resolve_ancestor_chain(thread_id).await?; + let chain = resolve_ancestor_chain_on(connection, thread_id).await?; let mut context = InheritedContext::default(); // Each edge's cutoff belongs to its child. A stored snapshot replaces all // inherited state, so later parent compaction/rewind cannot change it. for (index, tid) in chain.iter().enumerate() { - let row: (Option,) = + let stored: (Option,) = sqlx::query_as("SELECT inherited_context FROM threads WHERE id = ?1") .bind(tid) - .fetch_one(&store.pool) + .fetch_one(&mut *connection) .await?; - if let Some(snapshot) = row.0 { + if let Some(snapshot) = stored.0 { context = InheritedContext::from_json(&snapshot)?; continue; } if index == 0 { continue; } - let meta = store.load_meta(tid).await?; + let meta = load_meta_on(connection, tid).await?; let Some(cutoff) = meta.snapshot_at_message_id else { context = InheritedContext::default(); continue; }; let parent = &chain[index - 1]; - let own = store.load_payloads_up_to(parent, &cutoff).await?; + let own = load_payloads_up_to_on(connection, parent, &cutoff).await?; if own.is_empty() { // A parent with no own entries can point into its inherited region. if let Some(position) = context @@ -165,8 +240,7 @@ pub(super) async fn load_inherited_context( } else { let own_ids = own.iter().map(PersistedPayload::id).collect::>(); context.flags.extend( - store - .load_message_flags(parent) + super::compaction::load_flags_on(connection, parent) .await? .into_iter() .filter(|(id, _)| own_ids.contains(id)), @@ -183,99 +257,86 @@ pub(super) async fn load_inherited_context( Ok(context) } -pub(super) async fn load_context_payloads( - store: &SqliteThreadStore, - thread_id: &ThreadId, -) -> Result> { - let mut payloads = store.load_inherited_context(thread_id).await?.payloads; - payloads.extend(store.load_payloads(thread_id).await?); - Ok(payloads) -} - -pub(super) async fn load_context( - store: &SqliteThreadStore, +/// 沿 parent_thread_id 链向上回溯,返回从根到自身的有序列表 +async fn resolve_ancestor_chain_on( + connection: &mut SqliteConnection, thread_id: &ThreadId, -) -> Result> { - let messages = store - .load_context_payloads(thread_id) - .await? - .into_iter() - .filter_map(|payload| payload.as_message().cloned()) - .collect::>(); - if !messages.is_empty() { - store.save_context_cache(thread_id, &messages).await?; +) -> Result> { + let mut chain = vec![thread_id.clone()]; + let mut current = thread_id.clone(); + loop { + let row: Option<(Option,)> = + sqlx::query_as("SELECT parent_thread_id FROM threads WHERE id = ?1") + .bind(current.as_str()) + .fetch_optional(&mut *connection) + .await?; + match row { + Some((Some(parent),)) => { + if chain.contains(&parent) { + anyhow::bail!("cyclic thread ancestry"); + } + chain.push(parent.clone()); + current = parent; + } + _ => break, + } } - Ok(messages) + chain.reverse(); + Ok(chain) } -pub(super) async fn list_child_threads( - store: &SqliteThreadStore, - parent_id: &ThreadId, -) -> Result> { - let rows: Vec = - sqlx::query_as(AssertSqlSafe(format!( - "SELECT {THREAD_META_COLUMNS} FROM threads t WHERE t.parent_thread_id = ?1 ORDER BY t.created_at ASC" - ))) - .bind(parent_id.as_str()) - .fetch_all(&store.pool) +async fn load_payloads_up_to_on( + connection: &mut SqliteConnection, + thread_id: &ThreadId, + message_id: &str, +) -> Result> { + let target_row: Option<(i64,)> = + sqlx::query_as("SELECT rowid FROM messages WHERE thread_id = ?1 AND message_id = ?2") + .bind(thread_id.as_str()) + .bind(message_id) + .fetch_optional(&mut *connection) .await?; - - rows.into_iter() - .map(|row| { - meta_from_row( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, - row.11, row.12, row.13, - ) - }) - .collect() -} - -pub(super) async fn list_session_threads( - store: &SqliteThreadStore, - root_id: &ThreadId, -) -> Result> { - let rows: Vec = sqlx::query_as(AssertSqlSafe(format!( - "WITH RECURSIVE session_tree AS ( - SELECT * FROM threads WHERE id = ?1 - UNION ALL - SELECT t.* FROM threads t - INNER JOIN session_tree st ON t.parent_thread_id = st.id - ) - SELECT {THREAD_META_COLUMNS} FROM session_tree t ORDER BY t.created_at ASC" - ))) - .bind(root_id.as_str()) - .fetch_all(&store.pool) + let Some((target_rowid,)) = target_row else { + return Ok(vec![]); + }; + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT message_id, content FROM messages WHERE thread_id = ?1 AND rowid <= ?2 ORDER BY rowid", + ) + .bind(thread_id.as_str()) + .bind(target_rowid) + .fetch_all(&mut *connection) .await?; - rows.into_iter() - .map(|row| { - meta_from_row( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, - row.11, row.12, row.13, - ) + .map(|(row_id, content)| { + let payload = deserialize_persisted_payload(&content)?; + if payload.id().as_uuid().to_string() != row_id { + anyhow::bail!("persisted payload message id mismatch"); + } + Ok(payload) }) .collect() } -pub(super) async fn invalidate_context_cache( - store: &SqliteThreadStore, - thread_id: &ThreadId, -) -> Result<()> { - sqlx::query("UPDATE threads SET cached_context = NULL WHERE id = ?1") - .bind(thread_id.as_str()) - .execute(&store.pool) +impl SqliteSessionDatabase { + /// 保存 child 的继承区:只在缺失时写入,已有值不被覆盖。 + pub(super) async fn store_inherited_context( + &self, + thread_id: &ThreadId, + context: &InheritedContext, + ) -> Result<()> { + let snapshot = context.to_json()?; + // Validate before publishing, including the message/flag reference boundary. + InheritedContext::from_json(&snapshot)?; + let result = sqlx::query( + "UPDATE threads SET inherited_context = ?1 WHERE id = ?2 AND inherited_context IS NULL", + ) + .bind(snapshot) + .bind(thread_id) + .execute(&self.pool) .await?; - Ok(()) -} - -pub(super) async fn get_context_cache_epoch( - store: &SqliteThreadStore, - thread_id: &ThreadId, -) -> Result { - let row: Option<(i64,)> = - sqlx::query_as("SELECT context_cache_epoch FROM threads WHERE id = ?1") - .bind(thread_id.as_str()) - .fetch_optional(&store.pool) - .await?; - Ok(row.map(|(e,)| e as u64).unwrap_or(0)) + if result.rows_affected() != 1 { + anyhow::bail!("inherited context already exists or thread is missing"); + } + Ok(()) + } } diff --git a/peri-resources/src/sessions/sqlite_store/database.rs b/peri-resources/src/sessions/sqlite_store/database.rs new file mode 100644 index 000000000..41e6f3f14 --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/database.rs @@ -0,0 +1,45 @@ +//! 会话库的私有所有权:连接池、访问模式、canonical 路径与执行租约登记。 +//! +//! 数据面([`super::session_data::SqliteSessionData`])与执行/登记面 +//! ([`super::SqliteThreadStore`])共用本结构,因此同一个库只有一条连接真相: +//! 一面写入的数据,另一面在同一事务语义下立即可见,不需要第二份 pool 或第二个 +//! 数据库文件。 +//! +//! 事务、CAS、连接与锁文件都留在这里的实现里,不跨 `SessionDataPort` 暴露。 + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Mutex, Weak}; + +use peri_acp_types::thread::ThreadId; +use sqlx::SqlitePool; + +use super::execution::ExecutionLease; + +/// 本机 SQLite 会话库的唯一 owner。 +/// +/// 迁移期可见性放宽到 `crate::sessions`:桥与门面的共享构造点(`sqlite_store.rs` +/// 的 `open_shared*`)与门面实现需要同一个句柄类型;字段仍只在 `sqlite_store` 内可见。 +pub(in crate::sessions) struct SqliteSessionDatabase { + pub(super) pool: SqlitePool, + pub(super) read_only: bool, + pub(super) db_path: PathBuf, + /// root owner 的弱引用登记:lease 的持有者是调用方,这里只用于复核准入。 + /// 键是 `thread_id` 原文(v10 之后只有这一个执行域)。 + pub(super) execution_leases: Mutex>>, +} + +impl SqliteSessionDatabase { + pub(super) fn new(pool: SqlitePool, read_only: bool, db_path: PathBuf) -> Self { + Self { + pool, + read_only, + db_path, + execution_leases: Mutex::new(HashMap::new()), + } + } + + pub(super) fn is_read_only(&self) -> bool { + self.read_only + } +} diff --git a/peri-resources/src/sessions/sqlite_store/execution.rs b/peri-resources/src/sessions/sqlite_store/execution.rs index a437d80e0..43b786514 100644 --- a/peri-resources/src/sessions/sqlite_store/execution.rs +++ b/peri-resources/src/sessions/sqlite_store/execution.rs @@ -1,9 +1,15 @@ -//! Stable sidecar OS ownership and durable dirty generations. Drop never implies clean. +//! Stable sidecar OS ownership and durable dirty generations. +//! +//! v10 之后只有**一个执行域**:执行代际与 sidecar 锁都按 `thread_id` 原文归属——同一个 +//! 本机库服务多个 store 时,那个维度已经不存在(用户裁决不做交叉能力)。调用方给 thread +//! id,本层按原文写行、按摘要取锁。 Drop never implies clean. -use super::SqliteThreadStore; +use super::database::SqliteSessionDatabase; +use crate::sessions::local_port::SessionFacts; use anyhow::{Context, Result}; use async_trait::async_trait; use peri_acp_types::{ + session_resources::SessionResourceResult, thread::ThreadId, workspace::{RecoveryRequiredDetails, SessionExecutionLease, WorkspaceError}, }; @@ -18,7 +24,15 @@ use std::{ time::Duration, }; -pub(super) struct ExecutionLease { +/// 本机域的 sidecar 锁名:`.lock`。 +/// +/// v10 之后只有这一个执行域,锁目录仍是既有的 `.execution-locks/`:既有行、既有锁名 +/// 逐字节相同,升级不改本机身份。 +pub(in crate::sessions) fn local_lock_name(id: &ThreadId) -> String { + format!("{:x}.lock", Sha256::digest(id.as_str().as_bytes())) +} + +pub(in crate::sessions) struct ExecutionLease { thread_id: ThreadId, generation: i64, pool: SqlitePool, @@ -28,16 +42,70 @@ pub(super) struct ExecutionLease { mutation_uncertain: AtomicBool, } +impl ExecutionLease { + /// 新建租约;`file` 是已取得的 sidecar OS 锁(`None` 表示只读/无锁场景)。 + pub(super) fn new( + thread_id: ThreadId, + generation: i64, + pool: SqlitePool, + file: Option, + ) -> Self { + Self { + thread_id, + generation, + pool, + file: tokio::sync::Mutex::new(file), + active: AtomicBool::new(true), + mutation_gate: Arc::new(tokio::sync::RwLock::new(())), + mutation_uncertain: AtomicBool::new(false), + } + } + + /// 本次所有权是否仍接受新写入(关闭不可逆)。 + pub(in crate::sessions) fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) + } + + /// 是否存在无法证明终态的写入。 + pub(in crate::sessions) fn is_uncertain(&self) -> bool { + self.mutation_uncertain.load(Ordering::Acquire) + } + + /// 有界排空屏障:取写侧门禁再释放,证明此刻没有已准入写入在途。 + /// + /// 调用方负责超时:等待外部 future 时不持普通全局互斥量。 + pub(in crate::sessions) async fn wait_for_in_flight(&self) { + let _gate = self.mutation_gate.clone().write_owned().await; + } + + /// 结束本次所有权:会话**数据已被删除**时的收尾,不做 clean CAS。 + /// + /// 与 [`SessionExecutionLease::mark_clean`] 的唯一区别是「不发声明」:代际行已随数据 + /// 删除,clean 这句话没有对象,硬写下 `clean = 1` 只会制造一条描述不存在会话的行。 + /// 顺序相同——先关准入(等待已准入写入结束,不可逆),再释放 OS 锁。 + /// + /// 释放后本租约仍是幂等终态:`mark_clean` 见到锁已不在会直接成功返回,因此调用方 + /// 无需知道数据删除与所有权结束的先后。 + /// + /// 只有调用方能给出「数据确实已删除」这个事实(门面在删除返回成功后调用),本方法 + /// 自己不做任何推测:它不查数据行,也不把「行缺失」当成删除的证据。 + pub(in crate::sessions) async fn dispose_ownership(&self) { + let _writes = self.mutation_gate.clone().write_owned().await; + self.active.store(false, Ordering::Release); + self.file.lock().await.take(); + } +} + /// The guard retains both the lease and admission lock until the complete SQL operation finishes. /// A cancelled mutation leaves its run dirty even if SQLx still has a queued database command. -pub(super) struct ExecutionWriteGuard { +pub(in crate::sessions) struct ExecutionWriteGuard { lease: Arc, _gate: tokio::sync::OwnedRwLockReadGuard<()>, completed: bool, } impl ExecutionWriteGuard { - pub(super) fn finish(mut self) { + pub(in crate::sessions) fn finish(mut self) { self.completed = true; } } @@ -50,6 +118,70 @@ impl Drop for ExecutionWriteGuard { } } +/// 排他写入范围:持有同一 owner 的写侧门禁。 +/// +/// 与 [`ExecutionWriteGuard`] 的区别是并发语义:读侧门禁允许同 root 的多个 mutation +/// 并发(SQLite 自己保证事务串行),写侧门禁用来把「检查 + 写入」做成一段不可插入的 +/// 区间(child resume 认领的状态检查与写入、未发布创建的撤销)。 +pub(in crate::sessions) struct ExclusiveExecutionGuard { + lease: Arc, + _gate: tokio::sync::OwnedRwLockWriteGuard<()>, + completed: bool, +} + +impl ExclusiveExecutionGuard { + pub(in crate::sessions) fn finish(mut self) { + self.completed = true; + } +} + +impl Drop for ExclusiveExecutionGuard { + fn drop(&mut self) { + if !self.completed { + self.lease.mutation_uncertain.store(true, Ordering::Release); + } + } +} + +/// 事务性写入的效果边界:进入 `commit` 前可证明「未生效」,提交成功后是「已生效」, +/// 只有提交自身的失败落在证明之外。 +/// +/// `sqlx` 的 SQLite 事务在 `commit()` 失败后由连接回滚,但「回滚是否真的完成」不由 +/// 调用方观察得到;这里把不可证明的那一小段标出来,让写入准入保持未决,而不是用 +/// `Err` 冒充「没生效」。 +#[derive(Default)] +pub(in crate::sessions) struct TransactionEffect { + committing: bool, + committed: bool, +} + +impl TransactionEffect { + pub(super) fn new() -> Self { + Self::default() + } + + /// 即将调用 `commit()`:此后失败不再能证明未生效。 + pub(super) fn enter_commit(&mut self) { + self.committing = true; + } + + /// `commit()` 已成功返回。 + pub(super) fn commit_succeeded(&mut self) { + self.committing = false; + self.committed = true; + } + + /// 按效果结清准入:可证明「未生效」或「已生效」时才 `finish`;其余情况释放 guard + /// 由 `Drop` 留下未决证据。 + pub(super) fn settle(&self, guard: Option) { + if !self.committing { + if let Some(guard) = guard { + guard.finish(); + } + } + } +} + #[async_trait] impl SessionExecutionLease for ExecutionLease { fn thread_id(&self) -> &ThreadId { @@ -72,29 +204,22 @@ impl SessionExecutionLease for ExecutionLease { // Closing admission is irreversible, even if the SQL await is cancelled. // Otherwise a late old-owner mutation could run after a committed clean row. self.active.store(false, Ordering::Release); - let updated = sqlx::query("UPDATE execution_runs SET clean = 1 WHERE thread_id = ? AND generation = ? AND clean = 0") - .bind(&self.thread_id).bind(self.generation).execute(&self.pool).await?; - if updated.rows_affected() != 1 { - let run: Option<(i64, bool)> = - sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = ?") - .bind(&self.thread_id) - .fetch_optional(&self.pool) - .await?; + let updated = self.mark_clean_row().await?; + if updated != 1 { // A cancelled mark_clean can have committed its SQL already. Only this // exact generation's clean record makes a retry safe to release the lock. - if run != Some((self.generation, true)) { - let exists: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM threads WHERE id = ?") - .bind(&self.thread_id) - .fetch_one(&self.pool) - .await?; - // New-session compensation can delete its row while the lease owns it. - if exists.0 != 0 { - return Err(WorkspaceError::RecoveryRequired(RecoveryRequiredDetails { - thread_id: self.thread_id.clone(), - generation: self.generation, - }) - .into()); - } + // + // 记录缺失在这里只有一个诚实结论:不知道它为什么不见了(被外部改写、 + // 库损坏、行没写成功),因此如实上报 `RecoveryRequired`。**刻意删除不走这条 + // 路**——会话删除由 [`Self::dispose_ownership`] 显式结束所有权(数据与代际 + // 行同时消失,之后本方法见到锁已释放即幂等成功),所以这里不需要、也不允许 + // 用「行不在」猜出「被删了」。 + if self.load_state_row().await? != Some((self.generation, true)) { + return Err(WorkspaceError::RecoveryRequired(RecoveryRequiredDetails { + thread_id: self.thread_id.clone(), + generation: self.generation, + }) + .into()); } } self.active.store(false, Ordering::Release); @@ -103,6 +228,61 @@ impl SessionExecutionLease for ExecutionLease { } } +impl ExecutionLease { + /// 宣告这一代 clean,返回受影响行数(1 = 本次生效)。 + async fn mark_clean_row(&self) -> Result { + Ok(sqlx::query( + "UPDATE execution_runs SET clean = 1 WHERE thread_id = ? AND generation = ? AND clean = 0", + ) + .bind(self.thread_id.as_str()) + .bind(self.generation) + .execute(&self.pool) + .await? + .rows_affected()) + } + + /// 这条 identity 的代际行(复核用;缺行返回 `None`)。 + async fn load_state_row(&self) -> Result> { + Ok( + sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = ?") + .bind(self.thread_id.as_str()) + .fetch_optional(&self.pool) + .await?, + ) + } +} + +impl ExecutionLease { + /// 放弃本次所有权:等待已准入写入 → 执行补偿 → 关闭准入并释放 OS 锁。 + /// + /// 只用于「本次创建被撤销」:数据行会被删除,因此不能走 `mark_clean` 的 clean CAS + /// (那要求记录仍然存在)。 + /// + /// 补偿失败时**不动**所有权状态:既不关闭准入也不放锁,调用方仍持有这条会话并可 + /// 重试撤销或继续使用。反过来若先关闭准入再补偿,一次失败的补偿会把会话变成 + /// 「既没撤销、又不能再用」,那才是真正的半状态。 + pub(in crate::sessions) async fn abandon_ownership( + &self, + compensate: F, + ) -> SessionResourceResult + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + // 补偿期间取写侧门禁:已准入的写入先结束,新写入等在这里。 + let gate = self.mutation_gate.clone().write_owned().await; + let outcome = compensate().await; + if outcome.is_ok() { + // 补偿成功:这条 identity 已撤销,关闭准入不可逆,然后释放 OS 锁。 + self.active.store(false, Ordering::Release); + drop(gate); + let mut file = self.file.lock().await; + file.take(); + } + outcome + } +} + /// 执行锁的重试预算与间隔。 /// /// `flock` 的锁挂在 open file description 上,而 `fork` 出的子进程共享父进程的描述符 @@ -114,12 +294,13 @@ impl SessionExecutionLease for ExecutionLease { const EXECUTION_LOCK_RETRY_BUDGET: Duration = Duration::from_millis(500); const EXECUTION_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10); -impl SqliteThreadStore { - async fn lock_execution(&self, id: &ThreadId) -> Result { - let safe_id = format!("{:x}", Sha256::digest(id.as_bytes())); +impl SqliteSessionDatabase { + /// `lock_name` 是 [`local_lock_name`] 给出的锁相对名,落在 `.execution-locks/`。 + pub(super) async fn lock_execution(&self, lock_name: &str) -> Result { + let name = lock_name.to_owned(); let mut directory = self.db_path.as_os_str().to_os_string(); directory.push(".execution-locks"); - let path = std::path::PathBuf::from(directory).join(format!("{safe_id}.lock")); + let path = std::path::PathBuf::from(directory).join(name); tokio::task::spawn_blocking(move || -> Result { std::fs::create_dir_all(path.parent().context("lock directory missing")?)?; // 所有进程复用同一 inode,绝不删除锁文件。 @@ -154,16 +335,19 @@ impl SqliteThreadStore { if self.read_only { return Err(WorkspaceError::ExecutionLeaseRequired.into()); } - let _file = self.lock_execution(&target.thread_id).await?; + let _file = self + .lock_execution(&local_lock_name(&target.thread_id)) + .await?; let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; let updated = sqlx::query( "UPDATE execution_runs SET clean = 1 WHERE thread_id = ? AND generation = ? AND clean = 0", ) - .bind(&target.thread_id) + .bind(target.thread_id.as_str()) .bind(target.generation) .execute(&mut *tx) - .await?; - if updated.rows_affected() != 1 { + .await? + .rows_affected(); + if updated != 1 { return Err(WorkspaceError::RecoveryGenerationMismatch.into()); } tx.commit().await?; @@ -173,27 +357,31 @@ impl SqliteThreadStore { pub(super) async fn acquire_execution_lease_impl( &self, id: &ThreadId, + facts: &SessionFacts, ) -> Result> { if self.read_only { return Err(WorkspaceError::ExecutionLeaseRequired.into()); } // 取得执行所有权是准入的最后一步:绑定已在同一次准入里复核过(解析或 - // `validate_session_binding`),这里只复核已记录证据,不再重复完整发现。 - self.reassert_session_binding_impl(id).await?; - let parent: (Option,) = - sqlx::query_as("SELECT parent_thread_id FROM threads WHERE id = ?") - .bind(id) - .fetch_one(&self.pool) - .await?; + // `validate_session_binding_value`),这里只复核调用方给出的已记录字节,不重复完整发现。 + let binding = facts + .binding + .as_ref() + .ok_or(WorkspaceError::BindingMissing)?; // Owned children have one owner: the root lease and its close transaction. - if parent.0.is_some() { + // 树根由数据面回答:本机没有这条会话的行时(远端组合)也不该去本机表里找父链。 + if facts.root != *id { return Err(WorkspaceError::ExecutionLeaseRequired.into()); } - let file = self.lock_execution(id).await?; + let mut connection = self.pool.acquire().await?; + Self::validate_binding_relation_on(&mut connection, binding).await?; + drop(connection); + let file = self.lock_execution(&local_lock_name(id)).await?; + let key = id.clone(); let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; let prior: Option<(i64, bool)> = sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = ?") - .bind(id) + .bind(id.as_str()) .fetch_optional(&mut *tx) .await?; if let Some((generation, false)) = prior { @@ -203,7 +391,7 @@ impl SqliteThreadStore { }) .into()); } - Self::validate_session_binding_on(&mut tx, id).await?; + Self::validate_binding_relation_on(&mut tx, binding).await?; let generation = prior .map_or(Some(1), |(generation, _)| generation.checked_add(1)) .context("execution generation exhausted")?; @@ -211,79 +399,183 @@ impl SqliteThreadStore { "INSERT INTO execution_runs (thread_id, generation, clean) VALUES (?, ?, 0) ON CONFLICT(thread_id) DO UPDATE SET generation = excluded.generation, clean = 0", ) - .bind(id) + .bind(id.as_str()) .bind(generation) .execute(&mut *tx) .await?; tx.commit().await?; - let lease = Arc::new(ExecutionLease { - thread_id: id.clone(), + let lease = Arc::new(ExecutionLease::new( + id.clone(), generation, - pool: self.pool.clone(), - file: tokio::sync::Mutex::new(Some(file)), - active: AtomicBool::new(true), - mutation_gate: Arc::new(tokio::sync::RwLock::new(())), - mutation_uncertain: AtomicBool::new(false), - }); + self.pool.clone(), + Some(file), + )); self.execution_leases .lock() .map_err(|_| WorkspaceError::ExecutionLeaseRequired)? - .insert(id.clone(), Arc::downgrade(&lease)); + .insert(key, Arc::downgrade(&lease)); Ok(lease) } + /// 本进程登记的这条 identity 的活 owner(精确 id,不沿父链上溯)。 + /// + /// 上溯需要父链,而父链是数据面事实(远端组合里本机没有这条会话的行),因此本函数只回答 + /// 「本进程是否持有**这一条** identity 的租约」。子树归属由调用方按 [`SessionFacts::root`] + /// 显式问 root(见 [`Self::owner_lease`])。 + pub(super) fn registered_lease(&self, id: &ThreadId) -> Result>> { + Ok(self + .execution_leases + .lock() + .map_err(|_| WorkspaceError::ExecutionLeaseRequired)? + .get(id) + .and_then(std::sync::Weak::upgrade)) + } + + /// 找到持有这棵树的活 owner。 + /// + /// `Ok(None)` 只表示这棵树既没有绑定也没有活 owner——那是 legacy/测试路径;有绑定 + /// 而没有活 owner 是 `ExecutionLeaseRequired`,不能当成「无 owner」放行。 + /// + /// 活 owner 只可能挂在 root 上:取得所有权要求这条会话没有父会话(子会话由 root 的租约 + /// 与它的关闭事务统一持有,自己不写 `execution_runs`),所以只查这条会话与 [`SessionFacts::root`] + /// 两处即可。树形事实由调用方从数据面给出,本机不再走自己的 `threads` 父链。 + /// + /// 本函数不取门禁:调用方必须自己决定要读侧还是写侧门禁(见 + /// [`Self::require_execution_lease`] 与 [`Self::exclusive_execution_guard`]), + /// 避免在同一个 async 任务里嵌套两次加锁。 + pub(super) async fn owner_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>> { + if let Some(lease) = self.registered_lease(id)? { + return Ok(Some(lease)); + } + if facts.root != *id { + if let Some(lease) = self.registered_lease(&facts.root)? { + return Ok(Some(lease)); + } + } + if facts.bound { + return Err(WorkspaceError::ExecutionLeaseRequired.into()); + } + Ok(None) + } + + /// 诊断读取:只回答「本进程是否持有这棵树的 owner」,不把「有绑定但无 owner」 + /// 当成错误——那正是可执行(尚未取得所有权)的常态。 + pub(super) async fn live_owner_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>> { + if let Some(lease) = self.registered_lease(id)? { + return Ok(Some(lease)); + } + if facts.root != *id { + return self.registered_lease(&facts.root); + } + Ok(None) + } + + /// 本机执行代际事实(generation, clean);不创建锁文件、不改变状态。 + pub(super) async fn load_execution_state(&self, id: &ThreadId) -> Result> { + Ok( + sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = ?") + .bind(id.as_str()) + .fetch_optional(&self.pool) + .await?, + ) + } + + /// 删除这条 identity 的执行代际行(会话数据已删除时的收尾;删不到是正常情况)。 + /// + /// 本机组合在删数据的同一事务里已经删过(`session_data::delete_tree`),这里是幂等的 + /// 空操作;数据在**远端**的组合靠这一步收敛——远端行消失后本机还留着一条代际行, + /// 那是一条没有对象的行:它会让同名 identity 的重新创建看起来「已有代际」。 + pub(super) async fn delete_execution_state(&self, id: &ThreadId) -> Result<()> { + sqlx::query("DELETE FROM execution_runs WHERE thread_id = ?") + .bind(id.as_str()) + .execute(&self.pool) + .await?; + Ok(()) + } + pub(super) async fn require_execution_lease( &self, id: &ThreadId, + facts: &SessionFacts, ) -> Result> { if self.read_only { return Err(WorkspaceError::ExecutionLeaseRequired.into()); } - let mut current = id.clone(); - let mut visited = std::collections::HashSet::new(); - let mut bound = false; - loop { - if !visited.insert(current.clone()) { - return Err(WorkspaceError::InvalidBinding.into()); - } - // An adopted legacy root can still have unbound children. Their mutations - // belong to the same root owner even though their own binding is absent. - bound |= self.load_session_binding_impl(¤t).await?.is_some(); - let owned = self - .execution_leases - .lock() - .map_err(|_| WorkspaceError::ExecutionLeaseRequired)? - .get(¤t) - .and_then(std::sync::Weak::upgrade); - if let Some(lease) = owned { - let gate = lease.mutation_gate.clone().read_owned().await; - // Close may have won while admission waited behind its write lock. - if !lease.active.load(Ordering::Acquire) { - return Err(WorkspaceError::ExecutionLeaseRequired.into()); - } - if lease.mutation_uncertain.load(Ordering::Acquire) { - return Err(WorkspaceError::RecoveryRequired(RecoveryRequiredDetails { - thread_id: lease.thread_id.clone(), - generation: lease.generation, - }) - .into()); - } - return Ok(Some(ExecutionWriteGuard { - lease, - _gate: gate, - completed: false, - })); - } - let parent: Option<(Option,)> = - sqlx::query_as("SELECT parent_thread_id FROM threads WHERE id = ?") - .bind(¤t) - .fetch_optional(&self.pool) - .await?; - match parent { - Some((Some(parent),)) => current = parent, - _ if bound => return Err(WorkspaceError::ExecutionLeaseRequired.into()), - _ => return Ok(None), - } + let Some(lease) = self.owner_lease(id, facts).await? else { + return Ok(None); + }; + self.write_guard_for(lease).await + } + + /// 读侧写入准入(按已解析的 root 租约):[`Self::owner_lease`] 之后的取门禁一步, + /// 门禁挂在 root 的租约上(子会话与 root 共享同一条门禁)。 + pub(super) async fn write_guard_for( + &self, + lease: Arc, + ) -> Result> { + let gate = lease.mutation_gate.clone().read_owned().await; + // Close may have won while admission waited behind its write lock. + if !lease.active.load(Ordering::Acquire) { + return Err(WorkspaceError::ExecutionLeaseRequired.into()); + } + if lease.mutation_uncertain.load(Ordering::Acquire) { + return Err(WorkspaceError::RecoveryRequired(RecoveryRequiredDetails { + thread_id: lease.thread_id.clone(), + generation: lease.generation, + }) + .into()); + } + Ok(Some(ExecutionWriteGuard { + lease, + _gate: gate, + completed: false, + })) + } + + /// 排他范围:与 [`Self::require_execution_lease`] 相同的准入判定(同一套数据面事实), + /// 但取写侧门禁。 + pub(super) async fn exclusive_execution_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + if self.read_only { + return Err(WorkspaceError::ExecutionLeaseRequired.into()); + } + let Some(lease) = self.owner_lease(id, facts).await? else { + return Ok(None); + }; + self.exclusive_guard_for(lease).await + } + + /// 写侧写入准入(按已解析的 root 租约):同 [`Self::write_guard_for`],取写锁。 + pub(super) async fn exclusive_guard_for( + &self, + lease: Arc, + ) -> Result> { + let gate = lease.mutation_gate.clone().write_owned().await; + if !lease.active.load(Ordering::Acquire) { + return Err(WorkspaceError::ExecutionLeaseRequired.into()); + } + if lease.mutation_uncertain.load(Ordering::Acquire) { + return Err(WorkspaceError::RecoveryRequired(RecoveryRequiredDetails { + thread_id: lease.thread_id.clone(), + generation: lease.generation, + }) + .into()); } + Ok(Some(ExclusiveExecutionGuard { + lease, + _gate: gate, + completed: false, + })) } } diff --git a/peri-resources/src/sessions/sqlite_store/failure.rs b/peri-resources/src/sessions/sqlite_store/failure.rs new file mode 100644 index 000000000..89a775c12 --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/failure.rs @@ -0,0 +1,199 @@ +//! 私有失败映射:`sqlx` / `anyhow` → 领域失败。 +//! +//! 数据面([`super::session_data`])、本机执行面([`super::local`])与门面 +//! ([`crate::sessions::SessionResourcesImpl`])共用同一套分类,避免同一种失败在三处 +//! 各自解释。这里只回答「原因是什么」,不决定效果确定性——那是行为层的判断 +//! (见 `MutationOutcome`)。 +//! +//! 映射规则固定为:会话行缺失是 `NotFound`;本机 workspace 语义原样保留变体; +//! 唯一键冲突是「identity 已存在」;外键失败是绑定关系问题;解码/列形状失败是 +//! 数据读不懂;其余 SQL 失败是「后端暂时不可用」,不冒充「没生效」。 +//! +//! 唯一例外是写事务的提交阶段([`commit_failure`]):一旦进入 `commit()`,错误形状不再 +//! 能回答问题,「没生效」也不能由原因推出,因此固定上报未决持久化。 + +use peri_acp_types::session_resources::{SessionResourceError, SessionResourceErrorKind}; +use peri_acp_types::thread::ThreadId; +use peri_acp_types::workspace::WorkspaceError; + +pub(in crate::sessions) fn invalid_input(detail: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::InvalidInput { + detail: detail.to_owned(), + }) +} + +pub(in crate::sessions) fn corrupt(detail: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Corrupt { + detail: detail.to_owned(), + }) +} + +pub(in crate::sessions) fn unavailable(detail: &str) -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Unavailable { + detail: detail.to_owned(), + }) +} + +pub(in crate::sessions) fn not_found() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::NotFound) +} + +pub(in crate::sessions) fn read_only_store() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::ReadOnlyStore) +} + +/// 「需要一条活的本机执行所有者」这一领域的统一失败。 +pub(in crate::sessions) fn lease_required() -> SessionResourceError { + SessionResourceError::new(SessionResourceErrorKind::Workspace( + WorkspaceError::ExecutionLeaseRequired, + )) +} + +/// SQLx 失败 → 领域失败:只给稳定分类,不把 SQL 文本或驱动消息带出实现。 +pub(in crate::sessions) fn map_sqlx(error: &sqlx::Error) -> SessionResourceError { + match error { + sqlx::Error::RowNotFound => not_found(), + sqlx::Error::Database(database) => { + if database.is_unique_violation() { + invalid_input("session identity already exists") + } else if database.is_foreign_key_violation() { + SessionResourceError::new(SessionResourceErrorKind::Workspace( + WorkspaceError::InvalidBinding, + )) + } else if database.is_check_violation() { + corrupt("stored value violates its column constraints") + } else { + unavailable("session database rejected the write") + } + } + sqlx::Error::Decode(_) + | sqlx::Error::ColumnDecode { .. } + | sqlx::Error::ColumnNotFound(_) => { + corrupt("stored session data does not match its column types") + } + _ => unavailable("session database is unavailable"), + } +} + +/// 写事务进入 `commit()` 之后的失败:不再能证明「未生效」,一律上报未决持久化。 +/// +/// `sqlx` 在提交失败后由连接回滚,但「回滚是否真的完成」不由调用方观察得到;底层错误 +/// 长得像 [`SessionResourceErrorKind::Unavailable`](IO、驱动未分类失败)时尤其不能冒充 +/// 「没写进去」。因此提交阶段不做原因分类,效果固定为 +/// [`MutationOutcome::Unknown`](peri_acp_types::session_resources::MutationOutcome): +/// 写入准入据此不结清范围,由 `Drop` 在租约上留下未决证据,阻断续写与 clean。 +/// +/// 只用于**写事务**的 `commit()`:提交之前的失败(输入非法、约束冲突、可证明的回滚) +/// 仍走 [`write_failure`] / [`map_sqlx`],不得被判成 `Unknown`;只读事务没有写入效果, +/// 也不走这里。 +pub(in crate::sessions) fn commit_failure(thread_id: Option) -> SessionResourceError { + SessionResourceError::persistence_uncertain(thread_id) +} + +/// 读取路径失败映射:会话行缺失是 `NotFound`,其余(含解码、投影、祖先链损坏) +/// 都是「记录在但读不懂」。 +pub(in crate::sessions) fn read_failure(error: anyhow::Error) -> SessionResourceError { + match error.downcast_ref::() { + Some(sql_error) => map_sqlx(sql_error), + None => corrupt("stored session data is not readable"), + } +} + +/// 已经带效果的领域失败优先保留:`Unknown`(提交未决)与 `Applied`(已保存未准入)经 +/// `anyhow` 传播后(compaction 事务、本机执行面),不得再按错误形状降级成「没生效」。 +pub(in crate::sessions) fn preserve_domain_failure( + error: anyhow::Error, +) -> Result { + error.downcast::() +} + +/// `anyhow` 链上是否已存在「未决持久化」的领域失败:桥侧结清写入准入前询问, +/// 避免把提交未决当成已确定效果。 +pub(in crate::sessions) fn is_persistence_uncertain(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(SessionResourceError::is_persistence_uncertain) +} + +/// 写入路径失败映射:领域失败(已带效果)原样保留,绑定形状/关系失败保持 workspace 语义。 +pub(in crate::sessions) fn write_failure(error: anyhow::Error) -> SessionResourceError { + match preserve_domain_failure(error) { + Ok(domain) => domain, + Err(error) => match error.downcast_ref::() { + Some(workspace) => { + SessionResourceError::new(SessionResourceErrorKind::Workspace(workspace.clone())) + } + None => match error.downcast_ref::() { + Some(sql_error) => map_sqlx(sql_error), + None => corrupt("stored session data is not writable"), + }, + }, + } +} + +/// 本机执行面失败映射:领域失败(已带效果)原样保留,workspace 语义同样保留,SQL 失败按 +/// 原因分类,其余(IO、发现探测等)都是「后端暂不可用」,不冒充「没有这条会话」。 +pub(in crate::sessions) fn execution_failure(error: anyhow::Error) -> SessionResourceError { + match preserve_domain_failure(error) { + Ok(domain) => domain, + Err(error) => { + if let Some(workspace) = error.downcast_ref::() { + return SessionResourceError::new(SessionResourceErrorKind::Workspace( + workspace.clone(), + )); + } + match error.downcast_ref::() { + Some(sql_error) => map_sqlx(sql_error), + None => unavailable("local session execution state is unavailable"), + } + } + } +} + +/// 绑定关系复核失败映射:本机登记不存在(`RowNotFound`)是绑定无效,不是「会话不存在」。 +pub(in crate::sessions) fn binding_relation_failure(error: anyhow::Error) -> SessionResourceError { + match error.downcast_ref::() { + Some(sqlx::Error::RowNotFound) => SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::InvalidBinding), + ), + _ => write_failure(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use peri_acp_types::session_resources::MutationOutcome; + + /// 提交阶段漏分类的形态:底层失败(IO / 驱动未分类)经 [`map_sqlx`] 得到 + /// `Unavailable`,效果是 `NotApplied`——那是「没生效」的假证据。写事务提交阶段的 + /// 失败必须改判 `Unknown`,让写入准入保留未决证据并阻断续写与 clean。 + #[test] + fn test_commit_stage_failure_is_unknown_not_a_not_applied_proof() { + let io = sqlx::Error::Io(std::io::Error::other("commit response lost")); + assert_eq!(map_sqlx(&io).effect(), MutationOutcome::NotApplied); + + let error = commit_failure(Some("s-commit".to_owned())); + assert!(error.is_persistence_uncertain()); + assert_eq!(error.effect(), MutationOutcome::Unknown); + assert_eq!(commit_failure(None).effect(), MutationOutcome::Unknown); + } + + /// `anyhow` 传播(compaction 事务、本机执行面)不能把已定效果的领域失败降级: + /// 若 `write_failure` / `execution_failure` 仍按错误形状分类,提交未决会在第二次 + /// 映射里变成 `NotApplied` 并放开写入准入。 + #[test] + fn test_domain_unknown_survives_anyhow_mapping() { + let unknown = || anyhow::Error::new(commit_failure(Some("s-anyhow".to_owned()))); + assert_eq!(write_failure(unknown()).effect(), MutationOutcome::Unknown); + assert!(write_failure(unknown()).is_persistence_uncertain()); + assert_eq!( + execution_failure(unknown()).effect(), + MutationOutcome::Unknown + ); + assert!(is_persistence_uncertain(&unknown())); + assert!(!is_persistence_uncertain(&anyhow::Error::new( + sqlx::Error::RowNotFound + ))); + } +} diff --git a/peri-resources/src/sessions/sqlite_store/legacy_test.rs b/peri-resources/src/sessions/sqlite_store/legacy_test.rs index 726a1b5b9..44961a1cd 100644 --- a/peri-resources/src/sessions/sqlite_store/legacy_test.rs +++ b/peri-resources/src/sessions/sqlite_store/legacy_test.rs @@ -161,7 +161,7 @@ async fn legacy_adoption_failure_rolls_back_binding_and_snapshot() { let store = SqliteThreadStore::new(&path).await.unwrap(); let workspace = store.resolve_workspace(&cwd).await.unwrap(); sqlx::raw_sql("CREATE TRIGGER reject_binding BEFORE INSERT ON session_bindings BEGIN SELECT RAISE(FAIL, 'injected binding failure'); END;") - .execute(&store.pool).await.unwrap(); + .execute(&store.database.pool).await.unwrap(); let error = store .adopt_legacy_thread(&id, cwd.to_str().unwrap(), &workspace, "snapshot") .await @@ -170,7 +170,7 @@ async fn legacy_adoption_failure_rolls_back_binding_and_snapshot() { assert!(store.load_session_binding(&id).await.unwrap().is_none()); assert!(store.load_frozen_snapshot(&id).await.unwrap().is_none()); sqlx::query("DROP TRIGGER reject_binding") - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); store @@ -223,7 +223,7 @@ async fn legacy_adoption_rejects_changed_cwd_child_and_lost_native_binding() { store.update_meta(&id, meta).await.unwrap(); sqlx::query("INSERT INTO execution_runs VALUES (?, 1, 0)") .bind(&id) - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); let error = store diff --git a/peri-resources/src/sessions/sqlite_store/local.rs b/peri-resources/src/sessions/sqlite_store/local.rs new file mode 100644 index 000000000..cf9bd1d42 --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/local.rs @@ -0,0 +1,562 @@ +//! 本机执行面:发现、登记、owner、dirty、创建准入与关闭。 +//! +//! 本模块是「本机事实」的唯一持有者:项目/工作区证据来自发现([`super::discovery`]), +//! 执行所有权来自 `execution_runs` 与 sidecar OS 锁,创建准入把两者与 durable 数据 +//! 按本地事实组合起来。数据面([`super::session_data`])只回答数据事实,不判断 +//! 「这条会话在本机能不能执行」。 +//! +//! 门面([`crate::sessions::SessionResourcesImpl`])是唯一调用方;本类型不导出给 +//! 业务侧,也不提供无 guard 的写入入口。 +//! +//! v10 之后它是 [`LocalExecutionPort`] 的**唯一**实现:远端组合的 canonical 数据在远端, +//! 但执行事实(workspace 证据、执行代际、OS 锁、owner)仍只写在本机库,绑定字节与父链由 +//! 数据端口提供。执行域因此只有一个——按 `thread_id` 原文,没有 store 维度。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Result; +use peri_acp_types::session_resources::{NewSession, SessionResourceError, SessionResourceResult}; +use peri_acp_types::thread::ThreadId; +use peri_acp_types::workspace::{ + RecoveryRequiredDetails, ResolvedWorkspace, SessionBinding, SessionExecutionLease, +}; + +use super::connection::ReadOnlyThreadStoreError; +use super::database::SqliteSessionDatabase; +use super::execution::{ + local_lock_name, ExclusiveExecutionGuard, ExecutionLease, ExecutionWriteGuard, +}; +use super::failure::{ + binding_relation_failure, commit_failure, execution_failure, lease_required, map_sqlx, +}; +use super::session_data::SqliteSessionData; +use super::session_rows::{insert_binding_row, insert_thread_row}; +use crate::sessions::local_port::{LocalExecutionPort, RevokeEffect, SessionFacts}; + +/// 本机执行面的句柄:与数据面共用同一个库(同一条连接真相)。 +#[derive(Clone)] +pub(in crate::sessions) struct LocalExecution { + database: Arc, +} + +impl LocalExecution { + pub(in crate::sessions) async fn open(db_path: impl Into) -> Result { + Ok(Self { + database: Arc::new(SqliteSessionDatabase::open(db_path).await?), + }) + } + + /// 复用既有库句柄:迁移期桥与门面必须指向同一份连接与 owner 登记。 + pub(in crate::sessions) fn from_shared_database(database: Arc) -> Self { + Self { database } + } + + pub(in crate::sessions) async fn open_existing_read_only( + db_path: impl AsRef, + ) -> std::result::Result { + Ok(Self { + database: Arc::new(SqliteSessionDatabase::open_existing_read_only(db_path).await?), + }) + } + + /// 默认数据库位置 `~/.peri/threads/threads.db`;不创建目录、数据库或连接。 + pub(in crate::sessions) fn default_database_path() -> Result { + SqliteSessionDatabase::default_database_path() + } + + /// 数据面句柄:只有门面持有它,业务侧拿不到。 + pub(in crate::sessions) fn data_port(&self) -> SqliteSessionData { + SqliteSessionData::new(Arc::clone(&self.database)) + } + + pub(in crate::sessions) fn is_read_only(&self) -> bool { + self.database.is_read_only() + } + + /// 测试用:直接读库内事实(生产侧由各行为自己的后置条件覆盖)。 + #[cfg(test)] + pub(in crate::sessions) fn pool(&self) -> &sqlx::SqlitePool { + &self.database.pool + } + + // ── 发现与登记 ──────────────────────────────────────────────────────────── + + /// 解析并登记本机执行目录(只读打开时按 `WorkspaceError::ReadOnlyStore` 失败)。 + pub(in crate::sessions) async fn resolve_workspace( + &self, + cwd: &Path, + ) -> Result { + self.database.resolve_workspace_impl(cwd).await + } + + /// 用调用方给出的绑定字节做本机复核(本机组合来自 `session_bindings`,远端组合来自 + /// 远端会话行)。 + /// + /// `full` 为真时叠一次完整发现快照比对(一次准入的权威复核),否则只查关系与关键 + /// 文件对象(准入内的复核)。两种模式走的是同一套判定,因此「能不能执行」与 + /// 「绑定向哪里」不会出现两套结论。 + pub(in crate::sessions) async fn validate_binding_value( + &self, + binding: &SessionBinding, + full: bool, + ) -> Result { + self.database + .validate_binding_value_impl(binding, full) + .await + } + + /// 本进程当前持有的全部活 owner(关闭协调用;不跨进程探测)。 + pub(in crate::sessions) fn live_leases(&self) -> Vec> { + let Ok(map) = self.database.execution_leases.lock() else { + return Vec::new(); + }; + map.values().filter_map(std::sync::Weak::upgrade).collect() + } + + /// 放弃一次未发布创建的所有权并执行补偿。 + /// + /// 传入的 lease 必须是**本进程这条 identity 的活 owner**:撤销会删除执行行, + /// 不能让另一个 owner(或另一条会话的 lease)替它承担补偿。补偿动作由调用方给出 + /// (数据面的撤销行为),本函数只负责准入顺序:关闭准入 → 等待在途写入 → 补偿 → + /// 释放 OS 锁。 + /// + /// 所有权按**精确 identity** 认:撤销的对象是这次创建的那条会话,它的租约就登记在这个 + /// id 上(子会话的写入另走 root 的门禁,不参与撤销)。 + pub(in crate::sessions) async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + revoke: F, + ) -> SessionResourceResult<()> + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + let owned = self + .database + .registered_lease(id) + .map_err(execution_failure)? + .ok_or_else(lease_required)?; + if !same_lease(&owned, lease) { + return Err(lease_required()); + } + owned.abandon_ownership(revoke).await + } + + /// legacy 来源证据:无绑定、无父会话、无执行代际,且保存的绝对 cwd 落在本机已登记 + /// 工作区内。 + /// + /// 这是「这条历史来自本机某个已登记目录」的证据,不是「可以执行」的许可;接纳本身 + /// 仍由数据面在写事务内复核(保存路径一致、登记关系一致、既有绑定只校验不覆盖)。 + pub(in crate::sessions) async fn legacy_confirmed(&self, id: &ThreadId) -> Result { + let row: Option<(String, Option)> = + sqlx::query_as("SELECT cwd, parent_thread_id FROM threads WHERE id = ?1") + .bind(id.as_str()) + .fetch_optional(&self.database.pool) + .await?; + let Some((cwd, parent)) = row else { + return Ok(false); + }; + if parent.is_some() { + return Ok(false); + } + if self.database.load_session_binding_impl(id).await?.is_some() { + return Ok(false); + } + if self.database.load_execution_state(id).await?.is_some() { + return Ok(false); + } + let cwd = PathBuf::from(cwd); + if !cwd.is_absolute() { + return Ok(false); + } + // 两侧是不同时刻写入的字符串:同一目录可能一侧已解析、另一侧仍是符号链接 + // 路径(macOS 的 /var 与 /private/var)。按字面比较会把本机 legacy 误判成 + // 外来会话,因此按文件系统事实比较。 + let Ok(cwd) = cwd.canonicalize() else { + return Ok(false); + }; + let roots: Vec<(String,)> = sqlx::query_as("SELECT root FROM workspaces") + .fetch_all(&self.database.pool) + .await?; + Ok(roots.iter().any(|(root,)| { + Path::new(root) + .canonicalize() + .map(|root| cwd.starts_with(root)) + .unwrap_or(false) + })) + } + + // ── owner / dirty ───────────────────────────────────────────────────────── + + /// 沿 root 关系找到活 owner;`None` 表示这棵树既无绑定也无 owner。 + pub(in crate::sessions) async fn owner_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>> { + self.database.owner_lease(id, facts).await + } + + /// 诊断读取:本进程是否持有这棵树的 owner(没有不构成错误)。 + pub(in crate::sessions) async fn live_owner( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>> { + self.database.live_owner_lease(id, facts).await + } + + /// 本机执行代际事实;不创建锁文件。 + pub(in crate::sessions) async fn execution_state( + &self, + id: &ThreadId, + ) -> Result> { + self.database.load_execution_state(id).await + } + + /// 读侧写入准入(允许同 root 并发 mutation)。 + pub(in crate::sessions) async fn write_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + self.database.require_execution_lease(id, facts).await + } + + /// 写侧写入准入(把「检查 + 写入」做成不可插入的区间)。 + pub(in crate::sessions) async fn exclusive_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + self.database.exclusive_execution_guard(id, facts).await + } + + /// 取得已有会话的执行所有权。 + pub(in crate::sessions) async fn acquire_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + self.database.acquire_execution_lease_impl(id, facts).await + } + + /// 解除精确代际的 dirty(CAS 在实现内部,不跨接口传递)。 + pub(in crate::sessions) async fn reset_dirty( + &self, + target: &RecoveryRequiredDetails, + ) -> Result<()> { + self.database.reset_dirty_execution_impl(target).await + } + + // ── 删除后的收尾 ───────────────────────────────────────────────────────── + + /// 会话数据已被删除:结束这条 identity 的本机执行事实与所有权。 + /// + /// 顺序是「先抹掉代际行,再放所有权」:抹掉之后即使本进程崩溃,剩下的也只是锁文件 + /// (进程退出即释放),不会留下一条描述不存在会话的代际行。没有活 owner 不是错误 + /// ——删除可能是由另一个仍持有 owner 的调用方完成的,本机这时没有可结束的所有权。 + /// + /// 所有权按**精确 identity** 认([`SqliteSessionDatabase::registered_lease`]):数据已经被删, + /// 这条会话的父链此刻无从解析,能证明的只有「本进程持有这条 identity 的租约」。因此删除 + /// 一棵**子树**只结束这棵子树里本进程持有的所有权,不会连带结束它 root 的所有权。 + pub(in crate::sessions) async fn dispose_execution( + &self, + id: &ThreadId, + ) -> SessionResourceResult<()> { + if self.database.is_read_only() { + // 只读打开不会取得 owner,也没有删除路径能走到这里(门面在写入准入就拒绝)。 + // 这里不写库也不改状态:本机执行事实不归只读的一次打开处置。 + return Ok(()); + } + self.database + .delete_execution_state(id) + .await + .map_err(execution_failure)?; + if let Some(lease) = self + .database + .registered_lease(id) + .map_err(execution_failure)? + { + lease.dispose_ownership().await; + } + Ok(()) + } + + // ── 创建准入(本地塌缩) ─────────────────────────────────────────────────── + + /// 新建会话:OS 预留 → 完整数据与执行代际同一事务 → 返回 owner。 + /// + /// 本地同库让 `threads` → `session_bindings` → frozen → `execution_runs` 落在同一个 + /// `BEGIN IMMEDIATE` 里,因此创建意图、数据保存与执行准入在本地是**一次提交**: + /// 事务失败则什么都没保存(锁文件句柄随返回值释放),提交则数据完整且已有一个 + /// 未结清的执行代际(`clean = 0`),进程在提交后崩溃也只是普通 dirty,恢复依据是 + /// 完整数据加代际本身,不需要重造 binding/frozen。 + pub(in crate::sessions) async fn create_with_lease( + &self, + input: &NewSession, + ) -> SessionResourceResult> { + if self.database.is_read_only() { + return Err(super::failure::read_only_store()); + } + // 先占稳定 OS 锁:同一 identity 的创建与他处执行互斥,且在写库之前就排除。 + let file = self + .database + .lock_execution(&local_lock_name(&input.thread_id)) + .await + .map_err(execution_failure)?; + let snapshot_at = input + .meta + .snapshot_at_message_id + .map(|id| id.as_uuid().to_string()); + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + // 绑定关系与关键文件对象在写事务内复核:未登记的 project/workspace 给出 + // workspace 语义的失败,而不是留到最后变成外键错误。 + let resolved = SqliteSessionDatabase::validate_binding_relation_on(&mut tx, &input.binding) + .await + .map_err(binding_relation_failure)?; + // `threads.cwd` 以已复核的绑定为准:同一份事实只有一个来源,调用方给的 cwd + // 不再构成第二个真相。 + let binding_cwd = + super::discovery::path_text(&resolved.cwd).map_err(super::failure::write_failure)?; + let mut row = super::session_data::new_session_row( + input, + snapshot_at.as_deref(), + Some(input.frozen.as_str()), + 0, + ); + row.cwd = binding_cwd; + insert_thread_row(&mut tx, &row) + .await + .map_err(super::failure::write_failure)?; + insert_binding_row(&mut tx, &input.thread_id, &input.binding) + .await + .map_err(super::failure::write_failure)?; + sqlx::query("INSERT INTO execution_runs (thread_id, generation, clean) VALUES (?1, 1, 0)") + .bind(input.thread_id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(input.thread_id.clone())))?; + self.register_lease(input.thread_id.clone(), 1, Some(file)) + } + + /// 为一个已保存完整数据、但还没有执行代际的会话建立准入(收敛,不是重建)。 + /// + /// 只用于 [`Self::create_with_lease`] 之外留下的 `data_saved` 状态:数据面已确认 + /// 保存完整(远程保存、或进程在准入前结束),此时不能重造 binding/frozen,也不能 + /// 报「确定未创建」。 + /// + /// **绑定字节由调用方给出**(来自数据端口:本机组合是本机 `session_bindings` 行, + /// 远程组合是远端会话行)。本机只做本机的事:执行代际的唯一性、sidecar 锁、以及 + /// 「这组绑定字节指向本机已登记的 workspace」这一条复核——canonical 会话是否存在 + /// 由数据面回答,本机不查 `threads`/`session_bindings`(远程组合里它们本来就没有这 + /// 条会话的行)。 + pub(in crate::sessions) async fn admit_existing( + &self, + id: &ThreadId, + binding: &SessionBinding, + ) -> SessionResourceResult> { + if self.database.is_read_only() { + return Err(super::failure::read_only_store()); + } + let existing = self + .database + .load_execution_state(id) + .await + .map_err(execution_failure)?; + if existing.is_some() { + // 已有代际的会话走正常取得所有权路径(含 dirty 判定),不在这里插队。 + return Err(super::failure::invalid_input( + "session already has an execution generation", + )); + } + let file = self + .database + .lock_execution(&local_lock_name(id)) + .await + .map_err(execution_failure)?; + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + // 业务前提必须仍然成立:绑定关系与关键对象在准入这一刻复核。 + SqliteSessionDatabase::validate_binding_relation_on(&mut tx, binding) + .await + .map_err(binding_relation_failure)?; + sqlx::query("INSERT INTO execution_runs (thread_id, generation, clean) VALUES (?1, 1, 0)") + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + self.register_lease(id.clone(), 1, Some(file)) + } + + /// 登记租约并返回:`ExecutionLease` 的持有者是调用方,库里只留弱引用用于复核准入。 + fn register_lease( + &self, + id: ThreadId, + generation: i64, + file: Option, + ) -> SessionResourceResult> { + let key = id.clone(); + let lease = Arc::new(ExecutionLease::new( + id.clone(), + generation, + self.database.pool.clone(), + file, + )); + self.database + .execution_leases + .lock() + .map_err(|_| lease_registration_failure(&id))? + .insert(key, Arc::downgrade(&lease)); + Ok(lease) + } +} + +/// 登记失败时数据已经提交:这条会话存在且有一个未结清的执行代际,只是本次没能拿到 +/// owner。这个结果必须按「已保存、未准入」上报,不能谎称「没有生效」。 +fn lease_registration_failure(id: &ThreadId) -> SessionResourceError { + SessionResourceError::saved_but_not_admitted(id.clone()) +} + +/// 传入的 `Arc` 是否就是本进程登记的那条租约。 +/// +/// 比较的是同一个分配对象的地址:`Arc` 与 `Arc` 互转只能靠地址, +/// 而这里要回答的正是「是不是同一次所有权」,不是「是不是同一个会话」。 +pub(in crate::sessions) fn same_lease( + owned: &Arc, + lease: &Arc, +) -> bool { + std::ptr::eq( + Arc::as_ptr(owned) as *const (), + Arc::as_ptr(lease) as *const (), + ) +} + +/// 本机执行面对门面的行为:全部委托到本文件的方法。 +/// +/// 这层转发不改变任何语义(同一份 SQL、同一套锁与代际),它的存在只是让门面按端口调用, +/// 从而与远程组合共用一套公开行为。 +#[async_trait::async_trait] +impl LocalExecutionPort for LocalExecution { + fn is_read_only(&self) -> bool { + self.is_read_only() + } + + async fn resolve_workspace(&self, cwd: &Path) -> Result { + self.resolve_workspace(cwd).await + } + + async fn validate_binding_value( + &self, + binding: &SessionBinding, + full: bool, + ) -> Result { + self.validate_binding_value(binding, full).await + } + + async fn legacy_confirmed(&self, id: &ThreadId) -> Result { + self.legacy_confirmed(id).await + } + + async fn execution_state(&self, id: &ThreadId) -> Result> { + self.execution_state(id).await + } + + async fn owner_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>> { + self.owner_lease(id, facts).await + } + + async fn dispose_execution(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.dispose_execution(id).await + } + + async fn live_owner( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result>> { + self.live_owner(id, facts).await + } + + fn live_leases(&self) -> Vec> { + self.live_leases() + } + + async fn write_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + self.write_guard(id, facts).await + } + + async fn exclusive_guard( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + self.exclusive_guard(id, facts).await + } + + async fn acquire_lease( + &self, + id: &ThreadId, + facts: &SessionFacts, + ) -> Result> { + self.acquire_lease(id, facts).await + } + + async fn reset_dirty(&self, target: &RecoveryRequiredDetails) -> Result<()> { + self.reset_dirty(target).await + } + + async fn create_session( + &self, + input: &NewSession, + ) -> SessionResourceResult> { + self.create_with_lease(input).await + } + + async fn admit_existing( + &self, + id: &ThreadId, + binding: &SessionBinding, + ) -> SessionResourceResult> { + self.admit_existing(id, binding).await + } + + async fn abandon_initialization( + &self, + id: &ThreadId, + lease: &Arc, + revoke: RevokeEffect<'_>, + ) -> SessionResourceResult<()> { + self.abandon_initialization(id, lease, move || revoke).await + } + + #[cfg(test)] + fn sqlite_pool(&self) -> Option<&sqlx::SqlitePool> { + Some(self.pool()) + } +} diff --git a/peri-resources/src/sessions/sqlite_store/row_mapping.rs b/peri-resources/src/sessions/sqlite_store/row_mapping.rs index f335ab870..8cb8cafd3 100644 --- a/peri-resources/src/sessions/sqlite_store/row_mapping.rs +++ b/peri-resources/src/sessions/sqlite_store/row_mapping.rs @@ -40,7 +40,7 @@ pub(super) type ThreadRow = ( // ── 辅助函数 ────────────────────────────────────────────────────────────────── -pub(super) fn role_of(msg: &BaseMessage) -> &'static str { +pub(in crate::sessions) fn role_of(msg: &BaseMessage) -> &'static str { match msg { BaseMessage::Human { .. } => "user", BaseMessage::Ai { .. } => "assistant", @@ -92,8 +92,10 @@ pub(super) fn meta_from_row( }) } -/// 从消息列表中提取标题(取第一条 Human 消息的前 50 字符) -pub(super) fn extract_title(msgs: &[BaseMessage]) -> Option { +/// 从消息列表中提取标题(取第一条 Human 消息的前 50 字符)。 +/// +/// 这是**领域纯规则**:两个 adapter 共用同一份实现,不在远端复制一遍。 +pub(crate) fn extract_title(msgs: &[BaseMessage]) -> Option { use peri_acp_types::messages::{ContentBlock, MessageContent}; for msg in msgs { if let BaseMessage::Human { content, .. } = msg { diff --git a/peri-resources/src/sessions/sqlite_store/schema.rs b/peri-resources/src/sessions/sqlite_store/schema.rs index de70695a5..74eec95bb 100644 --- a/peri-resources/src/sessions/sqlite_store/schema.rs +++ b/peri-resources/src/sessions/sqlite_store/schema.rs @@ -1,14 +1,17 @@ -//! 单库 schema 升级:保留历史与执行状态,事务内调整结构。 +//! 单库 schema 升级:保留历史、执行状态与生命周期锚点,事务内调整结构。 +use super::database::SqliteSessionDatabase; +#[cfg(test)] use super::SqliteThreadStore; +use crate::sessions::canonical::{self, CREATE_INDEXES, CREATE_TABLES}; use anyhow::Result; use peri_acp_types::workspace::WorkspaceError; use sqlx::{AssertSqlSafe, Connection, SqliteConnection}; use std::collections::HashSet; -/// 本构建写入并接受的 schema 版本;2..5 经升级路径收敛到此值,0 视为待建库。 +/// 本构建写入并接受的 schema 版本;2..9 经升级路径收敛到此值,0 视为待建库。 /// 版本接受判定、迁移收尾写入与拒绝时的「本构建上限」都由它派生,避免三处各写一份。 -pub(super) const CURRENT_SCHEMA_VERSION: i64 = 6; +pub(in crate::sessions) const CURRENT_SCHEMA_VERSION: i64 = 10; #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum SchemaState { @@ -18,6 +21,10 @@ pub(super) enum SchemaState { Version3, Version4, Version5, + Version6, + Version7, + Version8, + Version9, Current, } @@ -37,6 +44,10 @@ pub(super) async fn inspect(connection: &mut SqliteConnection) -> Result return Ok(SchemaState::Current), + 9 => return Ok(SchemaState::Version9), + 8 => return Ok(SchemaState::Version8), + 7 => return Ok(SchemaState::Version7), + 6 => return Ok(SchemaState::Version6), 5 => return Ok(SchemaState::Version5), 4 => return Ok(SchemaState::Version4), 3 => return Ok(SchemaState::Version3), @@ -91,6 +102,28 @@ pub(super) async fn inspect(connection: &mut SqliteConnection) -> Result Result<()> { + for table in canonical::CANONICAL_TABLES { + let existing: Option<(String,)> = + sqlx::query_as("SELECT type FROM sqlite_schema WHERE name = ?1") + .bind(table) + .fetch_optional(&mut *connection) + .await?; + if let Some((actual_type,)) = existing { + if actual_type != "table" { + anyhow::bail!("schema upgrade blocked: {table} already exists as a {actual_type}"); + } + } + } + Ok(()) +} + async fn column_names(connection: &mut SqliteConnection, table: &str) -> Result> { let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info(?)") .bind(table) @@ -99,7 +132,7 @@ async fn column_names(connection: &mut SqliteConnection, table: &str) -> Result< Ok(rows.into_iter().map(|(name,)| name).collect()) } -impl SqliteThreadStore { +impl SqliteSessionDatabase { /// DDL 与版本号在同一事务中提交;不回填历史 SessionBinding。 pub(super) async fn init_schema(&self) -> Result<()> { let mut connection = self.pool.acquire().await?; @@ -139,17 +172,16 @@ impl SqliteThreadStore { .execute(&mut *tx) .await?; } else if matches!(state, SchemaState::Empty | SchemaState::Legacy) { - sqlx::raw_sql( - "CREATE TABLE IF NOT EXISTS threads ( - id TEXT PRIMARY KEY, title TEXT, cwd TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, message_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS messages ( - message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, - role TEXT NOT NULL, content TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id);" - ).execute(&mut *tx).await?; + // 建表语句来自 `sessions::canonical`:本机新库与远端初始化下发的是同一份清单 + // (逐条执行,两边执行器的语句单元相同),形状不可能各自漂移。旧库(表已存在) + // 在这里是空操作,列由下面的补列循环补齐。 + ensure_canonical_names_hold_tables(&mut tx).await?; + // 标识符与列定义均来自 `canonical` 的静态清单,不含外部输入。 + for statement in CREATE_TABLES { + sqlx::raw_sql(AssertSqlSafe((*statement).to_owned())) + .execute(&mut *tx) + .await?; + } // 新库与逐步升级的旧库使用同一列定义,已有列及其值保持原样。 for (table, columns) in [ ( @@ -188,30 +220,12 @@ impl SqliteThreadStore { } } } - sqlx::raw_sql( - "CREATE TABLE projects ( - id TEXT PRIMARY KEY, locator TEXT NOT NULL, object_identity TEXT NOT NULL, - UNIQUE(locator, object_identity) - ); - CREATE TABLE workspaces ( - id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), - root TEXT NOT NULL, root_identity TEXT NOT NULL, discovery TEXT NOT NULL, - UNIQUE(root, root_identity), UNIQUE(id, project_id) - ); - CREATE TABLE session_bindings ( - thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, - schema_version INTEGER NOT NULL, - project_id TEXT NOT NULL, workspace_id TEXT NOT NULL, relative_cwd TEXT NOT NULL, - FOREIGN KEY(workspace_id, project_id) REFERENCES workspaces(id, project_id) - ); - CREATE INDEX idx_bindings_project ON session_bindings(project_id, thread_id); - CREATE INDEX idx_bindings_workspace ON session_bindings(workspace_id, relative_cwd, thread_id); - CREATE INDEX idx_threads_updated ON threads(updated_at DESC, id DESC) WHERE hidden = 0 AND message_count > 0; - CREATE TABLE execution_runs ( - thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, - generation INTEGER NOT NULL, clean BOOLEAN NOT NULL - );" - ).execute(&mut *tx).await?; + // 索引在建表与补列之后:`idx_threads_updated` 引用后补的列。 + for statement in CREATE_INDEXES { + sqlx::raw_sql(AssertSqlSafe((*statement).to_owned())) + .execute(&mut *tx) + .await?; + } } if matches!(state, SchemaState::Version2 | SchemaState::Version3) { migrate_identity_values(&mut tx).await?; @@ -232,6 +246,14 @@ impl SqliteThreadStore { .into()); } } + // 执行代际表:v6 及其以前带 `threads` 外键,v7 起去掉——远程模式下本机不存在 + // `threads` 行,级联删除会把另一台机器持有的执行代际抹掉。对已升级的库这只是一次 + // 形状校验,行内容一字不改(`execution_runs` 里的行按 `thread_id` 原文归属, + // 不再有 store 维度)。 + ensure_execution_runs_without_foreign_key(&mut tx).await?; + // v10 回退:删除 v7..v9 写下的本机远程痕迹(本机登记、未决锚点、远端操作日志、 + // 按 store 分区的执行域)。对没有这些表的库是幂等的。 + drop_remote_local_state(&mut tx).await?; sqlx::query(AssertSqlSafe(format!( "PRAGMA user_version = {CURRENT_SCHEMA_VERSION}" ))) @@ -242,6 +264,188 @@ impl SqliteThreadStore { } } +/// v10 回退:v7..v9 写下的本机远程痕迹一次性删除。 +/// +/// 用户裁决撤销了「远程存储在本机留有痕迹」的整套能力(只做初始化时的切换,不做交叉 +/// 能力),因此这五张表连同它们的语义一起消失,本地表结构回到 remote 工作之前:不加表、 +/// 不加列、没有 store 维度。表清单与建表时的列形状一一对应: +/// +/// | 表 | 原来承载的事实 | +/// | --- | --- | +/// | `session_store_registrations` | 本机对远端 store 的登记(执行准入依据) | +/// | `session_lifecycle_commitments` | 本机未决写 / 删除墓碑锚点 | +/// | `session_remote_operations` | 远端操作日志(发送前登记与结清) | +/// | `remote_execution_runs` | 按 `(store, root)` 分区的执行代际 | +/// | `remote_lifecycle_commitments` | 按 `(store, thread_id)` 分区的删除锚点 | +/// +/// **删除前先校验形状**:表存在但列不符说明它不是本模块建的表(同名的别的业务表), +/// 此时**不删**并拒绝升级(fail-closed,整个事务回滚),而不是把不认识的数据丢掉。 +/// 表不存在时跳过,因此对没有这些表的库是幂等的。 +/// +/// 不触碰 `threads` / `messages` / `session_bindings` / `workspaces` / `projects` 与 +/// `execution_runs` 的任何行。`execution_runs` 里可能残留远程会话的执行代际行(本机 +/// `threads` 里没有对应行)——按用户裁决它们是**有效事实**(执行代际仍按 `thread_id` +/// 单键存放,唯一执行域),既不删除也不改写。 +async fn drop_remote_local_state(connection: &mut SqliteConnection) -> Result<()> { + for (table, columns) in DROPPED_LOCAL_TABLES { + if !table_exists(connection, table).await? { + continue; + } + require_columns(connection, table, columns).await?; + // 标识符来自下方静态清单,未包含外部输入。 + sqlx::query(AssertSqlSafe(format!("DROP TABLE {table}"))) + .execute(&mut *connection) + .await?; + } + Ok(()) +} + +/// v10 删除的本机表及其建表时的列形状。 +/// +/// 清单只服务一次回退迁移:v10 跑完这些表不复存在,也没有任何写入路径再引用它们, +/// 因此常量本身可以在下一个 schema 版本里一并删除。 +const DROPPED_LOCAL_TABLES: &[(&str, &[&str])] = &[ + ( + "session_store_registrations", + &[ + "store_id", + "engine", + "locator_digest", + "installation_id", + "created_at", + ], + ), + ( + "session_lifecycle_commitments", + &[ + "thread_id", + "root_id", + "kind", + "state", + "generation", + "operation_id", + "detail", + "created_at", + "updated_at", + ], + ), + ( + "session_remote_operations", + &[ + "operation_id", + "store_id", + "thread_id", + "root_id", + "behavior", + "digest", + "state", + "created_at", + "updated_at", + ], + ), + ( + "remote_execution_runs", + &["store_id", "root_id", "generation", "clean"], + ), + ( + "remote_lifecycle_commitments", + &[ + "store_id", + "thread_id", + "kind", + "state", + "created_at", + "updated_at", + ], + ), +]; + +/// `execution_runs` 收敛到 v7 形状:存在但不带 `threads` 外键。 +/// +/// 新库与 legacy 库直接按目标形状创建;已有带外键的表逐行复制后重建。重建只在 +/// `execution_runs` 自己的子表上进行,不需要关闭外键强制。 +async fn ensure_execution_runs_without_foreign_key( + connection: &mut SqliteConnection, +) -> Result<()> { + if !table_exists(connection, "execution_runs").await? { + create_execution_runs(connection).await?; + return Ok(()); + } + require_columns( + connection, + "execution_runs", + &["thread_id", "generation", "clean"], + ) + .await?; + let foreign_tables: Vec<(String,)> = + sqlx::query_as("SELECT DISTINCT \"table\" FROM pragma_foreign_key_list('execution_runs')") + .fetch_all(&mut *connection) + .await?; + if !foreign_tables.iter().any(|(table,)| table == "threads") { + return Ok(()); + } + let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM execution_runs") + .fetch_one(&mut *connection) + .await?; + sqlx::raw_sql( + "CREATE TABLE execution_runs_local ( + thread_id TEXT PRIMARY KEY, + generation INTEGER NOT NULL, + clean BOOLEAN NOT NULL + ); + INSERT INTO execution_runs_local (thread_id, generation, clean) + SELECT thread_id, generation, clean FROM execution_runs; + DROP TABLE execution_runs; + ALTER TABLE execution_runs_local RENAME TO execution_runs;", + ) + .execute(&mut *connection) + .await?; + let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM execution_runs") + .fetch_one(&mut *connection) + .await?; + if before != after { + return Err(WorkspaceError::DiscoveryError( + "execution state rows changed during schema migration".into(), + ) + .into()); + } + Ok(()) +} + +async fn create_execution_runs(connection: &mut SqliteConnection) -> Result<()> { + sqlx::raw_sql( + "CREATE TABLE execution_runs ( + thread_id TEXT PRIMARY KEY, + generation INTEGER NOT NULL, + clean BOOLEAN NOT NULL + );", + ) + .execute(&mut *connection) + .await?; + Ok(()) +} + +async fn table_exists(connection: &mut SqliteConnection, table: &str) -> Result { + let row: Option<(String,)> = + sqlx::query_as("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .bind(table) + .fetch_optional(&mut *connection) + .await?; + Ok(row.is_some()) +} + +async fn require_columns( + connection: &mut SqliteConnection, + table: &str, + required: &[&str], +) -> Result<()> { + let actual = column_names(connection, table).await?; + if !required.iter().all(|column| actual.contains(*column)) { + return Err(WorkspaceError::UnsupportedDatabaseSchema.into()); + } + Ok(()) +} + /// schema 5:登记键从单列唯一放宽为 (定位路径, 文件对象证据) 组合。 /// /// 目录被替换(同路径的新文件对象)或换位(同一对象的新路径)是正常演进:它们 diff --git a/peri-resources/src/sessions/sqlite_store/schema_test.rs b/peri-resources/src/sessions/sqlite_store/schema_test.rs index 46ac3cca8..2978ef70f 100644 --- a/peri-resources/src/sessions/sqlite_store/schema_test.rs +++ b/peri-resources/src/sessions/sqlite_store/schema_test.rs @@ -1,11 +1,12 @@ use super::*; +use peri_acp_types::session_resources::SessionResources; use peri_acp_types::{ messages::BaseMessage, store::{serialize_persisted_payload, PersistedPayload, ThreadStore}, thread::ThreadMeta, workspace::{ScopedThreadQuery, ThreadScope}, }; -use sqlx::{sqlite::SqliteConnectOptions, Connection}; +use sqlx::{sqlite::SqliteConnectOptions, AssertSqlSafe, Connection}; use std::path::Path; /// [回归测试] 实际旧库包含 thread_goals,不能因额外业务表而拒绝启动。 @@ -46,10 +47,9 @@ async fn test_legacy_with_goals_upgrades_and_preserves_auxiliary_data() { ).fetch_all(&mut connection).await.unwrap(); connection.close().await.unwrap(); // 调用应用启动所用的 Resources 门面,复现相同的写打开入口。 - let resources = crate::Resources::open_with(Some(path.clone())) + let (store, _facade) = crate::sessions::open_store_and_facade_for_tests(path.clone()) .await .unwrap(); - let store = resources.thread_store(); assert_eq!( store .load_meta(&"old-session".to_owned()) @@ -228,10 +228,10 @@ async fn test_single_database_upgrade_preserves_history_and_binds_only_new_sessi ); assert_eq!(reopened.load_meta(&old_id).await.unwrap().cwd, old.cwd); let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") - .fetch_one(&reopened.pool) + .fetch_one(&reopened.database.pool) .await .unwrap(); - assert_eq!(version, 6); + assert_eq!(version, CURRENT_SCHEMA_VERSION); reopened.close().await; let reader = SqliteThreadStore::open_existing_read_only(&path) .await @@ -269,7 +269,7 @@ async fn test_single_database_upgrade_preserves_all_existing_columns_and_context let before = history_bytes(&mut connection).await; connection.close().await.unwrap(); let store = SqliteThreadStore::new(&path).await.unwrap(); - let after = history_bytes(&mut store.pool.acquire().await.unwrap()).await; + let after = history_bytes(&mut store.database.pool.acquire().await.unwrap()).await; assert_eq!( after, before, "所有原始列值(包括不解码的上下文)必须保持原样" @@ -344,7 +344,9 @@ async fn test_single_database_future_version_is_rejected_before_writing() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("threads.db"); let mut connection = legacy_database(&path).await; - sqlx::query("PRAGMA user_version = 7") + // 比本构建更新一代:写打开必须拒绝,且不猜列形状、不降级写。 + let future = CURRENT_SCHEMA_VERSION + 1; + sqlx::query(AssertSqlSafe(format!("PRAGMA user_version = {future}"))) .execute(&mut connection) .await .unwrap(); @@ -354,13 +356,13 @@ async fn test_single_database_future_version_is_rejected_before_writing() { assert!(matches!( error.downcast_ref::(), Some(WorkspaceError::UnsupportedSchemaVersion { - found: 7, + found, supported: CURRENT_SCHEMA_VERSION, - }) + }) if *found == future )); // 拒绝理由必须可追溯:报错要复述实际版本与本构建上限,否则用户只知道「不支持」。 let message = error.to_string(); - assert!(message.contains("version 7"), "{message}"); + assert!(message.contains(&format!("version {future}")), "{message}"); assert!( message.contains(&format!("newest supported: {CURRENT_SCHEMA_VERSION}")), "{message}" @@ -388,7 +390,7 @@ async fn test_single_database_concurrent_upgrade_is_idempotent() { 1 ); let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM session_bindings") - .fetch_one(&store.pool) + .fetch_one(&store.database.pool) .await .unwrap(); assert_eq!(count, 0); @@ -445,7 +447,7 @@ async fn test_single_database_default_writer_child_process() { }; let store = SqliteThreadStore::default_path().await.unwrap(); assert_eq!( - store.db_path, + store.database.db_path, std::fs::canonicalize(Path::new(&home).join(".peri/threads/threads.db")).unwrap() ); assert_eq!( @@ -457,12 +459,13 @@ async fn test_single_database_default_writer_child_process() { "/old/worktree" ); store.close().await; - let reader = crate::sessions::open_thread_store_read_only(None) + // 只读命令面(meta 子命令)的生产入口:只读门面,不创建目录/库/schema。 + let reader = crate::sessions::open_session_resources_read_only(None) .await .unwrap(); assert_eq!( reader - .load_meta(&"old-session".to_owned()) + .load_session_meta(&"old-session".to_owned()) .await .unwrap() .title @@ -605,7 +608,7 @@ async fn test_schema3_identity_migration_reuses_binding_and_survives_reopen() { let execution: (i64, bool) = sqlx::query_as( "SELECT generation, clean FROM execution_runs WHERE thread_id = 'old-session'", ) - .fetch_one(&store.pool) + .fetch_one(&store.database.pool) .await .unwrap(); assert_eq!(execution, (7, false)); @@ -690,7 +693,7 @@ async fn test_version2_upgrade_removes_required_revision_and_preserves_execution connection.close().await.unwrap(); let store = SqliteThreadStore::new(&path).await.unwrap(); - let mut connection = store.pool.acquire().await.unwrap(); + let mut connection = store.database.pool.acquire().await.unwrap(); assert_eq!(history_bytes(&mut connection).await, before_history); let migrated_identity = identity_and_execution_bytes(&mut connection).await; assert!(migrated_identity @@ -704,7 +707,7 @@ async fn test_version2_upgrade_removes_required_revision_and_preserves_execution .fetch_one(&mut *connection) .await .unwrap(); - assert_eq!(version, 6); + assert_eq!(version, CURRENT_SCHEMA_VERSION); drop(connection); let old = store @@ -918,10 +921,10 @@ async fn test_version4_upgrade_relaxes_registration_keys_and_preserves_rows() { let store = SqliteThreadStore::new(&path).await.unwrap(); let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") - .fetch_one(&store.pool) + .fetch_one(&store.database.pool) .await .unwrap(); - assert_eq!(version, 6); + assert_eq!(version, CURRENT_SCHEMA_VERSION); // 原有登记、绑定与历史原样可用。 let workspace = store.resolve_workspace(dir.path()).await.unwrap(); @@ -978,7 +981,7 @@ async fn test_version4_upgrade_relaxes_registration_keys_and_preserves_rows() { SELECT '33333333-3333-4333-8333-333333333333', project_id, root, '{\"device\":9,\"inode\":9}', discovery FROM workspaces WHERE id = '22222222-2222-4222-8222-222222222222'", ) - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); let duplicate_workspace = sqlx::query( @@ -986,7 +989,7 @@ async fn test_version4_upgrade_relaxes_registration_keys_and_preserves_rows() { SELECT '44444444-4444-4444-8444-444444444444', project_id, root, root_identity, discovery FROM workspaces WHERE id = '22222222-2222-4222-8222-222222222222'", ) - .execute(&store.pool) + .execute(&store.database.pool) .await; assert!( duplicate_workspace.is_err(), @@ -997,7 +1000,7 @@ async fn test_version4_upgrade_relaxes_registration_keys_and_preserves_rows() { "INSERT INTO projects (id, locator, object_identity) SELECT '55555555-5555-4555-8555-555555555555', locator, '{\"device\":10,\"inode\":10}' FROM projects", ) - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); let duplicate_project = sqlx::query( @@ -1005,7 +1008,7 @@ async fn test_version4_upgrade_relaxes_registration_keys_and_preserves_rows() { SELECT '66666666-6666-4666-8666-666666666666', locator, object_identity FROM projects WHERE id = '11111111-1111-4111-8111-111111111111'", ) - .execute(&store.pool) + .execute(&store.database.pool) .await; assert!( duplicate_project.is_err(), @@ -1017,7 +1020,7 @@ async fn test_version4_upgrade_relaxes_registration_keys_and_preserves_rows() { SELECT '77777777-7777-4777-8777-777777777777', 'missing-project', root || '-orphan', root_identity, discovery FROM workspaces WHERE id = '22222222-2222-4222-8222-222222222222'", ) - .execute(&store.pool) + .execute(&store.database.pool) .await; assert!(orphan.is_err(), "工作区必须仍受 projects 外键约束"); store.close().await; @@ -1098,7 +1101,7 @@ async fn assert_registration_upgrade_allows_directory_changes(version: i64) { ), "原路径被替换后旧会话必须拒绝执行:{error}" ); - let mut connection = store.pool.acquire().await.unwrap(); + let mut connection = store.database.pool.acquire().await.unwrap(); // 精确读取旧会话,不以新增线程的插入顺序推断目标。 assert_eq!(history_bytes(&mut connection).await, old_history); let after = identity_and_execution_bytes(&mut connection).await; @@ -1116,7 +1119,7 @@ async fn assert_registration_upgrade_allows_directory_changes(version: i64) { .fetch_one(&mut *connection) .await .unwrap(); - assert_eq!(current, 6); + assert_eq!(current, CURRENT_SCHEMA_VERSION); drop(connection); store.close().await; } @@ -1189,14 +1192,14 @@ async fn test_registration_upgrade_preserves_healthy_v5_composite_registrations( // 首次开库升级,第二次开库保持同一结果;不访问 fixture 中不存在的旧目录。 for _ in 0..2 { let store = SqliteThreadStore::new(&path).await.unwrap(); - let mut connection = store.pool.acquire().await.unwrap(); + let mut connection = store.database.pool.acquire().await.unwrap(); assert_eq!(identity_and_execution_bytes(&mut connection).await, before); assert_eq!(history_bytes(&mut connection).await, history); let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") .fetch_one(&mut *connection) .await .unwrap(); - assert_eq!(version, 6); + assert_eq!(version, CURRENT_SCHEMA_VERSION); drop(connection); store.close().await; } diff --git a/peri-resources/src/sessions/sqlite_store/schema_v10_test.rs b/peri-resources/src/sessions/sqlite_store/schema_v10_test.rs new file mode 100644 index 000000000..76dc6a472 --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/schema_v10_test.rs @@ -0,0 +1,376 @@ +//! schema v10 回退迁移:删除 v7..v9 写下的本机远程痕迹。 +//! +//! 用户裁决撤销「远程存储在本机留有痕迹」的整套能力,本机表结构回到 remote 工作之前: +//! 不加表、不加列、没有 store 维度。覆盖: +//! +//! - v7 / v8 / v9 三种来源库都收敛到同一形状(本机表集合与 v6 时代一致); +//! - 表数据连同表一起消失,**业务表逐行不动**; +//! - `execution_runs` 的行全部保留,包括远程会话遗留的孤儿行(按裁决它们是有效事实); +//! - 同名但形状不符的表 → fail-closed 拒绝并整体回滚,不删不认识的数据; +//! - 没有那 5 张表的库是幂等的。 +//! +//! 所有库都在 tempdir 中构造,不触碰真实本机数据库。 + +use super::schema::CURRENT_SCHEMA_VERSION; +use super::*; +use peri_acp_types::workspace::WorkspaceError; +use sqlx::{sqlite::SqliteConnectOptions, Connection, SqliteConnection}; +use std::path::Path; + +/// v10 回退删掉的本机表。 +const DROPPED_TABLES: &[&str] = &[ + "session_store_registrations", + "session_lifecycle_commitments", + "session_remote_operations", + "remote_execution_runs", + "remote_lifecycle_commitments", +]; + +/// v9 库的完整形状:v6 时代的业务表 + v7..v9 追加的本机远程痕迹。 +const V9_SCHEMA: &str = r#" +PRAGMA user_version = 9; +CREATE TABLE threads ( + id TEXT PRIMARY KEY, title TEXT, cwd TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, message_count INTEGER NOT NULL DEFAULT 0, + parent_thread_id TEXT, snapshot_at_message_id TEXT, hidden BOOLEAN NOT NULL DEFAULT 0, + cancel_policy TEXT NOT NULL DEFAULT 'cascade', config TEXT, cached_context TEXT, + frozen_context TEXT, inherited_context TEXT, agent_status TEXT NOT NULL DEFAULT 'active', + context_cache_epoch INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE messages ( + message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, content TEXT NOT NULL, truncated BOOLEAN NOT NULL DEFAULT 0, + excluded BOOLEAN NOT NULL DEFAULT 0, projection TEXT +); +CREATE INDEX idx_messages_thread_id ON messages(thread_id); +CREATE TABLE projects ( + id TEXT PRIMARY KEY, locator TEXT NOT NULL, object_identity TEXT NOT NULL, + UNIQUE(locator, object_identity) +); +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), + root TEXT NOT NULL, root_identity TEXT NOT NULL, discovery TEXT NOT NULL, + UNIQUE(root, root_identity), UNIQUE(id, project_id) +); +CREATE TABLE session_bindings ( + thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL, + project_id TEXT NOT NULL, workspace_id TEXT NOT NULL, relative_cwd TEXT NOT NULL, + FOREIGN KEY(workspace_id, project_id) REFERENCES workspaces(id, project_id) +); +CREATE INDEX idx_bindings_project ON session_bindings(project_id, thread_id); +CREATE TABLE execution_runs ( + thread_id TEXT PRIMARY KEY, generation INTEGER NOT NULL, clean BOOLEAN NOT NULL +); +CREATE TABLE thread_goals (thread_id TEXT PRIMARY KEY, objective TEXT NOT NULL); +CREATE TABLE session_lifecycle_commitments ( + thread_id TEXT PRIMARY KEY, root_id TEXT NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL, + generation INTEGER, operation_id TEXT, detail TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE session_store_registrations ( + store_id TEXT PRIMARY KEY, engine TEXT NOT NULL, locator_digest TEXT NOT NULL, + installation_id TEXT NOT NULL, created_at TEXT NOT NULL +); +CREATE TABLE session_remote_operations ( + operation_id TEXT PRIMARY KEY, store_id TEXT NOT NULL, thread_id TEXT NOT NULL, + root_id TEXT NOT NULL, behavior TEXT NOT NULL, digest TEXT NOT NULL, state TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE remote_execution_runs ( + store_id TEXT NOT NULL, root_id TEXT NOT NULL, generation INTEGER NOT NULL, + clean BOOLEAN NOT NULL, PRIMARY KEY (store_id, root_id) +); +CREATE TABLE remote_lifecycle_commitments ( + store_id TEXT NOT NULL, thread_id TEXT NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (store_id, thread_id) +); +"#; + +/// 建一个 v9 形状但 `user_version` 由调用方指定的库(v7/v8 是同一批表的历史形态)。 +async fn v9_database(path: &Path, user_version: i64) -> SqliteConnection { + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::raw_sql(V9_SCHEMA) + .execute(&mut connection) + .await + .unwrap(); + sqlx::query(sqlx::AssertSqlSafe(format!( + "PRAGMA user_version = {user_version}" + ))) + .execute(&mut connection) + .await + .unwrap(); + connection +} + +/// 往 v9 库里填数据:一条本机会话、一条远程遗留的执行代际行、五张表各一行。 +async fn populate(connection: &mut SqliteConnection) { + populate_business(connection).await; + populate_remote_traces(connection).await; +} + +/// 业务表数据(迁移必须逐行不动):本机会话、消息、登记关系、目标,以及两条执行代际行 +/// ——`local-root` 是本机的,`remote-root` 是 remote 工作留下的孤儿行。 +async fn populate_business(connection: &mut SqliteConnection) { + sqlx::raw_sql( + "INSERT INTO threads (id, title, cwd, created_at, updated_at, message_count) + VALUES ('local-root', '本机会话', '/work', '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', 1); + INSERT INTO messages (message_id, thread_id, role, content) + VALUES ('m1', 'local-root', 'user', 'hello'); + INSERT INTO projects (id, locator, object_identity) VALUES ('p1', '/work', 'dev:1'); + INSERT INTO workspaces (id, project_id, root, root_identity, discovery) + VALUES ('w1', 'p1', '/work', 'dev:1', 'git'); + INSERT INTO session_bindings (thread_id, schema_version, project_id, workspace_id, relative_cwd) + VALUES ('local-root', 1, 'p1', 'w1', '.'); + INSERT INTO thread_goals (thread_id, objective) VALUES ('local-root', '保留目标'); + INSERT INTO execution_runs (thread_id, generation, clean) VALUES ('local-root', 4, 0); + INSERT INTO execution_runs (thread_id, generation, clean) VALUES ('remote-root', 7, 0);", + ) + .execute(&mut *connection) + .await + .unwrap(); +} + +/// 五张本机远程痕迹表各一行:迁移要连表带行一起删掉。 +async fn populate_remote_traces(connection: &mut SqliteConnection) { + sqlx::raw_sql( + "INSERT INTO session_lifecycle_commitments + (thread_id, root_id, kind, state, created_at, updated_at) + VALUES ('gone', 'gone', 'tombstone', 'deleted', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'); + INSERT INTO session_store_registrations + (store_id, engine, locator_digest, installation_id, created_at) + VALUES ('store-a', 'turso', 'digest', 'install', '2026-09-01T00:00:00Z'); + INSERT INTO session_remote_operations + (operation_id, store_id, thread_id, root_id, behavior, digest, state, created_at, updated_at) + VALUES ('op1', 'store-a', 'remote-root', 'remote-root', 'append', 'd', 'pending', + '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'); + INSERT INTO remote_execution_runs (store_id, root_id, generation, clean) + VALUES ('store-a', 'remote-root', 3, 0); + INSERT INTO remote_lifecycle_commitments + (store_id, thread_id, kind, state, created_at, updated_at) + VALUES ('store-a', 'remote-root', 'tombstone', 'deleting', + '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z');", + ) + .execute(&mut *connection) + .await + .unwrap(); +} + +/// 库内的表清单(不含 SQLite 内部表)。 +async fn table_names(connection: &mut SqliteConnection) -> Vec { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name", + ) + .fetch_all(&mut *connection) + .await + .unwrap(); + rows.into_iter().map(|(name,)| name).collect() +} + +async fn read_only(path: &Path) -> SqliteConnection { + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(path).read_only(true)) + .await + .unwrap() +} + +/// v7 / v8 / v9 三种来源库都收敛到同一形状:本机表集合回到 v6 时代,业务数据一行不动。 +#[tokio::test] +async fn test_v7_v8_v9_all_converge_and_drop_only_the_remote_tables() { + for source_version in [7, 8, 9] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + let mut connection = v9_database(&path, source_version).await; + populate(&mut connection).await; + let tables_before = table_names(&mut connection).await; + connection.close().await.unwrap(); + + let store = SqliteThreadStore::new(&path).await.unwrap(); + let mut connection = read_only(&path).await; + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION, "来源版本 {source_version}"); + + // 五张本机远程表消失,且没有留下其它新增结构。 + let tables_after = table_names(&mut connection).await; + for table in DROPPED_TABLES { + assert!( + !tables_after.iter().any(|name| name == table), + "来源版本 {source_version}:{table} 应当已被删除" + ); + assert!(tables_before.iter().any(|name| name == table)); + } + let expected: Vec = tables_before + .iter() + .filter(|name| !DROPPED_TABLES.contains(&name.as_str())) + .cloned() + .collect(); + assert_eq!(tables_after, expected, "来源版本 {source_version}"); + + // 业务表逐行保留。 + let (title,): (String,) = + sqlx::query_as("SELECT title FROM threads WHERE id = 'local-root'") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(title, "本机会话"); + let (objective,): (String,) = + sqlx::query_as("SELECT objective FROM thread_goals WHERE thread_id = 'local-root'") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(objective, "保留目标"); + let (cwd,): (String,) = sqlx::query_as( + "SELECT relative_cwd FROM session_bindings WHERE thread_id = 'local-root'", + ) + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(cwd, "."); + let (locator,): (String,) = sqlx::query_as("SELECT locator FROM projects WHERE id = 'p1'") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(locator, "/work"); + + // 执行代际全部保留,包括本机没有 `threads` 行的远程遗留行。 + let runs: Vec<(String, i64, bool)> = sqlx::query_as( + "SELECT thread_id, generation, clean FROM execution_runs ORDER BY thread_id", + ) + .fetch_all(&mut connection) + .await + .unwrap(); + assert_eq!( + runs, + vec![ + ("local-root".to_owned(), 4, false), + ("remote-root".to_owned(), 7, false), + ], + "v10 不重建 execution_runs,也不删除远程遗留的代际行" + ); + connection.close().await.unwrap(); + + // 迁移后的库可以正常写打开(门面与桥共用同一条连接真相)。 + store.close().await; + let reopened = SqliteThreadStore::new(&path).await.unwrap(); + reopened.close().await; + } +} + +/// 没有那 5 张表的库是幂等的:不报错、不加表、不改业务数据。 +#[tokio::test] +async fn test_database_without_remote_tables_is_idempotent() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::raw_sql(V9_SCHEMA) + .execute(&mut connection) + .await + .unwrap(); + // 删掉 v7..v9 引入的表与 v9 版本号,模拟「从未建过这些表」的库。 + for table in DROPPED_TABLES { + sqlx::query(sqlx::AssertSqlSafe(format!("DROP TABLE {table}"))) + .execute(&mut connection) + .await + .unwrap(); + } + sqlx::query("PRAGMA user_version = 8") + .execute(&mut connection) + .await + .unwrap(); + // 这些库从来只有业务表,所以只填业务数据(远程痕迹表不存在,无从填写)。 + populate_business(&mut connection).await; + let tables_before = table_names(&mut connection).await; + connection.close().await.unwrap(); + + let store = SqliteThreadStore::new(&path).await.unwrap(); + let mut connection = read_only(&path).await; + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert_eq!(table_names(&mut connection).await, tables_before); + // 业务数据一行不动:本机会话与两条(含远程遗留的)代际行都还在。 + let runs: Vec<(String, i64, bool)> = sqlx::query_as( + "SELECT thread_id, generation, clean FROM execution_runs ORDER BY thread_id", + ) + .fetch_all(&mut connection) + .await + .unwrap(); + assert_eq!( + runs, + vec![ + ("local-root".to_owned(), 4, false), + ("remote-root".to_owned(), 7, false), + ] + ); + connection.close().await.unwrap(); + store.close().await; +} + +/// 同名但形状不符的表 → fail-closed:拒绝升级、整体回滚,不认识的数据一行不动。 +#[tokio::test] +async fn test_mismatched_table_shape_fails_closed_and_rolls_back() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + let mut connection = v9_database(&path, 9).await; + populate(&mut connection).await; + // 把登记表换成同名的别的业务表(列不符)。 + sqlx::raw_sql( + "DROP TABLE session_store_registrations; + CREATE TABLE session_store_registrations (store_id TEXT PRIMARY KEY, note TEXT); + INSERT INTO session_store_registrations VALUES ('not-ours', '别人写的');", + ) + .execute(&mut connection) + .await + .unwrap(); + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + + let error = SqliteThreadStore::new(&path).await.err().unwrap(); + assert!(matches!( + error.downcast_ref::(), + Some(WorkspaceError::UnsupportedDatabaseSchema) + )); + + let mut connection = read_only(&path).await; + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(version, 9, "失败的迁移不推进版本号"); + // 其余四张表仍在:整体回滚,不是「删一半」。 + for table in DROPPED_TABLES { + let present: Option<(String,)> = + sqlx::query_as("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .bind(table) + .fetch_optional(&mut connection) + .await + .unwrap(); + assert!(present.is_some(), "{table} 在失败的迁移里必须原样保留"); + } + let (note,): (String,) = + sqlx::query_as("SELECT note FROM session_store_registrations WHERE store_id = 'not-ours'") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(note, "别人写的"); + connection.close().await.unwrap(); + // 结构未被部分改动(WAL 下文件字节可能变化,因此比对表清单而不是文件内容)。 + assert!(!before.is_empty()); +} diff --git a/peri-resources/src/sessions/sqlite_store/schema_v7_test.rs b/peri-resources/src/sessions/sqlite_store/schema_v7_test.rs new file mode 100644 index 000000000..ee3bd2a9f --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/schema_v7_test.rs @@ -0,0 +1,370 @@ +//! schema v6 → v10 迁移:执行状态显式保存,v7..v9 的本机远程痕迹回退删除。 +//! +//! 覆盖:dirty 代际逐行保留、去外键后的删除语义、v10 回退后本机不再有 store 维度的表、 +//! 迁移失败整体回滚、只读打开不迁移也不按版本拒绝。所有库都在 tempdir 中构造,不触碰 +//! 真实本机数据库。 + +use super::schema::CURRENT_SCHEMA_VERSION; +use super::*; +use crate::sessions::data::SessionDataPort; +use peri_acp_types::store::{serialize_persisted_payload, PersistedPayload, ThreadStore}; +use peri_acp_types::workspace::{RecoveryRequiredDetails, WorkspaceError}; +use sqlx::{sqlite::SqliteConnectOptions, AssertSqlSafe, Connection, SqliteConnection}; +use std::path::Path; + +/// v6 库的完整形状(含 execution_runs 的 threads 外键与旧 registration 约束)。 +const V6_SCHEMA: &str = r#" +PRAGMA user_version = 6; +CREATE TABLE threads ( + id TEXT PRIMARY KEY, title TEXT, cwd TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, message_count INTEGER NOT NULL DEFAULT 0, + parent_thread_id TEXT, snapshot_at_message_id TEXT, hidden BOOLEAN NOT NULL DEFAULT 0, + cancel_policy TEXT NOT NULL DEFAULT 'cascade', config TEXT, cached_context TEXT, + frozen_context TEXT, inherited_context TEXT, agent_status TEXT NOT NULL DEFAULT 'active', + context_cache_epoch INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE messages ( + message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, content TEXT NOT NULL, truncated BOOLEAN NOT NULL DEFAULT 0, + excluded BOOLEAN NOT NULL DEFAULT 0, projection TEXT +); +CREATE INDEX idx_messages_thread_id ON messages(thread_id); +CREATE TABLE projects ( + id TEXT PRIMARY KEY, locator TEXT NOT NULL, object_identity TEXT NOT NULL, + UNIQUE(locator, object_identity) +); +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), + root TEXT NOT NULL, root_identity TEXT NOT NULL, discovery TEXT NOT NULL, + UNIQUE(root, root_identity), UNIQUE(id, project_id) +); +CREATE TABLE session_bindings ( + thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL, + project_id TEXT NOT NULL, workspace_id TEXT NOT NULL, relative_cwd TEXT NOT NULL, + FOREIGN KEY(workspace_id, project_id) REFERENCES workspaces(id, project_id) +); +CREATE TABLE execution_runs ( + thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, + generation INTEGER NOT NULL, clean BOOLEAN NOT NULL +); +CREATE TABLE thread_goals (thread_id TEXT PRIMARY KEY, objective TEXT NOT NULL); +"#; + +async fn v6_database(path: &Path) -> SqliteConnection { + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::raw_sql(V6_SCHEMA) + .execute(&mut connection) + .await + .unwrap(); + connection +} + +/// v6 库 + 历史 + 脏执行代际 + 辅助表数据。 +async fn populated_v6(path: &Path) -> Vec { + let mut connection = v6_database(path).await; + let message = BaseMessage::human("history before v7"); + let content = serialize_persisted_payload(&PersistedPayload::Message(message.clone())).unwrap(); + sqlx::query( + "INSERT INTO threads (id, title, cwd, created_at, updated_at, message_count, frozen_context) + VALUES ('old-root', '旧会话', '/old/worktree', '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', 1, '{\"version\":1}')", + ) + .execute(&mut connection) + .await + .unwrap(); + sqlx::query( + "INSERT INTO messages (message_id, thread_id, role, content) + VALUES (?1, 'old-root', 'user', ?2)", + ) + .bind(message.id().as_uuid().to_string()) + .bind(&content) + .execute(&mut connection) + .await + .unwrap(); + sqlx::query( + "INSERT INTO execution_runs (thread_id, generation, clean) VALUES ('old-root', 4, 0)", + ) + .execute(&mut connection) + .await + .unwrap(); + sqlx::query("INSERT INTO thread_goals VALUES ('old-root', '保留目标')") + .execute(&mut connection) + .await + .unwrap(); + connection.close().await.unwrap(); + std::fs::read(path).unwrap() +} + +/// v10 回退删掉的本机表:升级之后一张都不该留在库里。 +const DROPPED_TABLES: &[&str] = &[ + "session_store_registrations", + "session_lifecycle_commitments", + "session_remote_operations", + "remote_execution_runs", + "remote_lifecycle_commitments", +]; + +/// 表是否存在于库内。 +async fn table_present(connection: &mut SqliteConnection, table: &str) -> bool { + let row: Option<(String,)> = + sqlx::query_as("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .bind(table) + .fetch_optional(&mut *connection) + .await + .unwrap(); + row.is_some() +} + +#[tokio::test] +async fn test_v6_upgrade_keeps_dirty_execution_history_and_auxiliary_tables() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + populated_v6(&path).await; + + let store = SqliteThreadStore::new(&path).await.unwrap(); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new().filename(&path).read_only(true), + ) + .await + .unwrap(); + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + + // execution_runs:外键去掉了,行内容(generation/clean)逐行保留。 + let foreign: Vec<(String,)> = + sqlx::query_as("SELECT \"table\" FROM pragma_foreign_key_list('execution_runs')") + .fetch_all(&mut connection) + .await + .unwrap(); + assert!( + foreign.is_empty(), + "v7 起本机执行行不再依赖 threads 外键:{foreign:?}" + ); + let runs: Vec<(String, i64, bool)> = + sqlx::query_as("SELECT thread_id, generation, clean FROM execution_runs") + .fetch_all(&mut connection) + .await + .unwrap(); + assert_eq!(runs, vec![("old-root".to_owned(), 4, false)]); + + // 历史与辅助表逐字节保留。 + let history: (String, String) = + sqlx::query_as("SELECT role, content FROM messages WHERE thread_id = 'old-root'") + .fetch_one(&mut connection) + .await + .unwrap(); + let expected = BaseMessage::human("history before v7"); + assert_eq!(history.0, "user"); + assert!(history.1.contains("history before v7")); + let goals: (String,) = sqlx::query_as("SELECT objective FROM thread_goals") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(goals.0, "保留目标"); + let frozen: (Option,) = + sqlx::query_as("SELECT frozen_context FROM threads WHERE id = 'old-root'") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(frozen.0.as_deref(), Some("{\"version\":1}")); + let _ = expected; + + // v10 回退:v7..v9 引入的本机远程痕迹一张都不留。 + for table in DROPPED_TABLES { + assert!( + !table_present(&mut connection, table).await, + "v10 之后本机不该再有 {table}" + ); + } + connection.close().await.unwrap(); + + // dirty 代际在同一 (thread_id, generation) 上仍可精确解除。 + store + .reset_dirty_execution(&RecoveryRequiredDetails { + thread_id: "old-root".to_owned(), + generation: 4, + }) + .await + .unwrap(); + let (clean,): (bool,) = + sqlx::query_as("SELECT clean FROM execution_runs WHERE thread_id = 'old-root'") + .fetch_one(&store.database.pool) + .await + .unwrap(); + assert!(clean); + + // 删除不再依赖外键级联:执行行由删除路径显式清理(数据面行为见端口测试)。 + let data = SqliteSessionData::new(Arc::clone(&store.database)); + data.delete_tree(&"old-root".to_owned()).await.unwrap(); + let runs: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM execution_runs") + .fetch_one(&store.database.pool) + .await + .unwrap(); + assert_eq!(runs.0, 0); +} + +#[tokio::test] +async fn test_v6_upgrade_failure_rolls_back_version_structure_and_rows() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + populated_v6(&path).await; + // 预置一张形状不符的同名表:v10 回退必须整体失败,而不是把不认识的数据丢掉。 + let mut connection = + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(&path)) + .await + .unwrap(); + sqlx::query("CREATE TABLE session_store_registrations (store_id TEXT PRIMARY KEY)") + .execute(&mut connection) + .await + .unwrap(); + let before: Vec<(String, Option)> = + sqlx::query_as("SELECT name, sql FROM sqlite_schema ORDER BY name") + .fetch_all(&mut connection) + .await + .unwrap(); + connection.close().await.unwrap(); + + let error = SqliteThreadStore::new(&path).await.err().unwrap(); + assert!(matches!( + error.downcast_ref::(), + Some(WorkspaceError::UnsupportedDatabaseSchema) + )); + + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new().filename(&path).read_only(true), + ) + .await + .unwrap(); + let after: Vec<(String, Option)> = + sqlx::query_as("SELECT name, sql FROM sqlite_schema ORDER BY name") + .fetch_all(&mut connection) + .await + .unwrap(); + assert_eq!(after, before, "失败的迁移不得留下半迁移结构"); + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(version, 6, "失败后版本保持 6,可重试"); + let foreign: Vec<(String,)> = + sqlx::query_as("SELECT \"table\" FROM pragma_foreign_key_list('execution_runs')") + .fetch_all(&mut connection) + .await + .unwrap(); + assert_eq!(foreign, vec![("threads".to_owned(),)], "结构未被部分重建"); + let runs: Vec<(String, i64, bool)> = + sqlx::query_as("SELECT thread_id, generation, clean FROM execution_runs") + .fetch_all(&mut connection) + .await + .unwrap(); + assert_eq!(runs, vec![("old-root".to_owned(), 4, false)]); + assert!( + table_present(&mut connection, "session_store_registrations").await, + "形状不符的同名表在失败的迁移里必须原样保留" + ); + let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM session_store_registrations") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(rows.0, 0); +} + +#[tokio::test] +async fn test_read_only_open_accepts_v6_v7_and_future_shapes_without_migrating() { + let directory = tempfile::tempdir().unwrap(); + + // v6:只读打开不迁移、不建表、不改版本号。 + let v6 = directory.path().join("v6.db"); + populated_v6(&v6).await; + let reader = SqliteThreadStore::open_existing_read_only(&v6) + .await + .unwrap(); + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&reader.database.pool) + .await + .unwrap(); + assert_eq!(version, 6, "只读打开不写 user_version"); + for table in DROPPED_TABLES { + assert!( + !table_present(&mut reader.database.pool.acquire().await.unwrap(), table).await, + "只读打开不建表:{table}" + ); + } + assert_eq!( + reader + .load_meta(&"old-root".to_owned()) + .await + .unwrap() + .title + .as_deref(), + Some("旧会话") + ); + reader.close().await; + + // 未来版本:只读按列形状放行(不因版本号拒绝)。 + let future = directory.path().join("future.db"); + populated_v6(&future).await; + let future_version = CURRENT_SCHEMA_VERSION + 1; + let mut connection = + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(&future)) + .await + .unwrap(); + sqlx::query(AssertSqlSafe(format!( + "PRAGMA user_version = {future_version}" + ))) + .execute(&mut connection) + .await + .unwrap(); + connection.close().await.unwrap(); + let before = std::fs::read(&future).unwrap(); + let reader = SqliteThreadStore::open_existing_read_only(&future) + .await + .unwrap(); + assert!(reader.load_meta(&"old-root".to_owned()).await.is_ok()); + reader.close().await; + + // 同一份未来版本库:写打开拒绝、不降级、不改动文件。 + let error = SqliteThreadStore::new(&future).await.err().unwrap(); + assert!(matches!( + error.downcast_ref::(), + Some(WorkspaceError::UnsupportedSchemaVersion { found, .. }) if *found == future_version + )); + assert_eq!(std::fs::read(&future).unwrap(), before); +} + +#[tokio::test] +async fn test_upgraded_database_reopens_without_second_migration() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + populated_v6(&path).await; + + let store = SqliteThreadStore::new(&path).await.unwrap(); + store.close().await; + let reopened = SqliteThreadStore::new(&path).await.unwrap(); + let (version,): (i64,) = sqlx::query_as("PRAGMA user_version") + .fetch_one(&reopened.database.pool) + .await + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + for table in DROPPED_TABLES { + assert!( + !table_present(&mut reopened.database.pool.acquire().await.unwrap(), table).await, + "重复打开不重建已回退的表:{table}" + ); + } + let runs: Vec<(String, i64, bool)> = + sqlx::query_as("SELECT thread_id, generation, clean FROM execution_runs") + .fetch_all(&reopened.database.pool) + .await + .unwrap(); + assert_eq!(runs, vec![("old-root".to_owned(), 4, false)]); +} diff --git a/peri-resources/src/sessions/sqlite_store/session_data.rs b/peri-resources/src/sessions/sqlite_store/session_data.rs new file mode 100644 index 000000000..2d9d1f94e --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/session_data.rs @@ -0,0 +1,1178 @@ +//! SQLite 数据适配器:`SessionDataPort` 的本机实现。 +//! +//! 与执行/登记面共用同一个 [`SqliteSessionDatabase`](同一 pool、同一个库),因此 +//! 「数据保存」与「执行准入」看到的是同一份事实,不需要第二个连接真相。事务、锁文件、 +//! 连接与 CAS 都在本模块及相邻私有模块内部,端口不导出任何一项。 +//! +//! 两类事实的边界: +//! +//! - 本机 workspace 的目录/Git 证据属于执行面;本模块只读取与写入**已登记**的关系。 +//! - `BindingState::{LegacyConfirmed, ExternalOrUnregistered}` 由执行面按目录来源 +//! 联合判定后对外表达;数据面只回答「有绑定且登记一致」「无绑定」「绑定指向的登记 +//! 已不存在」这三种记录事实,不把「没有绑定」冒充成 legacy 或损坏。 +//! +//! 本机没有跨进程的生命周期锚点(v10 删除了 `session_lifecycle_commitments`):撤销与 +//! 删除都是同事务的数据事实,没有「先留证据、再动作、之后收尾」的三步。提交阶段的失败由 +//! [`commit_failure`] 报成未决持久化(效果 `Unknown`),未决证据只在进程内的租约上, +//! 崩溃后由 `execution_runs.clean = 0` 走既有的显式恢复流程。 + +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use peri_acp_types::messages::MessageId; +use peri_acp_types::session_resources::{ + BindingState, ChildSnapshot, ForkSnapshot, FrozenSnapshotBytes, FrozenState, NewSession, + PersistenceRecovery, RewindBoundary, SessionMetaPatch, SessionResourceError, + SessionResourceErrorKind, SessionResourceResult, SessionSnapshot, +}; +use peri_acp_types::store::{ + serialize_persisted_payload, CompactionChange, InheritedContext, MessageFlags, PersistedPayload, +}; +use peri_acp_types::thread::{AgentStatus, ThreadId, ThreadMeta}; +use peri_acp_types::workspace::{ + ResolvedWorkspace, ScopedThreadPage, ScopedThreadQuery, SessionBinding, WorkspaceError, + SESSION_BINDING_VERSION, +}; +use sqlx::SqliteConnection; + +use super::database::SqliteSessionDatabase; +use super::failure::{ + commit_failure, corrupt, invalid_input, map_sqlx, not_found, read_failure, unavailable, + write_failure, +}; +use super::row_mapping::extract_title; +use super::session_rows::{ + delete_thread_child_rows, insert_binding_row, insert_thread_row, ThreadRowInsert, +}; +use super::{compaction, context, session_rows, workspace as workspace_store}; +use crate::sessions::canonical::payload_role; +use crate::sessions::data::ensure_child_relation; +use crate::sessions::data::ChildResumeRecord; +use crate::sessions::data::SessionDataPort; +use crate::sessions::local_port::SessionFacts; + +/// 同一份 [`SqliteSessionDatabase`] 的数据面句柄。 +/// +/// `closed` 是共享的:门面发给 child resume 认领 handle 的副本与门面自身看到同一个 +/// 关闭状态,关闭一个即关闭全部写入入口。 +#[derive(Clone)] +pub(crate) struct SqliteSessionData { + database: Arc, + /// 端口关闭后不再接受新写入;读取不受影响(历史仍可解释)。 + closed: Arc, +} + +impl SqliteSessionData { + pub(super) fn new(database: Arc) -> Self { + Self { + database, + closed: Arc::new(AtomicBool::new(false)), + } + } + + /// 写入前置:端口未关闭且本次打开可写。 + fn writable(&self) -> SessionResourceResult<()> { + if self.closed.load(Ordering::Acquire) { + return Err(unavailable("session data port is closed")); + } + if self.database.is_read_only() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::ReadOnlyStore, + )); + } + Ok(()) + } +} + +// ─── 行读取原语 ─────────────────────────────────────────────────────────────── + +/// 线程树(含自身):删除与归属判定都用同一遍递归。 +async fn thread_tree_on( + connection: &mut SqliteConnection, + root: &ThreadId, +) -> anyhow::Result> { + let rows: Vec<(String,)> = sqlx::query_as( + "WITH RECURSIVE session_tree AS ( + SELECT id FROM threads WHERE id = ?1 + UNION ALL + SELECT t.id FROM threads t INNER JOIN session_tree st ON t.parent_thread_id = st.id + ) + SELECT id FROM session_tree", + ) + .bind(root.as_str()) + .fetch_all(&mut *connection) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + +/// 会话树根的持久化事实:沿 parent 链向上回溯。 +pub(super) async fn thread_root_on( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> anyhow::Result { + let mut current = id.clone(); + let mut visited = HashSet::new(); + loop { + if !visited.insert(current.clone()) { + anyhow::bail!("cyclic thread ancestry"); + } + let row: Option<(Option,)> = + sqlx::query_as("SELECT parent_thread_id FROM threads WHERE id = ?1") + .bind(current.as_str()) + .fetch_optional(&mut *connection) + .await?; + match row { + Some((Some(parent),)) => current = parent, + Some((None,)) => return Ok(current), + None => anyhow::bail!("session row is missing"), + } + } +} + +/// 本机库自己的会话事实:数据与执行在**同一个库**时(迁移桥、本机组合的内部调用)的读法。 +/// +/// 判定与远端组合完全一致,差别只在事实来源:这里从本机 `session_bindings` 与 `threads` +/// 父链读出调用方在远端组合里要从数据端口取的三件事(绑定字节、这棵树有没有绑定、树根)。 +/// 远端组合**不能**用它——那时本机没有这条会话的行,三件事只能由数据端口回答。 +impl SqliteSessionDatabase { + pub(super) async fn local_session_facts(&self, id: &ThreadId) -> anyhow::Result { + let binding = self.load_session_binding_impl(id).await?; + let mut connection = self.pool.acquire().await?; + let root = thread_root_on(&mut connection, id) + .await + .unwrap_or_else(|_| id.clone()); + // 自身或 root 有绑定即算这棵树有绑定:接纳过的 legacy root 可以有自己没有绑定行的子会话。 + let bound = match &binding { + Some(_) => true, + None if root == *id => false, + None => self.load_session_binding_impl(&root).await?.is_some(), + }; + Ok(SessionFacts { + binding, + bound, + root, + }) + } +} + +/// 绑定行的事实:绑定、指向已消失的登记、或没有绑定行。 +enum BindingRowState { + Bound(SessionBinding), + /// 有绑定行,但它指向的本机登记不存在:记录在、身份无法在本机验证。 + Unregistered, + Absent, +} + +/// 绑定行的事实分类;不判断 legacy(那是本机来源证据与执行面的联合结论)。 +async fn binding_row_state_on( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> SessionResourceResult { + let row: Option<(i64, String, String, String)> = sqlx::query_as( + "SELECT schema_version, project_id, workspace_id, relative_cwd + FROM session_bindings WHERE thread_id = ?1", + ) + .bind(id.as_str()) + .fetch_optional(&mut *connection) + .await + .map_err(|error| map_sqlx(&error))?; + let Some((version, project_id, workspace_id, relative_cwd)) = row else { + return Ok(BindingRowState::Absent); + }; + // 版本不被本构建接受与记录损坏是两种事实,分开报告。 + if version != i64::from(SESSION_BINDING_VERSION) { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Unsupported, + )); + } + let binding = + workspace_store::decode_binding((version, project_id, workspace_id, relative_cwd)) + .map_err(|_| corrupt("session binding is not decodable"))?; + let registered: Option<(String,)> = + sqlx::query_as("SELECT id FROM workspaces WHERE id = ?1 AND project_id = ?2") + .bind(binding.workspace_id.to_string()) + .bind(binding.project_id.to_string()) + .fetch_optional(&mut *connection) + .await + .map_err(|error| map_sqlx(&error))?; + if registered.is_none() { + return Ok(BindingRowState::Unregistered); + } + Ok(BindingRowState::Bound(binding)) +} + +/// 会话自身的 frozen 列;`None` 表示从未保存过快照(legacy 缺失)。 +async fn frozen_bytes_on( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> anyhow::Result> { + let row: Option<(Option,)> = + sqlx::query_as("SELECT frozen_context FROM threads WHERE id = ?1") + .bind(id.as_str()) + .fetch_optional(&mut *connection) + .await?; + match row { + Some((frozen,)) => Ok(frozen), + None => anyhow::bail!("session row is missing"), + } +} + +/// `NewSession` 的 `threads` 行插入参数(创建路径与 fork/child 共用同一列形状)。 +pub(super) fn new_session_row<'a>( + input: &'a NewSession, + snapshot_at_message_id: Option<&'a str>, + frozen: Option<&'a str>, + message_count: i64, +) -> ThreadRowInsert<'a> { + ThreadRowInsert { + id: &input.thread_id, + title: input.meta.title.as_deref(), + cwd: &input.meta.cwd, + created_at: &input.created_at, + updated_at: &input.created_at, + message_count, + parent_thread_id: input.meta.parent_thread_id.as_deref(), + snapshot_at_message_id, + hidden: input.meta.hidden, + cancel_policy: input.meta.cancel_policy.as_str(), + config: None, + agent_status: AgentStatus::Active.as_str(), + frozen_context: frozen, + } +} + +/// 会话是否存在(用于 fork 来源、child 父行等关系检查)。 +async fn thread_exists_on( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> anyhow::Result { + let row: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM threads WHERE id = ?1") + .bind(id.as_str()) + .fetch_optional(&mut *connection) + .await?; + Ok(row.is_some()) +} + +// ─── 端口实现 ───────────────────────────────────────────────────────────────── + +#[async_trait] +impl SessionDataPort for SqliteSessionData { + async fn save_new_session(&self, input: &NewSession) -> SessionResourceResult<()> { + self.writable()?; + let snapshot_at = input + .meta + .snapshot_at_message_id + .map(|id| id.as_uuid().to_string()); + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + insert_thread_row( + &mut tx, + &new_session_row( + input, + snapshot_at.as_deref(), + Some(input.frozen.as_str()), + 0, + ), + ) + .await + .map_err(write_failure)?; + insert_binding_row(&mut tx, &input.thread_id, &input.binding) + .await + .map_err(write_failure)?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(input.thread_id.clone())))?; + Ok(()) + } + + async fn revoke_unpublished_session(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.writable()?; + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + // 撤销只针对「本次未发布的创建」:已经派生过子会话的 identity 不能被补偿掉, + // 否则子会话会指向一个不存在的父节点。 + let children: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM threads WHERE parent_thread_id = ?1") + .bind(id.as_str()) + .fetch_one(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + if children.0 > 0 { + return Err(invalid_input( + "session has published children and cannot be revoked", + )); + } + // 撤销即撤销:数据行与执行代际在同一次提交里消失,本机不再留「这个 identity 的 + // 初始化被刻意放弃」的终态锚点(那张表随 v10 删除)。identity 的复用判定因此只 + // 依据现有数据事实,不再有第二份本机证据。 + // + // 本机执行代际不靠外键级联(v7 起 execution_runs 无外键),显式删除。 + sqlx::query("DELETE FROM execution_runs WHERE thread_id = ?1") + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + // 子表行同样显式删除,不借 `ON DELETE CASCADE`:那份级联只在 SQLite 上存在, + // 远端执行器没有(见 [`session_rows::THREAD_CHILD_DELETES`])。先子后父。 + delete_thread_child_rows(&mut tx, id.as_str()) + .await + .map_err(|error| map_sqlx(&error))?; + sqlx::query(session_rows::DELETE_THREAD_SQL) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn adopt_legacy_session( + &self, + id: &ThreadId, + saved_cwd: &str, + workspace: &ResolvedWorkspace, + frozen: &FrozenSnapshotBytes, + ) -> SessionResourceResult<()> { + self.writable()?; + if !std::path::Path::new(saved_cwd).is_absolute() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::Unavailable), + )); + } + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + let row: Option<(String, Option)> = + sqlx::query_as("SELECT cwd, parent_thread_id FROM threads WHERE id = ?1") + .bind(id.as_str()) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + let Some((cwd, parent)) = row else { + return Err(not_found()); + }; + // 保存的绝对 cwd 是接纳依据;调用方不能借接纳顺手改绑或接纳 child。 + if cwd != saved_cwd || parent.is_some() { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBindingMismatch), + )); + } + // 本机登记关系必须一致(关键文件对象与目录证据由执行面在同一准入内复核)。 + let registered: Option<(String, String)> = + sqlx::query_as("SELECT project_id, root FROM workspaces WHERE id = ?1") + .bind(workspace.workspace_id.to_string()) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + let binding = SessionBinding::from_workspace(workspace); + match registered { + Some((project_id, root)) + if project_id == binding.project_id.to_string() + && std::path::Path::new(&root) == workspace.root => {} + _ => { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBindingMismatch), + )); + } + } + match binding_row_state_on(&mut tx, id).await? { + BindingRowState::Bound(existing) => { + // 竞争:已有绑定不覆盖、不修复,必须就是本 workspace。 + if existing != binding { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace( + WorkspaceError::ExecutionBindingMismatch, + ), + )); + } + } + BindingRowState::Unregistered => { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBindingMismatch), + )); + } + BindingRowState::Absent => { + let run: Option<(i64,)> = + sqlx::query_as("SELECT generation FROM execution_runs WHERE thread_id = ?1") + .bind(id.as_str()) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + if run.is_some() { + // 丢掉本机绑定不能成为绕过 dirty 恢复的路径。 + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::InvalidBinding), + )); + } + sqlx::query( + "UPDATE threads SET frozen_context = COALESCE(frozen_context, ?1) WHERE id = ?2", + ) + .bind(frozen.as_str()) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + insert_binding_row(&mut tx, id, &binding) + .await + .map_err(write_failure)?; + } + } + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn load_snapshot(&self, id: &ThreadId) -> SessionResourceResult { + // 一次读取视图:同一连接上的延迟事务让 meta/binding/frozen/历史/继承区来自 + // 同一个数据库状态,不让调用方拼多次跨时刻查询。meta 走轻量投影(不含 + // `cached_context` 正文):派生缓存不是历史事实,历史由 payloads 给出。 + let mut connection = self + .database + .pool + .acquire() + .await + .map_err(|error| map_sqlx(&error))?; + let mut tx = sqlx::Connection::begin(&mut *connection) + .await + .map_err(|error| map_sqlx(&error))?; + let meta = context::load_meta_on(&mut tx, id) + .await + .map_err(read_failure)?; + let parent = meta.parent_thread_id.clone(); + let binding = match binding_row_state_on(&mut tx, id).await? { + BindingRowState::Bound(binding) => BindingState::Bound(binding), + BindingRowState::Unregistered => BindingState::ExternalOrUnregistered, + BindingRowState::Absent if parent.is_some() => BindingState::ExternalOrUnregistered, + BindingRowState::Absent => BindingState::Missing, + }; + let frozen = match frozen_bytes_on(&mut tx, id).await.map_err(read_failure)? { + Some(bytes) => FrozenState::Present(FrozenSnapshotBytes::new(bytes)), + None => FrozenState::LegacyAbsent, + }; + let payloads = context::load_payloads_on(&mut tx, id) + .await + .map_err(read_failure)?; + let flags = compaction::load_flags_on(&mut tx, id) + .await + .map_err(read_failure)?; + let inherited = context::load_inherited_context_on(&mut tx, id) + .await + .map_err(read_failure)?; + // 只读事务:没有写入效果可以证明,提交失败仍按原因分类,不判 `Unknown`。 + tx.commit().await.map_err(|error| map_sqlx(&error))?; + Ok(SessionSnapshot { + meta, + binding, + frozen, + payloads, + flags, + inherited, + }) + } + + async fn load_binding(&self, id: &ThreadId) -> SessionResourceResult { + // 与 `load_snapshot` 同一条分类规则、同一次读取视图,只是不读历史与 frozen。 + let mut connection = self + .database + .pool + .acquire() + .await + .map_err(|error| map_sqlx(&error))?; + let mut tx = sqlx::Connection::begin(&mut *connection) + .await + .map_err(|error| map_sqlx(&error))?; + let meta = context::load_meta_on(&mut tx, id) + .await + .map_err(read_failure)?; + let state = match binding_row_state_on(&mut tx, id).await? { + BindingRowState::Bound(binding) => BindingState::Bound(binding), + BindingRowState::Unregistered => BindingState::ExternalOrUnregistered, + BindingRowState::Absent if meta.parent_thread_id.is_some() => { + BindingState::ExternalOrUnregistered + } + BindingRowState::Absent => BindingState::Missing, + }; + tx.commit().await.map_err(|error| map_sqlx(&error))?; + Ok(state) + } + + async fn load_session_history( + &self, + id: &ThreadId, + ) -> SessionResourceResult> { + let mut connection = self + .database + .pool + .acquire() + .await + .map_err(|error| map_sqlx(&error))?; + let mut tx = sqlx::Connection::begin(&mut *connection) + .await + .map_err(|error| map_sqlx(&error))?; + let payloads = context::load_context_payloads_on(&mut tx, id) + .await + .map_err(read_failure)?; + tx.commit().await.map_err(|error| map_sqlx(&error))?; + Ok(payloads) + } + + async fn load_meta(&self, id: &ThreadId) -> SessionResourceResult { + self.database.load_meta(id).await.map_err(read_failure) + } + + async fn session_exists(&self, id: &ThreadId) -> SessionResourceResult { + let row: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM threads WHERE id = ?1") + .bind(id.as_str()) + .fetch_optional(&self.database.pool) + .await + .map_err(|error| map_sqlx(&error))?; + Ok(row.is_some()) + } + + async fn binding_of(&self, id: &ThreadId) -> SessionResourceResult> { + self.database + .load_session_binding_impl(id) + .await + .map_err(read_failure) + } + + async fn session_root(&self, id: &ThreadId) -> SessionResourceResult { + // 父链解析不出(会话行缺失、链有环)时退回自身:执行代际的键因此仍然确定, + // 只是阻塞范围变窄,不影响调用方对「这条会话自己」的判定。 + let mut connection = self + .database + .pool + .acquire() + .await + .map_err(|error| map_sqlx(&error))?; + Ok(thread_root_on(&mut connection, id) + .await + .unwrap_or_else(|_| id.clone())) + } + + async fn list_sessions( + &self, + query: &ScopedThreadQuery, + ) -> SessionResourceResult { + self.database + .list_scoped_threads_impl(query) + .await + .map_err(write_failure) + } + + async fn list_children(&self, parent: &ThreadId) -> SessionResourceResult> { + self.database + .list_child_threads(parent) + .await + .map_err(read_failure) + } + + async fn list_session_tree(&self, root: &ThreadId) -> SessionResourceResult> { + self.database + .list_session_threads(root) + .await + .map_err(read_failure) + } + + async fn append_history( + &self, + id: &ThreadId, + payloads: &[PersistedPayload], + ) -> SessionResourceResult<()> { + self.writable()?; + if payloads.is_empty() { + return Ok(()); + } + // 相同 ID 的碰撞不能静默忽略:批次内重复与库存重复都必须失败, + // 否则「已存在但内容不同」会被当成成功。 + let mut seen = HashSet::with_capacity(payloads.len()); + for payload in payloads { + if !seen.insert(payload.id()) { + return Err(invalid_input("history batch repeats a message id")); + } + } + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + if !thread_exists_on(&mut tx, id).await.map_err(read_failure)? { + // 目标会话不存在与「外键拒绝」是两个不同的原因,前者更可诊断。 + return Err(not_found()); + } + for payload in payloads { + sqlx::query( + "INSERT INTO messages (message_id, thread_id, role, content) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(payload.id().as_uuid().to_string()) + .bind(id.as_str()) + .bind(payload_role(payload)) + .bind( + serialize_persisted_payload(payload) + .map_err(|_| corrupt("history entry is not serializable"))?, + ) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + } + let now = Utc::now().to_rfc3339(); + let updated = sqlx::query( + "UPDATE threads SET updated_at = ?1, + message_count = (SELECT COUNT(*) FROM messages WHERE thread_id = ?2) + WHERE id = ?2", + ) + .bind(&now) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + if updated.rows_affected() != 1 { + return Err(not_found()); + } + let messages = payloads + .iter() + .filter_map(PersistedPayload::as_message) + .cloned() + .collect::>(); + if let Some(title) = extract_title(&messages) { + sqlx::query("UPDATE threads SET title = ?1 WHERE id = ?2 AND title IS NULL") + .bind(&title) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + } + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn save_fork(&self, fork: &ForkSnapshot) -> SessionResourceResult<()> { + self.writable()?; + if fork.target.thread_id == fork.source_id { + return Err(invalid_input("fork target must differ from its source")); + } + let snapshot_at = fork + .target + .meta + .snapshot_at_message_id + .map(|id| id.as_uuid().to_string()); + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + if !thread_exists_on(&mut tx, &fork.source_id) + .await + .map_err(read_failure)? + { + return Err(not_found()); + } + insert_thread_row( + &mut tx, + &new_session_row( + &fork.target, + snapshot_at.as_deref(), + Some(fork.target.frozen.as_str()), + fork.payloads.len() as i64, + ), + ) + .await + .map_err(write_failure)?; + insert_binding_row(&mut tx, &fork.target.thread_id, &fork.target.binding) + .await + .map_err(write_failure)?; + let payload_ids = insert_history_rows(&mut tx, &fork.target.thread_id, &fork.payloads) + .await + .map_err(write_failure)?; + for (message_id, flags) in &fork.flags { + if !payload_ids.contains(message_id) { + return Err(invalid_input("fork flags reference an unknown message id")); + } + let updated = sqlx::query( + "UPDATE messages SET truncated = ?1, excluded = ?2, projection = ?3 + WHERE message_id = ?4 AND thread_id = ?5", + ) + .bind(flags.truncated) + .bind(flags.excluded) + .bind(projection_json(flags)?) + .bind(message_id.as_uuid().to_string()) + .bind(fork.target.thread_id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + if updated.rows_affected() != 1 { + return Err(corrupt("fork projection did not apply to exactly one row")); + } + } + tx.commit() + .await + .map_err(|_| commit_failure(Some(fork.target.thread_id.clone())))?; + Ok(()) + } + + async fn save_child(&self, child: &ChildSnapshot) -> SessionResourceResult<()> { + self.writable()?; + // 防御校验复用门面同一条规则:落库写的是 `target.meta` 里的父关系,声明与它不一致 + // 时必须在这里就停下,不能靠「父链核对」兜底——那条链查的是声明里的 parent。 + ensure_child_relation(child)?; + let snapshot_at = child + .target + .meta + .snapshot_at_message_id + .map(|id| id.as_uuid().to_string()); + let inherited = child + .inherited + .to_json() + .map_err(|_| corrupt("inherited context is not serializable"))?; + // 发布前校验引用边界,损坏的继承区不能落库。 + InheritedContext::from_json(&inherited) + .map_err(|_| corrupt("inherited context has invalid message references"))?; + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + if !thread_exists_on(&mut tx, &child.parent_id) + .await + .map_err(read_failure)? + { + return Err(not_found()); + } + // 父子关系与根归属必须与库内事实一致:child 的 root 必须真是 parent 链的根。 + let parent_root = thread_root_on(&mut tx, &child.parent_id) + .await + .map_err(read_failure)?; + if parent_root != child.root_id { + return Err(invalid_input("child root does not match its parent chain")); + } + // frozen 必须逐字节来自 root 的已保存快照:不重新扫描目录,也不重新冻结。 + let root_frozen = frozen_bytes_on(&mut tx, &child.root_id) + .await + .map_err(read_failure)?; + if root_frozen.as_deref() != Some(child.target.frozen.as_str()) { + return Err(invalid_input( + "child frozen snapshot must be the root's saved snapshot", + )); + } + // 子会话继承父会话的执行绑定身份,不另立 workspace 归属。 + match binding_row_state_on(&mut tx, &child.parent_id).await? { + BindingRowState::Bound(parent_binding) if parent_binding != child.target.binding => { + return Err(SessionResourceError::new( + SessionResourceErrorKind::Workspace(WorkspaceError::ExecutionBindingMismatch), + )); + } + _ => {} + } + insert_thread_row( + &mut tx, + &new_session_row( + &child.target, + snapshot_at.as_deref(), + Some(child.target.frozen.as_str()), + 0, + ), + ) + .await + .map_err(write_failure)?; + insert_binding_row(&mut tx, &child.target.thread_id, &child.target.binding) + .await + .map_err(write_failure)?; + sqlx::query("UPDATE threads SET inherited_context = ?1 WHERE id = ?2") + .bind(&inherited) + .bind(child.target.thread_id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(child.target.thread_id.clone())))?; + Ok(()) + } + + async fn load_child_resume_record( + &self, + child: &ThreadId, + ) -> SessionResourceResult { + let row: Option<(String,)> = + sqlx::query_as("SELECT agent_status FROM threads WHERE id = ?1") + .bind(child.as_str()) + .fetch_optional(&self.database.pool) + .await + .map_err(|error| map_sqlx(&error))?; + let Some((status,)) = row else { + return Err(not_found()); + }; + let status: AgentStatus = status + .parse() + .map_err(|_| corrupt("stored agent status is not readable"))?; + Ok(ChildResumeRecord { + // 认领事实就是「该会话正在运行」:active 之外的状态都可被认领。 + claimed: status.is_active(), + status, + }) + } + + async fn store_child_resume_record( + &self, + child: &ThreadId, + record: &ChildResumeRecord, + ) -> SessionResourceResult<()> { + self.writable()?; + let now = Utc::now().to_rfc3339(); + let updated = + sqlx::query("UPDATE threads SET agent_status = ?1, updated_at = ?2 WHERE id = ?3") + .bind(record.status.as_str()) + .bind(&now) + .bind(child.as_str()) + .execute(&self.database.pool) + .await + .map_err(|error| map_sqlx(&error))?; + if updated.rows_affected() != 1 { + return Err(not_found()); + } + Ok(()) + } + + async fn apply_compaction( + &self, + id: &ThreadId, + change: &CompactionChange, + ) -> SessionResourceResult<()> { + self.writable()?; + compaction::commit_compaction_lifecycle(&self.database, id, change) + .await + .map_err(write_failure) + } + + async fn apply_message_projections( + &self, + id: &ThreadId, + updates: &[(MessageId, MessageFlags)], + ) -> SessionResourceResult<()> { + self.writable()?; + if updates.is_empty() { + return Ok(()); + } + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + for (message_id, flags) in updates { + let updated = sqlx::query( + "UPDATE messages SET truncated = ?1, excluded = ?2, projection = ?3 + WHERE message_id = ?4 AND thread_id = ?5", + ) + .bind(flags.truncated) + .bind(flags.excluded) + .bind(projection_json(flags)?) + .bind(message_id.as_uuid().to_string()) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + if updated.rows_affected() != 1 { + // 不属于本会话或不存在:既不静默跳过,也不把别会话的行改掉。 + return Err(invalid_input( + "projection target is not a history entry of this session", + )); + } + } + // 派生视图与写入同事务失效:调用方不需要再补一次 cache 维护。 + sqlx::query( + "UPDATE threads SET cached_context = NULL, context_cache_epoch = context_cache_epoch + 1 + WHERE id = ?1", + ) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn rewind_history( + &self, + id: &ThreadId, + boundary: RewindBoundary, + ) -> SessionResourceResult<()> { + self.writable()?; + let target = boundary.message_id().as_uuid().to_string(); + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + let rowid: Option<(i64,)> = + sqlx::query_as("SELECT rowid FROM messages WHERE thread_id = ?1 AND message_id = ?2") + .bind(id.as_str()) + .bind(&target) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + // 未知截止点保持无变更语义:找不到目标就不动历史。 + if let Some((rowid,)) = rowid { + let sql = match boundary { + RewindBoundary::KeepThrough(_) => { + "DELETE FROM messages WHERE thread_id = ?1 AND rowid > ?2" + } + RewindBoundary::RemoveFrom(_) => { + "DELETE FROM messages WHERE thread_id = ?1 AND rowid >= ?2" + } + }; + sqlx::query(sql) + .bind(id.as_str()) + .bind(rowid) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + refresh_history_derivations(&mut tx, id).await?; + } + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn remove_history_entries( + &self, + id: &ThreadId, + ids: &[MessageId], + ) -> SessionResourceResult<()> { + self.writable()?; + if ids.is_empty() { + return Ok(()); + } + let unique: Vec = { + let mut seen = HashSet::with_capacity(ids.len()); + ids.iter().copied().filter(|id| seen.insert(*id)).collect() + }; + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + for message_id in &unique { + // 别会话的条目不允许被「精确移除」静默命中或静默跳过。 + let owner: Option<(String,)> = + sqlx::query_as("SELECT thread_id FROM messages WHERE message_id = ?1") + .bind(message_id.as_uuid().to_string()) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + match owner { + Some((owner,)) if owner == id.as_str() => { + sqlx::query("DELETE FROM messages WHERE message_id = ?1 AND thread_id = ?2") + .bind(message_id.as_uuid().to_string()) + .bind(id.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + } + Some(_) => return Err(invalid_input("history entry belongs to another session")), + // 已经不存在的条目是幂等删除,不产生错误。 + None => {} + } + } + refresh_history_derivations(&mut tx, id).await?; + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn update_meta( + &self, + id: &ThreadId, + patch: &SessionMetaPatch, + ) -> SessionResourceResult<()> { + self.writable()?; + if patch.title.is_none() + && patch.status.is_none() + && patch.cancel_policy.is_none() + && patch.config.is_none() + { + // 没有字段要改:不写、也不假装写入了新时间戳。 + return Ok(()); + } + let now = Utc::now().to_rfc3339(); + let mut builder: sqlx::QueryBuilder = + sqlx::QueryBuilder::new("UPDATE threads SET updated_at = "); + builder.push_bind(&now); + if let Some(title) = &patch.title { + builder.push(", title = ").push_bind(title.clone()); + } + if let Some(status) = &patch.status { + builder.push(", agent_status = ").push_bind(status.as_str()); + } + if let Some(policy) = &patch.cancel_policy { + builder + .push(", cancel_policy = ") + .push_bind(policy.as_str()); + } + if let Some(config) = &patch.config { + builder.push(", config = ").push_bind(config.clone()); + } + builder.push(" WHERE id = ").push_bind(id.as_str()); + let updated = builder + .build() + .execute(&self.database.pool) + .await + .map_err(|error| map_sqlx(&error))?; + if updated.rows_affected() != 1 { + return Err(not_found()); + } + Ok(()) + } + + async fn delete_tree(&self, id: &ThreadId) -> SessionResourceResult<()> { + self.writable()?; + let mut tx = self + .database + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_sqlx(&error))?; + if !thread_exists_on(&mut tx, id).await.map_err(read_failure)? { + return Err(not_found()); + } + let tree = thread_tree_on(&mut tx, id).await.map_err(read_failure)?; + // 删除即删除:数据行与执行代际在同一次提交里消失。v10 之前这里还会写一条删除 + // 墓碑(`session_lifecycle_commitments`),那是本机为「这条 identity 被刻意终止」 + // 留的第二份证据;用户裁决撤销跨安装的终态判定后,墓碑连同表一起删除。 + // + // v7 起 execution_runs 不再有外键:不显式删除就会留下永不收敛的孤儿执行行。 + for thread in &tree { + sqlx::query("DELETE FROM execution_runs WHERE thread_id = ?1") + .bind(thread.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + } + // 子表行显式删除,不借 `ON DELETE CASCADE`:级联只在 SQLite 上存在,远端执行器 + // 没有(见 [`session_rows::THREAD_CHILD_DELETES`])。顺序与远端 delete_tree 一致: + // 子行全部先删,最后才删 threads 行。 + for thread in &tree { + delete_thread_child_rows(&mut tx, thread) + .await + .map_err(|error| map_sqlx(&error))?; + } + for thread in &tree { + sqlx::query(session_rows::DELETE_THREAD_SQL) + .bind(thread) + .execute(&mut *tx) + .await + .map_err(|error| map_sqlx(&error))?; + } + tx.commit() + .await + .map_err(|_| commit_failure(Some(id.clone())))?; + Ok(()) + } + + async fn recover_persistence( + &self, + id: &ThreadId, + ) -> SessionResourceResult { + // 本机写入与执行代际同事务完成,**没有**跨进程的未决记录可收敛(v10 删除了 + // `session_lifecycle_commitments` 与 `session_remote_operations`)。这里只复核 + // 数据事实仍然可读:读不到就如实失败,不用「已收敛」把不存在的会话说成可重载。 + self.load_meta(id).await?; + Ok(PersistenceRecovery::Recovered) + } + + async fn drain(&self, _id: &ThreadId) -> SessionResourceResult<()> { + // 本机写入是同事务完成的,没有排队中的持久化,也没有本机未决记录可等:本方法 + // 因此不阻塞。在途写入的等待由门面按活跃租约完成(见 `drain_persistence`)。 + Ok(()) + } + + async fn close(&self) -> SessionResourceResult<()> { + // 连接池由共享库句柄所有(执行面也可能在用),这里只关闭数据侧的写入入口。 + self.closed.store(true, Ordering::Release); + Ok(()) + } +} + +/// 批量插入 canonical payload;返回批次内 ID 集合。 +async fn insert_history_rows( + connection: &mut SqliteConnection, + thread_id: &ThreadId, + payloads: &[PersistedPayload], +) -> anyhow::Result> { + let mut ids = HashSet::with_capacity(payloads.len()); + for payload in payloads { + if !ids.insert(payload.id()) { + anyhow::bail!("history batch repeats a message id"); + } + sqlx::query( + "INSERT INTO messages (message_id, thread_id, role, content) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(payload.id().as_uuid().to_string()) + .bind(thread_id.as_str()) + .bind(payload_role(payload)) + .bind(serialize_persisted_payload(payload)?) + .execute(&mut *connection) + .await?; + } + Ok(ids) +} + +fn projection_json(flags: &MessageFlags) -> SessionResourceResult> { + flags + .projection + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|_| corrupt("message projection is not serializable")) +} + +/// 历史变更后同步派生视图:计数、时间戳与缓存版本一起更新。 +async fn refresh_history_derivations( + connection: &mut SqliteConnection, + id: &ThreadId, +) -> SessionResourceResult<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE threads SET updated_at = ?1, + message_count = (SELECT COUNT(*) FROM messages WHERE thread_id = ?2), + cached_context = NULL, + context_cache_epoch = context_cache_epoch + 1 + WHERE id = ?2", + ) + .bind(&now) + .bind(id.as_str()) + .execute(&mut *connection) + .await + .map_err(|error| map_sqlx(&error))?; + Ok(()) +} diff --git a/peri-resources/src/sessions/sqlite_store/session_data_test.rs b/peri-resources/src/sessions/sqlite_store/session_data_test.rs new file mode 100644 index 000000000..5b9a07d0a --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/session_data_test.rs @@ -0,0 +1,1072 @@ +//! `SessionDataPort`(SQLite 数据面)行为测试。 +//! +//! 断言以可观察结果为准:一次保存后的完整事实、碰撞是否失败、墓碑与执行行是否 +//! 与数据删除同事务收敛;不复制实现细节。 + +use super::*; +use crate::sessions::data::SessionDataPort; +use peri_acp_types::messages::BaseMessage; +use peri_acp_types::session_resources::{ + BindingState, ChildSnapshot, ForkSnapshot, FrozenSnapshotBytes, FrozenState, NewSession, + NewSessionMeta, PersistenceRecovery, RewindBoundary, SessionMetaPatch, +}; +use peri_acp_types::store::{CompactionChange, PersistedPayload, ThreadStore}; +use peri_acp_types::workspace::{RecoveryRequiredDetails, ResolvedWorkspace}; +use sqlx::Connection; +use std::collections::HashMap; +use tempfile::TempDir; + +async fn database() -> (SqliteThreadStore, SqliteSessionData, TempDir) { + let directory = tempfile::tempdir().unwrap(); + let store = SqliteThreadStore::new(directory.path().join("threads.db")) + .await + .unwrap(); + let data = SqliteSessionData::new(Arc::clone(&store.database)); + (store, data, directory) +} + +fn frozen(marker: &str) -> FrozenSnapshotBytes { + FrozenSnapshotBytes::new(format!(r#"{{"version":1,"marker":"{marker}"}}"#)) +} + +fn binding_of(workspace: &ResolvedWorkspace) -> peri_acp_types::workspace::SessionBinding { + peri_acp_types::workspace::SessionBinding { + schema_version: peri_acp_types::workspace::SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + } +} + +async fn workspace(store: &SqliteThreadStore, cwd: &std::path::Path) -> ResolvedWorkspace { + store.resolve_workspace(cwd).await.unwrap() +} + +fn session( + id: &str, + cwd: &str, + workspace: &ResolvedWorkspace, + frozen: FrozenSnapshotBytes, +) -> NewSession { + NewSession { + thread_id: id.to_owned(), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("session {id}")), + cwd: cwd.to_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: binding_of(workspace), + frozen, + } +} + +fn payloads(count: usize) -> Vec { + (0..count) + .map(|index| PersistedPayload::Message(BaseMessage::human(format!("message {index}")))) + .collect() +} +// ─── 新建:完整快照一次保存 ──────────────────────────────────────────────────── + +#[tokio::test] +async fn test_save_new_session_persists_complete_snapshot_in_one_write() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + let input = session("s-new", &cwd, &workspace, frozen("new")); + + data.save_new_session(&input).await.unwrap(); + + let snapshot = data.load_snapshot(&"s-new".to_owned()).await.unwrap(); + assert_eq!(snapshot.meta.title.as_deref(), Some("session s-new")); + assert_eq!(snapshot.meta.cwd, cwd); + assert_eq!(snapshot.meta.message_count, 0); + assert_eq!(snapshot.meta.agent_status, AgentStatus::Active); + assert_eq!(snapshot.binding, BindingState::Bound(input.binding.clone())); + assert_eq!( + snapshot.frozen, + FrozenState::Present(FrozenSnapshotBytes::new(r#"{"version":1,"marker":"new"}"#)) + ); + assert!(snapshot.payloads.is_empty()); + assert!(snapshot.flags.is_empty()); + + // 同一 identity 再保存一次必须是明确失败,不能静默覆盖已发布会话。 + let error = data.save_new_session(&input).await.unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + ), + "{error}" + ); +} + +#[tokio::test] +async fn test_save_new_session_requires_a_registered_workspace() { + let (_store, data, directory) = database().await; + let store = SqliteThreadStore::new(directory.path().join("threads.db")) + .await + .unwrap(); + let workspace = workspace(&store, directory.path()).await; + let unknown = ResolvedWorkspace { + project_id: peri_acp_types::workspace::ProjectId::new(), + ..workspace + }; + let input = session("s-unknown", "/tmp", &unknown, frozen("unknown")); + + let error = data.save_new_session(&input).await.unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::Workspace(_) + ), + "{error}" + ); + // 未注册的绑定不能留下半条会话行。 + assert!(data.load_snapshot(&"s-unknown".to_owned()).await.is_err()); +} + +// ─── 读取:一致快照与轻量投影 ────────────────────────────────────────────────── + +#[tokio::test] +async fn test_snapshot_read_is_consistent_and_meta_projection_skips_cache_blob() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + let mut input = session("s-read", &cwd, &workspace, frozen("read")); + input.meta.title = None; + data.save_new_session(&input).await.unwrap(); + let payloads = payloads(3); + data.append_history(&"s-read".to_owned(), &payloads) + .await + .unwrap(); + let first = payloads[0].id(); + data.apply_message_projections( + &"s-read".to_owned(), + &[( + first, + peri_acp_types::store::MessageFlags { + truncated: true, + excluded: false, + projection: None, + }, + )], + ) + .await + .unwrap(); + // 直接写入派生缓存正文:轻量投影不得把它带出来。 + sqlx::query("UPDATE threads SET cached_context = ?1 WHERE id = 's-read'") + .bind("x".repeat(4096)) + .execute(&store.database.pool) + .await + .unwrap(); + + let snapshot = data.load_snapshot(&"s-read".to_owned()).await.unwrap(); + assert_eq!(snapshot.payloads.len(), 3); + assert_eq!( + snapshot + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>(), + payloads + .iter() + .map(PersistedPayload::id) + .collect::>() + ); + assert!(snapshot.flags[&first].truncated); + assert_eq!(snapshot.meta.message_count, 3); + // 自动标题:首条 human 消息。 + assert_eq!(snapshot.meta.title.as_deref(), Some("message 0")); + + let meta = data.load_meta(&"s-read".to_owned()).await.unwrap(); + assert!( + meta.cached_context.is_none(), + "轻量 metadata 投影不加载派生缓存正文" + ); + let page = data + .list_sessions(&peri_acp_types::workspace::ScopedThreadQuery { + scope: peri_acp_types::workspace::ThreadScope::All, + cursor: None, + limit: 10, + }) + .await + .unwrap(); + assert_eq!(page.entries.len(), 1); + assert_eq!(page.entries[0].thread.id, "s-read"); +} + +#[tokio::test] +async fn test_load_snapshot_reports_missing_rows_and_unregistered_bindings() { + let (store, data, directory) = database().await; + let error = data.load_snapshot(&"absent".to_owned()).await.unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + )); + + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-orphan", &cwd, &workspace, frozen("orphan"))) + .await + .unwrap(); + // 登记行被移除(曾有写入方在未强制外键时删掉登记)后,绑定不再能于本机验证: + // 报告为「本机登记缺失」而不是损坏,也不是 legacy。 + let mut connection = sqlx::SqliteConnection::connect_with( + &sqlx::sqlite::SqliteConnectOptions::new().filename(directory.path().join("threads.db")), + ) + .await + .unwrap(); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut connection) + .await + .unwrap(); + sqlx::query("DELETE FROM workspaces WHERE id = ?1") + .bind(workspace.workspace_id.to_string()) + .execute(&mut connection) + .await + .unwrap(); + connection.close().await.unwrap(); + let snapshot = data.load_snapshot(&"s-orphan".to_owned()).await.unwrap(); + assert_eq!(snapshot.binding, BindingState::ExternalOrUnregistered); +} + +// ─── append:碰撞失败、派生事实一并维护 ──────────────────────────────────────── + +#[tokio::test] +async fn test_append_history_rejects_duplicate_ids_instead_of_ignoring_them() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-append", &cwd, &workspace, frozen("append"))) + .await + .unwrap(); + let batch = payloads(2); + data.append_history(&"s-append".to_owned(), &batch) + .await + .unwrap(); + + // 已存在的 ID:无论内容是否相同都不能静默跳过。 + let error = data + .append_history(&"s-append".to_owned(), &batch[..1]) + .await + .unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + ), + "{error}" + ); + // 批次内重复同样失败。 + let repeated = vec![batch[0].clone(), batch[0].clone()]; + let error = data + .append_history(&"s-append".to_owned(), &repeated) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + )); + + let snapshot = data.load_snapshot(&"s-append".to_owned()).await.unwrap(); + assert_eq!(snapshot.payloads.len(), 2, "失败的追加不得留下部分写入"); + assert_eq!(snapshot.meta.message_count, 2); + + let error = data + .append_history(&"absent".to_owned(), &payloads(1)) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + )); +} + +// ─── fork:目标完整、来源不变 ────────────────────────────────────────────────── + +#[tokio::test] +async fn test_save_fork_copies_complete_target_and_leaves_source_untouched() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-source", &cwd, &workspace, frozen("source"))) + .await + .unwrap(); + let source_payloads = payloads(2); + data.append_history(&"s-source".to_owned(), &source_payloads) + .await + .unwrap(); + let source_flags = HashMap::from([( + source_payloads[1].id(), + peri_acp_types::store::MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + )]); + data.apply_message_projections( + &"s-source".to_owned(), + &source_flags + .iter() + .map(|(id, flags)| (*id, flags.clone())) + .collect::>(), + ) + .await + .unwrap(); + + // fork 的 ID 重映射由调用方(纯规则)完成;数据面保存的已经是重映射后的快照, + // 因此目标 ID 与来源不同。 + let forked = payloads(2); + assert_ne!(forked[0].id(), source_payloads[0].id()); + let target = session("s-fork", &cwd, &workspace, frozen("source")); + let fork = ForkSnapshot { + target, + source_id: "s-source".to_owned(), + payloads: forked.clone(), + flags: HashMap::from([( + forked[1].id(), + peri_acp_types::store::MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + )]), + }; + data.save_fork(&fork).await.unwrap(); + + let snapshot = data.load_snapshot(&"s-fork".to_owned()).await.unwrap(); + assert_eq!(snapshot.payloads.len(), 2); + assert_eq!(snapshot.payloads[0].id(), forked[0].id()); + assert!(snapshot.flags[&forked[1].id()].excluded); + assert_eq!(snapshot.meta.message_count, 2); + assert!(matches!(snapshot.frozen, FrozenState::Present(_))); + assert_eq!( + snapshot.binding, + BindingState::Bound(fork.target.binding.clone()) + ); + + let source = data.load_snapshot(&"s-source".to_owned()).await.unwrap(); + assert_eq!( + source + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>(), + source_payloads + .iter() + .map(PersistedPayload::id) + .collect::>() + ); + assert!(source.flags[&source_payloads[1].id()].excluded); + + // 来源不存在时不得凭空写入目标。 + let mut missing = fork.clone(); + missing.target.thread_id = "s-fork-missing".to_owned(); + missing.source_id = "absent".to_owned(); + let error = data.save_fork(&missing).await.unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + )); + assert!(data + .load_snapshot(&"s-fork-missing".to_owned()) + .await + .is_err()); +} + +// ─── child:继承 root 的原始 frozen 与绑定身份 ───────────────────────────────── + +#[tokio::test] +async fn test_save_child_uses_root_frozen_bytes_and_enforces_relationships() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-root", &cwd, &workspace, frozen("root"))) + .await + .unwrap(); + + let mut child = session("s-child", &cwd, &workspace, frozen("root")); + child.meta.parent_thread_id = Some("s-root".to_owned()); + let snapshot = ChildSnapshot { + target: child, + parent_id: "s-root".to_owned(), + root_id: "s-root".to_owned(), + inherited: peri_acp_types::store::InheritedContext { + payloads: payloads(1), + flags: HashMap::new(), + }, + }; + data.save_child(&snapshot).await.unwrap(); + + let loaded = data.load_snapshot(&"s-child".to_owned()).await.unwrap(); + assert_eq!(loaded.meta.parent_thread_id.as_deref(), Some("s-root")); + assert_eq!(loaded.inherited.payloads.len(), 1); + assert_eq!( + loaded.frozen, + FrozenState::Present(FrozenSnapshotBytes::new(r#"{"version":1,"marker":"root"}"#)) + ); + assert_eq!( + loaded.binding, + BindingState::Bound(snapshot.target.binding.clone()) + ); + assert_eq!(loaded.meta.message_count, 0); + assert_eq!( + data.list_children(&"s-root".to_owned()) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + data.list_session_tree(&"s-root".to_owned()) + .await + .unwrap() + .len(), + 2 + ); + + // 与 root 已保存快照不一致的 frozen 不得落库:child 用的是 root 的原始字节。 + let mut drifted = snapshot.clone(); + drifted.target.thread_id = "s-child-drift".to_owned(); + drifted.target.frozen = frozen("rebuilt"); + let error = data.save_child(&drifted).await.unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + ), + "{error}" + ); + assert!(data + .load_snapshot(&"s-child-drift".to_owned()) + .await + .is_err()); + + // root 归属必须与 parent 链一致。 + let mut wrong_root = snapshot.clone(); + wrong_root.target.thread_id = "s-child-root".to_owned(); + wrong_root.root_id = "s-other".to_owned(); + let error = data.save_child(&wrong_root).await.unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + )); +} + +/// 数据 adapter 不经门面也必须安全:落库写的是 `target.meta` 里的父关系,声明(`parent_id`) +/// 与它不一致时要在写入前拒绝——直接调用数据面的调用方不提供「门面已经校验过」的保证。 +#[tokio::test] +async fn test_save_child_refuses_a_declared_parent_relation_it_will_not_persist() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-guard-root", &cwd, &workspace, frozen("root"))) + .await + .unwrap(); + + // 声明的父/根都合法,但目标 meta 里没有父:旧行为会写出一条没有父的独立 root。 + let mut child = session("s-guard-child", &cwd, &workspace, frozen("root")); + child.meta.parent_thread_id = None; + let mut snapshot = ChildSnapshot { + target: child, + parent_id: "s-guard-root".to_owned(), + root_id: "s-guard-root".to_owned(), + inherited: Default::default(), + }; + let error = data.save_child(&snapshot).await.unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + )); + assert!(data + .load_snapshot(&"s-guard-child".to_owned()) + .await + .is_err()); + assert!(data + .list_children(&"s-guard-root".to_owned()) + .await + .unwrap() + .is_empty()); + + // 声明与 meta 一致时同一次保存才成立,落库的父就是声明的那一个。 + snapshot.target.meta.parent_thread_id = Some("s-guard-root".to_owned()); + data.save_child(&snapshot).await.unwrap(); + let loaded = data + .load_snapshot(&"s-guard-child".to_owned()) + .await + .unwrap(); + assert_eq!( + loaded.meta.parent_thread_id.as_deref(), + Some("s-guard-root") + ); + assert_eq!( + data.list_session_tree(&"s-guard-root".to_owned()) + .await + .unwrap() + .len(), + 2 + ); +} + +// ─── legacy 接纳 ────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_adopt_legacy_session_publishes_binding_and_frozen_once() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + // legacy 会话:有历史但无绑定、无 frozen(由旧版本写入)。 + let id = store + .create_thread(ThreadMeta::new(cwd.as_str())) + .await + .unwrap(); + + data.adopt_legacy_session(&id, &cwd, &workspace, &frozen("legacy")) + .await + .unwrap(); + let snapshot = data.load_snapshot(&id).await.unwrap(); + assert_eq!( + snapshot.binding, + BindingState::Bound(binding_of(&workspace)) + ); + assert!(matches!(snapshot.frozen, FrozenState::Present(_))); + + // 重复接纳:既有绑定与本次一致即幂等,不覆盖、不修复。 + data.adopt_legacy_session(&id, &cwd, &workspace, &frozen("legacy")) + .await + .unwrap(); + + // 借接纳改绑 cwd 必须失败。 + let error = data + .adopt_legacy_session(&id, "/elsewhere", &workspace, &frozen("legacy")) + .await + .unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::Workspace(_) + ), + "{error}" + ); + let after = data.load_snapshot(&id).await.unwrap(); + assert_eq!(after.binding, BindingState::Bound(binding_of(&workspace))); + assert_eq!(after.meta.cwd, cwd); +} + +#[tokio::test] +async fn test_adopt_legacy_session_refuses_to_bypass_dirty_execution() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + let id = store + .create_thread(ThreadMeta::new(cwd.as_str())) + .await + .unwrap(); + sqlx::query("INSERT INTO execution_runs (thread_id, generation, clean) VALUES (?1, 1, 0)") + .bind(&id) + .execute(&store.database.pool) + .await + .unwrap(); + + let error = data + .adopt_legacy_session(&id, &cwd, &workspace, &frozen("legacy")) + .await + .unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::Workspace( + peri_acp_types::workspace::WorkspaceError::InvalidBinding + ) + ), + "{error}" + ); + let snapshot = data.load_snapshot(&id).await.unwrap(); + assert_eq!(snapshot.binding, BindingState::Missing); + assert_eq!(snapshot.frozen, FrozenState::LegacyAbsent); +} + +// ─── 投影、compaction、rewind ───────────────────────────────────────────────── + +#[tokio::test] +async fn test_projection_and_compaction_maintain_derived_views_in_the_same_write() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-compact", &cwd, &workspace, frozen("compact"))) + .await + .unwrap(); + let history = payloads(3); + data.append_history(&"s-compact".to_owned(), &history) + .await + .unwrap(); + let epoch_before: (i64,) = + sqlx::query_as("SELECT context_cache_epoch FROM threads WHERE id = ?") + .bind("s-compact") + .fetch_one(&store.database.pool) + .await + .unwrap(); + + // 一次性投影变更集:flags 与派生视图同事务生效。 + data.apply_message_projections( + &"s-compact".to_owned(), + &[( + history[0].id(), + peri_acp_types::store::MessageFlags { + truncated: true, + excluded: false, + projection: None, + }, + )], + ) + .await + .unwrap(); + let flags = data + .load_snapshot(&"s-compact".to_owned()) + .await + .unwrap() + .flags; + assert!(flags[&history[0].id()].truncated); + let epoch_after: (i64,) = + sqlx::query_as("SELECT context_cache_epoch FROM threads WHERE id = ?") + .bind("s-compact") + .fetch_one(&store.database.pool) + .await + .unwrap(); + assert_eq!(epoch_after.0, epoch_before.0 + 1); + + // 指向别会话/不存在的条目:整体失败,不留部分写入。 + let error = data + .apply_message_projections( + &"s-compact".to_owned(), + &[ + ( + history[1].id(), + peri_acp_types::store::MessageFlags { + truncated: true, + excluded: true, + projection: None, + }, + ), + ( + peri_acp_types::messages::MessageId::new(), + peri_acp_types::store::MessageFlags::default(), + ), + ], + ) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + )); + let flags = data + .load_snapshot(&"s-compact".to_owned()) + .await + .unwrap() + .flags; + assert!( + !flags + .get(&history[1].id()) + .is_some_and(|flags| flags.truncated), + "失败批次不得留下部分 flags" + ); + + // compaction:追加摘要消息与 flags 一次生效。 + let summary = BaseMessage::ai("summary"); + let summary_id = summary.id(); + data.apply_compaction( + &"s-compact".to_owned(), + &CompactionChange { + flag_updates: vec![( + history[2].id(), + peri_acp_types::store::MessageFlags { + truncated: false, + excluded: true, + projection: None, + }, + )], + appended_messages: vec![summary], + }, + ) + .await + .unwrap(); + let snapshot = data.load_snapshot(&"s-compact".to_owned()).await.unwrap(); + assert_eq!(snapshot.payloads.len(), 4); + assert_eq!(snapshot.payloads[3].id(), summary_id); + assert!(snapshot.flags[&history[2].id()].excluded); + assert_eq!(snapshot.meta.message_count, 4); + + // 归属校验:别会话的条目不能在本次 compaction 里被改掉。 + let error = data + .apply_compaction( + &"s-compact".to_owned(), + &CompactionChange { + flag_updates: vec![( + peri_acp_types::messages::MessageId::new(), + peri_acp_types::store::MessageFlags::default(), + )], + appended_messages: vec![], + }, + ) + .await + .unwrap_err(); + assert!(!matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + )); +} + +#[tokio::test] +async fn test_rewind_boundaries_are_distinct_and_unknown_cutoffs_change_nothing() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-rewind", &cwd, &workspace, frozen("rewind"))) + .await + .unwrap(); + let history = payloads(4); + data.append_history(&"s-rewind".to_owned(), &history) + .await + .unwrap(); + + // 未知截止点:无变更(保留现有 rewind 语义)。 + data.rewind_history( + &"s-rewind".to_owned(), + RewindBoundary::RemoveFrom(peri_acp_types::messages::MessageId::new()), + ) + .await + .unwrap(); + assert_eq!( + data.load_snapshot(&"s-rewind".to_owned()) + .await + .unwrap() + .payloads + .len(), + 4 + ); + + // 保留到目标:目标本身保留。 + data.rewind_history( + &"s-rewind".to_owned(), + RewindBoundary::KeepThrough(history[1].id()), + ) + .await + .unwrap(); + let snapshot = data.load_snapshot(&"s-rewind".to_owned()).await.unwrap(); + assert_eq!( + snapshot + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>(), + vec![history[0].id(), history[1].id()] + ); + assert_eq!(snapshot.meta.message_count, 2); + + // 从目标开始移除:目标及之后的条目都不再存在。 + data.rewind_history( + &"s-rewind".to_owned(), + RewindBoundary::RemoveFrom(history[1].id()), + ) + .await + .unwrap(); + let snapshot = data.load_snapshot(&"s-rewind".to_owned()).await.unwrap(); + assert_eq!( + snapshot + .payloads + .iter() + .map(PersistedPayload::id) + .collect::>(), + vec![history[0].id()] + ); + assert_eq!(snapshot.meta.message_count, 1); +} + +#[tokio::test] +async fn test_remove_history_entries_is_exact_and_idempotent() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-remove", &cwd, &workspace, frozen("remove"))) + .await + .unwrap(); + data.save_new_session(&session("s-other", &cwd, &workspace, frozen("other"))) + .await + .unwrap(); + let history = payloads(2); + let other = payloads(1); + data.append_history(&"s-remove".to_owned(), &history) + .await + .unwrap(); + data.append_history(&"s-other".to_owned(), &other) + .await + .unwrap(); + + // 别会话的条目不得被本次移除命中。 + let error = data + .remove_history_entries(&"s-remove".to_owned(), &[other[0].id()]) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::InvalidInput { .. } + )); + assert_eq!( + data.load_snapshot(&"s-other".to_owned()) + .await + .unwrap() + .payloads + .len(), + 1 + ); + + data.remove_history_entries(&"s-remove".to_owned(), &[history[0].id()]) + .await + .unwrap(); + assert_eq!( + data.load_snapshot(&"s-remove".to_owned()) + .await + .unwrap() + .payloads + .len(), + 1 + ); + // 已经不存在的条目:幂等,不报错。 + data.remove_history_entries(&"s-remove".to_owned(), &[history[0].id()]) + .await + .unwrap(); +} + +// ─── metadata、resume、登记 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_update_meta_applies_only_requested_fields() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-meta", &cwd, &workspace, frozen("meta"))) + .await + .unwrap(); + let before = data.load_meta(&"s-meta".to_owned()).await.unwrap(); + + // 空 patch:不改任何字段,也不刷新时间戳。 + data.update_meta(&"s-meta".to_owned(), &SessionMetaPatch::default()) + .await + .unwrap(); + let after = data.load_meta(&"s-meta".to_owned()).await.unwrap(); + assert_eq!(after.updated_at, before.updated_at); + + data.update_meta( + &"s-meta".to_owned(), + &SessionMetaPatch { + title: Some(Some("renamed".to_owned())), + status: Some(AgentStatus::Done), + cancel_policy: Some(peri_acp_types::thread::CancelPolicy::Independent), + config: Some(Some("{\"k\":1}".to_owned())), + }, + ) + .await + .unwrap(); + let updated = data.load_meta(&"s-meta".to_owned()).await.unwrap(); + assert_eq!(updated.title.as_deref(), Some("renamed")); + assert_eq!(updated.agent_status, AgentStatus::Done); + assert_eq!( + updated.cancel_policy, + peri_acp_types::thread::CancelPolicy::Independent + ); + assert_eq!(updated.config.as_deref(), Some("{\"k\":1}")); + // cwd/parent/计数不是定向更新能改的字段。 + assert_eq!(updated.cwd, before.cwd); + assert_eq!(updated.parent_thread_id, before.parent_thread_id); + assert_eq!(updated.created_at, before.created_at); + + data.update_meta( + &"s-meta".to_owned(), + &SessionMetaPatch { + title: Some(None), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!(data + .load_meta(&"s-meta".to_owned()) + .await + .unwrap() + .title + .is_none()); + + let error = data + .update_meta( + &"absent".to_owned(), + &SessionMetaPatch { + status: Some(AgentStatus::Done), + ..Default::default() + }, + ) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::NotFound + )); +} + +#[tokio::test] +async fn test_child_resume_record_round_trip() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-resume", &cwd, &workspace, frozen("resume"))) + .await + .unwrap(); + let mut child = session("s-resume-child", &cwd, &workspace, frozen("resume")); + child.meta.parent_thread_id = Some("s-resume".to_owned()); + data.save_child(&ChildSnapshot { + target: child, + parent_id: "s-resume".to_owned(), + root_id: "s-resume".to_owned(), + inherited: Default::default(), + }) + .await + .unwrap(); + + let record = data + .load_child_resume_record(&"s-resume-child".to_owned()) + .await + .unwrap(); + assert_eq!(record.status, AgentStatus::Active); + assert!(record.claimed, "active 的 child 视为已被认领"); + + data.store_child_resume_record( + &"s-resume-child".to_owned(), + &crate::sessions::data::ChildResumeRecord { + status: AgentStatus::Done, + claimed: false, + }, + ) + .await + .unwrap(); + let record = data + .load_child_resume_record(&"s-resume-child".to_owned()) + .await + .unwrap(); + assert_eq!(record.status, AgentStatus::Done); + assert!(!record.claimed); +} + +// ─── 只读与关闭 ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_read_only_store_serves_reads_and_refuses_every_mutation() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-ro", &cwd, &workspace, frozen("ro"))) + .await + .unwrap(); + let path = directory.path().join("threads.db"); + store.close().await; + let reader = SqliteThreadStore::open_existing_read_only(&path) + .await + .unwrap(); + let read_only = SqliteSessionData::new(Arc::clone(&reader.database)); + + assert!(read_only.load_snapshot(&"s-ro".to_owned()).await.is_ok()); + let error = read_only + .save_new_session(&session("s-ro-2", &cwd, &workspace, frozen("ro"))) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::ReadOnlyStore + )); + let error = read_only + .append_history(&"s-ro".to_owned(), &payloads(1)) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::ReadOnlyStore + )); + let error = read_only.delete_tree(&"s-ro".to_owned()).await.unwrap_err(); + assert!(matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::ReadOnlyStore + )); + // 收敛检查在只读下不写库:没有未决锚点时报告可重载。 + assert_eq!( + read_only + .recover_persistence(&"s-ro".to_owned()) + .await + .unwrap(), + PersistenceRecovery::Recovered + ); +} + +#[tokio::test] +async fn test_close_stops_writes_and_keeps_history_readable() { + let (store, data, directory) = database().await; + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-close", &cwd, &workspace, frozen("close"))) + .await + .unwrap(); + + data.close().await.unwrap(); + let error = data + .append_history(&"s-close".to_owned(), &payloads(1)) + .await + .unwrap_err(); + assert!( + matches!( + error.kind(), + peri_acp_types::session_resources::SessionResourceErrorKind::Unavailable { .. } + ), + "{error}" + ); + assert!(data.load_snapshot(&"s-close".to_owned()).await.is_ok()); +} + +#[tokio::test] +async fn test_dirty_execution_generation_survives_a_port_restart() { + let (_store, data, directory) = database().await; + let store = SqliteThreadStore::new(directory.path().join("threads.db")) + .await + .unwrap(); + let workspace = workspace(&store, directory.path()).await; + let cwd = workspace.cwd.to_string_lossy().into_owned(); + data.save_new_session(&session("s-dirty", &cwd, &workspace, frozen("dirty"))) + .await + .unwrap(); + store + .acquire_execution_lease(&"s-dirty".to_owned()) + .await + .unwrap(); + drop(store); + assert!(data.load_snapshot(&"s-dirty".to_owned()).await.is_ok()); + + // 未 clean 的代际跨实例保留:精确代际解除仍然可用(执行面语义,数据侧只提供事实)。 + let reopened = SqliteThreadStore::new(directory.path().join("threads.db")) + .await + .unwrap(); + let generation: (i64, bool) = + sqlx::query_as("SELECT generation, clean FROM execution_runs WHERE thread_id = 's-dirty'") + .fetch_one(&reopened.database.pool) + .await + .unwrap(); + assert!(!generation.1, "Drop 不代表 clean"); + reopened + .reset_dirty_execution(&RecoveryRequiredDetails { + thread_id: "s-dirty".to_owned(), + generation: generation.0, + }) + .await + .unwrap(); +} diff --git a/peri-resources/src/sessions/sqlite_store/session_rows.rs b/peri-resources/src/sessions/sqlite_store/session_rows.rs new file mode 100644 index 000000000..98759c4ba --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/session_rows.rs @@ -0,0 +1,130 @@ +//! 会话行的私有写入原语:`threads` 行与 canonical binding 行的插入与删除。 +//! +//! 数据面([`super::session_data`])与迁移桥([`super::SqliteThreadStore`])共用同一 +//! 份列清单、绑定校验与删除语句,避免同一张表的 INSERT/DELETE 与同一套绑定规则在两处 +//! 各自维护。这里只做同事务内的行写入,不决定准入、不落锚点。 + +use anyhow::Result; +use peri_acp_types::workspace::SessionBinding; +use sqlx::SqliteConnection; + +use crate::sessions::canonical; + +/// 新建 `threads` 行的显式输入;派生字段(`message_count`、时间戳)由调用方给定, +/// 不在本层读时钟。 +pub(super) struct ThreadRowInsert<'a> { + pub id: &'a str, + pub title: Option<&'a str>, + pub cwd: &'a str, + pub created_at: &'a str, + pub updated_at: &'a str, + pub message_count: i64, + pub parent_thread_id: Option<&'a str>, + pub snapshot_at_message_id: Option<&'a str>, + pub hidden: bool, + pub cancel_policy: &'a str, + pub config: Option<&'a str>, + pub agent_status: &'a str, + pub frozen_context: Option<&'a str>, +} + +/// 插入一条 `threads` 行;`cached_context` 与 `context_cache_epoch` 从缺省值起步 +/// (派生缓存由行为在失效时清空,不在这里给值)。 +pub(super) async fn insert_thread_row( + connection: &mut SqliteConnection, + row: &ThreadRowInsert<'_>, +) -> Result<()> { + sqlx::query( + "INSERT INTO threads (id, title, cwd, created_at, updated_at, message_count, + parent_thread_id, snapshot_at_message_id, hidden, cancel_policy, config, cached_context, + frozen_context, agent_status, context_cache_epoch) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, ?12, ?13, 0)", + ) + .bind(row.id) + .bind(row.title) + .bind(row.cwd) + .bind(row.created_at) + .bind(row.updated_at) + .bind(row.message_count) + .bind(row.parent_thread_id) + .bind(row.snapshot_at_message_id) + .bind(row.hidden) + .bind(row.cancel_policy) + .bind(row.config) + .bind(row.frozen_context) + .bind(row.agent_status) + .execute(&mut *connection) + .await?; + Ok(()) +} + +/// 插入 canonical binding 行。 +/// +/// 绑定身份由调用方给出且必须已被本机 workspace 验证过;这里只做形状校验(版本、 +/// 相对路径)与写入。外键指向的本机登记不存在时由调用方按 workspace 语义映射。 +pub(super) async fn insert_binding_row( + connection: &mut SqliteConnection, + thread_id: &str, + binding: &SessionBinding, +) -> Result<()> { + super::workspace::validate_relative(&binding.cwd_relative_to_workspace)?; + let version = i64::from(binding.schema_version); + sqlx::query( + "INSERT INTO session_bindings (thread_id, schema_version, project_id, workspace_id, relative_cwd) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(thread_id) + .bind(version) + .bind(binding.project_id.to_string()) + .bind(binding.workspace_id.to_string()) + .bind(super::discovery::path_text(&binding.cwd_relative_to_workspace)?) + .execute(&mut *connection) + .await?; + Ok(()) +} + +// ─── 行删除原语 ─────────────────────────────────────────────────────────────── + +/// 删除 `threads` 行之前必须显式清理的子表:`(子表名, 语句)`。 +/// +/// **为什么两端都显式删**:远端执行器提供不了级联——远端 schema 不声明任何 +/// `REFERENCES`,`PRAGMA foreign_keys` 默认读数为 0、且是跨连接共享的可变状态,远端 +/// 也没有 `pragma_foreign_key_check` 等价物(传输面实测结论见母 issue +/// `spec/issues/2026-09-26-session-store-remote-backend.md` §9.28 的例外 2/3/4)。 +/// 一份删除逻辑要跑在两种执行器上,唯一能共用的表达就是显式 +/// 删除,因此本机侧也按同一份语句、同一顺序(先子后父)删。 +/// +/// **为什么本机的 `REFERENCES threads(id) ON DELETE CASCADE` 声明保留**:删外键要重建 +/// 表,对用户既有的实盘库是不必要的风险,而收益只是省下几条 DELETE。所以声明留着, +/// 但它从此退化成**空操作式的安全网**——本机删除路径自己已经删过子行,级联再执行时无 +/// 行可删;级联是兜底,**不是承重机制**。承重的是这里的语句。 +/// +/// **表名不是注释,是比对键**:`thread_child_delete_tests` 从运行库的真实 schema 派生 +/// 「哪些表的行会随 `threads` 行消失」,并与本清单交叉核对。新增一张 +/// `REFERENCES threads(...)` 的子表会让那个测试失败,直到这里的语句补上。 +/// +/// 移除条件:两端不再依赖显式删除(远端能提供等价的级联语义,或出现统一的删除抽象) +/// 之前,不要删除清单里的任何一项。 +pub(super) const THREAD_CHILD_DELETES: &[(&str, &str)] = canonical::THREAD_CHILD_DELETES; + +/// 删除 `threads` 行本身的语句;只在 [`delete_thread_child_rows`] 之后执行(先子后父, +/// 与远端 `session_lifecycle` 的 messages → session_bindings → threads 顺序一致)。 +pub(super) const DELETE_THREAD_SQL: &str = canonical::DELETE_THREAD_ROW_SQL; + +/// 显式删除一条 `threads` 行的全部子行([`THREAD_CHILD_DELETES`] 逐条执行)。 +/// +/// 调用方负责随后用 [`DELETE_THREAD_SQL`] 删父行,并与本调用处于同一事务。返回 +/// `sqlx::Error` 而不是 `anyhow::Error`:调用点按 [`super::failure::map_sqlx`] 分类 +/// 失败(写失败 / 只读 / 忙),不把数据库故障降级成不透明错误。 +pub(super) async fn delete_thread_child_rows( + connection: &mut SqliteConnection, + thread_id: &str, +) -> std::result::Result<(), sqlx::Error> { + for (_, statement) in THREAD_CHILD_DELETES { + sqlx::query(*statement) + .bind(thread_id) + .execute(&mut *connection) + .await?; + } + Ok(()) +} diff --git a/peri-resources/src/sessions/sqlite_store/thread_child_delete_test.rs b/peri-resources/src/sessions/sqlite_store/thread_child_delete_test.rs new file mode 100644 index 000000000..88aaa8183 --- /dev/null +++ b/peri-resources/src/sessions/sqlite_store/thread_child_delete_test.rs @@ -0,0 +1,297 @@ +//! `threads` 行删除的不变量:子行清理是**显式**的,不依赖 SQLite 外键级联。 +//! +//! 两个方向各挡一类回归: +//! +//! - **schema 交叉核对**(`test_every_thread_referencing_table_is_deleted_explicitly`): +//! 期望集合从运行库的**真实 schema** 派生(哪些表的外键指向 `threads`),再与生产声明 +//! [`THREAD_CHILD_DELETES`] 比对。新增一张 `REFERENCES threads(...)` 的子表会让它失败, +//! 直到删除逻辑跟上。 +//! - **行为证明**(三个 `*_without_cascade` 测试):在 `PRAGMA foreign_keys = OFF` 的 pool +//! 上跑生产的删除路径,断言删除后没有一张派生子表留下孤儿行。级联被真正关掉,这条断言 +//! 因此只可能由显式删除满足——它就是远端执行器(无条件级联)上的同一份逻辑。 +//! +//! 为什么必须这样测:本机 schema 声明了 `ON DELETE CASCADE`,谁都能「看出」显式删除多余 +//! ——看错了。远端执行器提供不了级联(母 issue §9.28 的例外 2/3/4:默认读数为 0、跨连接 +//! 共享、无 `foreign_key_check` 等价物),级联在本机只是 +//! 安全网。删掉显式删除语句,本机外键打开时照样全绿,只有这里会红。 + +use peri_acp_types::session_resources::{FrozenSnapshotBytes, NewSession, NewSessionMeta}; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::workspace::{ResolvedWorkspace, SessionBinding, SESSION_BINDING_VERSION}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; +use tempfile::TempDir; + +use super::session_rows::THREAD_CHILD_DELETES; +use super::*; +use crate::sessions::data::SessionDataPort; + +// ─── 夹具:同一份 schema,关掉外键强制的执行器 ──────────────────────────────── + +struct NoCascade { + store: SqliteThreadStore, + data: SqliteSessionData, + directory: TempDir, +} + +/// 先由生产 open 建好 schema(含 `ON DELETE CASCADE` 声明),再关掉这个 pool,用**同一个 +/// 文件**、但 `PRAGMA foreign_keys = OFF` 的 pool 打开它——远端 Turso 默认读数的可复现替身。 +/// +/// 不为测试降低生产可见性:`SqliteSessionDatabase::new` / `SqliteSessionData::new` 对本模块 +/// (`sqlite_store` 的后代)本就可见。 +async fn without_cascade() -> NoCascade { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("threads.db"); + SqliteThreadStore::new(&path).await.unwrap().close().await; + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(false) + .pragma("journal_mode", "WAL") + .pragma("foreign_keys", "OFF"), + ) + .await + .unwrap(); + // 夹具自证:pool 上的读数必须是 0,否则下面每条断言都可能是级联替我们过的。 + let (reading,): (i64,) = sqlx::query_as("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(reading, 0, "夹具必须跑在关掉外键强制的执行器上"); + let database = Arc::new(SqliteSessionDatabase::new( + pool, + false, + tokio::fs::canonicalize(&path).await.unwrap(), + )); + NoCascade { + store: SqliteThreadStore { + database: Arc::clone(&database), + }, + data: SqliteSessionData::new(database), + directory, + } +} + +/// 一条会话 = `threads` 行 + `session_bindings` 行 + 两条 `messages` 行,覆盖 schema 里 +/// 当前全部指向 `threads` 的子表。 +async fn seed(fixture: &NoCascade, id: &str, parent: Option<&str>) { + let workspace: ResolvedWorkspace = fixture + .store + .database + .resolve_workspace_impl(fixture.directory.path()) + .await + .unwrap(); + let input = NewSession { + thread_id: id.to_owned(), + created_at: "2026-09-27T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: Some(format!("session {id}")), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: parent.map(str::to_owned), + hidden: parent.is_some(), + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + }, + frozen: FrozenSnapshotBytes::new(format!(r#"{{"v":1,"id":"{id}"}}"#)), + }; + fixture.data.save_new_session(&input).await.unwrap(); + fixture + .data + .append_history( + &id.to_owned(), + &[ + PersistedPayload::Message(BaseMessage::human(format!("{id} 1"))), + PersistedPayload::Message(BaseMessage::human(format!("{id} 2"))), + ], + ) + .await + .unwrap(); +} + +// ─── 期望集合:从真实 schema 派生,不从生产语句反推 ─────────────────────────── + +/// 「行会随 `threads` 行一起消失」的子表:`(表名, 外键列)`。 +/// +/// 唯一来源是运行库的 schema(`pragma_foreign_key_list`)。不拿 +/// [`THREAD_CHILD_DELETES`] 反推——那样测试只是实现自己的镜像,什么也挡不住。 +async fn schema_child_tables(pool: &SqlitePool) -> Vec<(String, String)> { + let tables: Vec<(String,)> = sqlx::query_as( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .fetch_all(pool) + .await + .unwrap(); + let mut child_tables = Vec::new(); + for (table,) in tables { + let references: Vec<(String, String)> = + sqlx::query_as("SELECT \"table\", \"from\" FROM pragma_foreign_key_list(?)") + .bind(&table) + .fetch_all(pool) + .await + .unwrap(); + for (parent, column) in references { + if parent == "threads" { + child_tables.push((table.clone(), column)); + } + } + } + child_tables.sort(); + child_tables +} + +/// 一条 thread 在某张表里还剩多少行。 +async fn rows_for(pool: &SqlitePool, table: &str, column: &str, id: &str) -> i64 { + let (count,): (i64,) = sqlx::query_as(AssertSqlSafe(format!( + "SELECT COUNT(*) FROM \"{table}\" WHERE \"{column}\" = ?1" + ))) + .bind(id) + .fetch_one(pool) + .await + .unwrap(); + count +} + +/// 删除前:每一张派生子表都必须有这些 thread 的行,否则「删完没有孤儿」是句空话。 +async fn assert_child_rows_present(pool: &SqlitePool, tables: &[(String, String)], ids: &[&str]) { + for (table, column) in tables { + for id in ids { + assert!( + rows_for(pool, table, column, id).await > 0, + "夹具必须让子表 {table} 有 {id} 的行:新增指向 threads 的子表后请在这里补种子数据" + ); + } + } +} + +/// 删除后:没有任何一张派生子表留下指向已删 thread 的孤儿行。 +async fn assert_no_orphans(pool: &SqlitePool, tables: &[(String, String)], ids: &[&str]) { + for (table, column) in tables { + for id in ids { + assert_eq!( + rows_for(pool, table, column, id).await, + 0, + "{table}.{column} 里还有 {id} 的孤儿行:删除路径借了 `ON DELETE CASCADE`,\ + 而远端执行器没有级联,请改成显式删除(见 THREAD_CHILD_DELETES)" + ); + } + } +} + +// ─── 期望集合 vs 生产语句 ───────────────────────────────────────────────────── + +#[tokio::test] +async fn test_every_thread_referencing_table_is_deleted_explicitly() { + let fixture = without_cascade().await; + let derived = schema_child_tables(&fixture.store.database.pool).await; + assert!( + !derived.is_empty(), + "schema 派生结果为空:探测本身失效,下面的交叉核对会变成空转" + ); + + for (table, column) in &derived { + let declared = THREAD_CHILD_DELETES.iter().find(|(name, _)| name == table); + let Some((_, statement)) = declared else { + panic!( + "{table} 有指向 threads 的外键:它的行会随 threads 行消失,\ + THREAD_CHILD_DELETES 必须显式删除它(远端执行器没有级联可借)" + ); + }; + assert!( + statement.contains(table.as_str()), + "声明了 {table},语句却删的是别的表:{statement}" + ); + assert!( + statement.contains(column.as_str()), + "{table} 的外键列是 {column},删除语句必须按该列删:{statement}" + ); + } + + for (table, _) in THREAD_CHILD_DELETES { + assert!( + derived.iter().any(|(name, _)| name == table), + "THREAD_CHILD_DELETES 里的 {table} 没有指向 threads 的外键:表名写错,\ + 或这份清单被用来装非外键子表(那需要连同本断言一起改)" + ); + } +} + +// ─── 行为证明:三条生产删除路径在无级联执行器上都不留孤儿 ───────────────────── + +/// 数据面 `delete_tree`(整棵子树)。 +#[tokio::test] +async fn test_data_delete_tree_leaves_no_orphans_without_cascade() { + let fixture = without_cascade().await; + let parent = "s-parent"; + let child = "s-child"; + seed(&fixture, parent, None).await; + seed(&fixture, child, Some(parent)).await; + let pool = fixture.store.database.pool.clone(); + let tables = schema_child_tables(&pool).await; + assert_child_rows_present(&pool, &tables, &[parent, child]).await; + + fixture.data.delete_tree(&parent.to_owned()).await.unwrap(); + + assert_no_orphans(&pool, &tables, &[parent, child]).await; + for id in [parent, child] { + assert_eq!(rows_for(&pool, "threads", "id", id).await, 0, "{id} 未删除"); + } +} + +/// 数据面 `revoke_unpublished_session`(单条未发布会话)。 +#[tokio::test] +async fn test_revoke_unpublished_session_leaves_no_orphans_without_cascade() { + let fixture = without_cascade().await; + let id = "s-revoked"; + seed(&fixture, id, None).await; + let pool = fixture.store.database.pool.clone(); + let tables = schema_child_tables(&pool).await; + assert_child_rows_present(&pool, &tables, &[id]).await; + + fixture + .data + .revoke_unpublished_session(&id.to_owned()) + .await + .unwrap(); + + assert_no_orphans(&pool, &tables, &[id]).await; + assert_eq!(rows_for(&pool, "threads", "id", id).await, 0, "{id} 未删除"); +} + +/// 迁移桥 `SqliteThreadStore::delete_thread`(`ThreadStore` 转发)。 +#[tokio::test] +async fn test_bridge_delete_thread_leaves_no_orphans_without_cascade() { + let fixture = without_cascade().await; + let id = "s-bridged"; + seed(&fixture, id, None).await; + let pool = fixture.store.database.pool.clone(); + let tables = schema_child_tables(&pool).await; + assert_child_rows_present(&pool, &tables, &[id]).await; + + // 桥的删除走执行准入:有绑定行的会话是「有主」的,按生产语义先取得所有权。 + let facts = fixture + .store + .database + .local_session_facts(&id.to_owned()) + .await + .unwrap(); + let _lease = fixture + .store + .database + .acquire_execution_lease_impl(&id.to_owned(), &facts) + .await + .unwrap(); + + fixture.store.delete_thread(&id.to_owned()).await.unwrap(); + + assert_no_orphans(&pool, &tables, &[id]).await; + assert_eq!(rows_for(&pool, "threads", "id", id).await, 0, "{id} 未删除"); +} diff --git a/peri-resources/src/sessions/sqlite_store/workspace.rs b/peri-resources/src/sessions/sqlite_store/workspace.rs index 2505abd80..19ed7b924 100644 --- a/peri-resources/src/sessions/sqlite_store/workspace.rs +++ b/peri-resources/src/sessions/sqlite_store/workspace.rs @@ -1,8 +1,8 @@ //! Registry and session binding transactions; SQL-scoped lightweight history pages. use super::{ + database::SqliteSessionDatabase, discovery::{self, Discovery, Observation}, - SqliteThreadStore, }; use anyhow::{Context, Result}; use peri_acp_types::{ @@ -12,9 +12,9 @@ use peri_acp_types::{ use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection}; use std::path::{Component, Path, PathBuf}; -type BindingRow = (i64, String, String, String); +pub(super) type BindingRow = (i64, String, String, String); -fn decode_binding(row: BindingRow) -> Result { +pub(super) fn decode_binding(row: BindingRow) -> Result { if row.0 != i64::from(SESSION_BINDING_VERSION) { return Err(WorkspaceError::InvalidBinding.into()); } @@ -29,7 +29,10 @@ fn decode_binding(row: BindingRow) -> Result { }) } -fn validate_relative(path: &Path) -> Result<()> { +/// 相对路径必须是纯普通分量(不含 `..`、根、前缀),且文本可逆。 +/// +/// 绑定写入与绑定解码共用本规则:写入方不能存下无法解码的路径。 +pub(super) fn validate_relative(path: &Path) -> Result<()> { if path .components() .any(|component| !matches!(component, Component::Normal(_))) @@ -54,7 +57,7 @@ fn binding_cwd(root: &Path, relative: &Path) -> PathBuf { } } -impl SqliteThreadStore { +impl SqliteSessionDatabase { pub(super) async fn resolve_workspace_impl(&self, cwd: &Path) -> Result { let (cwd, observed) = discovery::observe(cwd).await?; let Observation { @@ -204,7 +207,9 @@ impl SqliteThreadStore { // 同一次准入已在解析阶段观测过完整发现,这里再跑一轮 Git 只是把同一次观测 // 重复一遍,代价是每个创建方都要等 Git(含慢 Git 的固定等待)。 let write_guard = if let Some(parent) = &meta.parent_thread_id { - let guard = self.require_execution_lease(parent).await?; + // 桥与本机数据面共用同一个库:父线程的事实(含它是谁的子会话)按本机读法取。 + let facts = self.local_session_facts(parent).await?; + let guard = self.require_execution_lease(parent, &facts).await?; // 子线程继承父线程的同一工作区:比对的是已记录的绑定身份,不需要重新发现。 let parent_workspace = self.reassert_session_binding_impl(parent).await; match parent_workspace { @@ -357,6 +362,18 @@ impl SqliteThreadStore { let row: Option = sqlx::query_as("SELECT schema_version, project_id, workspace_id, relative_cwd FROM session_bindings WHERE thread_id = ?") .bind(id).fetch_optional(&mut *connection).await?; let binding = decode_binding(row.ok_or(WorkspaceError::BindingMissing)?)?; + Self::validate_binding_relation_on(connection, &binding).await + } + + /// 绑定值的本机复核:登记关系加关键文件对象。 + /// + /// 与 [`Self::validate_session_binding_on`] 的差别只在于事实来源:那个从已保存的 + /// binding 行读,这个复核调用方手上的 binding 值(新建/fork/child 在写入前用它, + /// 避免未登记的 project/workspace 直接落到外键失败上)。 + pub(super) async fn validate_binding_relation_on( + connection: &mut SqliteConnection, + binding: &SessionBinding, + ) -> Result { let row: (String,) = sqlx::query_as("SELECT root FROM workspaces WHERE id = ? AND project_id = ?") .bind(binding.workspace_id.to_string()) @@ -369,12 +386,33 @@ impl SqliteThreadStore { workspace_id: binding.workspace_id, cwd: binding_cwd(&root, &binding.cwd_relative_to_workspace), root, - relative_cwd: binding.cwd_relative_to_workspace, + relative_cwd: binding.cwd_relative_to_workspace.clone(), }; Self::validate_resolved_on(connection, &workspace).await?; Ok(workspace) } + /// 绑定**值**的本机复核(远程组合:绑定来自远端会话行,不在本机 `session_bindings`)。 + /// + /// 判定与本机绑定完全同一套:登记关系(project/workspace 必须在本机登记过)加关键文件 + /// 对象身份,`full` 时再叠一次完整发现快照比对。因此「远端 binding 指向的本机对象」与 + /// 「本机会话的绑定」不会出现两套结论。 + pub(super) async fn validate_binding_value_impl( + &self, + binding: &SessionBinding, + full: bool, + ) -> Result { + let mut connection = self.pool.acquire().await?; + let workspace = match Self::validate_binding_relation_on(&mut connection, binding).await { + Ok(workspace) => workspace, + Err(error) => return Err(normalize_binding_lookup(error)), + }; + if full { + Self::revalidate_registered_observation_on(&mut connection, &workspace).await?; + } + Ok(workspace) + } + pub(super) async fn list_scoped_threads_impl( &self, query: &ScopedThreadQuery, @@ -533,3 +571,12 @@ async fn legacy_windows_path_comparison_accepts_verbatim_drive_and_unc() { #[cfg(test)] #[path = "workspace_test.rs"] mod tests; + +/// `workspaces` 里查不到这条绑定引用的工作区:那是「本机不认识这个绑定」, +/// 不是内部错误。未登记的 project/workspace 因此得到 workspace 语义的失败。 +fn normalize_binding_lookup(error: anyhow::Error) -> anyhow::Error { + match error.downcast_ref::() { + Some(sqlx::Error::RowNotFound) => WorkspaceError::InvalidBinding.into(), + _ => error, + } +} diff --git a/peri-resources/src/sessions/sqlite_store/workspace_test.rs b/peri-resources/src/sessions/sqlite_store/workspace_test.rs index 2586c49ab..e77e54639 100644 --- a/peri-resources/src/sessions/sqlite_store/workspace_test.rs +++ b/peri-resources/src/sessions/sqlite_store/workspace_test.rs @@ -326,7 +326,7 @@ async fn test_worktree_registration_reuses_exact_object_and_keeps_rows_unique() SELECT 'duplicate', project_id, root, root_identity, discovery FROM workspaces WHERE id = ?", ) .bind(registered.workspace_id.to_string()) - .execute(&store.pool) + .execute(&store.database.pool) .await; assert!( duplicate.is_err(), @@ -461,7 +461,7 @@ async fn test_worktree_scoped_pages_and_exact_directory_are_lightweight() { .unwrap(); // Deliberately corrupt large owner blobs; listing never decodes or aggregates them. sqlx::query("UPDATE threads SET frozen_context = 'broken', cached_context = 'broken'") - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); let first = store @@ -564,7 +564,7 @@ async fn test_worktree_binding_keeps_wire_revision_without_persisted_column() { let (revision_columns,): (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM pragma_table_info('session_bindings') WHERE name = 'revision'", ) - .fetch_one(&store.pool) + .fetch_one(&store.database.pool) .await .unwrap(); assert_eq!(revision_columns, 0); @@ -655,7 +655,7 @@ async fn test_worktree_binding_survives_clean_reopen_and_unknown_versions_fail_c ); sqlx::query("UPDATE session_bindings SET schema_version = 99 WHERE thread_id = ?") .bind(&id) - .execute(&reopened.pool) + .execute(&reopened.database.pool) .await .unwrap(); assert!(matches!( @@ -1014,7 +1014,13 @@ async fn test_worktree_clean_waits_for_admitted_mutation_before_releasing_os_own let (store, db) = store().await; let (id, _) = bound(&store, repo.path()).await; let lease = store.acquire_execution_lease(&id).await.unwrap(); - let mutation = store.require_execution_lease(&id).await.unwrap().unwrap(); + let facts = store.database.local_session_facts(&id).await.unwrap(); + let mutation = store + .database + .require_execution_lease(&id, &facts) + .await + .unwrap() + .unwrap(); let mut close = std::pin::pin!(lease.mark_clean()); // Poll once with an admitted mutation suspended: close must not publish clean. assert!(matches!( @@ -1024,12 +1030,12 @@ async fn test_worktree_clean_waits_for_admitted_mutation_before_releasing_os_own lease_process(&db.path().join("threads.db"), &id, "busy"); sqlx::query("UPDATE threads SET title = 'last owner write' WHERE id = ?") .bind(&id) - .execute(&store.pool) + .execute(&store.database.pool) .await .unwrap(); let run: (bool,) = sqlx::query_as("SELECT clean FROM execution_runs WHERE thread_id = ?") .bind(&id) - .fetch_one(&store.pool) + .fetch_one(&store.database.pool) .await .unwrap(); assert!(!run.0); @@ -1048,7 +1054,13 @@ async fn test_worktree_cancelled_mutation_remains_dirty_and_cannot_publish_clean let (store, _db) = store().await; let (id, _) = bound(&store, repo.path()).await; let lease = store.acquire_execution_lease(&id).await.unwrap(); - let mutation = store.require_execution_lease(&id).await.unwrap().unwrap(); + let facts = store.database.local_session_facts(&id).await.unwrap(); + let mutation = store + .database + .require_execution_lease(&id, &facts) + .await + .unwrap() + .unwrap(); // Dropping the capability without its completion signal models future cancellation. drop(mutation); let error = lease.mark_clean().await.unwrap_err(); diff --git a/peri-resources/src/sessions/sqlite_store_test.rs b/peri-resources/src/sessions/sqlite_store_test.rs index b81776172..71eb9c222 100644 --- a/peri-resources/src/sessions/sqlite_store_test.rs +++ b/peri-resources/src/sessions/sqlite_store_test.rs @@ -838,7 +838,7 @@ async fn test_commit_compaction_lifecycle_persists_flags_and_appended_messages_i let summary = BaseMessage::human("压缩摘要"); let reinject = BaseMessage::human("重新注入的用户上下文"); - let lifecycle = CompactionLifecycle { + let lifecycle = CompactionChange { flag_updates: vec![ ( original_messages[0].id(), @@ -912,7 +912,7 @@ async fn test_commit_compaction_lifecycle_rolls_back_flags_and_appends_when_mess .unwrap(); let summary = BaseMessage::human("不应落库的压缩摘要"); - let lifecycle = CompactionLifecycle { + let lifecycle = CompactionChange { flag_updates: vec![ ( original_messages[0].id(), @@ -1193,7 +1193,7 @@ async fn test_readonly_store_loads_exact_meta_and_distinguishes_missing_session( expected.title = Some("title".into()); expected.agent_status = AgentStatus::Done; let id = writer.create_thread(expected.clone()).await.unwrap(); - writer.pool.close().await; + writer.database.pool.close().await; let database_before = database_snapshot(&db_path).await; let directory_before = directory_snapshot(dir.path()); let reader = SqliteThreadStore::open_existing_read_only(&db_path) @@ -1264,10 +1264,10 @@ async fn test_readonly_store_rejects_corrupt_enum_time_type_and_negative_counts_ "UPDATE threads SET {column} = {value} WHERE id = ?1" ))) .bind(&id) - .execute(&writer.pool) + .execute(&writer.database.pool) .await .unwrap(); - writer.pool.close().await; + writer.database.pool.close().await; let database_before = database_snapshot(&db_path).await; let reader = SqliteThreadStore::open_existing_read_only(&db_path) .await @@ -1299,7 +1299,7 @@ async fn test_readonly_backed_trait_rejects_mutation_and_preserves_row() { let writer = SqliteThreadStore::new(&db_path).await.unwrap(); let expected = ThreadMeta::new("/before"); let id = writer.create_thread(expected.clone()).await.unwrap(); - writer.pool.close().await; + writer.database.pool.close().await; let database_before = database_snapshot(&db_path).await; let reader: Arc = Arc::new( SqliteThreadStore::open_existing_read_only(&db_path) @@ -1334,7 +1334,7 @@ async fn test_readonly_store_observes_wal_commit_but_not_uncommitted_update() { .create_thread(ThreadMeta::new("/baseline")) .await .unwrap(); - let mut transaction = writer.pool.begin().await.unwrap(); + let mut transaction = writer.database.pool.begin().await.unwrap(); sqlx::query("UPDATE threads SET cwd = '/pending' WHERE id = ?1") .bind(&id) .execute(&mut *transaction) diff --git a/peri-resources/tests/session_resources_contract.rs b/peri-resources/tests/session_resources_contract.rs new file mode 100644 index 000000000..e5aa08ed1 --- /dev/null +++ b/peri-resources/tests/session_resources_contract.rs @@ -0,0 +1,411 @@ +//! 会话资源门面的公共行为契约(本机 SQLite)。 +//! +//! 这是 crate 外视角的验证:只经 `SessionResources` 的公共行为,不碰内部句柄。 +//! 覆盖访问模式与数据能力的独立性、只读路径的零副作用、以及「保存完整 → 执行准入 → +//! 删除结束整条会话」的端到端后置条件。 + +use std::path::Path; +use std::sync::Arc; + +use peri_acp_types::session_resources::SessionStoreShutdownPort; +use peri_acp_types::session_resources::{ + AccessMode, BindingState, DataCapabilities, ExecutionAvailability, FrozenSnapshotBytes, + FrozenState, NewSession, NewSessionMeta, RewindBoundary, SessionResourceErrorKind, + SessionResources, +}; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::workspace::{ + RecoveryRequiredDetails, ResetDirtyRequest, ResolvedWorkspace, ScopedThreadQuery, + SessionBinding, ThreadScope, SESSION_BINDING_VERSION, +}; +use peri_resources::sessions::{ReadOnlyStoreErrorKind, SessionResourcesImpl}; +use peri_resources::SessionStoreShutdownOwner; +use tempfile::TempDir; + +fn git(root: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "Git fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn repository() -> TempDir { + let directory = tempfile::tempdir().unwrap(); + git(directory.path(), &["init", "-q"]); + git( + directory.path(), + &[ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ); + directory +} + +fn binding(workspace: &ResolvedWorkspace) -> SessionBinding { + SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd.clone(), + } +} + +fn session(id: &str, workspace: &ResolvedWorkspace) -> NewSession { + NewSession { + thread_id: id.to_owned(), + created_at: "2026-09-26T00:00:00Z".to_owned(), + meta: NewSessionMeta { + // 标题留空:首条用户消息应成为自动标题,这条规则也是契约的一部分。 + title: None, + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: binding(workspace), + frozen: FrozenSnapshotBytes::new(format!(r#"{{"v":1,"id":"{id}"}}"#)), + } +} + +fn message(text: &str) -> PersistedPayload { + PersistedPayload::Message(peri_acp_types::messages::BaseMessage::human(text)) +} + +/// 部署装配点做的事:`Resources` 交出**业务句柄 + 关闭权**(唯一)。 +/// +/// 业务句柄(`Arc`)没有关闭路径,交给 Agent/Controller;关闭权 +/// 留在调用方,只有持有它的装配能在任务排空之后关闭存储。crate 外没有第二条取得关闭权 +/// 的路径:按具体类型打开只服务 I/O,`close` 只在 Resources 层内可见。 +async fn deployment(path: &Path) -> (Arc, SessionStoreShutdownOwner) { + peri_resources::Resources::open_with(Some(path.to_path_buf())) + .await + .unwrap() + .into_parts() +} + +fn files(directory: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + names.sort(); + names +} + +#[tokio::test] +async fn test_contract_create_append_rewind_and_reopen() { + let repo = repository(); + let db = tempfile::tempdir().unwrap(); + let path = db.path().join("threads.db"); + let (facade, shutdown) = deployment(&path).await; + let workspace = facade.resolve_workspace(repo.path()).await.unwrap(); + let id = "c-lifecycle".to_owned(); + + let lease = facade + .create_session(&session(&id, &workspace)) + .await + .unwrap(); + let first = message("first"); + let second = message("second"); + facade + .append_history(&id, &[first.clone(), second.clone()]) + .await + .unwrap(); + // 追加后 metadata 与历史一起更新,不需要调用方补做计数。 + let snapshot = facade.load_session_snapshot(&id).await.unwrap(); + assert_eq!(snapshot.meta.message_count, 2); + assert_eq!(snapshot.meta.title.as_deref(), Some("first")); + assert_eq!(snapshot.binding, BindingState::Bound(binding(&workspace))); + assert!(matches!(snapshot.frozen, FrozenState::Present(_))); + + // rewind 只改目标之后的历史,并同步派生计数。 + facade + .rewind_history(&id, RewindBoundary::RemoveFrom(second.id())) + .await + .unwrap(); + let snapshot = facade.load_session_snapshot(&id).await.unwrap(); + assert_eq!(snapshot.meta.message_count, 1); + assert_eq!(snapshot.payloads.len(), 1); + + lease.mark_clean().await.unwrap(); + drop(lease); + drop(facade); + shutdown.shutdown().await.unwrap(); + + // 重新打开:历史、绑定、frozen 与干净代际都还在。 + let facade = SessionResourcesImpl::open(&path).await.unwrap(); + let snapshot = facade.load_session_snapshot(&id).await.unwrap(); + assert_eq!(snapshot.meta.message_count, 1); + assert_eq!(snapshot.payloads.len(), 1); + assert_eq!(snapshot.binding, BindingState::Bound(binding(&workspace))); + assert_eq!( + facade + .inspect_availability(Some(&id)) + .await + .unwrap() + .execution, + Some(ExecutionAvailability::Available) + ); + // 列表不加载大快照,但仍能看到这条会话。 + let page = facade + .list_sessions(&ScopedThreadQuery { + scope: ThreadScope::All, + cursor: None, + limit: 10, + }) + .await + .unwrap(); + assert!(page.entries.iter().any(|entry| entry.thread.id == id)); +} + +#[tokio::test] +async fn test_contract_read_only_open_reports_independent_facts_and_writes_nothing() { + let repo = repository(); + let db = tempfile::tempdir().unwrap(); + let path = db.path().join("threads.db"); + let (facade, shutdown) = deployment(&path).await; + let workspace = facade.resolve_workspace(repo.path()).await.unwrap(); + let id = "c-readonly".to_owned(); + let lease = facade + .create_session(&session(&id, &workspace)) + .await + .unwrap(); + lease.mark_clean().await.unwrap(); + drop(lease); + drop(facade); + shutdown.shutdown().await.unwrap(); + let before = files(db.path()); + + let facade = SessionResourcesImpl::open_existing_read_only(&path) + .await + .unwrap(); + let availability = facade.inspect_availability(Some(&id)).await.unwrap(); + // 三个事实互不推导:只读授权、只读能力面、以及本条会话不能取得执行权。 + assert_eq!(availability.access, AccessMode::ReadOnly); + assert_eq!(availability.capabilities, DataCapabilities::HistoryReadOnly); + assert_eq!( + availability.execution, + Some(ExecutionAvailability::ReadOnlyStore) + ); + // 历史仍可读。 + assert!(facade.load_session_meta(&id).await.is_ok()); + // 写入在副作用之前失败:登记新身份、会话写入、取得所有权三条路径都明确拒绝。 + let error = facade.resolve_workspace(repo.path()).await.unwrap_err(); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Workspace( + peri_acp_types::workspace::WorkspaceError::ReadOnlyStore + ) + )); + let error = match facade + .create_session(&session("c-readonly-new", &workspace)) + .await + { + Ok(_) => panic!("expected create_session to fail on a read-only store"), + Err(error) => error, + }; + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Workspace( + peri_acp_types::workspace::WorkspaceError::ReadOnlyStore + ) + )); + let error = facade + .append_history(&id, &[message("blocked")]) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::ReadOnlyStore + )); + let error = match facade.acquire_execution(&id, &workspace).await { + Ok(_) => panic!("expected acquire_execution to fail on a read-only store"), + Err(error) => error, + }; + assert!(matches!( + error.kind(), + SessionResourceErrorKind::ReadOnlyStore + )); + // 只读路径不创建、不改写任何文件(含锁文件与 schema)。 + assert_eq!(before, files(db.path())); + drop(facade); +} + +#[tokio::test] +async fn test_contract_read_only_open_of_missing_database_creates_nothing() { + let db = tempfile::tempdir().unwrap(); + let path = db.path().join("missing.db"); + let error = SessionResourcesImpl::open_existing_read_only(&path) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), ReadOnlyStoreErrorKind::DatabaseNotFound); + assert_eq!(files(db.path()), Vec::::new()); +} + +#[tokio::test] +async fn test_contract_delete_removes_the_session_and_its_execution_facts() { + let repo = repository(); + let db = tempfile::tempdir().unwrap(); + let path = db.path().join("threads.db"); + let (facade, shutdown) = deployment(&path).await; + let workspace = facade.resolve_workspace(repo.path()).await.unwrap(); + let id = "c-delete".to_owned(); + let lease = facade + .create_session(&session(&id, &workspace)) + .await + .unwrap(); + facade + .append_history(&id, &[message("before delete")]) + .await + .unwrap(); + + facade.delete_session_tree(&id).await.unwrap(); + // 删除即删除:数据与执行事实一起消失,本机不留第二份「它被删过」的痕迹。 + assert!(facade.load_session_meta(&id).await.is_err()); + let error = facade + .inspect_availability(Some(&id)) + .await + .expect_err("删除之后这条 identity 连执行事实都不该剩下"); + assert!(matches!(error.kind(), SessionResourceErrorKind::NotFound)); + // 删除也结束了本次所有权:owner 的收尾是幂等成功(它已经不再持有任何东西)。 + lease.mark_clean().await.unwrap(); + // 收敛读取没有对象:会话不存在,就没有「可重载」这回事。 + let error = facade.recover_session_persistence(&id).await.unwrap_err(); + assert!(matches!(error.kind(), SessionResourceErrorKind::NotFound)); + // 重复删除按「数据事实」回答:会话不在了。 + let error = facade.delete_session_tree(&id).await.unwrap_err(); + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Workspace(_) | SessionResourceErrorKind::NotFound + )); + drop(lease); + drop(facade); + shutdown.shutdown().await.unwrap(); + + // 重开仍读到同一事实:删除不被回滚,同一 identity 也可以重新创建(没有 durable 的 + // 「不许再用」封印——删除的对象是数据与执行事实,不是这个名字)。 + let facade = SessionResourcesImpl::open(&path).await.unwrap(); + assert!(facade.load_session_meta(&id).await.is_err()); + facade + .create_session(&session(&id, &workspace)) + .await + .expect("删除之后同名 identity 应当可以重新创建"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_contract_owner_is_exclusive_across_processes() { + let repo = repository(); + let db = tempfile::tempdir().unwrap(); + let path = db.path().join("threads.db"); + let facade = SessionResourcesImpl::open(&path).await.unwrap(); + let workspace = facade.resolve_workspace(repo.path()).await.unwrap(); + let id = "c-exclusive".to_owned(); + let lease = facade + .create_session(&session(&id, &workspace)) + .await + .unwrap(); + + // 本进程持有 owner:另一进程取得所有权必须报忙(锁在,代际还不重要)。 + let output = child(&path, repo.path(), &id, "busy"); + assert!( + output.status.success(), + "child failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + lease.mark_clean().await.unwrap(); + drop(lease); + + // 释放后另一进程取得所有权并崩溃:脏代际跨进程可见,必须显式接受风险才可解除。 + let output = child(&path, repo.path(), &id, "crash"); + assert!(output.status.success()); + let error = match facade.acquire_execution(&id, &workspace).await { + Ok(_) => panic!("expected recovery to be required after the other process crashed"), + Err(error) => error, + }; + let SessionResourceErrorKind::Workspace( + peri_acp_types::workspace::WorkspaceError::RecoveryRequired(details), + ) = error.kind() + else { + panic!("expected a dirty generation, got: {error:?}"); + }; + assert_eq!(details.generation, 2); + facade + .reset_dirty_execution(&ResetDirtyRequest { + target: RecoveryRequiredDetails { + thread_id: id.clone(), + generation: details.generation, + }, + accept_risk: true, + }) + .await + .unwrap(); + let next = facade.acquire_execution(&id, &workspace).await.unwrap(); + next.mark_clean().await.unwrap(); +} + +/// 子进程入口:同一测试二进制的另一进程,经公共门面动作验证跨进程所有权。 +#[tokio::test] +async fn test_contract_child_process() { + let Ok(db) = std::env::var("PERI_TEST_CONTRACT_DB") else { + return; + }; + let id = std::env::var("PERI_TEST_CONTRACT_ID").unwrap(); + let repo = std::env::var("PERI_TEST_CONTRACT_REPO").unwrap(); + let expected = std::env::var("PERI_TEST_CONTRACT_EXPECT").unwrap(); + let facade = SessionResourcesImpl::open(db).await.unwrap(); + let workspace = facade.resolve_workspace(Path::new(&repo)).await.unwrap(); + match expected.as_str() { + "busy" => { + let error = match facade.acquire_execution(&id, &workspace).await { + Ok(_) => panic!("acquired ownership while another process holds it"), + Err(error) => error, + }; + assert!(matches!( + error.kind(), + SessionResourceErrorKind::Workspace( + peri_acp_types::workspace::WorkspaceError::ExecutionBusy + ) + )); + } + "crash" => { + let _lease = facade.acquire_execution(&id, &workspace).await.unwrap(); + std::process::exit(0); + } + other => panic!("unknown expected child result: {other}"), + } +} + +fn child(db: &Path, repo: &Path, id: &str, expected: &str) -> std::process::Output { + std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "test_contract_child_process", "--nocapture"]) + .env("PERI_TEST_CONTRACT_DB", db) + .env("PERI_TEST_CONTRACT_REPO", repo) + .env("PERI_TEST_CONTRACT_ID", id) + .env("PERI_TEST_CONTRACT_EXPECT", expected) + .output() + .unwrap() +} diff --git a/peri-tui/locales/en/main.ftl b/peri-tui/locales/en/main.ftl index cbd6dfac9..eb35936e0 100644 --- a/peri-tui/locales/en/main.ftl +++ b/peri-tui/locales/en/main.ftl @@ -1134,12 +1134,13 @@ popup-ask-user-hint-single-unsubmitted = ↑/↓::navigate · Space::select · popup-ask-user-title = Ask User # ---- Confirm Popup (P2) ---- +# 风险选择的取消项:与具体风险种类无关,各风险说明共用。 +risk-choice-cancel = Cancel (default) dirty-recovery-title = Clear dirty state and restore this session? dirty-recovery-risk = Old child processes may still be running. dirty-recovery-unknown = Previous side effects are unknown. dirty-recovery-responsibility = You accept the risk and responsibility for what follows. dirty-recovery-hint = Up/Down: select · Enter: apply · Esc: cancel -dirty-recovery-cancel = Cancel (default) dirty-recovery-accept = Accept risk, clear dirty state and load popup-confirm-empty = No pending confirmation. @@ -1205,6 +1206,7 @@ thread-history-assistant = Assistant thread-history-system = System context thread-history-tool = Tool result session-restore-failed = Session restore failed: { $error }. Retry or use /clear to create a session. +session-creation-failed = Session creation failed: { $error } panel-host-settings = Host settings diff --git a/peri-tui/locales/zh-CN/main.ftl b/peri-tui/locales/zh-CN/main.ftl index 54e43951f..f9bcbab6b 100644 --- a/peri-tui/locales/zh-CN/main.ftl +++ b/peri-tui/locales/zh-CN/main.ftl @@ -1132,12 +1132,13 @@ popup-ask-user-hint-single-unsubmitted = ↑/↓::导航 · Space::选择 · E popup-ask-user-title = 用户问答 # ---- Confirm Popup (P2) ---- +# 风险选择的取消项:与具体风险种类无关,各风险说明共用。 +risk-choice-cancel = 取消(默认) dirty-recovery-title = 解除 dirty 状态并恢复原会话? dirty-recovery-risk = 旧子进程可能仍在运行。 dirty-recovery-unknown = 之前的副作用未知。 dirty-recovery-responsibility = 继续表示你接受风险,并承担后续结果。 dirty-recovery-hint = 上/下:选择 · Enter:执行 · Esc:取消 -dirty-recovery-cancel = 取消(默认) dirty-recovery-accept = 接受风险,解除 dirty 并加载 popup-confirm-empty = 暂无待确认项。 @@ -1203,6 +1204,7 @@ thread-history-assistant = 助手 thread-history-system = 系统上下文 thread-history-tool = 工具结果 session-restore-failed = 会话恢复失败:{ $error }。请重试或使用 /clear 创建会话。 +session-creation-failed = 会话创建失败:{ $error } panel-host-settings = 宿主配置 diff --git a/peri-tui/src/acp_client/client/recovery_test.rs b/peri-tui/src/acp_client/client/recovery_test.rs index 20af683b2..90d01bc3a 100644 --- a/peri-tui/src/acp_client/client/recovery_test.rs +++ b/peri-tui/src/acp_client/client/recovery_test.rs @@ -151,11 +151,11 @@ fn read_only_response(admission: &ReadOnlyAdmission) -> Value { } async fn wait_for_recovery_owner() --> std::sync::Arc { +-> std::sync::Arc { tokio::time::timeout(Duration::from_secs(5), async { loop { if let Some(atoms::ConfirmPayload { - pending_action: atoms::ConfirmAction::RecoverDirty(owner), + pending_action: atoms::ConfirmAction::RiskChoice(owner), .. }) = CONFIRM_PAYLOAD.state().read().as_ref() { @@ -168,6 +168,15 @@ async fn wait_for_recovery_owner() .expect("dirty load must offer an explicit risk decision") } +/// 风险选择载荷里的精确 dirty 目标。 +fn dirty_target( + owner: &std::sync::Arc, +) -> &RecoveryRequiredDetails { + // `RiskPrompt` 目前只有 dirty 解除一种形态:解出目标即证明载荷没有被换成别的风险。 + let crate::kit::popups::confirm_popup::RiskPrompt::DirtyRecovery(target) = &owner.prompt; + target +} + /// 接受风险后才写库:reset 携带精确 target 与显式字段,随后按原 ID/原目录恢复。 #[tokio::test] #[serial_test::serial] @@ -180,7 +189,7 @@ async fn test_dirty_load_accept_resets_exact_generation_then_loads_original_sess reach_dirty_load(&server, recovery_error(TARGET, 4)).await; let owner = wait_for_recovery_owner().await; assert_eq!( - owner.target, + dirty_target(&owner).clone(), RecoveryRequiredDetails { thread_id: TARGET.to_string(), generation: 4 @@ -641,7 +650,7 @@ async fn test_read_only_dirty_load_accept_resets_exact_generation_then_commits_o .await; let owner = wait_for_recovery_owner().await; - assert_eq!(owner.target, target); + assert_eq!(dirty_target(&owner), &target); owner.mark_displayed(); owner.answer(true); diff --git a/peri-tui/src/acp_client/client/session.rs b/peri-tui/src/acp_client/client/session.rs index 37b3758be..e9c737b9e 100644 --- a/peri-tui/src/acp_client/client/session.rs +++ b/peri-tui/src/acp_client/client/session.rs @@ -441,7 +441,11 @@ impl AcpTuiClient { && self .session_recovery .load(std::sync::atomic::Ordering::Acquire) - && crate::kit::popups::confirm_popup::confirm_dirty_recovery(target.clone()).await + && crate::kit::popups::confirm_popup::confirm_risk_choice( + crate::kit::popups::confirm_popup::RiskPrompt::DirtyRecovery(target.clone()), + ) + .await + == crate::kit::popups::confirm_popup::RiskChoice::Accepted { // operation gate 固定 source/target;确认等待期间不能提交其他 transition。 let ack = peri_acp_types::workspace::ResetDirtyRequest { diff --git a/peri-tui/src/app/mod.rs b/peri-tui/src/app/mod.rs index f6eac2370..630920197 100644 --- a/peri-tui/src/app/mod.rs +++ b/peri-tui/src/app/mod.rs @@ -25,7 +25,7 @@ mod provider; use crate::acp_client::AcpTuiClient; use crate::config::PeriConfig; -use std::path::PathBuf; +use peri_acp_types::session_store::SessionStoreDeployment; // ─── App ────────────────────────────────────────────────────────────────────── @@ -43,12 +43,17 @@ pub struct App { /// Initialized after App construction in run_app(); None until `set_acp_client` is called. pub acp_client: Option, pub(crate) acp_deployment: Option, + /// 部署关闭权(non-Clone):业务侧只拿 `session_resources`,全局销毁权留在这里, + /// 由宿主装配接管(见 `attach_acp` → `HostAssemblyInput`),在任务排空之后关闭。 + pub(crate) session_store_shutdown: + Option>, } impl App { - /// `db_path`:显式指定 SQLite 会话数据库路径;`None` 使用默认路径。 - /// 任一路径打开失败都会直接返回错误。 - pub async fn new(db_path: Option) -> anyhow::Result { + /// `session_store`:入口归一的会话存储定位描述(默认本机库 / 显式本机路径 / + /// 远程 locator + 凭证来源)。locator 的纯解析与后端选择在资源装配层完成, + /// 本函数不重新解释存储位置,打开失败直接返回错误。 + pub async fn new(session_store: SessionStoreDeployment) -> anyhow::Result { let cwd = std::env::current_dir() .unwrap_or_default() .to_string_lossy() @@ -87,12 +92,13 @@ impl App { None => lc.tr("app-not-configured"), }; - // 初始化 thread 存储(经 Resources 门面);打开失败直接上抛, - // TUI 路径由 run_tui 决定 exit 码。 - let resources = peri_resources::Resources::open_with(db_path) + // 初始化 thread 存储(经 Resources 门面);定位描述由入口归一, + // 纯解析失败与打开失败都直接上抛,TUI 路径由 run_tui 决定 exit 码。 + let resources = peri_resources::Resources::open_deployment(&session_store) .await .map_err(|e| anyhow::anyhow!("无法初始化 Resources 层: {e}"))?; - let thread_store: std::sync::Arc = resources.thread_store(); + // 业务句柄进服务注册表,部署关闭权留在 App(non-Clone),由宿主装配消费。 + let (session_resources, session_store_shutdown) = resources.into_parts(); // 初始化 cron state + spawn tick task let (cron_state, scheduler_arc) = CronState::new(); @@ -108,7 +114,7 @@ impl App { cwd: cwd.clone(), provider_name: provider_name.clone(), permission_mode: permission_mode.clone(), - thread_store: thread_store.clone(), + session_resources: session_resources.clone(), mcp_pool: None, mcp_task_owner: None, mcp_init_rx: None, @@ -126,6 +132,7 @@ impl App { config_source, acp_client: None, acp_deployment: None, + session_store_shutdown: Some(Box::new(session_store_shutdown)), }) } diff --git a/peri-tui/src/app/service_registry.rs b/peri-tui/src/app/service_registry.rs index 34afd69f8..27c23d36a 100644 --- a/peri-tui/src/app/service_registry.rs +++ b/peri-tui/src/app/service_registry.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use parking_lot::RwLock; use peri_acp_types::permission::SharedPermissionMode; use peri_acp_types::plugin::PluginLoadResult; +use peri_acp_types::session_resources::SessionResources; use super::cron_state::CronState; -use crate::{config::PeriConfig, thread::ThreadStore}; +use crate::config::PeriConfig; /// `ServiceRegistry` 中共享的配置类型:单一来源(Single Source of Truth)。 /// @@ -79,7 +80,8 @@ pub struct ServiceRegistry { pub cwd: String, pub provider_name: String, pub permission_mode: Arc, - pub thread_store: Arc, + /// 会话资源门面:Agent transcript/subagent、middleware 与协议面的唯一会话行为入口。 + pub session_resources: Arc, pub mcp_pool: Option>, pub mcp_task_owner: Option, pub mcp_init_rx: Option>, diff --git a/peri-tui/src/cli_integration_test.rs b/peri-tui/src/cli_integration_test.rs index 0990558bd..2bd676431 100644 --- a/peri-tui/src/cli_integration_test.rs +++ b/peri-tui/src/cli_integration_test.rs @@ -42,6 +42,15 @@ struct TestCli { config_file: Option, #[arg(long = "db-path", visible_alias = "dbPath")] db_path: Option, + #[arg(long = "session-store", visible_alias = "sessionStore")] + session_store: Option, + #[arg( + long = "session-store-token-env", + visible_alias = "sessionStoreTokenEnv" + )] + session_store_token_env: Option, + #[arg(long = "session-store-engine", visible_alias = "sessionStoreEngine")] + session_store_engine: Option, } #[test] @@ -559,6 +568,65 @@ fn test_real_cli_parses_config_and_db_flags() { assert_eq!(cli.db_path, Some(PathBuf::from("/tmp/threads.db"))); } +#[test] +fn test_session_store_flags_parse() { + let cli = TestCli::try_parse_from([ + "peri", + "--session-store", + "env:TURSO_URL", + "--session-store-token-env", + "TURSO_TOEKN", + "--session-store-engine", + "turso", + ]) + .unwrap(); + assert_eq!(cli.session_store.as_deref(), Some("env:TURSO_URL")); + assert_eq!(cli.session_store_token_env.as_deref(), Some("TURSO_TOEKN")); + assert_eq!(cli.session_store_engine.as_deref(), Some("turso")); +} + +#[test] +fn test_session_store_camel_aliases_parse() { + let cli = TestCli::try_parse_from([ + "peri", + "--sessionStore", + "/tmp/threads.db", + "--sessionStoreTokenEnv", + "PERI_TEST_TOKEN", + "--sessionStoreEngine", + "libsql", + ]) + .unwrap(); + assert_eq!(cli.session_store.as_deref(), Some("/tmp/threads.db")); + assert_eq!( + cli.session_store_token_env.as_deref(), + Some("PERI_TEST_TOKEN") + ); + assert_eq!(cli.session_store_engine.as_deref(), Some("libsql")); +} + +#[test] +fn test_session_store_missing_value_errors() { + assert!(TestCli::try_parse_from(["peri", "--session-store"]).is_err()); + assert!(TestCli::try_parse_from(["peri", "--session-store-token-env"]).is_err()); + assert!(TestCli::try_parse_from(["peri", "--session-store-engine"]).is_err()); +} + +#[test] +fn test_real_cli_parses_session_store_flags() { + // 直测真实 Cli(防 TestCli 镜像漂移) + let cli = Cli::try_parse_from([ + "peri", + "--session-store=env:TURSO_URL", + "--session-store-token-env=TURSO_TOEKN", + "--session-store-engine=turso", + ]) + .unwrap(); + assert_eq!(cli.session_store.as_deref(), Some("env:TURSO_URL")); + assert_eq!(cli.session_store_token_env.as_deref(), Some("TURSO_TOEKN")); + assert_eq!(cli.session_store_engine.as_deref(), Some("turso")); +} + /// [回归测试] 退役的审批快捷参数不得再被真实 CLI 接受。 #[test] fn test_removed_approve_flags_are_rejected() { diff --git a/peri-tui/src/cli_meta.rs b/peri-tui/src/cli_meta.rs index e9f5f1922..22bb9c0d1 100644 --- a/peri-tui/src/cli_meta.rs +++ b/peri-tui/src/cli_meta.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; - -use peri_acp_types::thread::ThreadMeta; -use peri_tui::thread::{ReadOnlyThreadStoreError, open_thread_store_read_only}; +use peri_acp_types::session_resources::{SessionResourceError, SessionResourceErrorKind}; +use peri_acp_types::session_store::SessionStoreDeployment; +use peri_acp_types::thread::{ThreadId, ThreadMeta}; +use peri_resources::{StoreOpenFailure, classify_open_failure}; use serde::Serialize; use uuid::Uuid; @@ -52,6 +52,10 @@ enum MetaErrorKind { SchemaIncompatible, SessionNotFound, CorruptSessionData, + /// 存储定位/凭证来源等配置错误(缺配置、互斥、未知引擎)。 + StoreNotConfigured, + /// 存储后端不可用(远程 adapter 未接线、连接失败)。 + StoreUnavailable, InternalError, } @@ -65,6 +69,8 @@ impl MetaErrorKind { Self::SchemaIncompatible => "schema_incompatible", Self::SessionNotFound => "session_not_found", Self::CorruptSessionData => "corrupt_session_data", + Self::StoreNotConfigured => "store_not_configured", + Self::StoreUnavailable => "store_unavailable", Self::InternalError => "internal_error", } } @@ -78,6 +84,8 @@ impl MetaErrorKind { Self::SchemaIncompatible => "thread database schema is incompatible", Self::SessionNotFound => "session was not found", Self::CorruptSessionData => "stored session metadata is corrupt", + Self::StoreNotConfigured => "session store configuration is invalid", + Self::StoreUnavailable => "session store is currently unavailable", Self::InternalError => "an internal error occurred", } } @@ -85,9 +93,12 @@ impl MetaErrorKind { fn exit_code(self) -> u8 { match self { Self::InternalError => 1, - Self::InvalidArgument | Self::InvalidSessionId => 2, + Self::InvalidArgument | Self::InvalidSessionId | Self::StoreNotConfigured => 2, Self::DatabaseNotFound | Self::SessionNotFound => 3, - Self::DatabaseUnreadable | Self::SchemaIncompatible | Self::CorruptSessionData => 4, + Self::DatabaseUnreadable + | Self::SchemaIncompatible + | Self::CorruptSessionData + | Self::StoreUnavailable => 4, } } } @@ -114,7 +125,7 @@ pub(crate) fn internal_error_outcome(json: bool) -> MetaCommandOutcome { } pub(crate) async fn run_meta_session( - db_path: Option, + deployment: SessionStoreDeployment, session_id: String, json: bool, ) -> MetaCommandOutcome { @@ -124,32 +135,44 @@ pub(crate) async fn run_meta_session( return error_outcome(MetaErrorKind::InvalidSessionId, json); } - let store = match open_thread_store_read_only(db_path).await { - Ok(store) => store, - Err(error) => return error_outcome(map_storage_error(&error), json), + // 统一只读入口:与 TUI/print/stdio 共享同一个 typed open request 与后端选择点, + // 访问意图在入口处固定为只读(不写任何本机文件、不登记 owner、不建目录)。 + let resources = match peri_resources::Resources::open_deployment(&deployment).await { + Ok(resources) => resources, + Err(error) => return error_outcome(map_open_error(&error), json), }; - let meta = match store.load_meta(&session_id).await { + let meta = match resources + .session_resources() + .load_session_meta(&ThreadId::from(session_id)) + .await + { Ok(meta) => meta, - Err(error) => { - let kind = error - .downcast_ref::() - .map(map_storage_error) - .unwrap_or(MetaErrorKind::InternalError); - return error_outcome(kind, json); - } + Err(error) => return error_outcome(map_resource_error(&error), json), }; success_outcome(SessionMetaDtoV1::from(meta), json) } -fn map_storage_error(error: &ReadOnlyThreadStoreError) -> MetaErrorKind { - match error { - ReadOnlyThreadStoreError::DatabaseNotFound => MetaErrorKind::DatabaseNotFound, - ReadOnlyThreadStoreError::DatabaseUnreadable => MetaErrorKind::DatabaseUnreadable, - ReadOnlyThreadStoreError::SchemaIncompatible => MetaErrorKind::SchemaIncompatible, - ReadOnlyThreadStoreError::SessionNotFound => MetaErrorKind::SessionNotFound, - ReadOnlyThreadStoreError::CorruptSessionData => MetaErrorKind::CorruptSessionData, - ReadOnlyThreadStoreError::Internal => MetaErrorKind::InternalError, +/// 门面读取失败按既有只读命令语义分类:只读打开已经成功,因此这里的失败只表达 +/// 「这条会话查不到」「库内容读不懂」或「库此刻读不了」,其余一律归内部错误。 +fn map_resource_error(error: &SessionResourceError) -> MetaErrorKind { + match error.kind() { + SessionResourceErrorKind::NotFound => MetaErrorKind::SessionNotFound, + SessionResourceErrorKind::Corrupt { .. } => MetaErrorKind::CorruptSessionData, + SessionResourceErrorKind::Unavailable { .. } => MetaErrorKind::DatabaseUnreadable, + _ => MetaErrorKind::InternalError, + } +} + +fn map_open_error(error: &anyhow::Error) -> MetaErrorKind { + match classify_open_failure(error) { + StoreOpenFailure::NotConfigured => MetaErrorKind::StoreNotConfigured, + StoreOpenFailure::NotFound => MetaErrorKind::DatabaseNotFound, + StoreOpenFailure::Unreadable => MetaErrorKind::DatabaseUnreadable, + StoreOpenFailure::SchemaIncompatible => MetaErrorKind::SchemaIncompatible, + StoreOpenFailure::Corrupt => MetaErrorKind::CorruptSessionData, + StoreOpenFailure::Unavailable => MetaErrorKind::StoreUnavailable, + StoreOpenFailure::Internal => MetaErrorKind::InternalError, } } diff --git a/peri-tui/src/cli_meta_test.rs b/peri-tui/src/cli_meta_test.rs index 8850ec529..6299a4d7a 100644 --- a/peri-tui/src/cli_meta_test.rs +++ b/peri-tui/src/cli_meta_test.rs @@ -1,4 +1,8 @@ +use std::path::PathBuf; + use chrono::{TimeZone, Utc}; +use peri_acp_types::session_resources::AccessMode; +use peri_acp_types::session_store::SessionStoreDeployment; use peri_acp_types::thread::{AgentStatus, ThreadMeta}; use super::*; @@ -79,6 +83,8 @@ fn json_errors_have_stable_shape_and_exit_mapping() { (MetaErrorKind::SchemaIncompatible, "schema_incompatible", 4), (MetaErrorKind::SessionNotFound, "session_not_found", 3), (MetaErrorKind::CorruptSessionData, "corrupt_session_data", 4), + (MetaErrorKind::StoreNotConfigured, "store_not_configured", 2), + (MetaErrorKind::StoreUnavailable, "store_unavailable", 4), (MetaErrorKind::InternalError, "internal_error", 1), ]; @@ -110,7 +116,8 @@ fn internal_error_human_contract_is_stable_without_product_trigger() { #[tokio::test] async fn invalid_uuid_fails_before_missing_database_is_observed() { let outcome = run_meta_session( - Some(PathBuf::from("/definitely/missing/threads.db")), + SessionStoreDeployment::local_path(PathBuf::from("/definitely/missing/threads.db")) + .with_access(AccessMode::ReadOnly), "not-a-uuid".to_owned(), true, ) @@ -121,3 +128,89 @@ async fn invalid_uuid_fails_before_missing_database_is_observed() { assert_eq!(outcome.exit_code, 2); assert_eq!(value["error"]["kind"], "invalid_session_id"); } + +// ─── 统一只读入口的错误映射(D-04)─────────────────────────────────────────── + +const META_TEST_UUID: &str = "550e8400-e29b-41d4-a716-446655440000"; + +/// 显式不会存在的凭证变量名:用例自己保证它不存在,因此既不受开发者环境影响, +/// 也不会真的去打真实网络。 +const ABSENT_CREDENTIAL_ENV: &str = "PERI_META_TEST_ABSENT_TOKEN_ENV"; + +/// 缺库仍是原来的 database_not_found(exit 3),不被新分类掩盖。 +#[tokio::test] +async fn missing_database_maps_to_database_not_found() { + let dir = tempfile::tempdir().unwrap(); + let deployment = SessionStoreDeployment::local_path(dir.path().join("missing.db")) + .with_access(AccessMode::ReadOnly); + let outcome = run_meta_session(deployment, META_TEST_UUID.to_owned(), true).await; + let value: serde_json::Value = + serde_json::from_str(outcome.stderr.as_deref().unwrap()).unwrap(); + + assert_eq!(outcome.exit_code, 3); + assert_eq!(value["error"]["kind"], "database_not_found"); +} + +/// 远程 locator 而凭证来源没配好(变量显式不存在)是**配置**错误:exit 2 +/// `store_not_configured`,在连网与本机 I/O 之前失败;locator 原文与凭证来源名都不回显。 +/// +/// 该用例的前身是 `unwired_remote_store_reports_unavailable`(前提 `RemoteStoreNotWired` +/// 已在 C 批删除,远程分支是真装配):此时再断言「远程不可用」既非事实,也要求真去连网。 +#[tokio::test] +async fn missing_credential_configuration_is_a_configuration_error() { + // 受控环境:显式移除变量,保证这条断言不依赖开发者环境、也不会打真实网络。 + unsafe { + std::env::remove_var(ABSENT_CREDENTIAL_ENV); + } + assert!( + std::env::var_os(ABSENT_CREDENTIAL_ENV).is_none(), + "凭证变量必须显式不存在: {ABSENT_CREDENTIAL_ENV}" + ); + + let outcome = run_meta_session( + SessionStoreDeployment::from_locator("turso://sentinel-db-sentinel-org.turso.io") + .with_credential_env(ABSENT_CREDENTIAL_ENV) + .with_access(AccessMode::ReadOnly), + META_TEST_UUID.to_owned(), + true, + ) + .await; + let value: serde_json::Value = + serde_json::from_str(outcome.stderr.as_deref().unwrap()).unwrap(); + let rendered = outcome.stderr.as_deref().unwrap(); + + assert_eq!(outcome.exit_code, 2); + assert_eq!(value["error"]["kind"], "store_not_configured"); + assert!( + !rendered.contains("sentinel-db-sentinel-org"), + "不回显 locator 原文: {rendered}" + ); + assert!( + !rendered.contains(ABSENT_CREDENTIAL_ENV), + "不回显凭证来源名: {rendered}" + ); +} + +/// 缺少 locator 的凭证来源是配置错误:不是「空库」也不是「不存在」。 +#[tokio::test] +async fn credential_without_locator_is_a_configuration_error() { + let outcome = run_meta_session( + SessionStoreDeployment::default_local() + .with_credential_env(ABSENT_CREDENTIAL_ENV) + .with_access(AccessMode::ReadOnly), + META_TEST_UUID.to_owned(), + false, + ) + .await; + + assert_eq!(outcome.exit_code, 2); + assert!( + outcome + .stderr + .as_deref() + .unwrap() + .contains("store_not_configured"), + "{:?}", + outcome.stderr + ); +} diff --git a/peri-tui/src/cli_print.rs b/peri-tui/src/cli_print.rs index 64a021612..2c5d50687 100644 --- a/peri-tui/src/cli_print.rs +++ b/peri-tui/src/cli_print.rs @@ -6,7 +6,6 @@ //! 同源,不复制),执行路径与 TUI 完全一致(session/new → prompt → 事件收集 //! → close)。 -use std::path::PathBuf; use std::sync::Arc; use crate::cli_args::OutputFormat; @@ -16,6 +15,7 @@ use peri_acp::host::assemble::{HostAssemblyInput, assemble_server_config}; use peri_acp::transport::mpsc::mpsc_transport_pair; use peri_acp_types::interaction::UnansweredCause; use peri_acp_types::messages::MessageContent; +use peri_acp_types::session_store::SessionStoreDeployment; use peri_tui::acp_client::{ AcpDeployment, AcpNotification, AcpTuiClient, interaction_response::{ @@ -39,7 +39,7 @@ pub async fn run_print( disallowed_tools: Vec, settings_path: Option, cwd: Option, - db_path: Option, + session_store: SessionStoreDeployment, ) -> Result<()> { let fmt: OutputFormat = match output_format.as_deref() { Some(s) => s.parse().map_err(|e: String| anyhow::anyhow!(e))?, @@ -138,11 +138,12 @@ pub async fn run_print( // thread 存储(经 Resources 门面)——协议面输入,ACP host 的 ephemeral // session 需要;middlewares 具体实现(CronScheduler / McpClientPool / 插件 // 数据等)由 ACP Host 装配面内部构造(§0 依赖方向)。 - // 默认或显式 db_path 打开失败都经 `?` 传播 exit 1。 - let thread_store = peri_resources::Resources::open_with(db_path) + // 定位描述由入口归一,本函数不重新解释存储位置;默认或显式定位失败都经 `?` 传播 exit 1。 + let resources = peri_resources::Resources::open_deployment(&session_store) .await - .map(|resources| resources.thread_store()) .map_err(|e| anyhow::anyhow!("无法初始化 Resources 层: {e}"))?; + // 业务句柄进宿主配置,部署关闭权留在部署这一侧(随配置交给宿主,排空后关闭)。 + let (session_resources, session_store_shutdown) = resources.into_parts(); // ── ACP host 装配(与 TUI 同源,见 peri_acp::host::assemble)── let host_config = assemble_server_config(HostAssemblyInput { @@ -150,11 +151,14 @@ pub async fn run_print( peri_config: Arc::new(parking_lot::RwLock::new(peri_config)), config_source: config_source.clone(), permission_mode: shared_permission, - thread_store: thread_store.clone(), + session_resources: session_resources.clone(), + session_store_shutdown: Some(Box::new(session_store_shutdown)), cwd: cwd.clone(), bare, // print 无 tick 语义(迁移前 print 路径无每秒 tick,行为零变化)。 drive_cron_tick: false, + // print 装配点无准备路径提供的插件聚合:按既有语义由装配面自行加载。 + prepared_plugins: None, }) .await; let (client_transport, server_transport) = mpsc_transport_pair(); diff --git a/peri-tui/src/kit/atoms.rs b/peri-tui/src/kit/atoms.rs index e730c783b..2c96f664e 100644 --- a/peri-tui/src/kit/atoms.rs +++ b/peri-tui/src/kit/atoms.rs @@ -725,8 +725,8 @@ pub static NOTIFICATION: AtomStatic> = AtomStatic::new(|| N /// 确认弹窗要执行的操作 #[derive(Debug, Clone)] pub enum ConfirmAction { - /// 仅当前 load transition 消费的一次性风险选择。 - RecoverDirty(std::sync::Arc), + /// 仅发起它的那次会话操作消费的一次性风险选择(dirty 解除)。 + RiskChoice(std::sync::Arc), /// 切换到指定 thread_id ThreadSwitch(String), /// 用户确认拒绝回答 AskUser 提问 diff --git a/peri-tui/src/kit/entry.rs b/peri-tui/src/kit/entry.rs index 894684263..ac0e6acfb 100644 --- a/peri-tui/src/kit/entry.rs +++ b/peri-tui/src/kit/entry.rs @@ -447,7 +447,19 @@ pub async fn run_kit_fullscreen( session_id ); } - Err(e) => tracing::warn!(error = %e, "kit: initial session creation failed"), + Err(e) => { + tracing::warn!(error = %e, "kit: initial session creation failed"); + // 启动时这次创建此前只有日志:界面既没有会话也没有原因,用户 + // 看到的只是「发不出消息」。会话存储未登记这类可修复的原因必须 + // 让用户看见(登记确认也可能因为窗口装不下而没能呈现)。 + *atoms::NOTIFICATION.state().write() = Some(atoms::Notification { + message: crate::i18n::tr_args( + "session-creation-failed", + &[("error".into(), e.to_string().into())], + ), + until: Instant::now() + std::time::Duration::from_secs(15), + }); + } } }); } diff --git a/peri-tui/src/kit/popup_overlay.rs b/peri-tui/src/kit/popup_overlay.rs index b4a860569..171270f67 100644 --- a/peri-tui/src/kit/popup_overlay.rs +++ b/peri-tui/src/kit/popup_overlay.rs @@ -47,7 +47,7 @@ pub fn PopupOverlay(mut hooks: Hooks) -> impl Into> { hooks.use_effect( move || { if !confirm_displayed { - crate::kit::popups::confirm_popup::cancel_pending_dirty_recovery(); + crate::kit::popups::confirm_popup::cancel_pending_risk_choice(); } }, confirm_displayed, @@ -59,20 +59,20 @@ pub fn PopupOverlay(mut hooks: Hooks) -> impl Into> { Some(PopupKind::Rewind) => render_popup(element!(RewindPopup()).into(), term_w, term_h), Some(PopupKind::OAuth) => render_popup(element!(OAuthPopup()).into(), term_w, term_h), Some(PopupKind::Confirm) => { - let recovery = atoms::CONFIRM_PAYLOAD + let risk = atoms::CONFIRM_PAYLOAD .state() .read() .as_ref() .and_then(|p| { - if let atoms::ConfirmAction::RecoverDirty(owner) = &p.pending_action { + if let atoms::ConfirmAction::RiskChoice(owner) = &p.pending_action { Some(owner.clone()) } else { None } }); - if let Some(owner) = recovery { + if let Some(owner) = risk { let popup: AnyElement<'static> = - element!(crate::kit::popups::confirm_popup::DirtyRecoveryPopup( + element!(crate::kit::popups::confirm_popup::RiskPopup( owner: Some(owner) )) .into(); @@ -132,11 +132,11 @@ fn render_empty() -> AnyElement<'static> { /// 打开弹窗(覆盖式)。已打开其他弹窗会被替换。 /// -/// 替换边界必须精确结清被覆盖的 dirty 风险选择:新 popup 保留,旧的等待方按 +/// 替换边界必须精确结清被覆盖的风险选择:新 popup 保留,旧的等待方按 /// 取消收敛,否则 load 会永久占住 operation gate(首帧之前没有 render Drop)。 pub fn open_popup(kind: PopupKind) { if kind != PopupKind::Confirm { - crate::kit::popups::confirm_popup::cancel_pending_dirty_recovery(); + crate::kit::popups::confirm_popup::cancel_pending_risk_choice(); } *atoms::POPUP_KIND.state().write() = Some(kind); } @@ -150,9 +150,9 @@ pub fn open_popup(kind: PopupKind) { pub fn close_popup() -> Option { let prev = *atoms::POPUP_KIND.state().read(); *atoms::POPUP_KIND.state().write() = None; - // 撤销边界:弹窗关闭后用户无法再作答,待决的 dirty 风险选择按取消收敛。 + // 撤销边界:弹窗关闭后用户无法再作答,待决的风险选择按取消收敛。 // 即使当前 kind 已被其他 popup 覆盖(close 不再是 Confirm),也必须结清。 - crate::kit::popups::confirm_popup::cancel_pending_dirty_recovery(); + crate::kit::popups::confirm_popup::cancel_pending_risk_choice(); // I21-C:根据关闭的 popup 类型清空对应 payload atom if let Some(kind) = prev { match kind { diff --git a/peri-tui/src/kit/popups/confirm_popup.rs b/peri-tui/src/kit/popups/confirm_popup.rs index 75996b607..0315ba8b8 100644 --- a/peri-tui/src/kit/popups/confirm_popup.rs +++ b/peri-tui/src/kit/popups/confirm_popup.rs @@ -3,9 +3,9 @@ //! 确认弹窗:从 `CONFIRM_PAYLOAD` atom 读取确认信息(title / message / details / pending_action), //! Enter 执行确认,Esc 取消关闭。 //! -//! 同一文件另含 dirty 恢复专用确认(`DirtyRecoveryPopup`):`RecoveryRequired` -//! 的风险解除不能复用「Enter 即确认」的通用语义,必须显式选择接受、默认取消, -//! 且确认内容未完整渲染时按取消收敛。 +//! 同一文件另含风险选择专用确认([`RiskPopup`]):显式风险接受(解除 dirty 代际) +//! 不能复用「Enter 即确认」的通用语义,必须显式选择接受、默认取消,且确认内容未完整 +//! 渲染时按取消收敛。 use ratatui_kit::{ crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}, @@ -20,19 +20,132 @@ use crate::kit::panel_mouse::AreaTracker; use crate::kit::popup_overlay::close_popup; use peri_theme::atoms::THEME_ATOM; +/// 一次显式风险选择要展示的领域载荷。 +/// +/// 只决定弹窗文案,不参与选择语义:选择语义(默认取消、一次性回答、未完整渲染即 +/// 取消)对所有风险接受完全相同,因此共用一套实现。新增一种风险接受在这里加一个 +/// 变体,不要另建一套弹窗。 +/// 提示:`Debug` 只打印风险种类,不打印等待通道。 +#[derive(Debug)] +pub(crate) enum RiskPrompt { + /// 解除某条精确的 dirty 执行代际(`peri/session_reset_dirty`)。 + DirtyRecovery(peri_acp_types::workspace::RecoveryRequiredDetails), +} + +impl RiskPrompt { + /// 弹窗说明正文(不含选项行)与接受项文案 key。 + /// + /// 每次调用现取 i18n 文本:语言在弹窗展示期间切换时重渲染要跟着变。 + fn body(&self) -> (Vec, &'static str) { + match self { + Self::DirtyRecovery(target) => ( + vec![ + i18n::tr("dirty-recovery-title"), + i18n::tr("dirty-recovery-risk"), + i18n::tr("dirty-recovery-unknown"), + i18n::tr("dirty-recovery-responsibility"), + target.thread_id.clone(), + format!("generation {}", target.generation), + i18n::tr("dirty-recovery-hint"), + ], + "dirty-recovery-accept", + ), + } + } + + /// 完整弹窗正文:说明正文 + 「取消 / 接受」两行,选中项加 `>` 前缀。 + fn lines(&self, accept_selected: bool) -> Vec { + let (mut lines, accept_key) = self.body(); + lines.push(Self::option(!accept_selected, "risk-choice-cancel")); + lines.push(Self::option(accept_selected, accept_key)); + lines + } + + /// 弹窗内容行数(含选项行):渲染高度与可见性判定都用它。 + fn line_count(&self) -> usize { + self.body().0.len() + 2 + } + + /// 弹窗需要的高度:内容行 + 上下边框。 + /// + /// 可见性判定用(内容没被完整画出来就不能算「用户看到了风险说明」)。 + fn popup_height(&self) -> u16 { + (self.line_count() + 2).min(u16::MAX as usize) as u16 + } + + /// 选项行文案:未选中加空格占位,选中加 `>` 前缀(宽度不随选择变化)。 + fn option(selected: bool, key: &str) -> String { + format!("{} {}", if selected { ">" } else { " " }, i18n::tr(key)) + } + + /// 「取消 / 接受」两行相对弹窗区域顶部的行号(0-based,含上边框)。 + /// + /// 鼠标命中判定用:正文行数随载荷变化,选项行必须跟着算,不能写死。 + fn option_rows(&self) -> (u16, u16) { + let lines = self.line_count() as u16; + (lines - 1, lines) + } +} + +/// 一次显式风险选择的结论。 +/// +/// 调用方必须能区分三者:把它们压成一个 `bool` 会让「用户拒绝」与「用户根本没拿到 +/// 确认机会」无法分辨,而后者是要报告给用户的失败,不是用户的选择。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RiskChoice { + /// 确认内容已完整渲染且用户显式接受——唯一允许动作的结论。 + Accepted, + /// 用户看到完整确认后没有接受(Esc / 默认项 / 被替换)。 + Declined, + /// 确认内容从未完整渲染给用户(首帧之前被抢占、窗口装不下、等待方被丢弃): + /// 这不是用户的决定,调用方不得把它讲成「用户拒绝」。 + NotShown, +} + /// 一次性选择与显示许可;不持有 client,不能重新选择当前会话。 #[derive(Debug)] -pub struct RecoveryConfirmation { - pub target: peri_acp_types::workspace::RecoveryRequiredDetails, - response: std::sync::Mutex>>, +pub struct RiskConfirmation { + pub(crate) prompt: RiskPrompt, + response: std::sync::Mutex>>, displayed: std::sync::atomic::AtomicBool, } -impl RecoveryConfirmation { +impl RiskConfirmation { + /// 建立一次风险选择:返回所有者与等待方。 + /// + /// 所有者由弹窗路径持有;等待方在所有者被替换、撤销、被丢弃或确认内容未完整 + /// 渲染时按取消收敛,因此调用方不需要超时兜底。 + pub(crate) fn new( + prompt: RiskPrompt, + ) -> ( + std::sync::Arc, + tokio::sync::oneshot::Receiver, + ) { + let (tx, rx) = tokio::sync::oneshot::channel(); + ( + std::sync::Arc::new(Self { + prompt, + response: std::sync::Mutex::new(Some(tx)), + displayed: std::sync::atomic::AtomicBool::new(false), + }), + rx, + ) + } + + /// 结清这次选择。 + /// + /// `accepted` 只是「用户按了接受」这一动作;能不能算接受由**查看时的可见性** + /// 决定:确认内容没完整渲染出来时,接受一律降级为 [`RiskChoice::NotShown`]。 + /// 可见性每次查看现取,不缓存——缓存会让「先渲染过、后来窗口变小」的接受蒙混过关。 pub(crate) fn answer(&self, accepted: bool) { if let Some(tx) = self.response.lock().unwrap().take() { let visible = self.displayed.load(std::sync::atomic::Ordering::Acquire); - let _ = tx.send(accepted && visible); + let choice = match (accepted, visible) { + (true, true) => RiskChoice::Accepted, + (false, true) => RiskChoice::Declined, + (_, false) => RiskChoice::NotShown, + }; + let _ = tx.send(choice); } } @@ -44,44 +157,44 @@ impl RecoveryConfirmation { } } -/// 弹窗被替换或撤销时,精确结清待决的 dirty 风险选择。 +/// 弹窗被替换或撤销时,精确结清待决的风险选择。 /// /// 这不是渲染兜底:确认可能在首帧渲染之前就被其他 popup 覆盖,此时 -/// `RecoveryDisplay` 尚未建立、没有 Drop 可依赖。残留 payload 会一直持有响应 -/// 通道,使等待用户回答的 load 永久占住 operation gate,因此替换/撤销边界 -/// 必须显式按取消收敛。只处理 dirty 确认载荷,其他确认语义不变。 -pub(crate) fn cancel_pending_dirty_recovery() -> bool { +/// [`RiskDisplay`] 尚未建立、没有 Drop 可依赖。残留 payload 会一直持有响应 +/// 通道,使等待用户回答的会话操作永久占住 operation gate,因此替换/撤销边界 +/// 必须显式按取消收敛。 +pub(crate) fn cancel_pending_risk_choice() -> bool { let state = CONFIRM_PAYLOAD.state(); let mut payload = state.write(); let pending = payload .as_ref() - .is_some_and(|p| matches!(p.pending_action, ConfirmAction::RecoverDirty(_))); + .is_some_and(|p| matches!(p.pending_action, ConfirmAction::RiskChoice(_))); if !pending { return false; } - let taken = payload.take().expect("dirty payload checked"); + let taken = payload.take().expect("risk choice payload checked"); drop(payload); - if let ConfirmAction::RecoverDirty(owner) = taken.pending_action { + if let ConfirmAction::RiskChoice(owner) = taken.pending_action { owner.answer(false); } true } -struct RecoveryPopupGuard(std::sync::Weak); -impl Drop for RecoveryPopupGuard { +struct RiskPopupGuard(std::sync::Weak); +impl Drop for RiskPopupGuard { fn drop(&mut self) { let Some(owner) = self.0.upgrade() else { return; }; owner.answer(false); - // 先释放 payload 锁再动 POPUP_KIND——与 `confirm_dirty_recovery` 的 + // 先释放 payload 锁再动 POPUP_KIND——与 `confirm_risk_choice` 的 // popup→payload 顺序保持单一方向,避免两个方向同时持锁。 let cleared = { let state = CONFIRM_PAYLOAD.state(); let mut payload = state.write(); let mine = payload.as_ref().is_some_and(|p| { matches!(&p.pending_action, - ConfirmAction::RecoverDirty(current) if std::sync::Arc::ptr_eq(current, &owner)) + ConfirmAction::RiskChoice(current) if std::sync::Arc::ptr_eq(current, &owner)) }); if mine { *payload = None; @@ -95,72 +208,72 @@ impl Drop for RecoveryPopupGuard { } } -pub(crate) async fn confirm_dirty_recovery( - target: peri_acp_types::workspace::RecoveryRequiredDetails, -) -> bool { - let (tx, rx) = tokio::sync::oneshot::channel(); - let owner = std::sync::Arc::new(RecoveryConfirmation { - target: target.clone(), - response: std::sync::Mutex::new(Some(tx)), - displayed: std::sync::atomic::AtomicBool::new(false), - }); +/// 请求一次显式风险选择,直到用户作出选择、弹窗被替换/撤销或确认内容未完整渲染。 +/// +/// 已有的确认载荷按取消收敛(不能抢占):同时只允许一个风险选择在等待。 +/// +/// 返回值区分「用户拒绝」与「用户没拿到机会」:调用方对后者的处理不能是把失败吞掉 +/// (`NotShown` 说明该次询问从未成立),对前者才是「用户的结论就是最终结论」。 +pub(crate) async fn confirm_risk_choice(prompt: RiskPrompt) -> RiskChoice { + let (owner, rx) = RiskConfirmation::new(prompt); { let popup = atoms::POPUP_KIND.state(); let mut popup = popup.write(); let payload = CONFIRM_PAYLOAD.state(); let mut payload = payload.write(); if popup.is_some() || payload.is_some() { - return false; + // 位置被占用:这次询问没有发生,也不能抢占别人的弹窗。 + return RiskChoice::NotShown; } + let (body, _) = owner.prompt.body(); *payload = Some(atoms::ConfirmPayload { - title: i18n::tr("dirty-recovery-title"), - message: i18n::tr("dirty-recovery-risk"), - details: vec![ - i18n::tr("dirty-recovery-responsibility"), - format!("{} / generation {}", target.thread_id, target.generation), - ], - pending_action: ConfirmAction::RecoverDirty(owner.clone()), + title: body[0].clone(), + message: body[1].clone(), + details: body[2..].to_vec(), + pending_action: ConfirmAction::RiskChoice(owner.clone()), }); *popup = Some(atoms::PopupKind::Confirm); } - let _guard = RecoveryPopupGuard(std::sync::Arc::downgrade(&owner)); + let _guard = RiskPopupGuard(std::sync::Arc::downgrade(&owner)); drop(owner); - rx.await.unwrap_or(false) + rx.await.unwrap_or(RiskChoice::NotShown) } -struct RecoveryDisplay { - owner: std::sync::Arc, +struct RiskDisplay { + owner: std::sync::Arc, width: u16, height: u16, area: Option, } -impl RecoveryDisplay { +impl RiskDisplay { fn record_area(&mut self, area: ratatui_kit::ratatui::layout::Rect) { let visible = area.width >= self.width && area.height >= self.height; self.area = visible.then_some(area); self.owner .displayed .store(visible, std::sync::atomic::Ordering::Release); - if !visible { - self.owner.answer(false); - } + // 装不下**不结清**这次选择:窗口尺寸变了下一帧就可能装得下,而这里结清只会 + // 让用户连一次机会都没有(旧行为:立刻按取消收敛 → 用户看到的是「什么都没 + // 发生,会话创建被拒绝」)。替用户接受风险仍被挡住——`RiskConfirmation::answer` + // 在可见性为假时把「接受」降级为未呈现,因此等待方只能得到 NotShown。 } } -impl Hook for RecoveryDisplay { +impl Hook for RiskDisplay { fn pre_component_draw(&mut self, drawer: &mut ComponentDrawer) { self.record_area(drawer.area); } } -impl Drop for RecoveryDisplay { +impl Drop for RiskDisplay { fn drop(&mut self) { self.owner.answer(false); } } -fn recovery_choice( +fn risk_choice( event: &Event, selected: &mut bool, area: Option, + options: (u16, u16), ) -> Option { match event { Event::Key(key) if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => { @@ -177,8 +290,8 @@ fn recovery_choice( Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => area .filter(|rect| rect.contains((mouse.column, mouse.row).into())) .and_then(|rect| match mouse.row.checked_sub(rect.y) { - Some(8) => Some(false), - Some(9) => Some(true), + Some(row) if row == options.0 => Some(false), + Some(row) if row == options.1 => Some(true), _ => None, }), _ => None, @@ -186,42 +299,23 @@ fn recovery_choice( } #[derive(Default, Props)] -pub struct DirtyRecoveryPopupProps { - pub owner: Option>, +pub struct RiskPopupProps { + pub owner: Option>, } #[component] -pub fn DirtyRecoveryPopup( - props: &DirtyRecoveryPopupProps, - mut hooks: Hooks, -) -> impl Into> { +pub fn RiskPopup(props: &RiskPopupProps, mut hooks: Hooks) -> impl Into> { let owner = props .owner .as_ref() - .expect("recovery owner required") + .expect("risk choice owner required") .clone(); let theme = hooks.use_atom(&THEME_ATOM); let _lang = hooks.use_atom(&LANG_VERSION); let selected = hooks.use_state(|| false); - let texts = vec![ - i18n::tr("dirty-recovery-title"), - i18n::tr("dirty-recovery-risk"), - i18n::tr("dirty-recovery-unknown"), - i18n::tr("dirty-recovery-responsibility"), - owner.target.thread_id.clone(), - format!("generation {}", owner.target.generation), - i18n::tr("dirty-recovery-hint"), - format!( - "{} {}", - if !*selected.read() { ">" } else { " " }, - i18n::tr("dirty-recovery-cancel") - ), - format!( - "{} {}", - if *selected.read() { ">" } else { " " }, - i18n::tr("dirty-recovery-accept") - ), - ]; + // 选项行随选择变化,必须在这里读 state 才能订阅重渲染。 + let texts = owner.prompt.lines(*selected.read()); + let options = owner.prompt.option_rows(); let width = texts .iter() .map(|s| unicode_width::UnicodeWidthStr::width(s.as_str())) @@ -229,14 +323,16 @@ pub fn DirtyRecoveryPopup( .unwrap_or(0) .saturating_add(2) .min(u16::MAX as usize) as u16; + let height = owner.prompt.popup_height(); let area = { - let tracker = hooks.use_hook(|| RecoveryDisplay { + let tracker = hooks.use_hook(|| RiskDisplay { owner: owner.clone(), width, - height: 11, + height, area: None, }); tracker.width = width; + tracker.height = height; tracker.area }; let action_owner = owner.clone(); @@ -246,7 +342,7 @@ pub fn DirtyRecoveryPopup( EventOptions { hit_test: true }, move |event| { let mut choice = *selected.read(); - let answer = recovery_choice(&event, &mut choice, area); + let answer = risk_choice(&event, &mut choice, area, options); if choice != *selected.read() { *selected.write() = choice; } @@ -400,7 +496,7 @@ pub(crate) fn execute_confirm_action( ) { match action { // 通用确认动作绝不能代替专用风险选择。 - ConfirmAction::RecoverDirty(owner) => owner.answer(false), + ConfirmAction::RiskChoice(owner) => owner.answer(false), ConfirmAction::ThreadSwitch(target_id) => { if let Some(tx) = atoms::THREAD_LOAD_TX.get() { let _ = tx.send(target_id.clone()); @@ -420,7 +516,7 @@ pub(crate) fn execute_confirm_action( } #[cfg(test)] -#[path = "dirty_recovery_test.rs"] +#[path = "risk_choice_test.rs"] mod recovery_tests; #[cfg(test)] diff --git a/peri-tui/src/kit/popups/dirty_recovery_test.rs b/peri-tui/src/kit/popups/risk_choice_test.rs similarity index 58% rename from peri-tui/src/kit/popups/dirty_recovery_test.rs rename to peri-tui/src/kit/popups/risk_choice_test.rs index 1ed9f4c43..6a092036b 100644 --- a/peri-tui/src/kit/popups/dirty_recovery_test.rs +++ b/peri-tui/src/kit/popups/risk_choice_test.rs @@ -12,6 +12,16 @@ fn target() -> RecoveryRequiredDetails { } } +/// dirty 风险选择的说明正文:选项行是最后两行,行号不写死。 +fn dirty_prompt() -> RiskPrompt { + RiskPrompt::DirtyRecovery(target()) +} + +/// dirty 弹窗的「取消 / 接受」两行行号。 +fn dirty_options() -> (u16, u16) { + dirty_prompt().option_rows() +} + /// 本文件只写 popup 两个 atom;仍按 RAII 保存/恢复,避免与并行 lib 测试互相污染。 struct PopupAtomsGuard { popup: Option, @@ -40,15 +50,13 @@ impl Drop for PopupAtomsGuard { fn owner( displayed: bool, ) -> ( - std::sync::Arc, - tokio::sync::oneshot::Receiver, + std::sync::Arc, + tokio::sync::oneshot::Receiver, ) { - let (tx, rx) = tokio::sync::oneshot::channel(); - let owner = std::sync::Arc::new(RecoveryConfirmation { - target: target(), - response: std::sync::Mutex::new(Some(tx)), - displayed: std::sync::atomic::AtomicBool::new(displayed), - }); + let (owner, rx) = RiskConfirmation::new(dirty_prompt()); + if displayed { + owner.mark_displayed(); + } (owner, rx) } @@ -70,46 +78,66 @@ async fn wait_for_payload() { #[serial] async fn test_dirty_recovery_generic_confirm_path_cannot_accept_risk() { let _guard = PopupAtomsGuard::capture(); - let (tx, rx) = tokio::sync::oneshot::channel(); - let confirmation = std::sync::Arc::new(RecoveryConfirmation { - target: target(), - response: std::sync::Mutex::new(Some(tx)), - displayed: std::sync::atomic::AtomicBool::new(true), - }); - execute_confirm_action(&ConfirmAction::RecoverDirty(confirmation), |_| {}); - assert!(!rx.await.unwrap(), "通用确认路径必须按取消收敛"); + let (confirmation, rx) = owner(true); + execute_confirm_action(&ConfirmAction::RiskChoice(confirmation), |_| {}); + assert_eq!( + rx.await.unwrap(), + RiskChoice::Declined, + "通用确认路径必须按取消收敛" + ); } /// 没有经过渲染确认可见时,任何 accept 都必须失败闭合。 +/// +/// 装不下的帧**不是**用户的决定:这里不结清这次选择(旧行为会立刻按取消收敛,让 +/// 「确认装不下」直接变成「用户没被问过、什么都没发生」——用户既没有机会,也看不到 +/// 原因)。窗口够大之后同一次确认仍然成立,接受依旧要求已完整渲染。 #[tokio::test] #[serial] async fn test_dirty_recovery_accept_requires_displayed_confirmation() { let _guard = PopupAtomsGuard::capture(); - let (hidden, hidden_rx) = owner(false); - { - let mut tracker = RecoveryDisplay { - owner: hidden, - width: 60, - height: 11, - area: None, - }; - // 终端比确认内容更小:不登记矩形,并立即按取消收敛。 - tracker.record_area(Rect::new(0, 0, 20, 4)); - assert!(tracker.area.is_none()); - } - assert!(!hidden_rx.await.unwrap(), "未渲染确认必须按取消收敛"); + let (hidden, mut hidden_rx) = owner(false); + let mut tracker = RiskDisplay { + owner: hidden.clone(), + width: 60, + height: dirty_prompt().popup_height(), + area: None, + }; + // 终端比确认内容更小:不登记矩形,也不作答。 + tracker.record_area(Rect::new(0, 0, 20, 4)); + assert!(tracker.area.is_none()); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut hidden_rx) + .await + .is_err(), + "装不下的帧不得替用户结清这次选择" + ); + // 窗口变大后同一帧循环把矩形登记回来,这次确认仍然可用。 + tracker.record_area(Rect::new(4, 2, 60, 11)); + assert_eq!(tracker.area, Some(Rect::new(4, 2, 60, 11))); + hidden.answer(false); + assert_eq!(hidden_rx.await.unwrap(), RiskChoice::Declined); + + // 从未完整渲染就接受:不得算接受,也不得被讲成用户拒绝。 + let (not_shown, not_shown_rx) = owner(false); + not_shown.answer(true); + assert_eq!(not_shown_rx.await.unwrap(), RiskChoice::NotShown); let (visible, visible_rx) = owner(false); - let mut tracker = RecoveryDisplay { + let mut tracker = RiskDisplay { owner: visible.clone(), width: 60, - height: 11, + height: dirty_prompt().popup_height(), area: None, }; tracker.record_area(Rect::new(4, 2, 60, 11)); assert_eq!(tracker.area, Some(Rect::new(4, 2, 60, 11))); visible.answer(true); - assert!(visible_rx.await.unwrap(), "已渲染且接受风险时才能确认"); + assert_eq!( + visible_rx.await.unwrap(), + RiskChoice::Accepted, + "已渲染且接受风险时才能确认" + ); } /// 默认选中取消:Enter(未切换)与 Esc 都是取消,选择后才可确认接受。 @@ -118,70 +146,76 @@ async fn test_dirty_recovery_accept_requires_displayed_confirmation() { fn test_dirty_recovery_default_selection_is_cancel() { let mut selected = false; assert_eq!( - recovery_choice( + risk_choice( &Event::Key(ratatui_kit::crossterm::event::KeyEvent::new( KeyCode::Enter, KeyModifiers::NONE )), &mut selected, - None + None, + dirty_options() ), Some(false) ); assert_eq!( - recovery_choice( + risk_choice( &Event::Key(ratatui_kit::crossterm::event::KeyEvent::new( KeyCode::Esc, KeyModifiers::NONE )), &mut selected, - None + None, + dirty_options() ), Some(false) ); assert_eq!( - recovery_choice( + risk_choice( &Event::Key(ratatui_kit::crossterm::event::KeyEvent::new( KeyCode::Down, KeyModifiers::NONE )), &mut selected, - None + None, + dirty_options() ), None ); assert!(selected); assert_eq!( - recovery_choice( + risk_choice( &Event::Key(ratatui_kit::crossterm::event::KeyEvent::new( KeyCode::Enter, KeyModifiers::NONE )), &mut selected, - None + None, + dirty_options() ), Some(true) ); // 输入与其他快捷键不产生选择,也不能落进背景输入区。 assert_eq!( - recovery_choice( + risk_choice( &Event::Key(ratatui_kit::crossterm::event::KeyEvent::new( KeyCode::Char('y'), KeyModifiers::NONE )), &mut selected, - None + None, + dirty_options() ), None ); assert_eq!( - recovery_choice( + risk_choice( &Event::Key(ratatui_kit::crossterm::event::KeyEvent::new( KeyCode::Char('c'), KeyModifiers::CONTROL )), &mut selected, - None + None, + dirty_options() ), None ); @@ -202,14 +236,26 @@ fn test_dirty_recovery_mouse_requires_recorded_area_rows() { }; let mut selected = false; assert_eq!( - recovery_choice(&click(10), &mut selected, area), + risk_choice(&click(10), &mut selected, area, dirty_options()), Some(false) ); - assert_eq!(recovery_choice(&click(11), &mut selected, area), Some(true)); - assert_eq!(recovery_choice(&click(6), &mut selected, area), None); + assert_eq!( + risk_choice(&click(11), &mut selected, area, dirty_options()), + Some(true) + ); + assert_eq!( + risk_choice(&click(6), &mut selected, area, dirty_options()), + None + ); // 未渲染/未登记区域:整窗点击不产生任何选择。 - assert_eq!(recovery_choice(&click(11), &mut selected, None), None); - assert_eq!(recovery_choice(&click(0), &mut selected, area), None); + assert_eq!( + risk_choice(&click(11), &mut selected, None, dirty_options()), + None + ); + assert_eq!( + risk_choice(&click(0), &mut selected, area, dirty_options()), + None + ); } /// 已有其他弹窗时不能抢占,也不能破坏原 payload。 @@ -224,7 +270,11 @@ async fn test_dirty_recovery_fails_closed_when_popup_is_unavailable() { details: vec![], pending_action: atoms::ConfirmAction::ThreadSwitch("other".into()), }); - assert!(!confirm_dirty_recovery(target()).await); + assert_eq!( + confirm_risk_choice(dirty_prompt()).await, + RiskChoice::NotShown, + "位置被占用时这次询问没有发生,不能当成用户拒绝" + ); assert_eq!(*POPUP_KIND.state().read(), Some(PopupKind::Hitl)); assert_eq!( CONFIRM_PAYLOAD.state().read().as_ref().unwrap().title, @@ -237,11 +287,11 @@ async fn test_dirty_recovery_fails_closed_when_popup_is_unavailable() { #[serial] async fn test_dirty_recovery_revoked_before_first_frame_answers_cancel() { let _guard = PopupAtomsGuard::capture(); - let waiter = tokio::spawn(confirm_dirty_recovery(target())); + let waiter = tokio::spawn(confirm_risk_choice(dirty_prompt())); wait_for_payload().await; let before = *POPUP_KIND.state().read(); - // 尚未经过任何渲染帧(RecoveryDisplay 未建立,没有 Drop 兜底)。 + // 尚未经过任何渲染帧(RiskDisplay 未建立,没有 Drop 兜底)。 crate::kit::popup_overlay::open_popup(crate::kit::atoms::PopupKind::OAuth); assert_eq!( *POPUP_KIND.state().read(), @@ -253,7 +303,11 @@ async fn test_dirty_recovery_revoked_before_first_frame_answers_cancel() { .await .expect("revoked confirmation must not keep the waiter alive") .unwrap(); - assert!(!answered, "被覆盖的确认只能按取消收敛"); + assert_eq!( + answered, + RiskChoice::NotShown, + "被覆盖的确认从未呈现给用户,只能按未呈现收敛" + ); // 撤销边界之后 close:新 popup 正常关闭,无残留 dirty payload。 crate::kit::popup_overlay::close_popup(); @@ -266,7 +320,7 @@ async fn test_dirty_recovery_revoked_before_first_frame_answers_cancel() { #[serial] async fn test_dirty_recovery_dropped_waiter_cancels_and_clears_popup() { let _guard = PopupAtomsGuard::capture(); - let waiter = tokio::spawn(confirm_dirty_recovery(target())); + let waiter = tokio::spawn(confirm_risk_choice(dirty_prompt())); wait_for_payload().await; assert_eq!(*POPUP_KIND.state().read(), Some(PopupKind::Confirm)); waiter.abort(); @@ -282,3 +336,31 @@ async fn test_dirty_recovery_dropped_waiter_cancels_and_clears_popup() { .await .expect("dropped recovery waiter must clear its popup"); } + +/// dirty 说明的文案完整且已本地化:i18n 缺 key 时 `tr` 会回退成 key 文本,必须挡住。 +#[test] +#[serial] +fn test_dirty_recovery_disclosure_is_localized_and_complete() { + let (body, accept_key) = dirty_prompt().body(); + assert_eq!(accept_key, "dirty-recovery-accept"); + assert_eq!( + body.len(), + 7, + "dirty 说明七行:标题/风险/未知/责任/会话/代际/提示" + ); + for line in &body { + assert!(!line.is_empty(), "dirty 说明不得有空行"); + assert!( + !line.starts_with("dirty-recovery-") && !line.starts_with("risk-choice-"), + "缺少本地化文案: {line}" + ); + } + + let lines = dirty_prompt().lines(false); + assert_eq!(lines.len(), dirty_prompt().line_count()); + assert!(lines[7].starts_with('>'), "默认选中取消"); + assert!(lines[8].starts_with(' '), "默认不选中接受"); + let lines = dirty_prompt().lines(true); + assert!(lines[8].starts_with('>'), "切换后选中接受"); + assert!(lines[7].starts_with(' ')); +} diff --git a/peri-tui/src/launch.rs b/peri-tui/src/launch.rs index 5e2602638..3f58e7c7a 100644 --- a/peri-tui/src/launch.rs +++ b/peri-tui/src/launch.rs @@ -3,7 +3,6 @@ //! 把 App 初始化、ACP server/client 配对、插件/Hook 装配等步骤提取为 //! `build_app_and_acp` / `teardown_app` 公共函数,供 `kit::entry::run_kit_fullscreen` 调用。 -use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; @@ -31,7 +30,8 @@ pub struct TuiLaunchOptions { pub settings: Option, pub allowed_tools: Vec, pub disallowed_tools: Vec, - pub db_path: Option, + /// 会话存储定位描述(由入口归一一次;恢复会话不重新解析存储位置)。 + pub session_store: peri_acp_types::session_store::SessionStoreDeployment, } /// 构建 App + ACP server/client,并把 acp_client 注入 App。 @@ -44,7 +44,7 @@ pub async fn build_app_and_acp( App, Option<(AcpTuiClient, mpsc::UnboundedReceiver)>, )> { - let mut app = App::new(opts.db_path.clone()).await?; + let mut app = App::new(opts.session_store.clone()).await?; // (I17-D) panic_notify_rx 已退役——ServiceRegistry.panic_notify_rx 字段删除, // 该参数仅保留签名以维持调用方兼容;实际 panic 通知走 tracing log。 @@ -180,12 +180,16 @@ pub async fn attach_acp( // ToolSearchIndex / SkillsProvider / PluginManager / // SettingsHooksLoader / 插件聚合数据)由 ACP Host 装配面内部构造 // (peri_acp::host::assemble);TUI 只提供协议面输入(§0 依赖方向)。 - thread_store: app.services.thread_store.clone(), + session_resources: app.services.session_resources.clone(), + // 部署关闭权随宿主移交:任务排空之后由宿主关闭会话存储。 + session_store_shutdown: app.session_store_shutdown.take(), cwd: app.services.cwd.clone(), bare: false, // TUI=true:复刻迁移前 TUI 每秒 tick 行为(cron 面板直持 // cron_state,tick 由 host 侧 scheduler 驱动执行)。 drive_cron_tick: true, + // TUI 装配点无准备路径提供的插件聚合:按既有语义由装配面自行加载。 + prepared_plugins: None, }, ) .await; diff --git a/peri-tui/src/launch_test.rs b/peri-tui/src/launch_test.rs index b0a5bbfcb..b209fda4e 100644 --- a/peri-tui/src/launch_test.rs +++ b/peri-tui/src/launch_test.rs @@ -37,7 +37,10 @@ async fn attach_acp_rejects_second_attachment_before_spawning_host() { // resolves the system profile independently of the HOME override. crate::config::set_global_config_path(Some(temp.path().join("settings.json"))); let db_path = temp.path().join("threads.db"); - let mut app = App::new(Some(db_path)).await.expect("app"); + let mut app = + App::new(peri_acp_types::session_store::SessionStoreDeployment::local_path(db_path)) + .await + .expect("app"); let (client_transport, _server_transport) = mpsc_transport_pair(); let (client, _notification_tx, _notification_rx) = AcpTuiClient::new_interactive(client_transport); diff --git a/peri-tui/src/main.rs b/peri-tui/src/main.rs index 01c6cb88b..4b78158cc 100644 --- a/peri-tui/src/main.rs +++ b/peri-tui/src/main.rs @@ -18,6 +18,8 @@ mod cli_workflow; // 实现已移至 peri_tui::kit::panic(lib 侧),AppShell mount 后重装 hook, // 覆盖 ratatui::init() 的包装 hook——见 kit/panic.rs 模块注释。 use peri_acp::host::stdio::StdioInput; +use peri_acp_types::session_resources::AccessMode; +use peri_acp_types::session_store::SessionStoreDeployment; use peri_tui::kit::panic::init_panic_notify; // ─── CLI 定义 ────────────────────────────────────────────────────────────── @@ -90,6 +92,18 @@ struct Cli { /// SQLite 会话数据库路径(默认 ~/.peri/threads/threads.db) #[arg(long = "db-path", visible_alias = "dbPath")] db_path: Option, + /// 会话存储定位:本机路径、远程 locator 或 env:<变量名>(与 --db-path 互斥) + #[arg(long = "session-store", visible_alias = "sessionStore")] + session_store: Option, + /// 远程会话存储的凭证来源(环境变量名,不接受 token 字面量) + #[arg( + long = "session-store-token-env", + visible_alias = "sessionStoreTokenEnv" + )] + session_store_token_env: Option, + /// locator 形态无法唯一决定引擎时显式指定(turso / libsql) + #[arg(long = "session-store-engine", visible_alias = "sessionStoreEngine")] + session_store_engine: Option, #[command(subcommand)] command: Option, @@ -430,6 +444,10 @@ fn validate_cli(cli: &Cli) -> std::result::Result<(), &'static str> { if cli.print.is_some() && cli.command.is_some() { return Err("--print cannot be used with a subcommand"); } + if cli.db_path.is_some() && cli.session_store.is_some() { + // 两个定位入口没有隐式覆盖顺序:同时出现直接是参数错误(早于任何 I/O)。 + return Err("--db-path cannot be combined with --session-store"); + } if matches!(cli.command, Some(Commands::Meta { .. })) && (cli.print.is_some() || cli.output_format.is_some() @@ -449,11 +467,40 @@ fn validate_cli(cli: &Cli) -> std::result::Result<(), &'static str> { || cli.settings.is_some() || cli.config_file.is_some()) { - return Err("meta only accepts --db-path and session --json"); + return Err( + "meta only accepts session store options (--db-path / --session-store*) and session --json", + ); } Ok(()) } +/// 部署参数 → 中性定位描述(D-04)。 +/// +/// 这里只做 CLI grammar 归一(本机路径 / locator 原文 / 默认 + 可选引擎与凭证来源), +/// **不解析 locator、不读环境变量、不判断后端**:那些纯解析与后端选择只发生在资源 +/// 装配层(`Resources::open_deployment`)。两个定位入口同时出现是参数错误,不设 +/// 隐式覆盖顺序。 +fn session_store_deployment( + cli: &Cli, + access: AccessMode, +) -> std::result::Result { + let deployment = match (cli.db_path.as_ref(), cli.session_store.as_deref()) { + (Some(_), Some(_)) => return Err("--db-path cannot be combined with --session-store"), + (Some(path), None) => SessionStoreDeployment::local_path(path.clone()), + (None, Some(raw)) => SessionStoreDeployment::from_locator(raw), + (None, None) => SessionStoreDeployment::default_local(), + }; + let deployment = match cli.session_store_engine.as_deref() { + Some(engine) => deployment.with_engine(engine), + None => deployment, + }; + let deployment = match cli.session_store_token_env.as_deref() { + Some(name) => deployment.with_credential_env(name), + None => deployment, + }; + Ok(deployment.with_access(access)) +} + #[derive(Clone, Copy)] enum TopLevelOptionShape { Flag, @@ -595,6 +642,12 @@ fn try_run_meta_before_configuration(args: &[OsString]) -> Option> { if validate_cli(&cli).is_err() { return Some(emit_meta_outcome(cli_meta::invalid_argument_outcome(json))); } + // meta 是显式只读入口:只读意图 + 同一份定位描述,先 UUID/grammar 再打开; + // 不加载 provider/MCP/Agent,也不新建本机执行登记。 + let deployment = match session_store_deployment(&cli, AccessMode::ReadOnly) { + Ok(deployment) => deployment, + Err(_) => return Some(emit_meta_outcome(cli_meta::invalid_argument_outcome(json))), + }; let Some(Commands::Meta { action }) = cli.command else { return Some(emit_meta_outcome(cli_meta::invalid_argument_outcome(json))); }; @@ -604,7 +657,7 @@ fn try_run_meta_before_configuration(args: &[OsString]) -> Option> { }; let outcome = match action { MetaAction::Session { session_id, json } => { - runtime.block_on(cli_meta::run_meta_session(cli.db_path, session_id, json)) + runtime.block_on(cli_meta::run_meta_session(deployment, session_id, json)) } }; Some(emit_meta_outcome(outcome)) @@ -646,6 +699,15 @@ fn main() -> Result<()> { .exit(); } + // 部署参数在这里归一一次(D-04):之后 TUI / print / ACP stdio 共享同一个定位描述, + // 各入口不再各自解释存储位置,恢复会话也不会重新解析出另一个存储。 + let session_store = match session_store_deployment(&cli, AccessMode::ReadWrite) { + Ok(deployment) => deployment, + Err(message) => Cli::command() + .error(clap::error::ErrorKind::ArgumentConflict, message) + .exit(), + }; + // 以 clap 解析结果为准(幂等;prescan 与 clap 同源 argv,二者一致) peri_tui::config::set_global_config_path(cli.config_file.clone()); @@ -666,7 +728,7 @@ fn main() -> Result<()> { cli.disallowed_tools.unwrap_or_default(), cli.settings, None, - cli.db_path, + session_store, )); } @@ -683,7 +745,7 @@ fn main() -> Result<()> { settings: cli.settings, allowed_tools: cli.allowed_tools.unwrap_or_default(), disallowed_tools: cli.disallowed_tools.unwrap_or_default(), - db_path: cli.db_path, + session_store, }) { Ok(()) => Ok(()), Err(_) => std::process::exit(1), @@ -705,7 +767,7 @@ fn main() -> Result<()> { permission_mode: peri_acp_types::permission::SharedPermissionMode::new( peri_acp_types::permission::PermissionMode::Bypass, ), - db_path: cli.db_path, + session_store, }) .await }) @@ -839,7 +901,8 @@ struct TuiOptions { settings: Option, allowed_tools: Vec, disallowed_tools: Vec, - db_path: Option, + /// 会话存储定位描述(已由入口归一;恢复会话不再重新解析存储)。 + session_store: SessionStoreDeployment, } fn propagate_tui_result(result: Result<()>) -> Result<()> { @@ -882,7 +945,7 @@ fn run_tui(opts: TuiOptions) -> Result<()> { settings: opts.settings.clone(), allowed_tools: opts.allowed_tools.clone(), disallowed_tools: opts.disallowed_tools.clone(), - db_path: opts.db_path.clone(), + session_store: opts.session_store.clone(), }; peri_tui::kit::entry::run_kit_fullscreen(launch_opts, panic_notify_rx).await }); diff --git a/peri-tui/src/main_test.rs b/peri-tui/src/main_test.rs index f85a5d5b4..f8bd03b8c 100644 --- a/peri-tui/src/main_test.rs +++ b/peri-tui/src/main_test.rs @@ -3,6 +3,9 @@ #[cfg(test)] use super::*; +#[cfg(test)] +use peri_acp_types::session_store::SessionStoreLocator; + #[test] fn test_propagate_tui_result_preserves_startup_failure() { let error = propagate_tui_result(Err(anyhow::anyhow!("database open failed"))) @@ -267,3 +270,118 @@ fn test_prescan_last_occurrence_wins() { Some(PathBuf::from("b")) ); } + +// ─── 会话存储部署参数(D-04)──────────────────────────────────────────────── + +/// `--db-path` 与 `--session-store` 互斥:同时出现是参数错误,且早于任何 I/O。 +#[test] +fn test_db_path_conflicts_with_session_store_before_io() { + let cli = Cli::try_parse_from([ + "peri", + "--db-path", + "/tmp/a.db", + "--session-store", + "/tmp/b.db", + ]) + .unwrap(); + assert_eq!( + validate_cli(&cli), + Err("--db-path cannot be combined with --session-store") + ); + // 部署参数构造同样拒绝,不设隐式覆盖顺序。 + assert!(session_store_deployment(&cli, AccessMode::ReadWrite).is_err()); +} + +/// 定位参数归一:默认 / `--db-path`(含 `--dbPath` 别名)/ `--session-store` + 可选 +/// 引擎与凭证来源;访问意图由入口给出。 +#[test] +fn test_session_store_deployment_normalizes_locator_options() { + let cli = Cli::try_parse_from(["peri"]).unwrap(); + let deployment = session_store_deployment(&cli, AccessMode::ReadWrite).unwrap(); + assert_eq!(deployment.locator(), &SessionStoreLocator::Default); + assert_eq!(deployment.access(), AccessMode::ReadWrite); + assert!(deployment.engine_name().is_none()); + assert!(deployment.credential_env_name().is_none()); + + for flag in ["--db-path", "--dbPath"] { + let cli = Cli::try_parse_from(["peri", flag, "/tmp/threads.db"]).unwrap(); + let deployment = session_store_deployment(&cli, AccessMode::ReadWrite).unwrap(); + assert_eq!( + deployment.locator(), + &SessionStoreLocator::LocalPath(std::path::PathBuf::from("/tmp/threads.db")) + ); + assert_eq!(deployment.access(), AccessMode::ReadWrite); + } + + let cli = Cli::try_parse_from([ + "peri", + "--session-store", + "env:TURSO_URL", + "--session-store-token-env", + "TURSO_TOEKN", + "--session-store-engine", + "turso", + ]) + .unwrap(); + let deployment = session_store_deployment(&cli, AccessMode::ReadOnly).unwrap(); + assert_eq!( + deployment.locator(), + &SessionStoreLocator::Locator("env:TURSO_URL".to_owned()) + ); + assert_eq!(deployment.engine_name(), Some("turso")); + assert_eq!(deployment.credential_env_name(), Some("TURSO_TOEKN")); + assert_eq!(deployment.access(), AccessMode::ReadOnly); +} + +/// `Debug` 不回显 locator 原文(远程 locator 含主机与库名)。 +#[test] +fn test_session_store_deployment_debug_keeps_locator_out() { + let cli = Cli::try_parse_from([ + "peri", + "--session-store", + "turso://sentinel-db-sentinel.turso.io", + ]) + .unwrap(); + let deployment = session_store_deployment(&cli, AccessMode::ReadWrite).unwrap(); + let rendered = format!("{deployment:?}"); + assert!(!rendered.contains("sentinel-db-sentinel")); + assert!(rendered.contains("")); +} + +/// meta 的受限 grammar 同步:只接受定位参数与 session 自身的 `--json`。 +#[test] +fn test_meta_grammar_allows_session_store_options() { + let session_id = "550e8400-e29b-41d4-a716-446655440000"; + let cli = Cli::try_parse_from([ + "peri", + "--session-store", + "env:TURSO_URL", + "--session-store-token-env", + "TURSO_TOEKN", + "meta", + "session", + session_id, + "--json", + ]) + .unwrap(); + assert!(validate_cli(&cli).is_ok()); + assert_eq!( + session_store_deployment(&cli, AccessMode::ReadOnly) + .unwrap() + .access(), + AccessMode::ReadOnly + ); + + // 仍拒绝与只读元数据无关的参数。 + for extra in [ + vec!["--model", "sonnet"], + vec!["--bare"], + vec!["--settings", "x.json"], + ] { + let mut args = vec!["peri"]; + args.extend(extra.iter().copied()); + args.extend(["meta", "session", session_id]); + let cli = Cli::try_parse_from(args).unwrap(); + assert!(validate_cli(&cli).is_err()); + } +} diff --git a/peri-tui/src/thread/mod.rs b/peri-tui/src/thread/mod.rs index 5e0703d3d..e27c6eee1 100644 --- a/peri-tui/src/thread/mod.rs +++ b/peri-tui/src/thread/mod.rs @@ -2,11 +2,9 @@ //! //! (I16-C) `browser::ThreadBrowser` 已退役——kit 单路径下 //! 使用 `kit/panels/thread_browser.rs::ThreadBrowserPanel`(独立实现)。 -//! 本模块仅 re-export `peri-resources` 中的 `ThreadStore` trait 与 -//! `SqliteThreadStore` 实现(契约类型位于 peri-acp-types)。 +//! 会话数据一律经 `peri_acp_types::session_resources::SessionResources` 门面; +//! 打开一律经 Resources 门面的部署入口(typed open request), +//! 本模块不再转发任何独立只读 seam 或裸存储类型。 -pub use peri_acp_types::store::ThreadStore; +pub use peri_acp_types::session_resources::SessionResources; pub use peri_acp_types::thread::{ThreadId, ThreadMeta}; -pub use peri_resources::sessions::{ - ReadOnlyThreadStoreError, SqliteThreadStore, open_thread_store_read_only, -}; diff --git a/scripts/check-file-size.sh b/scripts/check-file-size.sh index 16536cfd8..cc5a1d157 100755 --- a/scripts/check-file-size.sh +++ b/scripts/check-file-size.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # 大文件扫描门:按行数扫描源码,报告超阈值文件(拆分/重构参考,可接 CI 门)。 # -# 源码与测试分开设阈值——测试文件天然偏大,默认放宽(对齐 -# check-layer-imports.sh 的测试豁免思路);rg 尊重 .gitignore, -# target/node_modules 自动排除。 +# 源码与测试默认均为 1000 行,对齐 docs/standards/index.md 的 +# STD-SIZE-001;可分别设阈值用于排查,验收仍使用标准上限。 +# rg 尊重 .gitignore,target/node_modules 自动排除。 # # 用法:bash scripts/check-file-size.sh [--min N] [--test-min N] [--no-tests] [--top N] # --min N 源码阈值,默认 1000;0 = 不检查源码 -# --test-min N 测试阈值,默认 4000;0 = 不检查测试 +# --test-min N 测试阈值,默认 1000;0 = 不检查测试 # --no-tests 等价 --test-min 0 # --top N 每类最多展示条数,默认 30 # 退出码:0 无超阈值;1 存在超阈值;2 参数错误 @@ -18,7 +18,7 @@ cd "$(dirname "$0")/.." command -v rg >/dev/null 2>&1 || { echo "❌ 需要 ripgrep (rg)"; exit 1; } MIN=1000 -TEST_MIN=4000 +TEST_MIN=1000 TOP=30 while [ $# -gt 0 ]; do @@ -31,7 +31,7 @@ while [ $# -gt 0 ]; do cat <<'EOF' 用法:bash scripts/check-file-size.sh [--min N] [--test-min N] [--no-tests] [--top N] --min N 源码阈值,默认 1000;0 = 不检查源码 - --test-min N 测试阈值,默认 4000;0 = 不检查测试 + --test-min N 测试阈值,默认 1000;0 = 不检查测试 --no-tests 等价 --test-min 0 --top N 每类最多展示条数,默认 30 退出码:0 无超阈值;1 存在超阈值;2 参数错误 diff --git a/scripts/import-exemptions.conf b/scripts/import-exemptions.conf index 0989db2ee..352c2b285 100644 --- a/scripts/import-exemptions.conf +++ b/scripts/import-exemptions.conf @@ -30,18 +30,23 @@ # 批 3(tui-deps)收紧后:协议/契约类型(MessageContent/PermissionMode/ # SharedPermissionMode/PluginLoadResult/InstallScope/BackgroundTaskResult/ # CompactConfig)全部换 peri-acp-types 同源契约,use 引用清零。 -# 剩余豁免 = C 类「宿主装配点 + 面板数据源直读」: +# 剩余豁免 = C 类「宿主装配点 + 面板数据源直读」+ 受限只读部署早入口: # - 装配点(launch.rs / main.rs / cli_print.rs / app/mod.rs):构造 # CronScheduler / McpClientPool / ToolSearchIndex / host_ports / Resources # 门面并注入 ACP 端口(ACP Host 部署装配点职责) # - 数据源(service_snapshot / panels/plugin.rs / atoms / cron_state / # service_registry / cli_plugin):cron/mcp/plugin 具体句柄直读; # 「面板数据全部经 ACP」需新增 cron/list、mcp/list 命令面——超出本批 -# - thread/mod.rs = SqliteThreadStore re-export(pub use peri_resources) +# - cli_meta.rs = 受限只读部署早入口:peri meta 命令按既有设计绕过完整 ACP +# 启动,先校验 UUID,再经 Resources::open_deployment 只读打开(不写盘、 +# 不登记 owner、不建目录),无 runtime 副作用。仅此单点例外, +# 禁止泛化到整个 TUI——其余 TUI 路径仍须经 ACP 取数。 +# - thread/mod.rs 已无需豁免:SessionResources 经 peri-acp-types 门面 +# re-export,doc comment 改用中性 Resources 名称(无全路径引用)。 # - kit/workflow_snapshot.rs 仅 doc comment 提及 peri_workflow::(DTO 镜像) # 全部随 M-TUI 收紧后移除。 -peri-tui/src @ use peri_(agent|middlewares|lsp|workflow|resources|model)\b @ thread/mod.rs @ TUI-use -peri-tui/src @ peri_(agent|middlewares|lsp|workflow|resources|model):: @ app/cron_state.rs app/mod.rs app/service_registry.rs cli_plugin.rs cli_print.rs kit/atoms.rs kit/panels/plugin kit/service_snapshot.rs kit/workflow_snapshot.rs launch.rs @ TUI-fullpath +peri-tui/src @ use peri_(agent|middlewares|lsp|workflow|resources|model)\b @ cli_meta.rs @ TUI-use +peri-tui/src @ peri_(agent|middlewares|lsp|workflow|resources|model):: @ app/cron_state.rs app/mod.rs app/service_registry.rs cli_meta.rs cli_plugin.rs cli_print.rs kit/atoms.rs kit/panels/plugin kit/service_snapshot.rs kit/workflow_snapshot.rs launch.rs @ TUI-fullpath # # 豁免归属:全部 M-TUI(C 类装配/数据源直读,见上);kit/atoms.rs = # CronScheduler 类型签名(SharedPermissionMode 已换 peri-acp-types); @@ -49,6 +54,7 @@ peri-tui/src @ peri_(agent|middlewares|lsp|workflow|resources|model):: @ app/cro # launch.rs = plugin 数据源加载块(app.services.plugin_data 供 panels/hooks # 面板派生)+ hooks 生命周期 fire(teardown SessionEnd); # cli_print.rs = 仅 Resources::open()(thread_store 协议面输入); +# cli_meta.rs = 受限只读部署早入口(见上,非 C 类装配面,不得外扩); # main.rs 已随 M-TUI 收口清零(stdio 装配迁入 ACP Host,2026-08-06) # # ── 边 2: ACP 业务面(§0:ACP 纯协议层,只依赖契约层 peri-acp-types 与 diff --git a/spec/issues/2026-09-26-session-store-plan.md b/spec/issues/2026-09-26-session-store-plan.md new file mode 100644 index 000000000..45adb7a4a --- /dev/null +++ b/spec/issues/2026-09-26-session-store-plan.md @@ -0,0 +1,187 @@ +# 会话资源门面拆分与 Turso Cloud — 总设计计划 + +> 状态:**Verify / 实现与缺陷修复已提交**。Fable 已对 `4ba2eefe` 独立复验:原具体 P1/P2 反例通过,本机与已登记存储范围的云端行为通过;真实首登 `Created`、双真实 store 同 root E2E 和大历史上限仍待证。当前状态与命令证据以[母需求文首“最新独立验收”](2026-09-26-session-store-remote-backend.md)为准,不以本计划的历史批次状态推断尚未实现或无条件完成。 +> 授权:用户已授权实施、使用 `.env` 中的测试库做合成数据验证及阶段性提交。凭证仅由测试进程安全解析;无真实会话上传,不自动 push,独立 WIP 保持原状。 +> 本文后续 review/W0/A/B 段为当时设计与阶段证据,保留供核对;远程当前选用 `turso_serverless 0.1.3`,不将历史 SDK 候选或“未连接/未提交”记录当作当前状态。 +> 母需求:[会话资源门面拆分与远程存储 Adapter](2026-09-26-session-store-remote-backend.md)。本组计划属于 active spec,不替代现行 standards/design。 +> **2026-09-27 用户裁决撤销了本组的「本机登记 / 准入裁决」设计(配置即用;远端库 = 本地库的同一种模式)**:显式登记/接管入口与启动探测都不再存在,`session_store_registrations` 等本机远程痕迹表由 v10 删除。最新支持边界见母需求「支持边界」段;本文件与各分计划的登记相关段落是当时设计与阶段证据,保留供核对,不代表当前行为。 + +## 1. 交付目标 + +完成会话资源门面的整体职责拆分:消费侧统一依赖行为门面;数据端口由 SQLite/Turso Cloud adapter 实现,本机执行端口独立持有发现、绑定验证、owner/dirty 和排空事实。首期远程是本机执行 + 远程权威数据,不是跨机执行、同步、备份或离线双写。 + +接口按会话行为定义,事务、CAS、SQL batch、隔离级别、重试令牌与补偿协议不进入门面或 adapter 数据端口。原子性、持久化确认、顺序和诚实失败是必须保留的行为保证,不因隐藏机制而删除。 + +## 2. 分计划索引与事实源 + +| 计划 | 范围 | 计划事实源 | +| --- | --- | --- | +| [A:行为契约与纯逻辑](2026-09-26-session-store-sub-plan-a-contracts.md) | 门面/内部端口、输入输出、错误/能力、旧方法映射、纯变换 | 公共行为接口与结果语义 | +| [B:SQLite 与本机执行](2026-09-26-session-store-sub-plan-b-local.md) | 共享 SQLite 内核、registry/lease/dirty、guard、创建准入、本机兼容 | 本机事实与执行授权 | +| [C:Turso adapter](2026-09-26-session-store-sub-plan-c-turso.md) | 官方能力证据、SDK闸门、远程数据组织、私有确认与恢复 | 远程实现与故障收敛 | +| [D:配置与装配](2026-09-26-session-store-sub-plan-d-configuration.md) | locator/凭证、Resources 打开、CLI、TUI/print/stdio/meta、只读 | 部署输入与后端选择 | +| [E:消费侧迁移](2026-09-26-session-store-sub-plan-e-consumers.md) | Agent transcript/subagent、ACP 生命周期、Controller/middleware、无旁路迁移 | 调用顺序和生命周期整合 | +| [F:验证与云实验](2026-09-26-session-store-sub-plan-f-verification.md) | 现有回归、行为矩阵、故障注入、真实云测试、性能/清理证据 | 验证入口与证据口径 | + +同一语义不在多个子计划独立裁决。接口变更先改 A;本机/远程实现只能在接口保证内选择机制。本文负责总范围、依赖、文件所有权和开工闸门。 + +## 2.1 review-2 闭合索引(12 项) + +独立审阅提出 12 个开工前必须闭合的问题;下表记录本轮以当前代码核实后的关闭位置。判定为“审阅要求过度”的部分一并写明,避免后续实施者机械照做。 + +| # | 问题 | 结论与落点 | +| --- | --- | --- | +| 1 | 公共依赖图与字段最终类型 | 已闭合:门面链与全部最终字段见 [A §2.1](2026-09-26-session-store-sub-plan-a-contracts.md);`Resources`/`Controller.sessions` 返回 `Arc`,`AcpServerConfig.thread_store` 删除,`SessionExecutionLease` 保持两项公共方法(不是事务句柄) | +| 2 | schema 6 与删除后证据被 cascade 抹掉 | 已闭合:v6 → v7 迁移矩阵、`execution_runs` 去外键、删除墓碑见 [B §4.4/§5.3](2026-09-26-session-store-sub-plan-b-local.md);选择独立锚点而非“删除前全部收敛”的证明 | +| 3 | C 的 operation 收据封闭可证明性 | 已闭合:资格先于效果 + 同一原子操作 + 终态封闭竞争同一 identity,引擎前置条件 P1–P7 见 [C §5.0/§5.1](2026-09-26-session-store-sub-plan-c-turso.md) | +| 4 | 创建顺序、creation intent、崩溃点、SavedButNotAdmitted | 已闭合:状态机与崩溃点表见 [B §5.1/§5.2](2026-09-26-session-store-sub-plan-b-local.md);SQLite 同库塌缩为一个事务,远程明确为“durable 数据 + 本机准入”非分布式事务 | +| 5 | PreparedSessionInputs 固定输入、lease 前只读 | 已闭合:字段表、只读规则、插件 manifest 修复分离、new/legacy/fork/child 四条路径见 [E §3.2](2026-09-26-session-store-sub-plan-e-consumers.md) | +| 6 | locator→StoreId→登记→binding→证据→owner 状态表 | 已闭合:[B §6.1](2026-09-26-session-store-sub-plan-b-local.md) 八环矩阵(允许的读、执行前置、必须拒绝);只读允许读 schema/StoreId/binding,无写探测 | +| 7 | 写 guard 只在效果确定时 finish;pending 门禁统一 | 已闭合:三态 `MutationOutcome` 见 [A §5.4](2026-09-26-session-store-sub-plan-a-contracts.md),guard 规则与门禁矩阵见 [B §4.1/§4.3](2026-09-26-session-store-sub-plan-b-local.md) | +| 8 | AccessMode/DataCapabilities/ExecutionAvailability 独立 | 已闭合:三个枚举与不变量见 [A §5.3](2026-09-26-session-store-sub-plan-a-contracts.md) | +| 9 | 迁移矩阵(v6 数据、dirty、回滚、只读、future) | 已闭合:[B §5.3](2026-09-26-session-store-sub-plan-b-local.md) 迁移矩阵 + F 的 V-18 | +| 10 | F 的精确命令与非零命中 | 已闭合:[F §6/§6.1](2026-09-26-session-store-sub-plan-f-verification.md);目标不存在时 cargo 退出码 101 已实测 | +| 11 | 旧 ThreadStore 以编译证据退出 | 已闭合:[A §7.1](2026-09-26-session-store-sub-plan-a-contracts.md)(符号删除 + 全 target 编译;`Controller.sessions` 名称保留)+ F 的 V-15 | +| 12 | FilesystemThreadStore 非完整实现;child frozen 取原字节 | 已闭合:[A §7.1-6](2026-09-26-session-store-sub-plan-a-contracts.md)(`HistoryReadOnly`、no-op 删除)、[E §6](2026-09-26-session-store-sub-plan-e-consumers.md)(不可变 parent/root 已持久化 frozen 字节) | + +未采纳的审阅倾向(保留最小正确机制,不额外建框架):不为“封闭竞争”增加 Open/Applying 多阶段状态机(唯一键竞争 + 同一事务已足够,前提是 P1–P3 成立);不为“删除前收敛”增加分布式证明(改为墓碑锚点);不新增 crate、不做通用 UnitOfWork/事务 DSL。 + +### 2.2 review-3 复核(2026-09-26,本轮) + +上一轮的闭合文本逐条以当前代码复核,并补上查证中发现的缺口(只改计划,不改实现): + +| 复核动作 | 结果 | +| --- | --- | +| §2.1 的十二项落点逐条对照代码 | 主张与代码一致:`Resources.thread_store`(`context.rs:19,101`)、`HostAssemblyInput.thread_store`(`assemble.rs:123`)、`AcpServerConfig.thread_store`(`host/mod.rs:194`)与 `cfg.controller.sessions()` 双路径(`controller.rs:172,290`)、`SessionExecutionLease` 仅两方法(`workspace.rs:209-213`)、schema 6 与三处 `ON DELETE CASCADE`(`schema.rs:11,148,202,211`)、连接启用 `foreign_keys=ON`(`connection.rs:146`,级联确实生效)、`finish()` 无条件调用(`sqlite_store.rs:402-434` 等多处)、`completed: bool`(`execution.rs:33-51`)、`ReadOnlyAdmission` 三原因(`workspace.rs:141-153`)、`FilesystemThreadStore` 静默 no-op(`filesystem.rs:452-463`)、E §2.1 的旁路行号(`persistence.rs:171-191`、`session_fork.rs:50,117,143-172`、`claim.rs:105-140`、`prediction.rs:19,101`)均在位 | +| 需要修正/补齐的落点 | A:`CommandContext` 实体在 `peri-acp-types`(`command.rs:79,111`,`peri-acp` 再导出);补 `SubagentHost`/`SubagentSpawnConfig`/`SubagentResumeConfig`(`subagent/types.rs:100,183,388`)、middlewares `spawn_context`/`configuration`、TUI `Services` 三处生产字段的最终类型;`filesystem.rs` 行号改 452-463。B:v7 去掉 `execution_runs` 外键后,删除路径必须同事务显式删除执行行(否则留下孤儿 dirty 行),已写入 §4.4/§5.3;`mark_clean` 容忍分支行号改 `execution.rs:77-98`。C:封闭记录必须与原身份记录竞争**同一唯一键空间**(另建表不构成互斥),已写入 §5.1;远程版本标记改用引擎可移植的表行,不假设 `PRAGMA user_version`(§4)。D:`libsql://` 同样不能辨识引擎(§3.2)。E:date/env 一次定格、装配不重探(§3.2) | +| 外部证据 | C §2 四条官方页面描述本轮重新抓取复核,全部一致;新增 crates.io/docs.rs 事实:`turso_serverless` 0.1.3(pre-1.0,对应 Turso 引擎)、`libsql` 稳定 0.9.30(`0.10.0-pre.*`,`remote` feature 对应 libSQL 引擎)、`libsql-client` 已停更、官方推荐本地+sync 而被本计划有意排除(sync 排除与引擎选择无关) | +| 本机基线 | 隔离 `HOME` 到临时目录后复跑,结果与 review-2 一致:`--list` 156 tests / exit 0;`--lib` 156 passed / 0 failed / exit 0(8.03s) | +| B 数据侧后的基线(2026-09-26) | `cargo test -p peri-resources --lib` → 182 passed / 0 failed(156 + 22 数据面 + 4 v7 迁移);`cargo test -p peri-acp-types --lib` → 459;`-p peri-acp --lib` → 713;`-p peri-agent --lib` → 868;`cargo clippy --workspace --all-targets -- -D warnings` 与 `cargo check --workspace --all-targets` 均 exit 0 | +| B 执行侧后的基线(2026-09-26) | `cargo test -p peri-resources --lib` → 199 passed / 0 failed(182 + 17 门面);`cargo test -p peri-resources --test session_resources_contract` → 6 passed / 0 failed(F §6 固定目标名,不再是不存在的目标);`-p peri-acp-types --lib` → 460;`-p peri-acp --lib` → 713;`-p peri-agent --lib` → 868;clippy 全 target exit 0。生产路径仍走 `ThreadStore` 桥,消费侧切换属 E | +| 目标不存在 | 按 F 的精确命令复跑:`session_resources_contract` 与 `session_resources_turso -- --ignored --list` 均 exit 101(`no test target named …`),与 §6.1 记录一致。**更新(B 执行侧)**:`session_resources_contract` 目标已按 F 的名字建立(`peri-resources/tests/`),`--list` → 6 tests、运行 6 passed;`session_resources_turso` 仍不存在(属 C/F) | + +## 3. 现场证据与设计影响 + +| 已核对事实 | 影响 | +| --- | --- | +| `ThreadStore` 已经 trait 注入,但包括工作区/lease/dirty 与数据方法;`peri-acp/src/host/mod.rs:194` 与 `controller.rs:172` 是同一 Arc 的两条路径 | 不是再套一层 wrapper;必须迁移所有写调用并封闭 raw adapter,且消除双路径 | +| `Resources::open_with`、`open_thread_store_read_only`、`App::new(db_path)`、`cli_print`、stdio、meta 各自绑定 SQLite 路径 | 全部启动入口纳入 D,meta 的早启动不能牺牲 | +| SQLite 当前 schema 为 6(`schema.rs:11`),`execution_runs`/`session_bindings`/`messages` 对 `threads` 均 `ON DELETE CASCADE`(`schema.rs:148,202,211`) | 不能在保存 thread 前直接取得旧 lease;删除会抹掉 dirty 证据 → v7 墓碑锚点 | +| 新建目前是 resolve → `create_bound_thread` → lease → `SessionEnvironment::assemble` → frozen → 保存(`session_lifecycle.rs:520-620`),失败逐次 `delete_thread` 补偿 | E 拆 frozen 输入准备,A/B 提供完整 new/fork/child 行为 | +| `build_legacy_frozen_data` 不启动 session 资源,但插件发现会经 `try_generate_synthetic_manifest_fallback` 往插件缓存写 `plugin.json`(`loader.rs:91-140`,经 `generate_synthetic_manifest` 落盘) | 不能宣称它是纯函数;lease 前准备要移除写副作用(只读加载入口) | +| `ExecutionWriteGuard::finish()` 在所有 mutation 的正常返回(含 `Err`)后无条件调用(`sqlite_store.rs` 多处),Drop 只置 `mutation_uncertain` | 三态 `MutationOutcome`;只有效果确定才 finish | +| compact 已有原子 lifecycle 和 Unknown 热态失效;消费侧 `persistence.rs:175-191` 逐条写 flags 后另行 invalidation;writer 用 unbounded channel + 64 条/100ms 批量 | 沿用可信恢复方向,收口为完整行为,补 slow network 积压预算 | +| SQL 默认 no-op/假缺失真实存在(`store.rs` 的 `update_message_flags`/`delete_messages_since`/`load_message_flags`/`get_context_cache_epoch` 默认体;`filesystem.rs:452-459` 静默 no-op) | adapter 行为测试要排除假成功;不支持必须显式失败 | +| Rust 官方资料区分 Turso 与 libSQL 引擎、两种远程 SDK(C §2) | 远程路线是 over-the-wire 权威读写;`turso_serverless`(Turso 引擎)与 `libsql` remote(libSQL 引擎)都保留为候选,由 C-01 按目标库只读探测 + 官方对应关系二选一(C §5.0);sync/replica/双写仍显式 Unsupported | +| 仓库依赖为 `sqlx 0.9.0`/`reqwest 0.13.4`/`url 2`,无 `libsql`/`turso*` 条目(`Cargo.toml:51,80,81`) | 不在计划阶段预引入 SDK | +| 远程驱动候选两条:`turso_serverless` 0.1.3(pre-1.0)对应 Turso 引擎;`libsql` 0.9.30 对应 libSQL 引擎;官方默认推荐本地+sync(review-3 联网复核) | 用户只指定 Turso Cloud(产品名),未指定库引擎:SDK 由目标库只读探测结果 + 官方事实选定,探测前不预先排除任一条;不因 SDK 年轻或官方推荐而改用 sync/双写,未证明即保持远程写关闭 | +| HTTP v2 pipeline 在前项失败后仍执行后项 | pipeline 不是自动事务;批处理与原子行为须单独验证 | + +以上来自源码/测试静态核对和公开文档,不是本次运行通过证据。外部来源集中在 C;本轮没有访问用户账号或实际 URL。 + +## 4. 总体设计决策 + +### P-01:一个生产门面,两个内部职责 + +`peri-acp-types` 定义 `SessionResources` 行为契约;`peri-resources` 实现并装配内部数据/本机执行职责。Controller 与 session context 引用同一门面;业务不可取得裸数据写端口。Runtime 不新增持久化状态。 + +### P-02:完整行为而不是通用事务 + +新建、legacy 接纳、fork、child、compact、projection、rewind/delete 各有完整领域输入与后置条件。领域侧做确定性变换,adapter 不做 compact 算法或 frozen 渲染。禁止通用操作列表/SQL executor/UnitOfWork 外露。 + +### P-03:Binding 事实与验证分开 + +不可变 binding 与会话/frozen 在数据端保持原子关系;本机 registry 持有项目/工作区位置证据及执行资格。SQLite 可保持同库,Turso 不要求远端理解 inode/Git。 + +### P-04:明确数据保存与执行准入不是一个跨系统事务 + +完整数据保存后才建立可执行会话;本机失败可留下完整历史但不得发布执行成功。私有创建意图和锁预留用于恢复,不让 ACP 拼存储补偿。已有资源创建/关闭仍由其原 owner 管理。 + +### P-05:未知写入与 ordinary dirty 分离 + +C 的内部协议证明结果已生效或不可能再生效后,才能重新放行写入;一次空读取、超时、取消或用户同意 dirty 风险都不足。判定手段是**封闭记录与原始操作竞争同一唯一身份**(C §5.1):封闭提交成功即证明原操作不可能再生效,封闭本身未确认则保持阻塞。公开只表达 session 级恢复结果,不暴露 operation token。 + +### P-06:默认本机路径保守迁移 + +仍使用 `~/.peri/threads/threads.db`、既有历史/只读兼容语义,同库共享 pool;不引入云网络或第二份本机历史。远程模式下同一本机库只保存本机执行事实(registry、`execution_runs`、锚点、登记),canonical 历史仍在远端,不新建第二个本机数据库文件。旧生产 trait 不作为最终兼容路径,退出以符号删除 + 全 target 编译证明。 + +### P-07:首期单宿主远程范围 + +专用远程库/授权写入范围、稳定 StoreId 与本机登记配对;外来会话或 registry 丢失只读,不自动 legacy 接纳,也不自动初始化已有数据的存储。OS锁不承诺跨机互斥,禁止外部 writer/旧二进制混用同一可写范围。 + +## 5. 实施前闸门与未确认项 + +本轮不打断规划要求用户提供 secrets;以下在相应施工阶段前解决: + +| 闸门 | 待确认/验证 | 阻塞范围 | +| --- | --- | --- | +| G-01 | 本组行为与事实归属设计评审(含完整创建与 Unknown 语义) | **已解除**(review-2 闭合 12 项,见 §2.1);剩余实现级细节不构成开工阻塞 | +| G-02 | 目标数据库引擎与 SDK/版本,及其原子行为/权威读/晚提交恢复可行性 | 已缩小:远程路线为 over-the-wire 权威读写,候选 SDK 为 `turso_serverless`(Turso 引擎)与 `libsql` remote(libSQL 引擎)两条(C §5.0/§2),由 C-01 对授权测试库只读探测后在两者中选定一条并锁定精确版本;sync/replica/双写显式 Unsupported。仍需 C-01 实测 P1–P7,未证明前远程写保持关闭 | +| G-03 | 独立测试数据库写入/清理授权,以及 `TURSO_TOEKN` 拼写是否确认 | **blocked**:用户未作答(cloudAuthorized=false)。只交付 adapter/显式云入口/本机确定性验证;不读 `.env`、不连接;变量名只按原样记录,不设别名 | +| G-04 | frozen 预备输入可无写副作用准备,并与后续装配复用 | 设计已闭合(E §3.2:只读加载入口 + 同一对象消费),实现待验证 | +| G-05 | 远程未知写入能恢复终态并阻止迟到写;本机多进程/别名锁域证明 | 设计已闭合(C §5.1 + B §6.1),证据待 C-01/F;未证明只保留读/阻塞,不关闭 issue | +| G-06 | 初始性能与积压基线,确定实际条数/字节/时限预算 | 远程默认可用性承诺;不凭经验预填 SLA | + +不把外部待证事实伪装成已定 API。涉及首期多机接管、离线模式、自动导入旧历史等范围变更需单独裁决。 + +## 6. 批次与依赖 + +```text +W0:F-01 基线 + C-01 可行性证据 + A 接口/纯逻辑评审 + (review-2 已完成:设计评审闭合 + 本机基线,见 §2.1/§9;C-01 与云授权仍未完成) + ↓ +W1:A 行为契约 + B SQLite/本机门面实现 + ↓ +W2:E 本机端到端迁移 + D 统一 locator/只读入口 + ↓ +W3:B 远程 registry + C Turso 行为/内部恢复 + D 远程 factory + ↓ +W4:F 默认故障测试 + E 全链路 adapter 切换验证 + ↓ +W5:显式 Turso Cloud 实验、成本/清理证据、事实源更新 +``` + +W0 中外部权限未就绪可先完成纯设计/本机基线;但不得跳过 C-01 开始臆测远程实现。共享文件顺序施工,每批保持可编译/可测;短期旧 wrapper 退出清单绑定 W2,不允许 W5 仍存在 raw store 生产旁路。 + +## 7. 文件所有权与协作约束 + +| 文件域 | 主负责计划 | 接续方 | +| --- | --- | --- | +| `peri-acp-types/src/store.rs`(删除 trait、保留 payload/flags/继承编码)、新 `session_resources`/history 类型及 crate 导出 | A | E 更新消费字段时按 A 接口,不独立改结果模型 | +| `peri-acp-types/src/workspace.rs` | A 领域错误/执行类型 | B 行为实现、E 协议映射顺序接续 | +| `peri-resources/src/sessions/*` 本机/门面/registry | B | C 新增 Turso;不同时改共用门面 | +| `peri-resources/src/context.rs`、只读入口 | B 先完成门面返回 | D 接管外部 open request/factory;C 只提供构造函数 | +| `peri-resources/src/sessions/turso/*`、workspace/resource Cargo 依赖 | C | F 加测试和锁定证据 | +| TUI CLI/launch/print/meta、Agent resource wrapper、stdio 外部参数 | D | E 装配内部句柄引用顺序接续 | +| Controller、ACP lifecycle/dispatch、Agent transcript/subagent、middlewares bridge | E | F 契约/链路覆盖 | +| 新契约/云实验测试目标及证据组织 | F | 各计划自带相邻回归,不把测试拖到最后 | + +本次规划开始时已有 `.github/workflows/ci.yml`、`peri-middlewares/src/mcp/mod.rs`、`peri-middlewares/src/mcp/builtin_spike_test.rs`、`peri-cool` 的非本任务改动;未来开工重新核对,不覆盖、不混入提交。共享工作树内各阶段串行,开工前先看前序 diff。 + +## 8. 验收和文档收尾 + +完成要求不是“七份文档已勾完”,而是: + +- A 接口无底层机制,业务不按后端分支;新旧写入旁路收敛(符号删除 + 编译证据)。 +- B 保住 SQLite 的历史、schema、只读、owner/dirty、legacy、close 与成本基线;v7 迁移保留 dirty 且可回滚。 +- C 对完整会话行为及未决写入有故障证据(含封闭竞争的 P1–P7);D 所有部署入口一致且不泄露凭证。 +- E 的 Agent/ACP/Controller/middleware 使用同一路径;new/fork/child/compact 不在业务侧手工补偿。 +- F 的确定性矩阵与真实 Turso 冷恢复、性能、清理证据分开记账;任何未运行/缺凭证/0 tests 都不算通过。 + +实现时按 DOC-UPDATE-001 更新受影响的 architecture contracts、身份设计、code-index、模块路由与测试命令;本轮仅添加 active plans,不提前把它们写成已实现的权威设计。未来验收记录在获得实施授权后建立,不在本轮制造空的“已验收”文件。 + +## 9. review-2 / review-3 进度与证据(2026-09-26) + +review-2 已完成独立审阅 12 项的闭合(§2.1)与当时基线(`cargo test -p peri-resources --lib` → exit 0,156 passed / 0 failed,7.37s;两个目标不存在 → exit 101)。review-3 为同一日期的复核轮:只做设计闭合与事实核对,不改实现。记录如下,供后续阶段直接引用: + +| 项 | 结果 | +| --- | --- | +| §2.1 十二项 | 落点逐条对照当前代码复核;修正/补齐 9 处落点与措辞(见 §2.2),无被推翻的结论 | +| 以代码核实的关键事实 | 见 §3;本轮新增 `foreign_keys=ON`(级联确实生效)、`mark_clean` CAS 与容忍分支(`execution.rs:59-104`)、`.execution-locks` sidecar(`execution.rs:118-121`)、插件合成 manifest 落盘、`MessageTranscript.store`(`transcript.rs:180`)、`command/mod.rs:36` 再导出、`workspace.rs:71` 装配复用、`SESSION_BINDING_VERSION=1`(`workspace.rs:44`) | +| 本机基线 | 隔离 `HOME` 到 `mktemp -d`(保留真实 `CARGO_HOME`/`RUSTUP_HOME`)后:`cargo test -p peri-resources --lib -- --list` → exit 0,156 tests;`cargo test -p peri-resources --lib` → exit 0,156 passed / 0 failed(8.03s)。无预存在失败 | +| 不存在的测试目标 | `cargo test -p peri-resources --test session_resources_contract -- --list` → exit 101;`cargo test -p peri-acp --test session_resources_turso -- --ignored --list` → exit 101(现有目标仅 `concurrent_bg_agent_test`/`integration_test`/`prompt_cache_boundary`) | +| 外部证据 | 仅抓公开页面与包里元数据(`docs.turso.tech` 的 Rust Quickstart/Reference、SQL over HTTP Reference、libSQL HTTP v2 规范、crates.io/docs.rs);未读 `.env`、未连接任何数据库、未使用任何凭证;C §2 的四条描述与本轮抓取一致,另加版本与 API 面事实 | +| 云实验 | **blocked**:cloudAuthorized=false(G-03),本轮未执行任何云命令;`TURSO_URL`/`TURSO_TOEKN` 仍只按原样记名 | +| 仍属未验证 | C-01 的引擎/驱动精确版本与 P1–P7 实测、远程端到端链路、性能/积压预算、v7 迁移与墓碑的实际运行证据(实现阶段产出) | + +下一步(不在本轮授权内):按 §6 的 W1 起开工,先落 A 的接口与 B 的 SQLite/v7 迁移,再按 W2→W5 推进;每批以 F §6 的精确命令取证,`--list` 非零才开始。远程写路径在 C-01 通过前保持关闭。 diff --git a/spec/issues/2026-09-26-session-store-remote-backend.md b/spec/issues/2026-09-26-session-store-remote-backend.md new file mode 100644 index 000000000..1fddbbd5a --- /dev/null +++ b/spec/issues/2026-09-26-session-store-remote-backend.md @@ -0,0 +1,1677 @@ +# 会话资源门面拆分与远程存储 Adapter(首期 Turso Cloud) + +**状态**:Verify — 行为门面、SQLite/Turso adapter、部署配置与消费侧迁移已落地;Fable 对提交 `4ba2eefe` 的独立复验确认上一轮具体 P1/P2 反例均已修复,本机及**已登记存储范围**的云端行为通过。真实空库首次创建、双真实 store 同 root 端到端和大历史上限仍缺证据,issue 保持打开;不得将限定范围通过表述为无条件生产可用。 +**更新日期**:2026-09-27(支持边界按用户裁决更新:登记/准入撤销,见下) + +## 最新正向验证(2026-09-27,登记/准入撤销后) + +| 路径 | 证据 | +| --- | --- | +| 云端全生命周期(真实 Turso 测试库) | `sessions::remote::cloud_deployment_tests::cloud_deployment_entry_point_full_lifecycle` PASS:部署入口建会话 → 追加/排空 → compact → fork(复用 source message id 被拒绝、重映射后落库)→ child → 标题 A→B→A → 关闭;**新进程**冷恢复 → 未决收敛 → 解除 ordinary dirty → rewind → 删树;只读两档(`fresh` 本机无执行库按 `NotFound` 拒绝且零文件、`registered` 读到远端历史并类型化拒绝写入)。 | +| 产品入口往返(真实二进制) | `peri --session-store 'env:TURSO_URL' --session-store-token-env TURSO_TOEKN acp` 的 `session/new` 建会话;临时 HOME 的本机库(v10)只有 `execution_runs` 一行,`threads` / `session_bindings` 全空、无任何远程痕迹表;同 HOME 用 `peri … meta session --json` 读回该会话元数据 ⇒ 数据在远端、本机只留执行事实。 | +| 本机 schema 回退 | 既有本机库(v9,3.1 GB)含 v7..v9 写下的 5 张远程痕迹表,列形状与 `DROPPED_LOCAL_TABLES` 逐列一致(`require_columns` 通过):下次写打开会整体删除这 5 张表并保留 `execution_runs` 行。 | + +同轮门禁:`cargo check --workspace --all-targets`、`cargo clippy --workspace --all-targets -- -D warnings`、`cargo fmt --check` 均干净;`cargo test --workspace --lib` 全绿(`peri-resources` 325 passed / 22 ignored)。唯一例外是 `peri-tui` 的 `kit::acp_bridge` 两例**既有**不稳定性(同进程内多个用例改写全局 atom,与本次改动无关:同一台机器上两个不同构建的测试二进制各跑 3 次、各命中 1 次失败;该测试文件未被本次改动触碰)。 + +## 最新独立验收(Fable,提交 `4ba2eefe`) + +下文各阶段记录是当时状态,不代表当前仍未实现。最新结论与剩余边界以本节为准。 + +| 原问题 | 独立复验结果 | +| --- | --- | +| 跨 StoreId 错结清 | PASS:B 的恢复不改 A 的 pending、执行代际、墓碑及旧 raw dirty,不自动登记 | +| 首次初始化竞争 | 判定层 PASS:本机真 SQL + 生产判定只有胜者可首登,Unknown 不发创建事实;真实空 Turso 上 `Created` **未观测** | +| child 父关系不一致 | PASS:矛盾快照拒绝且无行;合法 child 仍属原 root,不能获取独立 owner | +| close 失败重试 | PASS:连续未决均拒绝,Closing 可恢复;无 live lease 的 durable pending 仍阻止关闭,结清后成功;业务门面无全局关闭权 | +| 显式本机路径 / 缺凭证 | PASS:`env:` 字面文件名不再解引用,PathBuf 保真;缺凭证为 NotConfigured,零 registry I/O | +| 取消读后同实例连接 | 云 PASS:取消后同实例下一读为正确 `NotFound`,不是传输 `Unavailable`;只读临时 HOME 零文件 | +| 收据保留 | PASS:清理仅删除本轮合成会话/消息,新连接复核收据保留;不删除旧 run 的封闭证据 | +| 同 root 跨 store / 旧 dirty | 本机判定及单真实库反例 PASS;两个真实已登记 store 并发端到端 **未验证** | +| frozen 同源 | PASS:7 项 prepared 测试;保存/live 使用同一准备输入,准备后修改外部文件不会重读 | + +本轮实际命中:`peri-resources --lib` 355 passed / 28 ignored,契约目标 6 passed;TUI bin 的 session_store 与 cli_meta 各 8 passed;ACP/Agent/Controller/Middlewares 编译检查和快照 fmt 通过。云端独立复验包含已登记部署生命周期、新进程冷恢复、两种真实强杀断点与只读取消恢复;未将 ignored 子入口的静默返回算作云证据。 + +支持边界(2026-09-27 用户裁决后更新):**显式登记/接管入口与启动探测已被用户裁决撤销——现行语义是「配置即用」**:配置里指到哪个会话存储就直接用哪个,不要求先登记,也不再有本机登记、准入裁决、跨安装来源判定,因此也不再有会话操作前的主动探测与登记风险确认。撤销的落地物:v10 迁移整体删除 5 张本机远程痕迹表(`session_store_registrations` 等,先校验表形状、不符即拒绝升级)、`remote/{registration,local_execution}.rs`、`local_port.rs` 的接纳/登记方法、`session_resources.rs` 的 `ExecutionAvailability::NoLocalRegistration` 变体与全部引用、`peri-acp-types::workspace` 的 `StoreAdmission` / `RegisterStoreRequest` / `StoreRegistrationOutcome`、`PeriCaps::session_store_registration_v1`、两条 RPC(`peri/session_store_status` / `peri/session_register_store`)与 wire 错误 `storeNotRegisteredV1` / `storeRegisteredFromDifferentOriginV1`、TUI 客户端探测(`acp_client/client/store_registration.rs`、`RiskPrompt::StoreRegistration`)与 `store-registration-*` 文案键。**新目标**:远端库 = 本地库的同一种模式,**两个存储模式必须一致**——schema/SQL 的统一是另一段工作,本节只记录目标与边界,不代表已完成(见下方「统一进行中」)。本节随后保留 2026-09-27 撤销前的支持边界原文,仅作历史;其结论已被取代: + +> **(已取代,保留为历史)** 显式登记/接管入口已落地,用户不再只有「由本安装初始化新库」一条路。两条经 `peri.sessionStoreRegistrationV1` 门控的 RPC(`peri/session_store_status`、`peri/session_register_store`)提供状态查询与登记;TUI 在会话操作前主动探测,需要时弹风险确认,用户显式接受后才登记。**未登记仍不自动认领**:已有数据而本机无登记时历史可读、执行拒绝,来源不一致拒绝且不覆盖既有登记;未协商能力、确认未呈现(`RiskChoice::NotShown`)、只读打开(`canRegister=false`)与用户取消都不得登记,且都留下用户可见结论。真实二进制 + 真实测试库已复现并修复三类静默路径:风险确认弹窗在「一帧装不下」时替用户按取消结清、弹窗位置被占用时静默跳过、启动期会话创建失败只写进程日志;修复后同一确认在窗口变化后仍成立(装不下的帧不再结清、未完整渲染的接受降级为 `NotShown`),登记成功、建会话、极短 prompt 与远端历史读回在真实 TUI 上成立,另以真实二进制 ACP stdio 走通「拒绝 → 登记 → 接纳 → 建会话 → 读回」。真实首登 `Created`、双真实 store E2E、大历史读写上限(P6)继续待证;已有登记夹具不能证明首次准入;未重新批量执行所有云实验或完整 workspace 套件,不将局部通过扩展到未测场景。 + +**统一已落地(远端库 = 本地库,两个存储模式一致)**:远端 adapter 现在**直接说本机形状的 SQL**——`threads` / `messages` / `session_bindings` / `projects` / `workspaces` 同名同列同语义,逐条建表/建索引清单、显式删除语句与 `messages.role` 派生都取自新的 `sessions/canonical.rs`(两种执行器共用的那一份 schema),两个 adapter 之间不再有表名/列名映射层。删掉的映射层:`peri_sessions`(meta 列 + 四个扁平绑定列)、`peri_session_messages`、远端独有的 `ordinal` 列与它的索引,以及单条 18 参数的会话插入(拆成 canonical 的 `threads` 行 + `session_bindings` 行 + child 的继承区写入,与本机同形)。 + +| 统一方案要回答的问题 | 落点 | +| --- | --- | +| 哪些表是 canonical(远端也要有) | `threads` / `messages` / `session_bindings` / `projects` / `workspaces`(`canonical::CREATE_TABLES`,`CREATE_INDEXES` 四条索引) | +| 哪些是本机执行事实(远端不建) | `execution_runs`(执行代际;远端没有执行面) | +| 本机独有的辅助业务表 | `thread_goals` / `extension_state` 这类**不是本模块建的**同库业务表:本模块只校验自己那几张表的形状,升级时原样保留、不进 canonical schema、远端不建 | +| 远端独有 | 执行器机制表:`peri_op_ledger`(幂等资格账本)、`peri_store_meta`(版本标记)——它们不是会话 schema,本机不建 | +| 版本标记放哪 | `peri_store_meta.schema_version` **就是**本机 `CURRENT_SCHEMA_VERSION`(同一个常量,不再是各写一份的 1);远端写不了 `PRAGMA user_version`(服务端拒绝,§9.8 探测项 5b),载体差异保留 | +| 不一致怎么办 | fail-closed,三处都拒绝且不猜测:契约不是 `peri.session.store/v2`、版本高于本构建、或「有 `peri_sessions`/`peri_session_messages` 却没有本构建身份」 | +| 旧形状的库 | **拒绝,不迁移**:持有 v1 契约的库在读身份时被 `matches_build` 挡下;没有身份但有旧会话表的库在写打开初始化前被旧形状探测挡下(`Unsupported`)。新库没有历史数据,迁移路径没有被需求,也不覆盖使用者看不见的数据 | +| 重复打开 | DDL 全部 `IF NOT EXISTS`,且**一条语句一个 spec**:远端执行器的语句单元就是一条语句,多句拼一个请求只会执行第一条(本轮实测踩到过,见下) | +| 并发初始化 | 语义不变:身份竞争仍是元数据行的唯一键竞争,`Created` 只在「本事务插入并提交」时产生 | +| 部分建表失败 | DDL 在托管批里 → 整批回滚、零残留;下一次写打开按同一份清单补齐 | + +执行器差异(同一份形状,差别只剩这些,且每条都有实测依据): + +| 差异 | 本机 SQLite | 远端 over HTTP | +| --- | --- | --- | +| 版本载体 | `PRAGMA user_version`(迁移收尾写入) | `peri_store_meta` 单行 | +| 幂等与资格 | 本地事务 + 主键冲突 | `peri_op_ledger` 资格行先于效果,同一托管批 | +| 父行检查 | DDL 的外键声明被强制执行(读写同连接打开 `foreign_keys`) | 写打开时把 `PRAGMA foreign_keys` 归位为 OFF:`projects`/`workspaces` 的**行**在远端没有来源(workspace 证据是本机事实),外键无从满足 | +| 删除 | 显式先删子行(`REFERENCES ... ON DELETE CASCADE` 保留但已退化为安全网) | 同一份显式删除语句(远端没有级联,也没有 `foreign_key_check` 等价物) | + +真云验证(新库,`--ignored --nocapture --test-threads=1`):`cloud_deployment_entry_point_full_lifecycle`(建会话 → 追加/排空 → compact → fork → child → 标题 A→B→A → 关闭 → 冷恢复 → 未决收敛 → rewind → 删树 → 只读两档)与 `cloud_session_*` / `cloud_history_*` / `cloud_identity_*`(2 例)/ `cloud_lifecycle_*`(2 例)/ `cloud_limit_*`(4 例)/ `cloud_mutation_*`(5 例)/ `cloud_recovery_*` / `cloud_tests::*`(3 例)全绿;`cloud_store_shape_snapshot` 盘点远端表集合 = `threads,messages,session_bindings,projects,workspaces,peri_store_meta,peri_op_ledger`(表名与形状即本机那一份),`schema_version=10`、`contract=peri.session.store/v2`。踩到并修掉的真缺陷:**DDL 一度被拼成一个多语句 spec**,远端只执行第一条 → 会话表根本没建,所有真云用例在清理阶段转红;改为逐条下发后全绿。 + +本轮未做/未验证(登记在案,不当作已解决):`projects` / `workspaces` 在远端是**空表**——本机 workspace 证据(locator/root/对象身份)没有进入 `SessionDataPort` 输入的通道,要让远端也持有 workspace 记录需要把该事实带进端口输入(契约变更),本段不做;服务端 `PRAGMA foreign_keys` 是**跨连接共享的可变状态**(实测曾被先前的探测留在 1,导致绑定写入撞外键),生产路径因此每次写打开归位,但别的写者若在会话写入期间打开它,写入仍可能失败;并发初始化竞争与旧形状库的拒绝路径只有判定层证据(离线 + 真云单条路径),没有多进程竞争的真云实验;一次性探测装置 `cloud_transport_probe_test.rs` 已删除(其结论沉淀见 §9.28),它的探测表前缀与本次改动无关。 + +另:撤销只针对登记与准入,与登记无关的修复照旧保留——dirty 恢复的三态风险选择(`RiskChoice::{Accepted,Declined,NotShown}` 与完整渲染判定)仍是 TUI 的现行行为,启动期会话创建失败的用户可见通知(`session-creation-failed`)也仍在。 + +收据空间成本是刻意保留的恢复证据:本轮 deployment 报 `retained_receipts=11`,强杀演练报 `retained_receipts=6`;旧 Fable foreign pending 封闭记录按清理器构造保留,未独立直接读取确认。复现程序和测试 HOME 均在系统临时目录;凭证只在授权测试进程内解析,未回显其值。 + +修复提交:`f9fdab61`(StoreId 隔离与收据)、`2dcb9b14`(初始化/child 准入)、`b47e98f5`(关闭权与连接生命周期)、`4ba2eefe`(路径与单次准备输入)。独立 verdict 为本机 PASS、已登记云支持范围 PASS;未验证子项不计通过,**保持 Verify,不关闭本 issue**。 + +**目标**:完成整体会话资源门面的职责拆分,通过 adapter 接入可替换的数据存储;保留本机 SQLite 默认路径,首期以 Turso Cloud 为远程对象,验证完整会话链路、失败恢复和运行成本。不是只增加一个远程类,或在混合职责的 `ThreadStore` 外再套一层转发。 + +**范围**:会话资源门面、数据与本机执行端口、实例化与定位配置、消费侧迁移、SQLite / Turso Cloud adapter、契约测试及显式云端实验。不改变消息内容格式、compact 算法,不重设计前端展示;仅做接入能力和错误表达所需的调用方调整。 + +**关联**:[会话、项目与 Worktree 身份](../../docs/design/session-workspace-identity.md)、[架构契约](../../docs/standards/architecture-contracts.md)、[测试规范](../../docs/standards/testing.md)、[资源代码索引](../../docs/code-index/peri-resources.md)。现行身份设计限定在本机同一存储;本 issue 扩展持久化位置,不自动扩展跨主机执行权。下文为待实现目标,不表示现行契约已经改变。 + +## 1. 背景与迁移前现状 + +本节描述提出需求时的代码状态;当前实现与独立验收见文首。 + +会话存储最初只有本机 SQLite 一种生产实现。契约与实现已分离,主要消费侧通过 `Arc` 注入;障碍不在于缺少 trait,而在于数据存取与本机执行责任混合,且打开入口绑定具体后端。 + +关键事实源: + +- `peri-acp-types/src/store.rs::ThreadStore` 同时承载历史数据、工作区发现、binding、执行 lease 与 dirty reset;部分默认方法返回 no-op、空数据或缺失值,无法区分「不支持」与「确实不存在」。 +- `peri-resources/src/context.rs::Resources::open_with` 接收 SQLite 路径;`sessions/mod.rs::open_thread_store_read_only` 是另一条打开入口。TUI、print、ACP stdio 和 `peri meta session` 都需纳入定位与装配迁移。 +- `sessions/sqlite_store/{workspace,execution,compaction}.rs` 承载 binding/frozen 原子接纳、根 owner 写入授权、dirty 代际及 compact 事务,不能拆接口时丢失这些契约。 +- `peri-agent/src/session/transcript{.rs,/persistence.rs}` 已有有序 writer、flush barrier 和 compact 提交不确定状态;远程接入应延续,而不是另建一份可漂移的历史。 +- `FilesystemThreadStore` 是测试用途,未完整持久化 compact flags,不支持原子 compact lifecycle;不能用它证明远程方案成立。 + +## 2. 已明确目标与首期边界 + +1. **整体拆分作为本次完成条件。** 消费侧统一依赖稳定的会话资源门面;内部将数据存取与本机执行职责分离,通过明确端口组合。允许一次性迁移契约和调用点,迁移完成后切换 adapter 不再改业务调用点,不出现按 SQLite/Turso 类型分支的业务逻辑。 +2. **两种真实 adapter。** 本机 SQLite 与 Turso Cloud 实现同一数据契约。首期验收不再要求数据库引擎「非 SQLite」,而是要求独立远程 adapter 与真实 Turso Cloud 读写成立;只在本机运行兼容引擎不算云端验收。 +3. **首期为本机执行、远程权威持久化。** 执行环境、文件工具与进程仍在本机;远程不是备份副本,也不是远程执行宿主。不提供多机接管、跨机并发续写、自动改绑、跨工作区续作、离线双写或自动同步合并。 +4. **单宿主范围可验证。** 明确远程存储实例身份、写入命名空间或授权隔离及本机执行登记的对应关系。相同存储的不同 locator 别名不能产生独立本机锁域;外来会话或丢失本机登记不得被当作 legacy 自动接纳。不用本机 OS 锁声称具有跨主机互斥,也不以 last-writer-wins 处理历史冲突。 +5. **默认本机行为不变。** 默认仍是 `~/.peri/threads/threads.db`,保留现有数据、schema 升级、只读降级与生命周期语义。拆职责不强迫 SQLite 改为两库,也不增加默认路径的网络依赖。不得静默迁移或上传现有会话。 + +## 3. 目标职责与装配 + +```text +Agent / ACP / Controller / TUI 既有合法资源访问入口 + │ + 稳定的会话资源门面 + │ + ┌─────────┴─────────┐ + │ │ + 会话数据端口 本机执行端口 + │ │ + SQLite / Turso adapter 发现与验证、根 owner、 + OS lease、dirty 与写入排空 +``` + +- **门面**:收口数据访问与执行授权的组合、能力判定、后端无关的错误及持久化完成边界。不是让调用方手工组合两面,也不是迁入 Agent 循环、ACP 协议生命周期或 TUI 状态;TUI 交互仍遵循现有 ACP 路径。 +- **数据端口**:元数据、canonical payload(含可信 reminder)、消息顺序、flags、frozen、继承快照、轻量列表及分页、原子 compact、删除/rewind,以及必要的持久化身份记录。不得要求 adapter 运行 Git、检查本机目录或管理执行进程。 +- **本机执行端口**:工作区发现和证据复核、绑定准入、根/子会话归属、独占所有权、dirty 代际恢复、写入准入关闭与排空证据。数据 adapter 不成为绕过 owner 的公开写入口;子 Agent、后台 writer、compact、rewind 和删除均受同一授权约束。 +- **装配入口**:沿 `peri-acp-types` 契约、`peri-resources` 实现与 Resources 实例化入口演进。普通打开和只读打开共用后端选择;业务侧不泄漏连接、pool、SDK 或具体 adapter 类型。不为首期预建插件框架,也不以新增 crate 作为拆分是否完成的判断标准。 +- **物理存储**:职责拆分不等于分库存放。SQLite adapter 可保留原 pool、schema 和事务;需要原子提交的持久化事实优先留在同一事务域。 + +具体类型名和方法集合在实施设计中确定,但完成时不得保留仍承担混合职责的旧生产旁路;必要兼容入口只能归一转入新门面。 + +### 3.1 行为优先,机制内聚 + +**门面与 adapter 实现的数据端口都以会话行为定义,不以数据库操作定义。** 适配面表达要保存/读取的会话事实、业务前提、可观察结果与失败语义;底层如何使用事务、条件更新、锁或重试实现这些保证,不进入接口。 + +- **尽可能纯化行为。** 历史裁剪、fork 的消息身份与 flags 映射、compact 变更的合法性判断等确定性逻辑,与 I/O 和后端选择分离;由领域侧根据显式输入计算结果,adapter 负责可靠存取,不重新解释 compact 算法或会话执行策略。不为此改算法或引入通用计划执行框架。 +- **接口按完整行为设计。** 例如保存新会话、接纳旧会话、追加历史、保存派生会话快照、应用压缩结果、回退历史。需要共同成立的事实以有领域含义的输入一次交付,调用方不拼装多次 CRUD 来维持一致性;具体方法按真实调用需求收敛,不照搬这份示例清单。 +- **不暴露存储机制。** 门面和数据端口不提供 `begin/commit/rollback`、事务句柄/闭包、连接、隔离级别、SQL batch、CAS 参数或底层重试令牌;也不引入通用 `UnitOfWork`、事务 DSL 或按后端选择的事务开关。 +- **保证可见,机制不可见。** 「压缩结果全部生效或不生效」「冻结快照不被覆盖」「成功确认后可恢复」「顺序稳定」是行为后置条件,必须可测试;事务、CAS、去重、补偿与网络重试是实现手段,封装在 adapter 或资源模块私有协调实现中,不要求消费侧执行协议。 +- **事实失败不能隐藏。** 无法确认是否已保存时,适配面返回后端无关的结果,门面阻止不安全续写并提供必要的恢复行为;不外泄事务状态机、数据库事务 ID 或要求调用方自行回滚。既有 owner/dirty/执行排空仍是本机执行领域语义,不因隐藏数据库机制而删除或移入远程 adapter。 + +## 4. 必须守住的契约 + +### 4.1 Binding 事实与本机执行语义分开 + +本机负责 binding 的发现、验证和授权;不可变 binding 记录是持久化事实,允许由数据 adapter 保存,不等于要求远程承担本机执行语义。实施前明确项目/工作区登记、位置证据、binding、执行代际分别由谁持有,以及本机登记丢失后的恢复边界。 + +- 新 thread 与 binding 的创建关系、legacy 接纳时 binding 与缺失 frozen 的原子提交必须保留;已有 binding/frozen 不可被普通 metadata 更新覆盖。 +- 若方案把原本同事务的数据分到本地和远程,必须先给出可恢复的提交状态及故障验证;禁止以「先写 A,再写 B,失败时尽力删除」冒充原子性。 +- frozen 的行为保证是只保存一次、不可覆盖,并返回权威快照或明确冲突;并发时的 CAS、胜者重读等由内部实现完成,不让调用方处理数据库竞争协议。新建/fork 中途失败不得发布可执行半成品;无法完成或无法确认完成时给出诚实结果,补偿过程留在内部。 +- 普通 fork 保持 source binding 与精确 frozen;子会话冻结 inherited payload/flags 并维持 ancestor/own 边界,不把远程恢复变成重新读取当前父会话状态。 + +### 4.2 持久化确认与未知结果 + +- adapter 明确成功返回代表的持久化、顺序及读取可见性保证。flush barrier 确认此前写入已满足契约,而不是仅入队、进入 SDK 缓冲或本地缓存。 +- 「应用压缩结果」保证摘要、flags、计数与缓存可见性一致生效,不由调用方分别更新;追加历史、派生快照、删除与回退分别定义完整的可观察结果。底层事务范围、幂等记录与缓存 epoch 留在内部,不作为调用参数或编排步骤。 +- 对外区分确定未生效、确定已生效与无法确认结果;不输出数据库事务阶段。网络断连/超时/取消不证明远程未保存;重新读取一次也不证明旧请求已终止。 +- adapter 与资源模块内部负责查询/收敛未知写入;门面只提供领域所需的恢复行为及结果。未收敛时不得盲目重试、回滚已保存历史、标 clean、自动 reset dirty 或放行新 owner;既有普通 dirty 恢复不能被用来忽略仍可能生效的远程请求。 +- adapter 内部重试只能建立在已验证的幂等或操作身份保证上,相关记录与协议不外泄到消费侧;不能由通用网络重试掩盖重复追加和部分成功。存储版本/并发校验不等于取得执行所有权。 +- 保留 writer 的有序批量写入、失败传播与热状态失效语义;评估远程慢请求对无界队列、关闭等待和资源占用的影响,落实有界超时、积压或背压策略。 + +### 4.3 能力、访问模式与错误 + +- 分开表达后端支持的会话行为、当前读写权限以及运行时健康/操作结果;提供类型化错误,不靠字符串匹配决定降级。能力面描述能否安全完成某项会话行为,不暴露是否支持 SQL 事务、CAS 或特定隔离级别;提供该行为的 adapter 必须自行满足完整后置条件。 +- 调用方进入功能前可检查静态能力和已知访问模式;实际操作仍需处理权限变化、网络失败和提交结果未知。 +- 完整可执行会话要求明确的最低能力集合。只读可用于历史访问;只写不具备恢复能力,不能伪装成完整会话后端。能力缺失时在副作用前拒绝相应操作。 +- 清理 `ThreadStore` 及 adapter 中掩盖缺口的 no-op、空 flags、假缺失默认实现;真正的数据缺失、legacy 状态与不支持必须可区分。保留能够保证语义等价的便利方法,不一概删除默认实现。 +- SQLite 现有只读打开降级保持原行为;Turso 的鉴权、服务不可用、限流等按自身事实分类,不能复用 SQLite 的失败分类或偷偷切换到本地临时库。 + +## 5. Turso Cloud Adapter 与配置 + +### 5.1 接入要求 + +- 实施前核实当时可用的官方 Rust SDK/协议、版本兼容性、事务与批量操作、读取一致性、取消/超时和服务限制,再选择依赖并记录证据。不能仅凭 SQL 兼容推定远程语义等同于本机 SQLite。 +- 第一版直接验证远程权威存储,不混入 embedded replica、离线缓存写回或本地/远程双写;若客户端内部存在缓存,必须明确其确认与刷新语义。 +- locator 表达存储位置和后端选择,鉴权以独立 secret 引用/环境变量传入。保留 `--db-path` 作为本机兼容入口并明确与新配置的冲突规则;覆盖 TUI、print、ACP stdio 与 `peri meta session` 的打开路径。 +- 新 adapter 负责自身 schema 初始化与版本校验;迁移在独立测试目标验证,不以连接成功代替数据契约验证。 +- scope/cursor 查询在数据端完成有界过滤与分页;避免将全部消息拉回本机筛选。对 fork 的逐条 flags 写入等热点评估批量方案,不让后端差异外溢到业务调用点。 + +### 5.2 测试凭证与安全边界 + +用户已说明项目 `.env` 中提供 `TURSO_URL` 与 `TURSO_TOEKN`。本 issue 只记录变量名,不读取、复制或记录其值,也不据此宣称已连通云端。 + +- `TURSO_TOEKN` 按用户提供的现有拼写记录;是否统一为 `TURSO_TOKEN` 在实际接入前确认。本轮不重命名 `.env`,实现不得静默假定另一名称或把拼写歧义固化为隐式兼容逻辑。 +- 云端测试显式启用,通过安全的 dotenv 加载/环境注入读取配置;不得将 `.env` 当 shell 脚本执行。默认本机测试不要求云凭证;缺配置时明确 skip/阻塞,不能算通过。 +- token、带凭证的连接信息不得进入源码、fixture、日志、错误、快照、测试报告或提交;定位信息只按必要的脱敏字段记录。不得打印 `.env` 或完整配置调试。 +- 实际写测试前确认目标是独立测试数据库或已授权且可靠隔离的测试命名空间。默认只写合成会话,不上传现有历史、真实项目指引或用户 frozen 数据。 +- 使用唯一运行标识登记测试创建的资源;清理仅针对本轮所有的数据,禁止清空共享数据库或改动既有数据。报告清理失败/残留,不以关闭连接冒充已清理。 + +## 6. 实施阶段与完成证据 + +详细设计入口:[总计划](2026-09-26-session-store-plan.md)。子计划分别覆盖 [A 行为契约](2026-09-26-session-store-sub-plan-a-contracts.md)、[B SQLite/本机执行](2026-09-26-session-store-sub-plan-b-local.md)、[C Turso adapter](2026-09-26-session-store-sub-plan-c-turso.md)、[D 配置与装配](2026-09-26-session-store-sub-plan-d-configuration.md)、[E 消费侧迁移](2026-09-26-session-store-sub-plan-e-consumers.md)、[F 验证与云实验](2026-09-26-session-store-sub-plan-f-verification.md)。下表为目标阶段概览,具体依赖和文件归属以总计划为准;计划编写不构成实施授权。 + +各阶段服务于同一次整体重构;只完成抽象或只连通 Turso 不构成本 issue 完成。 + +**review-2 进度(2026-09-26)**:设计闭合完成,未改实现。闭合项与落点见[总计划 §2.1](2026-09-26-session-store-plan.md);本机基线 `cargo test -p peri-resources --lib` → exit 0,156 passed / 0 failed;两个新测试目标尚不存在(`cargo test … --test session_resources_contract` 返回 exit 101,实测记录在 [F §6.1](2026-09-26-session-store-sub-plan-f-verification.md))。云端验收仍 **未执行**:原记录为未获授权(cloudAuthorized=false),已被后续用户授权更新:`.env` 指向测试库,允许初始化本次 schema、合成会话写读与仅清理本轮数据;未读取 `.env`、未连接或写入任何用户数据库;远程路线为 over-the-wire 权威读写,SDK/引擎候选两条(Turso 引擎 ↔ `turso_serverless`,libSQL 引擎 ↔ `libsql` remote),由 C-01 只读探测后选定([C §5.0](2026-09-26-session-store-sub-plan-c-turso.md));sync/embedded replica/双写显式 Unsupported。 + +**A 阶段实施进度(2026-09-26,本轮)**:契约与纯逻辑已落代码;消费侧字段未迁移、两个 adapter 未接、未连接任何云库。 + +落盘内容: + +- `peri-acp-types/src/session_resources.rs`:`SessionResources` 门面(行为清单逐项为**必需方法**,无 no-op 默认)、`AccessMode`/`DataCapabilities`/`ExecutionAvailability`/`SessionAvailability`(三者互不推导)、领域 I/O(`NewSession`/`NewSessionMeta`/`FrozenSnapshotBytes`/`ForkSnapshot`/`ChildSnapshot`/`SessionSnapshot`/`BindingState`/`FrozenState`/`SessionMetaPatch`/`RewindBoundary`)、`SessionResourceError`(失败原因与 `MutationOutcome` 分离,`Unknown` 只由未决持久化产生)、`ChildResumeClaim`、`PersistenceRecovery`。 +- `peri-acp-types/src/store.rs` → `store/mod.rs`,新增 `store/history.rs`(纯变换):fork 重映射(新 ID 经 `allocate_id` 注入,本模块不生成 UUID)、投影 flag 规则、flags 批次(默认即移除)、追加 ID 冲突检测、rewind 边界(`KeepThrough`/`RemoveFrom` 不合并)、compaction 变更应用;`CompactionLifecycle` 改名 `CompactionChange`(全仓 29 处引用同步)。 +- `peri-resources/src/sessions/data.rs`:`SessionDataPort` 内部行为 seam(无事务/CAS/SQL batch/连接/重试令牌)+ `SessionStoreId`/`HostInstallationId`/`StoreRegistration`/`ChildResumeRecord`。A 阶段无 implementor,模块内以 `#[allow(dead_code)]` 标注并写明「B/C adapter 接入后删除」。 +- 消费侧改用共享纯规则(行为不变,消除重复实现):`peri-acp/src/dispatch/session_fork.rs`、`peri-acp/src/host/requests/rewind.rs`、`peri-agent/src/session/transcript.rs`。 + +门面类型链与兼容退出(最终类型见 [A §2.1](2026-09-26-session-store-sub-plan-a-contracts.md);退出按 [E](2026-09-26-session-store-sub-plan-e-consumers.md) 逐引用替换): + +| 环节 | 最终类型 | 本轮状态 | +| --- | --- | --- | +| `peri-acp-types::store::ThreadStore` | 删除(契约由 `SessionResources` 取代) | 保留为**迁移桥**,模块文档写明「不扩展、不新增 no-op 默认」 | +| `peri-resources::Resources.thread_store()` | `session_resources: Arc` + `session_resources()` | 未改字段;结构体文档写明最终字段与访问器名 | +| `Controller::sessions()` | `Arc`(名称保留) | 未改;当前仍返回 `Arc` | +| `HostAssemblyInput` / `AcpServerConfig.thread_store` | `session_resources`(`AcpServerConfig` 删除该字段) | 未改 | +| `CommandContext.thread_store` / `SubagentHost.thread_store` | `session_resources`(不保留同义双字段) | 未改 | +| `SessionExecutionLease` | 公共面仍是 `thread_id` + `mark_clean` 两项 | 未改,已是目标形状 | + +本轮验证(全部 exit 0,未使用 no-op/ignore/删断言、未放宽任何断言): + +| 命令 | 结果 | +| --- | --- | +| `cargo clippy --workspace --all-targets -- -D warnings` | exit 0(workspace 无 error/warning) | +| `cargo test -p peri-acp-types --lib` | 459 passed / 0 failed(新增 `store::history` 12 项 + `session_resources` 6 项) | +| `cargo test -p peri-resources --lib` | 156 passed / 0 failed(与 W0 基线一致) | +| `cargo test -p peri-agent --lib` | 868 passed / 0 failed | +| `cargo test -p peri-acp --lib` | 713 passed / 0 failed | +| `cargo check --workspace --all-targets` | exit 0 | +| `cargo doc -p peri-acp-types --no-deps` | exit 0;新模块无 rustdoc 警告(既有警告均为改动前条目) | + +剩余事项(未完成,不声明已交付):`SessionResources` 尚无生产实现(B 的 `SessionResourcesImpl`),生产路径仍走 `ThreadStore`;消费侧六处字段迁移、Turso adapter、云验收(cloudAuthorized=false)均未开始。(`SessionDataPort` 的 `dead_code` 放行已在 B 数据侧改为 `cfg_attr(not(test), allow(dead_code))`:测试目标不放行,门面接入后删除。) + +**B 数据侧实施进度(2026-09-26,本轮)**:SQLite 数据面已落代码并接上内部端口;门面(`SessionResourcesImpl`)与消费侧迁移未开始,生产路径仍是 `ThreadStore` 桥,未连接任何云库、未读取 `.env`、未触碰本机真实数据库(全部测试库在 tempdir)。 + +落盘内容: + +- `peri-resources/src/sessions/sqlite_store/database.rs`:私有 `SqliteSessionDatabase`(pool / read_only / canonical 路径 / lease 弱引用表)。数据面与执行面**共用同一 `Arc`**:同一个库只有一条连接真相,不建第二个 pool、不建第二个库文件。`SQLite` 连接、schema 锁、只读 shape probe 与 `close` 一并归它。 +- `peri-resources/src/sessions/sqlite_store/session_data.rs`:`SqliteSessionData` 实现 `SessionDataPort` 的全部行为(新建/fork/child/legacy 接纳/一致 snapshot/轻量 metadata 与列表/append/投影/compaction/rewind/精确移除/定向 metadata/删除/子会话认领事实/登记读取/未决收敛/排空/关闭/flags)。事务、`BEGIN IMMEDIATE`、连接与推导语句都在实现内部,端口不导出任何一项。 + - `save_new_session`/`save_fork`/`save_child` 一次事务落 meta+binding+frozen(fork/child 另落 canonical 历史/继承区);`save_child` 校验 child 的 frozen 逐字节等于 root 已保存快照(不重扫目录、不重冻结)、root 归属等于 parent 链的根、绑定身份继承父会话。 + - `load_snapshot` 在单连接延迟事务里读 meta/binding 分类/frozen/自有 payload/flags/继承区;`load_meta` 与列表用 `THREAD_META_COLUMNS`,不加载 `cached_context` 正文。 + - `append_history` 不再 `INSERT OR IGNORE`:批次内重复与库存碰撞都明确失败(失败批次不落半行),计数/自动标题同事务维护;`apply_message_projections`/`apply_compaction`/`rewind_history`/`remove_history_entries` 把 flags、计数、时间戳与派生缓存失效放在同一个事务里,并拒绝改到别会话的条目。 + - 生命周期锚点:`delete_tree` 在同一事务写 `tombstone/deleting`、显式删除 `execution_runs` 行、删除 `threads` 行,提交后置 `deleted`(崩在中途按已删除幂等修复);`revoke_unpublished_session` 留 `creation_intent/abandoned` 终态锚点。本机同事务写入不存在 `mutation_pending` 窗口,因此本 adapter 只读该状态并按阻塞处理(`drain`/`recover_persistence`)。 + - `BindingState`:有绑定且登记一致 → `Bound`;绑定指向的本机登记已消失 → `ExternalOrUnregistered`;无绑定且是 child → `ExternalOrUnregistered`;无绑定根 → `Missing`(legacy 的目录来源证据由执行面在同一准入内判定,数据面不冒充 `LegacyConfirmed`)。`FrozenState::Unsupported` 留给能解码 envelope 的 frozen owner(ACP),数据面按 opaque 字节原样返回 `Present`/`LegacyAbsent`。 +- `peri-resources/src/sessions/sqlite_store/session_rows.rs`:`threads` 行与 canonical binding 行的唯一写入原语,数据面与桥共用同一列清单与绑定形状校验。 +- schema v6 → v7(`sqlite_store/schema.rs`):同一 `BEGIN IMMEDIATE` 内 ①逐行复制重建 `execution_runs` 去掉 `threads` 外键(`generation`/`clean` 原样保留,复制前后校验行数);②建 `session_lifecycle_commitments`(无外键,故不被级联带走)与 `session_store_registrations`;③`PRAGMA user_version = 7`。同名表形状不符即失败、整体回滚(版本保持 6、可重试);辅助表与其他业务表不触碰。只读打开不读也不写 `user_version`、不建表,按必需列形状放行 v6/v7/未来列形状;写打开遇到本构建不认识的版本仍 `UnsupportedSchemaVersion{found, supported}` 拒绝降级(启动降级过滤不变)。 +- `SqliteThreadStore` 降为消费侧迁移桥:只转发到共享库句柄 + 暴露 `data_port()`;`ThreadStore` 仍服务现有生产路径,等 E 删除。旧桥里的 `update_message_flags` 等保持原语义,新语义只在数据面生效。 +- 既有实现的必要改动:`WorkspaceError` 加 `Clone`(错误经 `anyhow` 链落到 `SessionResourceError` 时按原变体重建,不重新解释);`compaction::load_flags_on` 对损坏 message_id/projection 明确失败而不是静默跳过(B §7 要求,桥的 `load_message_flags` 也走这条路径)。 +- 新增测试 26 项:`sqlite_store/session_data_test.rs`(22:完整新建/快照一致与轻量投影/append 碰撞/source 不变的 fork/child 的 root frozen 与关系约束/legacy 接纳与 dirty 防绕过/投影与 compaction 原子性与归属/rewind 两种边界与未知截止点/精确移除幂等/定向 metadata/子会话认领/登记歧义/删除墓碑与执行行/撤销锚点/未决门禁与崩溃收敛/只读与关闭/dirty 跨实例)、`sqlite_store/schema_v7_test.rs`(4:v6→v7 保留 dirty 与辅助表、迁移失败整体回滚、只读打开不迁移且写打开拒绝未来版本、升级后重开不重复迁移)。 + +本轮验证(全部 exit 0;未使用 no-op/ignore/删断言,未放宽任何断言): + +| 命令 | 结果 | +| --- | --- | +| `cargo clippy --workspace --all-targets -- -D warnings` | exit 0 | +| `cargo check --workspace --all-targets` | exit 0 | +| `cargo test -p peri-resources --lib` | 182 passed / 0 failed(156 基线 + 22 数据面 + 4 v7 迁移) | +| `cargo test -p peri-acp-types --lib` | 459 passed / 0 failed(`WorkspaceError: Clone` 后无回归) | +| `cargo test -p peri-acp --lib` | 713 passed / 0 failed | +| `cargo test -p peri-agent --lib` | 868 passed / 0 failed | + +剩余事项(B 数据侧之后,未完成,不声明已交付): + +1. `SessionResourcesImpl` 门面未实现:写入前的 owner/未决检查、`MutationOutcome` 三态与 guard、创建准入与补偿、close 协调都还没有落点;`SqliteThreadStore::data_port()` 目前唯一调用方是测试(已就地注明)。 +2. 生产路径仍走 `ThreadStore` 桥:`create_bound_thread`/`create_thread` 的 INSERT 与 `session_rows` 原语尚未合并,`append_payloads` 仍是 `INSERT OR IGNORE`,`update_message_flags` 仍逐条写 + 另行 invalidation——这些都要在 E 切换时删除,不作为完成态。 +3. `mark_clean` 的「行不存在」容忍分支仍只查 `threads` 行计数(B §4.4.5 要求同时要求墓碑存在):`delete_tree`/`revoke` 现在会写墓碑,但执行面的判据切换属 B-03,未做。 +4. `FrozenState::Unsupported` 的产生点在 ACP 的 frozen 解码侧(E),数据面按 opaque 返回。 +5. C(Turso adapter)、D(locator/配置装配)、F(云实验)未开始。 + +**B 执行侧实施进度(2026-09-26,本轮)**:本机执行面与组合门面已落代码;生产消费侧未切换(仍走 `ThreadStore` 桥),未连接任何云库、未读取 `.env`、未触碰本机真实数据库(测试库全部在 tempdir,`HOME` 未参与)。上一段「剩余事项」中第 1、3 项已在本轮关闭,第 2 项属 E。 + +落盘内容: + +- `peri-resources/src/sessions/sqlite_store/local.rs`(新):`LocalExecution` —— 本机执行面唯一持有者(发现、登记、owner、dirty、创建准入、撤销、关闭)。可见性收在 `crate::sessions`,业务侧与其他 crate 拿不到。`create_with_lease` 是 B §5.1 的本地塌缩:先占稳定 OS 锁,再在一个 `BEGIN IMMEDIATE` 内校验绑定关系与关键文件对象、拒绝被撤销/删除过的 identity、写 `threads` + `session_bindings` + `execution_runs`(gen 1,`clean=0`);失败则一行不留(锁文件句柄随返回值释放),提交后崩溃只是普通 dirty,恢复依据是完整数据加代际本身。`admit_existing` 只为「数据已保存、执行代际未写」的收敛补准入(已有代际时拒绝插队)。`legacy_confirmed` 只看本机来源证据(无绑定、无父会话、无执行代际,且保存的绝对 cwd 落在本机已登记工作区内)。 +- `peri-resources/src/sessions/resources.rs` + `resources/{gate,claim}.rs`(新):`SessionResourcesImpl` 实现 A 的全部行为。统一准入 `MutationGate`:能力/权限 → 未决持久化 → 本 root 有效 owner,检查在门面内部,不靠调用方先查。效果结清 `WriteScope::settle`:只有 `Applied | NotApplied` 才释放写入准入,`Unknown`(含取消)丢弃范围,由 `Drop` 在租约上留下 `mutation_uncertain`。 + - 只读不退化:已有会话上的写入返回 `ReadOnlyStore`(历史可读、执行权不可得);需要登记新身份/新绑定的写入返回 `Workspace(ReadOnlyStore)`(连会话都还没有,没有可降级的对象)。`SessionResourceError::read_only_admission()` 把前者映射到既有 `ReadOnlyAdmission::ExecutionLeaseRequired`,消费侧迁移时不需要新的降级分支;`PersistenceUncertain` 不在该集合里。 + - 创建诚实结果:`create_session` 在 identity 已存在且**数据完整但没有执行代际**时尝试收敛准入(binding 身份一致 + 工作区证据仍然成立);前提不成立则返回 `SavedButNotAdmitted`(效果为 `Applied`,调用方不得据此删数据),绝不谎称「确定未创建」。`save_fork` 先完整落库再准入,准入失败同样如实报告。 + - `abandon_initialization`:校验传入 lease 就是本进程这条 identity 的活 owner(按分配地址比较,另一条会话的 lease 不能替它补偿),随后关闭准入 → 等待在途写入 → 数据面撤销(终态锚点 + 显式删执行行 + 删数据行)→ 释放 OS 锁;补偿失败也释放锁,不吞掉补偿错误。 + - `claim_child_resume`:先在 root 的**写侧**门禁内完成「读状态 + 写 active」(并发认领只有一个能成功),handle 保存认领前记录;`mark_running`/`hand_off_to_background` 维持 active,`mark_failed`/`mark_terminated` 走同一条恢复路径把原状态写回;移交后台后前台不能覆盖后台持有的终态;handle 的每次写入同样过统一准入。 + - `delete_session_tree` 需要活 owner 且无未决写;`drain_persistence`/`close` 有界等待(10s)在途写入并报告未结清(超时是 `Timeout`,未决是 `PersistenceUncertain`);`close` 只停止新准入(重复关闭幂等成功),不代写 clean、不关连接池——`Incomplete` 路径要保留 owner 与唯一关闭句柄让重试可行。 +- `peri-resources/src/sessions/sqlite_store/failure.rs`(新):数据面、执行面与门面共用的失败分类(行缺失 / workspace 语义 / 唯一键冲突 / 外键与未登记 / 解码 / 暂不可用),本机执行面失败不冒充「没有这条会话」。 +- `execution.rs`:抽出 `owner_lease`(沿 parent 链找活 owner;有绑定而无 owner 是 `ExecutionLeaseRequired`,不是「无 owner」)与 `live_owner_lease`(诊断读取,不把无 owner 当错误);新增 `ExclusiveExecutionGuard`(写侧门禁,与读侧同样按效果结清)与 `TransactionEffect`(把「提交自身的失败」单独标出);`ExecutionLease::abandon_ownership`(关闭准入 → 等在途 → 补偿 → 释放锁)。**`mark_clean` 的「记录缺失」容忍分支改为只认墓碑**(`tombstone` + `deleting`/`deleted`):记录缺失本身不再等于「已删除」,B §4.4.5 关闭。 +- **v7 之后的删除语义修复(生产路径)**:`SqliteThreadStore::delete_thread` 现在与数据面删除同语义——同一事务写墓碑、**显式删除 `execution_runs` 行**、再删 `threads` 行,提交后置 `deleted`。v7 去掉 `execution_runs` 外键后,旧实现只删 `threads` 会静默留下永不收敛的孤儿执行行;墓碑同时让 ACP 新建/分叉失败补偿里的 `delete_thread` + `mark_clean()` 组合继续成立(容忍分支的新判据)。该处也改用 `TransactionEffect`:提交自身的失败不再被当成「没生效」。 +- 端口与登记类型的 `allow(dead_code)` 全部删除;`SqliteThreadStore::data_port()` 删除(门面自己构造共享句柄,业务侧没有裸写入口);仅在「生产调用方属 C/E」的三处(`save_new_session`、`load_store_registration`、`load_flags`)保留 `cfg_attr(not(test), allow(dead_code))` 并在文档里写明归属与删除条件。 + +本轮新增测试 23 项:`sessions/resources_test.rs`(17,含跨进程子进程用例)、`tests/session_resources_contract.rs`(6,F 固定的集成目标名)。覆盖:完整创建与 owner 一次成立、失败创建一行不留、identity 复用与已保存态收敛、`SavedButNotAdmitted` 前提变化、只读两条失败路径与零副作用、无 owner 拒绝、未决写阻塞全部 mutation(读取与列表不受影响)、效果结清三态(`NotApplied` 结清 / `Unknown` 不结清并阻断 clean 与排空)、取消后未决租约、撤销的 owner 校验与终态锚点、child 沿用 root owner 与 root 关闭后的写入拒绝、认领串行与恢复、删除墓碑在级联后仍可判定且 owner 能收尾、关闭的幂等与未结清上报、跨进程 busy/dirty/精确解除。 + +本轮验证(全部 exit 0;未使用 no-op/ignore/删断言,未放宽任何断言): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | 199 passed / 0 failed(182 → 199,新增 17 项门面测试) | +| `cargo test -p peri-resources --test session_resources_contract` | 6 passed / 0 failed(F §6 固定的目标名,`--list` 6 tests) | +| `cargo test -p peri-acp-types --lib` | 460 passed / 0 failed(+1:`read_only_admission` 不变量) | +| `cargo test -p peri-acp --lib` | 713 passed / 0 failed | +| `cargo clippy --workspace --all-targets -- -D warnings` | exit 0 | + +剩余事项(本轮之后,未完成,不声明已交付): + +1. `SessionResourcesImpl` 尚未接入生产;消费侧六处字段迁移与旧 trait 退出属 E(`ThreadStore` 桥仍在,桥里 `create_bound_thread`/`append_payloads`/`update_message_flags` 仍是旧语义)。 +2. 桥的委托型写入(compaction 的四个函数)仍按 SQL 层粒度结清:对本地 SQLite 而言「返回 `Err` 即已回滚」可证明,但 `Unknown` 只在新行为层(门面)表达——E 删除桥时一并消失,不在这里改成两套机制。 +3. C(Turso adapter)与 D(locator/配置装配)未开始;远程登记表只有读取(`load_store_registration`,生产调用方属 C)。 +4. `FrozenState::Unsupported` 的产生点仍在 ACP 解码侧(E)。 +5. `SessionResources::close` 目前不关闭连接池(`Incomplete` 重试需要连接);确定性 teardown 由 D 在装配层决定。 + +**B 执行侧复核与修复(2026-09-26,同日复核轮)**:本轮不新增设计,只对已落盘实现做逐条复核,发现并修复两处真实缺陷;未读取 `.env`、未连接云库、未触碰本机真实数据库、未执行任何 git 写操作,起始 WIP 的四个文件/子模块未改。 + +- **clippy 实际失败(上一轮「exit 0」不成立)**:`sqlite_store/local.rs:321` 的 `row.cwd = &binding_cwd` 是 `&&str` 多余借用(`path_text` 返回 `&str`),`cargo clippy --workspace --all-targets -- -D warnings` 报 `needless_borrow` 并中止。已修为 `row.cwd = binding_cwd`。 +- **`save_child` 的写入门禁形同虚设**:门禁原本用 `with_mutation(&child.target.thread_id, …)`,而新 child 尚无 `threads` 行、也不会有自己的执行代际,`owner_lease` 沿链解析得到 `None` → `WriteScope::Concurrent(None)`,adapter 工作实际不在任何 guard 之内(违反 B §4.1.3「mutation guard 覆盖真正的 adapter 工作完成」,也与 A §112「使用已存在根 owner」不一致):该写入被取消/超时后不会在 root 租约上留下未决证据,`close`/`mark_clean` 会把仍在途的 child 写入当成已结清。已改为挂在 root owner 上(`with_mutation(&child.root_id, …)`),授权判定(`same_lease`)不变。 +- **新增回归测试**:`sessions/resources_test.rs::test_save_child_write_waits_for_the_root_gate` —— 占住 root 写侧门禁时 child 保存必须在准入处等待且一行数据都不落,释放后成立且 root 可 `mark_clean`。已按「先暴露原问题」验证:临时回退该修复后测试失败(`resources_test.rs` 断言处)、恢复后通过。 +- **未改动但已核对**:`save_fork` 的落库不取租约门禁是有意的 —— 目标是新 identity,落库结果自描述(`threads` 行在、`execution_runs` 无),重试按「已保存、未准入」收敛,不需要别处留未决证据(与 child 的差别已就地写入门面注释);`recover_persistence` 只做幂等墓碑收尾、`drain` 只读、`adopt_legacy_session` 属 legacy 豁免路径,均不构成缺口。 +- **fmt 门禁仍未通过(本轮新增发现,未处理)**:`cargo fmt --all -- --check` 在 17 个文件上报告差异,全部来自 A/B 各阶段落盘的文件(`session_resources.rs`、`store/history_test.rs`、`session_fork.rs`、`transcript{, _test}.rs`、`data.rs`、`resources{.rs,_test.rs}`、`resources/{gate,claim}.rs`、`sqlite_store{.rs, local.rs, execution.rs, session_data.rs}`、`session_resources_contract.rs` 等),说明这些文件落盘时没有跑 rustfmt;`lefthook.yml` 的 `fmt: cargo fmt --check` 因此会失败(CI 只跑 clippy,故 CI 不拦)。**未本轮修复的原因**:差异同时覆盖起始 WIP 的受保护文件 `peri-middlewares/src/mcp/builtin_spike_test.rs`,在不动该文件的前提下无法让 `cargo fmt --check` 变绿,而逐文件部分格式化只会在多个前序文件里留下大量与本阶段无关的改动。本阶段只保证**自己新增的行** fmt 干净(已核对 `resources_test.rs` 新增测试的差异为 0)。解除条件:WIP 收尾后对上述文件跑一次 `cargo fmt`(纯格式,无语义改动)。 + +本轮复跑证据(实际执行,非沿用上轮结论): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | 202 passed / 0 failed(上轮 201 + 本轮新增 1) | +| `cargo test -p peri-resources --test session_resources_contract` | 6 passed / 0 failed | +| `cargo test -p peri-acp-types --lib` | 460 passed / 0 failed | +| `cargo clippy --workspace --all-targets -- -D warnings` | 修复后 exit 0 | + +| 阶段 | 交付 | 验证 | +| --- | --- | --- | +| A:行为契约与事实归属 | 以领域输入/输出定义门面和数据端口;分离确定性行为与 I/O,明确 binding 一致性、存储身份、最低行为能力及错误模型;核实 Turso 适配可行性 | 接口不含数据库事务/CAS/重试编排;逐项映射既有 workspace/frozen/compact/close 不变量,不可满足项先暴露,不削弱为假成功 | +| B:完整拆分与本机迁移 | SQLite 接入新端口,统一门面、locator 与普通/只读装配;迁移 Agent/ACP/Controller/TUI 合法调用点,清除旧生产旁路 | 既有本机身份、恢复、只读降级、事务、子会话与关闭测试回归;同数据规模成本基线 | +| C:Turso adapter | 实现同一数据契约,组合本机执行端口;配置、能力、错误、写入确认与恢复接通 | 同一行为契约覆盖两 adapter;本机可控故障测试覆盖断连、取消、丢响应与部分失败 | +| D:真实云端实验与收尾 | 显式运行 Turso Cloud 全链路,记录正确性、恢复、时延/请求数/积压及限制;同步事实源 | 冷启动重读验证、云端测试报告、清理结果;不是仅 SDK/SQL smoke test | + +**E 执行侧增量修复(2026-09-26,告警与测试挂载批次)**:本轮不新增设计、不改生产行为,只处理前序 E 搬迁遗留的编译告警与测试挂载缺口。全仓 `cargo check --workspace --all-targets --message-format=short` 由 37 条告警降至 6 条(exit 0)。已清:`session/transcript.rs` 两处多余 `mut self`;`session/subagent.rs`、`agent/compact_v2/trigger_test.rs`、`agent/stages/stages_test.rs`、`session/subagent_test.rs`、`session/subagent/provenance_test.rs` 的未用导入;`stages_test.rs`/`full_test.rs`/`provenance_test.rs` 三处「改用 `MockSessionResources` 后遗留」的无用 `dir`/`path` 绑定;`peri-middlewares` 的 `tool_test/resume_test.rs` 11 处被遮蔽的重复 `parent` 构造(同一构造逐字出现两次)及其在占位符用例中已无消费方的 `create_thread`/`cwd` 夹具装配;`test_resources/mock.rs` 一处多余 `mut`;`tool_test.rs` 中诞生即无调用方的 `SessionFixture::open()`。 + +测试挂载:`session/transcript_test.rs` 相比 HEAD 丢了 1 个 `#[test]` 属性与 9 个用例(前序重写中断所致)。本轮恢复 `test_new_transcript_is_empty` 的 `#[test]` 与用例 `test_flush_persistence_without_backend_is_ok`(断言逐字取自 HEAD,未弱化),`cargo test -p peri-agent --lib -- transcript` = 54 passed / 0 failed。其余 8 个丢失用例(3 个 commit_compaction_lifecycle、1 个 compaction_reload reminder、2 个 apply_compaction_batch、1 个 flush 多错首个、1 个 failed_writer 批次截止)**未恢复**,因它们断言的是逐条 append/flags 部分失败与 invalidation 次序;E §「单次完整行为」已把这些细粒度观察合并,且写侧改为窗口批量(3 次 append 合并为 1 次调用),旧故障注入模型不再对应现契约。是否以新形态重建这些行为断言属 E/F 剩余工作。 + +**剩余 6 条告警为有意保留**,全部落在本轮新增的测试支撑文件:`session/test_resources.rs` 的 `db_path`、`session/test_resources/mock.rs` 的 `fail_append_at`/`fail_flags_at`/`fail_rewind`/`flag_updates`/`status_writes`、店铺式夹具方法(`list_child_threads`/`list_threads`/`append_payloads`/`load_inherited_context`/`create_bound_thread`/`resolve_workspace`)、`note_budget_failure`/`budget_failures`、`is_clean` 及 `lease`/`budget_failures` 字段。核对结论:这些替身 API 当前无调用方,但对应 HEAD 时代仍存在的真库夹具路径——`session/subagent/provenance_test.rs` 模块注释仍声明「Real SQLite spawn/compact/reopen/resume regression」,`TestSession::db_path` 正是冷重开真库的入口。删 API 会让「恢复真库用例」更难,故按「先核对挂载、不删 API」处理,留给 E/F 批次二选一:恢复真库用例消费它们,或收缩替身。 + +**本批未触碰的失败用例(如实记录,非本轮引入)**:`cargo test -p peri-agent --lib` = 859 passed / 4 failed;`cargo test -p peri-middlewares --lib -- resume` = 23 passed / 3 failed。失败点:`session/subagent/provenance_test.rs:183`(`spawn_subagent: parent session ... has no execution binding (Missing)`)、`session/exec/executor_provenance_test.rs:101`、`session/subagent_test.rs:1860/1931`(取消后持久化状态收尾超时、已提交写入的 owner 未保留)、`tool_test/resume_test.rs:271`(`thread not found`)、`resume_integration_test.rs:353`、`active_message_test.rs:129`(`bound subagent belongs to another root session execution owner`)。原因集中于门面/所有权夹具尚未迁移,属 E 计划消费侧剩余工作。 + +本轮为单会话直接实施(本会话未暴露子代理工具,未能按既有分工走「另一模型编码 + 独立验证」),证据为本会话直接执行的命令输出;未新增 `#[allow(dead_code)]`/`#[ignore]`,未改生产行为,未读 `.env`、未连云库、未执行 git 写操作,起始 stage 的 `CLAUDE.md` 及其它 WIP 未改。ACP 生命周期与 Controller/middleware 迁移、Turso adapter 仍未开始。 + +**E 执行侧行为回归与修复(2026-09-26,W2b 批次)**:本轮不改 A/B 任何实现,不新增设计;只对 Agent 已迁移行为做验证与最小修复,起点为本机 `cargo test -p peri-agent --lib`(隔离 `HOME`/`XDG_*`,保留 `CARGO_HOME`/`RUSTUP_HOME`)= **859 passed / 4 failed**。 + +1. **生产缺陷(本批修复,非仅测试)**:`session/subagent/factory/claim.rs` 在迁移中被改写成「内联 await `claim_child_resume` + `Drop` 里 detach 一个恢复旧值的任务」,丢掉了 HEAD 的 **claim worker 所有权**。后果有二:①调用方 future 被取消会连同资源侧**在进行中的 active 写入**一起丢掉(`ResumeClaim` 的 Drop 只发补偿,写入本身已被取消);②运行中被取消时 `Drop` 只把状态写回「认领前」,不再写领域终态 `cancelled`——线程以旧的 `done` 记录存活,取消事实丢失。已按资源侧 handle 语义恢复 worker:worker 独占「校验 → 认领(读状态+写 active)→ 等决定」全序列,调用方只提交领域结果(`HandOff` / `Finish(status)` / 关闭=准备失败),`Drop` 运行中发 `Finish(Cancelled)`、准备阶段直接关闭决定通道;`JoinHandle` 只 detach 不 abort,写入永不因调用方取消而中断。`validate_thread` 仍在 worker 内、仍可被取消(只读,无补偿需求)。 +2. **两处失效回归改走真实 SQLite 门面**(此前是全 mock 自洽): + - `session/subagent/provenance_test.rs`:模块声明「Real SQLite spawn/compact/reopen/resume regression」,但夹具曾被 `MockSessionResources` 顶替——父会话无绑定、`spawn_subagent` 需要 root 执行所有权、且「冷重开」写成 `MockSessionResources::new()`(**另一个空库**,语义上不可能是重开)。现改为真门面 `SessionResourcesImpl`:真绑定 root + 持有 `SessionExecutionLease` 落 child、真库读回 child/父 flags 与自有 payload、冷重开为**同一库文件的第二个句柄**(先 drop 原 lease 释放 OS 锁,再由新句柄按精确代际 `reset_dirty_execution(accept_risk)` 取回执行权)。断言逐条保留,未弱化。 + - `session/exec/executor_provenance_test.rs`:执行侧读入口已迁到门面(`load_session_snapshot`),用例只注入旧 `thread_store`,导致执行路径读不到继承区(`ancestor_len` 断言 0 != 1)。已在同一库文件上补注入真 `SessionResourcesImpl`,用例转绿。 +3. **未挂载测试盘点**:`peri-agent/src/agent/events_test.rs`(174 行 / 7 个用例)全仓无 `mod`/`#[path]` 挂载,且 HEAD 同样未挂载(非本批产生;其 `use super::*` 依赖的 `ExecutorEvent` 已迁至协议层,接入前需先修 import/类型归属),本轮只记录不动。 + +本轮复跑证据(实际执行;未新增 `#[allow(dead_code)]`/`#[ignore]`,未删任何断言,未改 ACP 主生命周期与 Turso,未读 `.env`、未连云库、未执行 git 写操作): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-agent --lib`(隔离 HOME/XDG) | **863 passed / 0 failed,exit 0**(起点 859/4) | +| `cargo test -p peri-agent --lib -- resume` | 30 passed / 0 failed | +| `cargo test -p peri-agent --lib -- provenance` | 5 passed / 0 failed(含上述两条真库回归) | +| `cargo test -p peri-agent --lib -- transcript` | 54 passed / 0 failed | +| `cargo test -p peri-agent --lib -- compact` | 200 passed / 0 failed | +| `cargo check --workspace --all-targets --message-format=short` | exit 0;`peri-agent` 测试目标告警仍为上一批保留的 6 条(替身夹具 API),本批改动文件 0 告警 | + +本批 changed files:`session/subagent/factory/claim.rs`(生产修复)、`session/subagent/provenance_test.rs`、`session/exec/executor_provenance_test.rs`(夹具迁真库)。 + +剩余(交 W2c):`cargo test -p peri-middlewares --lib -- resume` 的 3 例(`tool_test/resume_test.rs:271` 等,属该 crate 消费侧夹具,本轮未动);替身夹具 6 条告警的最终处置(消费或收缩);`events_test.rs` 的归属判定。 + +**E 消费侧收尾(2026-09-26,W2c 批次)**:只收敛 W2a/b 遗留的 `peri-middlewares` 失败项与 clippy,未改任何生产实现,未触 ACP 生命周期、Turso 与配置。起点 `cargo test -p peri-middlewares --lib -- subagent` = 203 passed / 3 failed。 + +三处失败是三类不同根因,均属夹具迁移遗漏或前提被现行契约取代: + +1. `active_message_test.rs::test_active_message_resumed_background_execution_accepts_info`:预置 thread 传 `parent_thread_id: None` 建成了自己的根,而工具执行所有权属夹具父会话 → `bound subagent belongs to another root session execution owner`。同目录其它用例一律传 `Some(parent_id)`,此处为遗漏;改为挂在夹具父会话下,断言未改。 +2. `resume_integration_test.rs::test_resume_multiple_times_keeps_thread_id_and_completes`:迁真门面后补了父会话句柄,但「cancel 前置」仍靠工具注入 token。`derive_cancel_token` 在 Cascade 下 parent 优先、注入 token 仅作 parent 缺席回退,故注入失效,首次 spawn 直接跑完(实测返回 `echo: task` 而非中断文本)。改为由父会话持有已取消 token(`Session::new_with_cancel`);第 3 步换回未取消 token,并补齐此前缺失的 `parent_thread_id`/`execution_owner`/`parent_session`(该步在修好第 1 步后必然失败)。 +3. `resume_test.rs`:原 `test_resume_thread_id_parent_mismatch_not_rejected` 的前置是 child 的 `parent_thread_id = "some-other-parent"` 指向不存在的 thread。真库下该状态不可读:`sqlite_store/context.rs::resolve_ancestor_chain_on` 对缺失行是 `fetch_optional` + break 的宽容语义,而 `load_inherited_context_on` 对链上每个成员用 `fetch_one`,两者不一致 → 快照读直接报 `NotFound`(实测 `load_messages` = `Err("session not found (NotApplied)")`),resume 因此报「thread not found」;换成指向另一个**真实根**则被归属校验拒绝。无论哪种,「parent 链不匹配仍可恢复」在现行契约下都不成立(§4.1:不得仅持有 child_thread_id 推断执行权),故改写为 `test_resume_thread_id_parent_mismatch_is_rejected_by_root_ownership`:断言拒绝原因正是执行根归属、且被拒绝的恢复不改动 thread 状态。这是按现行契约重写而非弱化。 + +**clippy 收敛**:清除 9 处 `clippy::clone_on_copy`(`ProjectId`/`WorkspaceId`/`CancelPolicy` 在测试夹具中的多余 `.clone()`):`peri-agent/src/session/test_resources/mock.rs`、`session/exec/executor_helpers/compact_cancel_test.rs`、`session/subagent_test.rs`、`peri-middlewares/src/subagent/tool/tool_test.rs`。`peri-middlewares` 目标 clippy 已 0 error。 + +本轮复跑证据(实际执行;未新增 `#[allow(dead_code)]`/`#[ignore]`,未删断言,未改生产行为,未读 `.env`、未连云库、未执行 git 写操作): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-agent --lib`(隔离 HOME/XDG) | 863 passed / 0 failed,exit 0 | +| `cargo test -p peri-middlewares --lib` | 1817 passed / 0 failed / 5 ignored(5 例 `#[ignore]` 均为既有:终端 120s 等待、2 处 marketplace 网络、hooks 真实 settings、attribution),exit 0 | +| `cargo test -p peri-middlewares --lib -- subagent` | 206 passed / 0 failed(起点 203/3) | +| `cargo test -p peri-middlewares --lib -- assembly` / `-- resume` | 35 / 26 passed,0 failed | +| `cargo test -p peri-resources --test session_resources_contract` | 6 passed / 0 failed | +| `cargo check --workspace --all-targets --message-format=short` | exit 0;告警仍为 `peri-agent` 测试支撑的 6 条(与上批相同,未增) | +| `cargo clippy --workspace --all-targets -- -D warnings` | exit 101;剩余 8 条 = 6 条 dead_code(同上,待 E/F 处置)+ 2 条 `clone_on_copy`(`peri-acp/src/session/command/compact_test.rs:188-189`,属 ACP 批次) | + +**剩余(交下一工作流)**: + +- `peri-acp` raw 桥:`host/mod.rs:194` `AcpServerConfig.thread_store: Arc`(同处 197-198 注释已写明「迁移完成后该桥删除」);经它扩散的生产调用点 `host/assemble.rs:128,202,215,287,596,660,663`、`host/requests/session_lifecycle.rs:38,60,723,1109,1125,1241`、`host/workspace.rs:72`、`host/prompt.rs:219,516`、`host/prediction.rs:19,101`、`host/stdio/mod.rs:127,149`、`session/mod.rs:140,216,232`、`dispatch/session_fork.rs:8,13`(`&dyn ThreadStore` 形参)、`dispatch/session_load.rs`(经 `Controller::sessions()`)。契约约束:`session_resources` 与 `thread_store` 必须出自同一次打开(同库句柄、同一 owner 登记,`host/mod.rs:195-199`),迁移是「删桥 + 调用点改走门面」,不是并存两套入口。 +- `peri-controller/src/controller.rs:172-173,203,295-296`:`Controller::new(sessions: Arc)` 与 `sessions()` 仍以 raw store 为通道,是 ACP dispatch 的唯一存储入口,需一并换成门面。 +- `peri-tui/src/{app,thread,kit,acp_client/client}` 仍有 5 处 `ThreadStore` 引用(含 `peri-tui/tests`),随 ACP 批次一并处理。 +- 替身夹具 6 条 dead_code 告警的最终处置(消费或收缩)与 `mock.rs` 拆分(>1000 行)未做;`peri-agent/src/agent/events_test.rs` 仍未挂载(HEAD 亦然)。 +- `sqlite_store/context.rs` 的祖先链语义不一致(`resolve_ancestor_chain_on` 宽容 / `load_inherited_context_on` 对链上成员 `fetch_one` 严格)未改:当前只影响「父行被删后遗留的子会话」(`delete` 路径可达),需与「丢失本机登记/外来会话」的边界一起定契约,不宜在本批顺手改。 + +**ACP 生命周期迁移(2026-09-26,ACP 批次)**:把 `session/new`、legacy 恢复准备、frozen 读取与执行准入迁到完整门面。未触 A/B 数据侧、Turso、配置、`Controller`(`sessions()` 仍返回 raw store),未做任何 git 写操作。 + +生产改动: + +- `host/requests/session_lifecycle.rs`:`handle_new` 改为 prepare(只读定格)→ `create_session(NewSession)`(meta/binding/frozen/执行代际/owner 一次成立)→ `validate_session` 复核 → `assemble_prepared` → 发布;失败走 `abandon_initialization`(装配失败时环境尚未建立,无对外资源需要排空,资源排空后的撤销语义留给 fork 未迁移路径)。ACP 侧 `create_bound_thread` / `acquire_execution_lease` / `store_frozen_snapshot_if_absent`+`delete_thread` 补偿链已从 new 路径删除(`store_new_frozen_snapshot_or_compensate` 仍服务于未迁移的 fork)。`load_frozen_data` 改读门面快照并按 `FrozenState` 三分(Present 解码 / LegacyAbsent 报「Bound session has no frozen snapshot」/ Unsupported 报本构建不可读)。 +- `host/requests/legacy_session.rs`:`prepare_for_restore` 改经门面判定 `BindingState`——`Bound` 直接返回;`ExternalOrUnregistered` 不再当作 legacy;`Missing` 先按保存的绝对 cwd 解析登记后复判(新库/新节点场景)。frozen 用门面 `FrozenState`,接纳走 `adopt_legacy_session`(权威事实,不再有 CAS bool)。 +- `host/workspace.rs`:新增 `resource_error`(workspace 语义保留既有载荷,其余按行为失败上报);`try_acquire_lease` 走门面 `acquire_execution(id, workspace)`,`clear_dirty_generation` 走 `reset_dirty_execution`(`accept_risk: true`——host 自动解除是既有裁决,现在由门面要求显式承担);删除仅为 raw 错误链存在的 `read_only_reason`。 +- `peri-acp-types/src/workspace.rs`:新增 `SessionBinding::from_workspace`(绑定构造唯一入口,版本/revision 由契约固定);`peri-resources` 私有 `binding_for` 删除并委托它。 +- `peri-resources/src/sessions/sqlite_store/local.rs`:`legacy_confirmed` 目录比较改按文件系统事实(两侧 canonicalize)。原字面 `starts_with` 把 macOS `/var` 与 `/private/var` 的同一目录判成外来会话,本机 legacy 因此无法确认(本轮回归实测暴露,`legacy_history_freezes_saved_workspace_configuration_and_plugins` 失败后修复)。 +- `peri-acp/src/session/command/compact_test.rs`:夹具改用 `SessionBinding::from_workspace`(顺带清掉 2 条 `clone_on_copy`)。 + +新增回归:`host/prepared_test.rs::new_session_persists_prepared_frozen_bytes_once`——new 之后持久化 frozen 字节等于同参数准备输入的字节、binding 为 `Bound`、live frozen 与持久化字节同源。 + +证据(隔离 HOME/XDG;未新增 `#[allow]`/`#[ignore]`,未删断言): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-acp --lib` | 719 passed / 0 failed(起点 718,+1 新回归) | +| `cargo test -p peri-resources --lib` | 206 passed / 0 failed | +| `cargo check -p peri-acp --all-targets` | exit 0 | +| `cargo clippy -p peri-acp -p peri-acp-types -p peri-resources --all-targets -- -D warnings` | exit 0(`compact_test.rs` 2 条 `clone_on_copy` 与 `prepared.rs` 2 条新告警已清) | + +**ACP 剩余生命周期迁移(2026-09-26,ACP 续批)**:把 fork、close/delete、rename、reset-dirty、prediction 标题、scoped list 与 metadata 轻量读迁到门面,并消除 ACP 生产侧 `cfg.thread_store` 直写与存储补偿。fork 改为「一致 source 快照(`load_session_snapshot`)→ 领域纯 ID 映射(`remap_fork_history`)→ 一次 `save_fork`」,删除逐条 `update_message_flags`、`delete_thread` 补偿与 forked-frozen 二次存写;`dispatch/session_fork.rs` 的 raw `fork_session` 与其注入故障的 mock 存储一并删除,测试改走真实 SQLite 门面(ID/flags 独立性、未闭合工具调用拒绝)。close/delete 走 `drain_persistence` → `delete_session_tree` → `mark_clean`,未加载会话的 rename/delete 经 `workspace::acquire_transient_owner`(按保存 cwd 解析 + 取得 owner);rename/prediction 经 `update_session_meta` 定向更新;`session/list` 与 scoped list 经 `list_sessions`;metadata 走 `load_session_meta`。`retain_failed_assembly` 的生产调用点随 fork 补偿链消失,其形态移入测试夹具保留关闭重试不变量(非新增 allow)。 + +证据(隔离 HOME/XDG;未新增 `#[allow]`/`#[ignore]`,未删断言,未做 git 写操作): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-acp --lib` | 718 passed / 0 failed(fork 测试 3→2,其中 2 个补偿用例由「原子保存 + 未闭合调用拒绝」替代) | +| `cargo check -p peri-acp --all-targets` | exit 0,零告警 | +| `cargo clippy -p peri-acp -p peri-acp-types -p peri-resources --all-targets -- -D warnings` | exit 0 | + +仍未完成(不得标记完成):`AcpServerConfig.thread_store` / `HostAssemblyInput.thread_store` / `Resources.thread_store` 双句柄仍在(TUI 与测试夹具仍消费);`SessionManager` 仍持 raw store(`list_sessions()`/`thread_store()` 无生产消费者,切换需把同步夹具改 async 开 SQLite 门面);保留的 raw **读**为 `session_context_payload`/`check_expected` 的 binding 复核与 history replay(`load_context_payloads` 祖先链语义需先成为领域函数,且门面缺「按 session identity 复核绑定 → workspace」与轻量 binding 投影);`Controller::sessions()` 仍返回 raw store;`MutationGate` 仍具体绑定 `SqliteSessionData` + `LocalExecution`(C 泛化);Turso/配置/云/Fable 未开始。 + +下一步(未做,不得标记完成):`Controller::sessions()` 仍返回 `Arc`;`AcpServerConfig.thread_store` / `HostAssemblyInput.thread_store` / `Resources.thread_store` 双句柄仍在,dispatch(`session_load`)、`prompt`、`SessionManager`、stdio 与 TUI 仍走 raw;history replay 仍用 `load_context_payloads`(ancestor 链组合语义需领域函数后再迁);`check_expected` 的 binding 复核(`validate_session_binding`/`reassert_session_binding`)未迁;`MutationGate` 仍具体绑定 `SqliteSessionData` + `LocalExecution`(C 泛化);Turso/配置/云/Fable 未开始。 + +## 7. 验收矩阵 + +### 7.1 行为与生命周期 + +| 场景 | 必须观察到的结果 | +| --- | --- | +| 两 adapter 切换 | 仅改变装配/定位配置,不改业务调用点;具体后端类型不出实现与装配层 | +| 行为纯化与机制封装 | 确定性变换可用显式输入/输出独立验证;同一行为契约测试覆盖两 adapter 的可观察结果,不断言共享事务步骤。审阅门面/数据端口及调用点:无事务句柄、CAS/隔离参数、SQL batch、底层重试或补偿编排 | +| 新建、追加、flush、关闭、重开 | 通过真实门面与会话路径运行;新进程/新连接重读顺序、payload(含 reminder)、计数与元数据一致 | +| load/resume 与 frozen | 恢复原 snapshot,不从当前环境重冻;未来版本、损坏、缺失本机登记与 legacy 缺失分别处理 | +| 普通 fork 与子 Agent | source binding/frozen 精确保留;fork 新 ID 和 flags 正确;继承快照与 own region 分离,写入仍受根 owner 约束 | +| compact / rewind / 删除 | compact 原子提交与缓存失效,失败不产生半套状态;rewind/删除精确作用于目标,冷恢复后结果一致 | +| scoped list 与分页 | 查询不加载大字段/消息正文、不全量拉取后过滤;范围与分页语义保持 | +| 只读、只写、能力缺失 | 进入相应功能前明确可用性;写入/执行被正确拒绝,无 no-op、假缺失或静默丢数据 | +| 本机多进程与存储别名 | 同一会话至多一个 owner;不同 locator 别名不绕过锁域;子会话不能绕过根 owner | +| dirty、取消与 close | 等写入及 owned 执行排空后才 clean;未知提交另有收敛路径,不借普通 dirty reset 跳过;未完成关闭保留真实阻塞 | +| 外来会话/本机登记丢失 | 不按当前 cwd 自动接纳或改绑;历史可读性与执行资格分别表达 | +| SQLite 兼容 | 原默认位置、现有历史、升级/legacy 接纳、只读降级、取消和恢复用例保持;无新增网络依赖 | + +### 7.2 故障与远程实验 + +- 可控故障测试覆盖:发送前失败、事务拒绝、部分步骤失败、提交成功但响应丢失、请求取消后远端晚提交、鉴权失败、只读权限、服务不可用,以及 new/fork 补偿失败。不能只 mock 成一个自洽的成功流程。 +- 对提交后丢响应/晚提交,断言不重复追加、不撤销已提交 compact、不发布错误 clean,也不允许新 owner 与旧写入并发;记录实际采用的结果收敛证据。 +- 真实 Turso Cloud 测试是显式、隔离的外部实验,不替代默认确定性测试。至少经门面跑通新建、追加、fork、compact、关闭、冷 load/resume;进程重启后比对 canonical 数据与派生状态,不以热内存或本机 fake 通过代替云端证据。 +- 使用相同合成历史规模记录 SQLite 基线与 Turso 的操作时延、请求次数、队列积压、flush/close 耗时;声明环境、样本规模与测量方法。SQLite 不得因拆分出现未解释的退化;Turso 的性能结论以实测为准,不预填收益数字。 +- 验收记录包含运行命令、SDK/协议版本、已覆盖场景、未验证项、失败诊断及测试数据清理结果;无凭证/未执行/环境阻塞不得标为通过。 + +## 8. 文档与完成边界 + +实现完成时按 `DOC-UPDATE-001` 同步受影响的 architecture contracts、身份设计、模块指引、code-index 与测试入口;本 issue 不长期复制现行事实源。只有整体门面拆分、SQLite 回归和 Turso Cloud 实验均有证据时才进入完成评估;若 Turso 无法满足关键契约,保留阻塞及实验结论,不以“接口已预留”或静默降级关闭 issue。 + +## 7. ACP 生命周期迁移 — 唯一门面链(PARTIAL,2026-09-26) + +本批把「生产双句柄」拆掉:`Resources` / `Controller` / `HostAssemblyInput` / +`AcpServerConfig` / `SessionManager` / TUI services 只持 `SessionResources` 门面。 + +- A 层新增三个只读投影(此前只能走裸存储):`load_session_binding`(轻量绑定分类, + 不拉历史)、`validate_bound_workspace(id, BindingRecheck)`(按 identity 复核绑定并给 + workspace)、`load_session_history`(继承区 + 自有 payload 的历史回放)。B 层在数据端口 + 补同名读取并由门面实现;`BindingRecheck::{Full,Recorded}` 对应原 + `validate_session_binding` / `reassert_session_binding` 的两次复核力度。 +- 删除:`AcpServerConfig.thread_store`、`HostAssemblyInput.thread_store`、 + `Controller::with_session_resources/session_resources`、 + `SessionManager::thread_store/list_sessions`、`Resources::thread_store`、 + `peri_agent::resources::{open_thread_store,open_thread_store_with,open_store_and_session_resources_with}`、 + `peri_agent::thread::{ThreadStore,SqliteThreadStore,FilesystemThreadStore}` re-export、 + `ExecutorContext.thread_store` 与 `WorkflowAgentConfig.thread_store`(生产恒 None 的死字段)。 +- 保留的测试入口:`peri_resources::sessions::open_store_and_facade_for_tests`(配对构造 + 裸句柄 + 门面,供夹具逐条构造事实);`peri-resources::sessions::open_session_resources_read_only` + 是 `peri-cli meta` 的只读生产入口。 +- 证据:6 个 crate `--lib` 全部通过(peri-acp / peri-controller / peri-agent / peri-tui / + peri-resources / peri-middlewares);peri-resources、peri-tui、peri-controller + `--all-targets` 通过;`peri-acp --all-targets` 仍失败(86 处,全部在测试夹具: + `cfg.thread_store` 51+16 处、`SessionContext.thread_store` 5 处、旧夹具函数 3 处、 + 测试替身缺三个新 trait 方法 1 处等)。 +- 未完成:peri-acp 测试夹具迁移(改用 `cfg.session_resources` / `open_session_resources_with` + + 显式夹具入口);`AgentState`(v1)的 `with_persistence` / `with_thread_context` 仍持裸 + trait 类型但生产无调用方(待随 v1 退役清理);Turso / 配置 / 云 / Fable 未开始。 + + +## 8. 迁移收尾:夹具/门面收敛(2026-09-26) + +本批只闭合第 7 节遗留的测试夹具迁移与测试替身收敛,不新增契约、不改生产数据路径。 + +- 门禁:`cargo check --workspace --all-targets` exit 0(零 error、零 warning); + `cargo clippy --workspace --all-targets -- -D warnings` exit 0(第 7 节的红是 + `peri-agent` 测试替身的 dead_code,已用「删除无消费者的多余替身 API」收敛, + 未新增 `allow(dead_code)`/`#[ignore]`);`cargo fmt --all --check` 仅剩独立 WIP + `peri-middlewares/src/mcp/builtin_spike_test.rs`(本批未触碰)。 +- 夹具迁移:peri-acp 的 requests/legacy/recovery/stdio/session 夹具改吃门面 + (`cfg.session_resources`);legacy、缺 binding、坏 frozen、未知版本等坏数据由 + `peri_resources::sessions::open_store_and_facade_for_tests` 配对裸句柄按原表播种, + 生产 config 未恢复 raw 入口。断言逐文件等量保留(177/31/40 等)。 +- 证据:`peri-acp --lib` 718、`peri-agent --all-targets` 863+4、`peri-resources` + 206+6、`peri-controller` 131、`--workspace --doc` 全通过。 +- 删除 `AgentState` 的裸存储遗留路径(`store`/`thread_id`/`persist_tx`/`persist_handle` + 字段与 `with_persistence`/`with_thread_context`/`store()`/`own_thread_id()`/ + `shutdown_persistence`/`is_persistence_shutdown`/`ancestor_len`):全仓(含测试) + 零消费点;会话历史事实源是 `MessageTranscript`(绑 `SessionResources` 门面)。 +- `peri-agent` 测试替身按职责拆分为 `test_resources/mock/{mod,observe,fixtures,session_resources}.rs` + (原单文件 1160 行),并删除无消费者的替身 API:裸存储形状的 + `list_threads`/`list_child_threads`/`append_payloads`/`load_inherited_context`/ + `close`/`create_bound_thread`/`resolve_workspace`,注入面 `fail_append_at`/ + `fail_flags_at`/`fail_rewind`/`status_writes`/`flag_updates` 与预算计数、`is_clean`、 + `TestSession::db_path`(冷重开真库的用例自建 tempdir 路径,见 + `session/subagent/provenance_test.rs`)。第 7 节「先核对挂载、不删 API」的二选一 + 在此按「收缩替身」结项。 +- 剩余 raw 桥:`open_store_and_facade_for_tests`(仅测试用配对构造); + `peri-resources` 内部 `SqliteThreadStore`/`FilesystemThreadStore` + `SqliteSessionData` + 仍是门面背后的 B 层实现(其实现级测试直接绑 `Arc`);生产侧 + `peri-agent::resources`、ACP、Controller、TUI 只持门面(无 `dyn ThreadStore` 字段)。 + +下一接点(D/C,均未开始):D(配置)从 `Resources::open_with` / +`peri_agent::resources::open_session_resources_with` 的打开面接后端选择;C(Turso) +在 `SessionResources` 门面后替换 B 层数据端口,接点是 `MutationGate` 仍具体绑定 +`SqliteSessionData` + `LocalExecution`(需泛化),门禁为 +`peri-resources` 实现级测试 + `tests/session_resources_contract.rs`。 + +## 9. C 远程基础与 D 配置基础(2026-09-26,本轮实施) + +授权更新:用户确认 `.env` 指向**测试库**,允许初始化本任务 schema、合成数据,以及只清理本轮对象; +C 子计划 §6.1 的 `cloudAuthorized=false` 与「只写计划」已失效。本轮**未读取真实历史、未上传任何 +项目数据**,探测只发只读请求;凭证只在本进程内解析,不进命令行、日志或报告。 + +### 9.1 C-01 只读探测证据(引擎/驱动选择) + +| 观测 | 结果 | +| --- | --- | +| `.env` 键存在性(只列键名) | 16 个键;引擎相关键名为 `TURSO_URL`、`TURSO_TOEKN`(拼写与计划一致) | +| locator scheme / 主机家族 | `turso://`;官方 Turso Cloud 域 | +| `GET /version` | 404:该端点在官方文档里是 libSQL/sqld 的版本身份入口,**但 404 不能单独证明目标库不是 sqld**(服务端可不暴露该路由)——引擎身份依据是「官方驱动↔引擎对应关系 + 选定驱动上的 SQL 行为实验」,不是这个端点 | +| `POST /v2/pipeline` 只读 `SELECT` | 200、`results[0].type=ok`,Bearer 认证通过 | +| 协议层参数绑定回环 | text / 64 位整数(9007199254740993)/ NULL 全部原值返回 | +| SDK 连接路径(`turso_serverless` 0.1.3) | 连接成功;SDK 层 text/int64/NULL 绑定回环全部 true | +| 引擎方言 | `sqlite_version()` = 3.50.4 | + +按官方「驱动匹配引擎」对应关系选定 **`turso_serverless` 0.1.3**(2026-09-04 发布;依赖 +reqwest 0.13 / tokio 1 / thiserror 2,与工作区既有版本同族)。`libsql` 0.9.30 未采用, +停更的 `libsql-client` 不采用,`turso` crate 的 sync 路线按 C §1 明确排除。 +C §5.1 的 P1–P7(原子批、唯一键冲突判别、写事务串行化、冷进程权威读、SDK 重试策略、 +超限拒绝、收据保留)**未实测**,远程写路径保持关闭。 + +### 9.2 本轮落盘 + +- `peri-resources/src/sessions/remote/`:端点解析(引擎只由已确认 scheme 或显式选择给出, + `https://` 缺引擎时明确要求显式选择)、凭证来源与值分离(`Debug` 脱敏、只接受显式注入、 + 不搜索 `.env`)、失败分类与脱敏(SDK 载荷文本不进领域失败)、连接与只读参数绑定回环 + (单次调用 20s 预算;SDK 0.1.3 无客户端超时配置,由 `tokio::time::timeout` 兜住)。 +- `peri-resources/src/sessions/open.rs`:typed locator 与打开请求(本机路径保留 Windows + drive/UNC;`env:` 只解引用一次、不递归;远程缺凭证来源直接报配置错误;locator 原文与 + 凭证值不进 `Debug`)。 +- `Resources`:`open()`/`open_with()` 归一为同一 open request;新增公开 `open_locator(locator, + engine, credential_env, read_only)` 与 crate 内 `open_request`;显式只读走既有只读 seam + (不建目录/库/锁、不迁移 schema);远程 locator 返回类型化 `RemoteStoreNotWired`, + **不静默回落**本机库。 +- 依赖:`peri-resources` 增 `turso_serverless = "0.1.3"` 与 `url`;测试目标用 `reqwest`。 + +### 9.3 验证证据 + +- `cargo test -p peri-resources`:226 lib + 6 集成通过;3 个云端探测默认 `#[ignore]`。 +- `cargo clippy -p peri-resources --all-targets -- -D warnings`:exit 0。 +- `cargo check --workspace --all-targets`:exit 0。 +- 显式云探测实跑(`--ignored`):输出仅键名存在性、脱敏特征与成功/失败分类,见 9.1。 + +明确未做:远程写路径与 schema 初始化、StoreId 首次竞争、C-02/C-03 adapter、 +D-04(TUI/print/stdio/meta 部署参数迁移)、`MutationGate` 泛化到远程组合。 + +### 9.4 D 配置面补齐(同日第二轮,仍不含 D-04/D-05) + +- `peri-resources/src/sessions/open.rs`:`StorageLocator`(未解析输入)、`SessionStoreOpenRequest` + (locator + 引擎 + 凭证来源 + 访问意图)、`AccessIntent` 纯解析(只接受 `read-write` / + `read-only`,无别名与大小写模糊匹配,失败不回显原始取值)、`AccessIntentError`。 +- `Resources::open_locator(locator, engine, credential_env, access)`:公开签名接收访问意图拼写, + 在进入任何 I/O 之前完成纯解析;`Resources::open_request` 仍是唯一后端选择点(普通与只读共用)。 +- 显式只读:走独立只读 seam,不建目录/库/锁、不迁移 schema、不登记 owner 与 binding;只读失败 + 的类型分类(`ReadOnlyThreadStoreError::kind()`)保留在 source chain,路径只加在 context 上。 +- 环境变量读取面固定为两处(`env:` locator 与远程 adapter 取凭证值):仅存在云 URL/token 变量 + 不切换后端,也不影响默认本机库。 +- 远程 locator 返回类型化 `RemoteStoreNotWired { engine }`,模块文档注明这是 **C-02/C-03 adapter + 落地前的临时状态**(门面照常返回行为结果或 `Unsupported`),不静默回落本机库、不假成功。 + +验证证据(本机离线):`cargo test -p peri-resources` 232 lib + 6 集成通过(3 个云探测默认 +`#[ignore]`,本轮未实跑云);`cargo clippy -p peri-resources --all-targets -- -D warnings` exit 0; +`cargo check --workspace --all-targets` exit 0;`cargo fmt -p peri-resources -- --check` exit 0。 +新增/强化的回归:访问意图拼写(含拼写错误在 I/O 前失败)、环境变量存在不切云、带凭证 URL 被拒 +且不回显、`file://` 不被当本机路径、显式只读不建父目录/库/侧车、只读与普通打开共享同一选择点、 +远程 locator 类型化 unsupported 不降级。 + +明确未做(与 9.3 一致,且不含真实云读写):远程 SDK 读写路径与 schema 初始化、StoreId 首次竞争、 +C-02/C-03 adapter、`MutationGate` 泛化、D-04/D-05(TUI/print/stdio/meta 部署参数、`--db-path` 与 +`--session-store` 互斥 grammar、关闭 owner 与协议映射)。 + +### 9.5 D-04 部署参数迁移(同日第三轮) + +把部署面从 `Option` 迁到 typed 定位描述,各入口不再各自解释存储位置: + +- 新增跨层中性类型 `peri_acp_types::session_store::SessionStoreDeployment`(locator 原文、可选引擎名、 + 凭证**来源**变量名、`AccessMode`)。它只承载部署事实:不含凭证值、不建连接、不做解析;`Debug` + 仅给形态与「是否配置」,不回显 locator 原文。 +- 新增 `Resources::open_deployment(&SessionStoreDeployment)` 作为各入口唯一装配点:进入任何 I/O 前 + 经 `SessionStoreOpenRequest::from_deployment` 一次性转成 typed open request(引擎名、locator 形态、 + 凭证来源与本机/远程一致性冲突全部在此失败),再进唯一后端选择点。第二轮的 + `Resources::open_locator` 与 `AccessIntent::parse`/`AccessIntentError` 随之删除(部署面无访问意图 + 拼写输入,`AccessIntent` 改由 `AccessMode` 单向映射),不留第二入口或死接口。 +- CLI:新增 `--session-store` / `--session-store-token-env` / `--session-store-engine`(含 camelCase + 别名),遵循 D1 规则(引擎只由已确认语法或显式选择给出;凭证只表来源、无默认名与别名; + `env:` 只解引用一次)。`--db-path`/`--dbPath` 保留;两个定位入口互斥由 `validate_cli` 判定并 + 返回参数错误(早于任何 I/O),部署参数构造同样拒绝,不设隐式覆盖顺序。meta 的受限 grammar + 同步为只接受这些定位参数与 session 自身的 `--json`。 +- 入口接线:TUI `main`/`TuiOptions`/`TuiLaunchOptions`/`App::new`、`-p` print、ACP + `StdioInput.session_store` → `peri_agent::resources::open_session_resources_deployment`、 + `peri meta session` 早启动路径都传同一份定位描述并只打开一次;`App` 持有门面后 `attach_acp` + 与恢复会话不重新解析存储(恢复 cwd 不改变存储)。`peri_tui::thread` 对消费侧的独立只读 + seam re-export 删除。 +- meta 只读路径:UUID 校验 → 只读 deployment → 统一入口;新增公开 `StoreOpenFailure` + + `classify_open_failure`(按类型化 source chain 分类,不解析错误文本、不回显 locator/凭证), + meta 新增 `store_not_configured`(exit 2) 与 `store_unavailable`(exit 4),缺库仍 + `database_not_found`(exit 3)——远程失败不再被统一回报成「数据库不存在」。meta 仍不加载 + provider/MCP/Agent,也不新建本机执行登记。 + +证据(本机离线;未跑云、未新增 `#[allow]`/`#[ignore]`、未删断言): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources` | 233 lib + 6 集成 passed / 0 failed(`sessions::open_tests` 17 条) | +| `cargo test -p peri-tui --lib` | 1671 passed / 0 failed | +| `cargo test -p peri-tui --bin peri` | 85 passed / 0 failed | +| `cargo test -p peri-tui --test print_exit` | 9 passed / 0 failed(`--db-path` 兼容未退化) | +| `cargo test -p peri-acp --lib host::stdio` | 17 passed / 0 failed | +| `cargo check --workspace --all-targets`、`cargo clippy --workspace --all-targets -- -D warnings` | exit 0 | + +真实二进制端到端(隔离 `HOME` + 临时目录;只读、无网络、无凭证值读取):缺库 → exit 3 +`database_not_found`;`--db-path` 与 `--session-store` 同给 → exit 2 `invalid_argument`;远程 locator +→ exit 4 `store_unavailable`(stderr 不含 locator 原文);非法 UUID → exit 2 `invalid_session_id`; +四条命令结束后临时目录仍为空(不建目录/库/侧车)。新增回归覆盖:CLI 解析与别名、互斥在 I/O 前、 +部署参数归一与 `Debug` 脱敏、各入口(`open_with` / 本机 locator / `local_path`)等价于同一选择点、 +meta 的三类错误映射。 + +明确未做:远程 adapter(C-02/C-03)与远程读写/schema 初始化/StoreId、`MutationGate` 泛化、 +D-05(关闭 owner 与 E 的协议映射联调)、任何真实云操作与凭证读取——`--session-store` 指向远程时 +仍返回类型化 `RemoteStoreNotWired`,不降级、不假成功。 + +### 9.7 C0/D1/D2 收尾复核与下一 C 开工清单(同日第四轮) + +本轮不加功能面:复核前三轮落盘是否真被消费侧接线,并把下一轮 C 完整 adapter 需要的接口/装配 +缺口写清。**远程后端仍不可用**:`--session-store` 指向 Turso locator 时各入口统一返回类型化 +`RemoteStoreNotWired`(meta exit 4 `store_unavailable`);本轮全部离线(未连云、未读凭证值, +云探测 3 条仍默认 `#[ignore]`)。 + +复核证据(本机,隔离 `HOME`/临时目录;本批未改 ACP 迁移核心,故不重跑其全量回归): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | 233 passed / 0 failed(3 ignored = 云探测) | +| `cargo test -p peri-resources --test session_resources_contract` | 6 passed / 0 failed | +| `cargo test -p peri-tui --lib` / `--bin peri` | 1671 / 85 passed,0 failed | +| `cargo test -p peri-tui --test print_exit` / `meta_session_cli` / `print_background_exit` | 9 / 19 / 2 passed | +| `cargo test -p peri-acp --lib host::stdio` | 17 passed / 0 failed | +| `cargo test -p peri-agent --lib resources` | 2 passed / 0 failed | +| `cargo check --workspace --all-targets`、`cargo clippy --workspace --all-targets -- -D warnings` | exit 0 | + +**下一 C 完整 adapter 所需**(现状事实,不是已完成能力): + +1. 可复用私有基础(`peri-resources/src/sessions/remote/`):`RemoteEngine` / `RemoteEndpoint` + (`sdk_url` / `locator_digest` / `host_class`,`Debug` 不含 host/path)、`CredentialSource::env` + → `SessionStoreCredential`(值不进 `Debug`)、`RemoteConnection::{connect, engine_read_facts, + bind_roundtrip, close}`(20s 预算由 `tokio::time::timeout` 兜住,SDK 无超时配置)、 + `RemoteFailureClass`(SDK 载荷文本不进领域失败)。已实测证据见 §9.1;**写路径、schema 初始化、 + `op_ledger`/receipt 与恢复收敛全部不存在**(C §5.1 P1–P7 未实测前保持关闭)。 +2. **data 口可替换(gate 泛化)**:`MutationGate.data` 仍是具体 `SqliteSessionData`, + `SessionResourcesImpl::from_local` 是唯一构造点(数据口由 `LocalExecution::data_port()` 派生)。 + 远程组合需要 `Arc`(或泛型)与「远程数据面 + 本机执行面」的构造点;门面的 + 数据调用已全部只经 `self.gate.data()`,影响面限于 gate 字段、构造与 `data()` 返回类型(另有 + `abandon_initialization` 里 `data.clone()` 的具体类型依赖)。 +3. **本机事实与权威数据的端口归属**:`SessionDataPort` 混有两类事实——权威会话数据,与**本机**记录 + (`load_store_registration` 读 `session_store_registrations`;`has_pending_persistence` / + `recover_persistence` / `drain` 读 `session_lifecycle_commitments`)。远程组合下本机事实必须由 + 本机库回答,远程 adapter 不得把它们写到远端;落地前需在 A/B 范围内定一次端口切分(第二个本机口, + 或由组合持有本机 SQLite 数据口转发),不在 D 面临时拼旁路。 +4. **预留面未接线**:`SessionStoreOpenRequest::{access, credential_source, input}` 当前无生产调用方 + (`allow(dead_code)` 显式标记,本轮修正了其中「D-04 迁移后使用」的过时注释);`access()` 与 + `intent().mode()` 等价,接线时应消费掉或删除。`remote/mod.rs` 的模块级 + `#![cfg_attr(not(test), allow(dead_code))]` 同样应在接入时移除,避免继续掩盖未接线范围。 +5. **兼容入口归属**:`Resources::open_with(Option)` 与 + `peri_agent::resources::open_session_resources{,_with}` 在生产已无消费方(调用方只剩测试), + 生产面统一走 `open_deployment`;下一批应归一或删除,不并存两套「路径即存储」语义。 +6. **D-05 未做**:关闭 owner 与 E 的协议映射联调未开始;远程只读/写入的真实行为依赖第 2、3 项。 + +未验证项(不得据此宣称可用):真实云读写、远程 schema 初始化、StoreId 首次竞争、P1–P7、 +远程只读全链路与 `--session-store` 的成功路径。 + +### 9.8 C-02 部分 + §5.1 机制实测(同日第五轮;写路径仍未面向业务开放) + +落盘(`peri-resources/src/sessions/`): + +- `remote/mutation.rs`:私有可变连接——`BEGIN IMMEDIATE` 托管事务批(单请求 all-or-nothing)、 + 20s 预算、只读打开拒绝写入、连接已有打开事务时拒绝执行(SDK 会静默加入该事务);结果三分类 + 「已生效 / 确定未生效 / 无法证明」,回滚失败、超时、写忙、网络失败一律判未决。 +- `remote/schema.rs`:远程独立 schema——`peri_store_meta` 单行(版本/契约/store 身份);身份读取 + 只有 SELECT;显式初始化在唯一键竞争下产生身份,已存在时读回既有身份,未知版本/契约/形状一律拒绝 + 且不做 DDL、不覆盖。 +- `remote/ledger.rs`:私有操作账本——资格写是原子批第一条语句、与效果同生共死;终态封闭与资格写 + 竞争同一主键;操作 id 与收据只在本模块可见,Debug 脱敏。 +- `host_facts.rs`:本机登记与未决锚点从数据端口迁出(`HostLocalFacts`),第二个 adapter 不必也不得 + 实现本机事实;`data.rs` 只保留两个 adapter 共同承担的会话行为。 +- 测试:`remote/{schema,ledger,mutation}_test.rs`(离线);`remote/cloud_mutation_test.rs`(5 个显式 + `#[ignore]` 实验,只操作本轮 run 命名空间,结束时用正常 mutation 路径清理并复核已清空)。 + +实测证据(授权测试库,实跑输出只含计数/布尔/类别): + +| 实验 | 观察 | 对应前置条件 | +| --- | --- | --- | +| 原子批中途约束失败 | `not_applied`,被拒语句=效果重复插入;资格行与两条效果行在新连接上都不存在 | P1 | +| 同一 operation_id 重复调用 | 第二次 `applied_replayed` 且返回**原收据**(收据随机生成,重算值不会相等),第二个效果不存在、原效果在 | P2 | +| 两连接并发同一 operation_id | 恰好一方 `applied`、另一方 `applied_replayed`,效果只出现一次 | P2/P3 | +| 封闭先提交 → 迟到原请求 | 封闭 `closed_never_applied`;迟到原请求不可能生效,其效果不存在 | §5.1(3)(4) | +| 封闭已生效操作 | 返回原收据,原效果不动 | §5.1(4) | +| 新连接读已提交行 / 身份读取 / 初始化幂等 | 新连接读到同一收据;`sqlite_master` 只读身份检查在该引擎可用;重复初始化返回同一 StoreId | P4、C §6 | + +仍未证明(不得据此宣称可用):业务表 schema 与 new/fork/child/compact 等完整行为(C-03)、恢复组合 +与未知结果收敛全链路(C-04)、D 装配与 `--session-store` 成功路径;P5(仅静态读源:SDK 0.1.3 无重试/ +退避代码,未做故障注入)、P6(超限先拒绝)、P7(收据保留的空间成本)。`remote/mod.rs` 的模块级 +`allow(dead_code)` 只表示「实现已落地、消费方未接线」,D 接入时必须删除。 + +### 9.9 C-03 第一批:远程会话数据 adapter(同日第六轮;写路径仍只由显式云实验驱动) + +落盘(`peri-resources/src/sessions/remote/`): + +- `session_schema.rs`:会话事实表与历史表(`peri_sessions` / `peri_session_messages`, + `message_id` 全局主键、`ordinal` 定序);不存本机登记/执行 owner/未决锚点,也不存 + `cached_context` 这类派生缓存。DDL 全部 `IF NOT EXISTS`。 +- `session_sql.rs`:静态 SQL + 全绑定参数;列投影由宏 `meta_columns!` 一处展开为 + 事实/列表两种投影,解码下标与投影同源(离线测试核对列名与下标)。 +- `session_codec.rs`:行值 ↔ 领域值编解码——payload 复用 `PersistedPayload` envelope、 + 继承区复用 `InheritedContext::to_json/from_json`;形状不符(缺列、负数计数、非法枚举、 + 时间戳不可解析、行内 ID 与主键不一致)一律 `Corrupt`,不猜、不默认。 +- `session_read.rs` / `session_write.rs`:一致读取在**一个只读事务批**(`BEGIN DEFERRED`)里取 + 会话事实行与历史行;写入走「资格先于效果」的托管事务批,操作 id = store 身份 + 语义标签 + + 内容摘要(SHA-256 前 16 位),同一内容重试命中同一 id(重放不产生第二次效果),不同内容 + 不会互相冒充。 +- `mutation.rs` 增补:`apply_schema`(幂等 DDL 批,无账本资格写)、`read_batch`(只读一致读)、 + `apply_qualified_reporting`(读回语句级受影响行数,用于「恰好一行」后置条件);`apply_qualified` + 行为不变(C-02 的 5 个云实验回归通过)。 +- `session_data.rs`:`SessionDataPort` 实现——已落地 `load_snapshot/load_meta/load_binding/ + load_session_history/load_flags/list_sessions/list_children/list_session_tree/save_new_session/ + save_fork/save_child/update_meta/drain/close`;`append_history`、`apply_compaction`、 + `apply_message_projections`、`rewind_history`、`remove_history_entries`、`delete_tree`、 + `revoke_unpublished_session`、`adopt_legacy_session`、child resume 记录、`recover_persistence` + 一律 `Unsupported`(阶段状态,D 装配激活前需补齐或由门面显式拒绝;`recover_persistence` 需要 + 本机未决锚点,属 C-04)。 + +实测证据(授权测试库,实跑;本轮清理后复核命名空间计数为 0): + +| 实验 | 观察 | +| --- | --- | +| 写—重连读 | 新建/`update_meta`/fork(2 条 payload + 1 条非默认 flag)/child(继承区 + root frozen 原文)在**新连接只读打开**上逐字段读回;fork `message_count=2`;root `cached_context` 为 `None` | +| 列举与树 | `children` 只有直接子会话、`tree` 含根与后代;scoped 分页只列出带历史的会话,条目只带绑定事实(`workspace_root=None`,不虚构本机根目录) | +| 只读与关闭 | 只读打开的写路径返回 `ReadOnlyStore`;`close` 后调用明确失败 | +| 拒绝与重放 | 同 id 不同内容 → `InvalidInput` 且不覆盖已保存的 frozen;同一内容重试 → `Ok` 且只一行;child 的 frozen 非 root 原文 → `InvalidInput` 且不落行;新连接复核行数 1/0 | + +已知边界(不得据此宣称远程可用):云端无本机目录校验与执行 owner,`LegacyConfirmed` 由门面按本机 +证据判定;`load_binding` 只回答绑定事实是否完整。首次实测发现并修复的真实缺陷:可写打开原先只在 +「身份刚建立」时建会话表,导致身份早于会话表存在的库上写入失败——现改为可写打开一律执行幂等 DDL。 +`remote/mod.rs` 的 `allow(dead_code)` 与这些 `Unsupported` 都应随 D 装配消失。 + +### 9.10 C-03 第二批:历史与生命周期行为补齐(同日第七轮;写路径仍未接线) + +落盘(`peri-resources/src/sessions/remote/`): + +- `session_history.rs`(新增):追加、投影/flags、compact、rewind、精确移除。写入仍是 + 「一次端口调用 = 一个托管事务批(资格先于效果)」;**批内守卫**用单行表 + `peri_store_meta(singleton)` 的主键冲突中止整批——`UPDATE` 匹配 0 行不会失败, + 因此「目标不存在」不能靠受影响行数兜住。序号由语句内 `MAX(ordinal)+1` 推进(批内顺序即 + 序次序),计数按本机同一规则**重数**,自动标题复用本机 `extract_title` 且仅 `title IS NULL` + 时写。 +- `session_lifecycle.rs`(新增):legacy 接纳(binding 与缺失 frozen 一次成立、已有值不变)、 + 未发布撤销(有子会话拒绝,本机同一文案)、删除会话树(子树 id 只读确认 + 同批删除)、 + child resume 认领事实(`agent_status` + 由状态派生的 `claimed`)。远端**不写**墓碑 + `session_lifecycle_commitments` / `creation_intent` / `execution_runs` / `workspace` 登记: + 那些是本机事实,远端没有跨机副本,删除就是删除。 +- 领域纯规则复用:`extract_title` 与绑定相对路径校验改为跨模块可见(`pub(crate)` / + `pub(super)`),远端不复制一份业务规则;未改任何公共契约、未动 `RemoteStoreNotWired`、 + 未激活 factory(消费者只有门面与两个 adapter)。 +- `recover_persistence` 仍为 `Unsupported`(需本机未决锚点,属 C-04)。 + +验证证据: + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **277 passed / 0 failed / 10 ignored**,exit 0(上一轮 266/10) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警,exit 0 | +| `cargo check --workspace --all-targets` | exit 0 | +| `rustfmt --config skip_children=true --check`(仅 8 个改动文件) | 干净(注意:`rustfmt` 默认递归子模块,全仓/整文件校验会误报无关文件的既有漂移) | + +新增离线断言 11 条:语句占位符数与绑定参数数一致(**测试当场抓到我把复用占位符数错**: +追加语句 `?2` 被主查询与子查询共用 = 7 个、计数刷新 = 3 个)、守卫确实落在单行主键上、 +rewind 两边界只差比较符、计数是重数而非自增、标题仅缺失时写、接纳只补 NULL、删除范围整树、 +认领标记不由独立列承载。 + +未完成与风险(不得据此宣称远程可用):**真实云探测本轮未跑**(本会话无委派工具,预算耗尽), +因此批内守卫在主引擎上的实际中止行为只有离线断言支撑,属**未验证**;`recover_persistence` +未实现;本机执行/登记组合与 D 装配激活(含删 `allow(dead_code)`)仍在下一批。两处**有意的偏离** +已记录在代码注释里:目标行不存在时远端明确失败(本机若干路径对 0 行更新返回成功)、 +recursive-CTE 删除范围改为「先只读取子树 id 再同批删除」(避免在 `DELETE` 里依赖 CTE 求值)。 + +### 9.11 C1–C3 数据 adapter 的诚实失败缺口 + 真引擎行为契约(同日第八轮;写路径仍未接线) + +本轮两件事:补齐「读不完整被当成缺失」这一类缺口,并在**真引擎**上跑 C-03 的行为契约 +(9.10 自陈的最大证据缺口)。 + +落盘(均在 `peri-resources/src/sessions/remote/`): + +- `mutation.rs`:读取出口统一校验**结果集数量 == 请求语句数**(`ensure_result_sets`), + 新增 `read_pair`(两段结果的一致读取)与 `sole_row`(主键查询至多一行,多行即错误)。 + 分类为 `Internal`:不是 `NotFound`(会把不完整回复伪装成「没有这一行」)、不是 `Corrupt` + (存储没坏)、不是 `Unsupported`(能力在)。 +- `session_read.rs`:快照与历史读取改走 `read_pair` + `sole_row`,去掉 + `batches.next().unwrap_or_default()`——原来少一个结果集会被读成「这个会话没有历史」。 +- `session_lifecycle.rs`:`delete_tree` 不再「先 `exists` 再用默认值兜住子树读取」——空子树 + 等价于根不存在 ⇒ `NotFound`,不再出现「什么都没删却返回 `Ok`」;`revoke_unpublished_session` + 的子会话计数只有**明确读到 0** 才继续(`revocation_gate`),读不出来即拒绝,不用不可证明的 + 证据做破坏性决定。两个纯函数 `tree_ids` / `revocation_gate` 带离线断言。 +- `session_history.rs`:**云端实验当场抓到并修复两个真实缺陷**——操作身份摘要缺项,导致不同 + 操作撞同一个 `operation_id`、第二次被当成重放**静默跳过**:(a) rewind 方向未进摘要 + (同一边界上的 `KeepThrough` 与 `RemoveFrom` 撞车);(b) 追加与 compact 只按内容摘要 + (同内容的两批追加、第二次同正文 compact 都被静默丢掉)。摘要现含方向与消息 id + (`entry_inputs`),并有「同内容不同 id 必须是不同身份」的离线断言。 +- `remote/mod.rs`:模块状态段与实现对齐(只剩 `recover_persistence` 未实现; + `allow(dead_code)` 与 `Unsupported` 明确标注为**临时状态**,装配前必须补齐或由门面拒绝)。 +- 测试结构:本轮 run 命名空间的共建夹具(清理/计数/合成输入)上移到 `cloud_tests`, + 新增 `cloud_history_test.rs` 与 `cloud_lifecycle_test.rs`(都在 700 行内)。 + +验证证据: + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **282 passed / 0 failed / 13 ignored**,exit 0(上一轮 277/10) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警,exit 0 | +| `cargo check --workspace --all-targets` | exit 0 | +| `cargo test -p peri-resources --doc` | exit 0 | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1`(已授权测试库) | **13 passed / 0 failed**(44.5s) | + +新增云实验断言(每条都读回可观察结果,不靠请求成功推断):追加的顺序/计数重数/`title IS NULL` +时补自动标题;批内重复 id 与撞已有行主键都被拒绝,且**撞主键的那一批一条都没落**;投影与 +compact 的 flags 定向生效、第二次同正文 compact 仍然落地;rewind 两边界按 `ordinal` 生效、 +未知边界无变更、二次移除幂等、跨会话移除被拒;child resume 认领由状态派生、legacy 接纳 +已有值不变且错误前置条件照实拒绝、删树移除整棵子树且二次删树 `NotFound`、有子会话的撤销 +被拒且一行都不删;**批内守卫在真引擎上的两条分支**——谓词成立 → 整批回滚(效果与资格写都不留), +谓词不成立 → 守卫一行都不插、批照常提交。所有实验结束都在新连接上复核本轮命名空间计数为 0。 + +未完成(不得据此宣称远程可用):`recover_persistence` 仍 `Unsupported`(C-04);本机执行/登记 +组合与未决锚点;D 装配——`Resources::open_deployment` 对 remote 仍返回 `RemoteStoreNotWired`、 +`allow(dead_code)` 未删;`MutationGate` 仍持有具体 `SqliteSessionData`/`LocalExecution`。 +本轮云实验覆盖的是**行为契约**,未做取消/超时/网络中断一类故障注入(P5/P6/P7 仍未实测)。 +安全:云实验只输出计数/布尔/领域类别,只清理本轮 run 命名空间并复核为 0,未读 `.env` 内容 +(selector 只传用户给出的键名)。 + +### 9.12 C-04 第一批:操作身份修正与本机操作日志(同日第九轮;远程写路径仍未接线) + +本轮修的是**身份模型**,不是再加一层壳:操作 id 由「store + 标签 + 内容摘要」派生时, +「同内容的后一次领域调用」会撞上前一次的 id → 被当成历史重放**静默丢掉效果** +(状态 A→B→A、标题 x→y→x、同边界 rewind 后追加再 rewind 都命中)。现在: + +- **每次领域调用铸造一个唯一操作 id**(`OperationId::mint`,形状 `{thread}.{uuid}`), + 内容摘要不参与身份生成,只做一致性校验(`remote/ledger.rs`)。 +- **发送前本机落盘**:新增本机表 `session_remote_operations`(schema v7 → v8, + `sqlite_store/remote_operations.rs`),记录 id/store/thread/root/行为/摘要/终态; + 登记失败即**不发送**。表无外键:远端会话行不在时记录仍可查(9.11 的缺口)。 + 选择独立表而不是 `session_lifecycle_commitments`:后者按 `thread_id` 一行,装不下 + 「每次调用一个 id、重启后逐个可查」(该表仍作为本机写入锚点,未决判定把两者并集)。 +- **确定终态才结清**:`Applied`/`NotApplied`/`ClosedNeverApplied` → `applied`/`never_applied`; + `Unknown` 保持 `pending`,门禁继续阻塞同根写入(§5.1 第 6 步)。 +- **恢复**:`recover_persistence` 不再 `Unsupported`——按本机日志逐条向远端账本求证; + 账本缺行时用**同一 id** 做终态封闭竞争(封闭先提交 ⇒ 此后不可能再生效;对方已提交 ⇒ + 读回原收据);账本行不可解释或封闭未确认则保持未结清。 +- **摘要在重放上真正起作用**:资格冲突读回的行摘要与本次身份不一致 → 不是重放, + 按确定未生效拒绝(`remote/mutation.rs` 的 `resolve_after_conflict`),不再把别人的效果 + 认成自己的。新建/fork/child 的摘要输入补齐 binding 与 metadata(`session_inputs`), + 不靠「同内容恰好在别处撞车」掩盖身份错误。 + +验证证据: + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **290 passed / 0 failed / 14 ignored**,exit 0(上一轮 282/13) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警,exit 0 | +| `cargo check --workspace --all-targets` | exit 0 | +| `cargo test -p peri-resources --doc` | exit 0 | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1`(已授权测试库) | **14 passed / 0 failed**(48.3s) | + +新增/更新的关键实验: + +- `cloud_identity_test.rs`(新):状态 A→B→A→B 落到 B、标题 x→y→x→y 落到 y、 + 同 boundary rewind ⇒ 追加 ⇒ 再 rewind 只剩一条、本轮会话账本行数 == 领域调用数(11)、 + 本机日志全部结清且 `recover_persistence` 返回 `Recovered`。全部断言走新连接读回。 +- `remote_operations_test.rs`(新,离线):发送前登记→未结清阻塞→结清放行、记录在结清后 + 仍可查、按 root 阻塞整棵树(远端会话靠日志里的 thread→root 关联)、同 id 异内容登记被拒、 + 结清单调(同终态幂等/异终态矛盾/`pending` 不是终态)、状态取值不认识即损坏、 + v7 库只读打开查询缺表不报错且只读不升级。 +- `remote/session_write.rs` 单元测试:同输入三次调用得到三个不同 id、同输入同摘要、 + 新建摘要覆盖 binding 与 metadata。 +- 语义更新(`cloud_session_test.rs` 实验二):操作身份不再由内容派生后,「同内容再保存」 + 是**新的领域调用**,照实撞会话主键 → 确定未生效(`InvalidInput`),且不改动已有行; + 重放语义只覆盖同一次操作(由本机日志的原 id 与封闭竞争承担)。 + +未完成(不得据此宣称远程可用):本机执行/登记组合(adapter 与执行面尚未在同一门面上组合, +远程会话的 owner/dirty/准入仍缺)、D 装配(`RemoteStoreNotWired` 未替换、`allow(dead_code)` +未删、`MutationGate` 仍持有具体 `SqliteSessionData`/`LocalExecution`);恢复路径未做故障 +注入实测(发送前崩溃、响应丢失、取消、迟到写仍是设计结论,交互式验证留给下一批); +`data_port()` 目前只有云实验夹具消费。安全:本机日志写在系统临时目录、只含 id/摘要/终态, +不含 payload/frozen/凭证;云实验只清理本轮 run 命名空间并复核为 0,未读 `.env` 内容。 + +### 9.13 C-04 第二批 + D 装配激活:真实门面组合与接纳裁决(同日第十轮) + +本轮把「数据在远端、本机管准入」从两套实现收敛成**一个门面 + 三个端口**,并接上 D 装配。 +不是加壳:`RemoteStoreNotWired` 已删除,`open_deployment` 的远程 locator 现在真的装配。 + +- **端口化**:`MutationGate` 持 `Arc`(canonical 数据)、 + `Arc`(本机证据/owner/代际/锁)、`Arc` + (未决锚点、远端操作日志、存储登记)。`lease` 只出现在执行端口,数据端口没有它; + 本机 `LocalExecution` 仍是同一份 SQLite 实现(本地塌缩不变),远程是本机执行面的第二个 + 实现,公开行为仍只有门面的那 33 条。 +- **接纳链**(B §6.1 第 3–5 环):`StoreId`(远端权威身份)→ 本机安装身份 → 登记 → + binding 复核 → root owner。按 StoreId 查登记(别名同域、URL 文本不进锁主键); + 「远端已有数据 + 本机无登记」⇒ 读取可用、执行与写入按 `WorkspaceError::StoreNotRegistered` + 拒绝,**不自动登记**;来源(引擎/locator 摘要)不一致 ⇒ 同样拒绝且不改写登记; + 首次登记只在「远端由本次打开初始化 + 写打开」时发生。只读打开连本机 schema 都不动 + (缺库时按 `NoLocalRegistry` 如实回答「没有记录」,不建文件)。 +- **root 归属**:远端会话在本机没有 `threads` 行,执行代际与 sidecar 锁**按 root** 归属 + (`admit_remote_root`/`acquire_remote_root`/`root_{write,exclusive}_guard`), + 不为使用旧 SQLite lease 造假历史行;子会话的执行权属于 root(与本地语义一致)。 + 远端绑定与本机 workspace 证据走同一套判定(`validate_binding_value`), + `LegacyConfirmed` 在远程首期恒不成立。 +- **创建/删除/关闭**:远程 `create_session` = 远端 durable 保存 → 本机准入,准入失败按 + `saved_but_not_admitted` 上报(数据自描述:行在、代际无,重试走 `admit_existing` 收敛); + 删除在发送远端删除**之前**落本机墓碑锚点(`anchor_deletion`);`close` 按活 owner 有界排空。 + +验证证据: + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **294 passed / 0 failed / 15 ignored**,exit 0(上一轮 290/14) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警,exit 0 | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_`(已授权测试库) | **15 passed / 0 failed**(64.2s;含本轮新增 1 项) | + +新增关键实验与测试: + +- `cloud_admission_test.rs`(新,显式云):真门面组合上 `create_session` 成功并给出活 owner + (远端保存 + 本机代际两步接线成立)、同一 root 在结清后回到 `Available`、 + `append_history`/`load_session_history` 走远端数据、`close` 排空; + **另一份全新安装**打开同一 store:历史可读、`execution == NoLocalRegistration`、 + mutation 报 `StoreNotRegistered`;两份安装的本机登记库都在系统临时目录。 +- `registration_test.rs`(新,离线):已有数据不自动登记、只读打开不写登记、 + 空库首次登记一次且复用同一安装身份、来源不一致拒绝且不改写登记。 +- `open_test.rs`:远程 locator 的配置失败在任何 I/O 之前发生(缺凭证 ⇒ 类型化 + `CredentialError`,不再有「未落地」这个临时结论)。 + +未完成(不得据此宣称远程可交付):故障注入实测(发送前崩溃、响应丢失、取消、迟到写仍是 +设计结论);`recover_persistence` 之外的崩溃恢复演练;E 全链路消费侧接入。 +安全:只读 `.env` 由测试进程的安全 parser 完成(只取两个显式键名的值,不 `source`/`eval`、 +不回显),云实验只清理本轮 run 命名空间并复核为 0,本机登记库/日志都在系统临时目录。 + +### 9.14 C-04 第三批:未决收敛接线、统一门禁与故障注入实测(同日第十一轮) + +本轮把 `recover_session_persistence` 从「已实现的 adapter 行为」接成**门面级可用的收敛路径**, +并把故障从设计结论变成注入到真实批上的实测。修掉两个真实缺陷: + +- **未决阻塞范围按后端解析**(新 `LocalExecutionPort::pending_scope`):远程会话在本机没有 + `threads` 行,之前用本机父链解析未决范围会得到「没有根」,于是 root 有未结清写时同根 + **子会话的首次写入**漏过门禁(该子会话在本机日志里也还没有自己的记录,两条回退都落空)。 + 门面现在按「传入 id ∪ 该 id 的 root」两问取并集;本机组合的答案仍是本机父链(行为不变), + 远程组合的答案来自远端父链(父关系创建后不变,带缓存)。 +- **`mark_clean` 统一门禁**:宣告 `clean = 1` 之前读同一张 `session_remote_operations`、 + 同一条未结清谓词(`has_pending_operations_on`,与 `has_pending_persistence` 共用), + 未结清时按 `WorkspaceError::RecoveryRequired` 拒绝且不关闭准入——「clean」的语义是 + 「这条 root 没有无法证明终态的效果」。本机组合从不写该表,这一步是恒真的空查询。 +- **恢复入口不需要令牌**:`recover_session_persistence(id)` 先等本进程在途写入结束(有界, + 超时按 `Timeout` 上报)再让数据面判定;`drain` 与写入门禁同样按 root 判定。恢复**不消费** + 调用方任何 operation id:身份在发送前已落进本机日志,恢复按日志里的原 id 还原。 + +故障注入面(`FaultPlan`,仅测试构建,生产构建没有这些字段)落在真实批上,不是替代返回值: + +- `drop_reply`:批**照常提交**(真实事务、真实账本行),结果按未决上报 ⇒ 响应丢失 / + 取消后仍提交 / 已提交但本机结清失败 / 进程在结清前结束的等价物; +- `drop_before_send`:批在发出前消失,远端什么都没有 ⇒ 发送前崩溃的等价物。 + +验证证据: + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **298 passed / 0 failed / 20 ignored**,exit 0(上一轮 294/15;ignored 全部是显式云实验) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警,exit 0 | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_`(已授权测试库) | **20 passed / 0 failed**(92.9s,含本轮新增 4 项:`cloud_recovery_test.rs` 3 项 + `cloud_local_fault_test.rs` 1 项) | + +新增关键测试: + +- `remote/cloud_recovery_test.rs`(新,显式云):① 响应丢失 → 本机日志停在 pending、 + 效果**真的在远端**(新连接读回)、恢复判「已生效」并结清,之后写入恢复;② 发出前消失 → + 恢复用**同一唯一键**封闭成「从未生效」,随后**迟到原请求**(同一 id + 同一摘要 + 真实效果) + 到达 → 整批回滚、`ClosedNeverApplied`、效果为零、账本仍是封闭态;③ 真门面:root 有未结清写 + (记录由**另一份安装**的子会话构成,本机登记库里没有它的记录)→ 同根子会话首次写入被拒 → + 恢复(不需要令牌)→ 放行;④ 重启 + 目标已删:同一份本机日志的**新打开**(句柄落下 = 进程 + 状态结束)+ 另一份安装删掉会话 → 恢复判「已生效」(依据是账本行,不是「目标读不到」)、 + 未决集合清空、目标仍 `NotFound` 且**不被重建**。 +- `remote/cloud_local_fault_test.rs`(新,显式云):故障注入在**本机事实端口**(真实 + `SqliteSessionData` 的装饰器,不是替代返回值)。`record_remote_operation` 失败 ⇒ 整次 + mutation 中止、错误**不是**未决、远端零效果、无需收敛;`settle_remote_operation` 失败 + (批已提交)⇒ 本机保守保留阻塞、效果真的在远端,恢复判「已生效」并放行。 +- `resources_test.rs`(离线,真实门面 + 真实 SQLite):未结清远端操作阻塞同根**全部入口** + (写入 root/子会话、fork 来源、删除、`acquire_execution`、`reset_dirty_execution`、 + `drain_persistence`、`close`、`mark_clean`);ordinary dirty 的显式解除**不解除**远程未决 + (拒绝后仍是 dirty,未结清记录原封不动);未决事实跨进程可见(同一条本机库另一次打开仍然 + 阻塞,本机组合如实报 `StillBlocked` 而不是假装收敛)。 + +未完成(不得据此宣称远程可交付):删除墓碑在恢复后的收尾(远端删除已生效时本机墓碑停在 +`deleting`;它不参与未决判定,各条查询都把 `deleting`/`deleted` 同等看待,identity 仍是终态, +但记录里少了 `deleted` 这一步);真实进程 kill 演练(上面第 ④ 项用「同一份日志的新打开」 +作重启等价物,没有真的杀进程);E 全链路消费侧接入。 +安全:`.env` 仍只由测试进程的安全 parser 读取(只取两个显式键名的值),云实验只操作并清理 +本轮 run 命名空间,本机日志/登记库都在系统临时目录,未上传真实历史或仓库内容。 + +### 9.15 C-05 第一批:部署入口端到端(跨进程冷恢复)与残留标记清理(同日第十二轮) + +前面几轮分别打的是 adapter 行为、门面接纳、故障收敛,没有任何一条实验从**部署入口** +(`Resources::open_deployment`,CLI/TUI/print/stdio/meta 共用的那一个装配点)走完整个生命周期。 +本轮补上,并把「已落地、消费方未接入」的残留标记清掉。 + +新增两个文件:`remote/cloud_deployment_test.rs`(父进程:提供合成环境、拉起子进程、用**新连接** +在阶段之间核对远端事实)与 `remote/cloud_deployment_child_test.rs`(三个子进程阶段;没有标记 +变量时直接返回)。每一段都在**独立进程**里跑:进程边界消失之后仍然成立的事实才是 durable 事实。 + +| 阶段 | 进程 | 实测结果 | +| --- | --- | --- | +| 写入 | 子进程 A(temp HOME + 合成 git workspace) | 创建 → 追加 2 条 → `drain_persistence` → compact(标记 + 摘要,读回 3 行)→ fork → child → 标题 A→B→A → `close`;远端 **3 会话 / 6 消息 / 8 账本行** | +| 冷恢复 | 子进程 B(同一 HOME,**新进程**) | `recover_session_persistence` 判 `Recovered` → 上一个进程未写 clean 留下的 ordinary dirty → 显式风险接受解除 → `acquire_execution`(全量绑定复核)→ rewind(3 → 1 行)→ 删树(root 树消失、fork 树仍在:**1 会话 / 3 消息**)→ `mark_clean` | +| 只读(全新 HOME) | 子进程 C | 远端历史可读(3 行);执行准入 `NoLocalRegistration`;写入拒绝 `StoreNotRegistered`;**HOME 一个文件都不建** | +| 只读(已登记 HOME) | 子进程 D | 只读打开不改本机状态(目录快照前后一致);写入拒绝 `ReadOnlyStore` | + +本轮证到的事: + +- **标题 A→B→A 经真实入口落到 `title-a`**:第三次领域调用不被当成历史重放(R1 的身份修正 + 在完整装配路径上成立,不只是 adapter 级实验)。 +- **fork 复用 source 的 `message_id` 被明确拒绝**(`InvalidInput`:「remote constraint violation」), + 不是静默丢行;产品路径的重映射语义(`peri-acp::dispatch::session_fork` 的纯 ID 重映射)因此 + 第一次有了回归。本轮实验第一版就是按「复制原 id」写的,云端当场报约束冲突——adapter 的 + 诚实失败是对的,改的是实验。 +- **只读零副作用**:两个只读阶段之后,会话/消息行数与账本行数与阶段前**完全一致**(父进程用 + 新连接在前后各核对一次),本机目录快照也一致。 + +顺带清理(不留「永不可用」的假完成面): + +- `SessionStoreOpenRequest::{access,credential_source,mode}` 的 `allow(dead_code)` 删除——三个 + 入口现在都有生产调用方(远程装配)。 +- `SqliteThreadStore::data_port` 生产无消费方(门面与远程组合都直接持 `LocalExecution`, + 同一实现的两个句柄),**删除**;两处测试夹具改用 `LocalExecution`(`close` 由数据面端口的 + `close` 承担,同一动作)。 +- `open_test.rs` 里「数据 adapter 尚未落地」的注释改为当前事实(断言本身仍有效:配置不完整 + 必须在任何 I/O 之前失败)。 +- `remote/mod.rs` 的模块级 `allow(dead_code)` 保留,但按实测重写:非测试构建里的未使用项只有 + 「显式 cloud 探测/回环面」与「逐条断言的 SQL 片段常量」两类,并写明新增未使用项必须属于这两类。 + +验证证据: + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **301 passed / 0 failed / 21 ignored**,exit 0(上一轮 298/20;新增 3 个子进程阶段测试 + 1 个显式云父测试) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警,exit 0 | +| `cargo check --workspace --all-targets` | exit 0 | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_`(已授权测试库) | **21 passed / 0 failed**(116.0s;新增 1 项端到端 15.2s,其余为上几轮的 adapter/门面/故障实验) | + +未完成(不得据此宣称远程可交付):删除墓碑在恢复后的收尾(同 §9.14);真实进程 kill 演练 +(本轮的「新进程」是真进程,但上一进程是正常退出,不是被 kill);E 全链路消费侧接入。 +安全:`.env` 仍只由测试进程的安全 parser 读取(两个显式键名 + 绝对路径),云实验只操作并清理 +本轮 run 命名空间(复核为 0),合成 workspace/HOME/本机登记库都在系统临时目录,未上传真实 +历史或仓库内容。 + +### 9.16 C-05 第二批:真进程强杀、P5–P7 实测与锚点收尾(同日第十三轮) + +第十二轮列出的未完项里有两件这一轮落到证据上(第三件是 E 消费侧,不在 C 范围): + +1. **真进程强杀演练**:`SIGKILL` 结束正在写远端的进程,同一份本机库的新进程收敛。 +2. **P5/P6/P7 从「未实测」变成有观测**:SDK 重试行为的静态审计 + 取消在途调用、超大单批、 + 收据保留与空间成本三条实验。 +3. **远程删除锚点收尾**:`anchor_deletion` 之后补上 `finalize_deletion`(删除确认 → 墓碑收终态)。 + +顺带**发现一处真实缺陷**(本批新证据,未修):放弃一个**已经发出**的在途请求之后,同一个 store +实例的连接不再可用。见本节末尾「本轮发现的连接生命周期缺口」。 + +#### 强杀演练怎么保证「死点是确定的、死法是真实的」 + +新增 `remote/cloud_kill_test.rs`(父进程)与 `remote/cloud_kill_child_test.rs`(子进程阶段), +只在 Unix 编译(`kill -9` 与信号语义)。装配仍是同一条路径:`open_remote` 拆成 +「本机事实建立(`open_local_facts`)+ 远端打开与装配(`assemble_remote`)」,测试用 +`open_remote_with_facts` 只替换本机事实端口这一层(`#[cfg(test)]` 缝,见 `composition.rs`), +远端 adapter、本机执行面、门禁、门面都是生产实现。 + +- 子进程在注入点写出 `PROBE KILL_READY` 后**停住不再返回**(本机事实端口的两个委托点: + 登记之后、结清之前); +- 父进程读到标记后执行 `kill -9 `,并断言子进程**以信号 9 结束**(不是正常退出); +- 两个注入点各自跑一次完整闭环:写入 → 强杀 → 父进程用新连接核对远端 → 同一 HOME 的新进程收敛 + → 父进程再用本机登记库核对 durable 事实。 + +| 注入点(死点) | 死之前的 durable 事实 | 实测收敛 | 证据 | +| --- | --- | --- | --- | +| 本机登记已落盘、远端请求**未发出** | 本机日志 1 条 `pending`,远端账本无此操作 | 收敛为「从未生效」:远端历史仍是 1 条(被杀那次写入永远不出现),新调用落地(2 条),被杀的写入出现 **0** 次 | `cloud_kill_before_send_converges_to_never_applied` | +| 远端批**已提交**、本机**未结清** | 本机日志 1 条 `pending`,远端账本已有收据 | 收敛为「已生效」:远端历史 2 条,新调用落地(3 条),被杀那次恰好 **1** 份(不重复、不撤销) | `cloud_kill_before_settle_converges_to_applied_once` | + +父进程独立核对的本机 durable 事实(不经过门面,直接读本机登记库):收敛后 +`session_remote_operations` 里**没有任何 `pending` 行**,且阶段二删除整棵树之后该 root 的墓碑 +`state = 'deleted'`。 + +#### 远程删除锚点的收尾(本批新代码) + +`LocalExecutionPort` 增加 `finalize_deletion`:本机组合是空操作(数据面 `delete_tree` 在提交后 +同一动作里收尾,`sqlite_store/session_data.rs`),远程组合把本机墓碑从 `deleting` 收到终态 +(`LocalExecution::finalize_deletion` → `finalize_tombstones`)。门面在**删除已生效之后**调用它, +失败只记录不翻转结论——删除不可逆,而所有消费方(`mark_clean` 的记录缺失容忍、执行行缺失容忍、 +identity 终态判定)都把 `deleting` 与 `deleted` 同等看待,缺的只是收尾标记。上表最后一行就是 +这条路径的端到端证据(真实装配路径删除 → 父进程用本机库新连接读到 `deleted`)。 + +#### C §5.1 P5/P6/P7 实测 + +新增 `remote/cloud_limit_test.rs`(四个实验,全部经真实门面)。判据刻意分成两条:**权威判据是 +新连接的原始计数**(不依赖 adapter 的读取预算),adapter 读回只作附加一致性检查——这样 +「整批生效或零部分结果」不会被读路径的预算问题掩盖。 + +| 前置条件 | 观测方式 | 实测 | +| --- | --- | --- | +| **P5** SDK 不自动重试 mutating 请求 | 读 SDK 源码 + 我方请求路径 | `turso_serverless` 0.1.3 全部源码里**没有** retry/backoff/sleep 逻辑(只有关于嵌套事务与 HTTP 失败的错误文档);我方 `connection.rs` 是单次调用 + 20s 预算(`REQUEST_BUDGET`),预算超时归 `Exceeded`→`Timeout`,**不推断**远端是否生效。因此同一次发送不会有第二个请求;重试只可能是调用方重新发起,而那是**新的领域调用**(新操作 id,R1 的身份语义) | +| **P5** 放弃在途调用后仍有 durable anchor | 直接 drop 调用 future(400ms 预算),再核对本机日志 | 两次运行各自落在合法的一侧,实验对两侧都断言:**① 已过发送前登记**(`anchor_written=true`、`local_append_records=2`)⇒ 那次写入最终**恰好一份**(`rows=2`,请求在被 drop 之前已到达并提交),后续调用再落 1 行;**② 没来得及登记**(`anchor_written=false`、`local_append_records=1`)⇒ 那次写入**从不出现**(`rows=1`),后续调用再落 1 行。两侧共同点:`local_pending=0`,取消从不产生第二份效果 | +| **P6** 超大单批输入先拒绝或整批生效,无部分结果 | 单条 256 KiB 与 1 MiB 消息正文各一次 `append_history`,每次用新连接的原始计数核对 | 两档都**整批生效**(`class=applied`;256 KiB 写入 720–1384 ms、1 MiB 写入 1382–2520 ms,两次运行的实测区间),256 KiB 档 adapter 读回与新连接计数一致(1 行)。**单请求上限没有被定位**:本批没有分块实现,没有触发「先拒绝」分支 | +| **P7** 收据不按 TTL 清理,记录空间成本 | 本轮收据条数与列字节数(`COUNT(*)` + `SUM(LENGTH(各列))`)在收敛与重读之后重算 | 收敛与重读前后**逐条不变**(2 行 / 454 字节,=`create_session` + 一次 `append_history`);没有任何清理路径删收据。成本口径:每行几十到几百字节,只含 id/kind/摘要/终态/原收据/时间戳,**不含 payload** | + +另外补上一条此前只被文档化的拒绝路径:远程 `create_session` 收到带父会话的输入时返回 +`InvalidInput`,且**远端零行**(该 id 不存在、本轮会话数仍为 1)。 + +#### 本轮发现的连接生命周期缺口(未修,下一批第一件) + +取消实验把两件事分开记录:① 同一次打开上尝试收敛;② 换一次打开(新连接、新 owner)收敛。 + +- 当取消切在**请求已经发出去之后**(实测那一次:`anchor_written=true`),① 的结果是 + `Unavailable { detail: "remote session store transport" }`:**同一个 store 实例的连接不再可用**, + 该实例上后续任何请求都在传输层失败; +- 当取消切在**发送之前**(实测另一次:`anchor_written=false`),① 直接返回 `Recovered` + ——没有任何请求在途,也就没有可坏的连接; +- ② 在**新打开**上两种情况都正常(收敛为 `Recovered`、行数 0/1、无未决、后续调用恰好 +1)。 + +同样现象在超大写入实验里独立复现:累计历史 ~1 MiB 时 adapter 的整段读回撞上 20s 预算 +(`Timeout`,请求被 drop),紧接着的普通调用也是传输层 `Unavailable`。 + +成因在我方 adapter 的连接缓存:`RemoteSessionData` 把 `RemoteStore`(内含 SDK `Connection`) +缓存在 `RwLock>` 里,**失败后不重建**;SDK 公开面也没有客户端超时配置。 +影响:一次超时或一次取消会让该进程内的远程操作全部以传输层失败上报,直到重开 store—— +**不假恢复**(不会谎称成功),但可用性上是真缺口。修法(下一批)需要 adapter 在传输类失败后 +标记连接失效并在下次请求重建,这会要求组合层把凭证保留到 adapter 内(或把连接交给组合层 +管理),因此是设计选择,不在本批硬塞。 + +#### 验证证据 + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **303 passed / 0 failed / 27 ignored**(上轮 301/21:新增 2 个强杀父测试 + 4 个边界实验) | +| `cargo test -p peri-resources --all-targets` | 303 passed / 0 failed(含 6 个独立目标测试) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | 零告警 | +| `cargo check --workspace --all-targets` / `cargo test --workspace --doc` | exit 0 / exit 0 | +| `cargo test -p peri-acp --lib`(消费侧恢复链路) | 717 passed / **1 failed**:`host::prepared_tests::new_session_persists_prepared_frozen_bytes_once`(断言是「持久化的 frozen 字节必须与准备输入同源」)。**该模块单独运行 6 passed**,失败只在全量并行跑时出现;文件属他轮未跟踪 WIP(`peri-acp/src/host/prepared{,_test}.rs`),本轮未改 peri-acp 任何文件,判定为隔离/竞态问题,留给 Fable 用同一棵树核对是否与既有状态相关 | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_`(已授权测试库) | **27 passed / 0 failed**(225.0s;新增 2 个强杀演练 + 4 个边界实验,其余为上几轮实验) | + +#### 本批实测命令(可照抄复跑) + +```bash +# 离线:策略与行为(默认 SQLite 组合完全不变) +cargo test -p peri-resources --lib +cargo test -p peri-resources --all-targets +cargo clippy -p peri-resources --all-targets -- -D warnings +cargo check --workspace --all-targets && cargo test --workspace --doc + +# 消费侧(恢复链路,门面类型化分类经 ACP 派发) +cargo test -p peri-acp --lib + +# 显式云(授权测试库;键名由选择器给出,值只在测试进程内解析) +PERI_CLOUD_ENV_FILE=<绝对路径>/.env PERI_CLOUD_URL_KEY= PERI_CLOUD_TOKEN_KEY= \ + cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1 cloud_ +``` + +#### Fable 应验证的实际效果(不要只看结论) + +1. **强杀演练真的杀在注入点**:跑 `cloud_kill_` 两个实验,确认子进程以**信号 9**结束、父进程 + 读到过 `KILL_READY`;把 `KillSwitch::stop_until_killed` 改成直接返回,实验应当失败。 +2. **收敛不是「读不到就算没发生」**:把 `recover_persistence` 的账本求证短路成「无行即 + never_applied」,`before-settle` 实验会失败(那次操作**有**收据且已提交)。 +3. **同一 RPC 重试 vs 新领域调用**:`OperationId::mint` 每次调用铸 id、摘要只做一致性校验; + A→B→A(状态/标题/rewind 边界)覆盖在 `cloud_identity_test.rs` 与部署端到端(标题 A→B→A + 落到 A)两处,`OperationId::scoped` 仅 `#[cfg(test)]`。审阅时确认生产路径里没有第二处把 + 内容拼进身份。 +4. **锚点收尾真的写了**:把 `finalize_deletion` 的远程实现改回 `Ok(())`,强杀父测试读到的墓碑 + 应当停在 `deleting`(这条断言是本批新增的)。 +5. **连接缺口是真缺口**:跑 `cloud_cancelled_write_keeps_a_durable_anchor`,看 + `same_instance_recover=` 那一行是不是传输层失败;再确认它没有被我改写成「换个实例就当通过」 + ——实验的结论只来自第二段(新打开),第一段是**如实记录**。 +6. **默认 SQLite 未受影响**:`cargo test -p peri-resources --lib` 全绿即本地塌缩语义未变 + (本批对本地只加了一个空操作端口方法与一个 `StoreAccess::of`)。 + +#### 尚未证明(不要当成已完成) + +- **P6 的「先拒绝」分支**:单请求上限没有定位;实测只在 256 KiB / 1 MiB 下证明了 all-or-nothing。 + 累计历史 ~1 MiB 时 adapter 的整段读回超出 20s 单请求预算(本窗口实测,不当作成功)。 +- **连接生命周期缺口未修**(见上):取消/超时之后同进程内的远程操作不可用,必须重开 store。 +- **迟到写与取消的组合**:端到端证据来自 R3 的 adapter 级实验(原始请求整批回滚的零效果断言), + 本批补的是门面级取消 + 本机 anchor。 +- **强杀时序抖动**:注入点确定,但「同一请求两次强杀之间」的时序组合没有穷举。 +- **E 全链路消费侧**:CLI/TUI/print/stdio/meta 仍只把 `SessionStoreDeployment` 交给同一个门面, + 没有跨进程的消费侧端到端(本轮强杀演练走 `open_remote`,部署入口端到端在 §9.15)。 +- **删除墓碑收尾的故障分支**:`finalize_deletion` 失败时只记录不翻转,这条容忍路径没有故障注入 + 实测(缺收尾时消费方同等看待是代码事实,不是实测结论)。 + +### 9.17 Fable P1 关闭:跨 store 结清与封闭证据保留(同日第十四轮) + +Fable 独立复现判 FAIL 的两条 P1 已修并给出可复跑证据。**其余 finding 维持 FAIL**(见本节末)。 + +#### 1) 跨 store 结清(P1) + +- **缺陷**:`RemoteSessionData::recover_persistence` 用 `anchors.pending_remote_operations(root)` + 取「本 root 全部未结清」,而 SQL 只按 thread/root 过滤、**未限定 store_id**;同一个 HOME 里 + store A 的未结清记录会被 store B 的恢复当成自己的,B 据此向**自己的**远端账本做终态封闭, + 把 A 的记录结清成 `never_applied`。 +- **修法**(都在内部,公共接口不暴露后端机制):未结清谓词以 `store_id` 为第一项,结清语句 + `WHERE store_id=?3 AND operation_id=?4 AND state='pending'`;门禁/可用性/关闭/`mark_clean` 的 + 未决查询按确切作用域提问;本机域不认领任何远端日志;busy/dirty/clean 的行键与 sidecar 锁名改 + 由内部 `ExecutionDomain::{Local, Remote(store)}` 派生(远程域长度前缀编码,本机域保持 thread id + 原文,历史行不重写);adapter 逐条核对 `record.store_id == self.store_id()`,不匹配则**不发远端 + SQL、不改本机记录**,外来记录原样保留——每个 store 只报告其范围结果。 +- **证据**: + +| 类型 | 命中 | +| --- | --- | +| 离线 | `stores_do_not_share_pending_operations_for_the_same_root`、`stores_do_not_share_execution_generations_for_the_same_root`、`mark_clean_is_blocked_only_by_its_own_store`、`local_sessions_do_not_share_execution_generations_with_remote_roots`、`test_remote_operation_log_is_store_scoped_and_cross_process_visible`、`test_unsettled_remote_operation_blocks_the_whole_root` 全绿 | +| 离线反例 | 把谓词与结清语句回退成旧形状(S1 实测):`stores_do_not_share_pending_operations_for_the_same_root`、`mark_clean_is_blocked_only_by_its_own_store` → 2 failed | +| 云反例 | 同时回退「store 谓词 + adapter 防御分支」(S2 实测):`cloud_foreign_store_pending_record_is_never_settled_through_another_store` 子进程失败于 `another store's recovery must not settle a foreign pending record`、父进程失败、`retained_receipts=3`(比修复后多 1 条 = B 的账本里写入了外来操作 id 的封闭回执) | +| 云(本批终态) | 同一命令在最终树上 **1 passed**:真实 store B + 本机合成 store A、同 HOME、与 B 的会话**同 root id**;子进程 `write_open_foreign_pending=preserved` / `read_only_foreign_pending=preserved`,父进程**新连接**读 B 账本 `foreign_receipts=0` | + +#### 2) 云清理器删除封闭证据(P1 第二半) + +- **缺陷**:`cloud_test.rs` 的 `DELETE_RUN_LEDGER_SQL` 与 `cloud_mutation_test.rs` 的 `CLEANUP_SQL` + (`DELETE FROM peri_op_ledger`)会删掉本轮收据——收据是「这次操作发生过」的证据,删掉后迟到的 + 同 id 请求失去判据,与 P7 口径(收据不按 TTL 清理)冲突。 +- **修法**:清理器**只删本轮合成会话/消息**(静态 SQL + 全绑定参数,`run` 只作绑定值,仅命中 + `{run}%`,不动未知/shared/其他 run);收据一律保留并报告条数。清理后的复核改到**新连接**上: + 本轮合成数据为 0、本轮收据只增不减、清理自身那张收据仍能经 adapter 读回 `Applied`。 +- **证据**: + +| 类型 | 命中 | +| --- | --- | +| 离线契约 | `test_cleanup_never_deletes_ledger_receipts`(效果语句不含 `peri_op_ledger`、`run` 不进 SQL 文本)、`test_cleanup_retention_check_reports_retained_receipts`(收据丢失或数据未清都失败) | +| 云(新连接) | 上表那次回归的清理复核即走新连接:`retained_receipts=2`(本轮真实写入 1 + 清理自身 1),读回无 `Absent/Closed` | +| 云反例 | 把新连接的收据读回指向一个不存在的操作 id(本批实测,非破坏性)→ 命令失败于 `cleanup receipt must stay readable on a new connection: Absent`,证明该复核不是空断言 | + +- **有意保留的空间成本**:本批 4 次云运行各在本轮命名空间留下 2 条收据(3 次成功运行观察到 + `retained_receipts=2`;那次故意失败的控制运行在复核前中止、未打印计数,其结构相同)。收据只含 + id/kind/摘要/终态/时间戳、**不含 payload**,不随 TTL 清除。 +- Fable 留的探针产物(`peri-fable-foreign-…pending`)原样保留:它是探针在**自己的 tempdir HOME** + 里插入的一行记录(进程结束随目录消失);本批以只读连接复核真实 HOME 库 + (`~/.peri/threads/threads.db`):**不存在**该外来记录行(该库没有 `session_remote_operations` + 表,该 id 只作为会话文本出现在 `-wal` 里),本批无任何清理路径触碰它(探针目录 + `peri-fable-probe-*` 亦只做只读核对)。 + +#### 本批命令与结果 + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **310 passed / 0 failed / 28 ignored** | +| `cargo test -p peri-resources --all-targets` | 310 passed + **6 passed**(契约目标 `session_resources_contract`) | +| `cargo check --workspace --all-targets` | Finished(无 error/warning) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | Finished(零告警) | +| `cargo fmt -p peri-resources -- --check` | 无差异 | +| `... --ignored --test-threads=1 cloud_foreign_store_pending_record_is_never_settled_through_another_store` | **1 passed**(5.3s;`foreign_scope=ok`、`foreign_receipts=0`、`retained_receipts=2`) | + +#### 仍未关闭(不得据此宣称完成) + +- Fable 本批其余 finding 维持 **FAIL**,本批一行未改:StoreId 首次竞争、child、close、path、conn、frozen。 +- `session_lifecycle_commitments` 仍按 `thread_id` 单键(无 store 列):同 id 跨 store 共享删除墓碑, + 影响面限于删除锚点;`mutation_pending` 在生产路径不写。 +- §9.16 的连接生命周期缺口(取消/超时后同进程内远程操作不可用)未修。 +- 本轮**只跑了一个云回归**,没有重跑 P5–P7 全量云实验:§9.16 的云结论仍是上轮读到的;本批改动不 + 改变 P7 口径(收据只增不减,清理器不再有该表的删除语句)。 +- `recover_persistence` 的跨域防御分支没有「从门面外部制造来路不符的本机日志」的专门测试(门面/夹具 + 无法生产该形状),由严格谓词 + adapter 再检查 + 上表云反例共同覆盖。 +- 远程执行域行键对**升级前**已存在的远程 root 行不做重写(本机域语义不变、远程域看不到它们): + 本批的明确取舍,见 S2 残留。 + +### 9.18 S1–S3 同因收尾:远程域独立存储与旧键残余处置(同日第十五轮) + +范围只有三件,都是 §9.17「仍未关闭」里那两条的直接原因,别的一行未碰(Fable 其余 finding、init、child、 +close、path、conn、云实验都不在本批)。 + +1. **远程墓碑不再与本机墓碑共用键空间**(原缺口:`session_lifecycle_commitments` 按 `thread_id` 单键)。 + 远程删除锚点改写在 `remote_lifecycle_commitments(store_id, thread_id)`(schema v9);本机墓碑表一字未改。 + 远程侧的 `anchor_deletion`/`finalize_deletion`/`identity_anchored` 全部走两列键,且 + `identity_anchored` 分成本机域与 `identity_anchored_in(store, …)` 两条路:本机墓碑不回答远程问题, + 远程墓碑也不回答本机问题。 +2. **旧 raw 执行代际与旧锁不再被忽略**(原缺口:S2 把远程键改成编码后直接看不到发布版写下的 raw 行)。 + 远程代际改写在 `remote_execution_runs(store_id, root_id)`(同样 v9),与本机 `execution_runs` 不相交; + 旧键残余按三条保守规则处置:**取不到旧锁就 `ExecutionBusy`**(旧 owner 活着不许越过);**唯一可证才迁移** + (本机没有同 id 会话身份,且日志里这个 root 只属于这一个 store,或本机只登记过这一个 store),在旧锁内 + 与本次代际推进同一次提交里搬运,`generation`/`clean` 原样保留(dirty 仍 dirty,报 `RecoveryRequired` + 精确代际);**歧义一律挡住**(`Corrupt`,旧行不删不改写,不自动归入当前 store)。报错的那次调用整体回滚, + 因此事实要么还在旧行、要么已经在本域表。 +3. **两域真正不相交,不再依赖对 id 内容的假设**。`ExecutionDomain::row_key`(编码键)删除:本机域用 + `thread_id` 原文,远程域是两列主键;sidecar 锁同名问题一起解决——本机锁仍是 `.lock`, + 远程锁落在 `.execution-locks/remote/` 子目录,内存 owner 登记键改成 `LeaseKey` 枚举(不是字符串)。 + `ThreadId` 是不透明字符串,构造一个恰好等于旧编码键的本机 id 不再可能串域(有专门回归)。 + +**迁移兼容结论**:schema 8 → 9 只加两张表,既有行(含未结清 dirty、墓碑、登记、操作日志收据)逐行保留, +升级可重复;只读打开旧库仍按列形状放行;本机域的行键、锁名、语义与既有测试完全不变。 + +**本批命令与结果** + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **317 passed / 0 failed / 28 ignored** | +| `cargo test -p peri-resources --test session_resources_contract` | **6 passed / 0 failed** | +| `cargo clippy -p peri-resources --all-targets` | 零 warning / 零 error | +| `rustfmt --edition 2021 --check <本批 13 个文件>` | 无差异(只格式化本批文件,未全仓) | + +新增回归(`sqlite_store/remote_execution_test.rs`,7 条):墓碑按 store 分域、可归属旧 dirty 迁移后仍 dirty +并按旧代际步进、不可归属旧行挡住且原样保留、外来旧行两域都不动、活旧锁 busy、等于旧编码键的本机 opaque id +不串域、v8→v9 只加表不改行。 + +**仍未关闭** + +- 旧键残余的处置只覆盖「同一时刻只有一个写者」:与旧二进制同时写不做互斥(本批明确不需要)。 +- 云回归本批未跑(跨 store 云用例由上批口径覆盖,远程域存储换了表,云断言按新表名改过一处)。 + +### 9.19 §9.18 追加:未决判定的作用域(同一轮,提交前收尾) + +§9.18 报告把 `pending_persistence_on` 说成「不在三缺口内」,那是误判:它读的 +`session_lifecycle_commitments` 正是 StoreId 隔离目标里的那张表,而且它对本机与远程两个作用域 +**都**先跑 `thread_root_on` 本机 `threads` 父链——同名本机 child 会把远程 root 映射到别人的树上, +随后那次 raw 锚点查询就跨域了。本批一并修掉: + +- `LocalStore`:本机父链的根 + 本机写入锚点(与本机域既有语义一致)。 +- `RemoteStore`:只问本 store 的事实——日志里的 thread → root 关联(没有记录按自身,不再用本机链猜)、 + `remote_lifecycle_commitments` 里本 store 的未决锚点、本 store 未结清的远端操作。 +- 本机表里的旧 raw `mutation_pending`:不忽略也不乱认,走 `remote_execution` 已有归属规则 + (可证属于本 store 计入未决、可证属于别的 store 不计、归属不明报 `Corrupt`)。 + +新增回归(`session_data_test.rs`):同名本机 child 的父级锚点不回答远程作用域;远端未结清操作不冒充 +本机未决,本机旧 raw 锚点不冒充远程未决(无证据时报 `Corrupt`,有唯一证据时计入)。 +`remote_execution.rs` 的注释里「发布版」措辞已改成「早期实现(本分支 checkpoint 之前,尚未发布)」, +不再宣称这些 raw 行来自已发布版本。 + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | **319 passed / 0 failed / 28 ignored** | +| `cargo test -p peri-resources --test session_resources_contract` | **6 passed / 0 failed** | +| `cargo clippy -p peri-resources --all-targets` | 零 warning / 零 error | + +### 9.20 Fable P1(第一半)关闭:身份初始化竞争的胜者表达(同日第十六轮) + +只处理「创建事实与竞争判定」。**本条不含** child 父关系校验(Fable 第二条 P1),也不含 close/连接 +重建/path/frozen/首次产品登记,它们各自独立成批。 + +- **缺陷**:`RemoteSessionData::open` 在调用 `initialize_store` **之前**就把 `CreatedByThisOpen` + 预设好,而 `initialize_store` 只回一个身份值——唯一键冲突时它读回胜者身份,**败方因此也获准首次 + 登记**(两个全新 registry 对同一个空库并发打开时,败方会认领别人的存储并拿到执行资格)。 +- **修法**(都在内部;公共行为接口不暴露 SQL/事务/CAS/重试): + `initialize_store` 返回结构化 `StoreIdentityOutcome::{Created, Existing}`,两者都带权威身份; + created 的唯一证据是**本事务在 `META_INSERT_INDEX` 上确切影响 1 行且批确认提交** + (`schema::inserted_meta_row` + `initialization_evidence` 纯判定),唯一键冲突与「批成功却无插入 + 证据」一律 `Existing` 并**读回**既有身份,结果未知(丢响应/超时/回滚失败)直接失败:不发身份、 + 不发创建事实。`CreatedByThisOpen` 只剩一个来源——`open_verdict(Created)`;「打开前读到空库」不再 + 参与推导。本机一侧未改:门禁仍只认 `CreatedByThisOpen`。 +- **证据**: + +| 类型 | 命中 | +| --- | --- | +| 离线新增(`remote/initialization_test.rs`,真 SQLite 引擎 + 真本机登记库,非桩) | 5 passed:竞争(胜者 `Created`/败方 `Existing` 且身份相同、败方零登记零资格)、已有身份不认领、未知结果不创建、读回拒绝不可解释身份、形状判定共用 | +| 离线反例(变异检验) | 把 `open_verdict` 改回「一律 `CreatedByThisOpen`」(等价修前行为):`only_the_winner_of_the_identity_race_may_register_first` → 1 failed | +| 离线全量 | `cargo test -p peri-resources --lib` → **324 passed / 0 failed / 28 ignored**;`cargo clippy -p peri-resources --all-targets` 零警告;`rustfmt --edition 2021 --check <本批 6 个文件>` 无差异(`registration.rs` 只同步一句门禁文档) | + +- **边界(诚实记录)**:**竞争判定本身只有本地 seam 证明**——`initialization_test.rs` 用真 SQLite + 引擎跑生产同一份初始化 SQL/读取计划,经生产同一条 `initialization_evidence → open_verdict`,本机侧 + 是真登记库;共享云 store 的身份不能重置(重建 `peri_store_meta` 即伪造历史),云端不重放竞争。 + 云侧只覆盖「再次初始化返回既有身份、不再发创建事实」这半边,第十八轮已真跑(见 9.22)。 + **首次创建的运行证据仍缺失**:尚未在空 Turso store 上观测 `Created` 与首次接纳;SDK 在该位置返回 + `rows_affected == 1` 的假设只有本地 seam 支撑。若实际返回 0/缺项,当前代码会保守判 `Existing`、 + 拒绝首次接纳,而不是错误发放资格;因此不能据这批补丁宣称新库首登已通过云验收。 + +### 9.21 Fable P1(第二半)关闭:child 父子关系「两处声明」只留一个真相(同日第十七轮) + +只处理 `save_child` 的父子/根归属输入一致性。**本条不含** close/连接重建/path/frozen/首次产品登记。 + +- **缺陷**:门面与两个 adapter 校验的是快照声明字段 `parent_id`/`root_id`,而**落库**用的是 + `target.meta.parent_thread_id`。给一个声明合法 parent/root、但 `target.meta.parent_thread_id = None` + 的 child,会被写成一条**没有父的独立 root**;此后这条 identity 还能自己取得执行权(Fable 复现路径)。 +- **修法**:`data::ensure_child_relation` 是唯一一条规则(不读存储、不看「库里有没有会话」): + `target.meta.parent_thread_id` 必须等于 `parent_id`,`parent_id`/`root_id` 都不得等于 + `target.thread_id`。门面在**任何副作用之前**调用它(不发门禁、不留未决证据),本机与远程 adapter + 各自再调一次作防御——三处同一份代码,不是三套规则。字段不合并(`parent_id` 是继承来源、`root_id` + 是执行域),一致性在入口强制。 +- **证据**: + +| 类型 | 命中 | +| --- | --- | +| 门面反例(真 SQLite,`resources_test.rs`) | 4 种不自洽(meta 无父 / meta 指向别的父 / 自指父 / 自指根)全部 `InvalidInput`,且零行、零绑定、零执行代际、无锚点、root 原样(树里只有自己、owner 照常可写);同一份合法快照随后仍成立,落库父 = 声明父,解析到的仍是 root 那条 owner | +| 数据面反例(`sqlite_store/session_data_test.rs`) | 不经门面直接调用:同样拒绝且零行;改回与 meta 一致后同一次保存才落库 | +| 远程入口反例(新增 `remote/session_child_guard_test.rs`,离线) | 输入不自洽 → `InvalidInput` 且本机收据表零行、未决查询为空(校验先于任何 I/O);自洽输入不被拦(继续走到连接,报 `Internal`) | +| 变异检验 | 逐个移除三处守卫:门面 + 本机 adapter 移除 ⇒ 门面反例在 `unwrap_err` 上失败(**修前确实保存成功**);单独移除本机 adapter ⇒ 数据面反例同样失败;单独移除远程守卫 ⇒ 远程反例拿到 `Internal`(它已去取连接) | +| 离线全量 | `cargo test -p peri-resources` → **327 passed / 0 failed / 28 ignored** + 契约 6 passed;`cargo clippy -p peri-resources -p peri-acp-types --all-targets -- -D warnings` 零警告;`rustfmt --edition 2021 --check`(本批 9 个文件)无差异;`cargo build --workspace` 通过 | + +- **边界(诚实记录)**:`save_child` 的**远端落库反例**(被拒后不落行、原 root 不变)仍只在离线层面 + 证明(校验先于 I/O、被拒零收据),远端父子列与 `target.meta` 同源由 `session_sql` 形状测试覆盖; + 近邻路径「远程 `create_session` 只接受 root」第十八轮已真跑(见 9.22)。E2E subagent 用例本批未跑。 +- **未关闭(不得标记完成)**:整体 Fable 仍 **FAIL**——**close、conn、path、frozen、首次产品登记** + 各自独立成批,本批一行未碰。 + +### 9.22 前两半收尾:真云半边实测与状态板(同日第十八轮) + +第十八轮仅做记录和云端验证;代码修复在 9.20/9.21 对应轮次落地,并随同一修复提交交付。该验证轮把前两节留在 `#[ignore]` 的云半边真跑, +并重跑离线全量确认最终状态。 + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources` | lib **327 passed / 0 failed / 28 ignored** + 契约 **6 passed** | +| `cargo test -p peri-resources --lib -- initialization_tests registration_tests child` | **39 passed / 0 failed / 1 ignored**(被忽略的是云用例 `cloud_pending_blocks_child_first_write`) | +| 真云 `cloud_mutation_committed_rows_are_visible_to_a_new_connection` | 1 passed:`initialize_verdict=existing`、`initialize_is_idempotent=true`、`cross_connection_read_after_write=true`(新连接读回同一条收据)、两连接同一 store 身份(`schema_version=1`) | +| 真云 `cloud_receipts_are_retained_with_measured_cost` | 1 passed:`retained_receipts=3`、`ledger_rows=2 ledger_bytes=454`、`recovery=Recovered`、`receipt_retention=ok` | +| 真云 `cloud_remote_create_refuses_parent_input` | 1 passed:带父输入 → `InvalidInput`("child sessions must be saved through the child path"),远端 `sessions=1`(只有 root 行)、`retained_receipts=2` | +| `cargo clippy -p peri-resources -p peri-acp-types --all-targets -- -D warnings` | 零警告 | +| `cargo check --workspace --all-targets` | 通过 | +| `rustfmt --edition 2021 --check --config skip_children=true`(前两批 15 个改动 .rs 文件) | 无差异 | + +- **服务端真实验证的部分**:再次初始化返回既有身份且不重铸身份、提交行对新连接可见、收据在清理与 + 重读后逐条逐字节保留、远程 `create_session` 的 root-only 拒绝(远端零 child 行)。 +- **只有本地 seam 的部分**:身份竞争本身(胜者 `Created`/败者 `Existing`、败者零登记零资格)与 + 「丢响应不得误认 created」——共享库身份不可重置(重建 `peri_store_meta` 即伪造历史),云端不重放 + 竞争;`save_child` 的远端零落行同样只有离线证明。 +- 云清理沿用前轮修正后的**共用实现**(`cloud_tests::cleanup_effects` / `with_cleanup`):只删本轮 run + 命名空间,ledger 收据有意保留(`retained_receipts` 为证),未复制任何旧清理器。 +- **状态板**:9.20、9.21 两条 P1 关闭(实证见上);**close、conn、path、frozen、首次产品登记仍 FAIL**, + 各自独立成批。整体 Fable 结论仍 **FAIL**。 + +### 9.23 path:部署定位类型区分(同日第十九轮) + +`SessionStoreDeployment` 的 locator 由 `Option` 改为中性枚举 `SessionStoreLocator`(`Default` / +`LocalPath(PathBuf)` / `Locator(String)`);`--db-path` 归一为 `LocalPath`,资源层按**类型**分派,不再把 +路径塞回字符串再解释。修掉两处同一成因的缺陷:`PathBuf::to_string_lossy` 让 Unix 非 UTF-8 文件名在 +往返中损坏(U+FFFD),以及 `--db-path <名字>` 里合法的 `env:` 字面文件名被当成环境引用解(缺变量时 +报配置错误、文件打不开)。 + +- 语义边界:`--db-path env:UNSET` 是名为 `env:UNSET` 的本机文件;`--session-store env:UNSET` 才解引用 + 环境变量。`LocalPath` 不解 `env:`、不判 URL(Windows drive/UNC 形状不按 scheme 猜),不经字符串往返。 +- 早失败:`LocalPath` 与 `Default` 一样拒绝远程专属参数(引擎名 → `EngineForLocalStore`;凭证来源 → + `CredentialSourceForLocalStore`),在入口转换处失败,不静默忽略;`Debug` 仍只给形态(新增 + ``),不回显路径/locator/凭证名。 +- 回归(新增,离线):`db_path_file_named_like_an_env_reference_opens_as_a_file`(真文件,旧实现报 + `EnvValueMissing`)、`db_path_keeps_non_utf8_bytes_end_to_end`(字节保真;macOS syscall 拒绝非 UTF-8 + 路径 EILSEQ、Linux 建库,两种平台都不得落到 lossy 变体)、`db_path_windows_shapes_skip_locator_parsing` + (形状解析,不要求本机是 Windows)、`env_colon_literal_is_a_file_name_for_db_path_but_a_reference_for_session_store`、 + `remote_only_parameters_on_confirmed_local_path_fail_early`。 +- 证据:`cargo test -p peri-acp-types --lib` 468 passed / 0 failed;`cargo test -p peri-resources --lib` + 351 passed / 0 failed / 28 ignored;`cargo test -p peri-tui --test meta_session_cli --test print_exit` + 19 + 9 passed;`cargo check -p peri-acp --all-targets` 通过;clippy(types/resources/tui all-targets) + 零警告;`rustfmt --edition 2021 --config skip_children=true` 本批文件无差异。 +- **既有失败(不得算作本批完成)**:`cargo test -p peri-tui --bin peri` → 84 passed / **1 failed**, + `cli_meta::tests::unwired_remote_store_reports_unavailable` 期望 exit 4 `store_unavailable`、实得 exit 1 + `internal_error`。因果:该用例只给远程 locator 与凭证**来源**、不设变量,`CredentialSource::resolve()` + 返回 `CredentialError::Missing`,而 `classify_open_failure` 只识别 `LocatorError`,遂归 `Internal`; + 与本批改动无关——`from_deployment` 的 `Locator` 分支调用与 HEAD 逐字相同,远程链一行未碰(判定依据: + diff 与静态调用等价;本批禁用 stash/checkout,未在 HEAD 工作树上实跑复核)。修法属另一批:要么测试 + 自带隔离变量,要么给 `classify_open_failure` 补 `CredentialError` 分类——需先定「凭证缺失」的 kind。 +- 仍 FAIL(各自独立成批):close / conn / frozen / 首次产品登记(首次云 `Created` 观测持续未完成); + prepared 冻结字节 seam(目标 2)本批未开工。 + +### 9.24 frozen:发布段只消费同一份准备输入(同日第二十轮) + +修掉目标 2 的「拿两次准备相等当同源」假证明:`handle_new` 拆为 resolve workspace → `prepare_new`(new 路径**唯一** +一次准备)→ `new_session_from_prepared`(发布段:写 meta/binding/frozen、取执行 owner、复核准入、装配环境、发布 +live 状态)。发布段接收调用方定格的准备对象,不读配置、不加载插件、不重建 frozen;生产与测试同调该函数,因此 +「保存字节 == 给定准备输入的字节」第一次成为可断言的性质。 + +- 回归重写:`new_session_persists_frozen_bytes_from_its_single_preparation`(端到端只走生产入口:持久化字节解出的 + `claude_md` 等于本次工作区输入、live frozen 重编码逐字节等于持久化字节、binding/owner 成立);新增 + `new_session_from_prepared_does_not_reread_external_frozen_inputs`(准备后改写 cwd/CLAUDE.md,再以该准备对象直投 + 发布段:保存字节精确等于给定字节、live frozen 逐字节同源、改写内容不得进入 live 状态;`rebuilt` 哨兵断言「外部 + 输入已变 → 二次准备必须给出不同字节」,使任何重读/重建都必失败)。 +- **判别力实证(变异测试)**:临时在发布段内再 `prepare_new` → 用例在「保存字节必须精确等于给定准备输入的字节」 + 处失败;回退后 7 passed。不加 `serial_test`、不重跑取绿、不放宽日期字段、不改 frozen envelope。 +- 删旧的 `new_session_persists_prepared_frozen_bytes_once`:其失败模式正是 §9.16 记录的并行 flaky(同一用例内两次 + 准备之间,并行用例可能改变进程级探测输入),且该断言从未证明是同一对象。 +- 证据:`cargo test -p peri-acp --lib prepared` 7 passed / 0 failed;`cargo test -p peri-acp --lib` **721 passed / + 0 failed / 0 ignored**(§9.16 记录的该模块全量并行失败已消失);`cargo clippy -p peri-acp --all-targets -- -D warnings` + 零告警;`rustfmt --edition 2021 --check --config skip_children=true` 本批文件无差异。 +- 仍 FAIL(各自独立成批):close / conn / 首次产品登记(首次云 `Created` 观测仍未完成,不得假称整体完成)。 + +### 9.25 收尾复核:路径不经 lossy 往返、两种定位入口语义分离、冻结字节同源(同日第二十一轮) + +本轮只做复核与证据固定,**无生产改动**。静态闸门:`peri-acp-types/src/session_store.rs` 与 +`peri-resources/src/sessions/open.rs` 内 `to_string_lossy` 零命中(全文检索);`from_deployment` 的 +`LocalPath` 分支直接 `StorageLocator::LocalPath(path.clone())`,`resolve_locator` 该变体直接 +`ResolvedLocator::Local(path.clone())`,全程无字符串往返。`SessionStoreDeployment` / +`SessionStoreLocator` **没有 serde 实现**(仅 `Clone/PartialEq/Eq` + 手写脱敏 `Debug`),因此不进入 +JSON-RPC wire;meta 的 JSON 输出只经 allowlist DTO(`json_success_is_one_exact_allowlisted_object` 通过)。 + +- 语义分离复核(真跑):`env_colon_literal_is_a_file_name_for_db_path_but_a_reference_for_session_store`、 + `db_path_file_named_like_an_env_reference_opens_as_a_file`(真文件)、`db_path_keeps_non_utf8_bytes_end_to_end`、 + `remote_only_parameters_on_confirmed_local_path_fail_early` 通过;CLI 侧 `test_db_path_conflicts_with_session_store_before_io`、 + `test_session_store_deployment_normalizes_locator_options`、`test_session_store_deployment_debug_keeps_locator_out` + 通过。 +- 证据(本轮实跑命中/退出):`cargo test -p peri-acp-types --lib session_store` 8 passed、全量 468 passed; + `cargo test -p peri-resources --lib sessions::open` 22 passed、全量 351 passed / 28 ignored; + `cargo test -p peri-resources --test session_resources_contract` 6 passed;`cargo test -p peri-acp --lib prepared` + 7 passed、全量 721 passed;`cargo test -p peri-tui --test meta_session_cli --test print_exit` 19 + 9 passed; + `cargo test -p peri-tui --bin peri session_store` 8 passed;`cargo check --workspace --all-targets` exit 0; + `cargo clippy`(types/resources/acp/tui all-targets,`-D warnings`)exit 0;本批文件按各自 crate edition 过 rustfmt + (`peri-tui` 2024、其余 workspace 2021;edition 不符会报 style-edition 假差异)无差异。 +- **既有失败(仍在,非本批引入;不得算作完成)**:`cargo test -p peri-tui --bin peri` → 84 passed / **1 failed**, + `cli_meta::tests::unwired_remote_store_reports_unavailable` 期望 exit 4、实得 exit 1。补强判定:`git show HEAD:` + 复核 HEAD 的 `from_deployment` 远程分支与当前逐字相同,`cli_meta.rs` 与 `classify_open_failure` 本批未改, + `cli_meta_test.rs` 唯一改动是构造器更名;且 `RemoteStoreNotWired` 已在 C 批删除(远程分支是真装配), + 「远程未接线」这个用例前提本身已过期——装配前先解析凭证值 → `CredentialError::Missing` 不在 + `classify_open_failure` 的识别集 → `Internal`。修法属另一批:先定「凭证缺失」的 kind(配置错误 exit 2 或 + 按新前提改写用例),不靠改测试掩盖。 +- 未验证 / 未完成(继续显式记账,不假称整体完成):首次产品登记仍 FAIL——首次云 `Created` **观测仍未完成**; + close / conn 仍 FAIL(各自独立成批)。首登产品边界不变:**仅本安装初始化的新库可获首次登记;已初始化但无本机 + registry 只能读**(未加 register/接管,不自动登记,不 seed 真实 registry)。Windows drive/UNC 仅形状断言 + (未在 Windows 实跑);macOS 非 UTF-8 路径按平台如实断言 EILSEQ(Linux 建库)。上述失败用例里「stderr 不回显 + locator 原文」的断言因其先断言 exit code 而未被执行,该路径脱敏本批未取得证据(脱敏另有 + `url_with_embedded_secret_is_rejected_without_echo`、`request_debug_keeps_host_and_credentials_out` 通过)。 + 本轮未跑云、未跑 LLM。 +- 父代理 commit 范围(本批 13 个文件):`peri-acp-types/src/session_store.rs`、`session_store_test.rs`、 + `peri-acp/src/host/requests/session_lifecycle.rs`、`peri-acp/src/host/prepared_test.rs`、 + `peri-resources/src/context.rs`、`peri-resources/src/sessions/open.rs`、`open_test.rs`、 + `remote/cloud_deployment_child_test.rs`、`peri-tui/src/main.rs`、`main_test.rs`、`cli_meta_test.rs`、 + `docs/code-index/peri-acp.md`、`docs/code-index/peri-resources.md`、本母 issue。**不属于本批**(勿一并提交): + `.github/workflows/ci.yml`、`CLAUDE.md`、`peri-cool`、`peri-middlewares/src/mcp/mod.rs` 及未跟踪的 + `peri-middlewares/src/mcp/builtin_spike_test.rs`。 + +### 9.26 缺凭证按类型归配置错误、meta 过期用例改写(同日第二十二轮) + +远程 locator 的凭证**来源**没配好时(变量未设置/空值/非 Unicode/名字非法、注入值为空),`CredentialError` +此前不在 `classify_open_failure` 的识别集(只认 `LocatorError`),被归 `Internal`(meta exit 1)。现在按 +**类型**分类:source chain 上出现 `CredentialError` → `StoreOpenFailure::NotConfigured`(meta exit 2 +`store_not_configured`),不解析错误文本;失败仍早于任何本机 I/O 与网络调用。`CredentialError` 保持 crate +内可见(`sessions` 的最小 `pub(crate)` re-export 仅供分类用),不进公共 API,不向 TUI 交底 SDK 类型或凭证 +值;`SessionStoreCredential` 仍无 `Debug`/序列化。未扩大其他错误分类与迁移。 + +- 过期用例改写(前提 `RemoteStoreNotWired` 已在 C 批删除,远程分支是真装配): + `cli_meta_test.rs::unwired_remote_store_reports_unavailable` → + `missing_credential_configuration_is_a_configuration_error`:显式不存在的变量(用例内移除并断言不存在, + 受控环境、不连网)+ 远程 locator 哨兵,断言 exit 2 / `store_not_configured`,locator 原文与凭证来源名 + 都不回显(meta 错误文案是固定串)。 +- 新增回归:`context_test.rs` 对 source chain 包装的五种 `CredentialError` 逐一断言 `NotConfigured` + (没有凭证原因的普通失败仍是 `Internal`,分类不被 context 放大);子进程 + 临时 HOME 真跑 + `Resources::open_deployment`:缺凭证 → `NotConfigured`,先断言 `local_registry_path()` == + `$HOME/.peri/threads/threads.db`(HOME 控制生效),再断言临时 HOME 目录项数 == 0(登记库/库/侧车零副作用)。 + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-tui --bin peri` | **85 passed / 0 failed**(此前的 `unwired_...` 失败随用例改写消失) | +| `cargo test -p peri-resources --lib` | 355 passed / 0 failed / 28 ignored | +| `cargo test -p peri-resources --lib context` / `sessions::open` | 21 / 22 passed | +| `cargo test -p peri-tui --test meta_session_cli` | 19 passed | +| `cargo clippy -p peri-resources -p peri-tui --all-targets -- -D warnings` | exit 0 | +| rustfmt(各 crate edition,`skip_children=true`) | 本批文件无差异 | + +- 现状:close / conn 已由本 HEAD 批(`b47e98f5` 等)修好,**待 Fable 复验**(不再记 FAIL);首次云 `Created` + 观测与 E 全链路消费侧仍未完成,各自独立成批。本批文件:`peri-resources/src/context.rs`、`context_test.rs`、 + `peri-resources/src/sessions/mod.rs`、`peri-tui/src/cli_meta_test.rs`、本母 issue。未读 `.env`、未用真实 + HOME(子进程临时 HOME)、未连网、未跑 LLM,无新增 `#[allow]`/`#[ignore]`。 + +### 9.27 v10 撤销后的真云回归收口:root-only 判定回家、删除表达两端统一(同日第二十三轮) + +v10 撤销(`120dc8cb`)拆掉本机远程痕迹表与整条登记/准入链之后,真云回归第一次整体重跑:21 通过、1 失败。 +失败的不是过期用例,是**真回归**;同轮把「一份删除逻辑跑两种执行器」的最后一处引擎依赖补齐。 + +**① 远程新建的 root-only 输入判定在撤销时丢了。** + +- 症状:带父的输入不再被拒绝,而是真的往远端写出一条父关系,随后本机准入报 `SavedButNotAdmitted`—— + 远端留下一条绕过 child 通路的会话行,调用方还拿到一个误导的错误分类(像是「本机没登记」,实际是 + 「这个输入不该走这条入口」)。绑定合法时准入会成功,那条没经过 `ensure_child_relation`、root owner + 门禁与 frozen 继承判定的会话就直接上线。 +- 根因:判定原先实现在被删的 `remote/local_execution.rs` 里;v10 把远程写路径内联进 `resources.rs` 时 + 只带了数据面调用,漏掉了这条输入判定。 +- 落点:判据回到**远程 adapter**(`remote/session_write.rs::write_new_session` 首行,构造语句与取连接之前): + `parent_thread_id.is_some()` → `InvalidInput`。**不放门面**:该规则按文档只约束远程入口,而「本机新建 + 接受带父的目标」是既有夹具的前提(`peri-middlewares` 的 `preset_resumable_thread(..., Some(parent))` + 等 13+ 处、`peri-agent` 的 `save_new_session`),上移会连坐本机模式。 +- 离线回归:`remote/session_child_guard_test.rs::test_remote_root_entry_rejects_a_parent_before_any_io`, + 用连接已关闭的 adapter 证明判定**先于任何远端读取**(带父 → `InvalidInput`;同一入口的 root 输入照常 + 走到存储路径 → `Internal`),与既有的 child 输入一致性用例同形。 +- 真云复核:`cloud_remote_create_refuses_parent_input` 复跑通过(`refused_class=InvalidInput`、`sessions=1`、 + `parent_input=refused`)。 + +**② 本机删除不再借 `ON DELETE CASCADE`:两端共用同一份删除语句。** + +本机三处删除 `threads` 行的路径(数据面 `delete_tree`、`revoke_unpublished_session`、迁移桥 +`SqliteThreadStore::delete_thread`)原先让 `messages`/`session_bindings` 靠级联消失,而远端 schema 不含任何 +`REFERENCES`,级联无从谈起(`PRAGMA foreign_keys` 在远端默认读 0、跨连接共享、无 `foreign_key_check` +等价物,见 §9.28 的例外 2/3/4)。现在三处都按**同一份语句**显式先删子行 +再删父行:`session_rows::THREAD_CHILD_DELETES` + `delete_thread_child_rows()`(`execution_runs` 沿用 v7 起 +就有的显式删除)。`ON DELETE CASCADE` 声明**保留**(删外键要重建实盘库的表,收益只是省几条 DELETE),但 +已退化为空操作式安全网——承重的是显式语句。 + +不变量由 `sqlite_store/thread_child_delete_test.rs`(4 项)守:期望集合从**运行库真实 schema** 派生 +(`sqlite_master` × `pragma_foreign_key_list`),与生产声明双向核对(新增一张 `REFERENCES threads` 的子表 +必然变红);三条生产删除路径都跑在 `PRAGMA foreign_keys = OFF` 的池上(夹具自证读数为 0),因此「删完没有 +孤儿行」只可能由显式删除满足——在 `foreign_keys = ON` 的本机上,把显式删除删掉是**不会红**的。四个测试都 +实测能变红(逐个注释掉显式删除 → 各自红;临时插一张子表 → 交叉核对红)。 + +**③ 「本地不留任何 store 痕迹」在真云端到端里被断言。** + +`cloud_deployment_test.rs` 的父测试原先只核对云端计数与本机库**文件存在**。现在每个阶段的新连接核对之后, +再用只读连接盘点本机执行面库(真实落盘文件,不是门面自陈): + +- schema 版本与表集合必须与**同一构建在本机模式下新建的库**逐表相同(期望值现场派生,不另抄名单); +- v10 删掉的五张本机远程表不得回归(名单从 `sqlite_store/schema.rs` 的 `DROPPED_LOCAL_TABLES` 原文派生); +- `threads`/`messages`/`session_bindings` 全为 0,`projects`/`workspaces` > 0(workspace 证据是授权的本机事实); +- 本轮 run 的执行代际必须精确等于预期:写入后 root 与 fork 两条 `clean = 0`(child 由 root 的租约持有, + 自己不落代际)、冷恢复删掉 root 树后只剩 fork、只读打开之后库内容逐项不变。 + +同时把子进程与父测试共用的三个会话后缀提为常量(`ROOT_SUFFIX`/`CHILD_SUFFIX`/`FORK_SUFFIX`),「哪条会话 +有本机执行事实」不再在两处各写一份。 + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | 330 passed / 0 failed / 22 ignored | +| `cargo test -p peri-resources --lib -- --ignored --nocapture --test-threads=1`(真云全量,隔离单跑) | **22 passed / 0 failed**(140.22s;含此前转红的 `cloud_remote_create_refuses_parent_input`) | +| `cargo clippy -p peri-resources --all-targets -- -D warnings` | exit 0 | +| `cargo fmt --check` | 本批文件无差异 | + +- 并发边界(实测):这两条真云用例**不是并发安全的**——一轮全量门在另一个测试进程同时跑同一个 + `cloud_deployment_` 用例(对方正在做故障演示、临时改坏断言)时红过一次;同一份最终代码隔离单跑 + 22/22。真云套件按既有约定串行单跑(`--test-threads=1`,且同一时刻只有一个进程),共享库上的 + 合成 run 命名空间隔离的是**数据**,隔离不了「对同一份源码快照的并发假设」。 + +- 边界:本地 schema 的 `ON DELETE CASCADE` 声明与 v10 的 `DROPPED_LOCAL_TABLES` 都不动(前者删外键要重建 + 实盘库;后者是回退迁移的删除清单)。同类「本机靠引擎特性、远端没有」的潜在分歧已记录、本轮未处理: + 唯一性冲突语义(本机靠主键冲突、远端靠批内守卫)、`session_bindings` 的复合外键 `(workspace_id, + project_id) REFERENCES workspaces`(远端无外键,绑定字节只是列)、列级 `NOT NULL`/`UNIQUE` 的逐列等价性。 +- 本批文件:`peri-resources/src/sessions/remote/{session_write.rs,session_child_guard_test.rs,mod.rs, + cloud_deployment_test.rs,cloud_deployment_child_test.rs}`、`peri-resources/src/sessions/sqlite_store + /{session_rows.rs,session_data.rs}`、`peri-resources/src/sessions/sqlite_store.rs`、 + `peri-resources/src/sessions/sqlite_store/thread_child_delete_test.rs`(新)、`docs/code-index/peri-resources.md`、 + 本母 issue。真云测试用既有 `.env` 凭证(测试进程内解析,未进 shell 环境、未打印)。 + +### 9.28 传输面 49 项实测的结论沉淀(2026-09-27;探测装置已删除) + +「一份 schema、两种执行器」的可行性原先由一套一次性探测装置给出(`remote/cloud_transport_probe_test.rs`, +49 项 + 中断清道夫 + 脱敏守护,在授权测试库上建 `peri_probe_%` 合成对象并逐轮清空)。**该文件已删除**: +它是一次性测量装置、不对应任何生产代码,留在仓库里只会被当成要维护的契约测试。结论沉淀在这里。 + +**可用(实测通过,可作为统一 schema 的表达基础)**:`CREATE TABLE` / `CREATE INDEX` / partial index(真 +按 `WHERE` 存)/ `ALTER TABLE ADD COLUMN` / `ALTER TABLE RENAME TO` / `DROP TABLE`(连带索引);托管批内 +DDL 可用且**失败整批回滚**;隐式 rowid 可投影、按插入序、`WHERE rowid > ?` 可用、跨连接稳定;upsert +(`ON CONFLICT … DO UPDATE`)、`UPDATE`/`DELETE … WHERE` 的受影响行数、`RETURNING`、`COUNT`/`GROUP BY`/ +子查询;`sqlite_master`/`sqlite_schema`、`pragma_table_info(?1)`(含绑定参数形态)、`PRAGMA table_info`/ +`index_list`;TEXT 往返**字节相等**(ascii/cjk/emoji/控制符/**NUL**/转义/SQL 元字符/64 KiB 共 8 档)。 + +**必须写明的例外(5 条,全部实测为不支持或行为不同)**: + +| # | 例外 | 对「一份 schema 两种执行器」的约束 | +| --- | --- | --- | +| 1 | **多语句序列不原子**:同一请求里顺序下发的语句,失败前的部分**留在库里**(对照:托管批失败整批回滚) | 需要原子性的初始化只能走托管批(`apply_schema` 就是这么走的),不能靠「一次请求里多写几条」 | +| 2 | **`PRAGMA foreign_keys` 连接建立时读数为 0**(库未被别的客户端设置过时),违例子行**被接受** | 远端默认**不强制外键**;写入正确性不能建立在外键上 | +| 3 | **没有 `pragma_foreign_key_check` 等价物**(块形式报 0 行、表值形式 `no such table`) | 本机迁移里的「先校验再收尾」闸门在远端没有对应物,远端 schema 变更要自己造校验 | +| 4 | **`PRAGMA foreign_keys` 是跨连接共享的可变状态**:另一条连接写 `OFF` 之后,本连接“立刻”读到 0 | 同一库的写入者之间**没有隔离**;外键开关不能当作连接级配置来依赖(阻塞级例外) | +| 5 | **`PRAGMA user_version` 只能读不能写**(写被服务端拒:`SQL not allowed statement`) | 本机迁移收尾那句在远端无效;`remote/schema.rs` 已改用 `peri_store_meta` 单行承载版本与身份 | + +**其它实测事实(信息级,供后续判断)**:建表与删表权限不分层(同一凭证可建可删);`PRAGMA foreign_keys += ON` 写生效、行为立即改变(所以「建连接后显式打开」这条路存在,只是它是共享状态、不是安全边界); +托管批内自带 `BEGIN`/`COMMIT` 被**驱动**在发出 HTTP 之前拒绝(misuse),序列内自带事务控制则被服务端 +接受;`PRAGMA user_version`/`foreign_keys` 之外未测 `journal_mode`/`busy_timeout`/`defer_foreign_keys` 等; +限额只探到下限——2000 条语句(约 17 万字节)、4 MiB 单行、2000 行读取均未被拒,**上限位置未定位**, +驱动(reqwest)不设超时,服务端何时掐断在途请求不可判定;服务端错误文本带 `Tursodb error:` 前缀,与 +§9.1 记录的引擎身份一致。 + +设计侧的落点已经落进代码:删除路径两端共用同一份**显式**语句(`session_rows::THREAD_CHILD_DELETES`, +见 §9.27②,例外 2/3/4 是它的依据);远端身份与版本走 `peri_store_meta`(例外 5);初始化走托管批 +(例外 1)。上表在后续统一 schema 的工作里是**输入**,不是待办。 diff --git a/spec/issues/2026-09-26-session-store-sub-plan-a-contracts.md b/spec/issues/2026-09-26-session-store-sub-plan-a-contracts.md new file mode 100644 index 000000000..8382b81b5 --- /dev/null +++ b/spec/issues/2026-09-26-session-store-sub-plan-a-contracts.md @@ -0,0 +1,234 @@ +# 会话资源拆分 — 子计划 A:行为契约与纯逻辑 + +> 状态:**本计划的行为契约与纯逻辑已落代码**(2026-09-26;落盘清单与验证证据见[母需求](2026-09-26-session-store-remote-backend.md)「A 阶段实施进度」段);消费侧字段迁移(E)、`SessionResourcesImpl`(B)与 adapter(C)尚未实施。日期:2026-09-26。 +> 上级:[总计划](2026-09-26-session-store-plan.md)。母需求:[issue](2026-09-26-session-store-remote-backend.md)。本文件是分计划间行为接口和结果语义的唯一计划事实源;底层机制见 B/C,调用顺序见 E。 + +## 1. 目标与代码事实 + +当前 `peri-acp-types/src/store.rs::ThreadStore` 混合数据、workspace、owner/dirty 与缓存方法。`peri-resources/src/sessions/sqlite_store.rs` 是 pool owner 和该 trait 的实现,生产消费大多持 `Arc`。因此既不能只重命名 trait,也无需新增全套资源框架。 + +目标是:业务侧通过一个会话资源门面获得稳定行为;内部本机执行与数据存取分别实现。adapter 的接口同样是完整行为,不提供通用数据库操作。 + +## 2. 模块与类型归属 + +以下名称为计划固定术语,后续实现若改名需同步本表;不是已存在 API。 + +| 内容 | 计划位置 | 可见性/责任 | +| --- | --- | --- | +| `SessionResources` trait | `peri-acp-types/src/session_resources.rs`(拟新增) | 消费侧统一门面,`Arc` 注入 | +| 行为输入输出、`SessionResourceError` | 上述模块及必要的相邻子模块 | 中性领域类型,不依赖 SDK/资源实现 | +| `SessionDataPort` | `peri-resources/src/sessions/data.rs`(拟新增) | 资源模块内部行为 seam;两 adapter 实现,不给业务裸写句柄 | +| `LocalExecution` | `peri-resources/src/sessions/execution/`(拟拆分) | 本机发现、registry、root owner、写入准入与排空 | +| `SessionResourcesImpl` | `peri-resources/src/sessions/resources.rs`(拟新增) | 门面实现,组合两面,统一写入授权及恢复门禁 | +| payload/flags/inherited 编码 | 现有 `peri-acp-types/src/store.rs` | 保持格式,旧混合 trait 最终删除;按体量分文件,不把数据类型删掉 | +| 纯历史变换 | `peri-acp-types/src/store/history.rs`(拟新增) | 显式输入输出;不读取时钟、生成 UUID、访问环境或数据库 | +| frozen 构建/编码 | 现有 ACP `session/{frozen,frozen_snapshot}.rs` | 不为存储反向依赖 ACP;adapter 保存版本化 opaque snapshot,领域构建/解码保持原 owner | + +不新增 crate;不建立 generic repository、UnitOfWork、transaction DSL、mutations 数组或 `execute_plan`。业务侧也不手工拿 data+execution 两个句柄拼装。 + +## 2.1 最终公共类型与字段(review-2 闭合) + +以下为**最终类型**;实施时不得再引入第二个门面、第二个存储句柄或按后端分支的业务字段。名称为计划固定术语,改名需同步本表。 + +### 门面与装配链(唯一句柄) + +| 位置 | 最终类型 | 说明 | +| --- | --- | --- | +| `peri-acp-types::session_resources::SessionResources` | `pub trait SessionResources: Send + Sync` | 唯一跨 crate 存储契约,取代 `store::ThreadStore` | +| `peri-resources::Resources` | 字段 `session_resources: Arc`(原 `thread_store`) | `Resources::open/open_with` 签名不变;访问器 `session_resources()` 取代 `thread_store()` | +| `peri-agent::resources` | `open_session_resources{,_with}(…) -> anyhow::Result>` | 取代 `open_thread_store*`;仍只做声明边转发 | +| `peri-acp::host::assemble::HostAssemblyInput` | `session_resources: Arc`(替换 `thread_store`) | provider/peri_config/config_source/permission_mode/cwd/bare/drive_cron_tick 不变 | +| `peri-acp::host::AcpServerConfig` | **删除** `thread_store` 字段;保留 `controller` 与 `session_manager` | 存储只经 `cfg.controller.sessions()` 取得,不再有第二条等价路径 | +| `peri-controller::Controller` | `sessions: Arc`;`Controller::new(Arc)`;`pub fn sessions(&self) -> Arc` | **名称保留**(review-2 明确允许),只改返回类型 | +| `peri-acp::session::SessionManager` | 持有装配传入的同一 `Arc` | 不新建连接、不另存注册表 | +| `peri-acp::session::command::CommandContext` | 字段 `thread_store: Option>` → `session_resources: Option>` | 实体定义在 `peri-acp-types::command::CommandContext`(`peri-acp-types/src/command.rs:79,111`;`peri-acp/src/session/command/mod.rs:36` 再导出),逐引用替换,不保留同义双字段 | +| `peri-agent/src/session/subagent/types.rs::SubagentHost` | 字段 `thread_store: Option>`(`types.rs:100`)→ `session_resources: Option>` | 同文件 `SubagentSpawnConfig.thread_store`(`types.rs:183`)与 `SubagentResumeConfig.thread_store`(`types.rs:388`,必填)同批替换,按 E-08 逐引用迁移,不保留双字段 | +| `peri-middlewares/src/subagent/tool/{spawn_context,configuration}.rs` | `spawn_context.rs:155` 的 `thread_store: Arc` 字段与 `configuration.rs:121` 的 `with_thread_store(Arc)`(`configuration.rs:122` 写 `host.thread_store`)→ 字段/构造器更名为 `session_resources`/`with_session_resources`,取 `Arc` | 禁止再经 `peri_agent::thread::ThreadStore` 传递,否则旧符号无法删除 | +| `peri-tui/src/app/service_registry.rs::Services` | 字段 `thread_store: Arc`(`service_registry.rs:82`)→ `session_resources: Arc` | TUI 只消费 D 的统一选择结果,不持有具体 adapter | +| `peri_agent::session::transcript::MessageTranscript` | `store: Option>` | writer 仍只有一个(§E) | +| `peri_agent::session::subagent::factory::claim::ResumeClaim` | `acquire(Arc, …)` | 领域语义改为 `claim_child_resume` | +| `peri-acp::host::SessionState` | `execution_owner: Option>` **不变** | 公共执行能力,不是事务句柄 | +| `peri_agent::thread` / `peri_tui::thread` re-export | 生产路径删除 `ThreadStore`/`SqliteThreadStore`/`FilesystemThreadStore`/`open_thread_store_read_only` 导出 | 测试经明确测试支持路径;见 §7.1 | + +`Arc` 是唯一跨层句柄:任何层都不得同时持有门面与数据端口、也不得从门面取回裸 adapter。 + +### 领域输入/输出字段 + +| 类型 | 字段 | 约束 | +| --- | --- | --- | +| `NewSession` | `thread_id: ThreadId`; `created_at: String`(RFC3339,构建时一次); `meta: NewSessionMeta`; `binding: SessionBinding`; `frozen: FrozenSnapshotBytes` | UUID/时钟一次生成,重试不重建;`FrozenSnapshotBytes` 是版本化 opaque 字节,沿用现有 JSON envelope | +| `NewSessionMeta` | `title: Option`; `cwd: String`; `parent_thread_id: Option`; `hidden: bool`; `cancel_policy: CancelPolicy`; `snapshot_at_message_id: Option` | 不携带 `message_count`/`updated_at`/`cached_context`/`context_cache_epoch`(由行为维护) | +| `ForkSnapshot` | `target: NewSession`; `source_id: ThreadId`; `payloads: Vec`; `flags: HashMap` | 两者已完成 ID 重映射(§6 纯函数);source 只读 | +| `ChildSnapshot` | `target: NewSession`; `parent_id: ThreadId`; `root_id: ThreadId`; `inherited: InheritedContext` | frozen 由不可变 parent/root 来源解析并复制原字节,不重新扫描目录 | +| `SessionSnapshot` | `meta: ThreadMeta`; `binding: BindingState`; `frozen: FrozenState`; `payloads: Vec`; `flags: HashMap`; `inherited: InheritedContext` | 一次一致读取;不含 pool、缓存 epoch、事务状态或执行 handle | +| `CompactionChange` | 采用现有 `CompactionLifecycle` 的领域含义:`flag_updates: Vec<(MessageId, MessageFlags)>` + `appended_messages: Vec` | 改名去掉“调用方管理提交”的机制命名 | +| `RewindBoundary` | `KeepThrough(MessageId)` / `RemoveFrom(MessageId)` | 区分 transcript 保留目标与用户 rewind 移除目标 | +| `SessionMetaPatch` | `title: Option>`; `status: Option`; `cancel_policy: Option`; `config: Option<…>` | 定向更新,禁止整份 `ThreadMeta` 覆盖 | +| `SessionStoreId` / `HostInstallationId` | newtype over `String` | 只出现在资源层与持久化记录;不进业务 DTO、不进 ACP wire | +| `PreparedSessionInputs` | 见 [E §3.2](2026-09-26-session-store-sub-plan-e-consumers.md) | ACP 内部类型,不是数据端口类型 | + +只读入口的返回必须能区分 `BindingState::{Bound, LegacyConfirmed, ExternalOrUnregistered, Missing}` 与 `FrozenState::{Present(…), LegacyAbsent, Unsupported}`;`Option` 的 `None` 不再同时表达 legacy、不支持和损坏。 + +## 3. 数据与访问视图 + +### 3.1 复用与新增领域输入 + +继续使用 `ThreadId`、`MessageId`、`PersistedPayload`、`MessageFlags`、`InheritedContext`、`SessionBinding`、`ScopedThreadQuery/Page` 等现有类型。 + +拟新增: + +- `NewSession`:固定 thread identity/创建时间、初始 metadata、不可变 binding、完整 frozen snapshot;UUID/时钟在构建层产生一次,不在自动重试时重建。 +- `ForkSnapshot`:目标身份、source 身份/确定截止点、由 source 精确复制的 binding/frozen(装在 `target: NewSession` 内)、重映射后的 own payload/flags。不是远端让 source 再执行一次 fork 算法。字段以 §2.1 表为准。 +- `ChildSnapshot`:parent/root 归属、继承 payload/flags、binding;冻结来源由不可变 parent/root 关系解析,数据行为校验根 frozen 存在且有效存储,必要复制其原始字节,禁止重新扫描目录。Agent 不编码 ACP frozen,不把父历史当前 flags 当继承快照。 +- `SessionSnapshot`:metadata、binding 分类、frozen 状态、own payload/flags、inherited 的一致视图;不含 pool、缓存 epoch、事务状态或执行 handle。 +- `CompactionChange`:采用现有 `CompactionLifecycle` 的领域含义(flags 更新与摘要追加),移除“调用方管理提交”的机制命名。 +- `RewindBoundary::{KeepThrough,RemoveFrom}`:显式区分保留目标与移除目标。现有 transcript rewind 保留目标,而 ACP 用户 rewind 会移除目标及以后,禁止合并时失真。 +- 定向 metadata 输入:标题、status、已有实际需要的 config/cancel policy 等;不允许借整份 `ThreadMeta` 修改 cwd、binding、计数、缓存或父子身份。 + +frozen 用什么 Rust wrapper 不改变现有 JSON envelope;格式升级不在范围。小型 metadata/summary 读取不能为了共用 `SessionSnapshot` 加载整份历史。 + +### 3.2 缺失必须有语义 + +不得再以 `Option` 的 None 同时表达 legacy、不支持和损坏。区分本机确认的 legacy、已绑定、外来/本机登记缺失、数据损坏、版本不支持;后两种是错误。本机 legacy 是来源和记录状态联合判定,Turso 首期不启用 legacy 自动接纳。 + +只读历史允许 binding 的本机位置不可用;可执行恢复要求有效本机登记。frozen 缺失仅在已有 legacy 规则允许时补齐,不能把不支持读快照当作缺失。 + +## 4. 行为接口清单 + +以下是行为粒度与后置条件,不要求每行必须独立成 trait;实现可合并同义读入口,但不能遗漏生产场景。 + +| 门面行为(拟名) | 输入/输出 | 数据端对应行为与保证 | +| --- | --- | --- | +| `inspect_availability` | 行为能力、访问模式、执行可用性 | 不暴露数据库 transaction/CAS 能力 | +| `resolve_workspace` / `validate_session` | cwd 或 session identity → 已验证 workspace | 本机执行负责;数据端只读取持久化事实 | +| `create_session` | `NewSession` → 会话身份与 root owner | `save_new_session` 完整保存 meta/binding/frozen;owner 成功后才返回执行准入 | +| `abandon_initialization` | 未发布会话 identity、有效 owner 与已排空执行资源的上下文 → 已撤销或明确阻塞 | 门面内部撤销本次初始化数据/登记;只针对本次未发布创建,不是通用 rollback,不修改既有 source 会话 | +| `adopt_legacy_session` | 已确认 legacy、保存 cwd 和候选冻结输入 → 权威恢复事实 | 接纳结果整体成立,竞争时返回胜者事实,不返回 CAS bool | +| `load_session_snapshot` | identity → `SessionSnapshot` | payload/flags/frozen/inherited 一致读取,不让调用方拼多次跨时刻查询 | +| `load_session_meta` / `list_sessions` | ID 或 scope/cursor/limit → 小型投影 | 不加载历史/大快照;列表数据端过滤 | +| `list_children` / `list_session_tree` | parent/root → 所需树关系与 metadata | 为现有子 Agent 查询保留,不滥用完整历史 | +| `append_history` | thread + canonical payload 批次 | 稳定顺序、计数和自动标题一致维护;相同 ID 的冲突不可静默忽略 | +| `save_fork` | `ForkSnapshot` → 新根身份和 owner | 完整目标快照保存,source 未改变 | +| `save_child` | `ChildSnapshot` → 新 child identity | 继承区和父子关系一起成立;使用已存在根 owner | +| `claim_child_resume` | child/root identity → 权威 metadata 与领域认领 handle | 在有效根 owner 下串行认领;handle 接收开始运行/移交后台/准备失败/终止等领域结果,内部保存 active/原状态/终态,调用方不拼补偿写入 | +| `apply_compaction` | thread + `CompactionChange` | 摘要/flags/计数/缓存视图全部生效或不生效 | +| `apply_message_projections` | thread + flags 变更集 | Micro/投影更新整体维护缓存,不让 writer 逐条写后另 invalidation | +| `rewind_history` / `remove_history_entries` | thread + 显式边界/ID 集合 | 只改目标历史,派生计数/缓存同步 | +| `delete_session_tree` | 目标 identity | 数据删除一致完成;执行关闭和 pending 证据不被 cascade 提前抹掉 | +| `rename_session` / `set_status` 等定向更新 | identity + 领域字段 | 不整份覆盖 metadata,避免并发丢更新 | +| `acquire_execution` / `reset_dirty_execution` | identity / 精确代际 | 本机 owner 领域行为;先排除持久化未知,再遵守现有 dirty 风险确认 | +| `recover_session_persistence` | identity → 可重载或仍阻塞 | adapter 内部收敛,不让调用方提供 operation token | +| 持久化排空/部署关闭 | 资源 owner 发起,业务方仅等待所需完成 | 保留唯一关闭权,不以 Drop 或 enqueue 成功充当保存成功 | + +`SessionExecutionLease` 是执行能力,不是数据库锁参数。保留其领域职责;普通数据方法内部由门面检查本 root 有效 owner。只读句柄不能靠创建一个 unbound thread 绕过授权。 + +`SessionExecutionLease` 的最终公共面仍是 `fn thread_id(&self) -> &ThreadId` + `async fn mark_clean(&self) -> Result<()>` 两项,不含事务、代际参数、CAS 或重试令牌。未决持久化是**内部**前置条件:`mark_clean`、执行准入(`acquire_execution`)、`reset_dirty_execution`、`delete_session_tree`、`save_fork` 与 `create_session` 都先在门面内部检查本 root 是否存在未决持久化,存在即返回 `PersistenceUncertain`,不靠调用方先查。`reset_dirty_execution` 只解除本机 dirty 代际,永远不解除未决持久化,也不接受把普通 dirty 自动映射成 reset。 + +## 5. 结果、错误与能力 + +### 5.1 结果语义 + +- 正常成功:领域后置条件已满足,包括存储可恢复性;之后的调用不需补做 flags/cache/补偿。 +- 确定未生效:输入无效、行为不支持、权限拒绝或已证实无保存结果等。 +- `PersistenceUncertain`:无法证明生效与否,携带安全 session/root 关联,不含 SQL、token、事务 ID。门面使热态失效并阻塞续写/clean。 +- 「数据已完整保存,但执行准入失败」单独表达,包含可定位的 session identity;不得包装成确定未创建。完整数据的撤销若属于新建失败策略,由门面内部承担并报告是否完成。 + +错误原因和效果确定性分别建模,不从 `Timeout`/`Unavailable` 自动推导未生效;实现用结构化枚举避免互相矛盾的布尔组合。仅保留调用方确实需要分支的结果,不输出内部恢复状态机。 + +### 5.2 能力与权限 + +行为能力描述「能否安全完成 compact/派生快照/rewind 等」;凡声明支持都必须满足完整后置条件,不允许 `supports_transactions` 或 `supports_cas`。 + +访问模式为读取能力/写权限事实,不把只写后端用于可执行恢复。执行资格由本机准入另判。静态能力检查不替代实际操作错误;权限动态变化和网络失败仍返回明确结果。 + +`WorkspaceError` 保留本机语义;读取失败不再一律包装为 workspace 不可用。旧 ACP/CLI 映射在 E/D 收敛,业务不识别 SQLx/Turso 错误字符串。 + +### 5.3 三个独立枚举(review-2 闭合) + +```rust +/// 本次打开实际取得的读写权限(配置/授权事实,不推导数据能力) +pub enum AccessMode { ReadWrite, ReadOnly } + +/// 后端能安全完成的会话行为面 +pub enum DataCapabilities { Complete, HistoryReadOnly } + +/// 本机执行资格(与数据能力、与访问模式都独立) +pub enum ExecutionAvailability { + Available, + NoLocalRegistration, + BindingMissing, + WorkspaceUnavailable, + OwnedElsewhere, + Dirty(RecoveryRequiredDetails), + PersistencePending, + ReadOnlyStore, + Unsupported, +} +``` + +不变量: + +- 三者互不推导。`AccessMode::ReadOnly` 不蕴含 `HistoryReadOnly`(远程只读授权下数据能力仍可为 `Complete`,只是本次不允许写);`DataCapabilities::Complete` 不蕴含可执行(缺本机登记/binding 时 `ExecutionAvailability` 仍拒绝)。 +- `DataCapabilities::Complete` 表示 §4 全部行为都满足完整后置条件;`HistoryReadOnly` 下所有 mutation 在副作用前返回 `Unsupported`。凡声明 `Complete` 的 adapter 不得对任何行为退化为 no-op。 +- `ExecutionAvailability::PersistencePending`(C 的未决写)与 `Dirty`(本机执行代际)是两个不同事实,映射到不同 wire 结果,禁止互相代替。 +- 显式只读(`AccessMode::ReadOnly`)不初始化 schema、不创建 StoreId、不写本机登记、不取得 owner,也不创建任何本机文件/目录。 + +### 5.4 写入三态与 guard 语义(跨 B/C 统一) + +```rust +pub enum MutationOutcome { + Applied, // 领域后置条件已满足,含存储可恢复性 + NotApplied(NotAppliedReason), // 已证明无效果:输入无效/不支持/权限拒绝/发送前失败/引擎明确拒绝 + Unknown(UnknownReason), // 未证明:超时、断连、取消、提交后丢响应、部分结果 +} +``` + +- 只有 `Applied | NotApplied` 允许释放写入准入(`ExecutionWriteGuard` 的 finish);`Unknown` 必须留下未决证据并阻塞同根后续写入与 clean。 +- `NotApplied` 必须来自实际证据(本地事务未开始、引擎明确拒绝、发送前失败),不得由 `Timeout`/`Unavailable`/取消自动推导;一次空查询或一次超时都不是证据。 +- 门面按三态决定热态失效、可否 clean、可否放行新 owner;内部状态机不外泄。 + +## 6. 纯化范围与后置验证 + +- 提取 `session_fork.rs` 的 payload/flags 映射,参数提供新旧 MessageId 映射;UUID 生成留调用层。 +- 提取 transcript 的 compact 引用合法性:消息存在、不修改 ancestor、追加 ID 不冲突。adapter 同时基于权威数据检查归属,不能只信纯函数对热态的验证。 +- 复用 `InheritedContext` 的版本及引用完整性校验,纯裁剪传入截止点。 +- 提取 rewind 边界与 canonical payload 裁剪;不在此改文件恢复算法。 +- frozen/config 构建包含环境读取,不宣称是纯函数;只将确定性转换与 I/O 分层,具体见 E。 + +不为了“纯”复制一套消息模型,不从 adapter 反向调用 compact planner,不改变已有算法结果。 + +## 7. 旧方法处置 + +| 旧方法族 | 处置 | +| --- | --- | +| `create_thread`、`create_bound_thread`、独立 frozen/inherited 写入 | 生产创建迁到完整 new/fork/child/legacy 行为;测试替身显式实现所需能力 | +| `append_messages`、`append_message`、`append_payloads` | canonical payload 作为存储事实,便利包装保证语义等价即可保留 | +| `load_messages`、`load_payloads`、`load_context*`、flags 读取 | 全历史恢复走一致 snapshot;消息视图从 snapshot 派生,保留必要轻量查询 | +| `update_meta` | 改定向更新,逐个核对实际字段消费者 | +| `update_message_flags` | 由完整 projection/compact 行为替代逐项持久化编排 | +| `invalidate_context_cache`、`get_context_cache_epoch` | 不再是消费侧存储步骤,内部派生维护 | +| `supports_compaction_lifecycle` | 改领域行为能力判定 | +| 默认 no-op/假缺失 | 删除该兼容语义,未支持显式报错或类型上不提供;便利函数不一刀切删除 | + +最终生产不得同时存在旧 trait 和新门面两条可独立写入的路径。短期兼容 wrapper 只能委托新门面,设置迁移退出清单,不作为完成态。 + +### 7.1 旧 API 退出以编译为证据(review-2 闭合) + +退出不是“grep 不到即可”,而是旧符号在类型上不可达: + +1. `peri-acp-types::store::ThreadStore`(trait)与 `store_frozen_snapshot_if_absent` 等默认体随 trait 一起**删除**;数据类型(`PersistedPayload`/`MessageFlags`/`InheritedContext`/`CompactionLifecycle`/序列化 helper)保留在 `peri-acp-types`,只是不再挂在 trait 上。 +2. `SqliteThreadStore`/`FilesystemThreadStore` 不再从 `peri_agent::thread`、`peri_tui::thread` 生产路径导出;`peri-resources` 只公开返回 `Arc` 的构造点。 +3. 证据 = 符号不存在 + `cargo build --workspace --all-targets`、`cargo clippy --workspace --all-targets -- -D warnings` 通过;任何残留引用都直接编译失败。grep 仅用于**发现遗漏**,不作为通过证据。 +4. 测试替身与故障注入:实现 `SessionResources` 的测试替身必须在自身声明 `DataCapabilities`(例如 `HistoryReadOnly`),不得默认继承完整能力。peri-resources 内部的私有传输 seam 只经 `#[cfg(any(test, feature = "test-support"))]` 暴露,该 feature 不被任何生产二进制启用(F 记录启用方式)。 +5. 不为“编译失败测试”引入 `trybuild` 等新依赖;退出证明由符号删除 + 全 target 编译承担。 +6. `FilesystemThreadStore` 是测试替身、不是完整 adapter 契约实现:迁移后声明 `DataCapabilities::HistoryReadOnly`,未支持的行为必须**显式失败**。已核实其现状(`peri-resources/src/sessions/filesystem.rs:452-463` 的 `update_message_flags` 是静默 no-op、`commit_compaction_lifecycle` 直接 bail),该 no-op 一并删除;需要 flags/compact 语义的测试改用 SQLite 实现。生产路径不得构造测试替身,`DataCapabilities::Complete` 也不得由测试替身声明。 + +## 8. 任务、测试与完成条件 + +- A-01:按第 4/7 节列全生产调用行为,核对真实缺失项,不先建大量抽象。 +- A-02:定义门面、领域数据、效果/错误/能力分类和内部端口;过编译迁移时不靠新增默认成功兜底。 +- A-03:提取纯变换并添加显式输入输出测试;消息/frozen 格式不变。 +- A-04:与 B/C/E 检查创建、执行准入、恢复和关闭边界;没有事务机制跨端口。 +- A-05:完成旧方法映射,更新契约注释及后续 doc tests 路由。 + +验证由 F 的 V-01/02/06/12 覆盖;实施前 A 的接口与结果语义评审通过才允许 B/C 扩散。类型/方法的变更必须同步各子计划,不允许由某个 adapter 的 SDK 决定公共接口。 diff --git a/spec/issues/2026-09-26-session-store-sub-plan-b-local.md b/spec/issues/2026-09-26-session-store-sub-plan-b-local.md new file mode 100644 index 000000000..b31f79984 --- /dev/null +++ b/spec/issues/2026-09-26-session-store-sub-plan-b-local.md @@ -0,0 +1,254 @@ +# 会话资源拆分 — 子计划 B:SQLite Adapter 与本机执行职责 + +> 状态:**B 数据侧与执行侧均已落代码**(2026-09-26):同一库共享句柄(`SqliteSessionDatabase`)、SQLite 数据端口实现(`SqliteSessionData`)、schema v6→v7(执行行去外键 + 生命周期锚点 + 本机登记表),以及本机执行面(`LocalExecution`)与组合门面(`SessionResourcesImpl`,B-02/B-03)。消费侧迁移(E)与远程 adapter(C)尚未实施;旧 `ThreadStore` 生产路径仍在,等 E 切换。日期:2026-09-26。 +> 上级:[总计划](2026-09-26-session-store-plan.md)。依赖:[A](2026-09-26-session-store-sub-plan-a-contracts.md)。远程内部确认见 [C](2026-09-26-session-store-sub-plan-c-turso.md),ACP/Agent 准入顺序见 [E](2026-09-26-session-store-sub-plan-e-consumers.md),验收见 [F](2026-09-26-session-store-sub-plan-f-verification.md)。 + +## 1. 当前实现基线 + +> 实施后基线(2026-09-26):pool/read-only/canonical 路径/lease 弱引用表已移入私有 `SqliteSessionDatabase`,数据面与执行面共用同一句柄;`SqliteThreadStore` 降为消费侧迁移桥并转发到该句柄。`CURRENT_SCHEMA_VERSION` 已是 7。 + +- `SqliteThreadStore` 持唯一 `SqlitePool`、read-only 标记、canonical 数据库路径及 root lease 弱引用表(`sqlite_store.rs:34-41`)。 +- `schema.rs:11::CURRENT_SCHEMA_VERSION` 当前为 **6**(review-2 复核确认,不要沿用旧索引中的版本数)。`execution_runs.thread_id` 外键指向 `threads` 且 `ON DELETE CASCADE`(`schema.rs:210-213`);`session_bindings.thread_id`、`messages.thread_id` 同样级联。现有 lease 必须在 thread 存在后取得。 +- `schema.rs:34-92::inspect` 只认 `PRAGMA user_version == CURRENT`、2..5 与「无版本但有 threads/messages」的 legacy;其他版本直接 `UnsupportedSchemaVersion{found, supported}`。升级在 `BEGIN IMMEDIATE` 内完成,DDL 与 `PRAGMA user_version` 同事务提交(`schema.rs:133-242`)。 +- `workspace.rs` 将项目/工作区登记、binding、新建/legacy 接纳与列表 SQL 放在同一模块;`discovery.rs` 是本机 Git/文件证据。 +- `execution.rs` 用稳定 sidecar OS 锁(`lock_execution`,`db_path + ".execution-locks/.lock"`,锁文件永不删除)、代际记录、mutation gate 和 `mutation_uncertain` 保护执行关闭;Drop 不代表 clean。`mark_clean` 用 `UPDATE … WHERE thread_id = ? AND generation = ? AND clean = 0` 精确 CAS,并在 `threads` 行已被删除(新建补偿路径)时容忍记录缺失(`execution.rs:59-103`)。 +- `compaction.rs` 的 Full lifecycle 已有同事务 flags/摘要/计数/cache 更新;部分 flags/delete/rewind 后另行 invalidation——实际逐条写来自消费侧:`peri-agent/src/session/transcript/persistence.rs:175-191` 对 `ApplyCompactionBatch` 逐条 `update_message_flags` 后再 `invalidate_context_cache`,需收口为完整行为。 +- 写打开/只读回退的版本与列形状判定不同;默认 `threads.db` 中还可能有其他业务表,不能复制 schema 时只保留本模块表。 + +本计划不重新设计本机项目/目录身份规则,不新增后台 daemon,不让远程 adapter 承担 Git 或 OS 锁。 + +## 2. 逻辑拆分,不强迫物理拆库 + +| 当前 | 目标逻辑归属 | 物理处理 | +| --- | --- | --- | +| `SqliteThreadStore` pool/read_only/db_path | 私有 `SqliteSessionDatabase`(拟名) | 同一 pool、同一库;不造第二条连接真相 | +| messages、flags、thread metadata、frozen/inherited、列表 | `SqliteSessionData` 实现 A 的内部数据端口 | 保留原表及编码,新增行为专用方法 | +| `discovery.rs` | 本机 execution/discovery | 先迁职责再移文件,保留 key-object/旧 Git 兼容测试 | +| projects/workspaces 证据、OS锁、execution_runs | 本机 execution registry | SQLite 模式仍可同库存放;逻辑 owner 不因物理位置改变 | +| 写入授权、根子关联、未知结果及 close 协调 | `SessionResourcesImpl` + 本机执行 owner | 消费只能通过门面进入写行为 | +| 内部 SQL 事务与关系检查 | SQLite 私有协调代码 | 不跨 A 接口传 transaction/connection/closure | + +SQLite 的同库原子性可由行为专用私有函数继续维护;不要求把每张表强行拆成 trait。若一个函数需同时复核本机事实与保存数据,可由私有 SQLite 组合实现调用本机验证器及同库 SQL,不把 Git 或目录解释职责移给数据 adapter。 + +## 3. 事实持有者 + +| 事实 | SQLite 默认 | Turso 组合 | +| --- | --- | --- | +| canonical 历史、binding、frozen、继承快照、thread 树 | 原 `threads.db` | 远端数据 adapter | +| 项目/工作区登记、路径/文件对象/Git证据 | 原项目/工作区表 | 本机受保护 registry(同一张本机表) | +| 列表用 root/cwd 展示信息 | 现有 SQL 投影 | 随 durable 会话保存的展示事实,不用于执行验证 | +| root owner、dirty generation、准入/排空状态 | `execution_runs` + OS sidecar | 本机 `execution_runs` + OS sidecar(**同一张本机表**) | +| 创建意图 / 删除墓碑 / 未决操作锚点 | 新表 `session_lifecycle_commitments`(§5.3) | 同一张本机表;C 只写不透明 detail | +| 远程未决写入的远端侧收据 | 不引入云专用机制 | 远端 `op_ledger`(C §5),本机只存锚点与 operation_id | +| StoreId 与宿主登记 | 本机沿 canonical path 既有身份范围 | 远端稳定 StoreId + 本机 `session_store_registrations`(§6.1) | + +远程 registry 不是历史缓存/复制库,不存另一份可编辑 canonical transcript。只保存本机执行事实和恢复最小信息。durable binding 只有一份权威记录,本机允许保存其不可变引用/校验摘要而不是能独立改写的副本。 + +**本机状态库的位置与形态(review-2 关闭)**:本机 registry 就是 canonical 本机路径下同一个 SQLite 库(默认 `~/.peri/threads/threads.db`),远程模式下它**只**保存本机执行事实(projects/workspaces 证据、`execution_runs`、`session_lifecycle_commitments`、`session_store_registrations`),不保存 canonical 历史,也不引入第二个本机数据库文件。因此远程模式下不允许把 `execution_runs` 留在对 `threads` 的外键上(该行在远程模式下根本不存在),见 §5.3 的 v7 迁移。 + +## 4. 统一写入准入 + +### 4.1 规则 + +1. 门面在所有 mutation 前检查行为能力、访问权限、thread/root 归属、本机有效 owner 和未决持久化状态。 +2. 子会话沿 parent 链找根,保留循环检测及 legacy 已接纳根约束;不能因为 child 自身无 binding 就免授权。 +3. mutation guard 覆盖真正的 adapter 工作完成。关闭停止新增准入,再等待已准入写入;不得在等待外部 future 时持普通全局 mutex。 +4. 数据端口不公开到 Agent/ACP/middlewares,生产构造不导出无 guard 的 adapter。`create_thread` 对全未绑定链的旧豁免只可作为明确 legacy/测试路径,不能用于新生产会话。 +5. 只在**效果已确定**时才 `finish()`。门面已按三态落地(`resources/gate.rs::WriteScope::settle`):`Applied | NotApplied` 才释放写入准入,`Unknown`(含取消、超时、提交后丢响应)丢弃范围,由 `Drop` 置 `mutation_uncertain` 并保留未决证据;`MutationOutcome` 由 `SessionResourceError::effect()` 派生,调用方拼不出矛盾组合。桥(`ThreadStore` impl)里带显式事务的两处已改用 `TransactionEffect` 标出「提交自身的失败」;其余单语句写入在本地 SQLite 上「返回 `Err` 即已回滚」可证明,保持 SQL 层粒度并由 E 随桥删除。数据面(`sqlite_store/session_data.rs`)的写事务提交阶段同口径:全部 `commit()` 失败经 `failure.rs::commit_failure` 固定为 `Unknown`(IO/驱动未分类失败不再冒充「没生效」),由租约留下未决证据并阻断续写与 clean;`commit()` 之前的失败与只读事务(`load_snapshot`)保持原因分类,不判 `Unknown`。同一口径覆盖本机执行面(`local.rs::create_with_lease` / `admit_existing`)与 compaction 事务(`compaction.rs` 三处 `commit()`);`write_failure` / `execution_failure` 先保留已带效果的领域失败(`Unknown` / `Applied` 不经 `anyhow` 第二次降级),桥侧三个 compaction 转发方法据此只在确定效果时 `finish()`(2026-09-26 提交前核实修复)。 +6. 门面不得将跨根授权混入通用 SQL 参数;执行能力是本机领域权限,adapter 仅接收已准入的完整行为。 + +### 4.2 close 与 dirty + +ACP/Agent 排空 owned 资源后请求结清;门面再等待 persistence worker/adapter 内部任务及未知结果收敛,最后写 clean 并释放锁。 + +- `Incomplete` 保留 owner、锁及唯一关闭句柄;不移动句柄后让重试无从等待。 +- 本机普通 dirty 维持现有精确 `(thread_id,generation)` CAS 语义,但 CAS 不出执行实现。 +- 远程 `PersistenceUncertain` 不能映射成普通 `RecoveryRequiredDetails`;未协商 recovery 客户端的自动 reset 路径同样不能解除未知写。`ReadOnlyAdmission::from_workspace_error`(`peri-acp-types/src/workspace.rs:141-154`)只认 `ExecutionBusy | RecoveryRequired | ExecutionLeaseRequired`,`PersistenceUncertain` 必须落在该集合之外并单独映射。 +- `reset_dirty_execution` 自身检查未决持久化,不能仅依赖 ACP 先检查。 +- 进程崩溃只释放 OS锁;下一进程先恢复 C 的未决写,再处理普通 dirty。 +- delete 后仍保留未完成本机执行/恢复证据;远端记录被删除不证明进程和请求均已结束。 + +### 4.3 未决持久化门禁矩阵(review-2 闭合) + +“未决持久化”= 本 root 存在 `session_lifecycle_commitments` 中 `kind='mutation_pending'` 且 `state != closed` 的锚点(其远端事实由 C 的 `op_ledger` 决定)。门禁对以下入口一律生效,且检查在门面内部,不靠调用方: + +| 入口 | 存在未决时 | 说明 | +| --- | --- | --- | +| `acquire_execution` | `PersistenceUncertain`,不发 lease | 新 owner 不得与仍可能生效的旧写并发 | +| `mark_clean` | `RecoveryRequired`/`PersistenceUncertain`,不放锁 | 现有 `mutation_uncertain` 分支之外再查锚点(跨进程可见) | +| `reset_dirty_execution` | `PersistenceUncertain`,不解除代际 | 普通 dirty reset 不解除未决写 | +| `delete_session_tree` | `PersistenceUncertain`,不删除 | 删除前必须先有未决收敛证据(§4.4 墓碑) | +| `save_fork` / `save_child` / `create_session` | `PersistenceUncertain`(source/root 有未决) | 不把未决来源复制进新会话 | +| rewrite/compact/append 等普通 mutation | 拒绝并等待收敛 | 与 §4.1 规则 1 一致 | +| 只读读取与列表 | 允许 | 历史可读性与执行资格分开表达 | + +### 4.4 删除的独立锚点(review-2 闭合) + +级联事实:`DELETE FROM threads` 会连带删除 `execution_runs`、`session_bindings`、`messages` 行,因此**不能**用这些表证明“删除时该会话已收敛”。同时 `require_execution_lease` 对已绑定根要求活 lease,删除本身已受 owner 约束。为使删除后的证据不被 cascade 抹掉: + +1. 删除前:门面确认 root 无未决持久化与未完成执行(§4.3)。 +2. 删除事务内(与 `DELETE FROM threads …` 同一 `BEGIN IMMEDIATE`):写入 `session_lifecycle_commitments` 墓碑行 `kind='tombstone', state='deleting'`,使“删除是刻意行为”与数据删除同事务成立。**同一事务内显式 `DELETE FROM execution_runs WHERE thread_id IN (被删 thread 集合)`**:现有 `delete_thread`(`sqlite_store.rs:402-434`)只删 `threads` 行,`messages`/`execution_runs` 行靠 `ON DELETE CASCADE` 清除;v7 去掉 `execution_runs` 的外键后该级联对它不再生效,若仍按原样只删 `threads`,会静默留下孤儿执行行(dirty 行永不收敛)。显式删除是保持本地 delete 语义与清理代价不变的必要步骤。 +3. 提交后:本地 adapter 将墓碑置 `state='deleted'`;若进程在两步之间崩溃,`state='deleting'` 且 `threads` 行已不存在的墓碑按 `deleted` 处理(幂等修复),**永不复活**该 identity。 +4. 远程 adapter:本机与远端不是一个事务,顺序固定为「先写本机墓碑 `deleting`(durable 锚点)→ 远端在 C 的操作身份内删除 → 远端确认后把墓碑置 `deleted`」。结果未知时保留 `deleting` + `mutation_pending` 锚点,不报告成功、不放行同 identity 的重新创建/登记;`deleting` 状态本身不被当作已删除,除非远端后续读到目标已不存在且封闭竞争确认(C §5.1)。 +5. `mark_clean` 的“行不存在”容忍分支从此要求存在对应墓碑(`deleting|deleted`),而不是只看 `threads` 行是否缺失。**已实施**:判据切到 `session_lifecycle_commitments` 的 `tombstone`;同时把生产桥的 `delete_thread` 改成与数据面删除同语义(同事务写墓碑 + 显式删 `execution_runs` 行 + 删 `threads` 行,提交后置 `deleted`),否则 v7 去掉外键后旧实现会留下孤儿执行行,且 ACP 的 `delete_thread` + `mark_clean()` 补偿组合会失去依据。 +6. 墓碑不按 TTL 自动清理:首期无法证明旧请求已失效时保留一行代价,不牺牲正确性换清理。同 identity 的重新登记被墓碑拒绝。 + +## 5. 创建与本机准入 + +### 5.1 默认路线 + +采用 **完整 durable 会话保存 + 本机执行准入**,而非先创建缺 frozen 的可用行。顺序固定为: + +1. **frozen 预备**:E 用 `PreparedSessionInputs` 一次读取 config/plugins/skills/agents/date/env 并构建 frozen 字节;只读、无 cache repair、无执行资源(E §3.2)。 +2. **本机 creation intent**:以稳定 `ThreadId` 在 `session_lifecycle_commitments` 写 `kind='creation_intent', state='reserved'`,并预留稳定 OS 锁(`lock_execution`)。此预留是私有准入机制,不是对外 transaction,也不是尚未存在 thread 的现有 execution lease。意图落盘失败即不发送任何远程请求。 +3. **完整数据保存**:数据行为一次保存 meta、binding、frozen 与初始化必需数据。 +4. **执行代际**:成功后建立 durable 执行代际(`execution_runs` 行 `clean=0`)并转为 root owner,复核关键目录对象。 +5. **返回并发布**:返回已保存身份/owner/权威事实,ACP 才开始有副作用的环境装配(MCP/LSP/hooks/cron)。 + +新 ThreadId 及初始内容在整个尝试中稳定;调用方超时不能再生成一个新 ID 自动重试。 + +**本地(SQLite)adapter 的顺序塌缩**:同库同事务内可顺序插入 `threads` → `session_bindings` → frozen → `execution_runs`,因此 creation intent、数据保存与执行代际在本地是**一个事务**;`reserved`/`data_saved` 中间态在该模式下不可达,也不需要恢复逻辑。生产新建路径按本地事实一次提交,不为了与远程对称而人为拆成多步。下面的状态机与中断表只在「durable 数据不在本机」的远程模式下承担恢复责任。 + +### 5.2 创建状态机与崩溃点(review-2 闭合) + +`session_lifecycle_commitments` 中 `kind='creation_intent'` 的 `state`: + +| state | 含义 | 允许的后续 | +| --- | --- | --- | +| `reserved` | 意图已落盘、已占 OS 锁,未确认任何 durable 数据 | 继续保存 / 收敛后 abandon | +| `data_saved` | durable 数据已确认完整(meta/binding/frozen 原子成立) | 准入(admit)或报告 `SavedButNotAdmitted` | +| `admitted` | 执行代际已建立并持有 owner | 转入正常生命周期;锚点改为依赖 `execution_runs` | +| `abandoned` | 初始化被撤销(数据未发布或已补偿),锁已释放 | 终态;同 identity 不再复用 | + +| 崩溃点 | 恢复依据与行为 | +| --- | --- | +| 意图提交前崩溃 | 无副作用、无锁;同一 ThreadId 可安全重试(等价于未开始) | +| `reserved` 且尚未发送远程请求 | 锚点 + C 的 `op_ledger` 均无该 operation → 由 C 执行封闭竞争后确认未生效,abandon | +| `reserved` 且请求在途/已丢响应 | 先读 C 的 `op_ledger`:`applied` → 走 `data_saved` 分支;缺失/未知 → 由 C 封闭竞争,**封闭确认成功**才允许 abandon;封闭仍未知则保持阻塞 | +| `data_saved`,执行代际未写 | 业务前提仍成立(workspace 证据一致)→ 准入收敛;否则报 `SavedButNotAdmitted`,保留 identity,不重造 binding/frozen | +| 执行代际已写、环境装配失败 | `execution_runs.clean=0` 即普通 dirty;ACP 排空资源后请求 `abandon_initialization`,由门面内部补偿并 mark_clean | +| 进程在完整保存后崩溃 | 下次依据原 creation intent + 完整事实收敛;绝不生成新 binding/frozen 或新 ThreadId | + +`SavedButNotAdmitted` 是独立领域结果:数据已完整保存、可定位 identity、执行准入未成立;不得包装成“确定未创建”,也不得让调用方据此删除数据。`abandon_initialization` 只处理本次未发布创建(new/fork/child),内部完成数据撤销 + 墓碑并报告是否完成;不是通用 rollback。 + +创建意图需要本地 schema 变更:随 §5.3 的 v7 受控升级进行,完整保留旧数据。默认 SQLite 不因 Turso 引入独立本地 registry 数据库;远程模式的本地事实仍落在同一本机库(§3)。 + +### 5.3 本地 schema v6 → v7 迁移矩阵(review-2 闭合) + +新增/变更 SQL(全部在 `BEGIN IMMEDIATE` 内,与 `PRAGMA user_version = 7` 同事务): + +```sql +-- 1) 生命周期锚点:无外键,故不被 threads 级联删除 +CREATE TABLE session_lifecycle_commitments ( + thread_id TEXT PRIMARY KEY, + root_id TEXT NOT NULL, + kind TEXT NOT NULL, -- 'creation_intent' | 'mutation_pending' | 'tombstone' + state TEXT NOT NULL, -- §5.2 创建态 / §4.4 墓碑态 / closed|unknown(未决) + generation INTEGER, + operation_id TEXT, -- C 的 operation identity;不导出到消费侧 + detail TEXT, -- 不透明恢复信息(禁止凭证/正文) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_lifecycle_root_kind ON session_lifecycle_commitments(root_id, kind); + +-- 2) 远程存储身份的本机登记;只有明确空的新存储才可初始化 +CREATE TABLE session_store_registrations ( + store_id TEXT PRIMARY KEY, + engine TEXT NOT NULL, + locator_digest TEXT NOT NULL, + installation_id TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- 3) execution_runs 去掉对 threads 的外键(远程模式下本机不存在 threads 行) +CREATE TABLE execution_runs_local ( + thread_id TEXT PRIMARY KEY, + generation INTEGER NOT NULL, + clean BOOLEAN NOT NULL +); +INSERT INTO execution_runs_local (thread_id, generation, clean) + SELECT thread_id, generation, clean FROM execution_runs; +DROP TABLE execution_runs; +ALTER TABLE execution_runs_local RENAME TO execution_runs; +``` + +| 维度 | v6 → v7 行为 | +| --- | --- | +| 触发 | 任何写打开且 `user_version = 6`(`inspect` 新增 `Version6` 分支);v2..v5 先走既有升级路径再落到 v7 | +| 已有 dirty 保留 | `execution_runs` 逐行复制,`clean=0` 与 generation 原样保留;迁移后同一 `(thread_id, generation)` 仍触发 `RecoveryRequired` | +| 删除语义保持 | v7 起 `execution_runs` 无外键,删除路径必须在同一事务显式删除被删 thread 的执行行(§4.4);不靠已失效的级联,不把孤儿 dirty 行留给后续进程 | +| 已有 binding/frozen/历史 | 不动;`threads`/`messages`/`session_bindings` 不重建 | +| 其他业务表 | 不触碰(沿用现有「只要求必需表存在」的判定) | +| 行数校验 | 复制后校验 `execution_runs` 行数不变;不一致 → 迁移失败并整体回滚 | +| 中途失败 | 事务内回滚,`user_version` 保持 6,库仍可被本构建写打开并重试;不出现半迁移状态 | +| 只读打开 | **不迁移,也不按版本号拒绝**:沿用现有只读 shape 兼容规则(`connection.rs::probe_load_meta_shape` 的必需列超集判定),只读路径不读也不写 `user_version`、不建表、不建锁文件;只有必需列缺失(形状不符)才 `SchemaIncompatible`。v6 与 v7 都满足必需列,因此只读都能进 | +| 旧二进制读 v7 | 只读仍按上述 shape 规则放行(现有只读实现不查版本号);写打开才走版本判定:`found > supported` → `UnsupportedSchemaVersion`,不回退、不降级写 | +| 未来版本 | **写打开**拒绝(`UnsupportedSchemaVersion{found, supported}`),不自动迁移、不猜列形状;只读打开不因版本号本身拒绝,仍按 shape 判定,且绝不降级写 | +| 远程适配 | 远端 schema 独立版本化(C §4);本机 v7 迁移与远端 schema 无关,两者不共享 DDL | + +`execution_runs` 去外键是本矩阵唯一的结构性重建:它是 `threads` 的子表,删除/重建不需要 `PRAGMA foreign_keys = OFF`(现有 registration rebuild 才需要),也不影响 `session_bindings` 的级联语义。 + +分支说明:`Empty`/`Legacy`(新库或旧库)在既有 DDL 段直接按 v7 形状创建——`execution_runs` 一开始就不带外键,并新建两张 v7 表——不做“复制-删除-改名”;只有 `Version2..Version6` 才走重建路径。`inspect` 需要新增 `Version6` 与 `Version7(=Current)` 两个分支,`needs_registration_rebuild` 保持只覆盖 2..5。 + +## 6. Binding、legacy 与本机身份 + +- 保留 canonical locator + 文件对象证据联合登记;独立 clone 不按 remote 合并,linked worktree 规则不变。 +- 每次准入一次完整发现;准入内后续 reassert 只查关系与关键对象,不重复 Git。 +- SQLite legacy 接纳仍校验保存的绝对 cwd、根身份、既有 execution 记录/frozen;binding 与缺失 frozen 同时生效。数据行为返回权威快照,不让 ACP 处理 bool 胜负。 +- Turso 首期缺 binding 不是 legacy。外来 HostRegistration、registry 缺失、目录相同但对象不同都阻止执行。 +- 同远程 StoreId 的 locator 别名、URL 规范化和连接路由不改变本机锁域。首次 HostRegistration 登记只可在明确空的新范围初始化;不能对已有数据自动重新认领。 +- 首期远程以独立数据库及单宿主写凭证为支持边界;若后续用 namespace,权限隔离需独立证明。应用 WHERE 条件不是授权隔离。 +- 本机 SQLite 延续 canonical path 锁域;hardlink/网络共享多机/复制数据库不属于现行保证。测试并说明支持范围,不把远程 StoreId 承诺误套为已支持所有本机别名。 + +### 6.1 locator → StoreId → 登记 → binding → workspace 证据 → owner(review-2 闭合) + +链上每一环是独立事实,缺环不自动补,也不因“同一目录”或“同一 URL”而推定一致。`AccessMode`/`DataCapabilities`/`ExecutionAvailability` 见 A §5.3。 + +| # | 环节 | 事实持有者 | 只读打开允许 | 写入/执行前置 | 必须拒绝的情形 | +| --- | --- | --- | --- | --- | --- | +| 1 | locator 解析(本机路径 / `env:` / 远程 URL) | D 的解析层 | 只解析、不连接、不读凭证以外的环境 | 解析成功 + 引擎/协议明确 | 未知 scheme、userinfo/带凭证 URL、drive 字母被当 scheme | +| 2 | 远端 schema 兼容性 | 远端 store | **允许读 schema/版本**,无写探测 | 版本已知且被本构建接受 | future schema、形状不符 → 拒绝(不 DDL、不迁移) | +| 3 | `SessionStoreId` | 远端 durable 行(adb 内唯一身份记录) | **允许读**(读不到 = 未初始化,不是“空库可用”) | 存在且与本机登记匹配 | 读取失败不当作“空存储”;不自动创建 | +| 4 | 本机 `session_store_registrations` 登记 | 本机库(§5.3) | **允许读** | 命中 store_id 且 `locator_digest` 与当前 locator 一致 | 无登记 + 远端已有数据 → 只读历史,禁止执行、禁止自动登记;有登记但 digest/来源不匹配 → 拒绝执行 | +| 5 | 首次登记(仅空存储) | 同上 | 不允许写 | 远端可证明为空(无 store_id 行)且本次为写打开 | 已有数据范围不得重新认领/迁移 | +| 6 | binding(durable 数据) | 数据 adapter | 允许读;`BindingState` 区分 Bound/LegacyConfirmed/ExternalOrUnregistered/Missing | 已绑定 + 关系一致 | `Missing`/来源不匹配 → 只读历史,不当作 legacy 自动接纳(SQLite 的 legacy 规则见 §6 首段,远程首期不启用) | +| 7 | workspace 证据(项目/工作区/文件对象/Git) | 本机 registry | 允许读已登记项 | 登记存在、对象身份一致、`validate_session_binding` 通过 | 目录被替换/移动、对象不同、登记缺失 → 拒绝执行 | +| 8 | owner(执行代际 + OS 锁) | `execution_runs` + sidecar 锁 | 不取得,不创建锁文件 | 1–7 全通过且无未决持久化(§4.3) | 他处持有、dirty 未解除、未决持久化、只读打开 → 只读准入 | + +只读打开(`AccessMode::ReadOnly`)允许做第 1/2/3/4/6/7 项的**读取**,但不得做任何写:不初始化 schema、不创建 StoreId、不写登记、不建锁文件、不取得 owner、不修复 plugin cache(E §3.2)。「读不到 store_id」与「store 为空可初始化」是两件事,只有写打开 + 明确空 + 首次登记才可初始化。 + +**跨系统引用不能伪装成外键(review-2 闭合)**:`session_bindings` 的 `project_id`/`workspace_id` 在本地模式下是同一库的外键;远程模式下 binding 是远端 durable 事实,而 project/workspace 登记是本机事实,远端不得建立指向本机表的外键,也不得自行判断目录证据。规则固定为: + +- 远端 binding 行保存 `schema_version`、`project_id`、`workspace_id`、`relative_cwd` 这四个**本机登记标识**,远端只做完整性校验(非空、版本接受),不校验登记是否存在; +- 执行准入由本机门面校验第 4/7 环(登记命中 + workspace 证据一致);登记缺失 → 只读历史,不自动重建; +- 本地模式继续使用原外键与同事务关系,不因远程改本地 schema(§5.3 只动 `execution_runs`)。 + +## 7. SQLite 数据行为迁移 + +- 新建/fork/child:分别收口完整快照;frozen/inherited 写失败无需 ACP/Agent 逐步删除数据。 +- append:保留批量和事务计数/标题更新;`INSERT OR IGNORE` 不足以证明相同 ID 内容相同,碰撞须区分完全重复与冲突,不能静默跳过错误数据。 +- snapshot read:同一读取视图获得 own payload/flags、inherited、binding/frozen,兼顾只读/WAL;metadata/list 不调用 full snapshot。 +- flags/compact:持久化归属校验在内部;损坏 projection/MessageId 不默默降为 None/跳过。 +- delete/rewind/projection:目标选择、改变及派生缓存维护为一项行为。保留现有未知截止点的无变更语义,避免本次顺便改变用户 rewind 规则。 +- metadata:定向字段更新;历史 status/标题并发不整份覆盖。 +- 列表:保留 scope/cursor、隐藏/空线程过滤和 legacy 路径关联;不要把 `THREAD_META_COLUMNS` 的内容大小聚合搬到轻量列表。 + +缓存 epoch、pool、SQLx transaction 不出实现;同一行为内避免占着事务连接再取 pool 导致容量死锁。 + +## 8. 迁移步骤与范围 + +- B-01:读取并锁定原 schema/只读行为及测试基线;不打开真实用户数据库做实验。已记录基线:`cargo test -p peri-resources --lib` → exit 0,156 passed / 0 failed(详见总计划 §8 基线表)。 +- B-02:建立共享 SQLite 内核与门面,按 A 提供完整数据行为;旧 trait 暂时仅向门面转发。**已完成(2026-09-26)**:共享内核 `SqliteSessionDatabase`、完整数据行为 `SqliteSessionData` 与门面 `SessionResourcesImpl` 均已落;「旧 trait 向门面转发」未做——桥仍直接持有共享句柄,该转发在 E 删除桥时不再需要(不是遗留旁路,见 B-06)。 +- B-03:提取本机发现/执行职责,迁统一 guard、三态 `MutationOutcome`、显式 Unknown 与 close/reset 门禁;不在远程 adapter 复制锁代码。**已完成(2026-09-26)**:`LocalExecution` 持有发现/登记/owner/dirty/创建准入/撤销;`MutationGate` 统一「能力/权限 → 未决持久化 → 本 root owner」;`WriteScope::settle` 按 `MutationOutcome` 结清;撤销、认领、删除、排空、关闭各有显式门禁;远程 adapter 不参与锁。 +- B-04:新建/fork/child/legacy 快照收口;只读默认路由继续复用现有连接检查。**已完成(数据侧)**:新建/fork/child 一次事务落完整快照,child 用 root 原始 frozen 字节,legacy 接纳保持原语义;只读路由未改动(连接检查仍在 `connection.rs`)。 +- B-05:实现 v7 迁移(§5.3:锚点表、登记表、`execution_runs` 去外键)、删除墓碑(§4.4)、创建意图状态机(§5.2)、远程登记链(§6.1),并测试 dirty 保留、迁移回滚、只读不迁移、别名与登记丢失。**已完成(本机侧)**:v7 迁移 + 删除墓碑 + 撤销锚点 + 登记表读取已落并测试(dirty 保留、迁移回滚、只读不迁移、登记歧义);`creation_intent` 的 `reserved`/`data_saved` 中间态在本机塌缩下不可达(§5.1),远程登记写入属 C。 +- B-06:与 E 切换生产调用并删除旧混合实现出口(符号删除 + 全 target 编译,A §7.1);保留有限测试替身但诚实声明能力,且不得保留静默 no-op。**未开始**(旧 trait 与桥仍在;E 负责切换,属下一阶段)。 + +现有文件主要为 `peri-resources/src/{context,sessions/mod,sessions/sqlite_store}.rs` 及 `sqlite_store/{connection,schema,workspace,execution,discovery,context,compaction,row_mapping}.rs`;拟新增位置按 A。D 拥有外部 locator/factory 参数修改,涉及 `context.rs` 时顺序接续,不并行覆盖。 + +## 9. 退出条件 + +F 中 V-03…12、V-14/16 通过;原 schema/legacy/只读/执行竞争/排空的回归仍有效;v7 迁移在 v6 库上保留全部 dirty 且可回滚;删除后墓碑仍可判定;默认路径没有第二套历史或网络成本。数据与执行接口可分别实现,但所有生产变更仍受同一门面授权。未知写入不能通过普通 dirty reset、删除或 Drop 绕过。 + +门面侧的当前落点(2026-09-26):V-03(创建/收敛/诚实结果)、V-10/V-11 的跨进程 owner 与崩溃 dirty、V-12(只读与不支持在副作用前失败)、V-17(三态结清)、V-19(墓碑在级联后仍可判定)已由 `sessions/resources_test.rs`(20 项,含复核轮为 `save_child` 门禁缺口新增的 1 项)与 `tests/session_resources_contract.rs`(6 项)覆盖并通过;V-18 由 `sqlite_store/schema_v7_test.rs`(4 项)覆盖。剩余的是生产切换(E)与远程行为(C)。 diff --git a/spec/issues/2026-09-26-session-store-sub-plan-c-turso.md b/spec/issues/2026-09-26-session-store-sub-plan-c-turso.md new file mode 100644 index 000000000..432e5b066 --- /dev/null +++ b/spec/issues/2026-09-26-session-store-sub-plan-c-turso.md @@ -0,0 +1,252 @@ +# 会话资源拆分 — 子计划 C:Turso Cloud Adapter + +> 状态:设计计划;C-01~C-04 已落代码并在授权测试库实测,C-05 第一批(部署入口端到端)与第二批(真进程 `SIGKILL` 演练、P5–P7 实测、删除锚点收尾)已落;剩余为 E 全链路。日期:2026-09-26。 +> 实施进度:引擎由授权测试库只读探测确认(`turso://` + 官方域;`GET /version` 404 只是该端点未暴露, +> **不足以**单独断定非 sqld;引擎依据是官方驱动对应关系 + 选定驱动上的 SQL 行为实验),选定 +> `turso_serverless` 0.1.3;私有 `sessions::remote` 已落连接、参数绑定、错误脱敏与显式 cloud 入口 +> (默认 `#[ignore]`),第五轮加落可变连接、独立 schema 与操作账本,并在授权测试库实跑 5 组最小实验: +> P1(原子批零部分结果)、P2(唯一键冲突可判别、重放返回原收据)、P3(并发同 operation_id 至多一方提交)、 +> P4(新连接权威读)与终态封闭记录(封闭先提交则迟到原请求不可能生效)成立。 +> P5(SDK 无自动重试的静态审计 + 取消在途调用后仍有 durable anchor)与 P7(收据保留与空间成本) +> 已有观测;**P6 只证到 all-or-nothing**:1/8 MiB 单批都整批生效,单请求上限本身未定位(既未 +> 触发「先拒绝」分支,也没有分块实现)。证据见[母需求](2026-09-26-session-store-remote-backend.md) +> §9.8 与 §9.16。§5.0 的两条候选路线中 libSQL 路线未采用(非「判为 Unsupported」)。 +> 第四轮(同日)收尾复核证据与下一轮开工清单(data 口可替换、本机事实端口归属、预留面、 +> 兼容入口)见母需求 §9.7。 +> 上级:[总计划](2026-09-26-session-store-plan.md)。依赖:[A 行为契约](2026-09-26-session-store-sub-plan-a-contracts.md)、[B 本机执行与 SQLite](2026-09-26-session-store-sub-plan-b-local.md);装配见 [D](2026-09-26-session-store-sub-plan-d-configuration.md),验收见 [F](2026-09-26-session-store-sub-plan-f-verification.md)。 + +## 1. 目标与不做事项 + +实现与 SQLite 满足同一会话行为契约的 Turso adapter。底层连接、SQL、事务、去重和故障收敛全部留在 `peri-resources` 私有实现中;业务端不传事务对象、CAS 参数、重试令牌或远程操作编号。 + +本期直接读写远程权威数据,不引入 embedded replica、sync/push/pull、本地历史缓存写回、离线续写或双写。执行仍由本机 owner 管理。云端存储可保存本机验证过的 binding 事实,但不运行 Git、不检查目录、不判断进程是否结束。 + +## 2. 已核实外部证据及限制 + +2026-09-26 阅读以下官方页面;它们是设计输入,不是对用户目标数据库的运行证明: + +| 来源 | 已观察到的描述 | 对计划的影响 | +| --- | --- | --- | +| [Rust Quickstart](https://docs.turso.tech/sdk/rust/quickstart) | 远程 Turso 引擎用 `turso_serverless`,远程 libSQL 引擎用 `libsql` 的 `remote` feature | 不能因为产品名都是 Turso Cloud 就选定同一个 SDK | +| [Rust Reference](https://docs.turso.tech/sdk/rust/reference) | 区分本地、sync、远程客户端;提供交互式事务示例 | 首期只选 over-the-wire 客户端;SDK 有事务方法不证明全部失败语义满足本项目 | +| [SQL over HTTP Reference](https://docs.turso.tech/sdk/http/reference) | 参数绑定、baton、结果数组、连接关闭;页面记载交互事务 5 秒窗口、连接闲置 10 秒关闭 | 不在远程事务中等待 Git、模型、文件发现或用户;数值属于待复核服务限制,不写入领域接口 | +| [官方 HTTP v2 协议](https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md) | 同 stream 串行使用 baton;pipeline 即使前项失败仍执行后续 request | 一组 SQL 放进 pipeline 不等于原子行为;必须验证失败后不会继续执行错误的提交 | + +仓库当前使用 `sqlx 0.9.0`(`features = ["runtime-tokio","sqlite"]`)、`reqwest 0.13.4`、`url 2`(`Cargo.toml:51,80,81`,`Cargo.lock` 中无 `libsql`/`turso*` 条目)——review-2 复核确认。依赖与 SDK 精确版本在 C-01 后锁定,不根据未读取的 `.env` 猜测引擎,不预先引入两套 SDK。选定路线见 §5.0。 + +**review-3(2026-09-26 本轮)重新联网复核**:只抓公开官方页面与 crates.io/docs.rs 元数据,未接触任何用户数据库、未读取 `.env`、未使用凭证。 + +| 复核项 | 本轮实际观察到的事实 | 影响 | +| --- | --- | --- | +| 引擎 ↔ 远程 SDK 对应 | Rust Quickstart 原文:over-the-network 时 “use the crate that matches your database engine”,`turso_serverless` 对应 Turso databases,`libsql`(`remote` feature)对应 libSQL databases | §5.0 的路线划分成立,不能因产品同名互认 | +| 远程驱动版本 | crates.io:`turso_serverless` 0.1.3(2026-09-04 更新,仓库 tursodatabase/turso);`libsql` 稳定 0.9.30(`0.10.0-pre.*` 为预发布);`libsql-client` 0.33.4 停更(2024-01);`turso` 0.7.2 是本地引擎/`turso::sync` 入口(sync 已被 §1 排除),不是本计划的 over-the-wire 远程入口 | 首期远程候选是 `turso_serverless`(Turso 引擎)与 `libsql` 0.9.30 `remote`(libSQL 引擎)两条;C-01 用「目标库只读探测 + 官方对应关系」二选一并锁定精确版本,预发布限制要记录,不得改用停更的 `libsql-client` 或改成 sync 路线 | +| 原子批处理入口 | docs.rs `turso_serverless` 0.1.3 公开面含 `Builder::new_remote(..).with_auth_token(..)`、`Connection::{query,batch,transactional_batch}`、`Transaction`/`TransactionBehavior`;文档称 `transactional_batch` 为同一 HTTP 请求内的原子批 | §5.1 的“单一原子操作”在选定 SDK 上有对应入口;是否真原子仍须 P1/P2 实测,不因文档措辞直接宣称 | +| 官方默认推荐 | Quickstart 明确 “For most applications, we recommend running a local database with sync (`turso::sync`)”;over-the-wire 远程用于“cannot store a local database file” | 母需求要远程权威数据,本计划**有意偏离**该推荐;实现不得自行改成 sync/embedded replica(§1 不做事项) | +| URL scheme 与引擎 | 该 crate 的 remote 示例 URL 形如 `libsql://my-db.turso.io` | scheme 不能辨识引擎;D §3.2 必须要求显式引擎选择,禁止按 scheme 或端口猜引擎 | + +C-01 仍需在获得授权后重跑并记录精确版本、限制与失败证据(本轮只是复核路由与可行性入口,不是引擎语义证明)。 + +### C-01:实现前可行性闸门 + +未来获得实施授权后,先确认目标引擎和独立测试数据库,再选择一个 SDK 做最小隔离验证: + +1. 参数绑定、64 位整数、UTF-8、NULL、blob/大 payload 与错误分类。 +2. 完整行为所需原子操作:中途约束失败时零部分结果,提交后的新连接读取一致。 +3. 冷进程读取的权威性;不能把 embedded replica 的 read-your-writes 文档套到远程新连接。 +4. SDK 是否自动重试 mutating 请求、如何设置请求时限/禁止不安全重试、取消后后台工作是否仍在运行。 +5. 服务对单次请求大小、语句数量、结果大小、事务时限及 schema DDL 的实际限制。 +6. 日志/错误是否包含 URL、Authorization、SQL 参数和消息内容,如何在 adapter 边界截断为安全诊断。 + +这一步只用于确认一个具体实现可用;失败则记阻塞并调整 adapter 内部方案,不能为适应 SDK 改弱 A 的行为后置条件。若改用直接 HTTP,需明确额外协议维护成本、认证转发/路由校验与等价测试,不作为静默兜底。 + +## 3. 文件与内部职责 + +建议新增位置(尚不存在): + +| 位置 | 内容 | +| --- | --- | +| `peri-resources/src/sessions/turso/mod.rs` | 数据行为 adapter;构造、访问模式、行为实现与安全错误转换 | +| `turso/connection.rs` | 选定 SDK、连接及请求预算、关闭证据;测试可替换的私有传输 seam | +| `turso/schema.rs` | 远程独立 schema 版本、初始化、能力及兼容性检查 | +| `turso/history.rs` | 追加、完整快照读取、压缩/回退/删除、顺序与派生摘要维护 | +| `turso/initialization.rs` | 完整会话/子会话/派生快照发布,初始化失败处理 | +| `turso/recovery.rs` | 私有发送前登记、结果收敛与恢复阻塞;不导出底层状态机 | +| 相邻 `_test.rs` | adapter 故障、序列化与 schema 行为测试 | + +按实现体量再分文件,不为每条方法设一个 module。Cargo 依赖声明由本计划负责,D 仅消费已确定的构造端口。 + +实际落位(2026-09-26 实施):连接、端点、凭证与失败分类已落在 +`peri-resources/src/sessions/remote/`(`mod.rs` / `connection.rs` / `endpoint.rs` / +`credentials.rs` / `failure.rs`),**未**新建 `sessions/turso/`。下一轮写路径、schema、 +`op_ledger` 与恢复收敛在本目录内扩写,不再起第二个远程目录。 + +## 4. 远程数据组织 + +远程 schema 与本地 schema 分别版本化,不直接运行本地 `PRAGMA user_version` 升级脚本。复用的应是领域编码和验证,不是一个泛化 SQL executor。 + +远程版本标记必须是引擎可移植的:优先使用普通表行(例如单行 `store_meta(schema_version, store_id, …)`),不把 `PRAGMA user_version` 当作远程契约——它是 SQLite 方言,选定引擎/驱动是否支持必须由 C-01 实测;未证明前不得写进远程初始化路径。读取版本只读不迁移:版本未知、形状不符或高于本构建接受范围时按 Unsupported 拒绝,不自行 DDL、不猜列形状。 + +- 保持现有 canonical payload、frozen 与 inherited envelope 字节格式,不为远程改消息模型。 +- 会话 metadata、不可变 binding、frozen、inherited、历史 payload/flags 为权威数据;列表摘要、计数和缓存是派生数据,写行为同时维护一致性。 +- 显式持久化每个 thread 内的历史顺序,不依赖 UUID 排序或跨库的物理 rowid;MessageId 保持现有语义,独立 fork 仍生成新 ID。 +- 远程持久化 `StoreId` 与会话来源 installation 身份;客户端解析定位别名后按同一 `StoreId` 使用本机状态。来源标记不构成跨机认证,写权限仍须限制在已授权宿主。 +- 项目/工作区 ID 和列表展示快照可随会话事实保存;它们不是本机文件证据,不能授予执行资格。本机目录证据、OS 锁与 dirty 仍按 B 管理。 +- 历史读取一次获得相互一致的 payload、flags、继承区与 frozen/身份视图;跨多个服务调用时一致性由 adapter 内部保证。分页历史若引入,必须固定读取视图,不拼接不同版本。 +- 列表按 scope/cursor 在远端过滤,投影不含消息正文、frozen/inherited 大字段;不用本机 registry 全量 join 远程历史。 +- 新建/fork/child 输入超出服务原子请求上限时,先明确拒绝;若需要分块,只能内部 staging 后完整发布,未发布数据不可被正常 load/list 消费。不以逐块成功假称整体成功。 + +## 5. 提交结果未知:内部恢复方案 + +这是适配实现设计,不是对外接口。A 只暴露「行为已生效 / 确定未生效 / 结果无法确认」及必要的会话恢复行为。 + +### 5.0 引擎/schema 选择路线(review-2 闭合,证据优先) + +| 路线 | 处置 | 理由 | +| --- | --- | --- | +| Turso 远程引擎(Turso Cloud 的 Turso Database 引擎) | **首期远程候选之一**(over-the-wire;驱动 `turso_serverless`);SDK/协议在 C-01 依据目标库探测结果 + 公开官方资料锁定并记录版本 | 与“远程权威数据 + 事务原子性 + 唯一约束”要求一致;是否就是目标库引擎必须由 C-01 只读探测证实,不预先认定 | +| libSQL 远程引擎(`libsql` 的 `remote` feature) | **同样保留为首期远程候选**;与 Turso 引擎候选实现同一 A 行为契约,由 C-01 按目标库只读探测结果二选一 | 用户只指定 Turso Cloud(产品名),未指定库引擎;官方要求驱动与引擎匹配,因此在探测前不得把任一路线预先判为 Unsupported,也不得用「拒绝另一种」代替交付 | +| embedded replica / sync / 双写 | **明确 Unsupported**,构造时返回类型化错误,不做静默兼容 | 首期不做副本与双写(§1);这与引擎选择无关,不因选哪条远程路线而变 | +| 用户现有数据库实例 | 只在用户确认的测试库上做只读探测与合成数据实验;不做“已兼容”声明 | 目标库引擎与凭证由用户确认(`.env` 指向测试库);探测只读、最小化,结果只记脱敏结论 | + +配置分辨率必须显式:locator 无法唯一决定引擎时要求显式选择(D §3.2),**不轮流试两种 SDK**、不隐式别名 token 变量名。选定依据是 C-01 对授权测试库的**只读引擎探测**(版本/引擎标识)+ 官方驱动对应关系,不是按 URL scheme 或「另一个更省事」猜;探测前两条候选都算可行,探测后的选择与证据一起记录(只记脱敏结论,不记 URL/token)。 + +本节外部依据来自 §2 的官方页面记录(review-2 初读,review-3 重新抓取复核;两轮都只读公开页面);**复核只覆盖路由与入口可行性,不是引擎语义证明**,因此 C-01 必须重新核对所选 SDK 的当前版本、维护状态与限制,并把实际读到的页面与版本写进证据,不能沿用本节的转述当作已复核事实。 + +远端 binding 行的 `project_id`/`workspace_id` 是本机登记标识而非远端外键(B §6.1 末段);远端 schema 不建立指向本机表的引用,也不判断目录证据。 + +### 5.1 首选机制及必要性 + +选择**发送前本机持久登记 + 远端同一原子操作内的身份资格 + 终态封闭竞争**。原因:只读一次发现目标数据不存在,不能证明旧网络请求以后不会到达;本机取消 future 也不取消远端 SQL。 + +机制(最小正确形态,**不引入 Open/Applying 多阶段状态机**): + +1. 发送前:adapter 生成内部 operation identity,把 thread/root、行为类别、输入摘要与恢复所需最小信息作为 `kind='mutation_pending'` 锚点写进本机库(B §5.3 的 `session_lifecycle_commitments`),登记失败即不发送。 +2. 远端每个 operation 在 `op_ledger(operation_id TEXT PRIMARY KEY, state, digest, receipt, …)` 有一行唯一身份。 +3. **资格先于效果**:mutation 的远端执行是**一个原子操作**,其第一步就是取得身份资格——在**同一事务**内先写/占用 `op_ledger` 行(`INSERT`,唯一键冲突即失败),再执行全部业务变更,最后把 receipt 与 `state='applied'` 写入同一行,然后提交。业务变更不在资格之外发生,也不存在“先写数据、后登记”的窗口。 +4. **封闭竞争**:恢复端对同一 `operation_id` 执行终态封闭——`INSERT` 终结行/把状态推进到 `closed`(唯一键竞争,`INSERT` 冲突即输)。竞争结果只有两种: + - 封闭先提交 → 原请求的资格写入必然冲突,其整个事务回滚,**业务变更从未生效且以后也不会生效**; + - 原请求先提交 → 封闭冲突/读到 `applied`,恢复直接返回原 receipt(幂等),不再执行第二次。 +5. 因此“封闭成功”是**写确认过的证明**,不是推断:不需要空查询、不需要超时、不需要读旧请求是否还活着。封闭本身结果未知时保持 `PersistenceUncertain`。 +6. 已确认结果后更新本机锚点;客户端在远端成功后、本机结清前崩溃,下次通过 receipt 收敛。 +7. 未决期间:禁止同根后续写入、clean、新 owner、普通 dirty reset、删除与 fork(B §4.3 门禁矩阵)。 + +第三种中间情形(必须显式处理,不能猜):原请求已写入资格行但尚未提交,封闭方的写入会撞上写锁。此时封闭方只允许三种结论——阻塞到原事务落定后重判、按预算重试后重判、返回 `StillUnknown`;**“写忙/冲突/超时”一律不得当作 `ClosedNeverApplied`**。“封闭成功”的判据固定为:封闭方自己的写入**提交成功**(响应确认,且后续可被冷连接读到)。 + +封闭可证明性的实现前提(review-3 明确,实现不得偏离): + +1. **同一唯一键空间**:封闭记录与原身份记录必须竞争同一个唯一约束——同一行上的条件状态转移(`… WHERE operation_id = ? AND state <> 'applied'`,`rows_affected = 0` ⇒ 已 applied)或同一唯一键下的插入冲突。另建一张“closed 表/独立索引”不构成互斥:串行化只保证先后顺序,不阻止原请求其后照常提交业务变更,属于设计错误。 +2. **资格先于业务效果**:写身份资格与全部业务变更在同一事务内,且资格写在该事务的第一条业务语句之前;不存在“先写数据、后登记”的窗口。 +3. **不靠观测推断**:不用一次空查询、一次超时、future 取消或客户端断开推断安全性;这些都不属于 `ClosedNeverApplied` 的证据。 + +上述安全性依赖以下引擎前置条件,C-01 必须逐条实测(缺一项即保持未决、不放开写): + +| # | 前置条件 | 观测方式 | +| --- | --- | --- | +| P1 | 单一原子操作/短事务的 all-or-nothing 提交,无部分可见结果 | 中途约束失败后零部分结果;新连接读不到中间态 | +| P2 | 唯一主键冲突可判别(错误分类或 `rows_affected = 0`),且冲突方整个事务不提交 | 并发插入同一 `operation_id`:至多一方提交 | +| P3 | 写事务被串行化,无 lost update(不存在两方都“提交成功”的序列化结果) | 交替并发写同一 key,断言最终状态与 receipt 一致 | +| P4 | 提交后的行对新连接/新进程可读(权威读,不是本连接缓存) | 冷进程重读 `op_ledger` 与数据 | +| P5 | SDK 默认不自动重试 mutating 请求;重试必须复用同一 `operation_id` | 读 SDK 重试配置并在故障注入下验证 | +| P6 | 大型 new/fork/child 输入超单请求上限时**先拒绝**;若分块,只能 staging 后由唯一一次“发布”操作取得资格(未发布 staging 对 load/list 不可见) | 超限输入返回明确拒绝;staging 行不可被正常读取 | +| P7 | 收据/封闭记录不按任意 TTL 删除;只有能证明旧请求失效的代际清理才能回收 | 首期不清理,记录空间成本 | + +“原请求已开启事务”这一情形由 P2+P3 覆盖:任何长事务要么先提交(封闭失败 → 幂等返回),要么后提交时资格冲突而整体回滚。**不允许**把提交确认建立在“读一次为空”“客户端超时”“future 被取消”之上。不要求实现名为 journal/receipt 的具体表,但替代方案须提供相同的崩溃与晚提交证据。 + +结果封闭的内部接口(私有,不外泄): + +```rust +enum OperationResolution { + Applied(Receipt), // 读到 applied 与 receipt,返回原结果 + ClosedNeverApplied, // 封闭竞争胜出,证明不可能再生效 + StillUnknown(Reason), // 封闭也未确认:保持阻塞,不清理、不放行 +} +``` + +### 5.2 边界情况 + +| 故障点 | 必须得到的行为 | +| --- | --- | +| 发送前本机登记失败 | 确定未生效,无远端副作用 | +| 已登记、尚未发送时崩溃 | 恢复可封闭该操作,不重新构造业务输入盲写 | +| 远端已保存、响应丢失 | 恢复返回原结果;摘要、消息、删除不可执行第二次 | +| 原请求挂起,恢复先封闭 | 原请求迟到不再产生数据变化 | +| 正常结果到达后本机结清失败 | 保守保留恢复阻塞,不能发布 clean | +| 新建/删除目标 thread 不存在 | 操作结果仍能按存储身份和 root/operation 找回,不能依赖目标行还在 | +| 网络长期不可用 | 可见有界失败;不转本地库、不继续生成无界未持久化历史 | + +内部恢复记录不得含凭证;若需保存 payload,按本机历史同等级权限管理且不形成第二份可编辑历史。首期**不新建独立的本机恢复数据库**:本机锚点写在 B §5.3 的 `session_lifecycle_commitments`(`kind='mutation_pending'`,`operation_id` + 不透明 `detail`),远端收据写在远端 `op_ledger`;两者都不按任意 TTL 删除——只有能证明旧请求已失效的代际清理才能回收,首期不能证明时保留并记录空间成本,不能牺牲正确性换清理。C-01 未通过前(§5.1 P1–P7 任一未证明),远程写路径保持关闭,只允许只读历史。 + +## 6. 单宿主与本机执行组合 + +- 远程 adapter 没有执行 lease 方法。所有业务变更经资源门面取得本机写入准入,再进入 adapter;准入 guard 覆盖真实后台工作完成,不以调用 future 返回/取消为界。 +- 恢复未决写入发生在新 owner 放行、ordinary dirty reset、fork/source snapshot 与 clean 之前;B 的执行 dirty 和 C 的持久化未知分别判定。 +- 同一远程存储实例身份在云端持久化,URL/SDK路由别名不作为锁主键。首次初始化也需要唯一身份竞争,失败者读取胜者,不各造一个 StoreId。 +- 本机登记缺失或安装身份与保存来源不同:仅允许读取历史,拒绝自动接纳为本机 legacy。首期不提供安装身份迁移/复制工具。 +- 单宿主限制由配置、来源检查、部署权限一起落实;它不是分布式 lease。共享凭证或复制整个本机状态目录到另一台机器不在安全接管承诺内。 + +## 7. 行为实现约束 + +- SDK mutation 重试默认关闭,只有内部相同操作身份确认安全时才重发。 +- 正常 RPC 成功仅在整项行为持久化后返回;flush/close 等待 adapter 内部任务,不把「已提交给 worker」当完成。 +- 所有请求和收尾均有界;首次保守预算由 C-01/F 的测量确定。超时返回不改变远端结果分类。 +- 状态/标题更新是定向行为,不能读取整份 ThreadMeta 后覆盖写;cache 失效属于写行为内部。 +- 取消不是 rollback。清理失败必须进入可观察阻塞,并保留唯一资源 owner,不能 Drop 掩盖后台任务仍在工作。 + +## 8. 任务与退出条件 + +- C-01:确认引擎/SDK并完成前置实验;记录版本、限制和失败证据。 +- C-02:实现私有连接/schema/StoreId;拒绝未知 schema,先验证读路径和只读路径。 +- C-03:实现新建、恢复、追加、flags/compact、fork/child、rewind/delete、列表和定向 metadata 行为。 + 第一批已落:列表(scoped 分页/children/tree)、一致读取(snapshot/meta/binding/history/flags)、 + 新建(meta+binding+frozen)、fork、child、定向 metadata。 + 第二批已落:追加(保序 + 批内重复 id 与全局主键冲突拒绝)、投影/flags、compact(flags + 追加 + + 重数计数)、rewind(显式 `KeepThrough`/`RemoveFrom`,未知边界保持无变更)、精确移除(幂等删除、 + 跨会话拒绝)、删除会话树、未发布撤销(有子会话拒绝)、legacy 接纳(已有值不变)、child resume + 认领事实(状态派生标记)。写入统一走「一次端口调用 = 一个托管事务批 + 批内守卫」, + 「0 行受影响」不会以成功收场。 + 第三批(诚实失败缺口 + 真引擎行为契约):读取出口校验结果集数量、单行查询不接受多行、 + 删树空子树 ⇒ `NotFound`、撤销的子会话计数必须**明确读到 0**;云端实验在真引擎上验证了上述 + 行为与守卫的两条分支,并当场抓到两个真实缺陷(rewind 方向与追加/compact 的消息 id 未进操作 + 身份摘要 → 不同操作撞同一 `operation_id` 被当成重放静默跳过,已修)。 + 未落:`recover_persistence`(属 C-04,需本机未决锚点)、本机执行/登记组合与 D 装配激活(下一批)。 + 证据表与未完成项见母 issue §9.11。 +- C-04:接内部恢复机制及本机执行协调;覆盖发送前/后、响应丢失、取消、重启和迟到写。 + 第一批已落(操作身份与本机操作日志):每次领域调用铸造唯一操作 id(内容摘要只做一致性 + 校验,不再派生身份);发送前把 id/store/thread/root/行为/摘要登记进本机表 + `session_remote_operations`(schema v7 → v8,登记失败即不发送),确定终态才结清; + `recover_persistence` 按本机日志向远端账本求证,缺行时用同一 id 做终态封闭竞争。 + 真引擎回归:状态 A→B→A→B、标题 x→y→x→y、同 boundary rewind 复用都真的生效, + 每次调用各留一行账本,本机日志全部结清。 + 第二批已落(本机执行/登记组合与 D 装配激活):`MutationGate` 改持三个端口 + (`Arc` + `Arc` + `Arc`), + 远程组合把远端 adapter、本机执行面与同一份本机事实装进**同一个门面**(没有第二套公开行为); + `StoreId → HostInstallation → 本机登记 → binding 复核 → root owner` 全链落地:本机没有登记 + 或来源不一致时读取可用、执行与写入按 `StoreNotRegistered` 拒绝,已有数据的存储永不自动认领; + 执行代际与 sidecar 锁按 **root** 归属(不为使用旧 SQLite lease 造 `threads` 假行), + 远端绑定与本机 workspace 证据走同一套 `validate_binding_value`; + `RemoteStoreNotWired` 已删除,`open_deployment` 的远程分支是真装配。 + **未落**:故障注入实测(发送前崩溃/响应丢失/取消/迟到写仍是设计结论)、`recover_persistence` + 之外的崩溃恢复演练、E 全链路消费侧。证据见母 issue §9.13。 + 第三批已落(未决收敛接线与统一门禁):`recover_session_persistence` 接成门面级收敛路径 + (先等本进程在途写入,再按本机日志里的原 id 向远端账本求证;缺行时用同一唯一键做封闭竞争; + 不需要调用方令牌);未决阻塞范围按后端解析(`LocalExecutionPort::pending_scope`: + 远程会话的 root 只能由远端父链给出,否则同根子会话的首次写入会漏过门禁), + 门面按「id ∪ root」取并集;`mark_clean`/`drain`/写入门禁读同一条未结清谓词, + ordinary dirty 的解除不解除远程未决;故障注入面(`FaultPlan`,仅测试构建)落在真实批上, + 真引擎回归覆盖「响应丢失 → 已生效」「发出前消失 → 封闭 → 迟到原请求整批回滚」 + 「root 未决阻塞同根子会话首次写入 → 恢复后放行」「重启(同一份本机日志的新打开)+ 目标 + 被另一份安装删掉 → 按账本收敛、目标不被重建」,本机事实端口上的注入覆盖「登记失败 ⇒ + 不发送且不是未决」与「结清失败 ⇒ 保留阻塞、恢复判已生效」。**未落**:删除墓碑在恢复后的 + 收尾(不影响未决判定与 identity 终态)、真实进程 kill 演练、E 全链路。证据见母 issue §9.14。 +- C-05:接 D 装配、E 全链路,用 F 确定性测试和显式云实验分别验收。 + 第一批已落(部署入口端到端):新增 `remote/cloud_deployment_test.rs`(父进程拉起子进程 + + 用新连接在阶段之间核对)与 `remote/cloud_deployment_child_test.rs`(写入 / 冷恢复 / 只读三 + 个进程阶段),全部经 `Resources::open_deployment` 真实入口、temp HOME 与合成 workspace: + 创建 → 追加/排空 → compact → fork → child → 标题 A→B→A → close → **新进程**冷恢复 + (未决收敛 → ordinary dirty → rewind → 删树)→ 两种本机状态的显式只读(全新 HOME 一个文件 + 都不建、已登记 HOME 写入按 `ReadOnlyStore` 拒绝,两者都不新增行与账本)。落在完整装配路径上 + 的两条事实:标题 A→B→A 落到第三次的值;fork 复用 source `message_id` 被明确拒绝。 + 同时清掉「已落地、消费方未接入」的残留标记(`SessionStoreOpenRequest` 的三个访问器、 + 桥的 `data_port`)。**未落**:E 全链路消费侧接入、真实 kill 演练。证据见母 issue §9.15。 + +退出条件:两 adapter 使用同一领域结果断言;真实 Turso 冷恢复成立;未知结果不会让旧写与新 owner 并发;业务接口没有新增存储机制。任何一项未证明,保持未完成。 diff --git a/spec/issues/2026-09-26-session-store-sub-plan-d-configuration.md b/spec/issues/2026-09-26-session-store-sub-plan-d-configuration.md new file mode 100644 index 000000000..c7c1ba576 --- /dev/null +++ b/spec/issues/2026-09-26-session-store-sub-plan-d-configuration.md @@ -0,0 +1,176 @@ +# 会话资源拆分 — 子计划 D:定位、凭证与部署装配 + +> 状态:设计计划,未实施。日期:2026-09-26。 +> 上级:[总计划](2026-09-26-session-store-plan.md)。依赖:[A](2026-09-26-session-store-sub-plan-a-contracts.md)、[B](2026-09-26-session-store-sub-plan-b-local.md)、[C](2026-09-26-session-store-sub-plan-c-turso.md)。消费迁移见 [E](2026-09-26-session-store-sub-plan-e-consumers.md),测试见 [F](2026-09-26-session-store-sub-plan-f-verification.md)。 + +## 1. 目标 + +打开入口表达「会话存储在哪里、如何访问」,而不是「SQLite 文件在哪里」。Resources 负责唯一后端装配;TUI、print、ACP stdio、meta 不各自解释远程 SDK 或降级。 + +保持默认本机行为。只读元数据命令继续在 provider/settings/Agent 初始化前执行,不因为引入远程而加载所有配置、启动执行资源或创建本机执行登记。 + +## 2. 现有入口与迁移清单 + +| 当前文件/符号 | 现行职责 | 本次变更 | +| --- | --- | --- | +| `peri-resources/src/context.rs::Resources::{open,open_with}` | `Option` → SQLite,写打开失败尝试只读 | 归一解析后的 open request,选择 adapter 与本机执行组合 | +| `peri-resources/src/sessions/mod.rs::open_thread_store_read_only` | 独立 SQLite 只读 seam | 转入同一选择器的明确只读模式,不走写打开再降级 | +| `peri-agent/src/resources.rs` | 资源打开薄入口(`open_thread_store{,_with}`) | 更名 `open_session_resources{,_with}`,返回 `Arc`(A §2.1);只保留一个入口,不再选择后端 | +| `peri-tui/src/main.rs` | `--db-path`/`dbPath`、meta 早路由 | 新定位参数、冲突验证、受限 meta grammar 同步 | +| `peri-tui/src/{launch.rs,app/mod.rs,cli_print.rs}` | 三条部署参数传递与打开;`App::new(db_path)` 与 `app.services.thread_store` | 传同一 open request,不重新读取环境决定不同存储;services 持有 `Arc` | +| `peri-tui/src/cli_meta.rs::run_meta_session` | UUID 校验后只读打开、九字段 DTO(`SessionMetaDtoV1`) | 保留早校验与 DTO;使用统一只读入口与安全错误映射;只读不写任何本机文件 | +| `peri-acp/src/host/stdio/mod.rs::StdioInput` | stdio 的 `cwd`/`permission_mode`/`db_path` 三个字段 | 携带同一资源定位描述,装配时只解析一次 | +| `peri-tui/src/thread/mod.rs`、`peri-agent/src/thread/mod.rs` | 具体 SQLite 类型 re-export(`SqliteThreadStore`/`FilesystemThreadStore`/`open_thread_store_read_only`) | 生产导出删除(A §7.1);测试迁至明确资源测试入口 | + +上述是路径清单,不表示 TUI 可以直驱执行;交互仍经 ACP。 + +## 3. 建议参数与解析决策 + +### 3.1 最小配置面 + +拟定公开输入: + +- `--session-store `:本机路径、已确认引擎的远程 locator(Turso Cloud 测试库),或 `env:<变量名>` 间接定位。远程引擎由 C-01 对目标库只读探测后选定(Turso 引擎 / libSQL 引擎两条候选,C §5.0);embedded replica 与 sync 不在首期支持面内,出现时按 Unsupported 报错,不当作另一种 locator 解释。 +- `--session-store-token-env <变量名>`:只表达凭证来源,不接受 token 字面量;远程模式必需。是否为它设一个统一默认名仍未决(G-03),在决定前不得内置任何默认或候选名(§6.1)。 +- 既有 `--db-path` 和 `--dbPath` 保留本机兼容性;与 `--session-store` 同时出现直接参数错误,不设隐式覆盖顺序。 +- 都未提供时仍打开默认本机 `threads.db`。仅设置 `TURSO_URL` 不自动切换后端,避免意外上传。 + +使用已有环境变量的拟定调用形态是 `--session-store env:TURSO_URL --session-store-token-env TURSO_TOEKN`;这是计划示意,不在本轮执行。变量拼写未确认(G-03):实现不得为它添加别名、同义词或“先试 A 再试 B”的回退,也不得把 `TURSO_TOKEN` 当作隐式同义。 + +第一版不新增完整 settings profile 系统。typed `SessionStoreOpenRequest` 位于资源装配层,包含解析后的 locator、凭证来源、访问意图;凭证对象不实现泄密的 Debug/Serialize。跨 crate 仅传与职责相符的中性类型,不把 SDK 类型放进 `peri-acp-types`。 + +### 3.2 解析顺序 + +1. 先验证 CLI grammar、互斥选项;meta 先验证 session UUID。 +2. 解析 locator:本地路径保留 Windows drive/UNC 语义,不能将 `C:` 当 URI scheme;`env:` 仅解引用一次且不递归。 +3. 只在选择远程后读取所指定的 URL/token 环境变量;空值、缺失、非法 URL 各有安全类型化错误。 +4. URL 禁止 userinfo 与携带凭证的 query;禁止日志输出完整配置。协议与引擎对应按 C-01 证据校验;仅有 `https` 不能可靠辨识引擎时明确要求显式选择,不猜测或轮流试两种 SDK。**`libsql://` 同样不能辨识引擎**(C §2 review-3:选定远程驱动的 remote URL 示例就是 `libsql://…`,libSQL 与 Turso 两种引擎都可能出现该 scheme),因此引擎只能由显式选择或已确认的 locator 语法给出;实现不得以 scheme、端口或响应探测推断引擎。 +5. 普通读写和显式只读使用同一选择结果进入 Resources factory。配置解析不是连接成功,更不是 schema 可写证明。 +6. 启动结果携带实际访问模式/行为能力;后续恢复到另一个 cwd 不能重新解析出另一个存储。 + +若 C-01 确认必须支持无引擎信息的 HTTPS URL,再增最小 `--session-store-engine ` 参数,与原生 scheme 冲突时报错;没有证据前不扩大配置面。 + +## 4. 凭证与 dotenv + +- 用户提供 `.env` 中已有 `TURSO_URL` 和 `TURSO_TOEKN`;计划不读取文件,不确认其值/引擎/有效性,不修改拼写。 +- 资源库只接受显式注入的凭证,不自行搜索 cwd 或父目录 `.env`;避免恢复会话时换目录悄悄换账号。 +- 云实验 runner 在明确给出的 `.env` 路径上使用 dotenv parser,只取所需键并注入受控测试子进程,不使用 shell `source`/`eval`,不 dump 整份环境。 +- 不因 meta 需要云凭证而开启常规 provider 配置加载;process env 和受限 locator 参数足够。产品若已有启动 dotenv 加载,保持其既有责任,不在 adapter 再加载一次。 +- 配置、错误链、SDK Debug、HTTP tracing 和测试快照不得输出 token、带凭证 URL 或原始响应正文;安全诊断使用行为名、错误种类和非敏感运行标识。 +- 不创建或提交真实 `.env`/token fixture;测试凭证值使用运行时生成的合成哨兵,仅用于检测不泄漏,禁止复制真实密钥。 + +## 5. 打开与只读语义 + +| 请求/故障 | 决策 | +| --- | --- | +| 默认或指定 SQLite 读写 | 保留现有 schema 检查、可恢复写打开失败后的只读尝试;两次都失败保持原错误意义 | +| 未知 SQLite schema | 保留现有版本/列形状判定细节,不简化为全部拒读或全部降级 | +| 显式 SQLite 只读 | 不创建目录、库、锁或迁移 schema | +| Turso 读写 | 先核实读写权限/行为可用性,再装配;不自动切回 SQLite | +| Turso 明确只读权限 | 可提供历史读取,执行/新建在副作用前拒绝;不得用普通写失败推导「只读可用」 | +| 鉴权失败/网络失败 | 保留安全分类与错误,不当空库、不初始化替代数据库 | +| 显式 Turso 只读/meta | 不发任何写请求、schema 初始化、installation 登记或 owner 获取;仅检查读取兼容性。**只读允许读取 schema/StoreId/binding**,但读不到 ≠ 空库可初始化 | +| 远程模式下本机 registry 库不可写 | 需要锚点的写/执行直接报错;**不允许**把只读请求静默降级成“远程只读会话”,也不为本机库另造降级路径 | +| 本机执行 registry 缺失 | 历史读取独立成立;写入/执行按 B 阻塞,不自动重建原绑定 | + +完整链(locator → 远端 schema → StoreId → 本机登记 → binding → workspace 证据 → owner)的状态/读写矩阵见 [B §6.1](2026-09-26-session-store-sub-plan-b-local.md);D 只负责第 1 环的解析与把 `AccessMode` 传给 factory。 + +`peri meta session` 的现有九字段 allowlist 与本机错误/退出码保持;新增远程错误映射应有明确测试。没有 wire 版本需求时不改变现有成功 DTO。错误至少区分缺配置/无权限/不可用/不兼容/目标会话不存在,不能统一回报数据库不存在。 + +## 6. Factory 与生命周期 + +Resources 持有一个稳定会话资源门面 `Arc`(A §2.1);SQLite factory 装配共享 pool 的数据 adapter/本机执行实现;Turso factory 装配远程数据 adapter、StoreId 和本机执行状态。消费侧不能取得裸数据写口,也不能取得具体 adapter 类型(`Resources::session_resources()` 是唯一访问器,`thread_store()` 随迁移删除)。 + +部署资源关闭应有唯一关闭 owner,clone 的业务门面不能随意关闭全局连接。先会话排空、确认持久化,再关 adapter worker/连接;`Drop` 只作兜底释放,不代替已完成证据。现有 Runtime 不持久化资源事实,不在 Runtime 另开连接/注册表。 + +D 只负责构造/持有关系和参数传递;会话 close 的生命周期行为归 B/E,远程收敛归 C。 + +### 6.1 云授权状态(2026-09-26 本轮更新) + +用户已确认 `.env` 指向**测试库**,授权只读探测、初始化本任务 schema、合成数据与只清理本轮对象; +原先记录的 `cloudAuthorized=false` 由此失效(真实云验收不再因此 blocked)。凭证变量名拼写已由 +只读键名报告确认(`TURSO_URL` / `TURSO_TOEKN`);实现仍不内置默认名、别名或候选名。 + +D-01–D-03 已落:typed `SessionStoreOpenRequest`、locator 纯解析(本机路径、`env:` 引用、远程端点; +引擎只由已确认语法或显式选择给出)、`Resources` 作为唯一后端选择点;显式只读走独立 +只读 seam(不建目录/库/锁、不迁移 schema);远程 locator 在 adapter 落地前返回类型化 +`RemoteStoreNotWired`,不降级为本机库。**D-04 已落(同日第三轮,见下)**;**D-05 未做**:关闭 owner +与协议映射(E 联调)仍待实施,真实云仍未实跑。 + +第四轮(同日)复核:D-01–D-04 的落盘在资源/CLI/TUI(含 meta、print)/ACP stdio 的受影响测试目标上 +复核通过(命令与结果见母需求 §9.7)。两处遗留如实记录、不在 D 面临时补:① +`Resources::open_with(Option)` 与 `peri_agent::resources::open_session_resources{,_with}` +在生产已无消费方(调用方只剩测试),生产面统一走 `open_deployment`,归一或删除随下一批; +② §8 验收 4 的「显式只读对两后端均无写副作用」目前只对本机后端成立,远程只读要等 C-02/C-03 +落地后才有对象可验。 + +同日第二轮补齐 D-01–D-03 的接口与回归面(仍未做 D-04/D-05): + +- 类型命名落定:`StorageLocator`(未解析输入:默认 / 本机路径 / 原文 / `env:` 引用)与 + `SessionStoreOpenRequest`(locator + 引擎 + 凭证来源 + 访问意图)。 +- `AccessIntent` 纯解析:只接受 `read-write` / `read-only` 两条拼写,无别名、无大小写模糊匹配; + 解析失败不回显原始取值。`Resources::open_locator(locator, engine, credential_env, access)` + 在进入任何 I/O 之前完成全部纯解析(拼写 / 引擎名 / locator 形态冲突)。 +- 环境变量读取面固定为两处:`env:` 形式的 locator 与远程 adapter 取凭证值。仅设置云 URL/token + 变量不切换后端,也不使默认本机库变成远程库(纯逻辑 + 打开层回归各一条)。 +- 显式只读失败保持类型化:`ReadOnlyThreadStoreError` 留在 source chain(消费侧可按 kind 映射 + 退出码),路径只加在 context 上;只读打开与普通打开共享同一选择点,不新增文件、不登记 owner。 +- 远程 locator 返回类型化 `RemoteStoreNotWired { engine }`,并注明这是 **C-02/C-03 adapter 落地前的 + 临时状态**:既不静默回落本机库,也不把「连上了」当成「可用了」。 + +同一日第三轮落 D-04(部署参数迁移): + +- 跨层中性类型 `peri_acp_types::session_store::SessionStoreDeployment`(locator 原文 / 可选引擎名 / + 凭证**来源** / `AccessMode`,`Debug` 不回显 locator 原文与凭证变量名):部署入口只传它,不传 + `Option`,也不传 SDK 或凭证值。 +- `Resources::open_deployment(&SessionStoreDeployment)` 是各入口唯一的装配点:进入任何 I/O 前把部署 + 参数一次性转成 typed open request(`SessionStoreOpenRequest::from_deployment`),再进唯一选择点。 + 取代并删除第二轮的 `Resources::open_locator`;`AccessIntent` 改为由 `AccessMode` 单向映射 + (部署面无拼写输入,`AccessIntent::parse`/`AccessIntentError` 随之删除,不留死接口)。 +- CLI:`--session-store`(含 `--sessionStore`)、`--session-store-token-env`、`--session-store-engine` + 遵循 D1 规则;`--db-path`/`--dbPath` 保留。两个定位入口互斥由 `validate_cli` 判定(错误早于任何 + I/O,且部署参数构造同样拒绝,不设隐式覆盖顺序)。meta 的受限 grammar 同步为只接受定位参数。 +- 入口接线:TUI `main`/`TuiOptions`/`launch`/`App`、`-p` print、ACP `StdioInput.session_store` 与 + `peri_agent::resources::open_session_resources_deployment`、`peri meta session` 早启动都传同一份 + 定位描述;`App` 持有门面后,`attach_acp` 与恢复会话不再重新解析存储。`peri_tui::thread` 对 + 消费侧的独立只读 seam re-export 一并删除。 +- meta 错误面:新增公开 `StoreOpenFailure` + `classify_open_failure`(按类型化 source chain 分类, + 不解析文本、不回显 locator/凭证);`store_not_configured`(2) 与 `store_unavailable`(4) 两个新 kind, + 缺库仍是 `database_not_found`(3),不统一回报成「数据库不存在」。 + +验证证据(本机离线;隔离 HOME;未实跑云、未新增 `#[allow]`/`#[ignore]`): + +| 命令 | 结果 | +| --- | --- | +| `cargo test -p peri-resources --lib` | 233 passed / 0 failed(`sessions::open_tests` 17 条) | +| `cargo test -p peri-tui --lib` | 1671 passed / 0 failed | +| `cargo test -p peri-tui --bin peri` | 85 passed / 0 failed(含新 CLI/meta 回归) | +| `cargo test -p peri-tui --test print_exit` | 9 passed / 0 failed(`--db-path` 兼容未退化) | +| `cargo test -p peri-acp --lib host::stdio` | 17 passed / 0 failed | +| `cargo check --workspace --all-targets` / `cargo clippy --workspace --all-targets -- -D warnings` | exit 0 | + +真实二进制端到端(隔离 `HOME`/临时目录,只读、无网络):缺库 → exit 3 `database_not_found`; +`--db-path` 与 `--session-store` 同给 → exit 2 `invalid_argument`;远程 locator → exit 4 +`store_unavailable`(输出不含 locator 原文);非法 UUID → exit 2 `invalid_session_id`;四条命令后 +临时目录仍为空(只读入口不建目录/库/侧车)。 + +第二轮遗留的接口描述(`open_locator` 与 `AccessIntent` 拼写解析)已被本轮取代。 + +## 7. 任务分解 + +- D-01:列全 `db_path`/`open_thread_store*` 的生产路径,建立解析输入与 typed open request。 +- D-02:实现 locator 纯解析、互斥、路径/URI/env 引用和凭证防泄漏测试。 +- D-03:统一 Resources 普通/只读选择;本机兼容入口仅归一转发。 +- D-04:迁移 TUI/print/stdio/meta/Agent resource wrapper 的部署参数;不留独立 SDK 打开路径。 +- D-05:集成关闭 owner、访问模式和安全诊断;与 E 的 protocol mapping 联调。 + +## 8. 验收 + +1. 相同 locator 从所有部署入口得到相同 StoreId/访问模式;默认路径仍本地且不访问网络。 +2. `--db-path` 与新参数冲突早于 I/O;非法 meta UUID 不读凭证、不连接、不创建文件。 +3. Unix 相对/绝对路径、Windows drive/UNC、空 env、递归 env、带凭证 URL 均有测试。 +4. 显式只读对两后端均无写副作用;本机读失败语义与既有测试一致。 +5. metadata DTO/退出码与安全错误检查覆盖人类输出和 JSON;不加入 frozen/消息正文/连接配置。 +6. 原始 token 和 URL userinfo 不出现在 Debug、错误或追踪;凭证缺失不被自动生成或 fallback 掩盖。 + +命令与测试安装位置由 F 统一登记;本计划不宣称任何拟新增 CLI 已存在。 diff --git a/spec/issues/2026-09-26-session-store-sub-plan-e-consumers.md b/spec/issues/2026-09-26-session-store-sub-plan-e-consumers.md new file mode 100644 index 000000000..bd1108150 --- /dev/null +++ b/spec/issues/2026-09-26-session-store-sub-plan-e-consumers.md @@ -0,0 +1,174 @@ +# 会话资源拆分 — 子计划 E:Agent / ACP / Controller / Middleware 消费迁移 + +> 状态:详细设计计划,未实施。日期:2026-09-26。 +> 上级:[总计划](2026-09-26-session-store-plan.md)。接口以 [A](2026-09-26-session-store-sub-plan-a-contracts.md) 为准,资源实现见 [B](2026-09-26-session-store-sub-plan-b-local.md)/[C](2026-09-26-session-store-sub-plan-c-turso.md),部署入口由 [D](2026-09-26-session-store-sub-plan-d-configuration.md) 负责,测试见 [F](2026-09-26-session-store-sub-plan-f-verification.md)。 + +## 1. 目标与不变量 + +一次性迁移全部生产调用点,去掉业务侧存储补偿/逐项更新/缓存失效协议;之后切换 adapter 不再改业务分支。 + +仍保持:TUI 交互经 ACP;循环与 compact 算法归 Agent;ACP 负责协议、冻结输入与环境装配;Controller 转发资源句柄;Runtime 管登记、取消/销毁转发但不持另一份持久化事实。文件恢复、进程关闭、MCP/LSP/hook 生命周期不是数据库事务,不交给 adapter。 + +## 2. 生产入口覆盖登记 + +| 编号 | 文件/符号 | 必须迁移的内容 | +| --- | --- | --- | +| E-01 | `peri-controller/src/controller.rs::{new,sessions}`(现 `sessions: Arc`,`controller.rs:172,198,290`) | 字段与访问器类型改为 `Arc`;**名称保留**,不另存数据/执行注册表 | +| E-02 | `peri-agent/src/resources.rs`(`open_thread_store{,_with}`)、`peri-agent/src/thread/mod.rs` | 更名 `open_session_resources{,_with}` 并返回新门面;删除 `ThreadStore`/`SqliteThreadStore`/`FilesystemThreadStore` 生产 re-export | +| E-03 | `peri-acp/src/host/{mod,assemble,workspace,stage_builder}.rs`(`AcpServerConfig.thread_store`,`host/mod.rs:194`;`HostAssemblyInput.thread_store`,`assemble.rs:123`) | **删除 `AcpServerConfig.thread_store` 字段**,存储只经 `cfg.controller.sessions()`;`HostAssemblyInput` 字段改名 `session_resources`;per-workspace 装配(`workspace.rs:71`)复用同一 Arc,不开第二个 store | +| E-04 | `peri-acp/src/session/mod.rs`、Agent session/exec context | SessionManager、CommandContext、executor 配置携带新门面(`CommandContext.thread_store` → `session_resources`) | +| E-05 | `peri-agent/src/session/transcript{.rs,/persistence.rs}` | append/flags/rewind/compact/flush 和失败传播;writer 仍唯一(unbounded channel + 64 条/100ms 批量基线) | +| E-06 | `peri-agent/src/session/exec/{compact_pipeline,executor_helpers/intercept,executor_helpers/v2_execute}.rs` | 一致快照恢复、compact 结果、热态失效 | +| E-07 | `peri-agent/src/session/subagent/factory/{spawn,resume,claim,context}.rs`、`subagent/lifecycle.rs` | child 完整保存、resume claim、继承区、status 与根 owner;child frozen 来源见 §6 | +| E-08 | `peri-middlewares/src/subagent/tool/{execute_resume,spawn_context,configuration}.rs` | 读取 metadata 与 SubagentHost 传参走门面,不残留 raw store | +| E-09 | `peri-acp/src/host/requests/{session_lifecycle,legacy_session}.rs` | new/load/resume/fork/close/delete/rename/list/metadata 全路径(`handle_new` 的 delete_thread 补偿链,`session_lifecycle.rs:545-620`) | +| E-10 | `peri-acp/src/dispatch/{session_fork,session_load,list_sessions,rewind}.rs` | 快照/纯 fork 映射/历史回放/回退;删除 `session_fork.rs:117` 的逐条 `update_message_flags` 与 `session_fork.rs:50` 的 `delete_thread` 存储补偿 | +| E-11 | `peri-acp/src/session/command/{compact,rewind}.rs` | slash 和 RPC 语义一致,不另建存储旁路 | +| E-12 | `peri-acp-types/src/{command,session}.rs` 及实际持有 ThreadStore 的共享类型 | 逐引用替换,不因只改顶层而让 context/host 继续混用 | + +上述大括号为定位缩写;实施前全仓搜索 `ThreadStore`、`SqliteThreadStore`、`thread_store`、`open_thread_store`、`controller.sessions` 补齐新出现引用。D 负责 TUI/print/stdio/meta 的外部参数与 factory,本计划负责装配后的业务行为和错误映射。 + +### 2.1 已核实的生产旁路清单(review-2) + +以下为静态核对到的真实 raw 调用点,迁移时必须逐个改走完整行为,不能只改类型: + +| 位置 | 现状 | 目标 | +| --- | --- | --- | +| `peri-agent/src/session/transcript/persistence.rs:171-191` | `delete_messages_since` / 逐条 `update_message_flags` + `invalidate_context_cache`(部分失败仍 invalidation) | 一次完整行为(projection/compaction/rewind),cache 与 flags 同行为维护 | +| `peri-acp/src/dispatch/session_fork.rs:50,117,145,161,169-172` | `delete_thread` 补偿、逐条 flags 往返、`acquire_execution_lease` + `append_payloads` 手工分步 | 一次 `save_fork(ForkSnapshot)` + 由门面取得目标 owner | +| `peri-agent/src/session/exec/compact_pipeline.rs:131,142,168` | `supports_compaction_lifecycle()` 能力探测 + `load_messages`/`load_message_flags` 分次读 | 一致快照读取 + 领域能力判定 | +| `peri-agent/src/session/exec/executor_helpers/{intercept.rs:328-341,v2_execute.rs:155-156}` | 分次 `load_inherited_context`/`load_payloads`/`load_message_flags` | 一次一致 `load_session_snapshot` | +| `peri-agent/src/session/subagent/factory/spawn.rs:216-227` | `create_bound_thread`/`create_thread` + `store_inherited_context` + 失败时 `delete_thread` | 一次 `save_child(ChildSnapshot)` | +| `peri-agent/src/session/subagent/factory/claim.rs:105-140` | worker 内 `update_thread_status("active")` 与补偿写 | 门面 `claim_child_resume` 内部持有状态写入 | +| `peri-acp/src/host/prediction.rs:19,101` | 直接持 `cfg.thread_store` 写标题 | 经门面定向 metadata 更新 | + +## 3. 冻结输入准备与新建 + +### 3.1 当前约束 + +`handle_new` 当前顺序是 resolve → create bound thread → lease → `SessionEnvironment::assemble` → build frozen → store frozen → 发布。这使 frozen 失败补偿散落在 ACP。 + +`host/assemble.rs::build_legacy_frozen_data` 已可不启动 MCP/hooks/tasks 地生成 frozen,但插件发现可能修复 manifest cache;`session/frozen.rs::build_frozen_data_with_config` 会读指引/skills/meta、探测环境、生成日期。它们不是纯函数,不能直接宣称可无副作用地提前执行。 + +### 3.2 目标准备结构 + +拟引入 ACP 内部 `PreparedSessionInputs`(不是数据端口类型)。**最终字段固定如下**(review-2 闭合;准备一次、后续只读消费,不再重读): + +```rust +pub(crate) struct PreparedSessionInputs { + pub cwd: String, // 规范化后的执行目录 + pub config: Arc, // 从同一 ConfigSource 读出并合并一次的视图 + pub config_source: Arc, // 路径决策事实源(后续持久化沿用,不再判定) + pub provider: LlmProvider, // 由同一 config 解析(或环境变量),失败即准备失败 + pub plugin_data: Option,// 一次加载的插件聚合(roots/commands/hooks/lsp/mcp) + pub skill_roots: Vec, + pub agent_dirs: Vec, + pub frozen: FrozenSessionData, // 由上述输入构建一次 + pub frozen_encoded: String, // 版本化 snapshot 字节(数据端口只存不渲染) + pub legacy: Option, // 仅 legacy:取自保存的绝对 cwd +} +``` + +规则: + +- **lease 之前只读**:准备阶段不启动 MCP/LSP/hook/cron tick/Workflow/Agent loop,不创建 thread、不占 lease、不做 cache repair,也不写任何会话数据或本机登记。 +- **插件 manifest 合成修复移出准备路径**:已核实 `PluginLoadResult` 的加载会经 `try_generate_synthetic_manifest_fallback` 往插件缓存目录写 `plugin.json`(`peri-middlewares/src/plugin/loader.rs:91-140,620-630`)。准备阶段改用**严格只读**加载入口(新增,例如 `load_enabled_plugins_readonly`);清单缺失/非法在准备期直接失败并定位插件,不做静默跳过、不做修复。修复只在授权后的原责任层(插件管理命令/交互路径)发生,且不得改动已冻结的输入。 +- **同一对象消费到底**:frozen 字节由同一 `PreparedSessionInputs` 产出(`frozen` / `frozen_encoded` 同源),环境装配使用同一 `config`/`provider`/`config_source`/roots,不第二次 `ConfigSource::load_at`、不第二次加载插件。装配期新增的只有既有副作用资源(MCP pool、LSP pool、hooks、cron),它们仍在发布之后创建。 +- **date/env 一次定格(review-3 明确)**:日期与运行环境探测只在准备阶段求值一次,结果随 `frozen` 一并定格,装配与后续持久化一律消费该结果,不重新取时间、不重新探测环境。已核实现状:`build_frozen_data_with_config` 已冻结 `frozen_date`(`session/frozen.rs:43,73`)与 cwd,但 `PromptEnv::with_frozen_date` 仍在**调用时**重新探测 `platform`/`os_version` 与 `is_git_repo`(`prompt/mod.rs:103-113`,源码注释已写明“调用方若需冻结也应缓存”);准备结构必须使这些取值与 frozen 同源,装配期不得再调 `detect`/`with_frozen_date` 各取一份。若装配期确需同一事实,从 `PreparedSessionInputs` 读取;两处取值不一致即视为准备结构缺陷,停止该批次而不是让两处各写一份。 +- 若准备无法与写副作用分离,停止该实施批次并回到 A/B 调整初始化领域状态;不得退回公开 create/transaction/frozen/rollback 拼接。 +- **三条路径明确区分**: + | 路径 | 准备输入 | frozen 来源 | 消费行为 | + | --- | --- | --- | --- | + | new | 上述完整结构 | 由同一输入构建一次 | `create_session(NewSession)` | + | legacy | `LegacyAdoptionInputs`:保存的绝对 cwd + 该目录的 config/plugin/frozen 构建结果 | 按保存的 cwd 构建(现有语义) | `adopt_legacy_session`,返回权威快照 | + | fork | 仅 source 一致快照 + 领域 ID 映射 | **不构建**,直接复用 source 的精确 frozen 字节 | `save_fork(ForkSnapshot)` | + | child | parent/root 关系 + 继承 payload/flags | 由不可变 parent/root 的**已持久化** frozen 源取原字节(见 §6) | `save_child(ChildSnapshot)` | + +### 3.3 新建目标流程 + +1. 解析期望 cwd、只读完成 workspace 发现与 `PreparedSessionInputs`(含 frozen 字节);此步不占 lease、无 cache repair、无执行资源。 +2. 生成一次 ThreadId 与完整 `NewSession`,调用门面 `create_session`;门面内部按 B §5.1 顺序执行 creation intent → 完整数据保存 → 执行代际 → owner,并返回权威身份/owner(或 `SavedButNotAdmitted`)。 +3. 使用同一 `PreparedSessionInputs`(不重读配置/插件)装配环境,并按返回的身份/owner 复核目录。 +4. 环境准备成功后注册 SessionManager/live state,建立实际需要的 Workflow/LSP 句柄;激活与发布保持现有顺序。 +5. `session/new` response 成功后发送首个 commands snapshot,再允许 MCP prewarm;不让发现结果抢到初始化事件前。 +6. 环境装配失败:ACP 排空已实际建立的资源,未完成时保留关闭 owner;只有执行资源已排空才调用门面的初始化失败行为,由内部处理数据撤销/恢复。ACP 不发原始 delete/frozen/CAS 序列。 + +初始化失败使用 A 的 `abandon_initialization` 领域行为;它不是 rollback 别名,只针对本次未发布的 new/fork/child,不能用于撤销已发布会话的任意历史。执行资源是否排空仍由实际 owner 证明,不用一个未经验证的布尔值绕过生命周期。 + +## 4. Load / resume / legacy / fork + +### 4.1 Load 与 resume + +- 从一致会话快照读 binding/frozen/history/flags,metadata 读取仍可走轻量入口。 +- 已绑定、真正本机 legacy、外来登记、持久化未决分别处理。不得把缺 binding/未支持当 legacy。 +- 本机 legacy 输入只从保存的绝对 cwd 构建,经门面专用接纳返回权威快照;不让 ACP 处理 winner bool 或二次写冻结。 +- 未知远程写先由门面恢复;若仍阻塞,不触发普通 dirty 自动 reset,不装配写执行资源。 +- owner 不可得时保留已有只读准入和历史回放;只读路径不启动环境、Workflow/LSP/MCP,也不回填 frozen/登记。 +- 请求 cwd 仅作期望校验;成功提交 live state 后才公布 active identity/cwd。load reservation、操作 gate、失败时旧状态恢复/NoSession 保持。 +- 原 `session/load` 先回放后响应的顺序、TUI reset 后再次回放的清边界规则不变。 + +### 4.2 普通 fork + +1. source 必须 idle 且具有 owner,持现有生命周期 gate 至快照取得/复制完成。 +2. 门面提供一致 source snapshot;领域纯函数验证工具往返完整性并产生固定新 ID 映射。 +3. 一次传完整 `ForkSnapshot`(payload、flags、source 原 binding/frozen),门面保存并取得目标 root owner。 +4. ACP 用 source 精确 frozen 装配新会话,不按当前日期/目录重冻;failure cleanup 与 new 相同。 +5. 删除 `cleanup_failed_fork` 式存储补偿和逐条 flags 往返。普通 fork 不引入 inherited ancestor;owned child 才有该区分。 + +## 5. Agent transcript 与运行期 + +### 5.1 保留一个 writer + +保留 FIFO、当前 64 条/100ms 的批量基线、Barrier/Shutdown 语义;不要在资源层再建一条可独立漂移的 transcript 队列。adapter 内部网络任务不拥有第二份业务历史。 + +- append 使用 canonical payload(包括可信 reminder),成功后计数/标题由数据行为维护。 +- `PersistOp::ApplyCompactionBatch` 转完整 projection 行为,不逐条 `update_message_flags` 再 invalidation。 +- Full compact 先 flush,再 `apply_compaction`,确认成功才改变内存 flags/摘要;未知或 writer failure 保留磁盘事实并使热态失效。 +- compact 输入来自一致 snapshot,不拼先 messages 后 flags 的远程跨时刻结果。 +- 异常分类在资源边界转安全错误;不要把 SDK 原始错误直接写 tracing 或 error body。 + +### 5.2 有界积压 + +当前 unbounded channel + 失败后 256 条缓冲不限制 in-flight 慢网络期间积压。计划使用**共享的待持久化条数/字节预算**覆盖 channel、pending batch 和 in-flight;具体阈值由 F 测量后确定。 + +不在 transcript 写锁内 await bounded sender。同步追加先预留预算;无法预留时设置 sticky 持久化失败,并由最近的循环/flush 边界停止后续模型/工具工作,丢弃热态信任。已在运行的工具按原生命周期排空,不声称取消了已发生副作用。若数据已进入 canonical 内存但未获准持久化,必须明确报未保存,绝不能算成功。 + +预算释放以行为效果已确定/缓冲已真实释放为准,取消调用方不等于释放仍被 adapter 持有的 payload。目标测试用可控暂停证明积压有界及错误及时到达,不只断言队列容量常量。 + +## 6. 子 Agent 与 middleware + +- spawn:领域侧确定 parent/root、保存 cwd、frozen 继承和 inherited payload/flags,一次 `save_child`;成功后才构造执行 session、注入首轮消息并启动。 +- child frozen 来源固定为**不可变 parent/root 的已持久化 frozen 字节**:数据行为解析 parent(无 parent 则 root)链并校验根 frozen 存在且有效,必要时原样复制字节;禁止重新扫描目录、禁止用当前目录/日期重冻。已核实现状是**父 session 内存副本**(`peri-agent/src/session/subagent/factory/spawn.rs:114-123,239-247` 从 `p.store().frozen.*` 取),这在冷恢复(父会话未加载)时没有来源,必须改为从持久化快照解析;同一会话内若使用内存副本,必须证明它与已保存快照逐字节相同(同一快照加载而来、期间未重渲染)。Agent 不依赖 ACP codec 编码 frozen。历史子会话沿原父链恢复,根 frozen 缺失/损坏时明确失败,不偷偷增加新的 JSON 格式。 +- resume:保留 parent/root 与绑定一致性验证;读取一致 own/inherited/flags,先拿有效 root 执行权,再恢复模型/工具集合。 +- `ResumeClaim` 的 worker 目前持有 active 写入及失败补偿,避免调用 future drop 丢失收尾。迁到 A 的 `claim_child_resume`,Agent 只报告开始运行/移交后台/准备失败/终止等领域结果,门面内部持有状态写入与恢复工作。数据端仍只接定向状态行为,不接远程 operation identity;不能只换类型后留下 status Unknown 仍开跑。 +- done/cancelled/error status 定向更新,不覆盖并发标题/计数;写失败进入既有可信终态处理。 +- middleware 的 `execute_resume` metadata 读取、`SubagentHost` 注入和工具 configuration 同步切门面;Workflow 内产生的 Agent 同样消费这一路径,不设独立后端。 + +## 7. Rewind、删除、关闭与协议错误 + +### Rewind + +区分 transcript `KeepThrough` 与用户 `RemoveFrom`;历史侧是一次门面行为,cache 与 flags 一起维护。本机文件复原仍由现有执行层完成,它与远程数据库不构成单事务。文件已改而历史保存失败时报告真实部分效果、阻止不可信热态,不由 adapter 宣称全部回滚。 + +### Close/delete + +维持既有 ACP MCP session close、SessionEnd、task drain、dynamic MCP、MCP pool/LSP 等排空顺序。只有拥有者给出真实执行收尾证据后才请求门面结清持久化并 clean;任一未完成保持 Closing/Incomplete 和唯一 owner。 + +delete 是显式生命周期行为,执行先关闭,数据删除由门面协调并在同事务写入墓碑(B §4.4);远程删除已发生但未知时不提前清本机 pending/dirty,也不提前删墓碑。rename/list/metadata/history 的短时准入或只读规则同样迁移,不能只覆盖 new/load。 + +### 错误映射 + +- 新 `PersistenceUncertain` 独立于普通 dirty,不进入 `ReadOnlyAdmission::from_workspace_error` 的自动 reset 分支。 +- ACP 只映射领域错误,现有只读 reason 与能力协商保持兼容;必要新增 wire 字段必须按 caps 门控并覆盖客户端消费,不能直接塞 SDK 内容。 +- 执行不可用不等于历史不可读;已保存未准入返回可定位 identity,而不是误报“会话不存在”。 +- 不新增用户必须操作的数据库恢复令牌;可见恢复行为围绕 session identity。 + +## 8. 施工顺序与退出检查 + +1. E-01…04:门面类型/注入归一,删除 `AcpServerConfig.thread_store`,禁止 raw-store 旁路;与 D 串行协调共享装配文件。 +2. E-05…06:transcript/compact 输入与保存行为、积压预算及 Unknown 传播(`MutationOutcome` 三态)。 +3. E-07…08:child/resume claim/status/middleware/Workflow 传播。 +4. E-09…11:准备输入、新建/恢复/fork/close/rewind 全路径;保留响应与资源生命周期顺序。 +5. E-12:扫描共享类型、测试替身和旧出口,消除生产 old/new 双写路径(符号删除 + 全 target 编译证据,A §7.1)。 + +F 的 V-02…15 与既有 ACP/Agent/Runtime 回归保护。搜索旧方法只能用来发现遗漏,不能以零字符串匹配代替行为测试。完成标准:SQLite/Turso 选择不出现在这些业务文件中;调用方不再编排 flags/cache/补偿/事务协议;默认与远程都使用同一条受授权的会话路径。 diff --git a/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md b/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md new file mode 100644 index 000000000..b9d72af9f --- /dev/null +++ b/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md @@ -0,0 +1,172 @@ +# 会话资源拆分 — 子计划 F:契约回归、故障验证与云端实验 + +> 状态:验证计划,所有待新增检查尚未执行;不代表已有测试通过。日期:2026-09-26。 +> 上级:[总计划](2026-09-26-session-store-plan.md)。覆盖 [A](2026-09-26-session-store-sub-plan-a-contracts.md) / [B](2026-09-26-session-store-sub-plan-b-local.md) / [C](2026-09-26-session-store-sub-plan-c-turso.md) / [D](2026-09-26-session-store-sub-plan-d-configuration.md) / [E](2026-09-26-session-store-sub-plan-e-consumers.md)。遵循 [testing.md](../../docs/standards/testing.md)。 + +## 1. 验证层次与证据规则 + +1. **纯逻辑**:显式输入/输出验证 fork ID/flags 映射、继承区、字段 patch、locator 和错误映射,不依赖网络、真实时间或全局环境。 +2. **数据行为契约**:相同场景与后置条件覆盖 SQLite 和远程 adapter,不断言两者共享 SQL 或事务步骤。 +3. **资源/执行生命周期**:真实门面 + 数据实现 + 本机执行;OS 锁、进程重启、dirty/close 用真实进程,不以 mock lease 替代证明。 +4. **跨层链路**:ACP new/load/resume/fork/prompt/close 与 Agent transcript/subagent 经真实资源门面;模型和外部工具在相应边界替换。 +5. **显式云实验**:唯一外部网络验证层,使用独立授权目标、合成会话、真实 Turso adapter;不加入默认 offline CI,也不能被本机模拟通过替代。 + +区分已有用例、拟新增用例、实际执行结果。每项证据记录命令、exit、命中测试数量、环境、失败或 skip 理由;0 tests/ignored/缺凭证不得算通过。故障先被目标回归暴露再修复,不以多跑几轮证明确定性。 + +## 2. 现有回归锚点 + +以下文件/测试已在源码确认,迁移时保护其行为而非内部构造方式: + +| 范围 | 现有锚点 | +| --- | --- | +| 默认打开/降级 | `peri-resources/src/context_test.rs` 的 `test_open_with_busy_schema_lock_degrades_to_read_only`;`sessions/default_path_test.rs` | +| schema | `sessions/sqlite_store/schema_test.rs`:`test_single_database_failed_upgrade_rolls_back_schema_and_version`、`test_single_database_future_version_is_rejected_before_writing`、保留历史及辅助表场景 | +| legacy 原子接纳 | `sessions/sqlite_store/legacy_test.rs`:`legacy_adoption_commits_snapshot_once_across_concurrent_restorers`、`legacy_adoption_failure_rolls_back_binding_and_snapshot`、`legacy_children_follow_adopted_root_execution_owner` | +| owner/close | `sessions/sqlite_store/workspace_test.rs`:`test_worktree_bound_writes_require_owner_and_metadata_cannot_rebind`、`test_worktree_clean_waits_for_admitted_mutation_before_releasing_os_ownership`、`test_worktree_cancelled_mutation_remains_dirty_and_cannot_publish_clean` | +| 跨进程 dirty | 同上:`test_worktree_execution_competes_across_processes_and_crash_remains_dirty`、`test_worktree_dirty_reset_held_stale_and_exact_generation` | +| compact | `sessions/sqlite_store_test.rs`:`test_commit_compaction_lifecycle_persists_flags_and_appended_messages_in_order`、`test_commit_compaction_lifecycle_rolls_back_flags_and_appends_when_message_is_missing` | +| inherited | `sessions/sqlite_inherited_context_test.rs`:跨 reopen payload/flags 冻结、坏版本/外来 flags、不存在截止点和循环 | +| ACP | `host/requests_test.rs`、`host/compact_recovery_test.rs`、`dispatch/session_fork_test.rs`、frozen snapshot 测试 | +| Agent | `session/transcript_test.rs`、`session/exec/executor_helpers/compact_cancel_test.rs`、subagent 测试 | +| Runtime | `peri-runtime/src/runtime_test.rs`:持久化失败保留映射、destroy 取消可重试;本次不迁移其事实所有权 | +| TUI | meta/CLI 相关测试及 load reservation、compact replay reload;不建立渲染输出单测 | + +## 3. 新增行为覆盖表 + +编号是计划覆盖项,不是已经存在的 test 名称。 + +| 编号 | 场景与断言 | 负责计划/测试层 | +| --- | --- | --- | +| V-01 | 接口审阅:无事务句柄、CAS/隔离参数、SQL batch、底层 retry token;业务点无 adapter 类型分支 | A + 静态审阅 | +| V-02 | fork 给定 ID 映射保持工具往返、projection 引用及 flags;ancestor 不变 | A/E + 纯逻辑 | +| V-03 | 新建/child/fork 完整保存后才可正常加载;初始化失败/取消不能留下可执行半成品 | B/C/E + 契约/生命周期 | +| V-04 | append 混合 Message/Reminder 保序、flush 后新连接重读;批次失败不假成功 | B/C/E + 契约 | +| V-05 | compact 整体生效、计数/缓存一致;不存在/跨 thread 的 flags 目标整项失败 | B/C + 契约 | +| V-06 | frozen 不覆盖,legacy winner 返回同一权威快照;绑定缺失/损坏/外来不混为 legacy | B/C/E + 契约/ACP | +| V-07 | inherited payload+flags 冷恢复,子会话写入仍要求根 owner | B/C/E + 契约/Agent | +| V-08 | rewind/delete 精确目标,删除树后计数/查询一致;未知截止点保留现行行为 | B/C/E + 契约 | +| V-09 | 状态/标题定向更新不覆盖并发字段;列表不读取大 blob,分页不全量下载 | B/C + 契约 | +| V-10 | 同 StoreId 多 locator/进程仅一 owner;工作区替换/移动拒绝;只读历史独立 | B/C + 真实进程 | +| V-11 | root close 等待所有已准入写入与子任务;取消 future 不释放真实后台写入 | B/C/E + 确定性屏障/进程 | +| V-12 | 不支持/只读/只写在副作用前失败;确定缺失与未知/不支持不同 | A/B/C/D + 契约 | +| V-13 | `.env` 不自动切存储;locator 冲突、UUID 错误在连接前失败;meta 不创建登记 | D + CLI/资源 | +| V-14 | 外来 installation 或 registry 丢失:历史可读、执行拒绝,不自动 rebind | B/C/E + 重启 | +| V-15 | 生产 ThreadStore 旁路消失:旧符号已删除,`cargo build --workspace --all-targets` 与 Clippy 通过;grep 只用于发现遗漏 | E + 编译证据(A §7.1) | +| V-16 | 默认 SQLite schema、旧历史和降级语义不变;跨平台锁/路径保证保持 | B/D + 既有回归 | +| V-17 | 写入三态:只有 `Applied` 或 `NotApplied` 释放准入;取消/超时/丢响应后 `finish` 未发生,同根后续写入与 clean 被拒;普通 `Err` 不再自动 finish | B + 契约/注入 | +| V-18 | v7 迁移:v6 库(含 `clean=0` 的 dirty 行与辅助表)升级后 dirty 代际原样保留、行数不变;迁移中途失败整体回滚且 `user_version` 仍为 6;只读打开 v6/v7 均不迁移、不建表;future 版本被拒 | B + 契约 | +| V-19 | 删除墓碑:`DELETE FROM threads` 后 `session_lifecycle_commitments` 墓碑仍在(不被 cascade),`deleting` 半态按 `deleted` 幂等处理,同 identity 不可重新登记;未决持久化存在时删除被拒 | B/C + 契约 | +| V-20 | 登记链拒绝路径:无本机登记 + 远端已有数据 → 只读历史且不自动登记;locator 别名不产生第二个锁域;显式只读不初始化 schema/StoreId/登记/owner、不建锁文件 | B/C/D + 契约/真实进程 | +| V-21 | 准备阶段只读:`PreparedSessionInputs` 准备期不写插件缓存(清单缺失直接失败)、不启动 MCP/LSP/hook/cron、不创建 thread/lease;装配与 frozen 使用同一对象,无第二次 config/plugin 读取 | E + 契约 | +| V-22 | C-01 前置条件 P1–P7 有实测证据(原子提交、唯一键竞争、写串行化、冷连接权威读、重试策略、超限拒绝、记录不清理);未证明时远程写保持关闭 | C + 隔离实验 | + +## 4. 可控远程故障矩阵 + +在 C 的私有传输 seam 注入延迟/断连,控制本地 adapter 真实数据行为的进度;测试不能只让一个 mock 返回预设成功后再断言成功。使用屏障/通知,不以 sleep 猜测请求到了哪一步。 + +| 故障 | 观察点 | +| --- | --- | +| 发送前失败 | 无远端数据变化,无无谓 dirty 清理授权 | +| 中途约束失败 | 整项行为无部分可见结果;pipeline 后续 SQL 不错误提交 | +| 保存成功但响应丢失 | 原行为只生效一次;恢复得到原结果,热态不能假回滚 | +| 延迟写晚于客户端取消/重启 | 恢复封闭与原操作竞争后至多一方生效,迟到写不能污染新执行 | +| 本机登记落盘失败 | 发送前失败或持续阻塞,不能丢失未决操作证据 | +| 已收到成功但本机结清失败 | 保留未确认状态,重开可收敛,不发布错误 clean | +| 权限拒绝/限流/服务不可用 | 安全类型化结果,没有本地库 fallback、无秘密输出 | +| 永久网络阻塞 | 请求/close 有界返回未完成,积压策略可见且内存有界,不悄悄丢消息 | +| new/fork 清理失败 | 不发布可执行会话,失败资源仍有 owner 与恢复路径 | + +使用相同 scenario 函数验证 SQLite 与选定 SDK adapter 的行为:限定在一个契约测试目标内参数化,不建立全仓库共享 `test_helpers` 框架。私有实现测试可断言收据/封闭细节,但外部契约测试只断言用户可观察结果。 + +## 5. 真实 Turso Cloud 实验方案 + +### 5.1 运行前置条件 + +- **G-03 已按用户授权解除(仅限下述范围)**:用户确认 `.env` 指向**测试库**,允许初始化本次 schema、写入/读取合成会话,并**只清理本轮创建的数据**。仍不得:读取或上传本机真实历史、真实项目指引/frozen;清空共享库;drop 未知表;新建计费资源。凭证只在 C 阶段脚本/测试进程内经 dotenv parser 注入必要键,禁止 Read/cat `.env`、env dump、shell source/eval、打印 URL/token,也不把真实值放进工具参数、源码、fixture 或报告——只输出键是否存在、脱敏引擎与验证结果。 +- 库引擎不由计划预设:C-01 对授权测试库做**只读**、最小化的引擎探测(版本/引擎标识),据此在 Turso 引擎与 libSQL 引擎两条 SDK 路线中选定一条并记录脱敏结论([C §5.0](2026-09-26-session-store-sub-plan-c-turso.md));探测失败即记阻塞,不靠「拒绝另一条路线」代替交付。若发现目标库中存在本任务之外的数据,保留并停止任何破坏性初始化,改申请专用测试库。 +- runner 显式启用云模式,通过安全 dotenv parser 加载指定路径,只注入必要环境;禁止 shell source、env dump。 +- 测试工作区使用 tempdir + 合成 Git/非 Git目录;生成合成 frozen、消息和 tool results,不读取真实仓库指引作为云 payload。 +- 测试 run ID 与所有创建对象登记在本机临时 manifest;只能清理该清单内对象。不创建账户、计费资源或数据库,除非另获授权。 + +### 5.2 链路 + +1. 用新门面/真实 Turso adapter 创建会话;经脚本模型执行一轮含工具往返的 Agent/ACP 流程,确认 flush。 +2. 更新标题、追加 reminder,执行 Full compact;校验摘要与 flags、缓存视图一致。 +3. 创建独立 fork 与 owned child,比较身份映射、binding/frozen 与继承区;原会话保持不变。 +4. clean close,关闭连接并终止测试宿主;由新进程仅凭相同定位和本机执行登记 cold load/resume。 +5. 比对 canonical payload、flags、frozen/inherited 字节和列表摘要;执行 rewind,再从独立连接确认结果。 +6. 只读连接验证所有读取和写拒绝;如不能获得只读凭证,记录该云场景未验证,不用本机模拟替代。 +7. 删除本轮对象并独立重读确认清理;失败列出安全的残留标识,不输出正文或连接字符串。 + +「两 adapter 同一契约」和「真实云链路」分别记录;SDK smoke test、HTTP 200 或本机服务端通过均不足以代替此链路。 + +### 5.3 性能观察 + +相同合成数据规模按小/中/长历史采样,固定字段内容和消息形状,记录规模、运行次数和环境,不预填通过阈值或收益: + +- new / load / append+flush / fork / compact / list / close 的 p50、p95 或逐次样本(样本不足时不造百分位)。 +- 各行为请求数、读写量、排队消息数/字节、峰值内存与关闭等待。 +- SQLite 迁移前/后对比与 Turso 单独报告;云 RTT 不拿来掩盖本地退化。 +- 慢网络下队列增长、批量写/读取是否避免每条消息一次往返。 + +## 6. 命令与测试落点 + +以下命令是后续执行方案;本轮只跑了 §6.1 的基线,且没有运行任何云相关命令: + +```bash +cargo test -p peri-resources --lib +cargo test -p peri-acp-types --lib +cargo test -p peri-agent --lib -- transcript +cargo test -p peri-agent --lib -- subagent +cargo test -p peri-acp --lib -- frozen_snapshot +cargo test -p peri-acp --lib -- compact_recovery +cargo test -p peri-acp --lib -- session_fork +cargo test -p peri-tui --lib -- load_reservation +cargo test -p peri-runtime --lib +cargo test -p peri-controller --lib +``` + +新增集成目标(尚不存在;review-2 固定了精确命令与目标名): + +```bash +# 共同行为契约(两 adapter 用同一 scenario 参数化) +cargo test -p peri-resources --test session_resources_contract -- --list # 先确认命中数 > 0 +cargo test -p peri-resources --test session_resources_contract + +# 显式云实验(本轮绝不执行;需 G-03 解除 + 隔离测试库授权) +cargo test -p peri-acp --test session_resources_turso -- --ignored --list # 先确认命中数 > 0 +cargo test -p peri-acp --test session_resources_turso -- --ignored +``` + +命名与命中规则: + +- 目标名与命令逐字固定为 `session_resources_contract`(`peri-resources/tests/`)与 `session_resources_turso`(`peri-acp/tests/`);改名必须同步 F 与本表,否则证据无效。 +- 执行前必须 `--list`:**目标不存在时 cargo 以退出码 101 报 `no test target named …`**(§6.1 已实测);`--list` 命中 0 条或退出码非零一律不算通过,不看 grep。 +- 选择器执行前同样以 `--list` 确认命中;新增文件必须在 module 中挂载。CLI meta 测试需按现有 binary/integration 目标运行,不能假定 `--lib` 会覆盖。最终按改动范围跑各受影响 crate 全测试、doc tests、格式和 Clippy,命令只支持其覆盖的结论。 +- 确定性故障测试使用 peri-resources 的私有传输 seam,经 `#[cfg(any(test, feature = "test-support"))]` 启用(A §7.1);该 feature 不被生产二进制启用,F 记录实际启用方式与命令。若 SDK 无本地无云服务端,只把传输故障归默认测试,SQL/服务端语义归显式实验,报告该证据边界。 +- 云测试未显式选择时 ignored;显式选择后缺凭证/未隔离须报告阻塞或非零退出,不静默通过。 + +### 6.1 本地基线(review-2 实测,2026-09-26) + +| 命令 | 退出码 | 结果 | +| --- | --- | --- | +| `cargo test -p peri-resources --lib -- --list` | 0 | 156 tests, 0 benchmarks | +| `cargo test -p peri-resources --lib` | 0 | 156 passed; 0 failed; 0 ignored(7.37s) | +| `cargo test -p peri-resources --test session_resources_contract -- --list` | 101 | `no test target named … in peri-resources package`(目标尚未创建) | +| `cargo test -p peri-acp --test session_resources_turso -- --ignored --list` | 101 | 同上;peri-acp 现有目标仅 `concurrent_bg_agent_test`/`integration_test`/`prompt_cache_boundary` | + +本 crate 基线无预存在失败。工作树中 `peri-middlewares/src/mcp/mod.rs` 的修改与 `builtin_spike_test.rs` 属他人任务,本组测试不依赖、不修改;跑全 workspace 测试时若出现该处失败,按外部改动记录而非本 issue 结论。 + +review-3 复核(2026-09-26,本轮,同样未执行云命令):把 `HOME` 隔离到 `mktemp -d`(保留真实 `CARGO_HOME`/`RUSTUP_HOME`,避免任何默认路径落到真实家目录)后复跑——`--list` exit 0 / 156 tests;`--lib` exit 0 / 156 passed / 0 failed(8.03s);`cargo test -p peri-resources --test session_resources_contract -- --list` 与 `cargo test -p peri-acp --test session_resources_turso -- --ignored --list` 均 exit 101(`no test target named …`)。与 review-2 记录一致,无新增预存在失败。 + + +## 7. 完成审查 + +- F-01:实施前基线,登记已有测试实际结果和非本任务失败(§6.1 已完成本 crate 基线)。 +- F-02:A/B 的纯逻辑、门面/SQLite/owner 及 schema 回归(含 V-17/18/19)。 +- F-03:C 的确定性故障与 C-01 SDK 前置证据(V-22 的 P1–P7)。 +- F-04:D/E 跨入口、跨层和新旧旁路清理审查(V-15 用编译证据、V-20/21)。 +- F-05:显式云实验及性能/清理证据;G-03 未解除前保持 blocked,不写“已验证”。 +- F-06:核对所有 V 项有归属和结果,同步受影响 standards/design/code-index;不把计划表勾选当运行证据。 + +没有真实 Turso 冷恢复证据、未知写入收敛证明或整体旧旁路清理,就不能关闭母 issue。任何 skip/ignored/0 tests/缺凭证都记为未通过。 diff --git a/spec/issues/2026-09-27-v4-cloud-architecture-audit.md b/spec/issues/2026-09-27-v4-cloud-architecture-audit.md new file mode 100644 index 000000000..eab6c91b2 --- /dev/null +++ b/spec/issues/2026-09-27-v4-cloud-architecture-audit.md @@ -0,0 +1,81 @@ +# v4 云架构与生态审计 + +状态:部分架构方向已确认,尚未形成实施契约。 + +用户裁决:同意持久状态、Agent 计算与工具环境分离,同意计算实例可替换、执行可恢复,同意 ACP 协议与可自定义传输层分离,同意 Orchestration 的独立职责与生命周期;已更新根 `CLAUDE.md` 的项目目标。Host/Deployment 概念仍需解释,尚未纳入目标;生态基础设施扩展暂缓。以下保留审计建议,未确认部分不构成已批准设计。 + +核查日期:2026-09-27。本文区分官方资料事实与对 Peri 的建议;资料反映核查时状态,未实施云部署或故障实验。 + +## 审计前提与总体判断 + +用户已确定:本地与云端共用核心,分阶段落地;本轮只讨论架构,不设计接口或实施细节。各项采纳状态以上述裁决为准。 + +现有 Harness、Sessions、Resources、Orchestration、Endpoint 覆盖了主要功能职责;v4 还需要明确状态、执行环境和宿主的关系。建议保留五个概念,补充横跨它们的 Host/Deployment 架构维度,不据此强制拆成微服务。 + +建议的大目标表述:Peri v4 建立本地与云端共用的 Agent 执行核心,将持久状态、Agent 计算与工具执行环境解耦;内部能力以 MCP 接入,对外业务交互以 ACP 统一,由可替换宿主提供部署、调度、身份与资源治理。核心不绑定具体进程生命周期、存储后端或云厂商,先完成本地形态,再逐步验证远程能力与可恢复的云端执行。 + +## 五个概念的调整建议 + +| 概念 | 应明确的架构职责 | +| --- | --- | +| Harness | 定义 Agent 执行语义与生命周期,不直接拥有某台机器的执行环境;模型推理服务、Harness 计算、工具执行可以位于不同位置 | +| Sessions | 会话历史与执行恢复状态的持久化边界;会话、一次任务运行、承载它的计算实例具有不同生命周期 | +| Resources | MCP 能力与工具环境边界;工作区、进程和产物可以独立于 Harness 实例存在,访问能力不自动授予资源所有权 | +| Orchestration | 拥有任务关系、协调与等待的领域语义;Middleware 提供接入,编排生命周期不绑定某个活跃 Harness 实例,状态经持久化边界保存 | +| Endpoint | ACP 是统一业务交互协议,stdio 是本地传输方式;连接寿命与任务寿命由部署形态明确约定 | + +Host/Deployment 负责将这些职责装配为本地进程、自托管服务或云宿主,并提供身份、执行准入、调度与资源治理。它是部署与控制职责,不是新增一套 Agent 业务实现。现有 Controller/Runtime 可作为演进落点,暂不要求新增 crate。 + +## 仓库证据与架构差距 + +- [远程存储装配](../../peri-resources/src/sessions/remote/composition.rs) 已明确区分远端数据与本机执行事实。这是存算分离的基础,但不等于支持任意云 worker 接管。 +- [后台任务模块](../../peri-agent/src/agent/async_tasks.rs) 明确 Task 为易失投影、重启不复活;云端持久执行需要另行确定 Run 的恢复契约,不能从消息保存推导执行恢复。 +- [Agent 依赖](../../peri-agent/Cargo.toml) 仍包含存储、资源和 OS 相关依赖。应将可移植执行核心与本地宿主装配区分,但本轮不决定具体拆分文件。 +- [MCP 目标设计](../../docs/design/mcp-adaptation-v4-part-1.md) 同时讨论逻辑能力和实例/进程隔离。建议进一步区分协议边界、信任边界和物理部署边界:能力保持隔离,物理部署由权限、故障范围和生命周期决定;不能从“采用 MCP”推导必须一能力一进程。 +- [总体架构](../../docs/design/architecture.md) 与 [设计索引](../../docs/design/README.md) 仍有旧的事实优先级措辞;若采纳本审计,统一按 [标准索引](../../docs/standards/index.md) 区分现状与目标,再同步受影响设计。本轮不自动批准或改写它们。 + +## 协议与生态边界 + +MCP `2026-07-28` 明确请求自包含,应用状态跨请求存在时通过显式身份引用;协议连接不等于业务会话。仓库 [MCP transport](../../peri-middlewares/src/mcp/client/transport.rs) 已优先该版本。建议保持内部能力 MCP 化,同时分清系统必需能力与模型可选择工具:持久化、授权和恢复由系统确定性执行,不交由模型决定。参见 [MCP 基础规范](https://modelcontextprotocol.io/specification/2026-07-28/basic)。 + +ACP 官方将协议与传输分开,当前 transport 页面仍把 Streamable HTTP 标为草案,同时允许保留协议语义的自定义传输。建议目标表述采用“ACP 统一业务出口,stdio 为本地 transport”,云端传输单独适配。参见 [ACP transports](https://agentclientprotocol.com/protocol/v1/transports)。 + +云基础设施在边界适配:身份系统、存储、观测和运行平台沿用各自生态,MCP 服务内部可使用对应原生能力;业务核心不绑定某云 SDK。ACP 统一面向客户端的业务语义,无需把基础设施健康检查、遥测等也重新发明成 ACP 业务接口。 + +外部 Agent 联邦可以在出现真实跨产品协作需求时,通过适配器评估 A2A;它的任务与发现语义不应强行套到内部同构 Subagent 上。参见 [A2A 规范](https://a2a-protocol.org/latest/specification/)。本轮不建议加入 v4 必交付范围。 + +## 云生态观察与架构建议 + +### 1. 以可恢复执行定义 serverless 目标 + +**事实**:AWS Lambda durable functions 通过 checkpoint/replay 恢复执行,并提供可释放计算资源的等待机制;Cloudflare Workflows 也以可独立重试的步骤组织执行,休眠可能丢失内存状态。参见 [AWS durable functions](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html)、[Cloudflare 工作流规则](https://developers.cloudflare.com/workflows/build/rules-of-workflows/)。 + +**建议**:v4 应分别定义计算部署位置、执行状态持久性、工作环境生命周期。持久化消息只是其中一部分;运行进度和等待状态也须明确归属。先明确需要“重启后重新发起任务”还是“从已确认边界续跑”,再选择运行时。serverless 是可选部署形态,不宜变成必须采用短生命周期函数的限制。 + +### 2. 给副作用及不确定结果制定契约 + +**事实**:AWS 明确 durable step 默认具有至少一次执行语义。已完成 checkpoint 可复用,但步骤完成前中断可能重复执行;启动去重与步骤副作用去重是两个问题。Cloudflare 也要求考虑步骤重试与幂等。参见 [AWS 幂等性说明](https://docs.aws.amazon.com/lambda/latest/dg/durable-execution-idempotency.html)、[Cloudflare 工作流规则](https://developers.cloudflare.com/workflows/build/rules-of-workflows/)。 + +**建议**:执行恢复与外部副作用之间需要明确责任边界,恢复不能无条件重复工具动作。不能从 MCP、队列或 durable runtime 推导出所有工具均 exactly-once。 + +### 3. 将可信控制与代码执行环境分开 + +**事实**:AgentCore Runtime 的 microVM 提供会话级 CPU、内存、文件系统隔离;实例状态与长期持久化状态不同。其安全文档还指出,VM 内代码能够接触执行角色凭据,因此角色权限必须受限。参见 [AgentCore runtime 生命周期](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-how-it-works.html)、[安全最佳实践](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-security-best-practices.html)。 + +**建议**:控制面负责身份、权限策略、调度与运行状态;Agent worker 承载推理;工具环境承载 shell、构建、浏览器及工作区。三者是责任边界,可以先在单进程部署,但不要共享无限权限或把沙箱存活当作 Run 存活。计算核心可替换,并不意味着编译环境、浏览器、LSP 或工作区必须随每次推理重建。工作区成果、补丁和大体积产物应有独立身份与持久化生命周期。 + +### 4. 多租户隔离包含授权与资源治理 + +**事实**:AgentCore 明确不负责 session-to-user 映射,应用后端必须维护用户与会话关系,并管理每用户会话数量。基础设施隔离不能替代应用授权。参见 [AgentCore 安全最佳实践](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-security-best-practices.html)。 + +**建议**:云形态须明确租户与运行资源的所有权,授权、配额和子任务治理属于宿主责任。单用户本地部署可采用简化策略,无需预先建设完整 SaaS 控制台。 + +### 5. 可观察性保持可移植,并区分运行事实与遥测 + +**事实**:OpenTelemetry Collector 可接收、处理并导出 traces、metrics、logs 到不同后端,也可承担批处理、重试和敏感数据过滤;小规模场景允许直接导出而不部署 Collector。参见 [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/)。 + +**建议**:Peri 保留自己的领域事件,观测厂商作为可替换适配器。恢复依据的执行记录不应依赖可采样、可丢弃的 telemetry。运行治理应同时覆盖推理、工具环境和存储资源。 + +## 范围与限制 + +本次材料支持架构取舍,不构成 AWS、Cloudflare 或某工作流产品的选型推荐。未验证 Rust SDK、目标区域、价格、限额、冷启动及本仓库部署兼容性;这些应在确定部署形态后另行验证。本地与云端共用核心、分阶段落地,避免以“云原生”为由预先引入 Kubernetes、服务网格或自建通用工作流平台。 From 7d4ff1d7e453d606e529daf93acaee89e8a1eace Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Sun, 27 Sep 2026 18:19:39 +0800 Subject: [PATCH 3/5] fix(tui): coalesce streaming publications and cache historical folds --- docs/code-index/peri-tui.md | 5 +- .../tui-streaming-markdown-performance.md | 14 +- .../adversarial_review.md | 163 ++++++ peri-tui/src/kit/acp_bridge.rs | 74 ++- peri-tui/src/kit/acp_bridge_test.rs | 2 + peri-tui/src/kit/acp_events/agent.rs | 2 +- peri-tui/src/kit/acp_events/fold.rs | 258 +++++++++ peri-tui/src/kit/acp_events/mod.rs | 67 +-- peri-tui/src/kit/acp_events/render.rs | 272 ++------- peri-tui/src/kit/acp_events/streaming.rs | 137 +++-- peri-tui/src/kit/acp_events/subagent.rs | 4 +- peri-tui/src/kit/acp_events/system.rs | 8 +- peri-tui/src/kit/acp_events/tool.rs | 24 +- peri-tui/src/kit/acp_events/turn.rs | 25 +- peri-tui/src/kit/acp_events_test.rs | 2 + .../kit/acp_events_test/bg_task_live_test.rs | 2 + .../acp_events_test/command_feedback_test.rs | 2 + .../acp_events_test/session_events_test.rs | 26 +- .../src/kit/acp_events_test/streaming_test.rs | 1 + .../acp_events_test/subagent_loading_test.rs | 22 + .../kit/acp_events_test/todo_skill_test.rs | 8 + .../kit/acp_events_test/turn_archive_test.rs | 8 + .../acp_events_test/turn_interrupted_test.rs | 18 + peri-tui/src/kit/acp_types/current_turn.rs | 44 +- .../kit/acp_types/current_turn/projection.rs | 12 + peri-tui/src/kit/message_area/mod.rs | 2 + .../kit/message_area/scroll/auto_follow.rs | 32 +- peri-tui/src/kit/publication_test.rs | 534 ++++++++++++++++++ peri-tui/src/kit/tui_render_unit/tool_card.rs | 11 +- ...09-27-p0-tui-streaming-view-rebuild-cpu.md | 223 ++++++++ 30 files changed, 1616 insertions(+), 386 deletions(-) create mode 100644 docs/experiment-tui-streaming-publication/adversarial_review.md create mode 100644 peri-tui/src/kit/acp_events/fold.rs create mode 100644 peri-tui/src/kit/publication_test.rs create mode 100644 spec/issues/2026-09-27-p0-tui-streaming-view-rebuild-cpu.md diff --git a/docs/code-index/peri-tui.md b/docs/code-index/peri-tui.md index f511fd864..dc2cc1a72 100644 --- a/docs/code-index/peri-tui.md +++ b/docs/code-index/peri-tui.md @@ -5,7 +5,7 @@ ## 架构速览 -- 数据流:`ACP transport → acp_client pump(interaction_lifecycle 在 forward 前分配 semantic owner;ordinary notification 按 Stable/Transitioning/NoSession 路由)→ acp_notifier(owner + RequestId debug JSON + payload;同步发布 commands/plan/spinner/context 后转发)→ acp_bridge(publish_if_owned 持 operation gate 完成 final owner/projection check;bridge-local 50 ms single-pending scheduler 合并主 Agent Streaming publication,reset/terminal/receiver-close/shutdown 失效 pending)→ dispatch_for_bridge(canonical ingest + PublicationIntent)→ VIEW_MODELS/ACP_STATE → components;CurrentTurn mutation lazy projection,response action 只能按 owner first-claim,terminal cleanup compare-and-clear 同 owner surface` +- 数据流:`ACP transport → acp_client pump(interaction_lifecycle 在 forward 前分配 semantic owner;ordinary notification 按 Stable/Transitioning/NoSession 路由)→ acp_notifier(owner + RequestId debug JSON + payload;同步发布 commands/plan/spinner/context 后转发)→ acp_bridge(publish_if_owned 持 operation gate 完成 final owner/projection check;bridge-local 50 ms single-pending scheduler 合并主/子 Agent Streaming publication,发布状态独立于 projection dirty,reset/terminal/receiver-close/shutdown 失效 pending)→ dispatch_for_bridge(canonical ingest + PublicationIntent)→ VIEW_MODELS/ACP_STATE → components;CurrentTurn mutation lazy projection,response action 只能按 owner first-claim,terminal cleanup compare-and-clear 同 owner surface` - 提交链路:`InputArea → SubmitRequest → SUBMIT_TX → submit_consumer → AcpTuiClient::ensure_session(acp_client/client/session.rs)/ prompt(acp_client/client/requests.rs)→ ACP transport`;取消经 `CANCEL_TX → spawn_cancel_consumer → AcpTuiClient::cancel` - 入口:`main.rs:613 main` → `run_tui`(:847)→ `kit/entry.rs:52 run_kit_fullscreen`(spawn kit 各链路)→ `launch.rs:41 build_app_and_acp`(App + AcpTuiClient + consumer 装配) - 稳定不变量:ACP 是交互与 Agent 执行边界(ARC-BOUNDARY-001);`BridgeState` 是事件 → 状态边界(切换会话/重置须过滤陈旧事件,BRIDGE_RESET_COUNTER 清理);render body 不写 atom;hooks 稳定顺序;交互事件按焦点/优先级分发;用户可见文本走 i18n 双 FTL(i18n/mod.rs:35 `tr`);文本按 Unicode 字符边界/显示宽度处理 @@ -29,7 +29,7 @@ | 改图片链接预览与打开行为 | `src/kit/message_area/{handlers,image_action,hits}.rs` + `src/kit/image_overlay.rs` + `src/kit/atoms.rs` | `register_image_hover`;`schedule_image_preview_hover`;`resolve_preview_target`;`request_preview`;`register_image_click` | 图片链接 Moved 命中后即时高亮,但需在同一目标稳定悬停 300 ms 才进入预览源;移出或换链接立即清空并使旧计时任务失效;预览仲裁为稳定 hover > cursor > focus,点击打开仍走 T5 校验与参数化命令 | | 改图片读取与资源限制 | `src/kit/image_safety.rs` + `image_safety_test.rs` | `grade_path` / `read_validated_image` / `sanitize_for_terminal` / `classify_url` | 路径分级与头部/MIME/尺寸/字节限额在真实读取入口校验,显示文本过滤控制字符;测试经原私有 tests 模块挂载 | | 改 keepgoing 按钮行为 | `src/kit/message_area/{handlers,hits,footer}.rs` + `src/kit/submit_consumer.rs` | `register_keepgoing_click`(handlers.rs:69);`KEEPGOING_DEBOUNCE`(:32);`compute_keepgoing_rect`(hits.rs:279);`build_footer_lines`(footer.rs:100);`handle_keepgoing_submit` | 命中最近一帧按钮 rect;冷却期内 Consumed 不提交;点击发送空内容 prompt,服务端仅继续 loop;布局与命中使用同一 KeepGoingLayout(ARC-KEEPGOING-001) | -| 改事件消费(新增/变更事件) | `src/kit/acp_bridge.rs` + `src/kit/acp_events/` + `acp_notifier.rs` | `spawn_acp_bridge_inner` / `PublicationScheduler`;`dispatch_for_bridge` → `PublicationIntent`;`push_view_models` | 事件先 canonical ingest,再由 bridge 合帧主 Agent Streaming publication;首 text/reasoning、block boundary 与 terminal 可立即,50 ms fixed deadline 不 debounce;reset/session/receiver-close/shutdown 失效 pending;终止事件必须离开 loading;root usage_update 仍按 session envelope 处理 | +| 改事件消费(新增/变更事件) | `src/kit/acp_bridge.rs` + `src/kit/acp_events/` + `acp_notifier.rs` | `spawn_acp_bridge_inner` / `PublicationScheduler`;`dispatch_for_bridge` → `PublicationIntent`;`push_view_models` | 事件先 canonical ingest;主/子 Streaming 与子 Block 共用 50 ms fixed deadline(不 debounce),主 Block 保留 boundary;首块与终态立即可见,None 保留积累;投影读取不消费发布状态,handler 同步 barrier 返回 Published 结算 pending;reset/session/receiver-close/shutdown 失效 pending;终止事件必须离开 loading;root usage_update 仍按 session envelope 处理 | | 改 Goal 状态栏与详情面板 | `src/kit/status_bar.rs` + `src/kit/panels/goal.rs` + `src/kit/acp_{notifier,bridge}.rs` + `src/kit/acp_events/system.rs` + `src/kit/session_boundary.rs` | `AcpEventData::GoalSnapshot`;`handle_goal_snapshot`;`GOAL_SNAPSHOT`;`PanelKind::Goal` | notifier 只解码并转发;bridge 完成 session ownership 校验后写 Goal atom;状态栏显示状态和主动接续次数,点击打开只读详情;session transition 清快照并关闭面板 | | 改 compact 信息展示 | `src/kit/acp_notifier/agent_event.rs` + `src/kit/acp_types/event_data.rs` + `src/kit/acp_events/compact.rs` + `src/kit/acp_bridge.rs` + 双语 `locales/*/main.ftl` | `decode_agent_event`;`handle_compact_started` / `handle_compact_completed`;`apply_bridge_reset` | 单一 ACP `peri/agent_event` 路径消费 started/completed;完成时按 strategy 显示压缩类型与收益;manual 提示在统一 reset 边界跨同会话 replay 保留,切换会话清理,auto 不触发 session/load | | 改输入/滚动/选择 | `src/kit/input_area.rs` + `message_area/scroll.rs` + `focus_router.rs` | `InputArea`(input_area.rs:117);`scroll::handle_event`(scroll.rs:516,滚轮节流/拖拽选中/键盘滚动);`focus_router::active_layer`(:105)、`classify_global_shortcut`(:117)、`message_accepts_key`(:147)、`input_accepts_key`(:190) | 消息区只处理滚轮、编辑区处理键盘(按焦点层分发);弹窗/面板遮挡时鼠标清理残留(scroll.rs:548 `is_occluded`);同优先级按注册序分发(keepgoing 须先于 scroll) | @@ -97,6 +97,7 @@ | 全局 atoms | kit/atoms.rs | `ACP_STATE`(:220)/`VIEW_MODELS`(:249)/`SUBMIT_TX`(:256)/`CANCEL_TX`(:257);`init_atoms`(:653) | | 消息累积模型入口 | kit/acp_types.rs + acp_types/{current_turn,tool_card,event_data}.rs | `acp_types.rs` re-export `CurrentTurn`、`ToolCardAccumulator`、`SubAgentAccumulator` 与 `AcpEventData`,canonical turn state 与生命周期在 current_turn.rs | | 主回合流式变更与子回合路由 | kit/acp_types/current_turn/{streaming,subagents}.rs | `append_text` / `append_reasoning` / `flush_text_segment` / `start_tool`;`start_subagent` / `stop_subagent` / `append_subagent_text`;冻结边界与 rolling hash 保持同一 state,child 路由取最后一次 occurrence | +| 发布与稳定历史回归 | kit/publication_test.rs + kit/acp_events/fold.rs | 真实 dispatch → scheduler 验证主/子合帧、模式/reset/终态;FoldedHistory 按源结构身份/phase/override 失效,稳定历史折叠访问与工具全文 hash 为零 | | 回合渲染投影 | kit/acp_types/current_turn/projection.rs | `view_models` / `sync_cache` / `sync_segments` / `sync_trailing` / `pair_agent_tool_cards`;dirty 读取才投影,冻结片段复用、trailing 一次性消费 freeze,最后配对计数 | ### 输入与提交(src/kit/) diff --git a/docs/design/tui-streaming-markdown-performance.md b/docs/design/tui-streaming-markdown-performance.md index f9c733990..8ec849a6f 100644 --- a/docs/design/tui-streaming-markdown-performance.md +++ b/docs/design/tui-streaming-markdown-performance.md @@ -10,11 +10,13 @@ ACP chunk 必须立即、完整、有序地写入 `BridgeState::current_turn` 的 canonical state。视觉 publication 是派生行为,由 bridge-local scheduler 控制,不能反向限制接收。 -默认 `Streaming` 模式采用单 pending、固定 50 ms cadence:每类主 Agent text/reasoning 的首个 chunk 立即发布,后续 chunk 合并到已有 deadline,新的 chunk 不延后该 deadline。明确 Markdown block boundary 可提前形成 publication barrier。`Block` 仅在边界发布,`None` 不发布中间主 Agent 内容。 +默认 `Streaming` 模式采用单 pending、固定 50 ms cadence:主 Agent 与各子流 occurrence/segment 的首个非空 text/reasoning chunk 立即发布,后续 chunk 共用已有 deadline,新的 chunk 不延后该 deadline。主 Agent `Block` 沿用 Markdown boundary 判定(含初始首块),子流 `Block` 不检测 Markdown boundary,按相同 50 ms cadence 合帧。`None` 不因 chunk 发布中间内容。首块、工具/消息边界、交互和终态是即时 barrier,因此 20/s 不是总发布次数上限。 Scheduler 与 `BridgeState` 由同一 bridge task 持有。reset、session transition、terminal、receiver close 和 shutdown 都必须使 pending deadline 失效;receiver close 可发布已接收但尚未投影的最终 canonical state,shutdown 不再写 UI。publication 前必须完成 session/reset 所有权检查,旧 deadline 不得覆盖新 session 或 terminal snapshot。 -Tool 与 SubAgent boundary 保持消息顺序和既有可见性。SubAgent 在 `Streaming`/`Block` 下仍可立即发布,在 `None` 下跳过中间 publication;其 canonical mutation 同样使用 lazy projection。 +Tool 与 SubAgent boundary 保持消息顺序和既有可见性。需要在 drain/replay 等副作用之前发布的 handler 使用同步 `publish_barrier` 并显式返回 `Published`;scheduler 结算 pending,不重复发布。同步 session/reset 与 UI 折叠路径保留原有 owner 检查和顺序,不迁移成异步 bridge 请求。 + +发布状态独立于 projection cache dirty:明确 projection 读取不能吞掉待发布事实;`push_acp_state` 的条目计数不物化 VM。scheduler 区分 replay 的无条件 pending 与受模式约束的 streaming pending。每次 chunk 重读模式,deadline 执行也校验模式:切到 `None` 取消流式 pending,保留 canonical 积累;切回 `Streaming` 在下个非空 chunk 恢复,主 `Block` 仍等既有边界。单独改配置而无新事件不保证即时 publication;replay、terminal、receiver close 等 barrier 不受 `None` 抑制。 ## Lazy ViewModel projection @@ -22,6 +24,14 @@ Tool 与 SubAgent boundary 保持消息顺序和既有可见性。SubAgent 在 ` Terminal handler 保持各事件既有 archive、reset、note、hash、segment order 和 loading 退出语义。合帧不得伪造 terminal,也不得让旧 pending publication 在 terminal 后再次出现。 +## 稳定历史折叠 + +Replay 工具终态使用 `fold_for_status` 生成基础 fold。`BridgeState::folded_history` 以 committed 结构身份、phase 和折叠覆盖表缓存派生历史;持有 canonical 共享节点使同长度修改也能通过 COW 身份变化失效。im 小向量无共享指针时使用固定容量分支的值比较。覆盖表独立于 canonical,添加/移除覆盖从 canonical 重建,避免残留 `user_modified`。 + +主回合归档、deactivate 或离开 PromptRunning 时先冻结 canonical 的 trailing 时长,重复冷重建不能重新读取已结束计时器。稳定历史不再遍历折叠或格式化/哈希工具正文;active turn 继续按当前状态折叠。后续 todo/group 与消息区全 slots 扫描仍可能为 O(N),BG_LIVE_DETAIL 独立的逐 chunk 明细更新不在本 scheduler 范围。 + +Auto-follow effect 将纯内部记账用无通知写入,真实滚动/follow 变化仍通知;依赖包含 loading epoch 与 bridge reset,不能只靠 generation 变化。 + ## 增量 Markdown 与最终 oracle 流式 Assistant bubble 使用既有 `MarkdownRenderCache` 的保守稳定前缀:只冻结明确闭合且不含高风险结构的 block,保存 immutable rendered chunks;table、image/reference-like、list-like 与未配对 fence 保留在 mutable tail。追加时只 parse/materialize mutable tail 和新稳定区域。 diff --git a/docs/experiment-tui-streaming-publication/adversarial_review.md b/docs/experiment-tui-streaming-publication/adversarial_review.md new file mode 100644 index 000000000..854842d0a --- /dev/null +++ b/docs/experiment-tui-streaming-publication/adversarial_review.md @@ -0,0 +1,163 @@ +# 对抗验证:TUI streaming publication 方案 + +本报告针对 2026-09-27 的 [issue 方案稿](../../spec/issues/2026-09-27-p0-tui-streaming-view-rebuild-cpu.md) S1–S4,以及审查开始时的生产源码。对照设计为 [现行性能设计](../design/tui-streaming-markdown-performance.md)。审查期间用户另外授权主 agent 实施修复;以下源码观察均描述修复前状态,不是修复后验收,也不推断并行修改已经解决这些问题。 + +**最新 disposition**:末节收尾审查为 **CONCLUSION_STANDS(限定为已收窄的机制与修复方向)**,未发现新的必须修复逻辑缺陷。先前攻击及首版问题的处理见末节;正式测试、lint 和现场性能证据由主 agent 的最终执行结果提供,本报告不替代它们。 + +## 裁定与证据 + +**CONCLUSION_WEAKENED**:三条缺陷机制成立,独立 publication 状态、主/子流统一调度和稳定历史折叠复用的方向成立;方案稿尚不足以直接作为完整实现契约。最强反例是折叠投影仍读墙钟,导致推荐缓存键缺少实质输入;其次是 UI 折叠与同步 session reset 对单一发布所有权的挑战。现场 CPU 的精确归因比例与修复收益仍未证实。 + +审查只写本报告,没有修改或挂载生产/测试源码。独立执行已有诊断二进制: + +```text +target/debug/deps/peri_tui-a1f73702302d4cd8 cpu_review_ --nocapture --test-threads=1 +exit 0; 3 passed; 0 failed; 1679 filtered out +``` + +命中 `cpu_review_main_chunk_projection_consumes_dirty`、`cpu_review_replay_fold_repeated_writes`、`cpu_review_subagent_bypasses_scheduler`。阅读了 `/tmp/peri-cpu-review-tests.rs`,确认它们断言当前缺陷,而非修复效果;既有二进制不是最终修复源码的构建证明。本轮未新增或执行下文提出的新反例测试,未运行 release benchmark 或重新现场采样。 + +## 攻击 1:历史折叠并非只依赖 revision、phase 与 override + +- **目标论断**:S3 在 committed revision、phase、override revision 不变时,可复用历史折叠结果;失效后从 canonical 重建应与原 pass 等价。 +- **具体反例 / 代码证据**:`acp_events/mod.rs::flush_current_turn` 在归档前调用 `CurrentTurn::deactivate`,后者只改 active 与 cache dirty,不冻结顶层 trailing bubble。`current_turn/projection.rs::sync_trailing` 的同长度缓存可继续保留 `started_at`;归档后的 canonical committed 因而仍可能带运行中 reasoning 和时钟起点。`render.rs::apply_fold_pass` 在非 PromptRunning phase 使用 `started_at.elapsed()` 冻结正文/推理,且只写临时快照。时间 t1 的终态缓存与 t2 的 override 失效重建将得到不同 duration;下个 PromptRunning phase 又可能从带 Running 的 canonical 恢复旧 reasoning 状态。缓存加入时钟会破坏稳定历史复用,忽略时钟则不满足逐次旧 pass 等价。 +- **严重程度**:严重,影响最终用户可见时长、动画及缓存等价 oracle。 +- **建议收敛**:把生命周期冻结与视觉 fold 分开。归档/终止时一次性形成稳定正文、reasoning duration/status;明确 LocalLoadingReset、挂起及仍运行 child 的行为。不要将旧 pass 对墙钟重复读取当作必须保持的正确行为,也不要将用户 override 回写 canonical。可以固定冻结投影,但必须说明失效重建如何保留该冻结事实。 +- **可证伪验证**:真实 dispatch 链路输入 reasoning/text → TurnDone/Interrupted/Suspended,捕获 terminal duration/status;推进受控时间,添加/删除 override,启动下一轮并再次发布,旧历史完整字段须不变。覆盖尚有 running child 导致 flush 被跳过的分支。相同瞬时时间比较 cached/uncached 不足以排除此攻击,必须跨时间和 phase。 + +## 攻击 2:单一发布所有权与同步 reset / UI 写入存在实际冲突 + +- **目标论断**:S1 bridge 持有 revision,统一函数成功写 VIEW_MODELS 后结算;折叠 UI 与 reset 接入同一边界即可。 +- **具体反例 / 代码证据**:`message_area/entry_nav.rs::apply_fold_toggle` 同步改 `FOLD_OVERRIDES`、当前 snapshot 条目和 snapshot.generation;bridge 自己另持 generation。`session_boundary.rs::project_session_boundary` 则要求在 lifecycle operation gate 内同步清空 UI,并立即调用 `push_view_models_for_reset`。若折叠只变 atom、不发 bridge intent,idle 无新 ACP 事件时不会消费 revision;若把旧 slot 延迟排队,reset 后同 slot/同 FoldKey 可属于新 session。仅在 publication 前读一次 reset counter,无法排除“检查后、写快照前发生 reset”的交错。 +- **严重程度**:严重,可丢交互或重新显示旧 session;影响所有权不变量。 +- **建议收敛**:明确同步 reset 是受同一所有权协议约束的特殊写入,不能无理由改成异步清空。折叠命令携带 session epoch 与稳定 FoldKey,在最新 canonical 上解析;显式唤醒 bridge,并定义连续快速 toggle 是传目标态还是操作。publication 与 reset 的 ownership 检查/写入须有可解释的线性化点。generation 只有一个常规 writer;reset 的 epoch 与 generation 分开。 +- **可证伪验证**:idle 点击无需后续 ACP 事件即生效;点击入队后 reset,再回放相同 ID,旧点击被拒绝;publication 在最后检查与写入之间遇 reset,旧 snapshot 不能复活;两次快速 toggle 的结果确定;同步 session boundary 返回时旧 UI 已清空。应通过真实 UI 命令/bridge 路径验证,不能仅调用 fold pass。 + +## 攻击 3:Streaming 热切 None 后已有 deadline 仍会发布 + +- **目标论断**:S2 None 不因 chunk 产生中间 publication,各模式共用 scheduler 且保持既有可见性。 +- **具体反例 / 代码证据**:`panels/config.rs` 的 ROW_STREAMING 直接修改 `TUI_CONFIG_HANDLE` 并保存配置,没有通知 scheduler。`PublicationScheduler::fire_at` 到期无条件 `push_view_models`。在 S1 修复使 Deferred 生效后,Streaming 接收 chunk 建立 deadline,切 None 后再收 chunk,到期会发布后者。反方向 None → Streaming 也不等同于某类 canonical 文本为空:None 中可能已积累大量文本却从未可见。 +- **严重程度**:严重,模式行为在修复后才真正暴露。 +- **建议收敛**:定义配置变化是否是 publication policy epoch;切入 None 取消普通流式 pending,保留 canonical 未发布事实以供合法 barrier/终态;切入 Streaming 明确是否立即显示已有积累。不要不加区分地取消 replay 的 Deferred。None 必须解释为“不由 chunk 触发”,若工具/交互/终态 barrier 允许携带此前文本,要明确写出。 +- **可证伪验证**:固定时钟,Streaming 建 pending → None → 新 chunk → 原 deadline;分别断言普通 publication 为零、canonical 完整、terminal 最终文本完整。再测 None → Streaming、Streaming → Block,以及同一 pending 中含 replay/流式输入时的策略。 + +## 攻击 4:首块、边界与模式的优先级仍欠定义 + +- **目标论断**:S2 “首个可见 text/reasoning 块”为 Immediate,身份按 occurrence/message 区分。 +- **具体反例 / 代码证据**:现行设计主 Block 仅在 Markdown 边界发布,None 跳过中间发布,方案的无条件首块表述可被实现为两者也立即发布。`current_turn/subagents.rs::append_subagent_text/reasoning` 没接收 chunk.message_id,子流内部不能直接沿用主消息身份判定;`start_subagent` 对同一 active occurrence 重复 start 幂等,停止后同 ID resume 则新 occurrence。空 chunk 仍可进入主 append 并改 active/dirty,但不必对应可见块。 +- **严重程度**:中等,容易让频率验收假通过或交互策略被静默改变。 +- **建议收敛**:逐模式定义首可见资格,尤其主 Block 保留边界规则、None 不因首块触发;资格按 occurrence、text/reasoning 类别及可靠 message/segment 身份维护,缺 ID 有明确 fallback。空 chunk 不消耗首可见资格。BG 无组不应凭 source ID 创建主 transcript 的首块。 +- **可证伪验证**:一个 child 持续 100 chunk,另一 child 中途首块;同 ID stop/resume;同 occurrence reasoning 后 text;工具边界后新段;缺/变化 message ID;空 chunk 后非空 chunk。按原因断言首块次数,并比较完整顺序。不能仅用 generation 增长数量代替首块分类。 + +## 攻击 5:revision 正确仍可能丢失 handler 副作用或终结语义 + +- **目标论断**:把 handler 直接发布移到 bridge 后,可维持现有 terminal/archive/交互行为。 +- **具体反例 / 代码证据**:`turn.rs::handle_turn_done` 先发布状态,再触发 compact replay / drain_input_buffer;中断不同分支的 drain 顺序并不相同。`BridgeState::inject_system_note` 自带发布及 ACP_STATE 更新。`flush_current_turn` 遇 running child 会拒绝清空,而 TurnSuspended 直接归档/reset;`handle_loading_reset` 只切 phase。`flush_on_receiver_close` 只是最终投影,并不生成 terminal/deactivate,也不自动清 loading。把“receiver close 最终 flush”扩大为“所有 close 都退出 loading”是新增生命周期语义,不能由 revision 顺便实现。 +- **严重程度**:严重,可能错误重排提交、归档或取消。 +- **建议收敛**:列出每个 handler 的 canonical mutation、atom 副作用、后续发送和 publication barrier 顺序。统一发布时允许把后续副作用放到 publication 后执行,但不删除。receiver close 明确保留原语义还是升级为传输断开状态;shutdown 保持无 UI 写入。child stop 不消费整个 session 的 terminal ownership。 +- **可证伪验证**:正常 terminal、零输出取消、stale cancel、挂起后 BG chunk、compact 后 replay、receiver close 与 reset 交错均经真实 bridge 运行。断言 INPUT_BUFFER 和 request 配对、note 恰一次、phase/loading、最终消息顺序和发布次数;不要用“没有 pending”替代这些断言。 + +## 攻击 6:override 删除不能只恢复 fold 而遗留 user_modified + +- **目标论断**:S3 覆盖添加/移除后正确失效并恢复默认;历史缓存可重复利用。 +- **具体反例 / 代码证据**:`render.rs::apply_fold_pass` 对 Tool/SubAgent/Interaction 使用 `override_fold.is_some() || vm.user_modified`。UI `apply_fold_override` 会把该标志设 true。若实现为了少重建而在上次 folded 缓存上删除 override,fold 虽回默认,user_modified 仍 true;分组免疫等行为可能继续保留。当前 canonical 与 UI snapshot 分离时此反例不必发生,因此这是约束新缓存实现的具体陷阱,不声称现行所有删除路径都有此 bug。 +- **严重程度**:中等。 +- **建议收敛**:失效重建从干净 canonical 开始,或显式恢复全部派生字段;不要把 folded 输出当下一次默认输入。明确“折叠成默认态”与“删除用户覆盖”是否两个动作;Group 与成员 Tool 的 key 不能互换。 +- **可证伪验证**:两张可分组成功工具,展开其中一张后移除 override,检查 fold、user_modified、content_hash 和最终分组全部恢复;同样覆盖 SubAgent/Interaction,reset 后复用相同 ID。只断言 FoldPassWrites=0 无法证明这一点。 + +## 攻击 7:无通知记账安全,但 effect 触发条件不能靠 incidental render + +- **目标论断**:S4 仅减少哨兵无变化通知,submit/reset/resize/anchor 等可见行为不变。 +- **具体反例 / 代码证据**:`run_auto_follow` 的 prev_total/prev_vis/prev_loading_epoch/prev_reset_counter/prev_items 等只是内部记账,改无通知有依据;scroll offset 与 follow_bottom 则被视图消费。`message_area/mod.rs` 虽订阅 LOADING_EPOCH、BRIDGE_RESET_COUNTER,use_effect 依赖元组只有 items_len、generation、loading、rows、height;若只有 epoch 改变而元组值相等,重新 render 不保证重新运行 effect。依赖此前的额外 wake 更不能保证它运行。 +- **严重程度**:中等,不能以少了通知推导滚动行为完整。 +- **建议收敛**:显式加入 effect 真正消费的生命周期依赖;内部记账无通知,实际视觉改变需通知。不要全局把 effect 的 write 改为 write_no_update。 +- **可证伪验证**:原地相同快照/几何重复调用时无额外 wake;仅 loading/reset epoch 改变也消费哨兵;follow false 时新输出不抢滚动,真实 submit 恢复 follow,resize 与 interaction anchor 后实际 offset 变化产生通知。测试通知行为与纯滚动状态,渲染外观按仓库规范实测,不能只测试返回目标 offset。 + +## 攻击 8:统一 VIEW_MODELS cadence 不覆盖后台详情热路径 + +- **目标论断**:主/子统一调度解除逐事件 UI 放大,对 BG 同样改善整体成本。 +- **具体反例 / 代码证据**:`streaming.rs` 在 BG 有组/无组两条路径均调用 `append_bg_text_chunk` / `append_bg_reasoning_chunk`,不受 None 限制。`bg_task_live.rs` 每次写 BG_LIVE_DETAIL,clone 当前 bubble、追加、全文 recompute_hash,再替换 nested_units;有组还同时维护 CurrentTurn。S1/S2 控制主 transcript publication,并不自动移除这条逐 chunk 的内容工作和 atom 通知。 +- **严重程度**:中等,削弱全局 CPU 改善外推;不推翻针对 replay×subagent transcript 的机制。 +- **建议收敛**:保留后台详情副作用,在报告中把它列为独立剩余成本;若测量显示主导再独立优化,不能为满足 20Hz 计数而丢 BG 内容。Deferred 次数上限仅适用于 scheduler 管理的 VIEW_MODELS 发布。 +- **可证伪验证**:分别对 sync child、BG 有组、BG 无组测 VIEW_MODELS publication、BG_LIVE_DETAIL 更新和 hash 字节;固定输出总长度变化 chunk 大小,观察 BG 成本是否仍随事件率放大。打开/关闭后台详情 pane 分开记录可见渲染量。 + +## 攻击 9:成本计数与现场归因仍须保持有限结论 + +- **目标论断**:当前诊断与 S3 零历史折叠工作足以解释并解决现场 CPU。 +- **具体反例 / 代码证据**:三诊断只证明合成输入上的 fold 写入、子流直推与主流 dirty 消费,未测 release 收益。已有 `FoldPassWrites` 不等于访问数,也不等于全文 hash 字节;稳定历史零写可仍全量扫描。当前计数器 publication reason 只按 phase 区分 Intermediate/Terminal,不能直接分离首块/边界/Deferred。分组 first_divergence、todo 定位、message_area prefix index 仍全量工作,BG 另有逐内容处理。 +- **严重程度**:轻微至中等;方案稿已诚实收窄多数承诺,本攻击未推翻根因方向。 +- **建议收敛**:正式验收新增真正的历史 fold visits、hash 调用/字节和 barrier reason 计数,区分 committed 与 live turn,预热后再测稳定区间;继续保留剩余 O(N) 声明。不用 `N=1000` 与 `N=100` 的耗时倍数证明 O(1),不将基于旧源码的三测试通过写成修复通过。 +- **可证伪验证**:回放/实时历史、override 稳态/失效、不同输出体积各自测量;真实 bridge 的时间推进与输出等价测试通过后,执行同类 release profile 与现场负载复测,按进程分别计数。原现场失配条目数和 replay 占比无法仅从调用栈样本反推。 + +## 最终评估 + +| 范围 | 裁定 | +| --- | --- | +| replay 终态 fold 残留导致重复历史变换 | 成立;已独立重跑诊断 | +| 子流 handler 直推绕过后置 scheduler | 成立;已独立重跑诊断 | +| 主流 projection 提前消耗 publication dirty | 成立;已独立重跑诊断 | +| S1–S4 总体方向 | 保留;需补齐时间、所有权、模式及副作用契约 | +| S3 推荐缓存键直接足够 | 被具体时长反例削弱 | +| 全 UI 20Hz / 全流程 O(增量) | 不成立,方案稿也不应作此承诺 | +| 修复正确性与 CPU 收益 | 本报告未验证 | + +最终裁定为 **CONCLUSION_WEAKENED**。应将上述高风险反例收进实施契约及正式回归,随后针对最终源码重新验收;不得把本次对方案稿的审查当作修复完成证明。任务关闭时按 DOC-HISTORY-001 将稳定结论吸收进设计/代码索引并清理本过程报告。 + +## 修复后审查:首版实现的只读复核 + +此节针对主 agent 邀请复核时的首版工作区 diff。该版本已新增 `acp_events/fold.rs`,引入 `PublicationIntent::{Published,Streaming,Hidden}`、scheduler.unpublished、`deactivate → freeze_trailing`,正式测试仍在补充。审查未改生产代码,也未把此前二进制的三诊断结果用于证明此版修复。本节列出的源码模式用于界定审查版本;后续修改消除该模式后,应依据最终测试更新结论。 + +### R1:主 Agent Block 首块直接发布,偏离保留的契约 + +- **目标论断**:主 Block 继续仅在 Markdown 边界发布。 +- **代码证据 / 具体轨迹**:`streaming.rs::stream_intent` 主 Block 分支使用 `if first || has_md_block_boundary_since(text, *pushed)`。空主 turn 收到普通 `"a"`,`starts_stream_block` 返回 true,结果 Immediate,而该文本没有 Markdown boundary。此前方案只建议改变子流 cadence,并未改变主 Block 首块策略。 +- **严重程度**:中等,确定的策略偏差;不是推测性竞态。 +- **建议收敛**:删除主 Block 的 first 例外,或取得并记录明确的主 Block 契约变更。子 Block 首块规则与主 Block 分开。 +- **可证伪验证**:真实 dispatcher + scheduler,Block 主 text/reasoning 首个普通字符均不产生 publication,随后边界立即发布完整缓冲;Streaming 同输入首块立即显示,子 Block 按明确新策略验收。本审查没有执行此新增用例。 + +### R2:LocalLoadingReset 没有结算已建立的 deadline + +- **目标论断**:取消与本地 loading 复位后,旧 pending 不会再次发布旧流状态。 +- **代码证据 / 具体轨迹**:`turn.rs::handle_loading_reset` 仍只改 phase=Idle 并调用 push_acp_state,没有 `publish_barrier` 或独立 intent。dispatcher 因此返回 None,scheduler.accept 不清理同模式 pending。Streaming 首块已发布 → 第二块建立 pending → LocalLoadingReset → 原 deadline 到期,`fire_at` 仍会发布该 buffered 内容。LocalLoadingReset 用于 cancel、clear、prompt 失败兜底,不能假定随后一定有 TurnInterrupted 替它结算。 +- **严重程度**:严重,属于已恢复正常的 scheduler 暴露出的生命周期缺口。 +- **建议收敛**:明确本地 loading reset 是最终视觉 barrier 还是只隐藏 loading;若承担取消兜底,则显式结算 pending 并稳定该结束状态,保留它与归档/清除的区别。不能仅靠 current_turn.cache_dirty,因为任意 projection 读取可清掉它。 +- **可证伪验证**:按上述事件轨迹推进固定时钟;断言该复位时的合法最终 snapshot、loading=false、旧 deadline 不再额外发布。再覆盖重复 LocalLoadingReset、随后仍有 child/BG 事件,以及真正 terminal 后再次 reset。本审查没有执行此新增用例。 + +### R3:模式恢复只在后续 chunk 被观察,没有配置事件唤醒 + +- **目标论断**:模式热切换之后,对已接收未发布内容的处理确定。 +- **代码证据 / 具体轨迹**:scheduler.fire_at 在 pending_mode 与当前配置不符时清 deadline 并保留 unpublished。Streaming pending → 切 None → deadline 到期被取消 → 切回 Streaming → 不再收到 chunk,此时 unpublished=true 且没有任何 pending。配置面板仍直接改配置,未唤醒该状态机,因此缓冲会一直等后续事件或 receiver close。`last_streaming_mode` 的 None→可见恢复分支仅由下一条 Streaming intent 触发;如果 None 期间没有 chunk,也未必记录过 None。 +- **严重程度**:中等,取决于批准的模式恢复语义;不把它误报为所有 Streaming 停流都挂起。 +- **建议收敛**:二选一明确写入设计:配置变化主动通知 scheduler 并处理已积累内容,或模式仅影响后续事件,并明确“切回模式本身不会 flush”。如果保留后一选择,不能宣称立即恢复此前不可见内容。 +- **可证伪验证**:独立覆盖有/无新 chunk 的两种模式切换轨迹,读取 publication、pending 与 canonical 三者,不只断言模式值。 + +### R4:已排除的首轮高风险与仍需最终证据的边界 + +- **普通终态冻结**:`deactivate` 现在调用 freeze_trailing,后者保留已有 trailing_reasoning_frozen_ms;时钟起点清空后重复 deactivate 不再次覆盖冻结标记。结合 sync_trailing 的 pending_freeze 路径,源码上已消除普通归档后重算历史时长的首轮反例。仍需正式跨时间测试证明,不能用本次阅读替代。 +- **历史结构失效**:核对本地 im 15.1.0 的 ptr_eq:Full 检查前后 chunks 与 middle 的共享身份。FoldedHistory 持有源引用,原地更改触发 COW;phase/override 值也参与失效。没有发现同长度 replacement 被该键静默漏过的具体路径。inline 分支值比较是明确例外,若非空 inline 可携大内容,其成本应单独承认。 +- **terminal 副作用**:handler 将原同步 push 原位替换为 publish_barrier,再由 Published 告知 scheduler 结算,保留了原先 publish/drain/replay 的顺序。这个收敛解决了简单外提发布的重排风险。 +- **UI 同步写入**:首版明确保留同步 fold/reset,并未实现首轮建议的异步命令迁移,因此不能再以异步 slot 污染作为本版新增 bug。原本跨线程 reset 与 publication 的最后检查/写入原子性,及 UI/bridge generation 双 writer 仍需单独实证;本轮未找到新证据证明这两个既有风险已经发生。 +- **auto-follow**:内部哨兵改 write_no_update,实际 scroll/follow 保留通知,并把 loading/reset epoch 加入 effect 依赖。源码上回应了首轮遗漏,没有发现新增可见更新丢失路径;最终通知和滚动行为尚须验证。 +- **计时口径**:历史 `folded_history.project` 位于 StageAssembleNs 区间,StageFoldNs 现在只含 live fold。若沿用旧字段名做前后耗时比较,必须解释该归属变化;不能把新 history cold fold 误认为 assemble 本身增长。 + +首版只读审查结论:**CONCLUSION_WEAKENED,等待 R1/R2 收敛和最终测试**。已发现的两项具体偏差不推翻整体修复方向;本节不构成正式测试、release 性能或现场体验通过证明。 + +## 收尾对抗审查与最终 disposition + +再次阅读了最新 TUI 生产 diff、`publication_test.rs` 及其真实模块挂载入口。遵照主 agent 要求,没有编译、没有启动测试,没有修改生产代码或其他文档。workspace 同时存在非本任务 ACP/Agent 修改,本审查不对那些改动作结论。 + +### 已处理与判断更正 + +1. **更正 R1 的初始反例**:此前只检查 `stream_intent`,漏看既有 `has_md_block_boundary_since` 明确规定 `since_chars == 0` 返回 true。因此“主 Block 首个普通字符发布是新增偏差”的论断不准确,撤回这一部分。当前去掉 `first ||` 的实际作用是防止后续 message/segment 首块额外绕过原 boundary helper,保留了现有首次推送与增量边界规则。`test_publication_block_and_empty_chunks_respect_boundaries` 中首次普通文本后 generation=1 与既有规则一致。文档不能把 Block 简化成“首次字符绝不发布”。 +2. **R2 已在代码中处理**:`handle_loading_reset` 在 PromptRunning→Idle 时调用同步 publish_barrier;Published 让 scheduler 清除 unpublished 与 deadline。`push_view_models` 在非 PromptRunning 时先 freeze_trailing,避免只有临时快照冻结。对应回归通过真实 dispatch→scheduler 路径断言已收推理完整、loading=false、pending 清理、时钟推进无重复发布、重复复位幂等。这里确认的是测试源码覆盖,未声称测试已运行通过。 +3. **R3 已明确收窄**:模式由 chunk/deadline 读取;只改配置且无后续事件不保证立即发布。None→Streaming 在后续 chunk 恢复;主 Block 仍遵循原边界函数,不能笼统说“切入任意可见模式,下个 chunk 总会立即显示”。deadline 模式检查取消纯流式 pending,replay Deferred 不受 None 抑制。该选择不再视为未解决代码缺陷。 +4. **历史冻结与计时已落实**:deactivate 冻结顶层 trailing,非 PromptRunning publication 冻结尚未归档的顶层 trailing;原空 reasoning placeholder 在终态保留 Completed 形态,重算 hash。历史 project 已计入 StageFoldNs。正式回归源码断言终态 canonical 无 started_at、reasoning 非运行态,并跨 phase/override/cold rebuild 比较完整条目。 +5. **S1 所有权方案已合理缩小**:handler 以 Published 显式确认同步 barrier,保留原 drain/replay 副作用顺序;UI fold/reset 维持原同步路径。此次修复不声称实现了全面异步 UI 命令所有权迁移,也不宣称消除所有既有 session 并发竞态。 + +### 最终代码与测试源码观察 + +未发现可据当前源码列为新增阻塞项的反例。独立 pending 标记不再被显式 projection 读取消费;稳定 committed 的结构 identity 与 override 值校验有明确失效路径;冷投影仍从 canonical 重建,覆盖状态不会反向污染基础卡片。新增正式测试由 `acp_bridge.rs` 挂载,覆盖主/子 text/reasoning 合帧、None 切换、replay、close、loading reset、Block、两子流与 resume、BG 无组、100/1000 历史稳态访问与 hash 操作计数、同长度工具更新/缩短以及归档时长字段稳定。 + +这些测试主要验证真实 handler→scheduler 的同步链路;不能据此宣称真实 async loop 所有 session/reset 交错、全部模式组合或终端体验已穷尽。仍需主 agent 以最终退出结果确认完整 suite;审查不重复运行以免与正在执行的全套验证争用资源。 + +**最终裁定:CONCLUSION_STANDS。** 此裁定限于三条机制已证实、修复按收窄契约正确处理对应发布/折叠路径、当前只读复核未发现新增阻塞逻辑缺陷。它不证明全 TUI 达到 20Hz 上限、不证明全部 publication 为 O(增量),也不证明 release 或现场 CPU 已降低到某个水平。BG_LIVE_DETAIL 的逐 chunk 工作、全 slots/分组扫描和最终测试运行结果仍应在交付中如实区分。 diff --git a/peri-tui/src/kit/acp_bridge.rs b/peri-tui/src/kit/acp_bridge.rs index fe9c3a8e2..0eadf4b37 100644 --- a/peri-tui/src/kit/acp_bridge.rs +++ b/peri-tui/src/kit/acp_bridge.rs @@ -5,7 +5,9 @@ //! Phase 2 完整实现——main_loop fan-out 后独立消费。 use crate::acp_client::AcpTuiClient; -use crate::kit::acp_events::{self, BridgeState, PublicationIntent, SessionPhase}; +use crate::kit::acp_events::{ + self, BridgeState, PublicationIntent, SessionPhase, StreamingMode, current_streaming_mode, +}; use crate::kit::acp_types::{AcpEventData, AcpEventWithEpoch, CurrentTurn}; use crate::kit::atoms; use tokio::sync::mpsc; @@ -42,6 +44,9 @@ pub(crate) struct PerfCounters { pub group_rebuilt_units: u64, /// 折叠 pass 实际写回的条目数(稳态应为 0——[G3] 只读扫描)。 pub fold_pass_writes: u64, + pub history_fold_visits: u64, + pub tool_hash_calls: u64, + pub tool_hash_bytes: u64, /// `push_view_models` 各阶段累计纳秒(组装 / 折叠 pass / todo 摘要 / 分组 / 写快照) /// ——长会话下按阶段定位成本归属,配合 `acp_events_test/perf_probe_test.rs`。 pub stage_assemble_ns: u64, @@ -72,6 +77,9 @@ pub(crate) enum PerfCounter { GroupCopiedUnits, GroupRebuiltUnits, FoldPassWrites, + HistoryFoldVisits, + ToolHashCalls, + ToolHashBytes, StageAssembleNs, StageFoldNs, StageTodoNs, @@ -107,6 +115,9 @@ pub(crate) fn observe_perf(counter: PerfCounter, value: u64) { PerfCounter::GroupCopiedUnits => counters.group_copied_units += value, PerfCounter::GroupRebuiltUnits => counters.group_rebuilt_units += value, PerfCounter::FoldPassWrites => counters.fold_pass_writes += value, + PerfCounter::HistoryFoldVisits => counters.history_fold_visits += value, + PerfCounter::ToolHashCalls => counters.tool_hash_calls += value, + PerfCounter::ToolHashBytes => counters.tool_hash_bytes += value, PerfCounter::StageAssembleNs => counters.stage_assemble_ns += value, PerfCounter::StageFoldNs => counters.stage_fold_ns += value, PerfCounter::StageTodoNs => counters.stage_todo_ns += value, @@ -207,6 +218,8 @@ fn synthetic_scheduler_state() -> BridgeState { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), } } @@ -247,12 +260,19 @@ const PUBLICATION_INTERVAL: std::time::Duration = std::time::Duration::from_mill struct PublicationScheduler { pending_deadline: Option, token: u64, + /// 与 projection cache 无关,记录已接收但尚未发布的 canonical 更新。 + unpublished: bool, + pending_mode: Option, + last_streaming_mode: Option, } impl PublicationScheduler { fn invalidate(&mut self) { self.pending_deadline = None; self.token = self.token.wrapping_add(1); + self.unpublished = false; + self.pending_mode = None; + self.last_streaming_mode = None; } fn accept(&mut self, intent: PublicationIntent, state: &mut BridgeState) { @@ -265,18 +285,41 @@ impl PublicationScheduler { state: &mut BridgeState, now: tokio::time::Instant, ) { + let mode = current_streaming_mode(); + if self.pending_mode.is_some_and(|scheduled| scheduled != mode) { + self.pending_deadline = None; + self.pending_mode = None; + } match intent { PublicationIntent::None => {} + PublicationIntent::Published => self.invalidate(), PublicationIntent::Immediate => { self.invalidate(); - if state.current_turn.has_unprojected_changes() { - acp_events::push_view_models(state); - } + acp_events::push_view_models(state); + self.last_streaming_mode = Some(mode); } PublicationIntent::Deferred => { + self.unpublished = true; + self.pending_mode = None; self.pending_deadline .get_or_insert(now + PUBLICATION_INTERVAL); } + PublicationIntent::Streaming => { + self.unpublished = true; + if self.last_streaming_mode == Some(StreamingMode::None) + && mode != StreamingMode::None + { + self.accept_at(PublicationIntent::Immediate, state, now); + } else if mode != StreamingMode::None && self.pending_deadline.is_none() { + self.pending_mode = Some(mode); + self.pending_deadline = Some(now + PUBLICATION_INTERVAL); + } + self.last_streaming_mode = Some(mode); + } + PublicationIntent::Hidden => { + self.unpublished = true; + self.last_streaming_mode = Some(mode); + } } } @@ -288,8 +331,17 @@ impl PublicationScheduler { return false; } self.pending_deadline = None; - // Deferred 也可能来自只修改 committed 的历史回放,不能仅检查 live turn。 + if self + .pending_mode + .take() + .is_some_and(|mode| mode != current_streaming_mode()) + { + self.last_streaming_mode = Some(current_streaming_mode()); + return false; + } + // 历史回放与明确 projection 读取都不能吞掉待发布事实。 acp_events::push_view_models(state); + self.unpublished = false; true } } @@ -318,6 +370,8 @@ fn apply_bridge_reset(state: &mut BridgeState, last_reset_counter: &mut u64, cou state.active_session_id = active_session_id; state.committed = im::Vector::new(); state.current_turn.reset(); + state.folded_history = Default::default(); + state.publication_intent = PublicationIntent::None; state.generation = 0; state.turn_generation = 0; state.last_prompt_generation = 0; @@ -362,7 +416,7 @@ fn flush_on_receiver_close( scheduler: &mut PublicationScheduler, last_reset_counter: &mut u64, ) { - let pending_publication = scheduler.pending_deadline.is_some(); + let pending_publication = scheduler.unpublished || scheduler.pending_deadline.is_some(); scheduler.invalidate(); let counter = atoms::BRIDGE_RESET_COUNTER.get(); if counter != *last_reset_counter { @@ -435,6 +489,8 @@ fn spawn_acp_bridge_inner( last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 追踪 BRIDGE_RESET_COUNTER——submit_consumer 的 /clear / thread_load @@ -482,7 +538,7 @@ fn spawn_acp_bridge_inner( let mode_is_none = matches!(current_streaming_mode(), StreamingMode::None); if !mode_is_none { - acp_events::push_view_models(&mut state); + scheduler.accept(PublicationIntent::Immediate, &mut state); } } } @@ -679,3 +735,7 @@ fn event_kind_short(event: &AcpEventData) -> &'static str { PluginSearchResult(_) => "PluginSearchResult", } } + +#[cfg(test)] +#[path = "publication_test.rs"] +mod publication_tests; diff --git a/peri-tui/src/kit/acp_bridge_test.rs b/peri-tui/src/kit/acp_bridge_test.rs index cf9c772af..da24ffcf6 100644 --- a/peri-tui/src/kit/acp_bridge_test.rs +++ b/peri-tui/src/kit/acp_bridge_test.rs @@ -25,6 +25,8 @@ fn scheduler_state() -> BridgeState { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), } } diff --git a/peri-tui/src/kit/acp_events/agent.rs b/peri-tui/src/kit/acp_events/agent.rs index c878ece71..561554469 100644 --- a/peri-tui/src/kit/acp_events/agent.rs +++ b/peri-tui/src/kit/acp_events/agent.rs @@ -18,7 +18,7 @@ pub(super) fn handle_agent_execution_failed(state: &mut BridgeState, message: &s state .current_turn .push_system_note(text, TuiNoteLevel::Error, content_hash); - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } diff --git a/peri-tui/src/kit/acp_events/fold.rs b/peri-tui/src/kit/acp_events/fold.rs new file mode 100644 index 000000000..03b39f86f --- /dev/null +++ b/peri-tui/src/kit/acp_events/fold.rs @@ -0,0 +1,258 @@ +//! 历史折叠投影:canonical 历史与 UI 覆盖分离,稳定历史不逐 chunk 再处理。 +use super::{SessionPhase, TuiRenderUnit}; +use crate::kit::tui_render_unit::{EntryStatus, FoldKey, FoldState, FoldTarget, fold_for_status}; +use std::collections::HashMap; + +#[derive(Default)] +pub(crate) struct FoldedHistory { + source: Option>, + phase: Option, + overrides: HashMap, + folded: im::Vector, +} + +impl FoldedHistory { + pub(super) fn project( + &mut self, + source: &im::Vector, + phase: SessionPhase, + overrides: &HashMap, + ) -> im::Vector { + // 持有源的共享节点:任何原地 set/update 都会 COW,从而改变结构身份。 + // im 的 inline 小向量没有共享指针;只有这个固定容量分支做值比较。 + let same_source = self + .source + .as_ref() + .is_some_and(|old| old.ptr_eq(source) || (source.is_inline() && old == source)); + if !same_source || self.phase != Some(phase) || self.overrides != *overrides { + #[cfg(test)] + crate::kit::acp_bridge::observe_perf( + crate::kit::acp_bridge::PerfCounter::HistoryFoldVisits, + source.len() as u64, + ); + self.folded = source.clone(); + apply_fold_pass(&mut self.folded, phase, overrides); + self.source = Some(source.clone()); + self.phase = Some(phase); + self.overrides = overrides.clone(); + } + self.folded.clone() + } +} + +/// [G2] 折叠状态机单点 pass——spec §7 折叠表 + FOLD_OVERRIDES 用户覆盖。 +/// +/// 对每个带 fold 字段的 VM 计算目标 fold,与现值不同才 COW set + 重算 hash(G1): +/// - 表值来自 [`fold_for_status`](tui_render_unit.rs 唯一策略单点); +/// - FOLD_OVERRIDES 中的 key 永远优先——用户手动操作,自动策略免疫 +/// (spec §7「running 变 completed 时,仅未被手动操作的 entry 可自动折叠」); +/// - 带覆盖的 VM 同时恢复 `user_modified=true`(流式重建后免疫仍成立); +/// - reasoning 状态推导:trailing 流式段(build_bubble_parts running=true) +/// 为 Running;phase 离开 PromptRunning → 全部 Completed。 +/// +/// [G3] active projection 每次发布调用;committed 仅缓存失效时调用。 +/// 单次 pass 为 O(N) 扫描,只对变化项克隆+set。 +pub(super) fn apply_fold_pass( + items: &mut im::Vector, + phase: SessionPhase, + overrides: &HashMap, +) { + use TuiRenderUnit::*; + // [PERF] 读引用代替快照克隆——表只被键盘 handler 低频写入,pass 本身 + // 不写该表(迭代期只读 + 末尾 COW set,无嵌套锁获取),持读锁安全; + // 空表短路:热路径(无手动覆盖)跳过全部查表与 FoldKey 构造克隆。 + let has_overrides = !overrides.is_empty(); + let mut updates: Vec<(usize, TuiRenderUnit)> = Vec::new(); + + for (i, vm) in items.iter().enumerate() { + match vm { + TuiAssistantBubble(b) => { + // ① reasoning 状态推导:phase 离开 PromptRunning → 全部 Completed。 + // ② 正文时长冻结(§6.2 `12.4s`):phase 离开 PromptRunning 时, + // 持有 started_at 的 bubble(trailing 流式段——冻结段在 + // build_bubble_parts 中恒 None)冻结 duration_ms,镜像 + // reasoning 的冻结机制。快照在 TurnDone 后静态,冻结值持续。 + // + // [PERF §15] 先对借用 `b` 做只读判定,命中变化才 clone—— + // 稳态下(无流式、无覆盖变更)零克隆零写入。 + let mut changed = false; + // reasoning 翻转参数:(fold, status, is_running, 冻结时长 ms) + let mut reasoning_update: Option<(FoldState, EntryStatus, bool, Option)> = + None; + if let Some(r) = b.reasoning.as_ref() { + // 状态推导:phase 离开 PromptRunning → 全部 Completed。 + let mut status = r.status; + if phase != SessionPhase::PromptRunning && status == EntryStatus::Running { + status = EntryStatus::Completed; + } + // 用户手动展开(覆盖表中存在 Reasoning(message_id))→ 覆盖优先。 + // 空表短路:无手动覆盖时不构造 FoldKey(避免逐 token 克隆 + // message_id)。 + let override_fold = if has_overrides { + b.message_id + .as_ref() + .and_then(|id| overrides.get(&FoldKey::Reasoning(id.clone())).copied()) + } else { + None + }; + let target_fold = override_fold + .unwrap_or_else(|| fold_for_status(FoldTarget::Reasoning, status)); + let fold_changed = r.fold != target_fold; + let status_changed = + r.status != status || r.is_running != (status == EntryStatus::Running); + if fold_changed || status_changed { + // Running → Completed 时冻结时长(§6.3 `Thought for 12s`): + // started_at 只属于流式段,冻结后置 None,时长不再增长。 + let frozen = + (status == EntryStatus::Completed && r.is_running).then(|| { + r.started_at + .map(|t| t.elapsed().as_millis() as u64) + .unwrap_or(0) + }); + reasoning_update = + Some((target_fold, status, status == EntryStatus::Running, frozen)); + changed = true; + } + } + // 正文时长冻结(§6.2):仅 trailing 流式段持有 started_at。 + let text_freeze = phase != SessionPhase::PromptRunning && b.started_at.is_some(); + if changed || text_freeze { + let mut updated = b.clone(); + if let Some((fold, status, is_running, frozen)) = reasoning_update { + let r = updated.reasoning.as_mut().expect("reasoning_update 必有块"); + r.fold = fold; + if let Some(ms) = frozen { + r.duration_ms = Some(ms); + r.started_at = None; + } + r.status = status; + r.is_running = is_running; + } + if text_freeze { + updated.duration_ms = Some( + b.started_at + .map(|t| t.elapsed().as_millis() as u64) + .unwrap_or(0), + ); + updated.started_at = None; + } + updated.recompute_hash(); + updates.push((i, TuiAssistantBubble(updated))); + } + } + TuiToolCard(t) => { + let status = if t.is_running { + EntryStatus::Running + } else if t.is_error { + EntryStatus::Error + } else { + EntryStatus::Completed + }; + let override_fold = if has_overrides { + overrides.get(&FoldKey::Tool(t.tool_id.clone())).copied() + } else { + None + }; + let user_modified = override_fold.is_some() || t.user_modified; + let target_fold = + override_fold.unwrap_or_else(|| fold_for_status(FoldTarget::Tool, status)); + if t.fold != target_fold || t.user_modified != user_modified { + let mut updated = t.clone(); + updated.fold = target_fold; + updated.user_modified = user_modified; + updated.recompute_hash(); + updates.push((i, TuiToolCard(updated))); + } + } + TuiSubAgentGroup(g) => { + // parent 终态由 canonical is_error 决定(nested child tool + // error 不提升 block error);Error → §7 表 (SubAgent, Error) + // => Expanded(与 tool error 展开语义一致)。 + let status = if g.is_running { + EntryStatus::Running + } else if g.is_error { + EntryStatus::Error + } else { + EntryStatus::Completed + }; + let override_fold = if has_overrides { + overrides + .get(&FoldKey::SubAgent(g.instance_id.clone())) + .copied() + } else { + None + }; + let user_modified = override_fold.is_some() || g.user_modified; + let target_fold = + override_fold.unwrap_or_else(|| fold_for_status(FoldTarget::SubAgent, status)); + if g.fold != target_fold || g.user_modified != user_modified { + let mut updated = g.clone(); + updated.fold = target_fold; + updated.user_modified = user_modified; + updated.recompute_hash(); + updates.push((i, TuiSubAgentGroup(updated))); + } + } + TuiSystemReminder(r) => { + let target_fold = if has_overrides { + overrides + .get(&FoldKey::SystemReminder(r.reminder_id)) + .copied() + .unwrap_or_else(|| { + fold_for_status(FoldTarget::System, EntryStatus::Completed) + }) + } else { + fold_for_status(FoldTarget::System, EntryStatus::Completed) + }; + if r.fold != target_fold { + let mut updated = r.clone(); + updated.fold = target_fold; + updated.recompute_hash(); + updates.push((i, TuiSystemReminder(updated))); + } + } + TuiAskUserBlock(a) => { + // [Slice 4 §6.8] 状态推导:pending → Running(Expanded 可聚焦, + // 等待期间锚定);结果回写(pending=false)→ Completed;error + // 优先。折叠策略来自 fold_for_status 的 Interaction 行 + // (Running→Expanded / Completed→Collapsed / Error→Expanded)。 + let status = if a.is_error { + EntryStatus::Error + } else if a.pending { + EntryStatus::Running + } else { + EntryStatus::Completed + }; + // 用户手动展开过(覆盖表存在 Interaction(request_id))→ 覆盖优先 + let override_fold = if has_overrides { + a.request_id + .as_ref() + .and_then(|id| overrides.get(&FoldKey::Interaction(id.clone())).copied()) + } else { + None + }; + let user_modified = override_fold.is_some() || a.user_modified; + let target_fold = override_fold + .unwrap_or_else(|| fold_for_status(FoldTarget::Interaction, status)); + if a.fold != target_fold || a.user_modified != user_modified { + let mut updated = a.clone(); + updated.fold = target_fold; + updated.user_modified = user_modified; + updated.recompute_hash(); + updates.push((i, TuiAskUserBlock(updated))); + } + } + _ => {} + } + } + + #[cfg(test)] + crate::kit::acp_bridge::observe_perf( + crate::kit::acp_bridge::PerfCounter::FoldPassWrites, + updates.len() as u64, + ); + + for (i, vm) in updates { + items.set(i, vm); + } +} diff --git a/peri-tui/src/kit/acp_events/mod.rs b/peri-tui/src/kit/acp_events/mod.rs index 7878afcc5..186a5559e 100644 --- a/peri-tui/src/kit/acp_events/mod.rs +++ b/peri-tui/src/kit/acp_events/mod.rs @@ -6,6 +6,7 @@ // ── Sub-modules ── mod agent; mod compact; +mod fold; pub(crate) mod render; mod streaming; mod subagent; @@ -214,9 +215,17 @@ pub struct BridgeState { /// observation assigns `None`, preventing a stale earlier sample from /// surviving to `TurnDone`. pub pending_cache_usage: Option, + pub(crate) publication_intent: PublicationIntent, + pub(crate) folded_history: fold::FoldedHistory, } impl BridgeState { + /// 显式同步 barrier:保留 terminal 发布先于 drain/replay 等副作用的顺序。 + fn publish_barrier(&mut self) { + render::push_view_models(self); + self.publication_intent = PublicationIntent::Published; + } + /// 将 current_turn 已产出内容 flush 到 committed,然后 reset。 /// /// 用于 BgCallbackBubble / TurnDone 两个需要保证时序正确性的位置: @@ -265,7 +274,7 @@ impl BridgeState { let content_hash = tui_hash_str(&text); self.current_turn .push_system_note(text, level, content_hash); - render::push_view_models(self); + self.publish_barrier(); render::push_acp_state(self); } @@ -297,11 +306,19 @@ impl BridgeState { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) enum PublicationIntent { + #[default] None, Immediate, + /// handler 已完成显式同步 barrier,scheduler 只结算 pending。 + Published, + /// 历史回放等不受 streaming mode 抑制的更新。 Deferred, + /// 普通流式更新;deadline 到期时仍须核对模式。 + Streaming, + /// canonical 已变化,但模式/块边界不允许自动发布。 + Hidden, } // --------------------------------------------------------------------------- @@ -316,9 +333,7 @@ pub(crate) fn dispatch_for_bridge( state: &mut BridgeState, event: &AcpEventData, ) -> PublicationIntent { - let generation_before = state.generation; - let text_was_empty = state.current_turn.text.is_empty(); - let reasoning_was_empty = state.current_turn.reasoning.is_empty(); + state.publication_intent = PublicationIntent::None; use AcpEventData::*; // S4.1 方案 B:CompactCompleted 置 compact_just_completed 后,任何流事件 // 到达即清除标志——agent 内部 auto-compact 后 ReAct 循环继续产出,流事件 @@ -343,8 +358,8 @@ pub(crate) fn dispatch_for_bridge( } match event { // ── §4.1 Streaming events ── - TextChunk(tc) => streaming::handle_text_chunk(state, tc), - ReasoningChunk(rc) => streaming::handle_reasoning_chunk(state, rc), + TextChunk(tc) => return streaming::handle_text_chunk(state, tc), + ReasoningChunk(rc) => return streaming::handle_reasoning_chunk(state, rc), // ── §4.1 Tools ── ToolStarted(ts) => tool::handle_tool_started(state, ts), @@ -538,40 +553,14 @@ pub(crate) fn dispatch_for_bridge( BgTaskCancelled { task_id, reason } => system::handle_bg_task_cancelled(task_id, reason), } - if state.generation != generation_before { - PublicationIntent::Immediate + let requested = std::mem::take(&mut state.publication_intent); + if requested != PublicationIntent::None { + requested } else if matches!( event, CommittedAssistantText { .. } | ReplayToolStarted { .. } | ReplayToolEnded { .. } ) { - // session/load 历史逐条只更新 canonical state,由 bridge 固定 deadline 合帧; - // SessionReplayDone 等边界 handler 仍会立即发布最终完整快照。 PublicationIntent::Deferred - } else if state.current_turn.has_unprojected_changes() { - match event { - TextChunk(_) => match current_streaming_mode() { - StreamingMode::Streaming if text_was_empty => PublicationIntent::Immediate, - StreamingMode::Streaming => PublicationIntent::Deferred, - StreamingMode::Block - if state.last_pushed_text_len == state.current_turn.text.chars().count() => - { - PublicationIntent::Immediate - } - StreamingMode::Block | StreamingMode::None => PublicationIntent::None, - }, - ReasoningChunk(_) => match current_streaming_mode() { - StreamingMode::Streaming if reasoning_was_empty => PublicationIntent::Immediate, - StreamingMode::Streaming => PublicationIntent::Deferred, - StreamingMode::Block - if state.last_pushed_reasoning_len - == state.current_turn.reasoning.chars().count() => - { - PublicationIntent::Immediate - } - StreamingMode::Block | StreamingMode::None => PublicationIntent::None, - }, - _ => PublicationIntent::Deferred, - } } else { PublicationIntent::None } @@ -584,7 +573,7 @@ pub(crate) fn dispatch_trusted_structured_for_bridge( match event { AcpEventData::SystemReminder { reminder, .. } => { system::handle_trusted_system_reminder(state, reminder); - PublicationIntent::None + std::mem::take(&mut state.publication_intent) } other => dispatch_for_bridge(state, &other), } @@ -594,9 +583,7 @@ pub(crate) fn dispatch_trusted_structured_for_bridge( /// 消费 publication intent 并执行合帧。 #[cfg(test)] pub(crate) fn dispatch_and_notify(state: &mut BridgeState, event: &AcpEventData) { - let generation_before = state.generation; - let _ = dispatch_for_bridge(state, event); - if state.generation == generation_before { + if dispatch_for_bridge(state, event) != PublicationIntent::Published { render::push_view_models(state); } } diff --git a/peri-tui/src/kit/acp_events/render.rs b/peri-tui/src/kit/acp_events/render.rs index baadeab92..612c1dafc 100644 --- a/peri-tui/src/kit/acp_events/render.rs +++ b/peri-tui/src/kit/acp_events/render.rs @@ -5,8 +5,8 @@ use crate::i18n; use crate::kit::atoms::FOLD_OVERRIDES; use crate::kit::submit_request::SubmitRequest; use crate::kit::tui_render_unit::{ - EntryStatus, FoldKey, FoldState, FoldTarget, TuiDivider, TuiRenderUnit, TuiTodoSummary, - TuiToolCard, TuiToolPresentation, fold_for_status, fold_state_code, tui_hash_combine, + FoldKey, FoldState, TuiDivider, TuiRenderUnit, TuiTodoSummary, TuiToolCard, + TuiToolPresentation, fold_state_code, tui_hash_combine, }; use fluent_bundle::FluentValue; use std::sync::Mutex; @@ -24,13 +24,17 @@ use std::sync::Mutex; /// 3. todo 进度摘要行(§6.9:TODO_ITEMS 派生,插在最终回答前); /// 4. `group_successful_tools`(§7:相邻成功工具压成 `TuiCollapsedGroup`)。 pub(crate) fn push_view_models(state: &mut BridgeState) { + // running child 可以阻止归档,但主 turn 的终态时长仍只冻结一次。 + if state.phase != SessionPhase::PromptRunning { + state.current_turn.freeze_trailing(); + } // [Diagnostic] 追踪 VIEW_MODELS 写入时机——配合 scroll diag 分析 submit/history 滚动问题。 // trace 级别:每 token 调用一次,默认 info filter 下不落盘。 let is_loading = state.phase == SessionPhase::PromptRunning; tracing::trace!( target: "msg_scroll_diag", committed = state.committed.len(), - current_turn = state.current_turn.view_models().len(), + current_turn = state.current_turn.view_model_count(), generation = state.generation, phase = ?state.phase, is_loading, @@ -40,7 +44,35 @@ pub(crate) fn push_view_models(state: &mut BridgeState) { // 记一次 elapsed 并重置 `__t`,供定向测量按阶段定位成本归属。 #[cfg(test)] let mut __t = std::time::Instant::now(); - let mut items = state.committed.clone(); + let mut active = state.current_turn.view_models().clone(); + #[cfg(test)] + { + crate::kit::acp_bridge::observe_perf( + crate::kit::acp_bridge::PerfCounter::StageAssembleNs, + __t.elapsed().as_nanos() as u64, + ); + __t = std::time::Instant::now(); + } + + let overrides_state = FOLD_OVERRIDES.state(); + let overrides = overrides_state.read(); + let mut items = state + .folded_history + .project(&state.committed, state.phase, &overrides); + + // [G2] 折叠状态机单点 pass(spec §7 表 + FOLD_OVERRIDES 用户覆盖)。 + // [共享安全] items 通过 join_into 与 current_turn 缓存共享元素——pass 先收集 + // 翻转目标,再用 im::Vector::set(内部 COW)应用,避免就地修改共享节点。 + super::fold::apply_fold_pass(&mut active, state.phase, &overrides); + drop(overrides); + #[cfg(test)] + { + crate::kit::acp_bridge::observe_perf( + crate::kit::acp_bridge::PerfCounter::StageFoldNs, + __t.elapsed().as_nanos() as u64, + ); + __t = std::time::Instant::now(); + } // [§6.6] turn 边界 divider:committed 末尾是**新 turn 的用户 prompt**(≥2 项, // 说明存在上一 turn 内容)且 current_turn 有内容时,在 prompt 之前插一条 @@ -62,7 +94,7 @@ pub(crate) fn push_view_models(state: &mut BridgeState) { }), ); } - join_into(&mut items, state.current_turn.view_models().clone()); + join_into(&mut items, active); #[cfg(test)] { crate::kit::acp_bridge::observe_perf( @@ -72,19 +104,6 @@ pub(crate) fn push_view_models(state: &mut BridgeState) { __t = std::time::Instant::now(); } - // [G2] 折叠状态机单点 pass(spec §7 表 + FOLD_OVERRIDES 用户覆盖)。 - // [共享安全] items 通过 join_into 与 current_turn 缓存共享元素——pass 先收集 - // 翻转目标,再用 im::Vector::set(内部 COW)应用,避免就地修改共享节点。 - apply_fold_pass(&mut items, state.phase); - #[cfg(test)] - { - crate::kit::acp_bridge::observe_perf( - crate::kit::acp_bridge::PerfCounter::StageFoldNs, - __t.elapsed().as_nanos() as u64, - ); - __t = std::time::Instant::now(); - } - // [§6.9] todo 进度摘要:活动 turn(current_turn 非空)且 TODO_ITEMS 非空时, // 插在 trailing 最终回答之前(回答后无 todo);无 trailing 回答时位于 turn 底部。 insert_todo_summary(&mut items, state.phase); @@ -704,221 +723,6 @@ fn build_collapsed_group(run: &im::Vector, failed_count: u32) -> TuiRenderUnit::TuiCollapsedGroup(group) } -/// [G2] 折叠状态机单点 pass——spec §7 折叠表 + FOLD_OVERRIDES 用户覆盖。 -/// -/// 对每个带 fold 字段的 VM 计算目标 fold,与现值不同才 COW set + 重算 hash(G1): -/// - 表值来自 [`fold_for_status`](tui_render_unit.rs 唯一策略单点); -/// - FOLD_OVERRIDES 中的 key 永远优先——用户手动操作,自动策略免疫 -/// (spec §7「running 变 completed 时,仅未被手动操作的 entry 可自动折叠」); -/// - 带覆盖的 VM 同时恢复 `user_modified=true`(流式重建后免疫仍成立); -/// - reasoning 状态推导:trailing 流式段(build_bubble_parts running=true) -/// 为 Running;phase 离开 PromptRunning → 全部 Completed。 -/// -/// [G3] 逐 token 调用,但只对变化项做克隆+set:稳态下(无流式、无覆盖变更) -/// 是 O(N) 只读扫描,零写入。 -fn apply_fold_pass(items: &mut im::Vector, phase: SessionPhase) { - use TuiRenderUnit::*; - // [PERF] 读引用代替快照克隆——表只被键盘 handler 低频写入,pass 本身 - // 不写该表(迭代期只读 + 末尾 COW set,无嵌套锁获取),持读锁安全; - // 空表短路:热路径(无手动覆盖)跳过全部查表与 FoldKey 构造克隆。 - let overrides_state = FOLD_OVERRIDES.state(); - let overrides = overrides_state.read(); - let has_overrides = !overrides.is_empty(); - let mut updates: Vec<(usize, TuiRenderUnit)> = Vec::new(); - - for (i, vm) in items.iter().enumerate() { - match vm { - TuiAssistantBubble(b) => { - // ① reasoning 状态推导:phase 离开 PromptRunning → 全部 Completed。 - // ② 正文时长冻结(§6.2 `12.4s`):phase 离开 PromptRunning 时, - // 持有 started_at 的 bubble(trailing 流式段——冻结段在 - // build_bubble_parts 中恒 None)冻结 duration_ms,镜像 - // reasoning 的冻结机制。快照在 TurnDone 后静态,冻结值持续。 - // - // [PERF §15] 先对借用 `b` 做只读判定,命中变化才 clone—— - // 稳态下(无流式、无覆盖变更)零克隆零写入。 - let mut changed = false; - // reasoning 翻转参数:(fold, status, is_running, 冻结时长 ms) - let mut reasoning_update: Option<(FoldState, EntryStatus, bool, Option)> = - None; - if let Some(r) = b.reasoning.as_ref() { - // 状态推导:phase 离开 PromptRunning → 全部 Completed。 - let mut status = r.status; - if phase != SessionPhase::PromptRunning && status == EntryStatus::Running { - status = EntryStatus::Completed; - } - // 用户手动展开(覆盖表中存在 Reasoning(message_id))→ 覆盖优先。 - // 空表短路:无手动覆盖时不构造 FoldKey(避免逐 token 克隆 - // message_id)。 - let override_fold = if has_overrides { - b.message_id - .as_ref() - .and_then(|id| overrides.get(&FoldKey::Reasoning(id.clone())).copied()) - } else { - None - }; - let target_fold = override_fold - .unwrap_or_else(|| fold_for_status(FoldTarget::Reasoning, status)); - let fold_changed = r.fold != target_fold; - let status_changed = - r.status != status || r.is_running != (status == EntryStatus::Running); - if fold_changed || status_changed { - // Running → Completed 时冻结时长(§6.3 `Thought for 12s`): - // started_at 只属于流式段,冻结后置 None,时长不再增长。 - let frozen = - (status == EntryStatus::Completed && r.is_running).then(|| { - r.started_at - .map(|t| t.elapsed().as_millis() as u64) - .unwrap_or(0) - }); - reasoning_update = - Some((target_fold, status, status == EntryStatus::Running, frozen)); - changed = true; - } - } - // 正文时长冻结(§6.2):仅 trailing 流式段持有 started_at。 - let text_freeze = phase != SessionPhase::PromptRunning && b.started_at.is_some(); - if changed || text_freeze { - let mut updated = b.clone(); - if let Some((fold, status, is_running, frozen)) = reasoning_update { - let r = updated.reasoning.as_mut().expect("reasoning_update 必有块"); - r.fold = fold; - if let Some(ms) = frozen { - r.duration_ms = Some(ms); - r.started_at = None; - } - r.status = status; - r.is_running = is_running; - } - if text_freeze { - updated.duration_ms = Some( - b.started_at - .map(|t| t.elapsed().as_millis() as u64) - .unwrap_or(0), - ); - updated.started_at = None; - } - updated.recompute_hash(); - updates.push((i, TuiAssistantBubble(updated))); - } - } - TuiToolCard(t) => { - let status = if t.is_running { - EntryStatus::Running - } else if t.is_error { - EntryStatus::Error - } else { - EntryStatus::Completed - }; - let override_fold = if has_overrides { - overrides.get(&FoldKey::Tool(t.tool_id.clone())).copied() - } else { - None - }; - let user_modified = override_fold.is_some() || t.user_modified; - let target_fold = - override_fold.unwrap_or_else(|| fold_for_status(FoldTarget::Tool, status)); - if t.fold != target_fold || t.user_modified != user_modified { - let mut updated = t.clone(); - updated.fold = target_fold; - updated.user_modified = user_modified; - updated.recompute_hash(); - updates.push((i, TuiToolCard(updated))); - } - } - TuiSubAgentGroup(g) => { - // parent 终态由 canonical is_error 决定(nested child tool - // error 不提升 block error);Error → §7 表 (SubAgent, Error) - // => Expanded(与 tool error 展开语义一致)。 - let status = if g.is_running { - EntryStatus::Running - } else if g.is_error { - EntryStatus::Error - } else { - EntryStatus::Completed - }; - let override_fold = if has_overrides { - overrides - .get(&FoldKey::SubAgent(g.instance_id.clone())) - .copied() - } else { - None - }; - let user_modified = override_fold.is_some() || g.user_modified; - let target_fold = - override_fold.unwrap_or_else(|| fold_for_status(FoldTarget::SubAgent, status)); - if g.fold != target_fold || g.user_modified != user_modified { - let mut updated = g.clone(); - updated.fold = target_fold; - updated.user_modified = user_modified; - updated.recompute_hash(); - updates.push((i, TuiSubAgentGroup(updated))); - } - } - TuiSystemReminder(r) => { - let target_fold = if has_overrides { - overrides - .get(&FoldKey::SystemReminder(r.reminder_id)) - .copied() - .unwrap_or_else(|| { - fold_for_status(FoldTarget::System, EntryStatus::Completed) - }) - } else { - fold_for_status(FoldTarget::System, EntryStatus::Completed) - }; - if r.fold != target_fold { - let mut updated = r.clone(); - updated.fold = target_fold; - updated.recompute_hash(); - updates.push((i, TuiSystemReminder(updated))); - } - } - TuiAskUserBlock(a) => { - // [Slice 4 §6.8] 状态推导:pending → Running(Expanded 可聚焦, - // 等待期间锚定);结果回写(pending=false)→ Completed;error - // 优先。折叠策略来自 fold_for_status 的 Interaction 行 - // (Running→Expanded / Completed→Collapsed / Error→Expanded)。 - let status = if a.is_error { - EntryStatus::Error - } else if a.pending { - EntryStatus::Running - } else { - EntryStatus::Completed - }; - // 用户手动展开过(覆盖表存在 Interaction(request_id))→ 覆盖优先 - let override_fold = if has_overrides { - a.request_id - .as_ref() - .and_then(|id| overrides.get(&FoldKey::Interaction(id.clone())).copied()) - } else { - None - }; - let user_modified = override_fold.is_some() || a.user_modified; - let target_fold = override_fold - .unwrap_or_else(|| fold_for_status(FoldTarget::Interaction, status)); - if a.fold != target_fold || a.user_modified != user_modified { - let mut updated = a.clone(); - updated.fold = target_fold; - updated.user_modified = user_modified; - updated.recompute_hash(); - updates.push((i, TuiAskUserBlock(updated))); - } - } - _ => {} - } - } - - #[cfg(test)] - crate::kit::acp_bridge::observe_perf( - crate::kit::acp_bridge::PerfCounter::FoldPassWrites, - updates.len() as u64, - ); - - for (i, vm) in updates { - items.set(i, vm); - } -} - /// 由 acp_bridge 在 BRIDGE_RESET_COUNTER 复位时调用—— /// 立即将空快照写入 VIEW_MODELS atom,防止其他 reader 读到旧 session 数据。 pub fn push_view_models_for_reset() { @@ -950,7 +754,7 @@ pub fn push_view_models_for_reset() { pub(crate) fn push_acp_state(state: &mut BridgeState) { let snapshot = AcpStateSnapshot { variant: state.variant, - view_count: state.committed.len() + state.current_turn.view_models().len(), + view_count: state.committed.len() + state.current_turn.view_model_count(), is_loading: state.phase == SessionPhase::PromptRunning, wizard_active: false, at_mention_active: *AT_MENTION_ACTIVE.state().read(), diff --git a/peri-tui/src/kit/acp_events/streaming.rs b/peri-tui/src/kit/acp_events/streaming.rs index 3bd9b3c80..2c275c48d 100644 --- a/peri-tui/src/kit/acp_events/streaming.rs +++ b/peri-tui/src/kit/acp_events/streaming.rs @@ -24,7 +24,16 @@ fn is_bg_agent_without_group(agent_id: &str) -> bool { is_bg } -pub(super) fn handle_text_chunk(state: &mut BridgeState, tc: &TuiTextChunk) { +pub(super) fn handle_text_chunk(state: &mut BridgeState, tc: &TuiTextChunk) -> PublicationIntent { + if tc.text.is_empty() { + return PublicationIntent::None; + } + let first = first_chunk( + state, + tc.agent_id.as_deref(), + tc.message_id.as_deref(), + false, + ); // 先尝试 SubAgent 组路由;带 agent_id 但无匹配组 = 主 agent 文本 // (v2 事件身份透传后主 agent chunk 亦携带 agent_id,`append_subagent_text` // 找不到组即回退主 agent 分支,不能静默丢弃——否则主 agent 回复不显示)。 @@ -50,11 +59,6 @@ pub(super) fn handle_text_chunk(state: &mut BridgeState, tc: &TuiTextChunk) { if !is_bg { state.phase = SessionPhase::PromptRunning; } - // SubAgent 文本:Streaming/Block→always push, None→skip - // 不做块边界检测——subagent 输出相对短且不是主要闪烁来源。 - if super::current_streaming_mode() != super::StreamingMode::None { - super::render::push_view_models(state); - } } else if tc .agent_id .as_deref() @@ -65,36 +69,31 @@ pub(super) fn handle_text_chunk(state: &mut BridgeState, tc: &TuiTextChunk) { } state.variant = 1; super::render::push_acp_state(state); - return; + return PublicationIntent::None; } else { state .current_turn .append_text(&tc.text, tc.message_id.as_deref()); state.variant = 1; state.phase = SessionPhase::PromptRunning; - let should_push = match super::current_streaming_mode() { - super::StreamingMode::Streaming => true, - super::StreamingMode::Block => { - if super::has_md_block_boundary_since( - &state.current_turn.text, - state.last_pushed_text_len, - ) { - state.last_pushed_text_len = state.current_turn.text.chars().count(); - true - } else { - false - } - } - super::StreamingMode::None => false, - }; - if should_push { - // bridge-local scheduler consumes the publication intent after canonical ingest. - } } super::render::push_acp_state(state); + stream_intent(state, first, routed_to_subagent, false) } -pub(super) fn handle_reasoning_chunk(state: &mut BridgeState, rc: &TuiReasoningChunk) { +pub(super) fn handle_reasoning_chunk( + state: &mut BridgeState, + rc: &TuiReasoningChunk, +) -> PublicationIntent { + if rc.text.is_empty() { + return PublicationIntent::None; + } + let first = first_chunk( + state, + rc.agent_id.as_deref(), + rc.message_id.as_deref(), + true, + ); // 同 handle_text_chunk:subagent 路由失败时回退主 agent 推理分支 // (主 agent thinking chunk 亦携带 agent_id,不能静默丢弃)。 let routed_to_subagent = rc.agent_id.as_deref().is_some_and(|agent_id| { @@ -115,10 +114,6 @@ pub(super) fn handle_reasoning_chunk(state: &mut BridgeState, rc: &TuiReasoningC if !is_bg { state.phase = SessionPhase::PromptRunning; } - // SubAgent 推理:Streaming/Block→always push, None→skip - if super::current_streaming_mode() != super::StreamingMode::None { - super::render::push_view_models(state); - } } else if rc .agent_id .as_deref() @@ -129,7 +124,7 @@ pub(super) fn handle_reasoning_chunk(state: &mut BridgeState, rc: &TuiReasoningC } state.variant = 1; super::render::push_acp_state(state); - return; + return PublicationIntent::None; } else { state .current_turn @@ -142,24 +137,72 @@ pub(super) fn handle_reasoning_chunk(state: &mut BridgeState, rc: &TuiReasoningC ); state.variant = 1; state.phase = SessionPhase::PromptRunning; - let should_push = match super::current_streaming_mode() { - super::StreamingMode::Streaming => true, - super::StreamingMode::Block => { - if super::has_md_block_boundary_since( + } + super::render::push_acp_state(state); + stream_intent(state, first, routed_to_subagent, true) +} + +/// 子流沿现有 occurrence/segment 路由;不把主 Agent 的空字符串当子流首块。 +fn first_chunk( + state: &BridgeState, + agent_id: Option<&str>, + message_id: Option<&str>, + reasoning: bool, +) -> bool { + if let Some(child) = agent_id.and_then(|id| { + state + .current_turn + .subagents + .iter() + .rev() + .find(|s| s.agent_id == id) + }) { + // 既有子流 API 没有透传 message_id,使用 occurrence 内的 segment 边界。 + child.child_turn.starts_stream_block(None, reasoning) + } else { + state + .current_turn + .starts_stream_block(message_id, reasoning) + } +} + +fn stream_intent( + state: &mut BridgeState, + first: bool, + subagent: bool, + reasoning: bool, +) -> PublicationIntent { + match current_streaming_mode() { + StreamingMode::None => PublicationIntent::Hidden, + StreamingMode::Streaming => { + if first { + PublicationIntent::Immediate + } else { + PublicationIntent::Streaming + } + } + StreamingMode::Block if subagent => { + if first { + PublicationIntent::Immediate + } else { + PublicationIntent::Streaming + } + } + StreamingMode::Block => { + let (text, pushed) = if reasoning { + ( &state.current_turn.reasoning, - state.last_pushed_reasoning_len, - ) { - state.last_pushed_reasoning_len = state.current_turn.reasoning.chars().count(); - true - } else { - false - } + &mut state.last_pushed_reasoning_len, + ) + } else { + (&state.current_turn.text, &mut state.last_pushed_text_len) + }; + if has_md_block_boundary_since(text, *pushed) { + *pushed = text.chars().count(); + PublicationIntent::Immediate + } else { + PublicationIntent::Hidden } - super::StreamingMode::None => false, - }; - if should_push { - // bridge-local scheduler consumes the publication intent after canonical ingest. } } - super::render::push_acp_state(state); } diff --git a/peri-tui/src/kit/acp_events/subagent.rs b/peri-tui/src/kit/acp_events/subagent.rs index 4f631adac..883e5243d 100644 --- a/peri-tui/src/kit/acp_events/subagent.rs +++ b/peri-tui/src/kit/acp_events/subagent.rs @@ -37,7 +37,7 @@ pub(super) fn handle_subagent_started( } state.variant = 1; state.phase = SessionPhase::PromptRunning; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -69,6 +69,6 @@ pub(super) fn handle_subagent_stopped( state.variant = 1; // phase 由 SubagentStarted + 流式事件维护,此处不再无条件覆盖 // (避免 bg agent 的场景 TurnDone/TurnSuspended 后被重新激活) - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } diff --git a/peri-tui/src/kit/acp_events/system.rs b/peri-tui/src/kit/acp_events/system.rs index 813422357..99dc5ee13 100644 --- a/peri-tui/src/kit/acp_events/system.rs +++ b/peri-tui/src/kit/acp_events/system.rs @@ -235,7 +235,7 @@ pub(super) fn handle_hitl_pending( .committed .push_back(TuiRenderUnit::TuiAskUserBlock(block)); } - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_popup_kind(state); super::render::push_acp_state(state); } @@ -256,7 +256,7 @@ pub(super) fn handle_ask_user(state: &mut BridgeState, pending: &PendingInteract .committed .push_back(TuiRenderUnit::TuiAskUserBlock(block)); } - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -357,7 +357,7 @@ pub(super) fn handle_interaction_terminal( if let Some((i, vm)) = updated { state.committed.set(i, vm); } - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -550,7 +550,7 @@ pub(super) fn handle_rewind_completed(state: &mut BridgeState, messages_json: &s } } state.phase = SessionPhase::Idle; - super::render::push_view_models(state); + state.publish_barrier(); // Rewind v2:回填目标 user 消息文本到输入框(复用 TurnInterrupted 的回填通道)。 // 消费 REWIND_TARGET_TEXT → INPUT_RESTORE_TEXT + 心跳 → InputArea use_effect diff --git a/peri-tui/src/kit/acp_events/tool.rs b/peri-tui/src/kit/acp_events/tool.rs index 04e8ce9d2..18ab55867 100644 --- a/peri-tui/src/kit/acp_events/tool.rs +++ b/peri-tui/src/kit/acp_events/tool.rs @@ -26,7 +26,7 @@ pub(super) fn handle_tool_started(state: &mut BridgeState, ts: &TuiToolStarted) state.variant = 1; // bg 工具事件不触碰 phase(Issue 2026-08-12):仅更新 BG_DISPLAY, // 主 agent 已空闲(TurnSuspended → Idle)时不得拉回 loading。 - super::render::push_view_models(state); + state.publish_barrier(); // block 模式:ToolStarted 时已推送缓冲文本到视图, // 同步追踪变量,确保工具执行完毕后新 TextChunk 的块边界检测从正确位置开始。 state.last_pushed_text_len = state.current_turn.text.chars().count(); @@ -70,7 +70,7 @@ pub(super) fn handle_tool_started(state: &mut BridgeState, ts: &TuiToolStarted) } state.variant = 1; state.phase = SessionPhase::PromptRunning; - super::render::push_view_models(state); + state.publish_barrier(); state.last_pushed_text_len = state.current_turn.text.chars().count(); state.last_pushed_reasoning_len = state.current_turn.reasoning.chars().count(); } @@ -86,7 +86,7 @@ pub(super) fn handle_tool_started(state: &mut BridgeState, ts: &TuiToolStarted) )); state.variant = 1; state.phase = SessionPhase::PromptRunning; - super::render::push_view_models(state); + state.publish_barrier(); state.last_pushed_text_len = state.current_turn.text.chars().count(); state.last_pushed_reasoning_len = state.current_turn.reasoning.chars().count(); } @@ -104,7 +104,7 @@ pub(super) fn handle_tool_ended(state: &mut BridgeState, te: &TuiToolEnded) { handle_bg_tool_ended(agent_id, te); state.variant = 1; // bg 工具事件不触碰 phase(Issue 2026-08-12,同 ToolStarted)。 - super::render::push_view_models(state); + state.publish_barrier(); state.last_pushed_text_len = state.current_turn.text.chars().count(); state.last_pushed_reasoning_len = state.current_turn.reasoning.chars().count(); state.complete_todo_if_current(&te.tool_id, te.is_error) @@ -121,7 +121,7 @@ pub(super) fn handle_tool_ended(state: &mut BridgeState, te: &TuiToolEnded) { ); state.variant = 1; state.phase = SessionPhase::PromptRunning; - super::render::push_view_models(state); + state.publish_barrier(); state.last_pushed_text_len = state.current_turn.text.chars().count(); state.last_pushed_reasoning_len = state.current_turn.reasoning.chars().count(); ended && state.complete_todo_if_current(&te.tool_id, te.is_error) @@ -133,7 +133,7 @@ pub(super) fn handle_tool_ended(state: &mut BridgeState, te: &TuiToolEnded) { .end_tool(&te.tool_id, te.output_summary.clone(), te.is_error); state.variant = 1; state.phase = SessionPhase::PromptRunning; - super::render::push_view_models(state); + state.publish_barrier(); state.last_pushed_text_len = state.current_turn.text.chars().count(); state.last_pushed_reasoning_len = state.current_turn.reasoning.chars().count(); ended && state.complete_todo_if_current(&te.tool_id, te.is_error) @@ -240,9 +240,15 @@ fn update_committed_tool_card( Some(card.input_summary.clone()), ), presentation: card.presentation.clone(), - // 保留既有折叠状态——折叠统一由 push_view_models 的 pass 按 - // 新状态(completed/error)重算;用户覆盖由 FOLD_OVERRIDES 表接管。 - fold: card.fold, + // 基础 fold 跟随终态;用户覆盖仍由派生折叠投影接管。 + fold: fold_for_status( + FoldTarget::Tool, + if is_error { + EntryStatus::Error + } else { + EntryStatus::Completed + }, + ), user_modified: card.user_modified, tool_calls_count: card.tool_calls_count, content_hash: 0, diff --git a/peri-tui/src/kit/acp_events/turn.rs b/peri-tui/src/kit/acp_events/turn.rs index 6a8ed30bb..4c286fe9b 100644 --- a/peri-tui/src/kit/acp_events/turn.rs +++ b/peri-tui/src/kit/acp_events/turn.rs @@ -34,7 +34,7 @@ pub(super) fn handle_turn_done(state: &mut BridgeState) { "TurnDone: writing ACP_STATE" ); - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); // C2: compact 命令完成后触发 session/load 重放。 @@ -161,7 +161,7 @@ pub(super) fn handle_turn_interrupted( state.last_pushed_reasoning_len = 0; state.variant = 0; state.phase = SessionPhase::Idle; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); // Issue 2026-08-05 遗留项(中):stale 分支复位后主动 drain 排队输入。 // 旧 turn 已取消、其 TurnDone 永不到达——排队输入(用户 loading 期间 @@ -209,7 +209,7 @@ pub(super) fn handle_turn_interrupted( state.last_pushed_reasoning_len = 0; state.variant = 0; state.phase = SessionPhase::Idle; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); return; } @@ -229,7 +229,7 @@ pub(super) fn handle_turn_interrupted( state.last_pushed_reasoning_len = 0; state.variant = 0; state.phase = SessionPhase::Idle; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -248,7 +248,7 @@ pub(super) fn handle_turn_suspended(state: &mut BridgeState) { state.last_pushed_reasoning_len = 0; state.variant = 0; state.phase = SessionPhase::Idle; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); // 注意:不调用 drain_input_buffer()——Agent 保持存活, // 输入缓冲在 Agent 真正完成(TurnDone)时再处理。 @@ -258,7 +258,7 @@ pub(super) fn handle_turn_committed(state: &mut BridgeState, steps: usize) { tracing::info!(steps, "bridge: TurnCommitted ({steps} steps)"); // 在 goal 自驱场景下 TurnDone 只在最终循环退出时触发, // TurnCommitted 作为每次 ReAct 迭代边界的刷新检查点,防止 TUI atom 漂移。 - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -291,7 +291,7 @@ pub(super) fn handle_session_replay_started(state: &mut BridgeState) { state.current_turn.reset(); state.last_pushed_text_len = 0; state.last_pushed_reasoning_len = 0; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -304,7 +304,7 @@ pub(super) fn handle_session_replay_done(state: &mut BridgeState) { state.current_turn.reset(); state.last_pushed_text_len = 0; state.last_pushed_reasoning_len = 0; - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -319,7 +319,7 @@ pub(super) fn handle_local_user_bubble(state: &mut BridgeState, text: &str) { .push_back(TuiRenderUnit::TuiUserBubble(TuiUserBubble::new( text.to_string(), ))); - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } @@ -359,19 +359,20 @@ pub(super) fn handle_user_input_delivered( .push_back(TuiRenderUnit::TuiUserBubble(TuiUserBubble::new( content.text_content(), ))); - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } /// S4.2: 本地 loading 复位请求(cancel / /clear / prompt 失败兜底,由 /// submit_consumer 注入 LOCAL_EVENT_TX)。幂等:phase 非 PromptRunning 时 /// no-op(不 push)——命令 compact、replay、正常提交等场景不受影响;phase -/// 为 PromptRunning 时复位为 Idle 并重推 ACP_STATE,防止后续事件触发 +/// 为 PromptRunning 时复位为 Idle,同步发布最终视图并结算 pending、重推 ACP_STATE,防止后续事件触发 /// push_acp_state 时用 phase 重算 is_loading=true 造成取消后 loading 闪回 /// (Issue 2026-08-05 S4.2)。 pub(super) fn handle_loading_reset(state: &mut BridgeState) { if state.phase == SessionPhase::PromptRunning { state.phase = SessionPhase::Idle; + state.publish_barrier(); super::render::push_acp_state(state); } } @@ -384,7 +385,7 @@ pub(super) fn handle_bg_callback_bubble(state: &mut BridgeState) { // ② bg 回调气泡在中间(LocalUserBubble 随后到达) // ③ 后续 AI 内容在后(TurnDone 归档) state.flush_current_turn(); - super::render::push_view_models(state); + state.publish_barrier(); super::render::push_acp_state(state); } diff --git a/peri-tui/src/kit/acp_events_test.rs b/peri-tui/src/kit/acp_events_test.rs index 802be05f6..019554ceb 100644 --- a/peri-tui/src/kit/acp_events_test.rs +++ b/peri-tui/src/kit/acp_events_test.rs @@ -107,6 +107,8 @@ fn make_fold_test_state() -> BridgeState { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), } } diff --git a/peri-tui/src/kit/acp_events_test/bg_task_live_test.rs b/peri-tui/src/kit/acp_events_test/bg_task_live_test.rs index 2d6c2bf92..9fb643799 100644 --- a/peri-tui/src/kit/acp_events_test/bg_task_live_test.rs +++ b/peri-tui/src/kit/acp_events_test/bg_task_live_test.rs @@ -266,5 +266,7 @@ fn make_state() -> BridgeState { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), } } diff --git a/peri-tui/src/kit/acp_events_test/command_feedback_test.rs b/peri-tui/src/kit/acp_events_test/command_feedback_test.rs index 0d375a6cc..3a9c8e24a 100644 --- a/peri-tui/src/kit/acp_events_test/command_feedback_test.rs +++ b/peri-tui/src/kit/acp_events_test/command_feedback_test.rs @@ -28,6 +28,8 @@ fn test_command_feedback_injects_system_note() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // Info(UiOnly 通道) diff --git a/peri-tui/src/kit/acp_events_test/session_events_test.rs b/peri-tui/src/kit/acp_events_test/session_events_test.rs index 1bd3dca89..ee3ef37cc 100644 --- a/peri-tui/src/kit/acp_events_test/session_events_test.rs +++ b/peri-tui/src/kit/acp_events_test/session_events_test.rs @@ -21,7 +21,7 @@ fn test_replay_events_defer_publication_until_scheduler_boundary() { assert_eq!(state.generation, 0, "逐条 replay 不应完整发布 VIEW_MODELS"); let intent = dispatch_for_bridge(&mut state, &AcpEventData::SessionReplayDone); - assert_eq!(intent, PublicationIntent::Immediate); + assert_eq!(intent, PublicationIntent::Published); assert_eq!(state.generation, 1, "completion 边界必须发布完整历史"); assert_eq!(VIEW_MODELS.state().read().items.len(), 2); } @@ -60,6 +60,8 @@ fn test_push_view_models_uses_bridge_state() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // push_view_models: 用 BridgeState 数据(空 committed + 空 current_turn)→ 空 items @@ -158,6 +160,8 @@ fn test_prediction_writes_prediction_atom() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; use peri_acp_types::event_data::{Prediction, PredictionAction}; @@ -213,6 +217,8 @@ fn test_rewind_completed_replaces_committed() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let messages_json = serde_json::json!([ @@ -277,6 +283,8 @@ fn test_rewind_completed_rebuild_preview_strips_reminder() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let messages_json = serde_json::json!([ { @@ -341,6 +349,8 @@ fn test_rewind_completed_restores_target_text_to_input() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let messages_json = serde_json::json!([ {"role": "user", "id": "msg-1", "content": "历史用户消息"}, @@ -400,6 +410,8 @@ fn test_rewind_completed_without_target_text_no_restore() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let messages_json = serde_json::json!([]).to_string(); dispatch_and_notify(&mut state, &AcpEventData::RewindCompleted { messages_json }); @@ -436,6 +448,8 @@ fn test_multi_turn_reasoning_preserved_in_committed() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // === Turn 1: user bubble, reasoning + text → TurnDone === @@ -578,6 +592,8 @@ fn test_auto_compact_completed_injects_detailed_system_note() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; super::super::compact::handle_compact_completed(&mut state, "", "auto", "micro", 7, 2048, 2, 1); @@ -625,6 +641,8 @@ fn test_full_compact_completed_shows_unmeasured_token_saving() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; super::super::compact::handle_compact_completed(&mut state, "", "auto", "full", 7, 0, 2, 1); @@ -669,6 +687,8 @@ fn test_unknown_and_empty_compact_strategy_use_full_unmeasured_detail() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; super::super::compact::handle_compact_completed( @@ -739,6 +759,8 @@ async fn test_compact_turndone_reload() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // Phase 5 Step 7 补遗(Step 8 回归修复):manual compact 场景的 UiOnly @@ -799,6 +821,8 @@ async fn test_compact_turndone_reload() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // ① CompactCompleted(auto):不置标志(S4.1 方案 A——服务端透传 trigger, diff --git a/peri-tui/src/kit/acp_events_test/streaming_test.rs b/peri-tui/src/kit/acp_events_test/streaming_test.rs index 4dc149b46..f39787120 100644 --- a/peri-tui/src/kit/acp_events_test/streaming_test.rs +++ b/peri-tui/src/kit/acp_events_test/streaming_test.rs @@ -56,6 +56,7 @@ fn test_boundary_no_boundary_in_tail() { /// 默认(未设置 streaming_mode 或 PERI_CONFIG_HANDLE 未初始化)应返回 Streaming。 #[test] +#[serial] fn test_mode_default_is_streaming() { // PERI_CONFIG_HANDLE 在测试中未初始化 → get() 返回 None → fallback 到 Streaming assert!( diff --git a/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs b/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs index 8f16a8696..45d2243a6 100644 --- a/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs +++ b/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs @@ -25,6 +25,8 @@ fn test_dispatch_subagent_streaming_updates_current_turn_group() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( @@ -84,6 +86,8 @@ fn test_subagent_stopped_freezes_child_trailing_bubble() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( @@ -175,6 +179,8 @@ fn test_subagent_stopped_after_turn_done_does_not_set_loading() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 模拟 TurnDone:归档 + 重置 phase/loading @@ -231,6 +237,8 @@ fn test_subagent_stopped_after_turn_suspended_does_not_set_loading() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 模拟 TurnSuspended:归档 + 重置 phase/loading @@ -290,6 +298,8 @@ fn test_bg_subagent_chunk_after_turn_suspended_does_not_leak_to_main() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // bg subagent 启动(注册 BG_AGENT_IDS + current_turn 组) @@ -389,6 +399,8 @@ fn test_bg_events_after_turn_suspended_keep_idle_loading() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 前置:bg 启动(注册 BG_AGENT_IDS + SubAgentGroup)→ 主 turn 挂起 @@ -544,6 +556,8 @@ fn test_subagent_stopped_after_subagent_started_keeps_loading() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // SubagentStarted 设置 phase=PromptRunning @@ -608,6 +622,8 @@ fn test_prompt_submitted_sets_loading() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( @@ -652,6 +668,8 @@ fn test_dispatch_sync_subagent_tool_routed_to_group() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 启动同步 sub-agent @@ -740,6 +758,8 @@ fn test_loading_reset_event_resets_phase() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 前置:PromptSubmitted 使 bridge 进入 PromptRunning,ACP_STATE 派生 loading dispatch_and_notify( @@ -805,6 +825,8 @@ fn test_loading_reset_then_turn_interrupted_keeps_idle() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, diff --git a/peri-tui/src/kit/acp_events_test/todo_skill_test.rs b/peri-tui/src/kit/acp_events_test/todo_skill_test.rs index 33ad7205d..019365482 100644 --- a/peri-tui/src/kit/acp_events_test/todo_skill_test.rs +++ b/peri-tui/src/kit/acp_events_test/todo_skill_test.rs @@ -25,6 +25,8 @@ fn test_todo_snapshot_advances_only_after_successful_tool_end() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let start = |id: &str, status: &str| { @@ -94,6 +96,8 @@ fn test_duplicate_todo_end_cannot_roll_back_newer_successful_snapshot() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let start = |id: &str, status: &str| { AcpEventData::ToolStarted(crate::kit::stream_data::TuiToolStarted { @@ -155,6 +159,8 @@ fn test_replay_skill_card_hides_raw_skill_output() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( @@ -213,6 +219,8 @@ fn test_later_started_todo_wins_when_successful_ends_arrive_out_of_order() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; let start = |id: &str, status: &str| { AcpEventData::ToolStarted(crate::kit::stream_data::TuiToolStarted { diff --git a/peri-tui/src/kit/acp_events_test/turn_archive_test.rs b/peri-tui/src/kit/acp_events_test/turn_archive_test.rs index c4f4e12a8..db8e5f6b4 100644 --- a/peri-tui/src/kit/acp_events_test/turn_archive_test.rs +++ b/peri-tui/src/kit/acp_events_test/turn_archive_test.rs @@ -25,6 +25,8 @@ fn test_two_turn_done_accumulates_committed() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 第一轮:stream one text → TurnDone @@ -89,6 +91,8 @@ fn test_turndone_archives_assistant_to_committed() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // 往 current_turn 写入一条 assistant 文本 @@ -148,6 +152,8 @@ fn test_turn_interrupted_empty_skips_archive() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( @@ -198,6 +204,8 @@ fn test_turn_done_clears_last_submitted_text() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( diff --git a/peri-tui/src/kit/acp_events_test/turn_interrupted_test.rs b/peri-tui/src/kit/acp_events_test/turn_interrupted_test.rs index b66b5e201..91ee35c9d 100644 --- a/peri-tui/src/kit/acp_events_test/turn_interrupted_test.rs +++ b/peri-tui/src/kit/acp_events_test/turn_interrupted_test.rs @@ -40,6 +40,8 @@ fn test_stale_turn_interrupted_does_not_rollback_new_turn() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, @@ -164,6 +166,8 @@ fn test_turn_interrupted_zero_output_rollback_still_works() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, @@ -251,6 +255,8 @@ fn test_turn_interrupted_archive_branch_drains_input_buffer() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, @@ -336,6 +342,8 @@ fn test_stale_turn_interrupted_request_id_mismatch() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // turn A:LocalUserBubble + PromptSubmitted(A1) dispatch_and_notify( @@ -457,6 +465,8 @@ fn test_stale_turn_interrupted_queued_branch_still_stale() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, @@ -542,6 +552,8 @@ fn test_stale_turn_interrupted_drain_is_idempotent() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // turn A 运行中 dispatch_and_notify( @@ -641,6 +653,8 @@ fn test_turn_interrupted_current_request_id_rollback() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, @@ -722,6 +736,8 @@ fn test_turn_interrupted_none_request_id_falls_back() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; dispatch_and_notify( &mut state, @@ -791,6 +807,8 @@ fn test_double_cancel_request_id_pairs() { last_prompt_generation: 0, current_request_id: None, pending_cache_usage: None, + publication_intent: Default::default(), + folded_history: Default::default(), }; // A 提交并运行 dispatch_and_notify( diff --git a/peri-tui/src/kit/acp_types/current_turn.rs b/peri-tui/src/kit/acp_types/current_turn.rs index 2b57e226b..8c28733ba 100644 --- a/peri-tui/src/kit/acp_types/current_turn.rs +++ b/peri-tui/src/kit/acp_types/current_turn.rs @@ -188,12 +188,36 @@ impl CurrentTurn { self.cache_dirty = true; } + /// 结构计数与 projection 一一对应,读取不物化正文或清除 cache_dirty。 + pub(crate) fn view_model_count(&self) -> usize { + self.segments.len() + + usize::from( + self.text.len() > self.last_text_flush + || self.reasoning.len() > self.last_reasoning_flush, + ) + } + + pub(crate) fn starts_stream_block(&self, message_id: Option<&str>, reasoning: bool) -> bool { + let changed_message = self + .last_message_id + .as_deref() + .zip(message_id) + .is_some_and(|(old, new)| old != new); + changed_message + || if reasoning { + self.reasoning.len() == self.last_reasoning_flush + } else { + self.text.len() == self.last_text_flush + } + } + pub(crate) fn has_unprojected_changes(&self) -> bool { self.cache_dirty } /// Mark the turn as no longer active (e.g. on `"turn-interrupted"`). pub fn deactivate(&mut self) { + self.freeze_trailing(); self.active = false; self.invalidate_cache(); } @@ -221,20 +245,26 @@ impl CurrentTurn { /// [§6.7] 冻结 trailing 流式段(镜像顶层折叠 pass 的翻转点语义)。 /// - /// 顶层 turn 的冻结由 `apply_fold_pass` 在 phase 离开 PromptRunning 时对 - /// 快照 VM 完成;子 turn(SubAgentAccumulator)不经过快照 pass,`stop_subagent` - /// 必须在此把 `text_started_at`/`reasoning_started_at` 一次性换算为冻结 + /// 顶层在 deactivate/离开 PromptRunning 时冻结,子 turn 在 stop_subagent + /// 时冻结;两者都把稳定时长留在 canonical projection,避免历史覆盖变化 + /// 或下一 turn 重新按墙钟计算。此处把 `text_started_at`/`reasoning_started_at` 一次性换算为冻结 /// 时长并清除——此后 trailing bubble 以 Completed/Collapsed 形态构建, /// elapsed 不再增长(详情面板不再出现永久的 `◐ Thinking… Ns`)。 /// 无 trailing 内容时为 no-op(幂等:重复 stop 安全)。 pub(crate) fn freeze_trailing(&mut self) { - if self.text.len() > self.last_text_flush - || self.reasoning.len() > self.last_reasoning_flush + if self.text_started_at.is_none() && self.reasoning_started_at.is_none() { + return; + } + if (self.text.len() > self.last_text_flush + || self.reasoning.len() > self.last_reasoning_flush) + && (self.text_started_at.is_some() || self.reasoning_started_at.is_some()) { self.trailing_frozen = Some(( self.text_started_at.map(|t| t.elapsed().as_millis() as u64), - self.reasoning_started_at - .map(|t| t.elapsed().as_millis() as u64), + self.trailing_reasoning_frozen_ms.or_else(|| { + self.reasoning_started_at + .map(|t| t.elapsed().as_millis() as u64) + }), )); } self.text_started_at = None; diff --git a/peri-tui/src/kit/acp_types/current_turn/projection.rs b/peri-tui/src/kit/acp_types/current_turn/projection.rs index fab6d324a..944a15562 100644 --- a/peri-tui/src/kit/acp_types/current_turn/projection.rs +++ b/peri-tui/src/kit/acp_types/current_turn/projection.rs @@ -312,6 +312,18 @@ impl CurrentTurn { reasoning_dur, None, ); + // 正文先到时已有空 thinking 占位;终态保留 Completed 形态, + // 与此前 snapshot fold 的可见契约一致。 + let reasoning = reasoning.or_else(|| { + Some(TuiReasoningBlock { + text: String::new(), + fold: fold_for_status(FoldTarget::Reasoning, EntryStatus::Completed), + status: EntryStatus::Completed, + is_running: false, + started_at: None, + duration_ms: Some(0), + }) + }); let mut bubble = TuiAssistantBubble { text: text_slice.to_string(), reasoning, diff --git a/peri-tui/src/kit/message_area/mod.rs b/peri-tui/src/kit/message_area/mod.rs index 98fe952c4..567fd8ac2 100644 --- a/peri-tui/src/kit/message_area/mod.rs +++ b/peri-tui/src/kit/message_area/mod.rs @@ -536,6 +536,8 @@ pub fn MessageArea(props: &MessageAreaProps, mut hooks: Hooks) -> impl Into 0 && ctx.vis_height > 0 { let max_scroll = ctx .total_visual_rows @@ -120,7 +120,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { // 判定改为 follow_bottom:跟随态(用户没在浏览)resize 后跟随到底;浏览态不打扰。 // 旧版用 proximity 阈值(视口 1/4)判定,浏览态距底 ≤ 阈值时仍会被误拉。 let prev_vis = *ctx.prev_vis_height.read(); - *ctx.prev_vis_height.write() = ctx.vis_height; + *ctx.prev_vis_height.write_no_update() = ctx.vis_height; if prev_vis != ctx.vis_height && *ctx.follow_bottom.read() && ctx.total_visual_rows > 0 @@ -133,7 +133,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { "auto_follow: resize (vis_height changed) → follow bottom", ); ctx.scroll_state.write().scroll_to_bottom(); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; } // ── [Fix #1] Submit 强制滚底:用户主动发送 prompt 时 LOADING_EPOCH 递增 ── @@ -141,7 +141,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { // 先设 is_loading=true,再 call prompt() RPC)。此时 scroll_to_bottom 定位 // 到当前的底部位置即可——user bubble 到达后 proximity 自然跟随。 let prev_epoch = *ctx.prev_loading_epoch.read(); - *ctx.prev_loading_epoch.write() = ctx.loading_epoch; + *ctx.prev_loading_epoch.write_no_update() = ctx.loading_epoch; if ctx.loading_epoch != prev_epoch && ctx.total_visual_rows > 0 && ctx.vis_height > 0 { tracing::trace!( target: "msg_scroll_diag", @@ -150,7 +150,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { "auto_follow: submit detected (LOADING_EPOCH changed) → force scroll_to_bottom", ); ctx.scroll_state.write().scroll_to_bottom(); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; *ctx.follow_bottom.write() = true; // 不 return——继续走后续逻辑处理 user bubble / 流式增长 } @@ -160,7 +160,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { // 后续的 prev==0 分支(在所有 proximity guard 之前)强制每批 scroll_to_bottom, // 且不消费 prev==0(保持 trigger 活跃至 replay 结束)。 let prev_ctr = *ctx.prev_reset_counter.read(); - *ctx.prev_reset_counter.write() = ctx.bridge_reset_counter; + *ctx.prev_reset_counter.write_no_update() = ctx.bridge_reset_counter; if ctx.bridge_reset_counter != prev_ctr { tracing::trace!( target: "msg_scroll_diag", @@ -168,8 +168,8 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { new_ctr = ctx.bridge_reset_counter, "auto_follow: BRIDGE_RESET_COUNTER changed → arming prev==0 force-scroll", ); - *ctx.prev_items_len.write() = 0; - *ctx.last_scrolled_at.write() = 0; + *ctx.prev_items_len.write_no_update() = 0; + *ctx.last_scrolled_at.write_no_update() = 0; } // [TRAP] parking_lot 同 thread 死锁规避:先 read copy 出 owned,guard 在语句末尾 drop,再 write。 @@ -177,7 +177,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { // ── 零内容保护 ── if ctx.total_visual_rows == 0 || ctx.vis_height == 0 { - *ctx.prev_items_len.write() = ctx.items_len; + *ctx.prev_items_len.write_no_update() = ctx.items_len; tracing::trace!(target: "msg_scroll_diag", "auto_follow: early return (zero total or vis)"); return; } @@ -186,7 +186,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { // reset 后首个非空快照滚到底;立即记录 items_len,后续 replay 批次遵守 // follow_bottom。用户若在 replay 中上滚,后续增长不再抢回 viewport。 let force_bottom = { - let mut prev_items_len = ctx.prev_items_len.write(); + let mut prev_items_len = ctx.prev_items_len.write_no_update(); consume_reset_force_bottom(&mut prev_items_len, ctx.items_len) }; if force_bottom && !ctx.is_loading { @@ -196,7 +196,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { "auto_follow: consuming reset force-scroll sentinel → scroll_to_bottom", ); ctx.scroll_state.write().scroll_to_bottom(); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; *ctx.follow_bottom.write() = true; return; } @@ -230,7 +230,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { "auto_follow: interaction anchor → align viewport to block bottom", ); ctx.scroll_state.write().set_offset(target); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; } return; } @@ -253,7 +253,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { if ctx.total_visual_rows > prev_lsa { tracing::trace!(target: "msg_scroll_diag", "auto_follow: loading → scroll_to_bottom"); ctx.scroll_state.write().scroll_to_bottom(); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; } else { tracing::trace!(target: "msg_scroll_diag", total = ctx.total_visual_rows, prev_lsa, "auto_follow: loading → skip (total_rows not greater than prev_lsa)"); } @@ -263,7 +263,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { if ctx.items_len < prev { tracing::trace!(target: "msg_scroll_diag", items_len = ctx.items_len, prev, "auto_follow: shrink → scroll_to_bottom"); ctx.scroll_state.write().scroll_to_bottom(); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; *ctx.follow_bottom.write() = true; return; } @@ -271,7 +271,7 @@ pub(in crate::kit::message_area) fn run_auto_follow(ctx: &AutoFollowCtx) { if ctx.total_visual_rows > prev_lsa { tracing::trace!(target: "msg_scroll_diag", "auto_follow: non-loading growth → scroll_to_bottom"); ctx.scroll_state.write().scroll_to_bottom(); - *ctx.last_scrolled_at.write() = ctx.total_visual_rows; + *ctx.last_scrolled_at.write_no_update() = ctx.total_visual_rows; } else { tracing::trace!(target: "msg_scroll_diag", total = ctx.total_visual_rows, prev_lsa, "auto_follow: non-loading → skip (total_rows not greater than prev_lsa)"); } diff --git a/peri-tui/src/kit/publication_test.rs b/peri-tui/src/kit/publication_test.rs new file mode 100644 index 000000000..bd607b52d --- /dev/null +++ b/peri-tui/src/kit/publication_test.rs @@ -0,0 +1,534 @@ +//! 真实 handler → intent → scheduler 回归,不使用会强制补发的 dispatch_and_notify。 +use super::*; +use crate::kit::stream_data::{TuiReasoningChunk, TuiTextChunk}; +use crate::kit::tui_render_unit::{FoldKey, FoldState, TuiRenderUnit}; +use serial_test::serial; + +struct ModeGuard { + mode: Option, + view: atoms::ViewModelsSnapshot, + acp: atoms::AcpStateSnapshot, + folds: std::collections::HashMap, +} +impl ModeGuard { + fn new(mode: &str) -> Self { + atoms::init_atoms(); + let view = atoms::VIEW_MODELS.state().read().clone(); + let acp = atoms::ACP_STATE.state().read().clone(); + let folds = atoms::FOLD_OVERRIDES.state().read().clone(); + let handle = atoms::TUI_CONFIG_HANDLE.get_or_init(|| { + std::sync::Arc::new(parking_lot::RwLock::new(crate::config::TuiConfig::default())) + }); + let old = handle.write().streaming_mode.replace(mode.into()); + Self { + mode: old, + view, + acp, + folds, + } + } + fn set(mode: &str) { + atoms::TUI_CONFIG_HANDLE + .get() + .unwrap() + .write() + .streaming_mode = Some(mode.into()); + } +} +impl Drop for ModeGuard { + fn drop(&mut self) { + atoms::TUI_CONFIG_HANDLE + .get() + .unwrap() + .write() + .streaming_mode = self.mode.take(); + atoms::VIEW_MODELS.set(self.view.clone()); + atoms::ACP_STATE.set(self.acp.clone()); + atoms::FOLD_OVERRIDES.set(self.folds.clone()); + } +} + +fn state() -> BridgeState { + let state = synthetic_scheduler_state(); + atoms::FOLD_OVERRIDES.state().write().clear(); + atoms::VIEW_MODELS.set(Default::default()); + state +} + +fn chunk(text: &str, agent: Option<&str>, reasoning: bool) -> AcpEventData { + if reasoning { + AcpEventData::ReasoningChunk(TuiReasoningChunk { + text: text.into(), + message_id: Some("message".into()), + agent_id: agent.map(str::to_owned), + }) + } else { + AcpEventData::TextChunk(TuiTextChunk { + text: text.into(), + message_id: Some("message".into()), + agent_id: agent.map(str::to_owned), + }) + } +} + +fn deliver( + state: &mut BridgeState, + scheduler: &mut PublicationScheduler, + event: AcpEventData, + now: tokio::time::Instant, +) { + let intent = acp_events::dispatch_for_bridge(state, &event); + scheduler.accept_at(intent, state, now); +} + +/// 首块立即可见,后续只在 fixed deadline 物化;明确读取不能吞掉发布事实。 +#[test] +#[serial] +fn test_publication_main_stream_survives_projection_read() { + let _mode = ModeGuard::new("streaming"); + for reasoning in [false, true] { + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + reset_perf_counters(); + for _ in 0..100 { + deliver( + &mut state, + &mut scheduler, + chunk("字", None, reasoning), + now, + ); + } + assert_eq!(state.generation, 1, "首块之外不得逐 chunk 发布"); + assert_eq!(perf_counters().projections, 1, "view_count 不应提前物化"); + assert_eq!( + state.current_turn.view_model_count(), + state.current_turn.view_models().len() + ); + assert!(!state.current_turn.has_unprojected_changes()); + assert!(!scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL / 2)); + assert!(scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL)); + assert_eq!(state.generation, 2); + let snapshot = atoms::VIEW_MODELS.state().read().clone(); + let TuiRenderUnit::TuiAssistantBubble(b) = &snapshot.items[0] else { + panic!("应为主回复") + }; + assert_eq!( + if reasoning { + &b.reasoning.as_ref().unwrap().text + } else { + &b.text + }, + &"字".repeat(100) + ); + } +} + +/// 子流共享调度窗口;其首块按自己的 occurrence/segment 判定。 +#[test] +#[serial] +fn test_publication_subagent_stream_and_block_coalesce() { + for mode in ["streaming", "block"] { + let _mode = ModeGuard::new(mode); + for reasoning in [false, true] { + let mut state = state(); + state + .current_turn + .start_subagent("child".into(), "child".into()); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + reset_perf_counters(); + for _ in 0..100 { + deliver( + &mut state, + &mut scheduler, + chunk("x", Some("child"), reasoning), + now, + ); + } + assert_eq!(state.generation, 1); + let first_projection_count = perf_counters().projections; + assert_eq!(first_projection_count, 2, "首块仅投影主/子 turn 各一次"); + assert!(scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL)); + assert_eq!(state.generation, 2); + let snapshot = atoms::VIEW_MODELS.state().read().clone(); + let TuiRenderUnit::TuiSubAgentGroup(g) = &snapshot.items[0] else { + panic!("应为子组") + }; + let TuiRenderUnit::TuiAssistantBubble(b) = &g.view_models[0] else { + panic!("应为子回复") + }; + assert_eq!( + if reasoning { + &b.reasoning.as_ref().unwrap().text + } else { + &b.text + }, + &"x".repeat(100) + ); + } + } +} + +#[test] +#[serial] +fn test_publication_none_switch_cancels_stream_deadline_but_terminal_flushes() { + let _mode = ModeGuard::new("streaming"); + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + deliver(&mut state, &mut scheduler, chunk("a", None, false), now); + deliver(&mut state, &mut scheduler, chunk("b", None, false), now); + ModeGuard::set("none"); + assert!(!scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL)); + deliver(&mut state, &mut scheduler, chunk("c", None, false), now); + assert_eq!(state.generation, 1); + deliver(&mut state, &mut scheduler, AcpEventData::TurnDone, now); + assert_eq!( + state.generation, 2, + "终态只发布一次,不能被 scheduler 重复发布" + ); + assert!(!scheduler.unpublished); + assert!(!scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL * 2)); + let snapshot = atoms::VIEW_MODELS.state().read().clone(); + let TuiRenderUnit::TuiAssistantBubble(b) = &snapshot.items[0] else { + panic!("应有终态回复") + }; + assert_eq!(b.text, "abc"); + assert!(!atoms::ACP_STATE.state().read().clone().is_loading); +} + +#[test] +#[serial] +fn test_publication_none_resume_and_replay_are_not_lost() { + let _mode = ModeGuard::new("none"); + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + deliver(&mut state, &mut scheduler, chunk("a", None, false), now); + assert_eq!(state.generation, 0); + assert_eq!(state.current_turn.view_models().len(), 1); + ModeGuard::set("streaming"); + deliver(&mut state, &mut scheduler, chunk("b", None, false), now); + assert_eq!(state.generation, 1, "恢复可见模式时显示已有积累"); + deliver( + &mut state, + &mut scheduler, + AcpEventData::CommittedAssistantText { + text: "history".into(), + reasoning: None, + }, + now, + ); + ModeGuard::set("none"); + assert!( + scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL), + "历史回放不因 None 丢失" + ); + assert_eq!(state.generation, 2); +} + +#[test] +#[serial] +fn test_publication_receiver_close_flushes_even_after_explicit_projection() { + let _mode = ModeGuard::new("none"); + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + deliver( + &mut state, + &mut scheduler, + chunk("pending", None, false), + tokio::time::Instant::now(), + ); + state.current_turn.view_models(); + let mut reset = atoms::BRIDGE_RESET_COUNTER.get(); + flush_on_receiver_close(&mut state, &mut scheduler, &mut reset); + assert_eq!(state.generation, 1); + assert!(!scheduler.unpublished); +} + +#[test] +#[serial] +fn test_publication_loading_reset_flushes_and_cancels_deadline() { + let _mode = ModeGuard::new("streaming"); + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + deliver(&mut state, &mut scheduler, chunk("a", None, true), now); + deliver(&mut state, &mut scheduler, chunk("b", None, true), now); + deliver( + &mut state, + &mut scheduler, + AcpEventData::LocalLoadingReset, + now, + ); + assert_eq!(state.generation, 2); + assert!(!scheduler.unpublished); + assert!(!scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL)); + assert!(!atoms::ACP_STATE.state().read().is_loading); + let snapshot = atoms::VIEW_MODELS.state().read().clone(); + let TuiRenderUnit::TuiAssistantBubble(b) = &snapshot.items[0] else { + panic!("应保留取消前已接收的推理") + }; + let reasoning = b.reasoning.as_ref().unwrap(); + assert_eq!(reasoning.text, "ab"); + assert!(!reasoning.is_running); + assert!(reasoning.started_at.is_none()); + deliver( + &mut state, + &mut scheduler, + AcpEventData::LocalLoadingReset, + now, + ); + assert_eq!(state.generation, 2, "重复复位无副作用"); +} + +#[test] +#[serial] +fn test_publication_block_and_empty_chunks_respect_boundaries() { + let _mode = ModeGuard::new("block"); + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + deliver(&mut state, &mut scheduler, chunk("", None, false), now); + assert_eq!(state.generation, 0); + deliver(&mut state, &mut scheduler, chunk("first", None, false), now); + deliver( + &mut state, + &mut scheduler, + chunk(" second", None, false), + now, + ); + assert_eq!(state.generation, 1); + assert!(!scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL)); + deliver( + &mut state, + &mut scheduler, + chunk("\n\nnext", None, false), + now, + ); + assert_eq!(state.generation, 2); + assert_eq!(state.current_turn.text, "first second\n\nnext"); + deliver( + &mut state, + &mut scheduler, + AcpEventData::TextChunk(TuiTextChunk { + text: "new message".into(), + message_id: Some("next-message".into()), + agent_id: None, + }), + now, + ); + assert_eq!(state.generation, 2, "新 message 不绕过主 Block 的边界规则"); + assert!(!scheduler.fire_at(&mut state, now + PUBLICATION_INTERVAL)); +} + +#[test] +#[serial] +fn test_publication_two_children_resume_and_bg_without_group() { + let _mode = ModeGuard::new("streaming"); + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + for id in ["a", "b"] { + state.current_turn.start_subagent(id.into(), id.into()); + } + for id in ["a", "b"] { + deliver( + &mut state, + &mut scheduler, + chunk("first", Some(id), false), + now, + ); + deliver( + &mut state, + &mut scheduler, + chunk(" tail", Some(id), false), + now, + ); + } + assert_eq!(state.generation, 2); + state.current_turn.stop_subagent("a", false, ""); + state + .current_turn + .start_subagent("a".into(), "resumed".into()); + deliver( + &mut state, + &mut scheduler, + chunk("resumed", Some("a"), false), + now, + ); + assert_eq!(state.generation, 3); + assert_eq!(state.current_turn.subagents.len(), 3); + atoms::BG_AGENT_IDS + .state() + .write() + .insert("missing-bg".into()); + deliver( + &mut state, + &mut scheduler, + chunk("background", Some("missing-bg"), false), + now, + ); + atoms::BG_AGENT_IDS.state().write().remove("missing-bg"); + assert_eq!(state.generation, 3); + assert!(state.current_turn.text.is_empty()); +} + +fn replay_card(state: &mut BridgeState, i: usize, ended: bool) { + acp_events::dispatch_for_bridge( + state, + &AcpEventData::ReplayToolStarted { + tool_id: format!("tool-{i}"), + tool_name: "Bash".into(), + input_summary: "synthetic".into(), + raw_input: serde_json::Value::Null, + }, + ); + if ended { + acp_events::dispatch_for_bridge( + state, + &AcpEventData::ReplayToolEnded { + tool_id: format!("tool-{i}"), + output_summary: "x".repeat(4096), + is_error: false, + }, + ); + } +} + +#[test] +#[serial] +fn test_publication_stable_replay_history_has_zero_fold_and_hash_work() { + let _mode = ModeGuard::new("streaming"); + for n in [100, 1000] { + let mut state = state(); + for i in 0..n { + replay_card(&mut state, i, true); + } + // 用户覆盖也必须复用,而不能只优化默认 fold。 + atoms::FOLD_OVERRIDES + .state() + .write() + .insert(FoldKey::Tool("tool-0".into()), FoldState::Expanded); + acp_events::push_view_models(&mut state); + reset_perf_counters(); + for _ in 0..20 { + acp_events::push_view_models(&mut state); + } + let counters = perf_counters(); + assert_eq!( + ( + counters.fold_pass_writes, + counters.history_fold_visits, + counters.tool_hash_calls, + counters.tool_hash_bytes + ), + (0, 0, 0, 0) + ); + for item in &state.committed { + let TuiRenderUnit::TuiToolCard(card) = item else { + panic!("应为工具") + }; + assert_eq!(card.fold, FoldState::Collapsed); + assert!(!card.user_modified, "用户覆盖不能污染 canonical"); + } + atoms::FOLD_OVERRIDES.state().write().clear(); + acp_events::push_view_models(&mut state); + let cached = atoms::VIEW_MODELS.state().read().clone().items; + state.folded_history = Default::default(); + acp_events::push_view_models(&mut state); + assert_eq!( + cached, + atoms::VIEW_MODELS.state().read().clone().items, + "恢复默认后与冷投影逐字段等价" + ); + } +} + +#[test] +#[serial] +fn test_publication_history_cache_invalidates_same_length_update_and_replacement() { + let _mode = ModeGuard::new("streaming"); + let mut state = state(); + for i in 0..100 { + replay_card(&mut state, i, false); + } + acp_events::push_view_models(&mut state); + acp_events::dispatch_for_bridge( + &mut state, + &AcpEventData::ReplayToolEnded { + tool_id: "tool-0".into(), + output_summary: "failed".into(), + is_error: true, + }, + ); + acp_events::push_view_models(&mut state); + let cached = atoms::VIEW_MODELS.state().read().clone().items; + let TuiRenderUnit::TuiToolCard(card) = &cached[0] else { + panic!("应为失败工具") + }; + assert_eq!(card.output_summary, "failed"); + assert_eq!(card.fold, FoldState::Collapsed); + state.folded_history = Default::default(); + acp_events::push_view_models(&mut state); + assert_eq!(cached, atoms::VIEW_MODELS.state().read().clone().items); + state.committed.clear(); + replay_card(&mut state, 0, true); + acp_events::push_view_models(&mut state); + assert_eq!( + atoms::VIEW_MODELS.state().read().clone().items.len(), + 1, + "缩短并复用 ID 不能复用旧历史" + ); +} + +#[test] +#[serial] +fn test_publication_archived_duration_is_stable_across_phase_and_override() { + let _mode = ModeGuard::new("streaming"); + for end in [ + AcpEventData::TurnDone, + AcpEventData::TurnSuspended, + AcpEventData::TurnInterrupted { + reason: "cancel".into(), + request_id: None, + }, + ] { + let mut state = state(); + let mut scheduler = PublicationScheduler::default(); + let now = tokio::time::Instant::now(); + deliver( + &mut state, + &mut scheduler, + chunk("thinking", None, true), + now, + ); + deliver( + &mut state, + &mut scheduler, + chunk("answer", None, false), + now, + ); + deliver(&mut state, &mut scheduler, end, now); + let TuiRenderUnit::TuiAssistantBubble(old) = state.committed[0].clone() else { + panic!("应归档回复") + }; + assert!(old.started_at.is_none()); + assert!(old.reasoning.as_ref().unwrap().started_at.is_none()); + assert!(!old.reasoning.as_ref().unwrap().is_running); + state.phase = SessionPhase::PromptRunning; + atoms::FOLD_OVERRIDES + .state() + .write() + .insert(FoldKey::Reasoning("message".into()), FoldState::Expanded); + acp_events::push_view_models(&mut state); + atoms::FOLD_OVERRIDES.state().write().clear(); + state.folded_history = Default::default(); + acp_events::push_view_models(&mut state); + assert_eq!( + atoms::VIEW_MODELS.state().read().clone().items[0], + TuiRenderUnit::TuiAssistantBubble(old) + ); + } +} diff --git a/peri-tui/src/kit/tui_render_unit/tool_card.rs b/peri-tui/src/kit/tui_render_unit/tool_card.rs index 672b6cd7c..9b4149ced 100644 --- a/peri-tui/src/kit/tui_render_unit/tool_card.rs +++ b/peri-tui/src/kit/tui_render_unit/tool_card.rs @@ -99,7 +99,7 @@ impl TuiToolCard { pub fn recompute_hash(&mut self) { let duration_secs = self.running_duration_ms.map(|ms| ms / 1000); let completed_secs = self.completed_duration_ms.map(|ms| ms / 1000); - let mut h = tui_hash_str(&format!( + let hash_input = format!( "{}|{}|{}|{}|{}|{}|{:?}|{:?}|{:?}|{:?}|{}", self.tool_id, self.tool_name, @@ -112,7 +112,14 @@ impl TuiToolCard { self.presentation, self.fold, self.user_modified, - )); + ); + #[cfg(test)] + { + use crate::kit::acp_bridge::{PerfCounter, observe_perf}; + observe_perf(PerfCounter::ToolHashCalls, 1); + observe_perf(PerfCounter::ToolHashBytes, hash_input.len() as u64); + } + let mut h = tui_hash_str(&hash_input); // [G-Diff] diff 定型于 tool-ended,此后不变——稳定摘要纳入 hash 保证 // diff 变更(含路径/计数/截断)触发按 hash 分片的渲染缓存重建。 h = tui_hash_combine(h, self.diff_code()); diff --git a/spec/issues/2026-09-27-p0-tui-streaming-view-rebuild-cpu.md b/spec/issues/2026-09-27-p0-tui-streaming-view-rebuild-cpu.md new file mode 100644 index 000000000..fddae145a --- /dev/null +++ b/spec/issues/2026-09-27-p0-tui-streaming-view-rebuild-cpu.md @@ -0,0 +1,223 @@ +# P0:TUI 流式发布重复处理历史工具卡,且发布调度与投影状态混用 + +**状态**:已修复,待现场性能验收;测试与对抗审查见末节。 +**优先级**:P0(用户指定)。 +**创建 / 最后核查**:2026-09-27(Asia/Shanghai)。 +**范围**:按后续授权完成 S1–S4 修复、回归测试与提交;保留事故时证据,不把测试计数等同于现场 CPU 收益。 +**来源**:本机两个 debug TUI 在活跃 subagent 流式输出期间 CPU 暴涨。 + +## 结论与证据边界 + +以下为修复前的事故机制;源码、合成诊断和现场采样共同支持: + +```text +历史回放:Running/Preview → 工具完成,但 committed 仍保留 Preview + → 每次发布重新从 committed 组装,重新折叠为 Collapsed + → 重复 clone / format / 全文 hash / 共享节点 COW +subagent 每个 chunk 在 handler 内直接发布 + → 放大上述历史处理成本 +消息区每次构建仍扫描全部 slots;generation 驱动的 effect 可再次唤醒渲染 + → 增加主线程负担 +``` + +还发现一条独立的正确性缺陷:主 Agent handler 的 `push_acp_state()` 会提前物化 ViewModel 并清掉 scheduler 使用的 dirty 标记,使主 Agent chunk 返回 `PublicationIntent::None`。所以不能再宣称「主 Agent 流式已正常受到 50 ms 合帧保护」;也不能仅删除 subagent 直推就认定修复完成。 + +| 判断 | 核查结果 | +| --- | --- | +| 回放完成的工具卡持续产生折叠失配 | 已证实:无用户覆盖时,回放开始设 Preview,结束保留 Preview,而终态策略要求 Collapsed | +| 失配使稳定历史反复 clone/hash/COW | 已证实:pass 只修改临时快照;64 张回放卡重复发布 20 次产生 1,280 次折叠写入 | +| subagent Streaming/Block chunk 逐条立即发布 | 已证实,且属于修复前设计许可,不是当时代码偏离设计 | +| 主 Agent 50 ms 调度在真实 handler 链路正常工作 | 被诊断反例推翻:文本与推理各 100 个 chunk,分别 100 次投影、0 次发布 | +| 现场所有失配都来自 replay、共有约 800 张卡 | 未证实:采样不能反推出卡片数与来源占比;用户覆盖也能造成重复变换 | +| 修复后总 CPU / 每次 publication 已与历史长度脱钩 | 未验证,也不是仅修复 fold + cadence 就能保证的性质 | + +修复已同步 [流式 Markdown 性能设计](../../docs/design/tui-streaming-markdown-performance.md):子流改为首块立即、后续合帧;主 Agent 发布状态与 projection dirty 分离。以下根因章节描述修复前实现。 + +## 现场数据(保留原观测口径) + +### 进程与 CPU + +两个进程均由本仓库 `./dev.sh` → `cargo run -p peri-tui` 启动,二进制为 `target/debug/peri`。事件与日志持续增长,不能称为空闲自旋。 + +| 项目 | PID 21485 | PID 68130 | +| --- | --- | --- | +| 原观测时运行时长 | 1:45:26 | 21:35 | +| `ps` 瞬时 %CPU | 172.9 | 155.2 | +| 10 秒 CPU 时间差分 | +15.98 s,约 160% | +15.16 s,约 152% | +| 线程分布 | 主线程约 50% + 一条 worker 约 98% | 主线程约 48% + 一条 worker 约 98% | + +共享日志 `.tmp/agent-tui.2026-09-27` 原窗口增长约 341 KB/s。原日志尾部事件带 subagent source ID;两份 sample 落在 `handle_reasoning_chunk` 直接调用 `push_view_models` 的路径,与 subagent 分支吻合。共享日志不能独立给出每个 PID 的完整事件归属或发布次数。 + +### 采样 + +原 PID 21485:3 秒窗口、热点线程 1557 个样本。下表是同一调用分支的包含计数,不是可相加的互斥 CPU 百分比,也不是整进程 CPU 占比。 + +| 调用分支 | 样本 | 相对该线程样本 | +| --- | --- | --- | +| `handle_reasoning_chunk` | 1549 | 99% | +| `push_view_models` 中主热点分支 | 1251 | 80% | +| `apply_fold_pass` 中主热点分支 | 995 | 64% | +| `TuiToolCard::recompute_hash` 中主热点分支 | 796 | 51% | +| `tui_hash_str` 中主热点分支 | 790 | 51% | + +原 PID 68130 的 2 秒 sample 也命中同链路。2026-09-27 15:40:04 +0800 再对 PID 68130 采样 3 秒,仍出现同一路径(对应主分支计数 1894 / 1696 / 1326 / 1061 / 1055)。这些计数用于确认热点仍存在,不用于比较不同窗口的性能收益。 + +本机原始文件:`/tmp/p21485.sample.txt`、`/tmp/p68130.sample.txt`、`/tmp/peri-cpu-review-68130.sample.txt`。这些是临时证据,非仓库 fixture;文件失效后需重新采样。 + +### 消息区构建次数与成本 + +原 120 KB 日志窗口覆盖 0.338 秒,包含 112 次 `frame-total`,即两进程合计约 330 次 **MessageArea 构建/秒**;该埋点不覆盖完整终端帧,不等于 publication 或屏幕实际刷新次数。 + +| 埋点 | 原样本耗时 / 元数据 | +| --- | --- | +| `hash+detect` | 567 µs;items=2555,rebuilds=0 | +| `concat` | 604 µs;slots=2555,logic_lines=3282,vis_rows=3282 | +| `viewport` | 372 µs;vp_lines=11 | +| `frame-total` | 1418–1596 µs;gen 示例为 111435 | + +`rebuilds=0` 只说明该次消息区 slot 缓存无需重建,不说明整个界面或所有构建都没有视觉变化。后续 07:42:42–07:43:11 UTC 的约 2 MB 日志尾部中,4274 次 `hash+detect` 有 2197 次 rebuilds=0、2077 次 rebuilds=1,不能用单条零重建样本推断全部窗口。 + +`push_acp_state()` 仅在 snapshot 不等时赋值;当前锁定的 ratatui-kit 0.10.3 写 guard 仅发生可变解引用时通知,故并非每个 chunk 都通过 ACP_STATE 再唤醒一帧。`run_auto_follow` 依赖 generation,且无条件写多个响应式哨兵,是另一条可重复唤醒路径;心跳、动画和交互也会触发构建。不能用「330 ÷ 40」证明未经合帧的发布量。实际 publication cadence 须独立计数。 + +## 根因 + +### P0-1:回放完成态未归一化,派生折叠结果又不复用 + +事实源:`peri-tui/src/kit/acp_events/tool.rs::{handle_replay_tool_started,update_committed_tool_card}`、`render.rs::push_view_models`、`fold.rs::apply_fold_pass`(修复前位于 render.rs)、`tui_render_unit/{fold,tool_card}.rs`。 + +1. 回放开始将卡片存入 `committed`,fold 为 `fold_for_status(Tool, Running) = Preview`。 +2. 回放结束写入输出并令 `is_running=false`,但保留 `fold: card.fold`。无覆盖时新的目标为 `fold_for_status(Tool, Completed|Error) = Collapsed`。 +3. `push_view_models` 从 `committed.clone()` 组装快照;clone 本身是 im::Vector 结构共享,不能描述为全历史正文深拷贝。pass 对失配项 clone、重算 hash,再向临时 vector set;set 可触发共享 chunk 的 COW,连带复制邻近条目。 +4. pass 没有回写 `committed` 或保留可复用的历史折叠投影,所以下次又对同一失配重做。 +5. hash 把输入、输出、presentation 等字段格式化后全文计算;成本不仅取决于卡片数,也取决于失配卡片携带的内容体积。 + +正常实时工具完成走 `build_tool_card`,按当前状态取 fold;此前 `perf_probe_test::build_state` 用实时工具生命周期构造历史,未覆盖 replay 的 Preview 残留。用户覆盖是另一条需覆盖的失配来源;不能把 replay 解释成唯一来源。 + +### P0-2:subagent 直接发布发生在 scheduler 之前 + +事实源:`acp_events/streaming.rs::{handle_text_chunk,handle_reasoning_chunk}`、`acp_events/mod.rs::dispatch_for_bridge`、`acp_bridge.rs::PublicationScheduler`。 + +已路由到 subagent 组的 Streaming/Block chunk 直接调用 `push_view_models`,随后 generation 变化让 dispatcher 返回 Immediate。**昂贵工作已经发生**,scheduler 无法事后合帧;generation 分支是「已经发布」的结果,不是直推的起因。None 模式不走这次直推;BG 组不存在的路由也不同,不能推广为所有带 agent_id 的事件都发布。 + +现行设计许可这一行为,问题是事件率与历史处理成本相乘。50 ms fixed deadline 机制确实存在,但不是所有 publication 的严格 20/s 全局上限:首块、边界、终态等 Immediate 均是例外,1 秒工具时长刷新也有直推入口。 + +### P0-3:缓存投影 dirty 被误作待发布状态 + +事实源:`render.rs::push_acp_state`、`acp_types/current_turn/projection.rs::{view_models,sync_cache}`、`current_turn.rs::has_unprojected_changes`。 + +```text +主 Agent append → cache_dirty=true + → handler 尾部 push_acp_state + → 为 view_count 调用 current_turn.view_models() + → sync_cache,cache_dirty=false(仅物化缓存,并未写 VIEW_MODELS) + → dispatch_for_bridge 检查 has_unprojected_changes=false + → 返回 None,scheduler 没有 deadline,也没有首 chunk publication +``` + +这是投影与发布生命周期混用。即便去掉这次隐式读取,也必须防止明确读取、诊断或未来消费者再次消掉待发布事实。该问题不能解释现场 subagent 的逐 chunk CPU 热点,但会使「仅删除 subagent 直推」的方案丢失可见更新,因此纳入同一修复链。 + +### 放大项:全 slots 扫描与重复 effect 通知 + +`message_area/mod.rs` 每次构建收集 hash/动画状态、检查 slots 并组装 prefix index,仍有 O(N) 元数据工作。`message_area/scroll/auto_follow.rs::run_auto_follow` 对多个哨兵无条件赋值,可在 publication 引起的构建后再次通知。修正 cadence 能减少触发次数,但不自动消除单次扫描或历史驻留;有界历史窗口仍是独立任务。 + +## 已执行的确定性诊断 + +本轮之前已在现有 bridge 测试模块临时挂载三个诊断用例,使用真实 replay handler、dispatcher 与 scheduler。命令 `cargo test -p peri-tui --lib cpu_review_ -- --nocapture --test-threads=1`:exit 0,3 passed / 0 failed。测试断言的是**当前缺陷行为**,不是修复后的通过证据。 + +| 用例 | 输入与控制 | 观察 | +| --- | --- | --- | +| `cpu_review_replay_fold_repeated_writes` | 64 张回放完成卡,各 1 KiB 合成输出;预热后 push 20 次 | FoldPassWrites=1280;committed 卡片仍 Preview。只把合成输入 fold 校正后,同样 20 次 push 为 0 | +| `cpu_review_subagent_bypasses_scheduler` | 已存在 child 组;固定 scheduler 时间连续 100 个 reasoning chunk | 100 次 Intermediate publication、100 次 generation 增长、无 pending deadline | +| `cpu_review_main_chunk_projection_consumes_dirty` | 空 live turn;text/reasoning 分别 100 个 chunk;默认 Streaming | 每组 100 次 projection、0 次 publication,intent 全为 None;推进 deadline 也无发布 | + +测试入口已删除、源文件恢复;诊断源暂存 `/tmp/peri-cpu-review-tests.rs`。该命令在入口删除后不会再命中测试,实施时应把场景转成正式回归并确认实际执行数量。它们证明机制,不证明现场失配规模、release 收益、BG/Block/None 全矩阵或真实终端体验。 + +`PerfCounters`、`PerfCounter`、`observe_perf` 均受 `#[cfg(test)]` 控制,不能在已有 debug/release 进程中直接开启。现场要测 publication/失配次数须另加受控诊断构建或埋点;需按 PID/session 分开,仅记计数、类型、长度,不记录用户内容。 + +## 解决方案与实际落地 + +采用「发布状态独立 + 主/子流统一调度 + 历史折叠投影复用」。以下保留方案要求,具体落地取舍见实施记录。方案目标是解除当前重复大内容工作并控制中间发布频率,不宣称本轮让全部 TUI 工作变成 O(增量)。 + +### S1:分离投影状态与发布状态,明确发布边界 + +- `CurrentTurn.cache_dirty` 只管 canonical → owned VM 缓存。bridge 单独持有待发布 revision 与已发布 revision,或等价的显式状态;**普通投影读取不得推进已发布状态**。 +- 发布失效覆盖 current_turn、committed 同长度替换、phase、折叠覆盖及其他影响 VIEW_MODELS 的输入;不能只以字符串长度或 current_turn dirty 判定。reset/session 使用独立 epoch,避免旧 pending 与新 revision 混用。 +- handler 负责 canonical mutation 和显式 publication intent;bridge 的统一发布函数负责取投影、派生快照、写 VIEW_MODELS 并在写入成功后标记 revision 已发布。去掉依赖 generation 差值推断意图的协议,generation 仅标识成功发布的快照。 +- handler 的终态与交互保留同步 `publish_barrier`,显式 `Published` 通知 scheduler 结算,保证发布先于 drain/replay 副作用;1 秒工具时长刷新与 receiver close 纳入 scheduler。同步 session/reset 与折叠 UI 原有 owner/顺序路径保留,不迁移为异步请求。Immediate 必须发布 committed-only 变化。 +- `push_acp_state` 只消费已取得的 projection 元数据或低成本维护的条目数,不为 view_count 隐式物化全文;publication 时获取一次 projection 并复用。计数必须与真实 VM 条目数一致,不能把 canonical segment 数直接当作 view_count。 + +### S2:主 Agent 与 subagent 的中间 publication 共用 50 ms fixed deadline + +**已改变此前 subagent 策略**:Streaming 下主/子流后续 chunk 使用同一个 bridge-local pending deadline。subagent Block 保留「不检测子流 Markdown 边界」的现有语义,但中间更新也受 50 ms 合帧;主 Agent Block 保留 Markdown 边界发布;None 不因 chunk 产生中间 publication。实施时同步更新现行性能设计与 code-index 的对应条款,不静默改变契约。 + +- 所有 chunk 仍立即按顺序接收入 canonical,不丢字、不以节流限制接收。后续 chunk 不推迟既有 deadline;没有新 chunk 时 deadline 也必须 flush。 +- 首个可见 text/reasoning 块、tool/SubAgent/message 边界、交互请求、主/子终态与中断为明确的 Immediate barrier。首块身份按 stream occurrence/message 区分,不能用主 Agent 文本是否为空判断每个 subagent chunk;同 agent_id 恢复运行须区分 occurrence。 +- 一次 barrier 发布最新合法快照,包含此前可发布的待定更新并结算 pending;子任务结束不能被误当成整个 session terminal。取消、主 terminal、reset、session 切换、receiver close 与 shutdown 遵守各自既有 archive/所有权规则,旧 deadline 不得重写终态或新 session;shutdown 不写 UI。 +- 频率验收只约束普通 Deferred publication。固定存续的 streams、无新 barrier 时,任意长度 T 的窗口中 Deferred 数不超过 `ceil(T/50ms)+1`;Immediate 按原因单独计数。大量新 stream/barrier 可以突破总 20/s,因此不能以总发布量断言严格 20/s。 +- 不选择「保留逐 chunk 直推,只优化 hash」:它仍让 atom 通知、布局扫描和 effect 随事件率无上限增长;不选择「仅删除直推」:S1 的发布状态缺陷必须同时解决。 + +### S3:消除 replay 失配,并复用稳定历史折叠投影 + +- replay 完成时通过唯一策略 `fold_for_status` 生成正确的基础终态 fold;默认 fold 不从临时 UI 覆盖反向写回。复用现有业务规则,不再复制 Completed/Error 决策表。 +- 在 BridgeState 所有的派生缓存中复用**已折叠的 committed 部分**;canonical committed 与纯视觉 fold override 分开。按 committed 结构身份、phase 和 fold-overrides 内容失效;折叠函数依赖变化时同步键,禁止每个 chunk 全文 hash 历史来判断缓存是否有效。 +- committed 未变、折叠输入未变时直接结构共享已折叠历史,不重新遍历/克隆/哈希历史卡片。current_turn 仍按当前状态处理;历史真实变更或覆盖变更可低频重建历史投影,追加优化可后续做,不要求第一步引入任意位置增量更新框架。 +- reset、session 切换、rewind/缩短、同长度 replay 工具更新、归档、override 添加/移除均须正确失效;缓存只保留本 bridge 的必要版本,不能按 revision 无限累积旧快照。分组和 todo 顺序沿用原流水线,禁止直接缓存最终 grouped 快照后拼尾巴破坏跨边界分组。 +- 不直接把整个 fold pass 回写 committed:会把 UI override、reasoning 状态/时长冻结混入基础状态,且无法自然覆盖 current_turn 缓存;不采用只有卡片 epoch 而仍全历史扫描的方案来宣称 O(1)。 + +### S4:减少无变化的 effect 通知,量化剩余成本 + +`run_auto_follow` 的哨兵值仅变化时通知;纯 effect 内部记账在不依赖重新渲染时可无通知写入,真实 scroll offset、anchor/follow 改变仍须触发渲染。覆盖流式吸底、用户上滚暂停跟随、resize、交互锚点及 session/reset。 + +本期保留分组差异检测、todo 定位、消息区 hash 扫描和 slot prefix index 的必要全量工作,不承诺 `push_view_models` 总成本与 N 无关。S3 的「稳定历史折叠无扫描/无正文工作」与这些 O(N) 元数据阶段分开测量;后续全局增量布局或有界历史另立契约。 + +## 验收与实施顺序 + +正式回归已落在 `peri-tui/src/kit/publication_test.rs`;下表保留验收目标,已执行范围与未验证项见末节。正式测试不得仅直接调用 `append_text + scheduler.accept`,也不得用会补发快照的 `dispatch_and_notify` 替代生产 dispatch 链路。 + +| 范围 | 必须证明的行为 | +| --- | --- | +| 主 Agent 发布 | 默认 Streaming 的首 text/reasoning 可见,后续 chunk 在固定 deadline 发布;提前调用 projection / push_acp_state 不吞更新;无后续事件也 flush | +| 多流与模式 | 主/子混流、多个 child、text/reasoning、Streaming/Block/None、BG 有组/无组、同 ID resume;chunk 内容完整且顺序不变,None 不因 chunk 意图泄漏中间发布 | +| 时间与 barrier | 可控时钟;首块/边界 Immediate 与 Deferred 分别计数;持续到达不延期、停止到达不挂起;barrier 后旧 deadline 不重复发布 | +| 生命周期 | committed-only 同长度替换、replay 完成、归档、主/子终态、取消、交互、receiver close/reset 竞争、session 切换、shutdown;无旧会话污染和 loading 残留 | +| 折叠重复成本 | replay 与实时两类历史,成功/失败、短/长输出、手动 override 与恢复默认;预热后稳定历史 FoldPassWrites=0、历史工具 hash 调用=0、历史折叠访问数=0;真实变化仍立即生效 | +| 缓存失效与等价 | overwrite/rewind/长度不变更新、phase 与 override 添加/移除、跨 session 相同 ID;在相同输入/受控时间点比较未分组 fold 与最终 grouped 快照的完整字段,忽略发布次数导致的 generation 差异 | +| 渲染 | 相同 generation/几何/滚动状态不因记账哨兵重复通知;真实滚动和视觉变化仍渲染,现有选择/复制/锚点契约不变 | +| 性能 | N=100/1000/现场规模,分别增加历史条数和历史正文字节;记录事件数、投影数、Deferred/Immediate 原因、fold 访问/写入/hash 字节、分组/布局扫描量及各阶段耗时;区分 debug 与 release | + +时间测量不替代操作计数。「N=1000 与 N=100 耗时只差常数倍」不能证明复杂度与 N 无关;P0 验收要求稳定历史折叠工作确实为零,而剩余 O(N) 阶段如实报告。整体收益需同类负载现场复测,不能预设 CPU 降幅;原 09-18 release 事故不是本次 replay×subagent 缺陷在 release 下已复现的证据。 + +相关命令:`cargo test -p peri-tui --lib`、`cargo clippy -p peri-tui --all-targets -- -D warnings`、`cargo fmt --all --check`、`git diff --check`;实施改动若触及跨 crate 契约再按 testing/architecture 标准扩大范围。已执行结果见实施记录;现场与 release 性能矩阵仍待验收。 + +## 关联与文档路由 + +- [前序长会话 CPU issue](2026-09-18-p1-long-session-pins-one-cpu-core.md):同一 push_view_models 热点;其实时生命周期基准未覆盖回放失配。 +- `2026-09-04-tui-long-markdown-streaming-cpu.md`:较早的合帧建议;scheduler 存在不等于当前完整生产链路正确。 +- 工作区另有 `2026-09-27-long-thread-history-window.md` 有界历史窗口提案:处理驻留和可见窗口,不能替代本 issue 的发布状态、重复折叠和 cadence 修复。 +- 已同步 `docs/design/tui-streaming-markdown-performance.md` 与 `docs/code-index/peri-tui.md` 的实际发布、历史缓存和测试入口。 + +## 对抗验证 + +方案先成文,再由独立 subagent 挑战证据、状态边界、失效规则、模式/生命周期反例及验收可执行性。审查报告:[adversarial_review.md](../../docs/experiment-tui-streaming-publication/adversarial_review.md)。首轮 CONCLUSION_WEAKENED 揭示时长冻结、同步发布顺序、模式 pending 和 effect 依赖风险;实现吸收后,收尾为 CONCLUSION_STANDS(仅限机制与实现方向)。审查者没有运行最终测试;测试证据由主执行者提供。 + +## 状态变更记录 + +| 日期 | 状态 | 记录 | +| --- | --- | --- | +| 2026-09-27 初报 | Open | 登记两个进程的 CPU、采样和日志证据;提出重复折叠与 subagent 高频发布 | +| 2026-09-27 文档复核 | Open | 确认 subagent 立即发布是现行设计许可,replay 为确定失配来源 | +| 2026-09-27 诊断与方案 | Open | 加入三个确定性诊断,撤回主 Agent 合帧已生效、帧数等于 publication、运行时可直接启用测试计数器等判断;提出 S1–S4,待对抗验证与实施 | + +## 实施记录与验证结果(2026-09-27) + +- 独立 `unpublished` 与显式 intent 替代 dirty/generation 推断;`view_model_count` 不物化 VM。主/子后续流式 chunk 共用 fixed deadline,空 chunk 不消耗首块;同步 terminal/local-loading-reset barrier 先发布再执行后续副作用,并清 pending。 +- 模式热切换按既有事件读取:Streaming pending 在模式改变时失效;None→Streaming 在下个非空 chunk 恢复,主 Block 仍遵守 Markdown boundary。无事件的纯配置改变不保证立即发布;replay 无条件 pending 保留。 +- replay terminal fold 归一化;历史缓存持有源共享节点,覆盖同长度更新、替换/缩短与 override 移除。im inline 小向量分支使用值比较,覆盖表比较成本随覆盖条数变化,不宣称整个发布 O(1)。 +- canonical trailing 时长在终态/归档前一次冻结,保留空 reasoning placeholder;auto-follow 纯记账无通知写入,effect 加入 loading/reset 依赖。BG_LIVE_DETAIL 仍有独立逐 chunk 明细写入,剩余 group/todo/layout 扫描仍为 O(N)。 + +正式测试使用真实 handler → dispatcher → scheduler:主/子 text/reasoning 各 100 chunks 在同一时刻仅首块发布,deadline 再发布完整内容;主流首块只投影一次,明确 projection 读取不会吞更新。N=100/1000、每卡 4 KiB replay 历史预热后重复发布 20 次,历史 fold 访问/写入与工具 hash 调用/字节均为 0,包含 override 并验证移除恢复、同长度更新和冷重建等价。此为确定性操作计数,不是 wall-time benchmark。 + +- 完整 TUI lib 测试:`CARGO_INCREMENTAL=0 cargo test -p peri-tui --lib -- --test-threads=1`,1683 passed / 0 failed / 7 ignored。先前沙箱限制本机 socket/剪贴板导致环境失败;沙箱外完整复跑通过。 +- 对抗审查后的发布回归:`CARGO_INCREMENTAL=0 cargo test -p peri-tui --lib publication -- --test-threads=1`,15 passed / 0 failed(11 个新增用例和 4 个既有相关用例)。 +- `cargo clippy -p peri-tui --all-targets -- -D warnings`、`cargo fmt --all --check`、`cargo test -p peri-tui --doc`(0 个 doctest)与 `git diff --check` 均通过。 +- 未验证:修复二进制同类现场 CPU 复采、release 性能矩阵与真实终端交互体验。暂不关闭现场验收,不给出 CPU 降幅。 From 5b3da25fd4ca6cf135c85dc09607a13d6ab9e8cf Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Sun, 27 Sep 2026 18:28:53 +0800 Subject: [PATCH 4/5] fix(session): preserve compact history and cancellation terminal Preserve canonical history through manual and automatic compaction, and keep cancellation and terminal events consistent across the real ACP lifecycle. Include regression coverage and the completed audit documentation. --- docs/code-index/peri-acp.md | 1 + docs/code-index/peri-agent.md | 2 +- peri-acp/src/host/compact_command_test.rs | 188 +++++++++ peri-acp/src/host/compact_recovery_test.rs | 3 + .../command/compact_persistence_test.rs | 357 ++++++++++++++++++ peri-acp/src/session/command/compact_test.rs | 355 +---------------- .../tests/compact_command_contract_test.rs | 236 ++++++++++++ .../src/session/exec/compact_pipeline.rs | 32 +- .../src/session/exec/executor_helpers.rs | 10 + .../executor_helpers/compact_cancel_test.rs | 11 + .../exec/executor_helpers/event_pump.rs | 7 +- .../exec/executor_helpers/intercept.rs | 6 +- ...6-session-store-sub-plan-f-verification.md | 12 + 13 files changed, 838 insertions(+), 382 deletions(-) create mode 100644 peri-acp/src/host/compact_command_test.rs create mode 100644 peri-acp/src/session/command/compact_persistence_test.rs create mode 100644 peri-acp/tests/compact_command_contract_test.rs diff --git a/docs/code-index/peri-acp.md b/docs/code-index/peri-acp.md index be84eafac..14c9f2b21 100644 --- a/docs/code-index/peri-acp.md +++ b/docs/code-index/peri-acp.md @@ -18,6 +18,7 @@ | 改待发送队列控制与执行准入 | `src/host/requests/user_input.rs` + `src/host/user_input.rs` + `src/host/prompt_dispatch.rs` | `handle_user_input`;`ensure_mailbox` / `schedule_mailbox`;`dispatch_prompt_turn_with_input` | 四短 RPC 不等 prompt_lock,session 持 Agent Mailbox;Agent ticket 经同一执行锁启动,RunStarted/done 身份配对,Stop 精确定位,MPSC/stdio 共用请求与事件链(ARC-BOUNDARY-001 / ARC-EVENT-001) | | 改插件 marketplace 搜索 | `src/host/requests/plugin.rs` + `plugin_search_test.rs` | `handle_search` / `search_marketplace_plugins` | 经 PluginManagerPort 获取缓存目录,复用 `plugin::marketplace::find_marketplace_json` 读取根或 `.claude-plugin` 布局;名称、描述、marketplace 名均忽略大小写匹配;无匹配明确返回空数组;回归经真实 `handle_request` 读取临时磁盘目录 | | 改 compact 后失败恢复 | `src/host/prompt.rs` + `src/host/compact_recovery_test.rs` | `finish_prompt_turn` | 不按 `ok` 丢弃可信 canonical snapshot;取消/模型或 forwarder 失败仍保留已提交 Full 摘要;persistence_inconsistent 移除热会话,冷加载恢复磁盘,ARC-COMPACT-001 | +| 验证手动 compact 跨轮与取消 | `src/host/compact_command_test.rs` + `src/session/command/compact_persistence_test.rs` + `tests/compact_command_contract_test.rs` | `run_session_loop` → `finish_prompt_turn`;`TransportEventSink` | 覆盖连续手动、自动 Full 后手动、新连接冷读后手动、摘要生成中取消;完整 canonical 历史保持,done 与 PromptResponse 在真实 MPSC 通知上同一终态;原始两项复现保留为 public API 集成回归 | | 改 System Reminder producer/ACP 投影 | `src/session/dynamic_mcp.rs` + `src/host/continuation.rs` + `src/session/event_sink.rs` + `src/dispatch/session_replay.rs` | `SessionDynamicMcpNotificationSink`;`enqueue_cron_trigger`;`push_system_reminder`;`send_system_reminder` | Dynamic MCP lifecycle/OAuth 与 Cron trigger 直接入 canonical queue;不改变 OAuth/cron 控制;ACP client 声明 `peri.systemReminder` 时收结构化 event,否则只收展示 fallback;load/replay 不伪装 user message;旧 Compact plain-text Human 经 `compact_reminder::legacy_compact_reminders` 生成 Legacy 通知,MPSC/stdio 共用出口 | | 新增/改会话协议方法 | `src/host/requests.rs`(注册面,`handle_request` :22,按方法分派到 `host/requests/{session_lifecycle,plugin,config_options,mcp_oauth,workflow,rewind}.rs`);`src/host/server_loop.rs`(`session/prompt` 单独处理,spawn 后台 task);`src/session/frozen_snapshot.rs`(版本化 frozen owner state);`src/dispatch/session_fork.rs`(fork payload 独立复制);stdio 侧部署装配点 `src/host/stdio/mod.rs`(`run_acp_stdio` 持有进程日志初始化,`assemble_stdio_config` 只装配配置,业务处理走统一 `run_acp_server`) | `handle_new/load/resume/fork`(requests/session_lifecycle.rs);`new_session_from_prepared`(new 的发布段:消费已定格准备输入写 meta/binding/frozen 并发布,不重读配置/插件、不重建 frozen);`handle_reset_dirty`(`peri/session_reset_dirty`,需 `peri.sessionRecoveryV1` 与显式 `accept_risk`);`fork_session`;`encode_frozen_snapshot` / `decode_frozen_snapshot`;`after_new_response`;其余 plugin/config/workflow/rewind handler | new 持久化 frozen 后才发布 session;load/resume 冷恢复原快照,未绑定 legacy 根恢复经 `requests/legacy_session.rs::prepare_for_restore` 按保存 cwd 原子接纳 binding + 缺失 frozen、loser 重读 winner;已绑定缺快照保持错误,未知/损坏版本及存储错误 fail closed;fork 继承 source frozen,并以新 `MessageId` 复制 payload/compact flags,使新 thread 独立拥有可压缩历史;new/fork 写失败补偿删除。`session/load` 保持 response 前 replay/通知;load/resume 补载驻留空历史时同步 canonical payload 与消息投影,后续 prompt/fork 从同一 payload 读取;`session/prompt` 是唯一 spawn 后台执行的方法;stdio 与 TUI 共用统一 host | | 改 prompt 执行流程(keepgoing/挂起注入/错误响应) | `src/host/prompt.rs` + `src/host/prompt_dispatch.rs` + `src/session/executor.rs` | `run_prompt`;`prompt_wire_response` / `execution_failure_to_acp_error`;`dispatch_prompt_turn`;`session/executor.rs` **仅 re-export** `peri_agent::session::exec::executor` 的执行入口(ARC-BOUNDARY-001) | 挂起时 prompt 注入 inbox;keepgoing 短路在 Agent 层;重试中的 `LlmRetrying` 是进度事件,不结束 prompt;仅 fatal `PromptResult.failure` 在历史/state/cancel-token 后处理完成后映射为 `session/prompt` JSON-RPC server error(`-32000`):message 保留脱敏限长后的 LLM/provider 原意,allowlist data 携带 `kind` 与可选 HTTP `status`、受控 diagnostic facts;ACP 不序列化完整 AgentError/ModelError/provider body;cancel/interrupted/max iterations/输出截断预算耗尽仍返回携带对应停止原因的标准 `PromptResponse`,协议成功不代表任务完成(ARC-OUTPUT-COMPLETION-001);mpsc/stdio 共用统一 host | diff --git a/docs/code-index/peri-agent.md b/docs/code-index/peri-agent.md index eac7f722d..629bac383 100644 --- a/docs/code-index/peri-agent.md +++ b/docs/code-index/peri-agent.md @@ -34,7 +34,7 @@ | 加工具(direct/deferred) | trait 事实源 `peri-acp-types/src/tools.rs`;注册面 = middleware 的 `collect_tools()`;组装 `src/session/exec/stage_builder/tools.rs::build_session_tool_view` | `BaseTool::is_direct()`(默认 **false** = deferred);Reason publication 在 `src/agent/stages/reason.rs`,专用 hook runner 在 `middleware_runner.rs::run_before_reason_catalog`;Dynamic MCP projection holder 由 `StageBuildInput::dynamic_mcp_projection` 从 session owner 透传;启动期候选经 `middleware_runner.rs::run_before_react_start`(:84)→ `SessionToolCatalog::replace_static_mcp_tools`(`session/tool_catalog.rs:260`)替换 static base | 每 turn 先应用 middleware disabled 与 agent allow/disallow filter 构造 session-local 视图;动态 refresh 后按 working map swap → `before_reason_catalog` → `before_model` → pin 发布,ToolSearch 在专用 hook 内重绑 Search index 与 Execute resolver;Discover/resource 的 projection lease 跨 stage build 复用并由 session close 释放;startup 提交只更新 static base,不替代 Reason boundary、不混入 dynamic overlay;不得使用静态核心白名单或等待下一 turn;契约 ARC-TOOLS-001 | | 改 PTC effective-target dispatch | `src/agent/stages/tool_dispatch/{effective_dispatcher,execution}.rs` + `peri-acp-types/src/tools.rs` | `StageEffectiveToolDispatcher::dispatch` / `dispatch_output`;`collect_tool_results` | canonical `RunPtcCode` 是 deferred-only,经 `SearchExtraTools → ExecuteExtraTool` 进入执行;从当前 pinned catalog canonical resolve,policy/HITL/event/tool card 投影 effective target,并复用 timeout/cancel;typed execution evidence 经 canonical direct/deferred wrapper 透传;嵌套调用不写 transcript 或重复执行外层 batch hook/失败计数;模型 assistant raw wrapper call 仅保留协议配对;direct tools 不受影响;PTC JavaScript tools API 仍为 string projection;旧 `run_code` 仅作搜索迁移关键词,不可执行 | | 改 cancel 链路 | `src/agent/stages/mod.rs` + `src/session/exec/executor_helpers/v2_execute.rs` + `peri-acp-types/src/session.rs` | `run_stage`(stage-local `AgentError::Interrupted` 规范化);`build_and_execute_agent_v2` / `classify_loop_terminal`;`cancel_cascade_agents` / `cancel_all_agents`;`CancelRequest` 在 `peri-acp-types/src/identity.rs` | stage 仍成对发射 `StageEnded(Error)`,loop 终态统一为 Interrupted;按 (session_id, turn_id, attempt_id) 三元组定位;幂等判定与终态归 Agent 层;clear_queue 默认 false;契约 ARC-CANCEL-001 | -| /compact 命令路径 | `src/session/exec/compact_pipeline.rs` | `run_compact(force=true)` → Full + re-inject | 编排:validate_inputs → resolve_auxiliary_model → run_v2_compact_with_cancel → assemble_compact_messages;取消返回 Cancelled | +| /compact 命令路径 | `src/session/exec/compact_pipeline.rs` + `src/session/exec/executor_helpers/{intercept,event_pump}.rs` | `run_compact(force=true)` → Full + re-inject;`executor_helpers::done_stop_reason` | 输入与 Host 同为 canonical 消息历史:按持久化快照校验完整 ID 和顺序,再恢复 flags 选择可见内容;命令与普通执行共用 done 终态投影,取消返回及通知均为 cancelled,不确定提交仍按 Internal/reload 收尾 | | 改 LLM 调用链路 | `src/agent/stages/reason.rs` + `src/agent/model_bridge.rs` | `run_reason`;`AgentModelBridge::build_request`;model_bridge 流式事件 v2 直发 | Reason:snapshot → LlmCallStart → before_model → generate(与 cancel 竞争)→ after_model → LlmCallEnd;bridge 每个 ModelRequest 同步读取一次当前 middleware prompt contribution,与 frozen base request-local 组合且不累加;事件契约 ARC-EVENT-001 | | 改工具执行分发 | `src/agent/stages/act.rs` + `src/agent/stages/tool_dispatch.rs` + `tool_dispatch/execution.rs` | `run_act`;`dispatch_tools`;`collect_tool_results`;`ToolResult::execution`;`ToolOutput::projected_text` | 外层一次 staging/commit 后计入含解析失败结果的 tool-growth,再执行 after_tools_batch;typed execution evidence 随统一 bounded projection 进入 live `ToolEnded`、`ToolResult` 与 `BaseMessage::Tool` 持久化;`SubagentFailure` 经 boxed error downcast 保留 child identity 与 SafeSubagentFailure,诊断 facts 同步进入模型可见 tool content;cancel/timeout error 保留 typed status,普通 legacy error 保持 unknown;PTC 内部调用不重复结算;私有执行管线保持审批 → yield → 并发完成即发 ToolEnded → after_tool → 后处理顺序,after_tool 看不到本轮待提交消息;ToolStarted 与实际执行均使用审批后参数,transcript 保留模型原始调用用于配对 | | 改 middleware 状态能力 / 消息修改 | `src/middleware/{capabilities,state}.rs` + `src/agent/agent_context.rs` + `src/agent/stages/middleware_runner.rs` | `BeforeAgentState` / `BeforeInputState` / `InputBatchState::input_message_ids` / `BeforeToolState` / `AfterToolState` / `AfterAgentState`;`MiddlewareState::replace_message`;`AgentContext::from_stage` / `reconcile_to_transcript`;`run_before_agent` / `run_before_input` | hook 不再暴露 cwd/step setter、store/thread 或无法回写的 token/context 快照;首次 Receive 按链序交错执行 before_agent / before_input,后续用户批次只执行 before_input;空批次不重读历史;替换按稳定 MessageId 查找,不增删/重排,输入准备成功或 Err 后均 reconcile;StateView 无可变 queue/catalog;队列和目录分别由 QueueState/CatalogState 提供,before_model 保留消息追加,其他 hook 无输入替换能力 | diff --git a/peri-acp/src/host/compact_command_test.rs b/peri-acp/src/host/compact_command_test.rs new file mode 100644 index 000000000..5bca5b9e7 --- /dev/null +++ b/peri-acp/src/host/compact_command_test.rs @@ -0,0 +1,188 @@ +//! 手动 compact 经 executor、Host 收尾及真实 ACP 通知的跨轮回归。 +use super::*; +use crate::session::event_sink::TransportEventSink; +use crate::transport::{mpsc::mpsc_transport_pair, types::IncomingMessage, AcpTransport}; +use peri_acp_types::PeriCaps; + +fn enable_compact_command(ctx: &mut SessionContext, model: Arc) { + let registry = Arc::new(crate::session::command::CommandRegistry::new()); + crate::session::command::register_builtins(®istry); + ctx.command_lookup = Arc::new(move |text| registry.resolve(text)); + ctx.fresh_auxiliary_model = Some(Arc::new(move || model.clone())); +} + +async fn run_manual_compact(ctx: SessionContext, sessions: SharedSessions) -> serde_json::Value { + let (client, server) = mpsc_transport_pair(); + let caps = Arc::new(dashmap::DashMap::new()); + caps.insert( + ctx.session_id.clone(), + PeriCaps { + agent_event_done: true, + ..Default::default() + }, + ); + let mut turn = make_recovery_turn(&ctx, &sessions, false).await; + turn.content = MessageContent::text("/compact"); + turn.event_sink = Arc::new(TransportEventSink::new(Arc::new(server), caps)); + let result = run_session_loop(ctx.clone(), turn).await; + assert!(!result.persistence_inconsistent); + assert!(result.failure.is_none()); + if result.stop_reason == PromptStopReason::EndTurn { + assert!( + result.history_replaced_by_compaction, + "成功命令必须确认摘要提交" + ); + } + let response = finish_prompt_turn(&sessions, &ctx.session_id, false, result) + .await + .unwrap(); + // 使用真实 EventSink + MPSC wire,避免只在记录型 sink 上验证映射。 + loop { + let notification = tokio::time::timeout(std::time::Duration::from_secs(5), client.recv()) + .await + .unwrap() + .unwrap(); + if let IncomingMessage::Notification { method, params } = notification { + if method == "peri/agent_event_done" { + assert_eq!(params["sessionId"], ctx.session_id); + assert_eq!( + params["stopReason"], response["stopReason"], + "done 通知与 PromptResponse 必须表达同一终态" + ); + break; + } + } + } + response +} + +/// [回归测试] Host 保存完整历史后,第二次命令不得拿它与 visible IDs 错配。 +#[tokio::test] +#[serial] +async fn test_manual_compact_twice_through_host_preserves_canonical_history() { + let dir = tempfile::tempdir().unwrap(); + let _home = HomeGuard::set(dir.path()); + let (mut ctx, store, sessions) = + make_recovery_context(&dir, Arc::new(SummaryModel), false).await; + enable_compact_command(&mut ctx, Arc::new(SummaryModel)); + let original = store + .load_payloads(ctx.thread_id.as_ref().unwrap()) + .await + .unwrap(); + for _ in 0..2 { + assert_eq!( + run_manual_compact(ctx.clone(), sessions.clone()).await["stopReason"], + "end_turn" + ); + } + assert_eq!(store.compact_commits.load(Ordering::SeqCst), 2); + let snapshot = store + .inner + .load_session_snapshot(ctx.thread_id.as_ref().unwrap()) + .await + .unwrap(); + assert_eq!(snapshot.payloads.len(), original.len() + 2); + for payload in original { + assert!(snapshot.flags[&payload.id()].excluded); + assert_eq!( + snapshot + .payloads + .iter() + .filter(|item| item.id() == payload.id()) + .count(), + 1 + ); + } + assert_next_turn_sees_summary(ctx, &sessions).await; +} + +/// [回归测试] 自动 Full 与手动 Full 共用 canonical 历史,下一轮 Reason 不复活旧原文。 +#[tokio::test] +#[serial] +async fn test_auto_full_then_manual_compact_through_host() { + let dir = tempfile::tempdir().unwrap(); + let _home = HomeGuard::set(dir.path()); + let (mut ctx, store, sessions) = + make_recovery_context(&dir, Arc::new(SummaryModel), false).await; + enable_compact_command(&mut ctx, Arc::new(SummaryModel)); + let turn = make_recovery_turn(&ctx, &sessions, true).await; + let result = run_session_loop(ctx.clone(), turn).await; + assert!(result.history_replaced_by_compaction); + finish_prompt_turn(&sessions, &ctx.session_id, false, result) + .await + .unwrap(); + run_manual_compact(ctx.clone(), sessions.clone()).await; + assert_eq!(store.compact_commits.load(Ordering::SeqCst), 2); + assert_next_turn_sees_summary(ctx, &sessions).await; +} + +/// [回归测试] 新连接冷读 canonical payload 和 flags 后,手动 compact 仍然可用。 +#[tokio::test] +#[serial] +async fn test_cold_snapshot_then_manual_compact_through_host() { + let dir = tempfile::tempdir().unwrap(); + let _home = HomeGuard::set(dir.path()); + let (mut ctx, store, sessions) = + make_recovery_context(&dir, Arc::new(SummaryModel), false).await; + enable_compact_command(&mut ctx, Arc::new(SummaryModel)); + run_manual_compact(ctx.clone(), sessions.clone()).await; + drop(sessions); + let reader = peri_resources::sessions::SessionResourcesImpl::open_existing_read_only( + dir.path().join("recovery.db"), + ) + .await + .unwrap(); + let snapshot = reader + .load_session_snapshot(ctx.thread_id.as_ref().unwrap()) + .await + .unwrap(); + assert!(snapshot.flags.values().any(|flags| flags.excluded)); + let restored = make_host_sessions(&ctx, snapshot.payloads); + run_manual_compact(ctx.clone(), restored.clone()).await; + assert_eq!(store.compact_commits.load(Ordering::SeqCst), 2); + assert_next_turn_sees_summary(ctx, &restored).await; +} + +/// [回归测试] 手动摘要模型正在生成时取消,wire 必须 cancelled,且未提交原文保留。 +#[tokio::test] +#[serial] +async fn test_manual_compact_model_cancel_has_consistent_wire_and_preserves_history() { + let dir = tempfile::tempdir().unwrap(); + let _home = HomeGuard::set(dir.path()); + let (mut ctx, store, sessions) = + make_recovery_context(&dir, Arc::new(SummaryModel), false).await; + let (entered, ready) = tokio::sync::oneshot::channel(); + enable_compact_command( + &mut ctx, + Arc::new(CancelGateModel { + entered: Mutex::new(Some(entered)), + }), + ); + let before = store + .load_payloads(ctx.thread_id.as_ref().unwrap()) + .await + .unwrap(); + let task = tokio::spawn(run_manual_compact(ctx.clone(), sessions.clone())); + tokio::time::timeout(std::time::Duration::from_secs(5), ready) + .await + .unwrap() + .unwrap(); + ctx.cancel.cancel(); + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .unwrap() + .unwrap(); + assert_eq!(response["stopReason"], "cancelled"); + assert_eq!(store.compact_commits.load(Ordering::SeqCst), 0); + let after = store + .load_payloads(ctx.thread_id.as_ref().unwrap()) + .await + .unwrap(); + assert_eq!( + after.iter().map(PersistedPayload::id).collect::>(), + before.iter().map(PersistedPayload::id).collect::>() + ); + assert!(sessions.lock().await[&ctx.session_id] + .cancel_token + .is_none()); +} diff --git a/peri-acp/src/host/compact_recovery_test.rs b/peri-acp/src/host/compact_recovery_test.rs index 39b2602dc..9bd17cc7a 100644 --- a/peri-acp/src/host/compact_recovery_test.rs +++ b/peri-acp/src/host/compact_recovery_test.rs @@ -16,6 +16,9 @@ use peri_acp_types::{ }; use std::{collections::HashMap, sync::atomic::AtomicBool}; +#[path = "compact_command_test.rs"] +mod compact_command_tests; + const SUMMARY: &str = "COMMITTED_COMPACT_RECOVERY_SUMMARY"; const OLD: &str = "OLD_HISTORY_MUST_STAY_EXCLUDED"; diff --git a/peri-acp/src/session/command/compact_persistence_test.rs b/peri-acp/src/session/command/compact_persistence_test.rs new file mode 100644 index 000000000..b99f95928 --- /dev/null +++ b/peri-acp/src/session/command/compact_persistence_test.rs @@ -0,0 +1,357 @@ +//! 手动 compact 的持久化行为与输入一致性校验。 + +use super::*; + +#[tokio::test] +async fn test_compact_pipeline_uses_bound_sqlite_lifecycle() { + let session = BoundSession::open("compact-pipeline.db").await; + let history = vec![ + BaseMessage::system("pipeline system prompt"), + BaseMessage::human("pipeline user question"), + BaseMessage::ai("pipeline assistant response"), + ]; + session.append(&history).await; + + let sink = Arc::new(MockEventSink::new()); + let ctx = make_ctx_with_model_and_thread( + sink, + history.clone(), + session.cwd.clone(), + Arc::new(MockSummaryModel::new( + "PIPELINE_LIFECYCLE_MARKER", + )), + Some(session.resources()), + Some(session.thread_id.clone()), + ); + + let result = execute_compact(&CompactCommand, ctx).await; + + assert_eq!(result.stop_reason, PromptStopReason::EndTurn); + assert!( + result + .messages + .iter() + .any(|message| message.content().contains("PIPELINE_LIFECYCLE_MARKER")), + "成功结果必须含 summary" + ); + let stored_history = session.stored_messages().await; + assert!( + stored_history + .iter() + .any(|message| message.content().contains("PIPELINE_LIFECYCLE_MARKER")), + "绑定 store 的 lifecycle 必须持久化 summary" + ); + let flags = session.stored_flags().await; + assert!(!flags.contains_key(&history[0].id()), "System 不得被排除"); + assert!(flags[&history[1].id()].excluded, "Human 必须被排除"); + assert!(flags[&history[2].id()].excluded, "AI 必须被排除"); +} + +#[tokio::test] +async fn test_compact_pipeline_does_not_append_preexisting_history_to_bound_thread() { + let session = BoundSession::open("compact-existing-history.db").await; + let history = vec![ + BaseMessage::human("already persisted user question"), + BaseMessage::ai("already persisted assistant response"), + ]; + session.append(&history).await; + + let ctx = make_ctx_with_model_and_thread( + Arc::new(MockEventSink::new()), + history.clone(), + session.cwd.clone(), + Arc::new(MockSummaryModel::new( + "EXISTING_HISTORY_NOT_DUPLICATED", + )), + Some(session.resources()), + Some(session.thread_id.clone()), + ); + + let result = execute_compact(&CompactCommand, ctx).await; + + assert_eq!(result.stop_reason, PromptStopReason::EndTurn); + let stored_history = session.stored_messages().await; + assert_eq!( + stored_history.len(), + history.len() + 1, + "已持久化的原始 history 不得在 compact 时被重复写入" + ); + for original in &history { + assert_eq!( + stored_history + .iter() + .filter(|message| message.id() == original.id()) + .count(), + 1, + "原始消息 {:?} 在 SQLite thread 中必须仅出现一次", + original.id() + ); + } + assert!( + stored_history.iter().any(|message| message + .content() + .contains("EXISTING_HISTORY_NOT_DUPLICATED")), + "compact 后必须追加 summary" + ); +} + +#[tokio::test] +async fn test_compact_pipeline_reuses_canonical_history_for_second_bound_sqlite_lifecycle() { + let session = BoundSession::open("compact-second-lifecycle.db").await; + let history = vec![ + BaseMessage::system("persistent system prompt"), + BaseMessage::human("first compact request"), + BaseMessage::ai("first compact response"), + ]; + session.append(&history).await; + + let first_sink = Arc::new(MockEventSink::new()); + let first = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + first_sink.clone(), + history, + session.cwd.clone(), + Arc::new(MockSummaryModel::new( + "FIRST_COMPACT_LIFECYCLE_SUMMARY", + )), + Some(session.resources()), + Some(session.thread_id.clone()), + ), + ) + .await; + assert_eq!(first.stop_reason, PromptStopReason::EndTurn); + assert!( + first.messages.iter().any(|message| message + .content() + .contains("FIRST_COMPACT_LIFECYCLE_SUMMARY")), + "首次 compact 必须产生 summary" + ); + + let second_sink = Arc::new(MockEventSink::new()); + let second = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + second_sink.clone(), + session.stored_messages().await, + session.cwd.clone(), + Arc::new(MockSummaryModel::new( + "SECOND_COMPACT_LIFECYCLE_SUMMARY", + )), + Some(session.resources()), + Some(session.thread_id.clone()), + ), + ) + .await; + + assert_eq!( + second.stop_reason, + PromptStopReason::EndTurn, + "第二次 compact 必须完成而非因 physical/visible history 不匹配被拒绝" + ); + assert!( + second_sink + .events() + .iter() + .any(|(_, json)| json.contains("compact_completed")), + "第二次 compact 必须发出 CompactCompleted,而非只以 EndTurn 返回错误" + ); + assert!( + second.messages.iter().any(|message| message + .content() + .contains("SECOND_COMPACT_LIFECYCLE_SUMMARY")), + "第二次 compact 必须返回新的 summary" + ); + let stored = session.stored_messages().await; + assert_eq!( + stored + .iter() + .filter(|message| message.content().contains("_COMPACT_LIFECYCLE_SUMMARY")) + .count(), + 2, + "同一 thread 的两次 compact 必须各自持久化一个 summary" + ); +} + +#[tokio::test] +async fn test_compact_pipeline_history_read_only_lifecycle_failure_preserves_durable_message_ids() { + // 迁前本测试用 FilesystemThreadStore(能力面 = 只读历史)。新契约下等价的能力面 + // 是只读打开的同一库:数据可读、`DataCapabilities::HistoryReadOnly`,完整 + // lifecycle 无法完成。 + let session = BoundSession::open("compact-read-only-lifecycle.db").await; + let history = vec![ + BaseMessage::human("read-only compact request"), + BaseMessage::ai("read-only compact response"), + ]; + session.append(&history).await; + let before_ids = session + .stored_messages() + .await + .iter() + .map(BaseMessage::id) + .collect::>(); + let read_only = session.open_read_only().await; + + let result = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + Arc::new(MockEventSink::new()), + history.clone(), + session.cwd.clone(), + Arc::new(MockSummaryModel::new( + "READ_ONLY_LIFECYCLE_MUST_NOT_APPEND", + )), + Some(read_only), + Some(session.thread_id.clone()), + ), + ) + .await; + + assert_eq!(result.stop_reason, PromptStopReason::EndTurn); + assert_eq!( + result + .messages + .iter() + .map(BaseMessage::id) + .collect::>(), + history.iter().map(BaseMessage::id).collect::>(), + "只读能力面下 lifecycle 不受支持时必须返回原始 history" + ); + assert_eq!( + session + .stored_messages() + .await + .iter() + .map(BaseMessage::id) + .collect::>(), + before_ids, + "pipeline 的 preliminary append 不得向只读后端写入重复 message IDs" + ); +} + +#[tokio::test] +async fn test_compact_pipeline_rejects_incoming_history_that_differs_from_bound_thread() { + let session = BoundSession::open("compact-history-mismatch.db").await; + let stored_history = vec![ + BaseMessage::human("stored user question"), + BaseMessage::ai("stored assistant response"), + ]; + let incoming_history = vec![ + BaseMessage::human("incoming user question"), + BaseMessage::ai("incoming assistant response"), + ]; + assert_ne!( + stored_history + .iter() + .map(BaseMessage::id) + .collect::>(), + incoming_history + .iter() + .map(BaseMessage::id) + .collect::>(), + "fixture 的 stored 与 incoming history 必须具有不同 ID" + ); + session.append(&stored_history).await; + + let sink = Arc::new(MockEventSink::new()); + let ctx = make_ctx_with_model_and_thread( + sink.clone(), + incoming_history.clone(), + session.cwd.clone(), + Arc::new(MockSummaryModel::new( + "HISTORY_MISMATCH_MUST_NOT_BE_PERSISTED", + )), + Some(session.resources()), + Some(session.thread_id.clone()), + ); + + let result = execute_compact(&CompactCommand, ctx).await; + + assert_eq!(result.stop_reason, PromptStopReason::EndTurn); + assert_eq!( + result + .messages + .iter() + .map(BaseMessage::id) + .collect::>(), + incoming_history + .iter() + .map(BaseMessage::id) + .collect::>(), + "持久化 context 与传入 history ID 不一致时必须原样返回 incoming history" + ); + let fb = result.feedback.as_ref().expect("不匹配时应携带 feedback"); + assert_eq!(fb.level, FeedbackLevel::Error); + assert_eq!(fb.channel, FeedbackChannel::UiOnly); + assert_eq!(fb.message, "compact persistence context mismatch"); + assert!( + sink.events().is_empty(), + "命令自身不应发射 CompactError 事件(Phase 5 Step 4 收敛为 feedback)" + ); + + let persisted_history = session.stored_messages().await; + assert_eq!( + persisted_history + .iter() + .map(BaseMessage::id) + .collect::>(), + stored_history + .iter() + .map(BaseMessage::id) + .collect::>(), + "不匹配时 store 必须保持仅含 stored history" + ); + assert!( + !persisted_history.iter().any(|message| message + .content() + .contains("HISTORY_MISMATCH_MUST_NOT_BE_PERSISTED")), + "不匹配时不得持久化 summary" + ); + assert!( + session.stored_flags().await.is_empty(), + "不匹配时不得写入 message flags" + ); +} + +#[tokio::test] +async fn test_compact_pipeline_without_thread_binding_returns_error_without_mutating_history() { + let history = vec![ + BaseMessage::human("unbound user question"), + BaseMessage::ai("unbound assistant response"), + ]; + let sink = Arc::new(MockEventSink::new()); + let ctx = make_ctx_with_model_and_thread( + sink.clone(), + history.clone(), + "/tmp".to_string(), + Arc::new(MockSummaryModel::new( + "UNBOUND_MUST_NOT_COMPACT", + )), + None, + None, + ); + + let result = execute_compact(&CompactCommand, ctx).await; + + assert_eq!(result.stop_reason, PromptStopReason::EndTurn); + assert_eq!( + result + .messages + .iter() + .map(BaseMessage::id) + .collect::>(), + history.iter().map(BaseMessage::id).collect::>(), + "缺少 store/thread binding 时必须保留原 history" + ); + let fb = result + .feedback + .as_ref() + .expect("缺少 binding 应携带 feedback"); + assert_eq!(fb.level, FeedbackLevel::Error); + assert_eq!(fb.channel, FeedbackChannel::UiOnly); + assert_eq!(fb.message, "compact persistence is unavailable"); + assert!( + sink.events().is_empty(), + "命令自身不应发射 CompactError 事件(Phase 5 Step 4 收敛为 feedback)" + ); +} diff --git a/peri-acp/src/session/command/compact_test.rs b/peri-acp/src/session/command/compact_test.rs index 24cf5d4a0..d71759441 100644 --- a/peri-acp/src/session/command/compact_test.rs +++ b/peri-acp/src/session/command/compact_test.rs @@ -580,359 +580,8 @@ fn make_human_with_skill_marker(skill_path: &str) -> BaseMessage { BaseMessage::human(format!("用户消息\n[Skill: {}]", skill_path)) } -#[tokio::test] -async fn test_compact_pipeline_uses_bound_sqlite_lifecycle() { - let session = BoundSession::open("compact-pipeline.db").await; - let history = vec![ - BaseMessage::system("pipeline system prompt"), - BaseMessage::human("pipeline user question"), - BaseMessage::ai("pipeline assistant response"), - ]; - session.append(&history).await; - - let sink = Arc::new(MockEventSink::new()); - let ctx = make_ctx_with_model_and_thread( - sink, - history.clone(), - session.cwd.clone(), - Arc::new(MockSummaryModel::new( - "PIPELINE_LIFECYCLE_MARKER", - )), - Some(session.resources()), - Some(session.thread_id.clone()), - ); - - let result = execute_compact(&CompactCommand, ctx).await; - - assert_eq!(result.stop_reason, PromptStopReason::EndTurn); - assert!( - result - .messages - .iter() - .any(|message| message.content().contains("PIPELINE_LIFECYCLE_MARKER")), - "成功结果必须含 summary" - ); - let stored_history = session.stored_messages().await; - assert!( - stored_history - .iter() - .any(|message| message.content().contains("PIPELINE_LIFECYCLE_MARKER")), - "绑定 store 的 lifecycle 必须持久化 summary" - ); - let flags = session.stored_flags().await; - assert!(!flags.contains_key(&history[0].id()), "System 不得被排除"); - assert!(flags[&history[1].id()].excluded, "Human 必须被排除"); - assert!(flags[&history[2].id()].excluded, "AI 必须被排除"); -} - -#[tokio::test] -async fn test_compact_pipeline_does_not_append_preexisting_history_to_bound_thread() { - let session = BoundSession::open("compact-existing-history.db").await; - let history = vec![ - BaseMessage::human("already persisted user question"), - BaseMessage::ai("already persisted assistant response"), - ]; - session.append(&history).await; - - let ctx = make_ctx_with_model_and_thread( - Arc::new(MockEventSink::new()), - history.clone(), - session.cwd.clone(), - Arc::new(MockSummaryModel::new( - "EXISTING_HISTORY_NOT_DUPLICATED", - )), - Some(session.resources()), - Some(session.thread_id.clone()), - ); - - let result = execute_compact(&CompactCommand, ctx).await; - - assert_eq!(result.stop_reason, PromptStopReason::EndTurn); - let stored_history = session.stored_messages().await; - assert_eq!( - stored_history.len(), - history.len() + 1, - "已持久化的原始 history 不得在 compact 时被重复写入" - ); - for original in &history { - assert_eq!( - stored_history - .iter() - .filter(|message| message.id() == original.id()) - .count(), - 1, - "原始消息 {:?} 在 SQLite thread 中必须仅出现一次", - original.id() - ); - } - assert!( - stored_history.iter().any(|message| message - .content() - .contains("EXISTING_HISTORY_NOT_DUPLICATED")), - "compact 后必须追加 summary" - ); -} - -#[tokio::test] -async fn test_compact_pipeline_reuses_visible_result_history_for_second_bound_sqlite_lifecycle() { - let session = BoundSession::open("compact-second-lifecycle.db").await; - let history = vec![ - BaseMessage::system("persistent system prompt"), - BaseMessage::human("first compact request"), - BaseMessage::ai("first compact response"), - ]; - session.append(&history).await; - - let first_sink = Arc::new(MockEventSink::new()); - let first = execute_compact( - &CompactCommand, - make_ctx_with_model_and_thread( - first_sink.clone(), - history, - session.cwd.clone(), - Arc::new(MockSummaryModel::new( - "FIRST_COMPACT_LIFECYCLE_SUMMARY", - )), - Some(session.resources()), - Some(session.thread_id.clone()), - ), - ) - .await; - assert_eq!(first.stop_reason, PromptStopReason::EndTurn); - assert!( - first.messages.iter().any(|message| message - .content() - .contains("FIRST_COMPACT_LIFECYCLE_SUMMARY")), - "首次 compact 必须产生 summary" - ); - - let second_sink = Arc::new(MockEventSink::new()); - let second = execute_compact( - &CompactCommand, - make_ctx_with_model_and_thread( - second_sink.clone(), - first.messages, - session.cwd.clone(), - Arc::new(MockSummaryModel::new( - "SECOND_COMPACT_LIFECYCLE_SUMMARY", - )), - Some(session.resources()), - Some(session.thread_id.clone()), - ), - ) - .await; - - assert_eq!( - second.stop_reason, - PromptStopReason::EndTurn, - "第二次 compact 必须完成而非因 physical/visible history 不匹配被拒绝" - ); - assert!( - second_sink - .events() - .iter() - .any(|(_, json)| json.contains("compact_completed")), - "第二次 compact 必须发出 CompactCompleted,而非只以 EndTurn 返回错误" - ); - assert!( - second.messages.iter().any(|message| message - .content() - .contains("SECOND_COMPACT_LIFECYCLE_SUMMARY")), - "第二次 compact 必须返回新的 summary" - ); - let stored = session.stored_messages().await; - assert_eq!( - stored - .iter() - .filter(|message| message.content().contains("_COMPACT_LIFECYCLE_SUMMARY")) - .count(), - 2, - "同一 thread 的两次 compact 必须各自持久化一个 summary" - ); -} - -#[tokio::test] -async fn test_compact_pipeline_history_read_only_lifecycle_failure_preserves_durable_message_ids() { - // 迁前本测试用 FilesystemThreadStore(能力面 = 只读历史)。新契约下等价的能力面 - // 是只读打开的同一库:数据可读、`DataCapabilities::HistoryReadOnly`,完整 - // lifecycle 无法完成。 - let session = BoundSession::open("compact-read-only-lifecycle.db").await; - let history = vec![ - BaseMessage::human("read-only compact request"), - BaseMessage::ai("read-only compact response"), - ]; - session.append(&history).await; - let before_ids = session - .stored_messages() - .await - .iter() - .map(BaseMessage::id) - .collect::>(); - let read_only = session.open_read_only().await; - - let result = execute_compact( - &CompactCommand, - make_ctx_with_model_and_thread( - Arc::new(MockEventSink::new()), - history.clone(), - session.cwd.clone(), - Arc::new(MockSummaryModel::new( - "READ_ONLY_LIFECYCLE_MUST_NOT_APPEND", - )), - Some(read_only), - Some(session.thread_id.clone()), - ), - ) - .await; - - assert_eq!(result.stop_reason, PromptStopReason::EndTurn); - assert_eq!( - result - .messages - .iter() - .map(BaseMessage::id) - .collect::>(), - history.iter().map(BaseMessage::id).collect::>(), - "只读能力面下 lifecycle 不受支持时必须返回原始 history" - ); - assert_eq!( - session - .stored_messages() - .await - .iter() - .map(BaseMessage::id) - .collect::>(), - before_ids, - "pipeline 的 preliminary append 不得向只读后端写入重复 message IDs" - ); -} - -#[tokio::test] -async fn test_compact_pipeline_rejects_incoming_history_that_differs_from_bound_thread() { - let session = BoundSession::open("compact-history-mismatch.db").await; - let stored_history = vec![ - BaseMessage::human("stored user question"), - BaseMessage::ai("stored assistant response"), - ]; - let incoming_history = vec![ - BaseMessage::human("incoming user question"), - BaseMessage::ai("incoming assistant response"), - ]; - assert_ne!( - stored_history - .iter() - .map(BaseMessage::id) - .collect::>(), - incoming_history - .iter() - .map(BaseMessage::id) - .collect::>(), - "fixture 的 stored 与 incoming history 必须具有不同 ID" - ); - session.append(&stored_history).await; - - let sink = Arc::new(MockEventSink::new()); - let ctx = make_ctx_with_model_and_thread( - sink.clone(), - incoming_history.clone(), - session.cwd.clone(), - Arc::new(MockSummaryModel::new( - "HISTORY_MISMATCH_MUST_NOT_BE_PERSISTED", - )), - Some(session.resources()), - Some(session.thread_id.clone()), - ); - - let result = execute_compact(&CompactCommand, ctx).await; - - assert_eq!(result.stop_reason, PromptStopReason::EndTurn); - assert_eq!( - result - .messages - .iter() - .map(BaseMessage::id) - .collect::>(), - incoming_history - .iter() - .map(BaseMessage::id) - .collect::>(), - "持久化 context 与传入 history ID 不一致时必须原样返回 incoming history" - ); - let fb = result.feedback.as_ref().expect("不匹配时应携带 feedback"); - assert_eq!(fb.level, FeedbackLevel::Error); - assert_eq!(fb.channel, FeedbackChannel::UiOnly); - assert_eq!(fb.message, "compact persistence context mismatch"); - assert!( - sink.events().is_empty(), - "命令自身不应发射 CompactError 事件(Phase 5 Step 4 收敛为 feedback)" - ); - - let persisted_history = session.stored_messages().await; - assert_eq!( - persisted_history - .iter() - .map(BaseMessage::id) - .collect::>(), - stored_history - .iter() - .map(BaseMessage::id) - .collect::>(), - "不匹配时 store 必须保持仅含 stored history" - ); - assert!( - !persisted_history.iter().any(|message| message - .content() - .contains("HISTORY_MISMATCH_MUST_NOT_BE_PERSISTED")), - "不匹配时不得持久化 summary" - ); - assert!( - session.stored_flags().await.is_empty(), - "不匹配时不得写入 message flags" - ); -} - -#[tokio::test] -async fn test_compact_pipeline_without_thread_binding_returns_error_without_mutating_history() { - let history = vec![ - BaseMessage::human("unbound user question"), - BaseMessage::ai("unbound assistant response"), - ]; - let sink = Arc::new(MockEventSink::new()); - let ctx = make_ctx_with_model_and_thread( - sink.clone(), - history.clone(), - "/tmp".to_string(), - Arc::new(MockSummaryModel::new( - "UNBOUND_MUST_NOT_COMPACT", - )), - None, - None, - ); - - let result = execute_compact(&CompactCommand, ctx).await; - - assert_eq!(result.stop_reason, PromptStopReason::EndTurn); - assert_eq!( - result - .messages - .iter() - .map(BaseMessage::id) - .collect::>(), - history.iter().map(BaseMessage::id).collect::>(), - "缺少 store/thread binding 时必须保留原 history" - ); - let fb = result - .feedback - .as_ref() - .expect("缺少 binding 应携带 feedback"); - assert_eq!(fb.level, FeedbackLevel::Error); - assert_eq!(fb.channel, FeedbackChannel::UiOnly); - assert_eq!(fb.message, "compact persistence is unavailable"); - assert!( - sink.events().is_empty(), - "命令自身不应发射 CompactError 事件(Phase 5 Step 4 收敛为 feedback)" - ); -} +#[path = "compact_persistence_test.rs"] +mod persistence_tests; /// 契约:compact 输出首条消息必须是 Human(摘要+续接指令), /// 不得为 System 或其他类型。 diff --git a/peri-acp/tests/compact_command_contract_test.rs b/peri-acp/tests/compact_command_contract_test.rs new file mode 100644 index 000000000..9a43cfc44 --- /dev/null +++ b/peri-acp/tests/compact_command_contract_test.rs @@ -0,0 +1,236 @@ +//! 手动 compact 的 canonical 历史与取消终态回归(真实门面与命令注册表)。 +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use peri_acp_types::{ + command::{CommandContext, DependencyBag, FeedbackLevel}, + event::{EventSink, ExecutorEvent}, + messages::BaseMessage, + session_resources::{FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResources}, + store::PersistedPayload, + workspace::SessionBinding, +}; +use peri_agent::session::exec::compact_pipeline::execute_compact; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct AuditSink(Mutex>, Mutex>); + +#[async_trait] +impl EventSink for AuditSink { + async fn push_event(&self, _: &str, event: &ExecutorEvent, _: u32) { + self.0 + .lock() + .unwrap() + .push(serde_json::to_string(event).unwrap()); + } + async fn push_done(&self, _: &str, reason: &str, _: Option<&str>) { + self.1.lock().unwrap().push(reason.to_owned()); + } +} + +/// [回归测试] 手动 compact 预取消曾返回 Cancelled,却发出正常完成的 done。 +#[tokio::test] +async fn test_cancelled_compact_done_agrees_with_prompt_result() { + use peri_acp_types::command::PromptStopReason; + use peri_acp_types::messages::MessageContent; + use peri_agent::session::exec::executor_helpers::{ + intercept_immediate_command, InterceptOutcome, InterceptRequest, + }; + let registry = Arc::new(peri_acp::session::command::CommandRegistry::new()); + peri_acp::session::command::register_builtins(®istry); + let history = vec![BaseMessage::human("preserve me")]; + let ids: Vec<_> = history.iter().map(BaseMessage::id).collect(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let sink = Arc::new(AuditSink::default()); + let event_sink: Arc = sink.clone(); + let task_manager: Arc = + Arc::new(peri_agent::agent::async_tasks::TaskManager::new()); + let content = MessageContent::text("/compact"); + let result = intercept_immediate_command(InterceptRequest { + content: &content, + history: &history, + history_payloads: history + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect(), + cwd: "/tmp", + session_id: "audit-cancel", + cancel: &cancel, + session_resources: None, + thread_id: None, + frozen_claude_md: None, + frozen_claude_local_md: None, + frozen_skill_summary: None, + frozen_system_prompt: None, + event_sink: &event_sink, + auxiliary_model: &None, + task_manager: &task_manager, + command_lookup: Arc::new(move |text| registry.resolve(text)), + compact_config_loader: Arc::new(Default::default), + }) + .await; + let InterceptOutcome::Handled(result) = result else { + panic!("真实注册表应拦截 compact"); + }; + assert_eq!(result.stop_reason, PromptStopReason::Cancelled); + assert!(result.failure.is_none()); + assert_eq!( + result + .persisted_payloads + .iter() + .map(PersistedPayload::id) + .collect::>(), + ids + ); + assert_eq!( + sink.1.lock().unwrap().as_slice(), + ["cancelled"], + "取消后的 done 必须与 PromptResult 的 Cancelled 一致" + ); +} + +struct SummaryModel; + +#[async_trait] +impl peri_model::Model for SummaryModel { + fn capabilities(&self) -> peri_model::ModelCapabilities { + peri_model::ModelCapabilities { + supports_tools: false, + supports_reasoning: false, + supports_vision: false, + supports_streaming: true, + } + } + async fn stream( + &self, + _: peri_model::ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + Err(peri_model::ModelError::cancelled()) + } + async fn complete( + &self, + _: peri_model::ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + peri_model::ModelResponse::new( + peri_model::ModelMessage::assistant_text("AUDIT_SUMMARY"), + peri_model::StopReason::EndTurn, + None, + None, + ) + } +} + +/// [回归测试] Host 的 canonical 历史曾导致第二次 compact 被可见视图校验拒绝。 +#[tokio::test] +async fn test_compact_again_using_host_canonical_history() { + let directory = tempfile::tempdir().unwrap(); + let resources: Arc = Arc::new( + peri_resources::sessions::SessionResourcesImpl::open(directory.path().join("audit.db")) + .await + .unwrap(), + ); + let workspace = resources.resolve_workspace(directory.path()).await.unwrap(); + let thread_id = uuid::Uuid::now_v7().to_string(); + let lease = resources + .create_session(&NewSession { + thread_id: thread_id.clone(), + created_at: "2026-09-27T00:00:00Z".into(), + meta: NewSessionMeta { + title: Some("audit".into()), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding::from_workspace(&workspace), + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"audit\":true}"), + }) + .await + .unwrap(); + let initial = vec![BaseMessage::human("question"), BaseMessage::ai("answer")]; + resources + .append_history( + &thread_id, + &initial + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect::>(), + ) + .await + .unwrap(); + let sink = Arc::new(AuditSink::default()); + let context = |history: Vec| { + let mut ctx = CommandContext::new( + "audit".into(), + history, + workspace.cwd.to_string_lossy().into_owned(), + sink.clone(), + CancellationToken::new(), + DependencyBag::new(), + ); + ctx.auxiliary_model = Some(Arc::new(SummaryModel)); + ctx.session_resources = Some(resources.clone()); + ctx.thread_id = Some(thread_id.clone()); + ctx + }; + let first = execute_compact(context(initial)).await; + assert_eq!(first.feedback.as_ref().unwrap().level, FeedbackLevel::Info); + let snapshot = resources.load_session_snapshot(&thread_id).await.unwrap(); + assert_eq!(snapshot.payloads.len(), 3); + assert_eq!( + snapshot + .flags + .values() + .filter(|flags| flags.excluded) + .count(), + 2 + ); + // Host finish_prompt_turn 保存 canonical payload;下一轮 handle_prompt 仅筛选 Message, + // 不按 excluded 筛选。这是输入边界复现,不声称启动了真实 TUI/transport。 + let next_history = snapshot + .payloads + .iter() + .filter_map(|payload| payload.as_message().cloned()) + .collect(); + let second = execute_compact(context(next_history)).await; + let after = resources.load_session_snapshot(&thread_id).await.unwrap(); + let completions = sink + .0 + .lock() + .unwrap() + .iter() + .filter(|event| event.contains("compact_completed")) + .count(); + lease.mark_clean().await.unwrap(); + assert_eq!( + second.feedback.as_ref().unwrap().level, + FeedbackLevel::Info, + "第二次 compact 应成功;实际反馈={:?},stop={:?},消息数={},完成事件数={}", + second.feedback, + second.stop_reason, + after.payloads.len(), + completions, + ); + assert_eq!(completions, 2, "每次提交都应发布一次完成事件"); + assert_eq!(after.payloads.len(), 4, "只追加两次摘要,不重复原文"); + assert_eq!(second.messages.len(), 1, "只返回本次可见摘要"); + for original in &snapshot.payloads { + assert!(after.flags[&original.id()].excluded); + assert_eq!( + after + .payloads + .iter() + .filter(|item| item.id() == original.id()) + .count(), + 1, + "完整原文和前次摘要各保留一份" + ); + } +} diff --git a/peri-agent/src/session/exec/compact_pipeline.rs b/peri-agent/src/session/exec/compact_pipeline.rs index 200855592..95cc40e8f 100644 --- a/peri-agent/src/session/exec/compact_pipeline.rs +++ b/peri-agent/src/session/exec/compact_pipeline.rs @@ -153,9 +153,9 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { } } - // 阶段 5: 已存在的 thread 从**一次一致快照**重建(payload 与 flags 同一次读取, - // 不拼「先 messages 后 flags」的跨时刻结果)。命令输入是可见视图,因此不能将 - // 物理存储中的 excluded 原文直接与其比较。 + // 阶段 5: 已存在的 thread 从一次一致快照重建。Host 跨 turn 保留 canonical + // history(含 excluded 原文),先核对完整消息身份与顺序,再恢复 flags 供 Full + // 选择可见内容;不能拿 visible view 与 canonical 输入比较。 let snapshot = match session_resources.load_session_snapshot(&thread_id).await { Ok(snapshot) => snapshot, Err(_) => { @@ -188,33 +188,23 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { }; } } else { - let persisted_flags = snapshot.flags.clone(); - for message in &persisted_history { - transcript.append(message.clone()); - } - transcript.set_flags_batch(persisted_flags); - let expected_history = if transcript - .entries() - .iter() - .any(|entry| transcript.flags(entry.id()).excluded) - { - assemble_compact_messages(&transcript, &None).messages - } else { - transcript.visible_messages().into_iter().cloned().collect() - }; - let visible_matches = expected_history.len() == history.len() - && expected_history + let history_matches = persisted_history.len() == history.len() + && persisted_history .iter() .zip(&history) .all(|(persisted, incoming)| persisted.id() == incoming.id()); - if !visible_matches { - warn!("compact: persisted visible history does not match command history"); + if !history_matches { + warn!("compact: persisted canonical history does not match command history"); return PipelineOutcome::EarlyReturn { history, stop_reason: PromptStopReason::EndTurn, message: "compact persistence context mismatch".to_string(), }; } + for message in persisted_history { + transcript.append(message); + } + transcript.set_flags_batch(snapshot.flags); transcript = transcript.with_persistence(session_resources, thread_id); } diff --git a/peri-agent/src/session/exec/executor_helpers.rs b/peri-agent/src/session/exec/executor_helpers.rs index 9666692ed..a144426eb 100644 --- a/peri-agent/src/session/exec/executor_helpers.rs +++ b/peri-agent/src/session/exec/executor_helpers.rs @@ -54,6 +54,16 @@ pub use v2_execute::{ V2ExecuteRequest, }; +// 普通执行与 Immediate 命令共用 done 协议投影,避免响应已取消而通知仍正常完成。 +fn done_stop_reason(reason: PromptStopReason) -> &'static str { + match reason { + PromptStopReason::EndTurn => "end_turn", + PromptStopReason::Cancelled => "cancelled", + PromptStopReason::MaxTurnRequests => "max_turn_requests", + PromptStopReason::MaxTokens => "max_tokens", + } +} + // ── 共享类型(L5:自 ACP executor.rs 迁入)────────────────────────────────── /// Agent 执行后的最终输出(state + 停止原因)。 diff --git a/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs b/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs index e4c03925e..5931bc6d2 100644 --- a/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs +++ b/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs @@ -321,6 +321,7 @@ struct Case { history: Vec, result: peri_acp_types::session::PromptResult, done_count: usize, + done_reasons: Vec, } async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Case { let dir = tempfile::tempdir().unwrap(); @@ -420,6 +421,7 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca let InterceptOutcome::Handled(result) = outcome else { panic!("manual command must be handled"); }; + let done_reasons = sink.push_done_stop_reasons.lock().unwrap().clone(); Case { _dir: dir, _repo: repo, @@ -429,6 +431,7 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca history, result, done_count: sink.push_done_count(), + done_reasons, } } @@ -482,6 +485,12 @@ async fn test_manual_compact_cancel_after_sql_commit_requires_reload_and_keeps_s assert!(case.result.persistence_inconsistent); assert!(!case.result.ok); assert!(case.result.failure.is_some()); + assert_eq!(case.result.stop_reason, PromptStopReason::EndTurn); + assert_eq!( + case.done_reasons, + ["end_turn"], + "未确认提交必须保留 Internal/reload 终态" + ); assert_durable_summary(&case).await; } @@ -500,6 +509,7 @@ async fn test_manual_compact_cancel_after_confirmed_pipeline_restores_canonical_ assert!(case.result.history_replaced_by_compaction); assert!(case.result.failure.is_none()); assert_eq!(case.result.stop_reason, PromptStopReason::Cancelled); + assert_eq!(case.done_reasons, ["cancelled"]); assert_eq!(case.result.messages.len(), 1); assert!(case.result.messages[0] .content() @@ -532,6 +542,7 @@ async fn test_manual_compact_precancel_preserves_verified_history_without_store_ assert!(!case.result.history_replaced_by_compaction); assert!(case.result.failure.is_none()); assert_eq!(case.result.stop_reason, PromptStopReason::Cancelled); + assert_eq!(case.done_reasons, ["cancelled"]); assert_eq!(case.store.calls.load(Ordering::SeqCst), 0); assert_eq!(case.result.messages.len(), 2); assert_eq!(case.done_count, 1); diff --git a/peri-agent/src/session/exec/executor_helpers/event_pump.rs b/peri-agent/src/session/exec/executor_helpers/event_pump.rs index ac3012ceb..bbee7a8fc 100644 --- a/peri-agent/src/session/exec/executor_helpers/event_pump.rs +++ b/peri-agent/src/session/exec/executor_helpers/event_pump.rs @@ -196,12 +196,7 @@ pub fn spawn_event_pump(req: SpawnPumpRequest) -> PumpHandle { .as_ref() .and_then(|f| f(telemetry_outcome)); - let stop_reason_str = match stop_reason { - PromptStopReason::EndTurn => "end_turn", - PromptStopReason::Cancelled => "cancelled", - PromptStopReason::MaxTurnRequests => "max_turn_requests", - PromptStopReason::MaxTokens => "max_tokens", - }; + let stop_reason_str = super::done_stop_reason(stop_reason); sink.push_done(&session_id, stop_reason_str, request_id.as_deref()) .await; diff --git a/peri-agent/src/session/exec/executor_helpers/intercept.rs b/peri-agent/src/session/exec/executor_helpers/intercept.rs index d719b3f5e..072117b38 100644 --- a/peri-agent/src/session/exec/executor_helpers/intercept.rs +++ b/peri-agent/src/session/exec/executor_helpers/intercept.rs @@ -288,7 +288,11 @@ pub async fn intercept_immediate_command(req: InterceptRequest<'_>) -> Intercept // 通知 TUI agent 执行完成,否则界面永久卡在 loading 状态。 // 命令 turn 无 request_id(None)——TUI 侧跳过 id 配对、回退代际兜底。 req.event_sink - .push_done(req.session_id, "end_turn", None) + .push_done( + req.session_id, + super::done_stop_reason(result.stop_reason), + None, + ) .await; let mut persisted_payloads = committed_payloads.unwrap_or_else(|| req.history_payloads.clone()); diff --git a/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md b/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md index b9d72af9f..772ca6b48 100644 --- a/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md +++ b/spec/issues/2026-09-26-session-store-sub-plan-f-verification.md @@ -162,6 +162,18 @@ review-3 复核(2026-09-26,本轮,同样未执行云命令):把 `HOME` ## 7. 完成审查 +2026-09-27 compact / cancel 专项已修复并验证,修复提交 `61315df8`:手动 compact 校验 canonical 历史,命令 done 与实际取消终态保持一致。稳定入口见 [Agent 索引](../../docs/code-index/peri-agent.md) 与 [ACP 索引](../../docs/code-index/peri-acp.md)。专项 issue 按 `DOC-HISTORY-001` 关闭并移出活动列表;完整调查与修复记录可用 `git show 61315df8:spec/issues/2026-09-27-session-adapter-compact-cancel-behavior-audit.md` 查看。 + +| 专项验证命令 | Exit | 结果 | +| --- | --- | --- | +| `cargo test -p peri-agent -p peri-acp --lib -- --test-threads=1`(沙箱外) | 0 | Agent 863 / ACP 725 passed;含新增 4 项 Host 跨轮、冷读与 MPSC wire 回归 | +| `cargo test -p peri-acp --test compact_command_contract_test -- --test-threads=1` | 0 | 原始 2 项复现由 failed 转为 passed | +| `cargo test -p peri-tui --lib kit::acp_notifier::tests::test_agent_done -- --test-threads=1` | 0 | 2 passed,cancelled 通知转为 TurnInterrupted | +| `cargo clippy -p peri-agent -p peri-acp --all-targets -- -D warnings` | 0 | 通过 | +| `cargo fmt --check` / `git diff --check` / commit pre-commit hooks | 0 | 格式、check、Clippy、typos 与依赖门通过 | + +首次沙箱内 ACP 全库运行因本地 HTTP mock server 被禁止监听而有 6 项失败,获准沙箱外重跑后全通过。本次 10 个 Rust 文件均低于 1000 行;全仓 size 扫描仍有 43 个本任务范围外超限文件(exit 1),不宣称全库满足规模限制。真实云、UI 按键 E2E 与跨平台矩阵未执行;冷读场景是新连接读快照后重建 Host,不冒充完整 session/load wire 或跨进程恢复。本专项不替代本计划其余 V 项验收。 + - F-01:实施前基线,登记已有测试实际结果和非本任务失败(§6.1 已完成本 crate 基线)。 - F-02:A/B 的纯逻辑、门面/SQLite/owner 及 schema 回归(含 V-17/18/19)。 - F-03:C 的确定性故障与 C-01 SDK 前置证据(V-22 的 P1–P7)。 From 4d23b72171666100c57aaca0a45881d7d7fa4366 Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Sun, 27 Sep 2026 19:02:18 +0800 Subject: [PATCH 5/5] fix(ci): harden Windows fixtures and host shutdown Honor isolated home paths across plugin and skill discovery, handle platform-specific path and locking semantics, and separate process startup from RPC timing assertions. Use real temporary workspaces in LSP tests and scope unsupported Unix fixtures explicitly. Complete prediction cancellation during host shutdown, serve prediction requests in print fixtures, retain Windows bootstrap variables, and preserve timeout diagnostics while reaping child processes. Co-Authored-By: deepseek-v4-flash --- Cargo.lock | 1 + docs/code-index/peri-middlewares.md | 2 +- peri-acp/src/host/assemble.rs | 14 +- peri-acp/src/host/executor_flow_test.rs | 2 + peri-acp/src/host/mcp_v4_startup_test.rs | 18 +- peri-acp/src/host/prediction.rs | 41 +- peri-acp/src/host/prediction_test.rs | 39 ++ peri-acp/src/host/prepared.rs | 10 +- peri-acp/src/host/requests_test.rs | 11 +- peri-js-runtime/Cargo.toml | 1 + peri-js-runtime/src/artifact.rs | 56 ++- peri-js-runtime/src/host.rs | 6 +- peri-js-runtime/src/rpc_test.rs | 17 +- peri-lsp/src/client_document_test.rs | 50 +- peri-lsp/src/client_lifecycle_test.rs | 54 ++- peri-lsp/src/client_test.rs | 46 +- peri-lsp/src/pool_test.rs | 28 +- peri-lsp/src/uri.rs | 18 + peri-middlewares/src/agents_md/mod.rs | 4 +- peri-middlewares/src/hooks/loader.rs | 20 +- peri-middlewares/src/hooks/loader_test.rs | 10 +- peri-middlewares/src/plugin/config.rs | 23 +- peri-middlewares/src/plugin/config_test.rs | 18 + peri-middlewares/src/plugin/mod.rs | 2 +- peri-middlewares/src/skills/loader.rs | 14 +- peri-middlewares/src/skills/loader_test.rs | 28 ++ peri-middlewares/src/skills/mod.rs | 9 +- peri-resources/src/context_test.rs | 5 + peri-resources/src/sessions/open_test.rs | 30 +- .../src/sessions/remote/session_shape_test.rs | 16 +- .../src/sessions/sqlite_store/legacy_test.rs | 31 +- .../src/sessions/sqlite_store/schema_test.rs | 2 + .../sessions/sqlite_store/workspace_test.rs | 8 +- peri-tui/tests/meta_session_cli.rs | 9 + peri-tui/tests/print_exit.rs | 433 +++++++++++------- ...2026-09-26-session-store-remote-backend.md | 10 +- 36 files changed, 769 insertions(+), 317 deletions(-) create mode 100644 peri-acp/src/host/prediction_test.rs diff --git a/Cargo.lock b/Cargo.lock index b24b4d792..0c044b0c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4003,6 +4003,7 @@ version = "0.2.0" dependencies = [ "async-trait", "dashmap", + "dirs-next", "fs2", "libc", "parking_lot", diff --git a/docs/code-index/peri-middlewares.md b/docs/code-index/peri-middlewares.md index 0b5572f46..cb2c02bc7 100644 --- a/docs/code-index/peri-middlewares.md +++ b/docs/code-index/peri-middlewares.md @@ -50,7 +50,7 @@ | 改 MCP OAuth / 凭证 | `src/mcp/client/oauth.rs`(flow 准入与回调)+ `src/mcp/oauth_flow.rs` + `auth_store.rs` + `callback_server.rs` + `client_oauth.rs`(授权执行与连接) | `OAuthCallbackServer::bind`(callback_server.rs:31)/`wait_for_code`(:43)/`parse_code_from_url`(:144);`FileCredentialStore`(auth_store.rs);OAuth 流程 `spawn_oauth_flow`(client_oauth.rs:25)/ `start_oauth_flow`(client_oauth.rs:63) | 每个 scoped connection 最多一个活跃 flow(`reserve_oauth_flow_scoped`,client/oauth.rs:266);授权码经 ACP RPC → `register_oauth_callback`(:46)/`deliver_dynamic_oauth_callback`(:130)按完整 identity 投递,回调表仍由 pool 持有;token 只落本机权限保护文件,跨进程文件锁内 read-modify-write,并经同目录唯一临时文件原子替换(ARC-SECRET-001) | | 改 plugin manifest / 加载 | `src/plugin/loader.rs`;类型事实源 `peri-acp-types/src/plugin.rs`(`PluginManifest` :269,`src/plugin/types.rs` 仅 re-export) | `load_manifest`(loader.rs:77);`load_plugins`(:491);`load_enabled_plugins_aggregated`(:632);`PluginCommandProvider`(:595,`new` :600) | manifest 字段类型以 peri-acp-types 为事实源(McpServerConfig :35、PluginCommand :175、PluginAgent :211、PluginLspServer :217、PluginManifest :269);`PluginMiddleware`(middleware.rs:7)只持有 LoadedPlugin 列表 | | 改 plugin 命令 / agents / MCP 回退 | `src/plugin/loader.rs` | `parse_command_md`(:66);`plugin_route_entries`(:276);`merge_plugin_mcp_servers`(:612);`CommandFrontmatter`(:53) | `commands` 兼容字符串路径与对象(字符串 = 相对插件根路径,不是名称,勿当名称解析);agents 未声明仍保留 `.claude/agents` 约定目录回退;插件 MCP 配置命名空间 `plugin:{name}:{server}` | -| 改 plugin 安装 / 市场 | `src/plugin/installer/` + `src/plugin/marketplace/` + `src/plugin/config.rs` | `install_plugin`(installer/install.rs:12)/`update_plugin`(:168)/`uninstall_plugin`(uninstall.rs:15)/`check_updates`(:109)/`cleanup_orphaned_plugins`(:150);`MarketplaceManager`(marketplace/manager.rs:20,`init` :129 / `spawn_refresh` :199);路径 `claude_home` / `installed_plugins_path` 等(config.rs:106-143) | 安装状态持久化 `installed_plugins.json`(load/save config.rs:175/:325);启用名单在 `~/.claude/settings.json`(save/load :428/:465);marketplace 缓存与刷新(manager.rs:57/:199) | +| 改 plugin 安装 / 市场 | `src/plugin/installer/` + `src/plugin/marketplace/` + `src/plugin/config.rs` | `install_plugin`(installer/install.rs:12)/`update_plugin`(:168)/`uninstall_plugin`(uninstall.rs:15)/`check_updates`(:109)/`cleanup_orphaned_plugins`(:150);`MarketplaceManager`(marketplace/manager.rs:20,`init` :129 / `spawn_refresh` :199);路径 `user_home`(用户主目录唯一权威,HOME 优先)/ `claude_home` / `installed_plugins_path` 等(config.rs:105-157) | 安装状态持久化 `installed_plugins.json`(load/save config.rs:175/:325);启用名单在 `~/.claude/settings.json`(save/load :428/:465);marketplace 缓存与刷新(manager.rs:57/:199) | | 改 skills 扫描 / 优先级 | `src/skills/loader.rs` | `resolve_skill_roots`(:273);`scan_skill_roots`(:87,SKILL.md 即叶子不再下钻);`find_skill_content`(:332);`list_skills`(:253);`load_skill_metadata`(:54) | 根优先级 User(`~/.claude/skills`) → Global(`skillsDir`) → Project(`{cwd}/.claude/skills`) → Plugin → Builtin(`disable_bundled` 控制);同名按来源顺序先者优先;符号链接防环;插件 skill root 经 `with_plugin_roots` 扩展点传入 | | 改 skills 注入 / 工具 | `src/skills/mod.rs` + `src/skills/tools.rs` + `src/subagent/skill_preload.rs` | `SkillsMiddleware`(mod.rs:104,`build_frozen_summary` :267、`format_discovery_protocol` :137、`resolve_roots_static` :283);`SkillTool`(tools.rs:29)/`DiscoverSkillsTool`(:114);`extract_skill_names_from_text`(skill_preload.rs:21) | 渐进式摘要注入:会话开始冻结(`with_frozen_summary`,ARC-FROZEN-001);SkillTool 按 cached metadata 匹配后在 blocking 线程调用 `content::load`;loader 与 preload 共用该正文加载入口;preload 在单一 blocking batch 内按需扫描一次并保留 registry-first 与输入顺序;预加载以 fake `SkillTool(skill_name)` ToolUse→ToolResult 序列 `add_message` 注入(放用户消息后,不碰 prompt cache 前缀);MCP skill 经 `with_mcp_registry` 接入 | | 改 Ultra-ADLC 编排契约 | `src/skills/builtin/skills/ultra-adlc/SKILL.md`;设计 `docs/design/ultra-adlc.md`;契约测试 `src/skills/builtin_test.rs` | builtin skill 的 `Logical Workflow 1`、`Main Agent decision seam`、`Progress reporting` | 默认由 fresh independent Opus Decision Arbiter 在 Workflow 1 内裁决,完整 packet 经两次 Opus 失败才升 Fable;裁决重试复用 Main Agent 以 SHA-256 验证的不可变 packet;只有枚举的意图/授权例外问用户;主管进度以四维完成率最小值计算,所有 ID 必须带语义内容 | diff --git a/peri-acp/src/host/assemble.rs b/peri-acp/src/host/assemble.rs index 14e501c0b..63f6e87ef 100644 --- a/peri-acp/src/host/assemble.rs +++ b/peri-acp/src/host/assemble.rs @@ -46,12 +46,16 @@ pub(crate) struct WorkspaceAssembly { /// /// 会话准备面(`host/prepared.rs`)经本函数调用:具体实现与插件装配同属宿主 /// 装配面,准备面不新建越层引用(§0 依赖门边 2)。 +/// +/// 用户级 `.claude` 在本装配面解析:`peri_middlewares::plugin::claude_home()` +/// 是 HOME 优先的唯一权威(Windows 的 `dirs_next::home_dir()` 读 Profile +/// known-folder、忽略 HOME/USERPROFILE,自带一份解析会与其余插件入口取到 +/// 不同目录)。准备面因此只提供执行目录。 pub(crate) fn discover_enabled_plugins_readonly( - claude_dir: &std::path::Path, cwd: &str, ) -> Result { peri_middlewares::plugin::load_enabled_plugins_aggregated_readonly( - claude_dir, + &peri_middlewares::plugin::claude_home(), Some(std::path::Path::new(cwd)), ) } @@ -270,9 +274,9 @@ pub(crate) async fn assemble_server_config_with_mcp_profile( prepared_plugins, } = input; - let claude_dir = dirs_next::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".claude"); + // 用户级 `.claude` 与准备面、插件 RPC 共用同一权威(HOME 优先): + // 见 `peri_middlewares::plugin::claude_home`。 + let claude_dir = peri_middlewares::plugin::claude_home(); // ── 插件聚合数据(bare 时跳过;准备路径消费同一聚合,不重读插件目录)── let (prepared_data, prepared_skill_roots, prepared_agent_dirs) = match prepared_plugins { diff --git a/peri-acp/src/host/executor_flow_test.rs b/peri-acp/src/host/executor_flow_test.rs index ad3a7766d..5c543c013 100644 --- a/peri-acp/src/host/executor_flow_test.rs +++ b/peri-acp/src/host/executor_flow_test.rs @@ -110,6 +110,8 @@ impl MockEventSink { } /// 事件流快照(与 `push_done` 交错记录,用于断言 terminal 顺序)。 + /// 调用方都在 `host::mcp_v4_startup_tests`(unix 用例),Windows 上没有使用者。 + #[cfg_attr(windows, allow(dead_code))] pub(super) fn operations(&self) -> Vec { self.operations.lock().unwrap().clone() } diff --git a/peri-acp/src/host/mcp_v4_startup_test.rs b/peri-acp/src/host/mcp_v4_startup_test.rs index 618f3b4ae..5f31f764a 100644 --- a/peri-acp/src/host/mcp_v4_startup_test.rs +++ b/peri-acp/src/host/mcp_v4_startup_test.rs @@ -14,6 +14,13 @@ //! //! 配置经真实项目级 `.mcp.json` 与真实 `run_initialize` 装载,HOME 重定向到临时 //! 目录,测试不读取也不启动用户自己的 MCP 配置。 +//! +//! 平台范围:整组用例只在 unix 运行。隔离靠 `HOME` 重定向(`HomeRedirect`),而全局 +//! `~/.peri/settings.json` 由 `dirs_next::home_dir()` 解析——Windows 上它读 Profile +//! known-folder 并忽略 `HOME`(见 `host::assemble`),用例会装载运行者自己的 MCP 配置。 +//! 因此平台范围上移到模块级:Windows 上不再留下只被排除用例引用的死代码。 + +#![cfg(not(windows))] use std::{ ffi::OsString, @@ -436,7 +443,6 @@ fn assert_fatal_without_reason( /// transport / initialize 失败(`System MCP` 已 Failed)→ 首个 prompt fatal, /// 模型 0 次调用。文案只保留阶段类别,不含对端原文。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_transport_failure_fails_first_prompt_without_model_call() { @@ -474,7 +480,6 @@ async fn system_mcp_transport_failure_fails_first_prompt_without_model_call() { /// 连接与协商都好、但 live `tools/list` 未完成 → 仍不得 ready: /// 这是「`Connected` + 无工具清单」被读成 ready 的原始风险面,只有真实成功的 /// `tools/list` 才算发现证据(空数组是成功结果,未回应不是)。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_connected_without_tool_discovery_is_not_ready() { @@ -496,7 +501,6 @@ async fn system_mcp_connected_without_tool_discovery_is_not_ready() { /// `tools/list` 失败 → 不得被读成「空工具列表」:即使 `system_mcp_tools = []` /// 也必须 fatal(契约 4 的两条分支之一)。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_tool_discovery_failure_is_not_an_empty_tool_list() { @@ -525,7 +529,6 @@ async fn system_mcp_tool_discovery_failure_is_not_an_empty_tool_list() { /// 对端不响应 → deadline 到期是**终态失败**,不是取消:模型 0 次调用、 /// 不是 `Cancelled` stop reason、不是 `Interrupted` 终态。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_timeout_is_fatal_not_cancelled() { @@ -560,7 +563,6 @@ async fn system_mcp_timeout_is_fatal_not_cancelled() { } /// peer 断开(子进程收到请求即退出)→ 同样在 Reason 之前 fatal。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_disconnected_peer_fails_first_prompt() { @@ -588,7 +590,6 @@ async fn system_mcp_disconnected_peer_fails_first_prompt() { } /// 连接与 `tools/list` 都成功但缺必需工具 → 仍不得放行(all-or-nothing)。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_missing_required_tool_fails_before_reason() { @@ -614,7 +615,6 @@ async fn system_mcp_missing_required_tool_fails_before_reason() { /// 必需工具在第一个真实 LLM 请求中就直接可见(无需 ToolSearch),而普通 MCP 工具 /// 仍是 deferred(只出现在 deferred 摘要里)。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_ready_exposes_required_tools_on_first_model_request() { @@ -670,7 +670,6 @@ async fn system_mcp_ready_exposes_required_tools_on_first_model_request() { /// 契约 4:`system_mcp_tools = []` 只要求 ready,不注入额外工具—— /// 同一台 server 的工具全部保持 deferred。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_empty_required_tools_ready_without_injection() { @@ -711,7 +710,6 @@ async fn system_mcp_empty_required_tools_ready_without_injection() { /// ordinary MCP 永久 pending(对端不响应)时,prompt 仍到达模型;system 依赖满足 /// 即可放行。断言时 ordinary 仍**在连接中**,证明确实没有被等待。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn ordinary_mcp_pending_does_not_block_startup() { @@ -755,7 +753,6 @@ async fn ordinary_mcp_pending_does_not_block_startup() { /// ordinary MCP 直接初始化失败(未声明 `system_mcp`)同样不得阻塞启动:只有 /// `system_mcp == Some(true)` 的 server 是启动依赖。与「同一 fixture 声明为 system /// 即 fatal」形成对照,固定 fatal 由启动依赖判定产生,而非 fixture 失败本身。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn ordinary_mcp_failure_does_not_block_startup() { @@ -786,7 +783,6 @@ async fn ordinary_mcp_failure_does_not_block_startup() { /// 闸门位置固定:输入已被 Receive 接纳(transcript 出现该 human 消息),但既无 /// assistant 输出也无线工具调用——即「Receive 之后、Compact / Reason 之前」。 -#[cfg(not(windows))] #[tokio::test] #[serial] async fn system_mcp_gate_runs_after_receive_and_before_reason() { diff --git a/peri-acp/src/host/prediction.rs b/peri-acp/src/host/prediction.rs index ae75bd929..65481e4e6 100644 --- a/peri-acp/src/host/prediction.rs +++ b/peri-acp/src/host/prediction.rs @@ -18,6 +18,11 @@ pub(super) fn spawn_prediction( let pred_sessions = sessions.clone(); let pred_resources = cfg.session_resources.clone(); let pred_caps_registry = cfg.session_manager.caps_registry(); + // 宿主 scope 的关停信号:关停方(如 `session/close` 经 + // `SessionEnvironment::shutdown`)会持 sessions 锁等待本会话任务收摊,本任务 + // 若在关停开始后继续排队等同一把锁即互等,只能耗到协作宽限(5s)被强杀。 + // 预测动作与通知只对活跃会话有意义,关停开始即放弃本轮。 + let pred_shutdown = cfg.host_task_spawner.shutdown_token(); let _ = cfg.host_task_spawner.spawn( task_scope::HostTaskOwnerKind::Session, @@ -26,7 +31,10 @@ pub(super) fn spawn_prediction( tracing::debug!("Prediction task started"); // 从 session 获取最新历史与当前标题 let (history, cwd, current_title) = { - let sessions = pred_sessions.lock().await; + let Some(sessions) = lock_or_shutdown(&pred_sessions, &pred_shutdown).await else { + tracing::debug!("Prediction: host scope shutting down, dropping prediction"); + return; + }; match sessions.get(&pred_session_id) { Some(s) => (s.history.clone(), s.cwd.clone(), s.title.clone()), None => { @@ -74,7 +82,14 @@ pub(super) fn spawn_prediction( // 元数据动作写入 session 状态;标题变更待持久化并推送 let mut applied_title: Option = None; { - let mut sessions = pred_sessions.lock().await; + let Some(mut sessions) = + lock_or_shutdown(&pred_sessions, &pred_shutdown).await + else { + tracing::debug!( + "Prediction: host scope shutting down, dropping metadata actions" + ); + return; + }; if let Some(state) = sessions.get_mut(&pred_session_id) { for action in &actions { match action { @@ -168,3 +183,25 @@ pub(super) fn spawn_prediction( }, ); } + +/// 获取 sessions 锁,同时观察宿主 scope 的关停信号:关停开始即返回 `None`。 +/// +/// 关停由 `session/close` 等持有 sessions 锁的调用方发起 +/// (`SessionEnvironment::shutdown` → `HostTaskOwner::shutdown`),它们在同一段代码里 +/// 等待本会话任务收摊。任务若在关停开始后继续排队等同一把锁,双方即互等,只能被 +/// 协作宽限后的强杀解开。预测结果只服务于活跃会话,关停后丢弃是正确语义 +/// (`biased` 让关停判定优先于恰好空闲的锁,行为不随调度抖动)。 +async fn lock_or_shutdown<'a, T>( + sessions: &'a tokio::sync::Mutex, + shutdown: &tokio_util::sync::CancellationToken, +) -> Option> { + tokio::select! { + biased; + () = shutdown.cancelled() => None, + guard = sessions.lock() => Some(guard), + } +} + +#[cfg(test)] +#[path = "prediction_test.rs"] +mod tests; diff --git a/peri-acp/src/host/prediction_test.rs b/peri-acp/src/host/prediction_test.rs new file mode 100644 index 000000000..1d6a72f6e --- /dev/null +++ b/peri-acp/src/host/prediction_test.rs @@ -0,0 +1,39 @@ +use std::{sync::Arc, time::Duration}; + +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use super::lock_or_shutdown; + +/// [回归测试] 关停方持 sessions 锁等待任务收摊时,预测任务必须立刻放弃等锁。 +/// +/// 旧行为:任务继续排队等同一把锁;关停方(`session/close` → +/// `SessionEnvironment::shutdown`)在同一段代码里等它结束,双方互等,只能耗完 +/// 协作宽限(5s)被强杀——Windows 的 print 用例因此顶穿预算。 +#[tokio::test] +async fn test_lock_is_abandoned_once_shutdown_begins() { + let sessions = Arc::new(Mutex::new(0usize)); + // 关停路径:先持锁,再取消 scope(`HostTaskOwner::begin_shutdown`),然后等任务。 + let _holder = sessions.lock().await; + let shutdown = CancellationToken::new(); + let waiter = { + let sessions = Arc::clone(&sessions); + let shutdown = shutdown.clone(); + tokio::spawn(async move { lock_or_shutdown(&sessions, &shutdown).await.is_some() }) + }; + tokio::task::yield_now().await; + shutdown.cancel(); + + let acquired = tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("关停开始后预测任务不得继续等 sessions 锁(会与关停方互等到强杀)"); + assert!(!acquired.unwrap(), "关停开始即放弃本轮预测"); +} + +/// 未关停时锁照常取到:放弃只针对关停,不是把所有预测都丢掉。 +#[tokio::test] +async fn test_lock_is_taken_while_scope_is_open() { + let sessions = Mutex::new(7usize); + let guard = lock_or_shutdown(&sessions, &CancellationToken::new()).await; + assert_eq!(*guard.expect("未关停时应取到锁"), 7); +} diff --git a/peri-acp/src/host/prepared.rs b/peri-acp/src/host/prepared.rs index 50da0b408..c17732b5e 100644 --- a/peri-acp/src/host/prepared.rs +++ b/peri-acp/src/host/prepared.rs @@ -177,11 +177,11 @@ impl PreparedSessionInputs { )), Some(source) if source.bare => Ok((None, Vec::new(), Vec::new())), Some(_) => { - let claude_dir = dirs_next::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".claude"); - let data = super::assemble::discover_enabled_plugins_readonly(&claude_dir, cwd) - .map_err(|error| { + // 严格只读发现:用户级 `.claude` 由装配面解析(HOME 优先的唯一 + // 权威在 `plugin::claude_home`,见 `assemble` 函数 doc), + // 准备面只提供执行目录。 + let data = + super::assemble::discover_enabled_plugins_readonly(cwd).map_err(|error| { AcpError::new(-32603, format!("Plugin discovery failed: {error}")) })?; let skill_roots = data.all_skill_roots.clone(); diff --git a/peri-acp/src/host/requests_test.rs b/peri-acp/src/host/requests_test.rs index 531037fbc..f4f3d06c6 100644 --- a/peri-acp/src/host/requests_test.rs +++ b/peri-acp/src/host/requests_test.rs @@ -2395,12 +2395,13 @@ async fn test_safe_oauth_capability_rejects_callback_secrets_over_acp() { // ── Phase 6 B3:plugin install/uninstall RPC 级投影断言(P2-2)────────────── -/// 测试期重定向 `$HOME`(`handle_request` 内 `claude_dir` 由 -/// `dirs_next::home_dir()` 计算,`refresh_plugin_command_entries` 经真实 +/// 测试期重定向 `$HOME`(插件入口的 `claude_dir` 经 +/// `peri_middlewares::plugin::claude_home()` 计算:HOME 优先且要求绝对路径, +/// 两个平台都被本重定向覆盖;`refresh_plugin_command_entries` 经真实 /// `load_enabled_plugins` 重载);Drop 时还原。进程级 env 态 → -/// 本组用例全部 `#[serial]`(与 store_test 同组互斥)。 -/// Windows 下 `dirs_next::home_dir()` 读 `USERPROFILE`(`HOME` 仅 Unix 生效), -/// 两个变量同步设置以保证隔离。 +/// 本组用例全部 `#[serial]`(与 store_test 同组互斥)。Windows 一并设置 +/// `USERPROFILE`(与 `HOME` 同源;`dirs_next::home_dir()` 在该平台走 Profile +/// known-folder,不读环境变量,故不构成隔离手段)。 struct HomeDirGuard { home: Option, #[cfg(windows)] diff --git a/peri-js-runtime/Cargo.toml b/peri-js-runtime/Cargo.toml index 7f09527b5..7fda807b8 100644 --- a/peri-js-runtime/Cargo.toml +++ b/peri-js-runtime/Cargo.toml @@ -15,6 +15,7 @@ dashmap = "6" parking_lot.workspace = true tempfile.workspace = true fs2 = "0.4" +dirs-next.workspace = true peri-process = { path = "../peri-process" } [target.'cfg(unix)'.dev-dependencies] diff --git a/peri-js-runtime/src/artifact.rs b/peri-js-runtime/src/artifact.rs index a0e9f8fcf..63cee7ea3 100644 --- a/peri-js-runtime/src/artifact.rs +++ b/peri-js-runtime/src/artifact.rs @@ -68,9 +68,18 @@ impl NpmArtifactProvider { Self } + /// PTC artifact 的用户级主目录:`$HOME` 优先(且要求绝对路径),否则 + /// `dirs_next` 的平台主目录。 + /// + /// Windows 的 `dirs_next::home_dir()` 读 Profile known-folder,而 `HOME` + /// 通常不存在;只认 `HOME` 会让本地缓存(`~/.peri/ptc`)在该平台永远不可用。 + /// 与 `peri_middlewares` 的 `plugin::user_home`、`peri_workflow` 的 + /// `workflow_prefix` 保持同一语义(HOME 优先,否则平台主目录)。 fn home(&self) -> Result { std::env::var_os("HOME") .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(dirs_next::home_dir) .ok_or(JsRuntimeError::ArtifactUnavailable) } } @@ -149,15 +158,32 @@ impl Drop for InstallLock { } } +/// 安装锁文件路径。 +fn lock_path(parent: &Path) -> PathBuf { + parent.join(format!(".{PACKAGE_VERSION}.lock")) +} + +/// `try_lock_exclusive` 的失败是否只是「锁已被占用」(可重试),而非致命错误。 +/// +/// 争用的平台表达不同:Unix `flock` 报 `EWOULDBLOCK`(std 映射为 +/// `WouldBlock`),Windows `LockFileEx(LOCKFILE_FAIL_IMMEDIATELY)` 报 +/// `ERROR_LOCK_VIOLATION`(33),std 不把它映射成 `WouldBlock`。只比较 +/// `ErrorKind` 会把 Windows 的争用当成致命错误,并发安装与取消等待随之失败。 +fn is_lock_contended(error: &std::io::Error) -> bool { + match fs2::lock_contended_error().raw_os_error() { + Some(expected) => error.raw_os_error() == Some(expected), + None => error.kind() == std::io::ErrorKind::WouldBlock, + } +} + async fn acquire_lock(parent: &Path, cancel: &CancellationToken) -> Result { tokio::fs::create_dir_all(parent).await?; - let path = parent.join(format!(".{PACKAGE_VERSION}.lock")); let file = tokio::fs::OpenOptions::new() .create(true) .truncate(false) .read(true) .write(true) - .open(path) + .open(lock_path(parent)) .await? .into_std() .await; @@ -167,7 +193,7 @@ async fn acquire_lock(parent: &Path, cancel: &CancellationToken) -> Result return Ok(InstallLock(file)), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if is_lock_contended(&error) => {} Err(error) => return Err(error.into()), } tokio::select! { @@ -420,6 +446,30 @@ mod tests { } } + /// [回归] 锁争用必须判为「可重试」,而非致命错误。 + /// + /// Unix `flock` 报 `EWOULDBLOCK`,Windows `LockFileEx(FAIL_IMMEDIATELY)` 报 + /// `ERROR_LOCK_VIOLATION`(33);只看 `ErrorKind::WouldBlock` 会让 Windows 的 + /// 并发安装与取消等待直接失败。 + #[tokio::test] + async fn contended_file_lock_is_retryable_on_this_platform() { + let home = tempfile::tempdir().unwrap(); + let parent = home.path().join(".peri/ptc"); + let _owner = acquire_lock(&parent, &CancellationToken::new()) + .await + .unwrap(); + let contender = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(lock_path(&parent)) + .unwrap(); + let error = contender.try_lock_exclusive().unwrap_err(); + assert!( + is_lock_contended(&error), + "本平台的锁争用必须可重试: {error:?}" + ); + } + #[tokio::test] async fn concurrent_install_has_one_winner_and_reuses_target() { let home = tempfile::tempdir().unwrap(); diff --git a/peri-js-runtime/src/host.rs b/peri-js-runtime/src/host.rs index cfcbf201d..efea2dea1 100644 --- a/peri-js-runtime/src/host.rs +++ b/peri-js-runtime/src/host.rs @@ -241,8 +241,10 @@ mod tests { #[tokio::test] async fn captures_bounded_stderr_tail() { - let payload = "x".repeat(STDERR_TAIL_BYTES + 1024); - let script = format!("process.stderr.write('prefix-' + '{}');", payload); + // 载荷在 Node 内生成:Windows 命令行上限 32767 字符,33KB 脚本经 `-e` + // 传参会以 os error 206(文件名或扩展名过长)拒绝启动。 + let payload = STDERR_TAIL_BYTES + 1024; + let script = format!("process.stderr.write('prefix-' + 'x'.repeat({payload}));"); let host = JsExecutionHost::spawn(JsProcessSpec::new("node", vec!["-e".into(), script])).unwrap(); diff --git a/peri-js-runtime/src/rpc_test.rs b/peri-js-runtime/src/rpc_test.rs index 809074729..4096e949a 100644 --- a/peri-js-runtime/src/rpc_test.rs +++ b/peri-js-runtime/src/rpc_test.rs @@ -23,7 +23,7 @@ async fn test_notification_writes_newline_and_flushes_frame() { let mut child = tokio::process::Command::new("node") .args([ "-e", - "process.stdin.once('data', data => process.stdout.write(data));", + "process.stdout.write('ready\\n'); process.stdin.once('data', data => process.stdout.write(data));", ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -35,6 +35,19 @@ async fn test_notification_writes_newline_and_flushes_frame() { 4 * 1024 * 1024, ); let stdout = child.stdout.take().expect("stdout 应为 piped"); + let mut reader = BufReader::new(stdout); + + // 就绪信号与 echo 分开计时:解释器冷启动(Windows CI 高负载下可达数秒)不 + // 属于「写入即 flush」的语义,却会吃掉同一份预算。行为断言仍是严格的 1s。 + let mut ready = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(30), + reader.read_line(&mut ready), + ) + .await + .expect("node 应在启动预算内就绪") + .unwrap(); + assert_eq!(ready, "ready\n"); channel .send_notification("test/event", serde_json::json!({"value": 1})) @@ -44,7 +57,7 @@ async fn test_notification_writes_newline_and_flushes_frame() { let mut line = String::new(); tokio::time::timeout( std::time::Duration::from_secs(1), - BufReader::new(stdout).read_line(&mut line), + reader.read_line(&mut line), ) .await .expect("完整 NDJSON frame 应被及时 flush") diff --git a/peri-lsp/src/client_document_test.rs b/peri-lsp/src/client_document_test.rs index 3467ec9c3..f21ded66c 100644 --- a/peri-lsp/src/client_document_test.rs +++ b/peri-lsp/src/client_document_test.rs @@ -1,8 +1,14 @@ //! 文档同步跨真实握手、writer 背压、连接更换的契约回归。 use super::*; +use crate::uri::test_workspace_uri; use std::{future::Future, path::Path, pin::Pin, task::Poll, time::Duration}; +/// 真实子进程交互的 liveness 预算,与客户端默认启动预算同源。 +/// gate/barrier 等待只协调真实 wire 边界,不是计时契约:CI 上首批并发用例 +/// 落在冷启动与构建产物扫描窗口内,固定 5s 会把平台启动成本误报成契约失败。 +const LIVENESS_BUDGET_MS: u64 = DEFAULT_STARTUP_TIMEOUT_MS; + const SCRIPT: &str = r#" binmode STDIN; binmode STDOUT; select STDOUT; $| = 1; while (1) { @@ -45,13 +51,13 @@ fn make_client(dir: &Path, gate_init: bool) -> Arc { ]), None, 3, - 5_000, + LIVENESS_BUDGET_MS, Arc::new(DiagnosticsRegistry::new()), )) } async fn wait_until(mut ready: impl FnMut() -> bool) { - tokio::time::timeout(Duration::from_secs(5), async { + tokio::time::timeout(Duration::from_millis(LIVENESS_BUDGET_MS), async { while !ready() { tokio::time::sleep(Duration::from_millis(2)).await; } @@ -127,7 +133,7 @@ async fn test_document_sync_not_ready_does_not_hide_the_first_open() { let client = make_client(dir.path(), true); let start = tokio::spawn({ let client = client.clone(); - async move { client.start("file:///tmp").await } + async move { client.start(&test_workspace_uri()).await } }); wait_until(|| dir.path().join("initialize").exists()).await; let open = client @@ -146,7 +152,10 @@ async fn test_document_sync_not_ready_does_not_hide_the_first_open() { .did_change("file:///tmp/change.rs", "accepted-change") .await .unwrap(); - client.request("barrier", None, 5_000).await.unwrap(); + client + .request("barrier", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); let records = documents(dir.path()); client.shutdown().await; assert!(matches!(open, Err(LspError::NotReady { .. }))); @@ -169,7 +178,7 @@ async fn test_document_sync_not_ready_does_not_hide_the_first_open() { async fn test_document_sync_cancel_before_admission_preserves_cache_and_version() { let dir = tempfile::tempdir().unwrap(); let client = make_client(dir.path(), false); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); client .did_open("file:///tmp/existing.rs", "rust", "initial") .await @@ -185,7 +194,10 @@ async fn test_document_sync_cancel_before_admission_preserves_cache_and_version( } drop(senders); std::fs::write(dir.path().join("release-input"), b"").unwrap(); - client.request("drained", None, 5_000).await.unwrap(); + client + .request("drained", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); client .did_open("file:///tmp/new.rs", "rust", "accepted-open") .await @@ -194,7 +206,10 @@ async fn test_document_sync_cancel_before_admission_preserves_cache_and_version( .did_change("file:///tmp/existing.rs", "accepted-change") .await .unwrap(); - client.request("barrier", None, 5_000).await.unwrap(); + client + .request("barrier", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); let records = documents(dir.path()); client.shutdown().await; assert_eq!( @@ -220,18 +235,21 @@ async fn test_document_sync_cancel_before_admission_preserves_cache_and_version( async fn test_document_sync_old_connection_cannot_publish_into_restarted_cache() { let dir = tempfile::tempdir().unwrap(); let client = make_client(dir.path(), false); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); let senders = fill_writer(&client, dir.path()).await; let mut old_open = Box::pin(client.did_open("file:///tmp/restarted.rs", "rust", "old-content")); poll_pending(old_open.as_mut()).await; - client.try_restart("file:///tmp").await.unwrap(); + client.try_restart(&test_workspace_uri()).await.unwrap(); let old_result = old_open.await; drop(senders); client .did_change("file:///tmp/restarted.rs", "new-content") .await .unwrap(); - client.request("barrier", None, 5_000).await.unwrap(); + client + .request("barrier", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); let records = documents(dir.path()); client.shutdown().await; assert!(matches!(old_result, Err(LspError::TransportClosed))); @@ -246,7 +264,7 @@ async fn test_document_sync_old_connection_cannot_publish_into_restarted_cache() async fn test_document_sync_cancel_after_admission_keeps_the_first_open() { let dir = tempfile::tempdir().unwrap(); let client = make_client(dir.path(), false); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); let blocked = block_writer(&client, dir.path()).await; { let mut open = @@ -263,12 +281,18 @@ async fn test_document_sync_cancel_after_admission_keeps_the_first_open() { } drop(blocked); std::fs::write(dir.path().join("release-input"), b"").unwrap(); - client.request("drained", None, 5_000).await.unwrap(); + client + .request("drained", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); client .did_open("file:///tmp/admitted.rs", "rust", "duplicate-content") .await .unwrap(); - client.request("barrier", None, 5_000).await.unwrap(); + client + .request("barrier", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); let records = documents(dir.path()); client.shutdown().await; assert_eq!(records.len(), 1, "取消确认等待不应撤销已经发送的 didOpen"); diff --git a/peri-lsp/src/client_lifecycle_test.rs b/peri-lsp/src/client_lifecycle_test.rs index 577289c18..5383b116e 100644 --- a/peri-lsp/src/client_lifecycle_test.rs +++ b/peri-lsp/src/client_lifecycle_test.rs @@ -1,6 +1,12 @@ use super::*; +use crate::uri::test_workspace_uri; use std::path::Path; +/// 真实子进程交互的 liveness 预算,与客户端默认启动预算同源。 +/// gate 等待只协调真实 wire 边界,不是计时契约:CI 上首批并发用例 +/// 落在冷启动与构建产物扫描窗口内,固定 5s 会把平台启动成本误报成契约失败。 +const LIVENESS_BUDGET_MS: u64 = DEFAULT_STARTUP_TIMEOUT_MS; + // 文件 gate 只协调真实 wire 边界;watchdog 不作为正确性计时断言。 const SCRIPT: &str = r#" open my $pid, '>', "$ENV{FIXTURE}/pid" or exit 1; @@ -50,17 +56,20 @@ fn make_client(dir: &Path, gate_init: bool) -> Arc { ]), None, 3, - 5_000, + LIVENESS_BUDGET_MS, Arc::new(DiagnosticsRegistry::new()), )) } async fn wait_for_file(path: &Path) { - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while !path.exists() { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - }) + tokio::time::timeout( + std::time::Duration::from_millis(LIVENESS_BUDGET_MS), + async { + while !path.exists() { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }, + ) .await .expect("真实服务器未到达预期协议边界"); } @@ -151,7 +160,7 @@ close $marker; HashMap::from([("FIXTURE".into(), cwd.to_str().unwrap().into())]), None, 3, - 5_000, + LIVENESS_BUDGET_MS, Arc::new(DiagnosticsRegistry::new()), ); let uri = crate::uri::path_to_uri(&cwd); @@ -159,11 +168,14 @@ close $marker; client.try_restart(&uri).await.unwrap(); client.shutdown().await; let starts = std::fs::read_to_string(cwd.join("starts")).unwrap(); - let expected = std::fs::canonicalize(cwd).unwrap(); - assert_eq!( - starts.lines().collect::>(), - vec![expected.to_str().unwrap(); 2] - ); + // 两侧都 canonicalize 后比较:Windows 的 canonicalize 返回 verbatim(`\\?\`) + // 盘符路径并展开 8.3 短名,而 `getcwd` 原样回显传入的 cwd,直接比较必然不等。 + let expected = std::fs::canonicalize(&cwd).unwrap(); + let reported: Vec<_> = starts + .lines() + .map(|line| std::fs::canonicalize(line).unwrap()) + .collect(); + assert_eq!(reported, vec![expected; 2]); } } @@ -174,11 +186,12 @@ async fn test_start_waits_for_initialize_before_publishing_readiness() { let client = make_client(dir.path(), true); let first = tokio::spawn({ let client = client.clone(); - async move { client.start("file:///tmp").await } + async move { client.start(&test_workspace_uri()).await } }); wait_for_file(&dir.path().join("initialize")).await; let premature_ready = client.is_ready(); - let second = client.start("file:///tmp"); + let uri = test_workspace_uri(); + let second = client.start(&uri); tokio::pin!(second); let premature_return = tokio::select! { biased; @@ -200,7 +213,7 @@ async fn test_start_waits_for_initialize_before_publishing_readiness() { async fn test_cancelled_request_releases_pending_registration() { let dir = tempfile::tempdir().unwrap(); let client = make_client(dir.path(), false); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); let request = tokio::spawn({ let client = client.clone(); async move { client.request("hang", None, 30_000).await } @@ -217,7 +230,10 @@ async fn test_cancelled_request_releases_pending_registration() { .dispatcher .dispatch_state() .pending_len(); - let next = client.request("next", None, 5_000).await.unwrap(); + let next = client + .request("next", None, LIVENESS_BUDGET_MS) + .await + .unwrap(); client.shutdown().await; assert_eq!(pending, 0, "取消请求后不能等待整条连接关闭才移除登记"); assert_eq!(next, Value::Null, "取消旧请求后连接仍能处理完整的新请求"); @@ -231,7 +247,7 @@ async fn test_cancelled_start_releases_connection_and_child() { let client = make_client(dir.path(), true); let start = tokio::spawn({ let client = client.clone(); - async move { client.start("file:///tmp").await } + async move { client.start(&test_workspace_uri()).await } }); wait_for_file(&dir.path().join("initialize")).await; let pid = std::fs::read_to_string(dir.path().join("pid")).unwrap(); @@ -258,7 +274,7 @@ async fn test_cancelled_start_releases_connection_and_child() { .await .expect("启动取消后子进程应被回收"); std::fs::write(dir.path().join("release"), b"").unwrap(); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); assert!(client.is_ready(), "取消后的下一次启动可重新完成握手"); client.shutdown().await; } @@ -272,7 +288,7 @@ async fn test_cancelled_shutdown_rejects_old_requests_and_can_finish_cleanup() { .unwrap() .env .insert("GATE_SHUTDOWN".into(), "1".into()); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); let request = tokio::spawn({ let client = client.clone(); async move { client.request("hang", None, 30_000).await } diff --git a/peri-lsp/src/client_test.rs b/peri-lsp/src/client_test.rs index 9c8adf3a3..adb601fec 100644 --- a/peri-lsp/src/client_test.rs +++ b/peri-lsp/src/client_test.rs @@ -6,6 +6,7 @@ use super::*; use crate::diagnostics::DiagnosticsRegistry; use crate::error::LspError; use crate::protocol::lsp_types::PublishDiagnosticsParams; +use crate::uri::test_workspace_uri; /// perl 编写的极简 LSP 服务器: /// - 每次 spawn 向 `$PERI_LSP_TEST_COUNT` 文件追加一行 "spawned"(用于断言 spawn 次数) @@ -226,7 +227,7 @@ async fn test_start_handshake_ok() { let count_file = dir.path().join("spawn_count.txt"); let client = make_fake_client(&count_file); - let result = client.start("file:///tmp").await; + let result = client.start(&test_workspace_uri()).await; assert!(result.is_ok(), "start 应完成握手: {:?}", result.err()); assert_eq!(spawn_count(&count_file), 1); assert!(client.is_ready()); @@ -249,7 +250,7 @@ async fn test_start_uses_configured_startup_timeout() { Arc::new(DiagnosticsRegistry::new()), ); - let err = client.start("file:///tmp").await.unwrap_err(); + let err = client.start(&test_workspace_uri()).await.unwrap_err(); assert!( matches!( err, @@ -280,7 +281,7 @@ async fn test_request_timeout_cleans_pending() { ); // initialize 请求 100ms 超时(慢服务器 3s 才响应) - let err = client.start("file:///tmp").await.unwrap_err(); + let err = client.start(&test_workspace_uri()).await.unwrap_err(); assert!( matches!(err, LspError::RequestTimeout { .. }), "慢服务器 + 短超时应触发超时: {err:?}" @@ -302,7 +303,8 @@ async fn test_concurrent_start_spawns_once() { let count_file = dir.path().join("spawn_count.txt"); let client = make_fake_client(&count_file); - let (r1, r2) = tokio::join!(client.start("file:///tmp"), client.start("file:///tmp")); + let uri = test_workspace_uri(); + let (r1, r2) = tokio::join!(client.start(&uri), client.start(&uri)); assert!(r1.is_ok(), "第一个 start 失败: {:?}", r1.err()); assert!(r2.is_ok(), "第二个 start 失败: {:?}", r2.err()); @@ -321,7 +323,7 @@ async fn test_did_open_idempotent_with_first_content() { // 同一 uri 重复 did_open 只发送一次通知,且携带首次传入的文本 let dir = tempfile::tempdir().unwrap(); let (client, didopen_file) = make_recording_client(dir.path()); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); client .did_open("file:///tmp/main.rs", "rust", "fn main() {}") @@ -352,7 +354,7 @@ async fn test_try_restart_resets_open_cache() { // try_restart 后 open_files 缓存清空,同一 uri 再次 did_open 应重新发送 let dir = tempfile::tempdir().unwrap(); let (client, didopen_file) = make_recording_client(dir.path()); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); client .did_open("file:///tmp/main.rs", "rust", "v1") @@ -361,7 +363,7 @@ async fn test_try_restart_resets_open_cache() { wait_for_didopen(&didopen_file, 1).await; assert_eq!(didopen_count(&didopen_file), 1); - client.try_restart("file:///tmp").await.unwrap(); + client.try_restart(&test_workspace_uri()).await.unwrap(); client .did_open("file:///tmp/main.rs", "rust", "v2") .await @@ -386,13 +388,13 @@ async fn test_restart_window_cooldown() { let count_file = dir.path().join("spawn_count.txt"); let client = make_fake_client(&count_file); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); for _ in 0..3 { - client.try_restart("file:///tmp").await.unwrap(); + client.try_restart(&test_workspace_uri()).await.unwrap(); } let spawns_before = spawn_count(&count_file); - let err = client.try_restart("file:///tmp").await.unwrap_err(); + let err = client.try_restart(&test_workspace_uri()).await.unwrap_err(); assert!( matches!( err, @@ -423,16 +425,16 @@ async fn test_restart_window_expiry_resets_count() { let mut client = make_fake_client(&count_file); client.restart_window = std::time::Duration::from_secs(2); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); for _ in 0..3 { - client.try_restart("file:///tmp").await.unwrap(); + client.try_restart(&test_workspace_uri()).await.unwrap(); } - let err = client.try_restart("file:///tmp").await.unwrap_err(); + let err = client.try_restart(&test_workspace_uri()).await.unwrap_err(); assert!(matches!(err, LspError::ServerCrashed { .. })); // 等待窗口过期(窗口 + 100ms 缓冲),冷却解除 tokio::time::sleep(client.restart_window + std::time::Duration::from_millis(100)).await; - client.try_restart("file:///tmp").await.unwrap(); + client.try_restart(&test_workspace_uri()).await.unwrap(); assert!(client.is_ready(), "窗口过后冷却解除,应能重启成功"); client.shutdown().await; @@ -459,7 +461,7 @@ async fn test_try_restart_clears_diagnostics() { DEFAULT_STARTUP_TIMEOUT_MS, Arc::clone(&diagnostics), ); - client.start("file:///tmp").await.unwrap(); + client.start(&test_workspace_uri()).await.unwrap(); diagnostics.handle_publish_diagnostics(&PublishDiagnosticsParams { uri: "file:///tmp/main.rs".parse().unwrap(), @@ -483,7 +485,7 @@ async fn test_try_restart_clears_diagnostics() { }); assert!(!diagnostics.get_all().is_empty(), "前置条件:诊断应非空"); - client.try_restart("file:///tmp").await.unwrap(); + client.try_restart(&test_workspace_uri()).await.unwrap(); assert!(diagnostics.get_all().is_empty(), "重启后旧诊断应被清空"); client.shutdown().await; @@ -567,7 +569,7 @@ async fn test_start_failure_initialize_kills_child() { let pid_file = dir.path().join("server.pid"); let client = make_pid_tracking_client(&pid_file, SLOW_LSP_WITH_PID_SCRIPT, 200); - let err = client.start("file:///tmp").await.unwrap_err(); + let err = client.start(&test_workspace_uri()).await.unwrap_err(); assert!( matches!( err, @@ -592,9 +594,15 @@ async fn test_start_failure_notify_kills_child() { // read task 不会因 EOF 触发清理)——只有失败路径的主动清理能终止它 let dir = tempfile::tempdir().unwrap(); let pid_file = dir.path().join("server.pid"); - let client = make_pid_tracking_client(&pid_file, CLOSE_STDIN_AFTER_INIT_SCRIPT, 5_000); + // 握手预算与其余进程型用例同源:断言的是 initialized 通知的 IO 失败, + // 不能让 5s 预算把冷启动延迟伪装成 initialize 超时。 + let client = make_pid_tracking_client( + &pid_file, + CLOSE_STDIN_AFTER_INIT_SCRIPT, + DEFAULT_STARTUP_TIMEOUT_MS, + ); - let err = client.start("file:///tmp").await.unwrap_err(); + let err = client.start(&test_workspace_uri()).await.unwrap_err(); assert!( matches!(err, LspError::Io(_)), "stdin 关闭后 initialized 通知应 IO 失败: {err:?}" diff --git a/peri-lsp/src/pool_test.rs b/peri-lsp/src/pool_test.rs index b8c823885..8a9f6c96f 100644 --- a/peri-lsp/src/pool_test.rs +++ b/peri-lsp/src/pool_test.rs @@ -48,9 +48,19 @@ fn make_config() -> LspConfigFile { } } +/// pool 的 root/cwd:平台临时目录,保证在运行平台上真实存在。 +/// +/// pool 用 `root_uri` 解码出的路径作为 LSP 子进程的 cwd;测试曾用 `"/tmp"`, +/// Windows 上它解码为 `<当前盘>:\tmp`(通常不存在),spawn 直接失败。 +fn fake_pool_root() -> String { + crate::uri::test_workspace_dir() + .to_string_lossy() + .into_owned() +} + #[test] fn test_extension_routing() { - let pool = LspServerPool::new("/tmp", make_config()); + let pool = LspServerPool::new(&fake_pool_root(), make_config()); assert!(pool.server_for_file("/test/main.rs").is_some()); assert!(pool.server_for_file("/test/index.ts").is_some()); assert!(pool.server_for_file("/test/App.tsx").is_some()); @@ -74,7 +84,7 @@ impl LspPoolPort for StubPool { /// 还原为同一 LspServerPool 实例(装配面会话级复用前置条件,H1)。 #[test] fn test_lsp_pool_port_downcast_roundtrip() { - let pool: Arc = Arc::new(LspServerPool::new("/tmp", make_config())); + let pool: Arc = Arc::new(LspServerPool::new(&fake_pool_root(), make_config())); let port: Arc = pool.clone(); let restored = match port.downcast_arc::() { Ok(restored) => restored, @@ -100,7 +110,7 @@ async fn test_lsp_pool_port_downcast_mismatch_returns_original() { #[test] fn test_case_insensitive_extension() { - let pool = LspServerPool::new("/tmp", make_config()); + let pool = LspServerPool::new(&fake_pool_root(), make_config()); assert!(pool.server_for_file("/test/main.RS").is_some()); assert!(pool.server_for_file("/test/main.TS").is_some()); } @@ -113,26 +123,26 @@ fn test_disabled_server() { .get_mut("rust-analyzer") .unwrap() .disabled = Some(true); - let pool = LspServerPool::new("/tmp", config); + let pool = LspServerPool::new(&fake_pool_root(), config); assert!(pool.server_for_file("/test/main.rs").is_none()); } #[test] fn test_has_servers() { - let pool = LspServerPool::new("/tmp", make_config()); + let pool = LspServerPool::new(&fake_pool_root(), make_config()); assert!(pool.has_servers()); } #[test] fn test_empty_config() { - let pool = LspServerPool::new("/tmp", LspConfigFile::default()); + let pool = LspServerPool::new(&fake_pool_root(), LspConfigFile::default()); assert!(!pool.has_servers()); assert!(pool.server_for_file("/test/main.rs").is_none()); } #[tokio::test] async fn test_ensure_server_for_file_no_match() { - let pool = LspServerPool::new("/tmp", make_config()); + let pool = LspServerPool::new(&fake_pool_root(), make_config()); // .md 文件没有匹配的 LSP 服务器 let result = pool.ensure_server_for_file("/test/readme.md").await; assert!(result.is_err()); @@ -219,7 +229,7 @@ fn make_fake_pool(count_file: &std::path::Path) -> LspServerPool { }, ); LspServerPool::new( - "/tmp", + &fake_pool_root(), LspConfigFile { lsp_servers: servers, }, @@ -285,7 +295,7 @@ fn make_fake_pool_with_pid( }, ); LspServerPool::new( - "/tmp", + &fake_pool_root(), LspConfigFile { lsp_servers: servers, }, diff --git a/peri-lsp/src/uri.rs b/peri-lsp/src/uri.rs index 7987d8434..aa0a1793c 100644 --- a/peri-lsp/src/uri.rs +++ b/peri-lsp/src/uri.rs @@ -146,6 +146,24 @@ fn hex_val(b: u8) -> Option { } } +/// 测试用工作区根目录:平台临时目录,保证在运行平台上真实存在。 +/// +/// 该目录会成为 LSP 服务子进程的 cwd(`LspTransport::spawn` 的 `current_dir`)。 +/// 测试曾用 `file:///tmp`:Windows 上它解码为 `<当前盘>:\tmp`,这个目录并不存在, +/// 无效的 cwd 让 spawn 直接失败(`LaunchFailed`),依赖真实握手或子进程探活的 +/// 用例随之失败;`jsonrpc::transport_tests` 因使用 `std::env::temp_dir()` 一直正常。 +#[cfg(test)] +pub(crate) fn test_workspace_dir() -> std::path::PathBuf { + std::env::temp_dir() +} + +/// 测试用工作区根 URI:`test_workspace_dir` 的 `file://` 形式,供 +/// `LspClient::start` / `try_restart` 使用。 +#[cfg(test)] +pub(crate) fn test_workspace_uri() -> String { + path_to_uri(&test_workspace_dir()) +} + #[cfg(test)] #[path = "uri_test.rs"] mod tests; diff --git a/peri-middlewares/src/agents_md/mod.rs b/peri-middlewares/src/agents_md/mod.rs index 290048e2d..1e9d93f43 100644 --- a/peri-middlewares/src/agents_md/mod.rs +++ b/peri-middlewares/src/agents_md/mod.rs @@ -133,9 +133,7 @@ impl AgentsMdMiddleware { cwd.join(".claude").join("AGENTS.md"), ]; - if let Some(home) = dirs_next::home_dir() { - candidates.push(home.join(".claude").join("AGENTS.md")); - } + candidates.push(crate::plugin::claude_home().join("AGENTS.md")); candidates.extend(self.extra_search_paths.iter().cloned()); diff --git a/peri-middlewares/src/hooks/loader.rs b/peri-middlewares/src/hooks/loader.rs index e4c727af9..ca826af56 100644 --- a/peri-middlewares/src/hooks/loader.rs +++ b/peri-middlewares/src/hooks/loader.rs @@ -109,14 +109,11 @@ pub(crate) fn extract_hooks(manifest: &PluginManifest, install_path: &Path) -> O /// Load hooks from `~/.claude/settings.json` global `hooks` field. /// /// Returns a list of `RegisteredHook` with `plugin_name = "settings.json"`. +/// +/// 目录经 [`crate::plugin::claude_home`] 解析(HOME 优先的唯一权威),与 +/// [`is_user_settings_path`] 的排除判定同源。 pub fn load_global_settings_hooks() -> Vec { - let claude_dir = match dirs_next::home_dir() { - Some(d) => d.join(".claude"), - None => { - tracing::warn!("Cannot determine home directory for global hooks"); - return Vec::new(); - } - }; + let claude_dir = crate::plugin::claude_home(); let settings_path = claude_dir.join("settings.json"); if !settings_path.exists() { tracing::warn!("No settings.json at {}", settings_path.display()); @@ -341,17 +338,16 @@ pub fn load_settings_project_hooks(cwd: &str) -> Vec { /// `path` 是否就是用户级 `~/.claude/settings.json`。无法确定主目录时视为不是。 /// -/// 主目录解析须与 `load_global_settings_hooks` 同源,否则排除会认错文件。 +/// 主目录解析须与 `load_global_settings_hooks` 同源([`crate::plugin::user_home`], +/// HOME 优先),否则排除会认错文件。 fn is_user_settings_path(path: &Path) -> bool { - dirs_next::home_dir().is_some_and(|home| is_user_settings_path_under(path, &home)) + is_user_settings_path_under(path, &crate::plugin::user_home()) } /// 同上判定,但主目录由调用方给出:字面相同,或经符号链接指向同一文件 /// (macOS `$HOME` 为链接、`/var` → `/private/var` 等)。 /// -/// 拆出该入口是为了让排除规则在 Windows 上也可验证——`dirs_next::home_dir()` -/// 在 Windows 走 Profile known-folder(`SHGetKnownFolderPath`),不读 -/// `HOME`/`USERPROFILE`,测试无法把 `~` 改写成临时目录。 +/// 拆出该入口让排除规则能直接以显式主目录验证,不必依赖进程环境。 fn is_user_settings_path_under(path: &Path, home: &Path) -> bool { let user_path = home.join(".claude").join("settings.json"); if path == user_path { diff --git a/peri-middlewares/src/hooks/loader_test.rs b/peri-middlewares/src/hooks/loader_test.rs index 6ec32389b..7a33c1aaf 100644 --- a/peri-middlewares/src/hooks/loader_test.rs +++ b/peri-middlewares/src/hooks/loader_test.rs @@ -517,13 +517,9 @@ fn write_hooks_settings(dir: &Path) { /// `~/.claude/settings.json` 是同一个文件,项目级加载必须跳过——否则同一份 /// hooks 会注册成 global 与 project 两组而执行两次。子目录仍按项目级加载。 /// -/// Windows 跳过:该平台 `dirs_next::home_dir()` 走 Profile known-folder -/// (`SHGetKnownFolderPath`),不读 `HOME`/`USERPROFILE`,`HomeGuard` 注入的临时 -/// `~` 不生效。路径判定本身由 `test_is_user_settings_path_under_*` 覆盖。 -#[cfg_attr( - windows, - ignore = "dirs_next::home_dir() 在 Windows 不读 HOME/USERPROFILE,无法注入临时主目录" -)] +/// 主目录经 `plugin::user_home`(HOME 优先)解析,`HomeGuard` 注入的临时 `~` +/// 在两个平台都生效(旧实现走 `dirs_next::home_dir()`,Windows 上读 Profile +/// known-folder 而不读环境变量,该平台只能跳过)。 #[test] fn test_project_hooks_skipped_when_cwd_is_home() { let tmp = tempdir().unwrap(); diff --git a/peri-middlewares/src/plugin/config.rs b/peri-middlewares/src/plugin/config.rs index 0e4cc21de..219e85b31 100644 --- a/peri-middlewares/src/plugin/config.rs +++ b/peri-middlewares/src/plugin/config.rs @@ -102,19 +102,28 @@ pub enum PluginConfigError { }, } -/// 返回 `~/.claude/` 根目录,不存在时返回 fallback(当前目录)。 +/// 返回用户主目录:`$HOME` 优先(且要求绝对路径),否则 `dirs_next::home_dir()`, +/// 都不可得时用当前目录。 /// -/// home 解析优先 `$HOME`(且要求绝对路径):跨平台工具惯例,Windows 下 -/// git-bash 与 CI/测试注入(USERPROFILE 之外)都依赖 HOME 生效; -/// `dirs_next::home_dir()` 在 Windows 2.0.0 读 Profile known-folder -/// (SHGetKnownFolderPath),完全忽略 HOME/USERPROFILE 环境变量。 -pub fn claude_home() -> PathBuf { +/// **用户级主目录的唯一权威**:`~/.claude` 下的 settings / skills / hooks / +/// AGENTS.md 与插件目录全部由本函数派生,各入口不得再拼一份——Windows 的 +/// `dirs_next::home_dir()` 在 2.0.0 读 Profile known-folder +/// (SHGetKnownFolderPath),完全忽略 HOME/USERPROFILE 环境变量,而跨平台工具 +/// 惯例、git-bash 与 CI/测试注入都依赖 HOME 生效;各拼一份会在 Windows 上取到 +/// 不同目录(插件入口按 HOME、其余入口按 profile)。 +pub fn user_home() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) .or_else(dirs_next::home_dir) .unwrap_or_else(|| PathBuf::from(".")) - .join(".claude") +} + +/// 返回 `~/.claude/` 根目录,不存在时返回 fallback(当前目录)。 +/// +/// 主目录解析见 [`user_home`]。 +pub fn claude_home() -> PathBuf { + user_home().join(".claude") } /// 返回 `~/.claude/plugins/` 目录 diff --git a/peri-middlewares/src/plugin/config_test.rs b/peri-middlewares/src/plugin/config_test.rs index 8cf84bfa0..d3836dbe3 100644 --- a/peri-middlewares/src/plugin/config_test.rs +++ b/peri-middlewares/src/plugin/config_test.rs @@ -262,6 +262,24 @@ fn test_plugins_dir_uses_claude_home() { assert!(path_str.contains("plugins")); } +/// `claude_home()` 的 HOME 优先契约:Windows 的 `dirs_next::home_dir()` 走 Profile +/// known-folder,不读 HOME/USERPROFILE,只有 HOME 维度能重定向 `~/.claude`;插件目录 +/// 解析依赖这一点(各入口各拼一份 home 会在 Windows 上分叉),故在此钉住—— +/// 断言跨平台可复现,不必等到 Windows CI。 +#[test] +fn test_claude_home_prefers_absolute_home_env() { + let _process_env = crate::process_env::lock().expect("process env lock"); + let home = tempdir().unwrap(); + let previous = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + let resolved = claude_home(); + match previous { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + assert_eq!(resolved, home.path().join(".claude")); +} + #[test] fn test_ensure_plugin_dirs_creates_missing_dirs() { // 模拟无 CC 环境:空临时目录下验证 ensure_plugin_dirs 创建所有子目录 diff --git a/peri-middlewares/src/plugin/mod.rs b/peri-middlewares/src/plugin/mod.rs index 02c49f23d..0b7c2c13b 100644 --- a/peri-middlewares/src/plugin/mod.rs +++ b/peri-middlewares/src/plugin/mod.rs @@ -10,7 +10,7 @@ pub use config::{ claude_home, claude_settings_path, installed_plugins_path, known_marketplaces_path, load_claude_settings, load_installed_plugins, load_known_marketplaces, load_plugin_manifest, marketplaces_cache_dir, plugin_cache_dir, plugins_dir, save_claude_settings_enabled_plugins, - save_installed_plugins, save_known_marketplaces, ClaudeSettings, PluginConfigError, + save_installed_plugins, save_known_marketplaces, user_home, ClaudeSettings, PluginConfigError, }; pub use install_counts::{ fetch_install_counts, format_install_count, is_install_counts_cache_valid, load_install_counts, diff --git a/peri-middlewares/src/skills/loader.rs b/peri-middlewares/src/skills/loader.rs index 2ca8fc783..a95ca0429 100644 --- a/peri-middlewares/src/skills/loader.rs +++ b/peri-middlewares/src/skills/loader.rs @@ -411,14 +411,12 @@ pub fn resolve_skill_roots( ) -> Vec { let mut roots = Vec::new(); - // 1. User - if let Some(h) = dirs_next::home_dir() { - roots.push(SkillRoot { - path: h.join(".claude").join("skills"), - source: SkillSource::User, - plugin_name: None, - }); - } + // 1. User(主目录经 plugin::claude_home 解析,HOME 优先的唯一权威) + roots.push(SkillRoot { + path: crate::plugin::claude_home().join("skills"), + source: SkillSource::User, + plugin_name: None, + }); // 2. Global(~/.peri/settings.json::skillsDir) if let Some(dir) = crate::skills::load_global_skills_dir() { diff --git a/peri-middlewares/src/skills/loader_test.rs b/peri-middlewares/src/skills/loader_test.rs index d7049ee4d..fb9bc2672 100644 --- a/peri-middlewares/src/skills/loader_test.rs +++ b/peri-middlewares/src/skills/loader_test.rs @@ -513,6 +513,34 @@ fn test_resolve_skill_roots_returns_standard_paths() { ); } +/// User root 必须跟随 `$HOME`(主目录唯一权威 `plugin::user_home`,HOME 优先): +/// Windows 的 `dirs_next::home_dir()` 读 Profile known-folder、不读 HOME,各入口 +/// 各拼一份会让 User 技能目录与插件目录分叉。 +#[test] +fn test_resolve_skill_roots_user_root_follows_home_env() { + let _process_env = crate::process_env::lock().expect("process env lock"); + let home = tempdir().unwrap(); + let previous = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + let roots = resolve_skill_roots("/tmp/test-project", vec![], false); + match previous { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + let expected = home.path().join(".claude").join("skills"); + assert!( + roots + .iter() + .any(|root| root.source == SkillSource::User && root.path == expected), + "User root 应跟随 HOME,实得 {:?}", + roots + .iter() + .filter(|root| root.source == SkillSource::User) + .map(|root| root.path.display().to_string()) + .collect::>() + ); +} + #[test] fn test_resolve_skill_roots_includes_plugin_roots() { let extra = tempfile::tempdir().unwrap(); diff --git a/peri-middlewares/src/skills/mod.rs b/peri-middlewares/src/skills/mod.rs index 2bc34ea90..e4c1b9db6 100644 --- a/peri-middlewares/src/skills/mod.rs +++ b/peri-middlewares/src/skills/mod.rs @@ -302,11 +302,10 @@ impl SkillsMiddleware { { let mut roots = Vec::new(); // User override - let user_dir = self.user_skills_dir.clone().unwrap_or_else(|| { - dirs_next::home_dir() - .map(|h| h.join(".claude").join("skills")) - .unwrap_or_default() - }); + let user_dir = self + .user_skills_dir + .clone() + .unwrap_or_else(|| crate::plugin::claude_home().join("skills")); roots.push(SkillRoot { path: user_dir, source: SkillSource::User, diff --git a/peri-resources/src/context_test.rs b/peri-resources/src/context_test.rs index 0bb0b6726..df4bb53ad 100644 --- a/peri-resources/src/context_test.rs +++ b/peri-resources/src/context_test.rs @@ -1,10 +1,12 @@ //! context.rs 单元测试:`Resources::open_with` 显式路径语义。 +#[cfg(unix)] use std::path::PathBuf; use tempfile::tempdir; use peri_acp_types::messages::BaseMessage; +#[cfg(unix)] use peri_acp_types::session_resources::AccessMode; use peri_acp_types::store::{PersistedPayload, ThreadStore}; use peri_acp_types::thread::ThreadMeta; @@ -297,10 +299,13 @@ async fn test_into_parts_hands_out_business_handle_and_deployment_close_owner() /// 显式不会存在的凭证变量名:表达「来源已给、变量/值确实没有」。 const ABSENT_CREDENTIAL_ENV: &str = "PERI_CONTEXT_TEST_ABSENT_TOKEN_ENV"; /// 子进程受控 HOME 的守卫变量:只在父用例拉起时出现。 +#[cfg(unix)] const HOME_GUARD_ENV: &str = "PERI_CONTEXT_TEST_REMOTE_CREDENTIAL_HOME"; /// locator 哨兵:断言错误输出不回显它。 +#[cfg(unix)] const LOCATOR_SENTINEL: &str = "turso://sentinel-db-sentinel-org.turso.io"; /// 子进程跑到断言的标记(父用例据此确认证据真的产生了)。 +#[cfg(unix)] const CHILD_REACHED_MARKER: &str = "context-child-verified-no-side-effects"; /// 凭证来源的每一种问题(名字非法、未设置、空值、非 Unicode、注入空值)都按**类型**归 diff --git a/peri-resources/src/sessions/open_test.rs b/peri-resources/src/sessions/open_test.rs index 1e3cf1b5d..7c0df1cbd 100644 --- a/peri-resources/src/sessions/open_test.rs +++ b/peri-resources/src/sessions/open_test.rs @@ -631,9 +631,11 @@ async fn db_path_file_named_like_an_env_reference_opens_as_a_file() { assert!(db_path.is_file(), "必须是本机文件,而不是环境变量引用"); } -/// Unix 非 UTF-8 文件名:字节原样到达打开层。macOS 的 syscall 拒绝非 UTF-8 路径 -/// (EILSEQ),Linux 接受并建库——两种结果都必须落在**原始字节**路径上:旧实现经 -/// `to_string_lossy` 改写后会在 U+FFFD 变体上另建一个库,静默打开错误的库。 +/// Unix 非 UTF-8 文件名:字节原样到达打开层,且**如实失败**。两种平台的失败原因都在 +/// 原始字节路径上(实跑):macOS 的 syscall 直接拒绝(EILSEQ,os error 92);Linux 的 +/// syscall 接受,由打开层的 sqlx 拒绝——它要求 SQLite 文件名是合法 UTF-8 +/// (`EstablishParams::from_options`,无平台分支)。旧实现经 `to_string_lossy` 改写后会在 +/// U+FFFD 变体上另建一个库,静默打开错误的库,因此失败必须落在原始字节路径上。 #[cfg(unix)] #[tokio::test] async fn db_path_keeps_non_utf8_bytes_end_to_end() { @@ -658,16 +660,18 @@ async fn db_path_keeps_non_utf8_bytes_end_to_end() { let opened = crate::Resources::open_deployment(&SessionStoreDeployment::local_path(db_path.clone())) .await; - #[cfg(target_os = "linux")] - { - drop(opened.expect("open(非 UTF-8 路径)")); - assert!(db_path.is_file(), "非 UTF-8 文件必须按原字节建立"); - } - #[cfg(not(target_os = "linux"))] - { - // 本机(macOS)拒绝该路径:如实失败,不改写成另一个路径后"成功"。 - drop(opened); - } + // 如实失败,不改写成另一个路径后"成功":macOS 在 syscall 层拒绝(EILSEQ, + // os error 92),Linux 的 syscall 接受、由打开层 sqlx 的 UTF-8 文件名要求拒绝。 + let error = match opened { + Ok(_) => panic!("非 UTF-8 路径必须如实失败:不得改写成 lossy 路径后成功"), + Err(error) => error, + }; + // 具体原因按平台不同,因此只断言失败出在「打开这个库」这一步:不得是部署解析 + // (locator / 引擎名 / 凭证)之类的另一条路径,也不把某平台的措辞写死。 + assert!( + error.to_string().contains("无法打开指定 SQLite 数据库"), + "必须是打开层的失败,得到 {error}" + ); let lossy_variant = dir .path() diff --git a/peri-resources/src/sessions/remote/session_shape_test.rs b/peri-resources/src/sessions/remote/session_shape_test.rs index e110895b8..8fab3830c 100644 --- a/peri-resources/src/sessions/remote/session_shape_test.rs +++ b/peri-resources/src/sessions/remote/session_shape_test.rs @@ -214,6 +214,18 @@ fn text(value: &str) -> Value { Value::Text(value.to_owned()) } +/// 平台绝对路径文本,用作「绑定相对路径必须是相对的」这条规则的反例。 +/// +/// 不能写死 `/etc`:Windows 上带根无盘符的路径不是绝对路径,规则不会拒绝它, +/// 断言就会把「没被拒绝」误报成契约失败。 +fn absolute_cwd() -> &'static str { + if cfg!(windows) { + r"C:\etc" + } else { + "/etc" + } +} + /// 一行会话事实(列顺序 = `FACT_PROJECTION`)。 fn fact_row() -> Vec { vec![ @@ -288,7 +300,7 @@ fn binding_decodes_from_the_appended_columns() { )); let mut absolute = fact_row(); - absolute[FACT_BINDING_RELATIVE_CWD] = text("/etc"); + absolute[FACT_BINDING_RELATIVE_CWD] = text(absolute_cwd()); assert!(matches!( codec::decode_binding(&absolute, FACT_BINDING_VERSION), Err(error) if matches!(error.kind(), SessionResourceErrorKind::Corrupt { .. }) @@ -536,7 +548,7 @@ fn write_sql_is_static_and_all_values_are_bound() { #[test] fn binding_cwd_must_be_relative_and_textual() { let mut session = new_session("session-a", None); - session.binding.cwd_relative_to_workspace = PathBuf::from("/etc"); + session.binding.cwd_relative_to_workspace = PathBuf::from(absolute_cwd()); let error = session_sql::insert_session_statements(&session_sql::session_insert(&session, 0, None)) .expect_err("absolute binding cwd is refused"); diff --git a/peri-resources/src/sessions/sqlite_store/legacy_test.rs b/peri-resources/src/sessions/sqlite_store/legacy_test.rs index 44961a1cd..b0a3b99a1 100644 --- a/peri-resources/src/sessions/sqlite_store/legacy_test.rs +++ b/peri-resources/src/sessions/sqlite_store/legacy_test.rs @@ -2,6 +2,35 @@ use super::*; use peri_acp_types::workspace::{ScopedThreadQuery, ThreadScope}; use sqlx::{Connection, SqliteConnection}; +/// 旧版会话保存的调用方路径文本。 +/// +/// macOS 上保持调用方原样:`/var/...` 与登记的 `/private/var/...` 由 +/// `legacy_path_sql` 归一,这里不预先归一,那条规则才有覆盖。 +/// +/// Windows 上不能直接用 `dir.path()`:runner 的 `%TEMP%` 是 8.3 短名 +/// (`C:\Users\RUNNER~1\...`),而登记 root 来自 canonicalize 的长名 +/// (`\\?\C:\Users\runneradmin\...`)。把短名展开成长名只能走文件系统,而这条匹配是 +/// SQL 层的显示关联(`legacy_path_sql` 只归一分隔符、verbatim 前缀与尾部分隔符)。 +/// 真实用户保存的是普通长名路径(`current_dir` 既不带短名也不带 verbatim 前缀), +/// 用例按这个形状取文本。 +fn legacy_saved_text(dir: &std::path::Path) -> std::path::PathBuf { + #[cfg(not(windows))] + { + dir.to_owned() + } + #[cfg(windows)] + { + let canonical = std::fs::canonicalize(dir).unwrap(); + // 先取出**拥有所有权**的普通形式:借用在这里结束,不匹配时才能把 canonical + // 原样返回(match 的 scrutinee 借用活到 match 结束,直接在里面移动会编译失败)。 + let ordinary = canonical + .to_str() + .and_then(|text| text.strip_prefix(r"\\?\")) + .map(std::path::PathBuf::from); + ordinary.unwrap_or(canonical) + } +} + async fn legacy_database(path: &std::path::Path, cwd: &std::path::Path) -> String { let mut connection = SqliteConnection::connect_with( &SqliteConnectOptions::new() @@ -36,7 +65,7 @@ async fn legacy_database(path: &std::path::Path, cwd: &std::path::Path) -> Strin async fn legacy_history_visible_after_upgrade_and_schema3_reopen() { let dir = tempfile::tempdir().unwrap(); // Old releases saved the caller's ordinary path, not canonical/verbatim Windows paths. - let cwd = dir.path().to_owned(); + let cwd = legacy_saved_text(dir.path()); let path = dir.path().join("threads.db"); let id = legacy_database(&path, &cwd).await; for _ in 0..2 { diff --git a/peri-resources/src/sessions/sqlite_store/schema_test.rs b/peri-resources/src/sessions/sqlite_store/schema_test.rs index 2978ef70f..4d8bd64cc 100644 --- a/peri-resources/src/sessions/sqlite_store/schema_test.rs +++ b/peri-resources/src/sessions/sqlite_store/schema_test.rs @@ -1,4 +1,6 @@ use super::*; +// `SessionResources` 的方法只在 unix 子进程用例里调用(Windows 的 home_dir 不读 HOME)。 +#[cfg(unix)] use peri_acp_types::session_resources::SessionResources; use peri_acp_types::{ messages::BaseMessage, diff --git a/peri-resources/src/sessions/sqlite_store/workspace_test.rs b/peri-resources/src/sessions/sqlite_store/workspace_test.rs index e77e54639..12577d7ad 100644 --- a/peri-resources/src/sessions/sqlite_store/workspace_test.rs +++ b/peri-resources/src/sessions/sqlite_store/workspace_test.rs @@ -617,11 +617,15 @@ async fn test_worktree_binding_cwd_text_matches_registration_without_trailing_se // 工作区根与子目录各建一个绑定:两者的执行目录文本都必须与解析结果一致。 // `root.join("")` 会给出 `/a/b/` 这样的形式,与解析给出的 `/a/b` 只差一个 // 分隔符;Path 比较看不出差别,按字符串比较目录的调用方会据此重跑完整发现。 - for (cwd, suffix) in [(repo.path().to_path_buf(), ""), (nested.clone(), "/sub")] { + // 后缀的分隔符随平台:Windows 的解析文本用 `\`。 + for (cwd, suffix) in [ + (repo.path().to_path_buf(), String::new()), + (nested.clone(), format!("{}sub", std::path::MAIN_SEPARATOR)), + ] { let (id, resolved) = bound(&store, &cwd).await; let registered = resolved.cwd.to_str().unwrap(); assert!( - registered.ends_with(suffix), + registered.ends_with(suffix.as_str()), "解析结果不符合预期:{registered}" ); let revalidated = store.validate_session_binding(&id).await.unwrap(); diff --git a/peri-tui/tests/meta_session_cli.rs b/peri-tui/tests/meta_session_cli.rs index 7df36c46d..462186453 100644 --- a/peri-tui/tests/meta_session_cli.rs +++ b/peri-tui/tests/meta_session_cli.rs @@ -5,6 +5,8 @@ use chrono::{TimeZone, Utc}; use peri_acp_types::store::ThreadStore; use peri_acp_types::thread::{AgentStatus, ThreadMeta}; use peri_resources::sessions::SqliteThreadStore; +// 只被 `default_database_*` 两条 unix 用例使用(见下方平台说明)。 +#[cfg(not(windows))] use serial_test::serial; use tempfile::TempDir; @@ -294,6 +296,10 @@ fn explicit_database_selection_never_falls_back_to_another_database() { assert_eq!(value["cwd"], "/b"); } +// 默认库位置由 `dirs_next::home_dir()` 派生,而 Windows 上它读 Profile known-folder、 +// 忽略 HOME(同 peri-resources/src/sessions/mod.rs 的默认路径用例):注入 HOME 无法把 +// 默认库指向沙箱,这条用例在 Windows 上验证不了自己的契约,只在 unix 运行。 +#[cfg(not(windows))] #[test] #[serial] fn default_database_reads_existing_single_store_without_modifying_history() { @@ -343,6 +349,9 @@ fn explicit_database_selection_returns_each_selected_database_value() { } } +// 同 `default_database_reads_existing_single_store_without_modifying_history`:注入 HOME +// 在 Windows 无效,这里观察的沙箱主目录根本不会被默认路径解析读到。 +#[cfg(not(windows))] #[test] #[serial] fn missing_default_database_does_not_create_home_paths() { diff --git a/peri-tui/tests/print_exit.rs b/peri-tui/tests/print_exit.rs index b5cda9f9b..1d82de2cc 100644 --- a/peri-tui/tests/print_exit.rs +++ b/peri-tui/tests/print_exit.rs @@ -1,4 +1,6 @@ use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use peri_acp_types::interaction::UnansweredCause; @@ -50,158 +52,194 @@ async fn run_print(format: &str, scenario: ProviderScenario, bare: bool) -> std: // Only the provider is mocked: the child runs the real CLI, ACP host, // agent loop, notification pump, session close and deployment shutdown. - let provider = tokio::spawn(async move { - let steps = match scenario { - ProviderScenario::Answer | ProviderScenario::Reject => 1, - ProviderScenario::ReadFile - | ProviderScenario::RecoverFromTruncation - | ProviderScenario::AskUser => 2, - ProviderScenario::Truncated => 3, - }; - for step in 0..steps { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - let (header_end, content_length) = loop { - let mut buffer = [0; 4096]; - let count = socket.read(&mut buffer).await.unwrap(); - assert_ne!(count, 0, "provider request ended before its headers"); - request.extend_from_slice(&buffer[..count]); - if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { - let headers = std::str::from_utf8(&request[..end]).unwrap(); - assert!(headers.starts_with("POST /v1/messages ")); - let length = headers - .lines() - .filter_map(|line| line.split_once(':')) - .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .unwrap() - .1 - .trim() - .parse::() - .unwrap(); - break (end + 4, length); + let expected_steps = match scenario { + ProviderScenario::Answer | ProviderScenario::Reject => 1, + ProviderScenario::ReadFile + | ProviderScenario::RecoverFromTruncation + | ProviderScenario::AskUser => 2, + ProviderScenario::Truncated => 3, + }; + let served_steps = Arc::new(AtomicUsize::new(0)); + let provider = tokio::spawn({ + let served_steps = Arc::clone(&served_steps); + async move { + let mut step = 0; + // 请求序列不预先封顶:prediction 调用(见下)与被测 step 序列交织在同一 + // provider 上,服务到测试收摊(`provider.abort()`)为止。step 序列是否 + // 完整由共享计数在子进程退出后断言,不靠「循环自然结束」隐式保证。 + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let (header_end, content_length) = loop { + let mut buffer = [0; 4096]; + let count = socket.read(&mut buffer).await.unwrap(); + assert_ne!(count, 0, "provider request ended before its headers"); + request.extend_from_slice(&buffer[..count]); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = std::str::from_utf8(&request[..end]).unwrap(); + assert!(headers.starts_with("POST /v1/messages ")); + let length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .unwrap() + .1 + .trim() + .parse::() + .unwrap(); + break (end + 4, length); + } + }; + while request.len() < header_end + content_length { + let mut buffer = [0; 4096]; + let count = socket.read(&mut buffer).await.unwrap(); + assert_ne!(count, 0, "provider request body was truncated"); + request.extend_from_slice(&buffer[..count]); + } + let body: Value = serde_json::from_slice(&request[header_end..]).unwrap(); + assert_eq!(body["stream"], true); + // 首个 prompt 之后宿主会另发一次 prediction 调用 + // (`peri_acp::host::prediction` → `execute_prediction`):它不属于本用例的 + // step 序列,但必须被服务——fixture 不应答就把它钉在 provider 上, + // 会话收摊只能等预测自身超时(30s)或协作宽限(5s)耗尽后强杀, + // 进程退出因此被顶到预算边缘。 + // 空文本应答让 prediction 动作集为空(不改会话元数据),且不推进 step。 + if is_prediction_request(&body) { + let frames = sse_frames(&[ + ( + "message_start", + json!({"message": { + "id": "fixture-prediction", "type": "message", "role": "assistant", + "model": "fixture-model", "content": [], + "usage": {"input_tokens": 1, "output_tokens": 0} + }}), + ), + ( + "content_block_start", + json!({"index": 0, "content_block": {"type": "text", "text": ""}}), + ), + ( + "content_block_delta", + json!({"index": 0, "delta": {"type": "text_delta", "text": ""}}), + ), + ("content_block_stop", json!({"index": 0})), + ( + "message_delta", + json!({"delta": {"stop_reason": "end_turn"}, + "usage": {"input_tokens": 1, "output_tokens": 1}}), + ), + ("message_stop", json!({})), + ]); + respond(&mut socket, "200 OK", "text/event-stream", &frames).await; + continue; + } + if matches!(scenario, ProviderScenario::ReadFile) && step == 1 { + let messages = body["messages"].to_string(); + assert!( + messages.contains("line 60 the quick brown fox"), + "后续真实请求应包含完整工具返回" + ); + assert!( + messages.contains("tool_result") && messages.contains("read-big"), + "保留历史工具调用和结果配对" + ); } - }; - while request.len() < header_end + content_length { - let mut buffer = [0; 4096]; - let count = socket.read(&mut buffer).await.unwrap(); - assert_ne!(count, 0, "provider request body was truncated"); - request.extend_from_slice(&buffer[..count]); - } - let body: Value = serde_json::from_slice(&request[header_end..]).unwrap(); - assert_eq!(body["stream"], true); - if matches!(scenario, ProviderScenario::ReadFile) && step == 1 { - let messages = body["messages"].to_string(); - assert!( - messages.contains("line 60 the quick brown fox"), - "后续真实请求应包含完整工具返回" - ); - assert!( - messages.contains("tool_result") && messages.contains("read-big"), - "保留历史工具调用和结果配对" - ); - } - if matches!(scenario, ProviderScenario::RecoverFromTruncation) && step == 1 { - let messages = body["messages"].to_string(); - assert!(messages.contains(ANSWER), "续跑保留被截断的已生成正文"); - assert!( - messages.contains("output token limit"), - "后续真实请求包含截断续跑提醒" - ); - } - if matches!(scenario, ProviderScenario::AskUser) && step == 1 { - assert_ask_user_result_is_unanswered(&body); - } + if matches!(scenario, ProviderScenario::RecoverFromTruncation) && step == 1 { + let messages = body["messages"].to_string(); + assert!(messages.contains(ANSWER), "续跑保留被截断的已生成正文"); + assert!( + messages.contains("output token limit"), + "后续真实请求包含截断续跑提醒" + ); + } + if matches!(scenario, ProviderScenario::AskUser) && step == 1 { + assert_ask_user_result_is_unanswered(&body); + } - let (status, content_type, response) = if matches!(scenario, ProviderScenario::Reject) { - ( - "401 Unauthorized", - "application/json", - json!({"type": "error", "error": { - "type": "authentication_error", "message": "controlled rejection" - }}) - .to_string(), - ) - } else { - // step 0 请求工具,step 1 给出终答(工具结果已在上方断言)。 - let tool_call = match (scenario, step) { - (ProviderScenario::ReadFile, 0) => { - Some(("read-big", "Read", json!({"file_path": big_file}))) - } - (ProviderScenario::AskUser, 0) => Some(( - "ask-user-question-1", - "AskUserQuestion", - json!({"questions": [{ - "question": "选择部署环境?", - "header": "部署环境", - "multiSelect": false, - "options": [{"label": ASK_USER_OPTION, "description": "fixture 选项"}] - }]}), - )), - _ => None, - }; - let truncate = matches!(scenario, ProviderScenario::Truncated) - || (matches!(scenario, ProviderScenario::RecoverFromTruncation) && step == 0); - let content = match &tool_call { - Some((id, name, _)) => { - json!({"type": "tool_use", "id": id, "name": name, "input": {}}) - } - None => json!({"type": "text", "text": ""}), - }; - let delta = match &tool_call { - Some((_, _, input)) => { - json!({"type": "input_json_delta", "partial_json": input.to_string()}) - } - None => json!({"type": "text_delta", "text": ANSWER}), - }; - let stop_reason = if tool_call.is_some() { - "tool_use" - } else if truncate { - "max_tokens" - } else { - "end_turn" - }; - let events = [ + let (status, content_type, response) = if matches!( + scenario, + ProviderScenario::Reject + ) { ( - "message_start", - json!({"message": { - "id": "fixture-response", "type": "message", "role": "assistant", - "model": "fixture-model", "content": [], - "usage": {"input_tokens": 1, "output_tokens": 0} - }}), - ), - ( - "content_block_start", - json!({"index": 0, "content_block": content}), - ), - ("content_block_delta", json!({"index": 0, "delta": delta})), - ("content_block_stop", json!({"index": 0})), - ( - "message_delta", - json!({"delta": {"stop_reason": stop_reason}, + "401 Unauthorized", + "application/json", + json!({"type": "error", "error": { + "type": "authentication_error", "message": "controlled rejection" + }}) + .to_string(), + ) + } else { + // step 0 请求工具,step 1 给出终答(工具结果已在上方断言)。 + let tool_call = match (scenario, step) { + (ProviderScenario::ReadFile, 0) => { + Some(("read-big", "Read", json!({"file_path": big_file}))) + } + (ProviderScenario::AskUser, 0) => Some(( + "ask-user-question-1", + "AskUserQuestion", + json!({"questions": [{ + "question": "选择部署环境?", + "header": "部署环境", + "multiSelect": false, + "options": [{"label": ASK_USER_OPTION, "description": "fixture 选项"}] + }]}), + )), + _ => None, + }; + let truncate = matches!(scenario, ProviderScenario::Truncated) + || (matches!(scenario, ProviderScenario::RecoverFromTruncation) + && step == 0); + let content = match &tool_call { + Some((id, name, _)) => { + json!({"type": "tool_use", "id": id, "name": name, "input": {}}) + } + None => json!({"type": "text", "text": ""}), + }; + let delta = match &tool_call { + Some((_, _, input)) => { + json!({"type": "input_json_delta", "partial_json": input.to_string()}) + } + None => json!({"type": "text_delta", "text": ANSWER}), + }; + let stop_reason = if tool_call.is_some() { + "tool_use" + } else if truncate { + "max_tokens" + } else { + "end_turn" + }; + let events = [ + ( + "message_start", + json!({"message": { + "id": "fixture-response", "type": "message", "role": "assistant", + "model": "fixture-model", "content": [], + "usage": {"input_tokens": 1, "output_tokens": 0} + }}), + ), + ( + "content_block_start", + json!({"index": 0, "content_block": content}), + ), + ("content_block_delta", json!({"index": 0, "delta": delta})), + ("content_block_stop", json!({"index": 0})), + ( + "message_delta", + json!({"delta": {"stop_reason": stop_reason}, "usage": {"input_tokens": if step == 0 {100} else {500}, "cache_read_input_tokens": 20, "cache_creation_input_tokens": 30, "output_tokens": if step == 0 {7} else {11}}}), - ), - ("message_stop", json!({})), - ]; - let response = events - .into_iter() - .map(|(event, data)| format!("event: {event}\ndata: {data}\n\n")) - .collect::(); - ("200 OK", "text/event-stream", response) - }; - socket - .write_all( - format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", - response.len() - ) - .as_bytes(), - ) - .await - .unwrap(); - socket.shutdown().await.unwrap(); + ), + ("message_stop", json!({})), + ]; + let response = sse_frames(&events); + ("200 OK", "text/event-stream", response) + }; + respond(&mut socket, status, content_type, &response).await; + step += 1; + served_steps.store(step, Ordering::SeqCst); + } } }); @@ -209,9 +247,17 @@ async fn run_print(format: &str, scenario: ProviderScenario, bare: bool) -> std: command .env_clear() .env("HOME", fixture.path()) + .env("USERPROFILE", fixture.path()) + .env("TMPDIR", fixture.path()) + .env("TEMP", fixture.path()) + .env("TMP", fixture.path()) .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .current_dir(fixture.path()) .args(["--print", "Reply briefly", "--output-format", format]) + // Windows 的 dirs_next::home_dir 不受 HOME/USERPROFILE 覆盖, + // 启动期的全局配置读取也必须显式指向 fixture。 + .arg("--config-file") + .arg(&settings) .arg("--settings") .arg(settings) .arg("--db-path") @@ -220,24 +266,97 @@ async fn run_print(format: &str, scenario: ProviderScenario, bare: bool) -> std: .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); + // env_clear 不会自动保留 Windows 系统环境;网络和子进程初始化仍需要 + // 系统目录。只恢复这两个系统变量,临时目录继续由本用例隔离。 + #[cfg(windows)] + for key in ["SystemRoot", "WINDIR"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } if bare { command.arg("--bare"); } - let child = command.spawn().unwrap(); - let result = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output()).await; - if result.is_err() { - provider.abort(); - } - let output = result - .expect( - "print must exit after the provider finishes, even while the ACP pump owns a client", + let mut child = command.spawn().unwrap(); + let mut stdout_pipe = child.stdout.take().unwrap(); + let mut stderr_pipe = child.stderr.take().unwrap(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + // 把 child 和已读输出留在 timeout 外;失败时先 kill/reap,再给出启动/退出 + // 阶段的证据,不能丢掉 wait_with_output 所有权后只剩 Elapsed(())。 + let result = tokio::time::timeout(Duration::from_secs(15), async { + tokio::try_join!( + child.wait(), + stdout_pipe.read_to_end(&mut stdout), + stderr_pipe.read_to_end(&mut stderr), + ) + }) + .await; + let status = match result { + Ok(result) => result.unwrap().0, + Err(_) => { + provider.abort(); + let provider_result = provider.await; + let cleanup = tokio::time::timeout(Duration::from_secs(5), child.kill()).await; + panic!( + "print must exit after the provider finishes, even while the ACP pump owns a client; \ + served steps: {}/{expected_steps}; provider: {provider_result:?}; cleanup: {cleanup:?}\n\ + stdout:\n{}\nstderr:\n{}", + served_steps.load(Ordering::SeqCst), + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr), + ); + } + }; + let output = std::process::Output { + status, + stdout, + stderr, + }; + tokio::time::timeout(Duration::from_secs(5), async { + while served_steps.load(Ordering::SeqCst) < expected_steps { + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "the CLI must have reached the provider for every step: 期望 {expected_steps} 次,实到 {} 次", + served_steps.load(Ordering::SeqCst) + ) + }); + provider.abort(); + output +} + +/// 把 `(event, data)` 序列渲染成 SSE 帧串(与真实 provider 的 wire 形态一致)。 +fn sse_frames(events: &[(&str, Value)]) -> String { + events + .iter() + .map(|(event, data)| format!("event: {event}\ndata: {data}\n\n")) + .collect() +} + +/// 区分 prediction 调用与用例的 step 序列:prediction 指令由 +/// `build_prediction_directive` 生成,`execute_prediction` 把它作为 system 消息注入; +/// Anthropic wire 格式下 system 在顶层 `system` 字段(不在 `messages[0]`),故整体扫描。 +fn is_prediction_request(body: &Value) -> bool { + body.to_string().contains("") +} + +/// 回一次应答并关闭连接(provider 侧不用 keep-alive)。 +async fn respond(socket: &mut tokio::net::TcpStream, status: &str, content_type: &str, body: &str) { + socket + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), ) - .unwrap(); - tokio::time::timeout(Duration::from_secs(1), provider) .await - .expect("the CLI must have reached the provider") .unwrap(); - output + socket.shutdown().await.unwrap(); } /// [回归测试] 第二次真实 provider 请求必须带回诚实的失败工具结果:`is_error=true`、 diff --git a/spec/issues/2026-09-26-session-store-remote-backend.md b/spec/issues/2026-09-26-session-store-remote-backend.md index 1fddbbd5a..5c0aef842 100644 --- a/spec/issues/2026-09-26-session-store-remote-backend.md +++ b/spec/issues/2026-09-26-session-store-remote-backend.md @@ -1451,8 +1451,11 @@ close、path、conn、云实验都不在本批)。 `CredentialSourceForLocalStore`),在入口转换处失败,不静默忽略;`Debug` 仍只给形态(新增 ``),不回显路径/locator/凭证名。 - 回归(新增,离线):`db_path_file_named_like_an_env_reference_opens_as_a_file`(真文件,旧实现报 - `EnvValueMissing`)、`db_path_keeps_non_utf8_bytes_end_to_end`(字节保真;macOS syscall 拒绝非 UTF-8 - 路径 EILSEQ、Linux 建库,两种平台都不得落到 lossy 变体)、`db_path_windows_shapes_skip_locator_parsing` + `EnvValueMissing`)、`db_path_keeps_non_utf8_bytes_end_to_end`(字节保真由同用例前半段的 `resolve_locator` + 断言覆盖,打开层按平台如实失败;**更正 2026-09-27**:原文「Linux 建库」有误——macOS 的 syscall 直接拒绝非 + UTF-8 路径(EILSEQ,os error 92),Linux 的 syscall 接受后由打开层 sqlx 拒绝,它要求 SQLite 文件名是合法 + UTF-8(`EstablishParams::from_options`,无平台分支);两种平台都不得落到 lossy 变体)、 + `db_path_windows_shapes_skip_locator_parsing` (形状解析,不要求本机是 Windows)、`env_colon_literal_is_a_file_name_for_db_path_but_a_reference_for_session_store`、 `remote_only_parameters_on_confirmed_local_path_fail_early`。 - 证据:`cargo test -p peri-acp-types --lib` 468 passed / 0 failed;`cargo test -p peri-resources --lib` @@ -1521,7 +1524,8 @@ JSON-RPC wire;meta 的 JSON 输出只经 allowlist DTO(`json_success_is_one_ - 未验证 / 未完成(继续显式记账,不假称整体完成):首次产品登记仍 FAIL——首次云 `Created` **观测仍未完成**; close / conn 仍 FAIL(各自独立成批)。首登产品边界不变:**仅本安装初始化的新库可获首次登记;已初始化但无本机 registry 只能读**(未加 register/接管,不自动登记,不 seed 真实 registry)。Windows drive/UNC 仅形状断言 - (未在 Windows 实跑);macOS 非 UTF-8 路径按平台如实断言 EILSEQ(Linux 建库)。上述失败用例里「stderr 不回显 + (未在 Windows 实跑);非 UTF-8 路径按平台如实断言失败原因(macOS syscall EILSEQ,Linux 由打开层 sqlx + 拒绝——**更正 2026-09-27**:原文「Linux 建库」有误)。上述失败用例里「stderr 不回显 locator 原文」的断言因其先断言 exit code 而未被执行,该路径脱敏本批未取得证据(脱敏另有 `url_with_embedded_secret_is_rejected_without_echo`、`request_debug_keeps_host_and_credentials_out` 通过)。 本轮未跑云、未跑 LLM。