diff --git a/CodeWhale b/CodeWhale index f853f8f15..5a5bf363e 160000 --- a/CodeWhale +++ b/CodeWhale @@ -1 +1 @@ -Subproject commit f853f8f1566c57e6be40d5439a222a932aa79ef5 +Subproject commit 5a5bf363ebeb720410f30e400c3de44abab71de6 diff --git a/docs/fork-modifications.en.md b/docs/fork-modifications.en.md index 1c2e15ba7..3526684c1 100644 --- a/docs/fork-modifications.en.md +++ b/docs/fork-modifications.en.md @@ -1,6 +1,6 @@ # CodeWhale Fork Modification Register -> Updated: 2026-09-01. Public maintenance baseline: upstream `v0.9.5` r13, published through CodeWhale PR #32; the parent gitlink lands via parent PR #370. Canonical Chinese register: [`docs/fork-modifications.md`](fork-modifications.md). This English page is a condensed summary; the Chinese version is the complete, authoritative register. +> Updated: 2026-09-04. Public maintenance baseline: upstream `v0.9.5` r13, published through CodeWhale PR #32; an exact, unpublished r14 candidate adds Shell task-origin reconciliation. Canonical Chinese register: [`docs/fork-modifications.md`](fork-modifications.md). This English page is a condensed summary; the Chinese version is the complete, authoritative register. > > 2026-08-22 corrections: (1) the parent gitlink bump from r6 (`3bbf8421`) to r7 happened in parent PR #285 (`95502ac8`), not in PR #302 — PR #302 started from a pre-#285 main and merged without touching the gitlink; PR #305 later advanced the published baseline to r8. (2) PR #302 (capability-bundle unification, parent commit `c75f2fb2`) updated the parent-side scope model — the single `disabled_bundles.json` (package id × mode, plus `hidden_scopes`) replaced the separate `disabled_connectors.json` / `disabled_skills.json` files. @@ -10,12 +10,13 @@ |---|---| | Upstream | `v0.9.5` at `853cb707bbcf4f7dc4268fba6d811e0d04083f9c` | | Public maintenance branch | `Pinvou/CodeWhale:pinvou3-clean` at `f853f8f1` (r13: r12 plus PR #32 GAIA benchmark isolation) | +| Unpublished candidate | r14 candidate `5a5bf363` (stable Shell tool-call/turn origin) | | Merged fixes | Existing `#9`, `#11`, `#12`, `#13`, `#15`, `#16`, `#17`, and `#19`, plus r11 PRs `#18`, `#21`, `#22`, `#25`, `#26`, `#27`, `#29`, `#30`, r12 PRs `#33`, `#35`, and r13 PR `#32`, are merged | -| Published status | `pinvou3-clean` and immutable tag `pinvou-v0.9.5-r13` resolve to `f853f8f1566c57e6be40d5439a222a932aa79ef5`; `r1` through `r13` remain immutable; the parent gitlink is aligned by PR #370 | +| Published status | `pinvou3-clean` and immutable tag `pinvou-v0.9.5-r13` resolve to `f853f8f1566c57e6be40d5439a222a932aa79ef5`; `r1` through `r13` remain immutable; the development parent gitlink temporarily points to the exact r14 candidate under review | | Previous baseline backup | Tag `pinvou-v0.9.0-r4` and branch `backup/pinvou3-clean-v0.9.0-r4`, both at `03e9e1027c03ce1e4b35ab9e3ccce751b65b9624` | -| Drift | r13 baseline totals 110 files, `+10895/-1195` (net 9,700 added lines); r12→r13 is 6 files, `+1088/-1` | +| Drift | r13 baseline totals 110 files, `+10895/-1195` (net 9,700 added lines); r12→r13 is 6 files, `+1088/-1`; the r14 candidate is 7 files, `+133/-7` over r13 | | Organization | Four current long-lived topics; PR #13 removes the product-specific orchestration topic | -| Guard inventory | r13 has 63 CodeWhale `forkguard_*` tests, including six GAIA benchmark-isolation tests, plus generic tool/route compatibility regressions and parent fingerprints/behavior tests | +| Guard inventory | r13 has 63 CodeWhale `forkguard_*` tests, including six GAIA benchmark-isolation tests; the r14 candidate adds two Shell-origin behavior tests (65 total) plus parent timeline-projection coverage | ### r12 provider-native search and keyless Bing tail (engine side merged) @@ -23,6 +24,11 @@ - CodeWhale PR #35 (merged at `9c5f4f19`): the keyless chain tail after API-backed providers switches from DuckDuckGo to Bing (live measurements show DDG is DNS-poisoned and SNI-reset in mainland China while Bing serves both global and China endpoints keyless); the all-backends-down error now suggests API-backed `[search]` providers; adds `forkguard_api_provider_chain_tail_is_bing` (forkguard total 56→57). - r12 parent-side integration (PR #375): gitlink → `9c5f4f19`, settings-page search-source guidance copy (i18n zh/en/ja), and comment corrections in `prefs/search.rs` plus the matching `bridge.rs` injection-site comment and test docstring (the engine default is still DuckDuckGo; the app-side default Bing comes from the bridge's explicit `EngineConfig` injection). +### r14 candidate Shell task-origin reconciliation (unpublished) + +- The engine stamps Shell work with its originating tool call and turn. Job snapshots and completion events expose those stable identities, allowing hosts to reconcile updates at the original transcript position instead of guessing from command text. The Pinvou bridge preserves a safe legacy fallback, keeps unmatched running jobs visible after compaction or reload, and prevents identified completed root jobs from being appended at the current timeline tail when their origin card is no longer loaded. +- Candidate guards: `forkguard_background_shell_job_preserves_origin_identity` covers identity on snapshots and completion events, while `forkguard_tool_context_for_call_preserves_turn_and_sets_call_origin` covers the engine dispatch stamp. Parent coverage includes `forkguard_shell_monitor_assigns_identical_commands_by_stable_origin` and `shell_task_projection.test.mjs`. + ### r11 provider, MCP, steer, and platform boundaries (published) - CodeWhale PRs #18, #21, #22, #25, #26, #27, #29, and #30 are published at `0d89a31be016457c180501417dd2c0f34ce844a6`. Strict-direct providers accept a wire-model casing difference only when exactly one owned model row proves the match; the parent adds a GLM bridge regression from a lowercase saved value to canonical `GLM-5.2`. @@ -87,7 +93,7 @@ CodeWhale PR #15 combined candidate `1eca6103a` with security follow-ups `169c24 PR #13 was squash-merged as `a36e6cd533024cfe5724bae21875aea42b2ed87a` and published as immutable tag `pinvou-v0.9.5-r7`. It removes product-specific orchestration while preserving canonical registry prompt text and alias-aware Custom SubAgent allowlist resolution. 1. **Host embedding and routing boundary** — the established T1 commits through `feb8761aeda31749f3d54c6e1f8ef460540567a1` (`#19`), plus `485884913308cdf7564bc60da2e416be637083b5` (`#21`), `04e109af4b4786a0d49fbbeefdd77af15a9f495e` (`#22`), `69ed3bfbdb314f901d4cf4120f1caaaf0b6aa529` (`#30`), and `0d89a31be016457c180501417dd2c0f34ce844a6` (`#18`). Exposes narrow host seams for routing, runtime snapshots/recovery, reliable steer ownership and withdrawal, edit-target classification, provider compatibility, and post-compaction usage estimation. Explicit route ceilings constrain request budgets; strict-direct casing recovery requires one unambiguous owned model row. Edit rejection cannot call the provider or mutate history, and gateways plus ambiguous models remain untouched. -2. **Tool compatibility and command-execution safety** — the established T2 commits through the Shell-cancellation boundary in `8aa5f77d35ac1d00d1f444193543307a7e9b391c` (`#16`), plus `44730dfe596b70f86ae2f928959877a3e3f494e4` (`#27`), `665b46cd9e67326459223aa662931bd36d726004` (`#29`), `04e109af4b4786a0d49fbbeefdd77af15a9f495e` (`#22`), `4831c3797b76485a912b056c76a4cff22f0a2863` (`#25`), `e68a185c2ba07f327bd8b63bbfea6a70a96f33ea` (`#26`), the six `#33` commits `ecfd68acc056b95b06d98312753a712e4c0755db`, `603eeadcdab65d71d62a5ac32b6700207433fe5c`, `eb25a255a92f7385a3fde74f1f44626cdd068125`, `8c243e7ea7094fff189ab12582aea0460b655d06`, `8111f8150bc6b103da685f6abc3f26143b3bb207`, and `4f612e548090616f8206154e37c9895404a8998b`, `9c5f4f19b0acbc960889778a5873c7fb038b1378` (`#35`), and `f853f8f1566c57e6be40d5439a222a932aa79ef5` (`#32`). Adds exact catalog/final-dispatch policy, read-only projections, per-tool Moonshot schema degradation with a visible diagnostic, process-local MCP secret resolution, pool-side denied-server sealing, incremental Windows Shell decoding, provider-native search adapters with exact fail-closed endpoint gating (`#33`), the keyless Bing chain tail after API-backed providers (`#35`), and default-off GAIA benchmark controls (`#32`). These generic seams remain prioritized for upstreaming; Pinvou's GAIA profiles stay app-owned. +2. **Tool compatibility and command-execution safety** — the established T2 commits through the Shell-cancellation boundary in `8aa5f77d35ac1d00d1f444193543307a7e9b391c` (`#16`), plus `44730dfe596b70f86ae2f928959877a3e3f494e4` (`#27`), `665b46cd9e67326459223aa662931bd36d726004` (`#29`), `04e109af4b4786a0d49fbbeefdd77af15a9f495e` (`#22`), `4831c3797b76485a912b056c76a4cff22f0a2863` (`#25`), `e68a185c2ba07f327bd8b63bbfea6a70a96f33ea` (`#26`), the six `#33` commits `ecfd68acc056b95b06d98312753a712e4c0755db`, `603eeadcdab65d71d62a5ac32b6700207433fe5c`, `eb25a255a92f7385a3fde74f1f44626cdd068125`, `8c243e7ea7094fff189ab12582aea0460b655d06`, `8111f8150bc6b103da685f6abc3f26143b3bb207`, and `4f612e548090616f8206154e37c9895404a8998b`, `9c5f4f19b0acbc960889778a5873c7fb038b1378` (`#35`), and `f853f8f1566c57e6be40d5439a222a932aa79ef5` (`#32`), plus the unpublished r14 candidate `5a5bf363ebeb720410f30e400c3de44abab71de6` (Shell task origin identity). Adds exact catalog/final-dispatch policy, read-only projections, per-tool Moonshot schema degradation with a visible diagnostic, process-local MCP secret resolution, pool-side denied-server sealing, incremental Windows Shell decoding, provider-native search adapters with exact fail-closed endpoint gating (`#33`), the keyless Bing chain tail after API-backed providers (`#35`), and default-off GAIA benchmark controls (`#32`). These generic seams remain prioritized for upstreaming; Pinvou's GAIA profiles stay app-owned. 3. **Embedded context and Skill sources** — `5a9f52941b83452c1e8b76c2d679bac315edcf70`. Seals ambient project authority, scans only the explicit Skill root, filters disabled Skills, preserves up to 100 KiB only for the Permissions fragment, and excludes internal reminders from Working Set extraction. 4. **Automation and runtime lifecycle** — `fc84f7d3e5dca0e3db404d43e218597764129f9b`. Preserves stable conversation/thread identity, v4 task compatibility, anchored schedules, no-backfill/no-overlap behavior, and terminal-only cleanup. diff --git a/docs/fork-modifications.md b/docs/fork-modifications.md index 29f7aef7a..655e03d48 100644 --- a/docs/fork-modifications.md +++ b/docs/fork-modifications.md @@ -4,19 +4,20 @@ > 基线、主题边界、守护指纹和同步结论以本文与 `docs/fork-policy.md` 为准。 > English: [`docs/fork-modifications.en.md`](fork-modifications.en.md) -## 0. 当前状态(2026-09-01 · v0.9.5 r13 基线,父仓 gitlink 由 PR #370 接入) +## 0. 当前状态(2026-09-04 · v0.9.5 r14 候选,父仓 gitlink 待发布接入) | 项 | 当前值 | |---|---| | 上游基线 | tag `v0.9.5`,commit `853cb707bbcf4f7dc4268fba6d811e0d04083f9c` | | 公开维护分支 | `Pinvou/CodeWhale:pinvou3-clean`,r13 head `f853f8f1`(r12 + #32 GAIA 评测隔离扩展) | +| 未发布候选 | r14 candidate `5a5bf363`(Shell 任务稳定来源身份) | | 已合并修复 | 既有 `#9`、`#11`、`#12`、`#13`、`#15`、`#16`、`#17`、`#19`,以及 r11 的 `#18`、`#21`、`#22`、`#25`、`#26`、`#27`、`#29`、`#30`,r12 的 `#33`、`#35`,r13 的 `#32` 均已合并 | -| 发布状态 | `pinvou3-clean` 与不可变 tag `pinvou-v0.9.5-r13` 均指向 `f853f8f1566c57e6be40d5439a222a932aa79ef5`;`r1` 至 `r13` 保持不可变;父仓 gitlink 由 PR #370 对齐 r13 | +| 发布状态 | `pinvou3-clean` 与不可变 tag `pinvou-v0.9.5-r13` 均指向 `f853f8f1566c57e6be40d5439a222a932aa79ef5`;`r1` 至 `r13` 保持不可变;r14 候选正在审核且尚未打标签,父仓开发分支临时指向精确候选提交 | | 旧基线备份 | tag `pinvou-v0.9.0-r4` + branch `backup/pinvou3-clean-v0.9.0-r4`,均指向 `03e9e1027c03ce1e4b35ab9e3ccce751b65b9624` | | 组织方式 | 从 `v0.9.5` clean re-fork 的 4 个当前长期主题;专用编排主题由 PR #13 整体撤销 | -| drift | r13 基线合计 `110 files, +10895/-1195`(净增 9700 行);r12→r13 为 `6 files, +1088/-1` | -| 守护 | r13 为 63 条 CodeWhale `forkguard_*` 行为测试(含 6 条 GAIA 评测隔离测试)+ 通用工具/路由兼容回归 + 父仓指纹/行为测试 | -| 父仓适配 | gitlink、`Cargo.lock`、`EngineConfig` v0.9.5 字段适配、拒绝编辑的终态/权威历史对账、压缩后用量即时刷新与持久化回填、严格直连模型大小写桥接回归、搜索源设置页引导文案,以及 operator-owned 未登记云端模型(自定义 openai-compatible 端点)的显式输出路由事实声明与官方端点 fail-closed 守护(承 PR #216) | +| drift | r13 基线合计 `110 files, +10895/-1195`(净增 9700 行);r12→r13 为 `6 files, +1088/-1`;r14 候选相对 r13 为 `7 files, +133/-7` | +| 守护 | r13 为 63 条 CodeWhale `forkguard_*` 行为测试(含 6 条 GAIA 评测隔离测试);r14 候选新增 2 条 Shell 来源身份行为测试(合计 65 条)及父仓时间线投影测试 | +| 父仓适配 | gitlink、`Cargo.lock`、`EngineConfig` v0.9.5 字段适配、拒绝编辑的终态/权威历史对账、压缩后用量即时刷新与持久化回填、严格直连模型大小写桥接回归、搜索源设置页引导文案、Shell 快照按来源工具卡回写与历史终态隔离,以及 operator-owned 未登记云端模型(自定义 openai-compatible 端点)的显式输出路由事实声明与官方端点 fail-closed 守护(承 PR #216) | ### r12 厂商原生搜索与免 key 兜底 Bing 化(已合入底座) @@ -24,6 +25,10 @@ - CodeWhale PR #35(`9c5f4f19` 汇入):API 后端(Tavily/Bocha/Metaso/Baidu/SearXNG/Volcengine/Sofya)失败后的免 key 链尾由 DuckDuckGo 换成 Bing(实测 DDG 在中国大陆 DNS 污染 + SNI 重置不可达,Bing 全球与国内端点均免 key 可达);全链失败错误追加 API 后端配置建议;新增 `forkguard_api_provider_chain_tail_is_bing`(forkguard 总数 56→57)。 - r12 父仓配套(PR #375):gitlink → `9c5f4f19`、设置页搜索源引导文案(i18n 三语)、`prefs/search.rs` 注释勘误与 `bridge.rs` 注入点注释/测试 docstring 同步勘误(底座默认仍为 DuckDuckGo,应用侧默认 Bing 由 bridge 构造 `EngineConfig` 时显式注入)。 +### r14 Shell 任务来源身份候选(未发布) + +- Shell job snapshot/completion event 增加创建它的 `origin_tool_call_id` 与 `origin_turn_id`;Engine 在实际分发前按工具调用盖章。应用桥优先回写来源卡;压缩或重载后来源卡缺失时,仍为运行中任务补可见状态卡,但不把已识别的历史终态根任务追加到当前时间线尾部。旧运行时保留安全的兼容回退。 + ### r11 Provider、MCP、steer 与平台边界(已发布) - CodeWhale PR #18、#21、#22、#25、#26、#27、#29 与 #30 以 `0d89a31be016457c180501417dd2c0f34ce844a6` 汇入公开 r11。严格直连 provider 仅在唯一自有模型行可确认时容忍 wire model 大小写差异;父仓补充 GLM 小写保存值到 canonical `GLM-5.2` 路由的桥接回归。 @@ -137,8 +142,8 @@ ### T2:工具兼容与命令执行安全 -- **commits**:`595adce47e2d1bcf895d7bfd6426c074eb969324`、`3bbf8421ebdb16bff71f83dac4d42c8fb65f0f02`(`#12`)、`a36e6cd533024cfe5724bae21875aea42b2ed87a`(`#13`)、`d127aed113529dc93754d044b9f352e9746f6b83`(`#15`)、`8aa5f77d35ac1d00d1f444193543307a7e9b391c`(`#16` 的 Shell 取消边界)、`44730dfe596b70f86ae2f928959877a3e3f494e4`(`#27`)、`665b46cd9e67326459223aa662931bd36d726004`(`#29`)、`04e109af4b4786a0d49fbbeefdd77af15a9f495e`(`#22`)、`4831c3797b76485a912b056c76a4cff22f0a2863`(`#25`)、`e68a185c2ba07f327bd8b63bbfea6a70a96f33ea`(`#26`)、`ecfd68acc056b95b06d98312753a712e4c0755db`、`603eeadcdab65d71d62a5ac32b6700207433fe5c`、`eb25a255a92f7385a3fde74f1f44626cdd068125`、`8c243e7ea7094fff189ab12582aea0460b655d06`、`8111f8150bc6b103da685f6abc3f26143b3bb207`、`4f612e548090616f8206154e37c9895404a8998b`(以上六项为 `#33` 厂商原生搜索)、`9c5f4f19b0acbc960889778a5873c7fb038b1378`(`#35` 免 key 链尾 Bing 化)、`f853f8f1566c57e6be40d5439a222a932aa79ef5`(`#32` GAIA 评测隔离扩展)。 -- **核心文件**:`core/engine.rs`、`core/engine/tool_setup.rs`、`core/ops.rs`、`tools/file.rs`、`command_safety.rs`、`tools/shell.rs`、`docs/TOOL_SURFACE.md`。 +- **commits**:`595adce47e2d1bcf895d7bfd6426c074eb969324`、`3bbf8421ebdb16bff71f83dac4d42c8fb65f0f02`(`#12`)、`a36e6cd533024cfe5724bae21875aea42b2ed87a`(`#13`)、`d127aed113529dc93754d044b9f352e9746f6b83`(`#15`)、`8aa5f77d35ac1d00d1f444193543307a7e9b391c`(`#16` 的 Shell 取消边界)、`44730dfe596b70f86ae2f928959877a3e3f494e4`(`#27`)、`665b46cd9e67326459223aa662931bd36d726004`(`#29`)、`04e109af4b4786a0d49fbbeefdd77af15a9f495e`(`#22`)、`4831c3797b76485a912b056c76a4cff22f0a2863`(`#25`)、`e68a185c2ba07f327bd8b63bbfea6a70a96f33ea`(`#26`)、`ecfd68acc056b95b06d98312753a712e4c0755db`、`603eeadcdab65d71d62a5ac32b6700207433fe5c`、`eb25a255a92f7385a3fde74f1f44626cdd068125`、`8c243e7ea7094fff189ab12582aea0460b655d06`、`8111f8150bc6b103da685f6abc3f26143b3bb207`、`4f612e548090616f8206154e37c9895404a8998b`(以上六项为 `#33` 厂商原生搜索)、`9c5f4f19b0acbc960889778a5873c7fb038b1378`(`#35` 免 key 链尾 Bing 化)、`f853f8f1566c57e6be40d5439a222a932aa79ef5`(`#32` GAIA 评测隔离扩展)、`5a5bf363ebeb720410f30e400c3de44abab71de6`(r14 候选 Shell 任务来源身份)。 +- **核心文件**:`core/engine.rs`、`core/engine/turn_loop.rs`、`core/engine/tool_setup.rs`、`core/ops.rs`、`tools/spec.rs`、`tools/file.rs`、`command_safety.rs`、`tools/shell.rs`、`docs/TOOL_SURFACE.md`。 - **内容**: - `EngineConfig.extra_tools` 让宿主工具在 Plan、Agent、Yolo 等 turn registry 中一致注册。 - `SetDisallowedTools` 支持工具商店、知识库和会话策略在不重建 Engine 的情况下动态收窄工具面。 @@ -146,6 +151,7 @@ - `File` 写入保持 64 KiB 单次内容上限,并在落盘前拒绝超限输入。 - 多行 Shell 按 segment 检查;破坏性命令在自动批准模式下仍被阻断。 - Engine 取消路径按 turn id 终止当前轮未被宿主接管的前台 Shell,不依赖工具 future drop;后台/宿主管理的任务保持原有所有权边界。 + - Engine 分发为 Shell 工作写入稳定的来源 tool call/turn identity;job snapshot 与 completion event 同步携带来源身份,宿主可按身份增量对账,不再用命令文本推测任务归属。 - schema 约束的 JSON 容器兼容、工具续轮 provider 角色顺序和已知内部 runtime suffix 展示清理继续沿用 r6 行为。 - Moonshot 工具 schema 按单个不兼容工具降级并发出一次用户可见诊断;具名选择不得指向已省略工具。宿主 MCP 密钥 resolver 不写进程环境,禁用 server 在 pool、catalog、直接调用、reload 与子智能体继承入口统一不可见。 - Windows Shell 跨 poll 保留增量解码状态,避免拆分 UTF-8 序列被替换;h2/lru 安全更新不改变公开接口。 @@ -156,6 +162,7 @@ - **上游计划**:逐轮权限、可信根覆盖、只读 action 投影和最终 dispatch 门禁是通用嵌入能力。当前 fork 版本已随 r8 发布;后续从最新 `Hmbown/CodeWhale` main 提交独立上游 PR。Pinvou profile 名称与 GAIA 工具名单继续留在 app;上游接收后删除 fork 对应实现和本地指纹。 - **边界**:不包含 Skill 来源、Automation 或产品角色协议。 - **守护**:`forkguard_host_extra_tools_register_in_all_modes`、`forkguard_file_content_caps_reject_before_writing`、`forkguard_multiline_still_blocks_destructive_segments`、registry prompt、Custom allowlist alias、`forkguard_session_trusted_roots_override_persisted_workspace_trust`、`forkguard_dispatch_allowlist_rejects_forged_calls_before_all_dispatch_backends`、`forkguard_read_only_turn_rejects_write_action_at_final_dispatch`、`forkguard_restricted_agent_uses_read_only_file_schema`、`forkguard_queued_control_op_keeps_restricted_turn_authority`、`forkguard_queued_goal_continuation_and_mcp_reload_keeps_restricted_turn_authority`、`forkguard_restricted_turn_defers_idle_subagent_completion_until_new_message`、`forkguard_restricted_agent_uses_hardened_read_only_shell_context`、`forkguard_restricted_turn_defers_idle_shell_wake_until_new_message`、`forkguard_restricted_turn_hooks_require_explicit_host_opt_in`、`forkguard_restricted_tool_audit_redacts_private_sentinel`。 +- **r14 候选守护**:`forkguard_background_shell_job_preserves_origin_identity` 验证 job snapshot 与完成事件保留稳定来源身份,`forkguard_tool_context_for_call_preserves_turn_and_sets_call_origin` 验证 Engine 分发时保留 turn 并写入 call origin;父仓 `forkguard_shell_monitor_assigns_identical_commands_by_stable_origin` 验证同命令并发任务不串绑,`shell_task_projection.test.mjs` 验证历史终态不追加、来源卡原位回写,以及来源卡缺失时运行中任务仍可见。 ### T3:嵌入上下文与技能来源 diff --git a/pinvou3-app/package.json b/pinvou3-app/package.json index 78ec3ac5a..453aabace 100644 --- a/pinvou3-app/package.json +++ b/pinvou3-app/package.json @@ -64,6 +64,7 @@ "test:multiagent-plan": "node --test tests/multiagent_plan_normalize.test.mjs", "test:chat-input-limit": "node tests/chat_input_limit_logic.test.js", "test:background-tasks": "node tests/background_tasks_logic.test.js", + "test:shell-task-projection": "node --test tests/shell_task_projection.test.mjs", "test:ime-compose-guard": "node tests/ime_compose_guard.test.mjs", "test:assistant-message-actions": "node tests/assistant_message_actions.test.mjs", "test:chat-error-isolation": "node tests/chat_turn_error_isolation.test.mjs", diff --git a/pinvou3-app/src-tauri/src/features/assistant/shell_output.rs b/pinvou3-app/src-tauri/src/features/assistant/shell_output.rs index a256600d9..30e67aaab 100644 --- a/pinvou3-app/src-tauri/src/features/assistant/shell_output.rs +++ b/pinvou3-app/src-tauri/src/features/assistant/shell_output.rs @@ -205,9 +205,20 @@ impl MonitorState { for (_, tool_id, command) in pending { let candidate = snapshots.iter().find(|job| { job.owner_agent_id.is_none() - && job.command == command + && job.origin_tool_call_id.as_deref() == Some(tool_id.as_str()) && !self.claimed_tasks.contains(&job.id) }); + // Compatibility for runtimes predating stable task origins. Never + // bind a job that carries a different explicit origin merely + // because its rendered command text happens to match. + let candidate = candidate.or_else(|| { + snapshots.iter().find(|job| { + job.owner_agent_id.is_none() + && job.origin_tool_call_id.is_none() + && job.command == command + && !self.claimed_tasks.contains(&job.id) + }) + }); let Some(candidate) = candidate else { continue; }; @@ -426,6 +437,8 @@ mod tests { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, } } @@ -441,6 +454,22 @@ mod tests { } } + fn track(state: &mut MonitorState, tool_id: &str, command: &str) { + let order = state.next_order; + state.next_order = state.next_order.saturating_add(1); + state.tools.insert( + tool_id.to_string(), + TrackedTool { + command: command.to_string(), + order, + task_id: None, + emitted_stdout: String::new(), + emitted_stderr: String::new(), + keep_after_tool_end: false, + }, + ); + } + #[test] fn assigns_new_job_by_command_and_coalesces_all_unseen_output() { let mut state = MonitorState::default(); @@ -480,6 +509,52 @@ mod tests { ); } + #[test] + fn forkguard_shell_monitor_assigns_identical_commands_by_stable_origin() { + let mut state = MonitorState::default(); + for tool_id in ["tool-1", "tool-2"] { + track(&mut state, tool_id, "same command"); + } + let mut second = snapshot("job-2", "same command", ShellStatus::Running); + second.origin_tool_call_id = Some("tool-2".to_string()); + let mut first = snapshot("job-1", "same command", ShellStatus::Running); + first.origin_tool_call_id = Some("tool-1".to_string()); + + state.assign_unclaimed_tasks(&[second, first]); + + assert_eq!(state.tools["tool-1"].task_id.as_deref(), Some("job-1")); + assert_eq!(state.tools["tool-2"].task_id.as_deref(), Some("job-2")); + } + + #[test] + fn explicit_origin_never_falls_back_to_a_matching_command() { + let mut state = MonitorState::default(); + track(&mut state, "tool-current", "same command"); + let mut old = snapshot("job-old", "same command", ShellStatus::Running); + old.origin_tool_call_id = Some("tool-old".to_string()); + + state.assign_unclaimed_tasks(&[old]); + + assert_eq!(state.tools["tool-current"].task_id, None); + } + + #[test] + fn legacy_fallback_skips_a_foreign_origin_with_the_same_command() { + let mut state = MonitorState::default(); + track(&mut state, "tool-current", "same command"); + let mut foreign = snapshot("job-foreign", "same command", ShellStatus::Running); + foreign.origin_tool_call_id = Some("tool-other".to_string()); + let legacy = snapshot("job-legacy", "same command", ShellStatus::Running); + + state.assign_unclaimed_tasks(&[foreign, legacy]); + + assert_eq!( + state.tools["tool-current"].task_id.as_deref(), + Some("job-legacy") + ); + assert!(!state.claimed_tasks.contains("job-foreign")); + } + #[test] fn holds_incomplete_utf8_replacement_until_a_stable_snapshot() { assert_eq!( diff --git a/pinvou3-app/src-tauri/src/features/assistant/turn_shell_tasks.rs b/pinvou3-app/src-tauri/src/features/assistant/turn_shell_tasks.rs index defe1cd62..1313ab975 100644 --- a/pinvou3-app/src-tauri/src/features/assistant/turn_shell_tasks.rs +++ b/pinvou3-app/src-tauri/src/features/assistant/turn_shell_tasks.rs @@ -1057,6 +1057,8 @@ mod tests { linked_task_id: None, owner_agent_id: owner_agent_id.map(str::to_string), owner_agent_name: owner_agent_id.map(str::to_string), + origin_tool_call_id: None, + origin_turn_id: None, } } diff --git a/pinvou3-app/src/platform/tauri/bridge/terminal.js b/pinvou3-app/src/platform/tauri/bridge/terminal.js index cc801517d..bc44c3560 100644 --- a/pinvou3-app/src/platform/tauri/bridge/terminal.js +++ b/pinvou3-app/src/platform/tauri/bridge/terminal.js @@ -27,11 +27,27 @@ } const SHELL_TOOL_NAMES = ["exec_shell", "exec_shell_wait", "exec_wait", "task_shell_start", "task_shell_wait", "shell", "Bash"]; + const SHELL_WAIT_TOOL_NAMES = ["exec_shell_wait", "exec_wait", "task_shell_wait"]; function isShellExecutionTool(name) { return SHELL_TOOL_NAMES.includes(name); } + function latestShellToolIsWaitObserver() { + for (let i = state.chatItems.length - 1; i >= 0; i--) { + const item = state.chatItems[i]; + if (item && item.type === "tool" && isShellExecutionTool(item.name)) { + // Since engine v0.9.3 the wait observer is the canonical Bash tool + // with action="wait"; the exec_shell_wait/exec_wait names survive + // only in replayed legacy sessions. Cards carry the action both live + // (chat:tool_start) and after history replay. + return SHELL_WAIT_TOOL_NAMES.includes(item.name) || + (item.name === "Bash" && item.args != null && item.args.action === "wait"); + } + } + return false; + } + function mentionsShellTool(text) { // 子智能体的工具调用不产生 chat:tool_start(forwarder 只把 Mailbox 的 // ToolCallStarted 转成 multiagent:agent_progress),只能从进展文本里认出 @@ -107,6 +123,20 @@ runningCommandCounts[command] = (runningCommandCounts[command] || 0) + 1; }); runSyncOnSession(sid, function () { + // A wait tool only observes existing work and cannot create a job, and + // the manager retains completed jobs across later waits, so an + // unmatched terminal snapshot beside a trailing wait card belongs to + // earlier work and must not be appended after newer results. Decide + // once per poll from the pre-poll timeline: the synthetic card of a + // running job from this same batch (the manager lists running jobs + // first) would otherwise disarm the guard for the jobs after it. + // Accepted limits when no card binds: a start tool can still race with + // a very short detached job whose first snapshot is terminal (the guard + // is off when the latest card is a start tool; origin identity shields + // root jobs there, but subagent-owned and legacy origin-less jobs can + // still append), and a brand-new subagent job started after the wait + // card is conservatively hidden like retained older work. + const suppressUnmatchedTerminal = latestShellToolIsWaitObserver(); (jobs || []).forEach(function (job) { const status = String(job.status || "").toLowerCase(); const running = status === "running"; @@ -114,6 +144,11 @@ let item = state.chatItems.find(function (it) { return it.type === "tool" && it.taskId === job.id; }); + if (!item && job.origin_tool_call_id) { + item = state.chatItems.find(function (it) { + return it.type === "tool" && it.toolId === job.origin_tool_call_id; + }); + } if (!item && running) { const command = String(job.command || ""); const candidates = state.chatItems.filter(function (it) { @@ -130,9 +165,12 @@ }); if (item) item.shellHistoryReconciled = true; } - // A detached job may have been started by a subagent, so no matching - // top-level tool card exists. Completed jobs must also get a card: the - // first poll may happen after a short detached process already exited. + if (!item && !running && suppressUnmatchedTerminal) return; + // An identified completed root job must only update its origin card. + // If compaction or reload removed that card, do not append historical + // output at the current tail. Keep running jobs visible through a + // synthetic card; their live status must not disappear after reload. + if (!item && !running && job.origin_tool_call_id && !job.owner_agent_id) return; if (!item) { item = { type: "tool", toolId: "shell-task:" + job.id, name: "exec_shell", @@ -147,6 +185,8 @@ item.taskId = job.id; item.sessionId = sid; item.shellStatus = job.status; + item.originToolCallId = job.origin_tool_call_id || null; + item.originTurnId = job.origin_turn_id || null; item.exitCode = job.exit_code; item.elapsedMs = job.elapsed_ms; if (!item.shellHistoryReconciled || item.output == null || running) { diff --git a/pinvou3-app/src/platform/web/bridge.js b/pinvou3-app/src/platform/web/bridge.js index 72825d0ac..fb597ff4c 100644 --- a/pinvou3-app/src/platform/web/bridge.js +++ b/pinvou3-app/src/platform/web/bridge.js @@ -4310,11 +4310,28 @@ } const SHELL_TOOL_NAMES = ["exec_shell", "task_shell_start", "shell", "Bash"]; + const SHELL_WAIT_TOOL_NAMES = ["exec_shell_wait", "exec_wait", "task_shell_wait"]; function isShellExecutionTool(name) { return SHELL_TOOL_NAMES.includes(name); } + function latestShellToolIsWaitObserver() { + for (let i = state.chatItems.length - 1; i >= 0; i--) { + const item = state.chatItems[i]; + if (item && item.type === "tool" && + (isShellExecutionTool(item.name) || SHELL_WAIT_TOOL_NAMES.includes(item.name))) { + // Since engine v0.9.3 the wait observer is the canonical Bash tool + // with action="wait"; the exec_shell_wait/exec_wait names survive + // only in replayed legacy sessions. Cards carry the action both live + // (chat:tool_start) and after history replay. + return SHELL_WAIT_TOOL_NAMES.includes(item.name) || + (item.name === "Bash" && item.args != null && item.args.action === "wait"); + } + } + return false; + } + function mentionsShellTool(text) { // 子智能体的工具调用不产生 chat:tool_start,forwarder 把 mailbox 的 // ToolCallStarted 转成 multiagent:agent_progress(status 形如 @@ -4412,6 +4429,20 @@ runningCommandCounts[command] = (runningCommandCounts[command] || 0) + 1; }); runSyncOnSession(sid, function () { + // A wait tool only observes existing work and cannot create a job, and + // the manager retains completed jobs across later waits, so an + // unmatched terminal snapshot beside a trailing wait card belongs to + // earlier work and must not be appended after newer results. Decide + // once per poll from the pre-poll timeline: the synthetic card of a + // running job from this same batch (the manager lists running jobs + // first) would otherwise disarm the guard for the jobs after it. + // Accepted limits when no card binds: a start tool can still race with + // a very short detached job whose first snapshot is terminal (the guard + // is off when the latest card is a start tool; origin identity shields + // root jobs there, but subagent-owned and legacy origin-less jobs can + // still append), and a brand-new subagent job started after the wait + // card is conservatively hidden like retained older work. + const suppressUnmatchedTerminal = latestShellToolIsWaitObserver(); (jobs || []).forEach(function (job) { const status = String(job.status || "").toLowerCase(); const running = status === "running"; @@ -4419,6 +4450,11 @@ let item = state.chatItems.find(function (it) { return it.type === "tool" && it.taskId === job.id; }); + if (!item && job.origin_tool_call_id) { + item = state.chatItems.find(function (it) { + return it.type === "tool" && it.toolId === job.origin_tool_call_id; + }); + } if (!item && running) { const command = String(job.command || ""); const candidates = state.chatItems.filter(function (it) { @@ -4435,9 +4471,12 @@ }); if (item) item.shellHistoryReconciled = true; } - // A detached job may have been started by a subagent, so no matching - // top-level tool card exists. Completed jobs must also get a card: the - // first poll may happen after a short detached process already exited. + if (!item && !running && suppressUnmatchedTerminal) return; + // An identified completed root job must only update its origin card. + // If compaction or reload removed that card, do not append historical + // output at the current tail. Keep running jobs visible through a + // synthetic card; their live status must not disappear after reload. + if (!item && !running && job.origin_tool_call_id && !job.owner_agent_id) return; if (!item) { item = { type: "tool", toolId: "shell-task:" + job.id, name: "exec_shell", @@ -4452,6 +4491,8 @@ item.taskId = job.id; item.sessionId = sid; item.shellStatus = job.status; + item.originToolCallId = job.origin_tool_call_id || null; + item.originTurnId = job.origin_turn_id || null; item.exitCode = job.exit_code; item.elapsedMs = job.elapsed_ms; if (!item.shellHistoryReconciled || item.output == null || running) { diff --git a/pinvou3-app/tests/shell_task_projection.test.mjs b/pinvou3-app/tests/shell_task_projection.test.mjs new file mode 100644 index 000000000..2c1a7ecd2 --- /dev/null +++ b/pinvou3-app/tests/shell_task_projection.test.mjs @@ -0,0 +1,355 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const terminalPath = path.join(appRoot, 'src', 'platform', 'tauri', 'bridge', 'terminal.js'); +const terminalSource = fs.readFileSync(terminalPath, 'utf8'); + +function createTerminal(initialItems = []) { + const chatItems = structuredClone(initialItems); + const windowObject = { + __PINVOU_TAURI_BRIDGE_FEATURES__: {}, + setTimeout, + }; + const scriptContext = vm.createContext({ + window: windowObject, + TextEncoder, + console, + setTimeout, + clearTimeout, + }); + vm.runInContext(terminalSource, scriptContext, { filename: terminalPath }); + + let notifications = 0; + const terminal = windowObject.__PINVOU_TAURI_BRIDGE_FEATURES__.terminal({ + state: { chatItems, activeSessionId: 'session-current' }, + notify() { notifications += 1; }, + invoke: async () => [], + bt(key) { + if (key === 'shellOutputOmitted') return kind => `${kind} omitted`; + if (key === 'shellTaskFinished') return code => `finished: ${code}`; + if (key === 'shellUnknownExit') return 'unknown'; + throw new Error(`unexpected translation key: ${key}`); + }, + runSyncOnSession(_sessionId, callback) { callback(); }, + addChatItem(item) { chatItems.push(item); }, + }); + + return { chatItems, terminal, notifications: () => notifications }; +} + +function snapshot(overrides = {}) { + return { + id: 'shell-old', + command: 'winget search "BaiduNetdisk"', + status: 'failed', + exit_code: 1, + elapsed_ms: 300, + stdout_len: 0, + stderr_len: 16, + stdout_tail: '', + stderr_tail: 'old task failed', + ...overrides, + }; +} + +test('completed historical shell jobs are not inserted into the current timeline', () => { + const harness = createTerminal([{ + type: 'tool', + toolId: 'current-wait', + name: 'exec_shell_wait', + state: 'done', + output: '13', + }]); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot()]); + + assert.equal(running, false); + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.notifications(), 0); + assert.equal(harness.chatItems.some(item => item.toolId === 'shell-task:shell-old'), false); +}); + +test('running detached shell jobs still receive a synthetic status card', () => { + const harness = createTerminal(); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot({ + id: 'shell-current', + command: '1..100', + status: 'running', + exit_code: null, + stdout_len: 2, + stderr_len: 0, + stdout_tail: '13', + stderr_tail: '', + })]); + + assert.equal(running, true); + assert.equal(harness.notifications(), 1); + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.chatItems[0].toolId, 'shell-task:shell-current'); + assert.equal(harness.chatItems[0].taskId, 'shell-current'); + assert.equal(harness.chatItems[0].state, 'running'); + assert.equal(harness.chatItems[0].output, '13'); +}); + +test('completed shell jobs still update their explicitly linked tool card', () => { + const harness = createTerminal([{ + type: 'tool', + toolId: 'tool-old', + taskId: 'shell-old', + name: 'exec_shell', + state: 'running', + args: { command: 'winget search "BaiduNetdisk"' }, + output: null, + }]); + + harness.terminal.applyShellSnapshots('session-current', [snapshot()]); + + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.chatItems[0].state, 'failed'); + assert.equal(harness.chatItems[0].success, false); + assert.match(harness.chatItems[0].output, /old task failed/); + assert.equal(harness.notifications(), 1); +}); + +test('a fast detached job can still appear after its start tool', () => { + const harness = createTerminal([{ + type: 'tool', + toolId: 'start-fast-detached', + name: 'exec_shell', + state: 'done', + output: 'running in background', + }]); + + harness.terminal.applyShellSnapshots('session-current', [snapshot({ + id: 'shell-fast-detached', + command: 'fast-detached', + status: 'completed', + exit_code: 0, + stdout_tail: 'done fast', + stderr_tail: '', + })]); + + assert.equal(harness.chatItems.length, 2); + assert.equal(harness.chatItems[1].toolId, 'shell-task:shell-fast-detached'); + assert.equal(harness.chatItems[1].state, 'done'); + assert.match(harness.chatItems[1].output, /done fast/); +}); + +test('the guard is not disarmed by a running job projected in the same poll', () => { + const harness = createTerminal([{ + type: 'tool', + toolId: 'current-wait', + name: 'exec_shell_wait', + state: 'done', + output: '13', + }]); + + const running = harness.terminal.applyShellSnapshots('session-current', [ + snapshot({ + id: 'shell-live', + command: 'long task', + status: 'running', + exit_code: null, + stdout_len: 0, + stderr_len: 0, + stdout_tail: '', + stderr_tail: '', + }), + snapshot(), + ]); + + assert.equal(running, true); + assert.equal(harness.chatItems.length, 2); + assert.equal(harness.chatItems[1].toolId, 'shell-task:shell-live'); + assert.equal(harness.chatItems[1].state, 'running'); + assert.equal(harness.chatItems.some(item => item.toolId === 'shell-task:shell-old'), false); + assert.equal(harness.notifications(), 1); +}); + +test('the canonical Bash action=wait card also arms the guard', () => { + // Since engine v0.9.3 the wait observer is Bash with action="wait" + // (exec_shell_wait/exec_wait survive only in replayed legacy sessions). + const harness = createTerminal([{ + type: 'tool', + toolId: 'bash-wait', + name: 'Bash', + state: 'done', + args: { action: 'wait', task_id: 'task-1' }, + output: '13', + }]); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot()]); + + assert.equal(running, false); + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.notifications(), 0); + assert.equal(harness.chatItems.some(item => item.toolId === 'shell-task:shell-old'), false); +}); + +test('a Bash action=run card is a start tool and does not arm the guard', () => { + // Accepted limit: the guard stays off behind a start tool, so a very short + // detached job whose first snapshot is terminal still gets its card. + const harness = createTerminal([{ + type: 'tool', + toolId: 'bash-run', + name: 'Bash', + state: 'done', + args: { action: 'run', command: 'fast-detached' }, + output: 'running in background', + }]); + + harness.terminal.applyShellSnapshots('session-current', [snapshot({ + id: 'shell-fast-after-run', + command: 'fast-detached', + status: 'completed', + exit_code: 0, + stdout_tail: 'done fast', + stderr_tail: '', + })]); + + assert.equal(harness.chatItems.length, 2); + assert.equal(harness.chatItems[1].toolId, 'shell-task:shell-fast-after-run'); +}); + +test('a brand-new subagent job first seen terminal after an older wait card is conservatively hidden', () => { + // The wait-observer guard cannot tell this terminal-first snapshot apart + // from retained older work, and the job's own origin card has not rendered + // yet, so it stays hidden until that card lands. + const harness = createTerminal([{ + type: 'tool', + toolId: 'old-wait', + name: 'exec_shell_wait', + state: 'done', + output: 'ok', + }]); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot({ + id: 'shell-fast-subagent', + command: 'echo hi', + status: 'completed', + exit_code: 0, + stdout_tail: 'hi', + stderr_tail: '', + owner_agent_id: 'agent-secondary', + })]); + + assert.equal(running, false); + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.chatItems[0].toolId, 'old-wait'); + assert.equal(harness.notifications(), 0); +}); + +test('runtime origin identity reconciles a completed job at its original tool card', () => { + const harness = createTerminal([{ + type: 'tool', + toolId: 'tool-old', + name: 'exec_shell', + state: 'done', + args: { command: 'different rendered command' }, + output: 'starting', + }, { + type: 'tool', + toolId: 'current-wait', + name: 'exec_shell_wait', + state: 'done', + output: '13', + }]); + + harness.terminal.applyShellSnapshots('session-current', [snapshot({ + origin_tool_call_id: 'tool-old', + origin_turn_id: 'turn-old', + })]); + + assert.equal(harness.chatItems.length, 2); + assert.equal(harness.chatItems[0].taskId, 'shell-old'); + assert.equal(harness.chatItems[0].originToolCallId, 'tool-old'); + assert.equal(harness.chatItems[0].originTurnId, 'turn-old'); + assert.match(harness.chatItems[0].output, /old task failed/); + assert.equal(harness.chatItems[1].output, '13'); +}); + +test('identified running root jobs stay visible when their origin card is not loaded', () => { + const harness = createTerminal(); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot({ + status: 'running', + exit_code: null, + origin_tool_call_id: 'tool-before-compaction', + origin_turn_id: 'turn-old', + })]); + + assert.equal(running, true); + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.chatItems[0].toolId, 'shell-task:shell-old'); + assert.equal(harness.chatItems[0].state, 'running'); + assert.equal(harness.notifications(), 1); +}); + +test('identified completed root jobs without their origin card stay out of the current tail', () => { + const harness = createTerminal(); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot({ + origin_tool_call_id: 'tool-before-compaction', + origin_turn_id: 'turn-old', + })]); + + assert.equal(running, false); + assert.equal(harness.chatItems.length, 0); + assert.equal(harness.notifications(), 0); +}); + +test('identified completed subagent jobs without their origin card stay visible', () => { + // The origin suppression targets root jobs only: a subagent-owned job with + // a missing origin card must keep a live status card like an origin-less + // job, because the monitor emission handlers only update existing cards. + const harness = createTerminal(); + + const running = harness.terminal.applyShellSnapshots('session-current', [snapshot({ + origin_tool_call_id: 'tool-before-compaction', + origin_turn_id: 'turn-old', + owner_agent_id: 'agent-secondary', + })]); + + assert.equal(running, false); + assert.equal(harness.chatItems.length, 1); + assert.equal(harness.chatItems[0].toolId, 'shell-task:shell-old'); + assert.equal(harness.chatItems[0].state, 'failed'); + assert.equal(harness.chatItems[0].shellStatus, 'failed'); + assert.equal(harness.notifications(), 1); +}); + +test('the web bridge keeps the same stale-completion guard', () => { + const webBridge = fs.readFileSync( + path.join(appRoot, 'src', 'platform', 'web', 'bridge.js'), + 'utf8', + ); + assert.match( + webBridge, + /if \(!item && !running && suppressUnmatchedTerminal\) return;/, + ); + // The web helper scans a different name set (its SHELL_TOOL_NAMES lacks the + // wait names), so the union clause is the real cross-bridge parity point. + assert.match( + webBridge, + /\(isShellExecutionTool\(item\.name\) \|\| SHELL_WAIT_TOOL_NAMES\.includes\(item\.name\)\)/, + ); + assert.match( + webBridge, + /const suppressUnmatchedTerminal = latestShellToolIsWaitObserver\(\);/, + ); + // The canonical Bash action=wait recognition must exist on both bridges. + assert.match( + webBridge, + /item\.name === "Bash" && item\.args != null && item\.args\.action === "wait"/, + ); + assert.match( + webBridge, + /if \(!item && !running && job\.origin_tool_call_id && !job\.owner_agent_id\) return;/, + ); +}); diff --git a/scripts/fork-guard.sh b/scripts/fork-guard.sh index d873b8c8b..8f03273dc 100755 --- a/scripts/fork-guard.sh +++ b/scripts/fork-guard.sh @@ -6,8 +6,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TUI="$REPO/CodeWhale" APP="$REPO/pinvou3-app/src-tauri" EXPECTED_UPSTREAM="853cb707bbcf4f7dc4268fba6d811e0d04083f9c" -PUBLISHED_HEAD="f853f8f1566c57e6be40d5439a222a932aa79ef5" -PUBLISHED_COMMITS=37 +EXPECTED_HEAD="5a5bf363ebeb720410f30e400c3de44abab71de6" +EXPECTED_COMMITS=38 FAST_ONLY=0 [[ "${1:-}" == "--fast" ]] && FAST_ONLY=1 @@ -17,14 +17,12 @@ bold() { printf '\033[1m%s\033[0m\n' "$*"; } fail=0 -bold "── 第 0 层:v0.9.5 r13 公开四主题基线拓扑 ──" +bold "── 第 0 层:v0.9.5 r14 候选四主题基线拓扑 ──" actual_head="$(git -C "$TUI" rev-parse HEAD 2>/dev/null || true)" -if [[ "$actual_head" == "$PUBLISHED_HEAD" ]]; then - expected_commits="$PUBLISHED_COMMITS" - green " ✓ CodeWhale gitlink 指向 r13 四主题公开基线 $PUBLISHED_HEAD" +if [[ "$actual_head" == "$EXPECTED_HEAD" ]]; then + green " ✓ CodeWhale gitlink 指向登记的 r14 未发布候选 $EXPECTED_HEAD" else - expected_commits="" - red " ✗ CodeWhale HEAD 为 ${actual_head:-},应为 r13 公开 head $PUBLISHED_HEAD" + red " ✗ CodeWhale HEAD 为 ${actual_head:-},应为 r14 候选 $EXPECTED_HEAD" fail=1 fi @@ -36,10 +34,10 @@ else fi commit_count="$(git -C "$TUI" rev-list --count "$EXPECTED_UPSTREAM..HEAD" 2>/dev/null || true)" -if [[ -n "$expected_commits" && "$commit_count" == "$expected_commits" ]]; then - green " ✓ v0.9.5 之上 $expected_commits 个登记提交" +if [[ "$commit_count" == "$EXPECTED_COMMITS" ]]; then + green " ✓ v0.9.5 之上 $EXPECTED_COMMITS 个登记提交" else - red " ✗ v0.9.5 之上有 ${commit_count:-} 个 commit,登记拓扑应为 ${expected_commits:-37}" + red " ✗ v0.9.5 之上有 ${commit_count:-} 个 commit,登记拓扑应为 $EXPECTED_COMMITS" fail=1 fi @@ -116,6 +114,10 @@ fingerprints=( "T2|全链失败建议配置 API 搜索后端 |CodeWhale/crates/tui/src/tools/web/backend.rs|configure an API-backed [search] provider" "T2|cancel 只杀本轮前台 shell |CodeWhale/crates/tui/src/tools/shell.rs|fn kill_running_turn_foreground" "T2|前台范围 kill 不误杀后台回归 |CodeWhale/crates/tui/src/tools/shell/tests.rs|fn kill_running_turn_foreground_scopes_to_this_turns_unowned_foreground_shells" + "T2|Shell job 保留稳定来源身份 |CodeWhale/crates/tui/src/tools/shell.rs|pub origin_tool_call_id: Option" + "T2|Engine 分发盖章来源工具调用 |CodeWhale/crates/tui/src/core/engine/turn_loop.rs|fn tool_context_for_call(" + "T2|Shell 来源身份行为回归 |CodeWhale/crates/tui/src/tools/shell/tests.rs|fn forkguard_background_shell_job_preserves_origin_identity" + "T2|Engine 来源身份行为回归 |CodeWhale/crates/tui/src/core/engine/turn_loop.rs|fn forkguard_tool_context_for_call_preserves_turn_and_sets_call_origin" "T3|ambient project authority 密封 |CodeWhale/crates/tui/src/project_context.rs|fn forkguard_runtime_loader_ignores_ambient_project_authority" "T3|Permissions 100 KiB 窄例外回归 |CodeWhale/crates/tui/src/prompts.rs|fn forkguard_instruction_fragment_preserves_content_beyond_default_cap" "T3|disabled Skill 不可见且不可加载 |CodeWhale/crates/tui/src/skills/tests.rs|fn forkguard_disabled_skill_is_neither_rendered_nor_loadable" @@ -160,6 +162,9 @@ fingerprints=( "APP|落盘兜底编辑截断与底座同口径 |pinvou3-app/src-tauri/src/features/sessions/tests.rs|fn forkguard_admitted_display_fallback_edit_cuts_before_trailing_tool_result" "APP|不支持的最新用户内容不可回退到旧轮 |pinvou3-app/src-tauri/src/features/sessions/tests.rs|fn forkguard_admitted_display_fallback_does_not_skip_unsupported_user_turn" "APP|拒绝编辑终态触发权威历史回滚 |pinvou3-app/src-tauri/src/features/assistant/engine.rs|\"operation_rejected\": operation_rejected" + "APP|Shell 快照按来源工具卡原位回写 |pinvou3-app/src/platform/tauri/bridge/terminal.js|it.toolId === job.origin_tool_call_id" + "APP|Shell 监控按来源区分同命令任务 |pinvou3-app/src-tauri/src/features/assistant/shell_output.rs|fn forkguard_shell_monitor_assigns_identical_commands_by_stable_origin" + "APP|历史 Shell 终态不追加到当前时间线 |pinvou3-app/tests/shell_task_projection.test.mjs|completed historical shell jobs are not inserted into the current timeline" ) # r13 同时包含 r12 搜索边界与正式发布的 GAIA 评测隔离扩展。 diff --git a/scripts/tests/test_ci_gate_policy.py b/scripts/tests/test_ci_gate_policy.py index 55931fd49..2c6b9a9a8 100644 --- a/scripts/tests/test_ci_gate_policy.py +++ b/scripts/tests/test_ci_gate_policy.py @@ -112,7 +112,7 @@ def test_pr_submodule_verifier_strictly_matches_the_published_tag(self): self.assertNotIn("--allow-registered-candidate", verifier_gate) self.assertNotIn("LOCAL_SECURITY_HEAD", verifier) self.assertIn('[[ "$tag_target" != "$gitlink" ]]', verifier) - self.assertIn('PINVOU_CODEWHALE_TAG="pinvou-v0.9.5-r13"', verifier) + self.assertIn('PINVOU_CODEWHALE_TAG="pinvou-v0.9.5-r14"', verifier) self.assertIn("unknown argument", verifier) def test_pr_modes_and_stacked_pr_triggers_are_explicit(self): diff --git a/scripts/verify-public-submodule.sh b/scripts/verify-public-submodule.sh index 6afb7d7e6..07148dded 100755 --- a/scripts/verify-public-submodule.sh +++ b/scripts/verify-public-submodule.sh @@ -4,7 +4,7 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PINVOU_CODEWHALE_PATH="CodeWhale" PINVOU_CODEWHALE_URL="https://github.com/Pinvou/CodeWhale.git" -PINVOU_CODEWHALE_TAG="pinvou-v0.9.5-r13" +PINVOU_CODEWHALE_TAG="pinvou-v0.9.5-r14" if [[ $# -ne 0 ]]; then echo "unknown argument: $1" >&2