From bcd2fe5259a360679ba8f97f1fe53e59567cb160 Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:02:43 +0800 Subject: [PATCH 1/2] fix: validate source cache inputs and preserve healthy workspace continuity --- CHANGELOG.md | 6 + ...43\350\256\241\345\210\222\344\271\246.md" | 37 ++-- ...43\350\267\257\347\272\277\345\233\276.md" | 13 +- docs/codex_worklog.md | 41 ++++ package.json | 2 +- scripts/roslyn/gateway-lifecycle.mjs | 17 +- scripts/verify-multi-agent.mjs | 12 +- scripts/verify-roslyn-gateway.mjs | 40 ++++ skills/wincode/references/diagnostics.md | 2 +- src/Adapters/LocalTextAdapter.ts | 19 +- src/Adapters/RepomixAdapter.ts | 61 +++--- src/Core/Cache.ts | 31 +++ src/Core/ResourceManager.ts | 67 +++---- src/Core/ToolRouter.ts | 33 +++- src/Core/Workspace.ts | 3 +- src/Gateway/WorkspaceTools.ts | 2 +- tests/fixtures/cache-peer.mjs | 32 +++ tests/flaui-adapter.test.ts | 3 +- tests/local-text.test.ts | 2 +- tests/repomix-disabled.test.ts | 15 +- tests/request-concurrency.test.ts | 58 ++++++ tests/roslyn-contracts.test.ts | 52 +++++ tests/runtime-cache-regressions.test.ts | 185 ++++++++++++++++++ tools/WinCode.Tray/TrayAcceptance.cs | 19 +- 24 files changed, 605 insertions(+), 147 deletions(-) create mode 100644 tests/fixtures/cache-peer.mjs create mode 100644 tests/runtime-cache-regressions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1c6ea..c96a42e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.14.0 (unreleased) +- Bind builtin context cache reuse to the selected file contents; rescan bounded local-text inputs and reuse declarations by content within the existing memory budget. Untracked directories, deep files, additions/deletions and same-size edits no longer reuse stale query results. CLI output without an input manifest is not reused as a cached snapshot. +- Treat a missing overflow attachment as a cache miss for memory and disk readers, including after another process prunes the shared cache. +- Confirm healthy same-root workspaces without draining business requests. Cancelling read-only confirmation does not enter recovery or reset a healthy Host; real rebind and cleanup failures retain the recovery gate. +- Preserve the healthy Roslyn Host and snapshot on repeated same-workspace opens, including Windows case/separator aliases. Retain typed restart/reload requirements and sticky cleanup recovery; concurrent recovery confirmations reset only once. +- Remove cancelled mutex waiters immediately while preserving FIFO order and ownership through running-task cleanup. This is cancellation hygiene; bounded MCP admission remains planned. + - Make stale/failed tray observations explicit, refresh passively before manual release, report registration failures and keep native listeners available after malformed registrations. Preserve warm Roslyn state across settings visibility and connection changes. - Bind native Release source inputs and complete published outputs at build time; reject stale native artifacts even when regenerating the delivery manifest. diff --git "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" index a4456a7..06fefec 100644 --- "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" +++ "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" @@ -1,8 +1,8 @@ # WinCode 下一轮工程化迭代计划书 -更新:2026-09-10 11:57(北京时间)。当前工作区:D:/CODEX PROJECT/WinCode MCP;分支 codex/m1-parent-ownership,HEAD 6e27e06,工作树为 0.14.0 未发布增量。 +更新:2026-09-10 14:01(北京时间)。工作区 D:/CODEX PROJECT/WinCode MCP;修复分支 codex/runtime-cache-continuity 基于 origin/main dda0203(已合并 #34),保留上一轮增量并完成本轮四类验收缺陷修复。 -本版依据当前代码、2026-09-10 多实例诊断和 GitHub 一手实现重新收敛。用户本次授权回顾、推荐和修改规划文档;下面的新行为是最终推荐方案,尚未实施,也不将文档写入视为新公共契约或真实客户端配置已获实施授权。已确认完成的旧 M0–M4 工作项已从待办中移除,历史证据保留在 [工作日志](docs/codex_worklog.md) 与 [CHANGELOG](CHANGELOG.md)。 +本版依据当前代码、多实例诊断和 GitHub 一手实现维护。用户随后已明确要求按计划实施;N1–N3 的推荐方向已确认,不重复申请方向审批。本轮先交付可独立验证的 N2 热态保留与 N3 的互斥取消基础,固定根迁移和完整准入容量尚未实现。已完成的 M0–M4、N2 从待办移除,历史证据保留在 [工作日志](docs/codex_worklog.md) 与 [CHANGELOG](CHANGELOG.md)。真实客户端配置和对外发布另按有效授权执行。 ## 1. 最终推荐 @@ -26,15 +26,14 @@ 已从开发待办移除:本机交付重建、原生 owner guard、UIA 启动探测延后、可逆手动释放、最小 WinForms 托盘/安全管道、状态过期与注册拒绝反馈、原生源码与交付绑定,以及已经运行的双实例托盘/Roslyn 贯通。对应核心 360/360、桌面 35/35 和后续专项回执见 [11:32 稳定性记录](docs/codex_worklog.md#2026-09-10-1132--0140-稳定性收尾工作流连续性状态可信度与原生交付北京时间)。这些是已有回归基线,不是下一轮重新建设任务。 -当前 delivery 已重新核验 matched=true,contentId=132e047e7d81a73a26b3b1cc24623ee464fa653f718049f30af1d1873c54fa7d。该身份对应本机已构建产物;不能用较早 core 回执证明其后所有原生修改都重跑过核心测试,也不代表正在运行的旧消费者已更新。 +N2 已取得核心 364/364、真实 Roslyn MCP 22 场景和 E4 错误契约 16 场景证据;同根十次重复打开、四次并发确认保持实际 Host PID/snapshot,SDK 变化仍要求新进程,冷加载与热重载损坏、取消/崩溃/超时分别验证。互斥队列取消会立即删除等待节点,执行中的清理仍持锁,但这还不是完整有界准入。当前交付身份与补充桌面/多实例回执统一记录在工作日志;不能据此声称正在运行的旧消费者已更新。 | 现象 | 当前证据等级 | 下一步 | | --- | --- | --- | | A 打开 A,B 在同一连接打开 B,A 普通名称查询得到 B.Api.Save(int) | **已复现**;旧精确 symbolLocation 会拒绝,但普通名称和相对路径跟随最后一次切换 | N1 固定连接工作区,错误目标在变更前拒绝 | -| 健康状态同路径 workspace_open 后原 Host 退出,下一搜索重载 | **已复现**;一次小项目重载约 3.9 秒 | N2 保留热态,同时保留必要恢复 | | 单实例 128 个搜索全部完成,但最长约 14 秒 | **已观察**;inFlight=129 包含 hello;代码无等待数量上限,未复现 OOM | N3 有界受理与等待,不把超时/缓存预算当总内存上限 | | drain 监听器超过默认数量 | **已定位至测试客户端 SDK**;任务结束后监听器为 0,Gateway 没有同类警告 | 保留诊断;不抬高阈值或更换依赖冒充修复 | -| 独立实例共享同项目 cacheDir 的写入/清理 | **未验证**;本轮 Roslyn 查询 cache entries=0 | N4 先用实际缓存/打包路径并发验证 | +| 独立实例共享同项目 cacheDir 的写入/清理 | 已复现并修复 peer prune 后悬空 overflow 命中;两进程回归通过 | N4 保留其他写入/退出交错与源码/UI 边界,不重复建设已通过用例 | | 一个原生托盘专项没有生成 UI 回执 | **既有未定位失败**;旧脚本未保留退出码,超时只是推测,随后三次未复现 | N5 利用已补诊断复查,不能写成已修复 | | 实际 Codex 的最新构建/Roslyn 消费闭环、其他软件接入、Node 22/远端 CI、长期大项目资源趋势 | **仍未完成或范围不足** | N5 保留并逐项完成,不从旧计划中误删 | @@ -56,14 +55,14 @@ ## 4. 只包含未完成工作的实施顺序 -推荐顺序:N1 → N2 → N3 → N4 → N5。N1/N2 可在同一开发批次交付,但分别保留验收。具体版本号在实施时确定,不按旧 M0–M5 的建议版本重发已有功能。 +剩余顺序:N1 → N3 → N4 → N5。N2 已先完成独立实现和验收;N1 改变跨项目公共行为时,仍须迁移已有切换及故障注入测试,不能以删掉故障覆盖换取通过。具体版本号在实施时确定,不按旧 M0–M5 的建议版本重发已有功能。 ### N1:连接固定项目,阻止任务串线 目标:从根本上去除同一连接“最后一次 workspace_open 决定全部后续调用”的隐式切换。 1. 新推荐契约为 Gateway 在启动时绑定当前配置工作区;客户端配置必须优先提供明确的绝对 --workspace。现有 cwd 回退若保留,hello 必须标明根及来源,迁移检查不得把未知 cwd 当用户选择。第一版不增加自动发现项目或后台按项目新建进程的管理器。 -2. workspace_open 指向同一已绑定根时走 N2;指向另一根时返回拟定 WORKSPACE_MISMATCH,附 activeWorkspace/requestedWorkspace 和选择正确项目连接的恢复建议。拒绝发生在修改 config、watcher、缓存命名空间、trash、求值或重置 Host 之前;不能仅在结果返回时补校验。 +2. workspace_open 指向同一已绑定根时沿用已实现的热态保留/恢复行为;指向另一根时返回拟定 WORKSPACE_MISMATCH,附 activeWorkspace/requestedWorkspace 和选择正确项目连接的恢复建议。拒绝发生在修改 config、watcher、缓存命名空间、trash、求值或重置 Host 之前;不能仅在结果返回时补校验。 3. 绑定根由 Router/WorkspaceManager 共同遵守,不能只保护 MCP 外层而允许内部调用换根;检查别名、组合工具和所有项目相关调用。用户目标是不同目录下的整个项目,同一工作区内部已有的项目引用规则保持。 4. 路径身份复用当前安全规则,覆盖 Windows 大小写/分隔符、中文空格、父目录穿越和目录联接。不能为了把路径“认成同一个”而绕过现有链接拒绝或扩大项目求值边界。 5. 不同时引入“默认固定根”和“后台仍可随意切换”的双重语义。现有 A→B→A 正常切换是公共行为,改变它属于明确迁移:更新 Schema 描述、错误契约、Skill 源文件、示例及相应测试;旧诊断报告继续保留。 @@ -74,17 +73,6 @@ 限制:固定根不能替 Agent 判断它是否选错了 MCP 工具连接。Skill 与客户端必须尊重 WORKSPACE_MISMATCH,不得忽略打开失败继续声称操作的是 B。若真实目标客户端只能提供全局单连接且必须动态切换多项目,本路线不满足该要求,应在 N5 迁移前核验并重新选择请求身份方案,不能暗加不安全兼容开关。 -### N2:同项目重复打开保持热态,恢复仍可用 - -1. 将普通重复确认与已知故障恢复分开。健康同根、无需要重启的已知状态时,不调用 resetConnection,不清空 snapshot,不重复启动 Host;仍返回有界的当前概览。 -2. 优先保留现有 HOST_RESTART_REQUIRED → workspace_open 的恢复提示:由 Adapter 暴露最小的类型化已知状态,使 Router 能在必要时选择恢复路径,不从错误字符串猜测。workspaceRecovery/restart_gateway 的既有门禁不得被“同根快捷返回”绕过。 -3. 输入改变时继续依赖现有 Roslyn 输入检查、INPUTS_CHANGED/SNAPSHOT_STALE 和显式新搜索;普通文件变化不应一律演化成杀进程。根相同不代表 Configuration、TFM、SDK、项目输入或加载状态都有效,恢复判断须覆盖这些差异。 -4. 保持持锁后复查与取消收尾。多个同根打开请求、首次加载、手动释放和恢复同时到来时,复用正在进行的必要操作或有序等待,不能连锁重置刚恢复的 Host。 - -关键文件:[ToolRouter](src/Core/ToolRouter.ts)、[RoslynAdapter](src/Adapters/RoslynAdapter.ts)、[McpServer](src/Gateway/McpServer.ts)及现有恢复/生命周期测试。 - -验收:同项目十次重复打开及夹杂查询保持同一 snapshot 和实际 Host 身份;原精确引用继续正确;已知需要重启、清理失败、冷态、源码/项目配置变化分别走正确路径;新建 Host 次数为必要的 0 或 1,而不是每个等待者各启动一次。状态显示为“保留已知热态”不构成新鲜度已验证承诺。 - ### N3:有限突发先排队,过载可解释且不重启 推荐起点是每实例最多受理 **32 个未完成业务请求(执行中与排队合计)**;Roslyn 的执行并发仍为 1,其他已有并行能力不强制全局串行。32 是待实测起点,不是现有限制,也不是用户机器适用的固定最优值。 @@ -92,7 +80,7 @@ - 在进入昂贵准备/扫描/适配器工作之前统一做有界受理。外层准入与内层 Roslyn mutex 复用一份请求归属,组合工具内部调用不重复占用外层容量;取消后的逻辑容量和实际等待节点都需要释放,避免“计数变小但 Promise 链仍无限增长”。 - 正常 FIFO 排队,保留先来请求,不用新请求挤掉已受理工作。满额时返回拟定 SERVER_BUSY 和有限队列状态,只有确认尚未执行才标记可稍后重试;不伪造准确 retryAfter,不在服务端自动循环重试。 - 等待受取消和总时间预算约束,排队时间纳入预算;进入 Adapter 后只能使用剩余预算,不能逐层重新起算。没有收到客户端截止时间时使用当前对应操作的有界预算,不假设所有软件都使用测试 SDK 的 60 秒配置。 -- 排队取消应立即移除等待项且不启动 Host;执行中取消继续等待当前操作及资源收尾,之后才归还执行权,不能提前让新请求进入仍在使用的 Host。 +- 将已实现的 Mutex 物理取消/FIFO/清理后交还执行权接入统一准入,补连续取消和补入的端到端容量验收;不能仅凭互斥单测声称 Gateway 已有总受理上限。 - 对请求参数增加统一序列化预算,建议先用 **64 KiB/tool arguments** 并对已有合法调用做兼容性检查;补齐缺少长度限制的字段。该值限制被接受并保留的业务负载,不能消除 SDK 已解析至 10 MiB 帧时的瞬时分配,也不是进程 RSS 上限。 - tools/list、被动状态、取消和关闭不能排在慢查询后。MCP 状态请求可预留最多 **4 个**有界轻量槽,不能无限放行;托盘继续只读内存状态。workspace_open/恢复进入同一受理约束,但不能先把自身计入 inFlight 再等待自己清空而形成死锁。 - 增加已受理/执行/等待/拒绝/取消数量及队列等待耗时。手动释放在任何排队、执行或取消收尾期间均拒绝,不排队等空闲、不自动释放。hello 的完整磁盘统计不能变成饱和时的高频旁路扫描。 @@ -103,11 +91,13 @@ ### N4:补同项目共享存储与编辑竞争证据 -N1 固定项目只解决实例内根变化;两个独立连接仍可能指向同一物理项目和默认 cacheDir。目前缓存有原子临时文件写入和每实例写队列,但没有因此证明跨进程清理安全。 +已完成并移出本节待办:实际双进程 peer prune 删除附件后的内存命中重建,以及新 CacheManager 从磁盘读取缺失附件后的重建;内置打包按实际文件内容复用,本地文本扫描不再信任有界工作区指纹作为完整输入身份。新增 8 项回归见 `tests/runtime-cache-regressions.test.ts`。取消同根确认保持健康 Host、慢查询期间同根确认/hello 不等待 drain 也已验证;这些结论不覆盖真正切换期间的轻量状态旁路(仍属 N3)。 + +N1 固定项目只解决实例内根变化;两个独立连接仍可能指向同一物理项目和默认 cacheDir。缓存有原子临时文件写入、每实例写队列和附件存在性复核;仍未建立附件长期租约,不能保证返回引用在未来任意时刻不被清理。 1. 复用隔离生成项目,两个真实 Gateway 同时走当前缓存/内置打包路径:同键和不同键写入、读取、prune、overflow 大内容引用,以及其中一个退出。只调用已安装工具链,不为此下载安装真实 Repomix。 2. 记录是否有损坏 JSON、跨项目正文、另一实例仍使用的 overflow 被清除、退出后队列不排空。普通缓存未命中可以重算;错误正文或看似命中却指向错误/缺失实体必须阻断验收。 -3. 最终倾向明确可变临时文件的拥有者:若复现共享可变产物竞争,优先仅隔离正在写入的临时/overflow 产物并维护引用归属;不要先把整个持久缓存改成每次启动一个 UUID 目录,避免磁盘复制与失去复用。若现有做法已安全,保留并补回归;需要跨进程锁、新清理政策或数据迁移时再明确具体变更。 +3. 最终倾向明确可变临时文件的拥有者:本轮已对失效附件按缓存未命中重建;若后续证据要求跨调用保护附件,再明确仅隔离正在写入的临时/overflow 产物及其引用归属;不要先把整个持久缓存改成每次启动一个 UUID 目录,避免磁盘复制与失去复用。若现有做法已安全,保留并补回归;需要跨进程锁、新清理政策或数据迁移时再明确具体变更。 4. 同项目两个实例分别查询时修改生成夹具:旧定位明确失效,新搜索获得对应输入的结果,不把同时编辑或跨文件非原子变更说成完全一致快照。 5. UIA 是独立边界:有固定 WPF 夹具后验证两个实例明确选择不同窗口是否混淆;同窗口的写操作尚未验证前不承诺多 Agent 同时操作安全,不因代码工作区不同推断桌面也隔离。 @@ -117,7 +107,8 @@ N1 固定项目只解决实例内根变化;两个独立连接仍可能指向 - 在配置变更前检查实际客户端能否为不同项目提供独立 MCP 连接,生成明确的项目配置和恢复备份;用隔离项目验证两种实际软件的接入。是否支持项目连接需实测,不能根据 SDK 模拟结果推定。 - 对当前 Codex 完成最新 build/instance/provider/schema → 搜索 → 精确引用 → 影响/重构 → 夹具修改后的旧定位拒绝 → 新搜索恢复。前一轮仅准备的 [配置预览](test-tmp/tray-workflow/run-Ho43hD/client-configuration-preview.json)不是已经应用的配置;实际配置修改、重连和已安装 Skill 更新按有效授权执行。 -- 保留 [VcfIp2 未定位回执](test-tmp/tray/run-VcfIp2/report.json)。使用已补充的阶段、退出码和超时诊断复查;再次复现先找真实官方/GitHub 案例再做最小修复,不以增加超时或忽略错误宣称解决。没有复现时写“未再现”,不能写“根因已修复”。 +- 保留 [VcfIp2 未定位回执](test-tmp/tray/run-VcfIp2/report.json) 和本轮 [xaa7Wf 超时回执](test-tmp/tray/run-xaa7Wf/report.json)。新回执确认 uiTimedOut=true,最后阶段为隐藏/唤出;不能据此确定旧 VcfIp2 同因。已补 Hide 前后、隐藏延迟、ShowExisting 工作线程/确认、VisibleChanged、写报告的时间与线程标识;两次专项通过仍不是根因修复。后续按阶段定位,不增加总超时或忽略错误。 +- 保留本轮 [桌面首次失败](test-tmp/check/2026-09-10T04-59-31-835Z-desktop/report.json):WPF 完整树/截图场景返回 success=false,但旧断言未记录领域错误。已补 requestId/errorCode/errorMessage/captureMethod 诊断,单套件复测 13/13;尚无底层失败回执,不能归因为窗口就绪、截图环境或 Mutex。后续复现按诊断定位,不增加固定 sleep 或放宽成功断言。 - 正常负载先做短时小项目验证,确认成本后再用一个经授权的代表项目观察连续工作与空档;分开记录 Node、Code Host/已观测后代的内存、CPU、句柄、队列和启动次数。长期样本/大项目未跑就明确留空,不用短时结果证明无泄漏。 - 依变更执行针对性测试、npm run check、真实 Roslyn/E4;涉及托盘/原生行为再跑对应桌面验收。测试 inventory、错误形状、Schema/Skill 与交付清单必须一致。Node 22、具体提交的远端 CI 和实际发布状态单独核实。 - 托盘八注册容量及恢复已验证,移出开发待办;跨权限/Windows 会话拒绝矩阵、DPI/Explorer 重建仍属平台待验范围,不把它们重新包装为功能开发。九个真实 Host 的压力测试只有出现实际需求才扩大。 @@ -138,7 +129,7 @@ N1 固定项目只解决实例内根变化;两个独立连接仍可能指向 ## 6. 推荐方案的确认边界与延后范围 -USER_DECISION_REQUIRED:N1 将“可切换当前根”改为“连接固定项目”,N2 调整同路径重开语义,N3 引入可观察的过载/参数预算。这三项是本版明确推荐的同一批次契约变更;后续实施前确认接受其兼容性影响。本次只编写规划,不修改这些生产行为。N3 的起始参数在确认的方向内按测试调整,若需要显著降低并发能力或改变使用方式,应重新对齐。 +已确认方向:N1 将“可切换当前根”改为“连接固定项目”,N2 调整同路径重开语义,N3 引入可观察的过载/参数预算。用户已要求按计划工作;本轮 N2 已实现,N1 与完整 N3 仍待实施。N3 的起始参数在确认方向内按测试调整;若实际客户端不支持项目连接,或需要显著降低并发能力、改变使用方式,则标记 USER_DECISION_REQUIRED 并重新对齐。 用户此前已经确定的自动释放关闭、手动释放、Windows 11 基准和优先连续工作继续有效,不重复审批。实际客户端配置、下载、安装、发布或推送仍按当时有效授权执行;不把“规划已写好”当外部操作批准。 diff --git "a/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" "b/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" index 24c83d7..b84f6ab 100644 --- "a/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" +++ "b/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" @@ -1,12 +1,12 @@ # WinCode 迭代路线图 -更新:2026-09-10(北京时间)。当前为 codex/m1-parent-ownership 上的 0.14.0 未发布增量,HEAD 6e27e06;本文件只列未完成工作。完整推荐、兼容性取舍、GitHub 依据和验收标准见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md),历史实施及失败见 [工作日志](docs/codex_worklog.md)。 +更新:2026-09-10 13:58(北京时间)。当前修复分支 codex/runtime-cache-continuity 基于 origin/main dda0203(已合并 #34 的 0.14.0 基线),保留上一轮未提交增量并修复本轮验收缺陷。本文件只列未完成工作。完整推荐与验收标准见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md),历史实施及失败见 [工作日志](docs/codex_worklog.md)。 ## 最终推荐 独立 Gateway、连接固定项目、健康 Host 保持热态、有界排队。多个 Agent 可通过同一连接处理同一项目;并发独立项目/不同软件使用各自连接。减少无效重载,以已实现的设置内手动释放平衡内存,自动释放继续关闭。 -固定项目、同路径重开以及过载规则是推荐的新公共行为,尚未实施。相比目前可随意切换当前根的方式,它需要明确的客户端项目配置;若目标软件只支持一个全局连接,先验证兼容性再决定是否改用每请求工作区身份,不能悄悄保留不安全切换。 +用户已确认按计划实施。N2 同路径重开保留热态已完成;固定项目与完整过载规则尚未实施。固定项目需要明确的客户端项目配置;若目标软件只支持一个全局连接,先验证兼容性再决定是否改用每请求工作区身份,不能悄悄保留不安全切换。 ## 已移出待办的基线 @@ -14,17 +14,20 @@ 实际消费者最新构建/Roslyn 接入、既有未定位原生验收失败、共享存储竞争、长期资源趋势等仍保留为待验,不能随已完成功能删除。 +本轮移除 N2:健康同路径重复打开保留 Host/snapshot,Windows 大小写/分隔符别名保持身份;已知 SDK 重启要求和清理失败门禁继续生效。核心 364/364、真实 Roslyn 22 场景、E4 16 场景通过。N3 的物理取消/FIFO 基础已实现,但统一容量与过载尚未完成;具体回执和测试前提修正保留在工作日志。 + +补充验收修复:实际内容绑定的上下文缓存、按内容复用解析的有界文本扫描、缺失附件命中重建,以及同根确认取消/慢查询边界已通过新增 8 项回归。真实 Roslyn 取消后 PID/snapshot 保留;不把这些结果称作 N1 或完整 N3 已完成。 + ## 接下来的开发与验证 | 顺序 | 尚未完成的目标 | 完成判据 | | --- | --- | --- | | N1 | 连接固定工作区,错误目标在副作用前拒绝 | 同一连接打开 B 不改变已绑定 A 的根、Host、快照、watcher、cache/trash;独立 B 连接继续正确 | -| N2 | 健康同路径重复打开保持热态,已知恢复仍生效 | 十次重复确认保持 PID/snapshot;配置/输入变化、恢复门、释放/取消竞态不被快捷路径绕过 | | N3 | 有界受理、FIFO 等待、取消归还、明确过载 | 建议从每实例 32 个未完成业务请求、4 个状态槽及 64 KiB 参数预算实测;正常突发顺畅,超载不无限积压、不重启、不自动重放 | | N4 | 同项目多实例的共享存储/源码变更边界 | 实际缓存/打包读写、prune/overflow 与退出交错无错误正文;源码变化后旧定位失效;UIA 窗口隔离另验 | | N5 | 实际客户端闭环、残余失败和交付验收 | 明确项目配置与实际 build/provider/schema;至少两种目标软件接入;旧失败保留根因状态,Node 22/远端 CI 与长期观测按实际证据报告 | -N1/N2 可同批实施并分别验证;N3 参数只是起始建议,需按现有合法请求和相同任务对照调整。N4 若没有复现存储缺陷,保留现有缓存并补回归,不预先引入跨进程锁或按每次启动复制全部持久缓存。 +N2 已先完成;接下来实施 N1 时保留已有热态与故障恢复覆盖。N3 参数只是起始建议,需按现有合法请求和相同任务对照调整。N4 已复现并修复 peer prune 后的悬空附件命中,新增双进程回归;剩余源码/UI 竞争与其他存储交错继续验证,不预先引入跨进程锁或按每次启动复制全部持久缓存。 ## 必须保留的边界 @@ -32,7 +35,7 @@ N1/N2 可同批实施并分别验证;N3 参数只是起始建议,需按现 - 默认 local-text,Roslyn 继续显式配置与项目求值许可;hello/托盘观察不触发加载。 - Gateway 仅清理自建资源;可选托盘退出不影响 MCP,手动释放在排队/执行/取消收尾时拒绝。 - 10 MiB 传输帧上限、单实例缓存预算、队列容量、Node RSS 和原生内存分别评估;短时通过不等于长期无泄漏。 -- 实现公共契约变化前确认推荐方向;实际客户端配置、依赖/环境、发布和推送另按有效授权执行。 +- N1–N3 推荐方向已经确认;实际客户端配置、依赖/环境、发布和推送另按有效授权执行。 ## 延后 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index 995a081..0cf09bc 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -921,3 +921,44 @@ - 用户明确授权快速推送当前状态。提交范围为当前 0.14.0 的 owner guard、延迟 UIA 探测、手动释放、原生托盘、交付校验、并发诊断及精简后的 N1–N5 规划;发布到 origin/codex/m1-parent-ownership,不改 main,不创建 Release。 - 推送前 git diff --check、delivery:verify matched=true 和测试 inventory(47 个测试文件)通过;复用此前核心 360/360、桌面 35/35 及专项回执,没有为快速检查点重复运行全套测试。源码与锁文件、原生工程和测试源码一并提交;忽略的 dist/bin/obj、.deps、test-tmp 回执、真实客户端配置和凭据不上传。 - 已知限制随检查点保留:共享实例跨项目会影响后续普通查询,同根重开会重置健康 Host,等待队列缺少容量限制;N1–N5 仅为推荐计划,尚未实现。VcfIp2 原生验收失败未定位,实际消费者最新构建/Roslyn 接入、共享存储竞争及长期资源验证未完成。远端 CI 在推送后独立运行,提交本身不表示 CI 已通过。 + +## 2026-09-10 13:05 — N2 热态保留与 N3 互斥取消基础(北京时间) + +- 用户已明确要求按确认的计划继续实施,N1–N3 方向不再待审批。本轮在干净的 bf220a9 / codex/m1-parent-ownership 基线上先交付可独立验证的 N2 和互斥取消基础;N1 固定根需要同步迁移原有跨项目及完整故障注入覆盖,仍为下一开发项。没有修改实际客户端配置、已安装 Skill、依赖或自动释放策略,也未提交/推送本轮修改。 +- `ToolRouter.openWorkspace` 健康同根不再无条件 `resetConnection`;Windows 大小写和分隔符别名沿用现有根的拼写,保持 session/watch 身份。持工作区锁后读取 Adapter 已有的类型化 `restartRequired/cleanupFailed`,需要时才执行重置;十个并发恢复确认只重置一次。`reloadRequired` 保留给显式搜索;既有 `workspaceRecovery/restart_gateway` 门禁保持。Configuration/TFM/可执行文件参数在 Adapter 构造时冻结,修改客户端启动配置仍须重建连接,重复打开不热应用配置文件。 +- `Mutex` 的 Promise 尾链改为有序可删除等待集合;取消立即删除等待闭包和监听器,存活请求保持 FIFO。已执行的任务在实际操作及异步清理结束后才交还执行权。增加只读 `pendingCount` 供真实等待节点断言;128 个受控等待者中取消 64 个后,在队首仍阻塞时实际节点已降为 64,剩余依序完成并归零。**没有增加 MCP 总受理上限、参数预算或 SERVER_BUSY;本项不等于完成 N3。** +- 针对性测试 [63/63](../test-tmp/n2-targeted.log);最终核心 [364/364](../test-tmp/check/2026-09-10T04-58-35-445Z-core/report.json),包含类型、构建、既有原生锁定还原/发布、stdio 与交付检查;E4 [16 场景](../test-tmp/error-contracts/run-RcGwrj/report.json)通过。新增 UI 失败诊断后另跑 typecheck 通过。首次 typecheck 的 `never[]` 断言收窄错误属测试代码的低级类型问题,改用长度断言,没有改变生产行为;失败回执保留在 `test-tmp/check/2026-09-10T04-54-35-495Z-core/report.json`。 +- 真实 Roslyn MCP [22 场景回执](../test-tmp/roslyn-gateway/run-uffy9G/report.json)通过:十次同根打开、四个并发确认夹杂引用保留实际 Host/已观测后代 PID、snapshot、watcher/session;真实 SDK 选择输入变化要求 `HOST_RESTART_REQUIRED → workspace_open` 并回收旧 Host;冷加载损坏、热态旧证据拒绝/重载失败、修复后显式搜索,以及真实 MSBuild 取消/崩溃/超时均验证。它运行在源码逻辑相同、工具描述更新前的构建;最终描述/Schema 由后续核心与三实例回执覆盖,不声称旧消费者已经更新。 +- 第一次真实 MCP 回执 [run-2WfcZA](../test-tmp/roslyn-gateway/run-2WfcZA/report.json)在第 16 个场景失败:旧测试借同根打开制造冷态,改动后实际为热态输入失效,返回 SNAPSHOT_STALE 而不是预期 PROJECT_LOAD_FAILED。按用户要求先检索并阅读 [Roslyn #54796](https://github.com/dotnet/roslyn/issues/54796)、[rust-analyzer reload.rs](https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/reload.rs),再结合本地 `WorkspaceSession.EnsureFresh/ReloadAsync` 确认层次。上游 SDK 加载案例只是参考,不是本地错误码的来源。修正前置状态并增加冷、热两种严格断言;生命周期故障先证明旧定位失效,再显式搜索进入真实阻塞 target,未把多种错误合并放行,也没有加入自动重放。 +- 最终构建的 [三实例十场景](../test-tmp/multi-agent/run-hnl1r7/report.json)完成:独立 A/B/A 冷加载、96 次交错引用、128 次搜索、64 请求取消 16 个、跨实例定位拒绝、单连接退出及帧/托盘边界。相同路径重开缺陷 `observed=false`,Host PID 15772 保留,snapshot 前后均为 `84e8f7c18ad44533963eeecad5b75b43`;观察过的进程最终 survivors=[]。**共享连接跨项目问题仍 `observed=true`,普通搜索仍可返回 B.Api.Save(int)**;success 代表诊断完成,不能解释为多 Agent 风险全部解决。客户端 SDK drain 警告再次出现,堆栈仍在 `@modelcontextprotocol/client/dist/stdio.mjs:205`,阶段结束监听器归零;未提高阈值或隐藏警告。 +- 因 Mutex 同时用于 UIA,补跑桌面回归。首次 [34/35](../test-tmp/check/2026-09-10T04-59-31-835Z-desktop/report.json)的完整树/截图场景只留下 `success=false`,旧断言无领域错误码。检索 [FlaUI #394](https://github.com/FlaUI/FlaUI/issues/394)、[FlaUI AutomationElement](https://github.com/FlaUI/FlaUI/blob/main/src/FlaUI.Core/AutomationElements/AutomationElement.cs) 和 [微软 UIA 等待接口](https://learn.microsoft.com/en-us/windows/win32/api/uiautomationclient/nf-uiautomationclient-iuiautomationwindowpattern-waitforinputidle) 后,先在测试断言中补 requestId/errorCode/errorMessage/captureMethod,不改生产时序或超时。单套件 [13/13](../test-tmp/n2-desktop-diagnostic.log)未再现。首次失败缺少底层信息,窗口就绪、捕获环境、Mutex 均不能确定为根因;此项与旧 VcfIp2 失败分别保留在 N5。 +- 最终本地交付 matched=true,contentId=`9de57af9a81d9966457b0eb8e1a1320d75d04fe76fd8ca3a79ee5504674a8834`;Gateway buildId=`0987e341f543efdd78305937878d722a85cada178ff11e9156cc578f984a7d78`,Schema hash=`e1f7a396188e3bd4ba905935d2e896465a38526b219e54d9047b79bdd85d4411`。对应 revision 是基线 bf220a9,未提交增量由 source/artifact hash 区分。同步更新工具描述、仓库 Skill 诊断源文件、CHANGELOG、原规划和路线图;删除已完成 N2 待办,保留 N1、完整 N3、N4、N5 及新旧未定位失败。 +- 作者反证自审:保温不能掩盖 SDK/输入失效,已用真实 SDK 变更与冷热损坏反例验证;取消早返回不能允许新任务进入尚未清理的 Host,单测与真实 UIA/Roslyn 取消分别覆盖。短时小项目及 SDK 模拟不证明长期无泄漏、真实多软件接入、共享缓存/窗口完全隔离;互斥节点可删除不证明业务队列有界。自动释放继续关闭,设置内手动释放行为保留。没有独立模型/人工审查,本轮不把作者自审当独立审核。 +- 13:10 补充桌面收尾:第二次完整桌面运行 [35/35 和 owner-death 通过](../test-tmp/check/2026-09-10T05-02-26-806Z-desktop/report.json),但托盘 [xaa7Wf](../test-tmp/tray/run-xaa7Wf/report.json)确认 35 秒保护超时、退出码为空、最后在隐藏/唤出阶段;不能外推旧 VcfIp2 同因。先查 [微软 WinForms 线程安全调用](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/how-to-make-thread-safe-calls) 与 [dotnet/winforms #4631](https://github.com/dotnet/winforms/issues/4631),再只在隔离 `TrayAcceptance` 加毫秒/线程和 Hide、延迟、ShowExisting 工作线程/确认、VisibleChanged、报告写入的阶段日志,未改线上行为或增加超时。两次专项 [ZvxMdQ](../test-tmp/tray/run-ZvxMdQ/report.json)、[XVjI4K](../test-tmp/tray/run-XVjI4K/report.json)通过;第三次随最终完整桌面运行的 [sTDf8v](../test-tmp/tray/run-sTDf8v/report.json)通过。三次未再现仍不构成根因修复,N5 保留。 +- 最终完整 [desktop 检查](../test-tmp/check/2026-09-10T05-07-25-412Z-desktop/report.json) success=true:35/35、owner-death、托盘及真实托盘/Roslyn 工作流全部通过。[工作流四场景](../test-tmp/tray-workflow/run-3jNah3/report.json)包括忙碌释放拒绝、七次间隔热态观察、仅释放 A 后显式查询恢复/B 保持热态,以及退出托盘两个 MCP 继续查询,最终 survivors=[]。托盘诊断源码按标准流程重新发布后,最终 delivery contentId 更新为 `1b5a924800a412c6fd2fffa82663b43c46d29095c1e703d4678ade473a9baeb4`,matched=true;Gateway buildId/Schema 未再变化。UTF-8、本轮文档本地链接和 git diff --check 通过。工作树保持本地可审阅修改,N1/完整 N3 的既有缺口及桌面未定位失败不作为本轮已解决事项。 + +## 2026-09-10 13:47 — 潜在实际运行缺陷验收(北京时间) + +- 用户要求查验既有测试未检测到的实际运行漏洞并给出建议。本轮只读审查生产实现,使用 `test-tmp/runtime-audit/` 下被 Git 忽略的隔离脚本和夹具;保留 `codex/m1-parent-ownership`、基线 `bf220a9` 上原有未提交增量。没有修改生产代码、已有测试、规划书、实际客户端配置或依赖,没有发布或推送。以下 `observed=true` 表示缺陷被复现,不是产品验收通过。 +- **P1:缓存成功返回旧代码。** [已有提交的 Git 仓库回执](../test-tmp/runtime-audit/git-uj1Ucy/report.json)中,新建但未暂存的 `src/Target.cs` 从 Gone/OLD_VALUE 改为 PresentNow/NEW_VALUE_WITH_MORE_CHARACTERS;变更前后 `git status` 都为 `?? src/`,显式 watcher 失效通知和 fresh 指纹计算后指纹仍相同,公开 MCP prepare_context 返回旧全文、find_code_symbol 返回已消失的 Gone,且 queryComplete=true、truncated=false。仅暂存夹具文件后,对照查询返回新全文、Gone 数量归零、fromCache=false。另一个 [深层非 Git 文件场景](../test-tmp/runtime-audit/run-jIeKUX/report.json)同样复现旧全文;目录指纹的深度 3/文件数 100 上限未覆盖实际选中文件。当前证据覆盖 local-text 和内置上下文打包,不宣称复现 Roslyn 语义快照失效。根因位置:WorkspaceFingerprint.ts 的 Git 状态目录粒度、前 100 项统计与有界目录扫描;LocalTextAdapter/RepomixAdapter 信任此指纹的缓存命中。建议对实际输入建立依赖/内容身份,利用已读取的有限文件内容校验,watcher 只作加速失效信号;不要每请求全仓哈希,也不要把要求用户 git add 当成修复。 +- **P2:共享磁盘缓存清理后返回悬空附件。** [两进程回执](../test-tmp/runtime-audit/run-jIeKUX/report.json)使用两个真实 Node 进程、当前 CacheManager/RepomixAdapter 和同一缓存目录。为确定性触发淘汰,把合法阈值缩为 maxDiskEntries=1、maxEntryBytes=8192;A 打包产生全文附件,B 淘汰磁盘条目和附件,A 的内存命中仍返回 fromCache=true、contentOmitted=true,但 overflowPath 已不存在。未模拟文件系统或修改 TTL。确认的是缺失上下文附件,不是源文件丢失或跨项目污染。建议命中时校验附件存在性/身份,缺失则失效重建,并明确多进程清理所有权;原子写入不能单独解决附件生命周期。 +- **P2:取消同根确认误入恢复并重启健康 Roslyn。** 同一回执在实际同根元数据读取完成、Router 的 await 后取消检查前设置可控取消窗口,使用真实已发布 Roslyn Host。未改变根目录或源码,却出现 recovery-required(roslynLoaded=true)、业务请求被阻止;按提示重新打开后变冷,下一查询 Host PID 从 18476 变为 6384,snapshot 也变化。触发来自 rootPrepared=true 后统一进入恢复分支。建议区分无状态变更的同根确认与真实重绑定/清理失败,保留后者的强制恢复保护。此前正常同根保温测试通过仍成立,但 N2 的取消边界验收未通过,应重新列入后续修复范围。本轮是可控 AbortSignal 窗口,不称为真实第三方客户端手势复现。 +- **P2:同根确认等待慢请求,连被动状态也阻塞。** [默认超时回执](../test-tmp/runtime-audit/drain-JdeB0m/report.json)通过真实 MCP 处理器及受控慢查询,保持默认 shutdownMs=8000。同根打开在约 8010.7ms 后 TOOL_EXECUTION_FAILED;期间 hello 也等待约 8011.4ms 才成功。放行原查询后查询正常、工作区健康。根因是判断 sameWorkspace 前先设置切换屏障并等待 drain,状态请求也经过屏障。建议尽早分类同根确认,并让被动状态保持有界响应;真正切换/释放仍需排空,不能只增加超时。这是确定性模拟慢业务耗时,不是实测大型项目冷加载耗时。 +- 发现非低级逻辑失败后按用户要求查阅真实上游资料:[Git status 官方行为](https://git-scm.com/docs/git-status.html)、[VS Code 文件监听限制](https://github.com/microsoft/vscode/wiki/File-Watcher-Issues)、[VS Code 监听实现说明](https://github.com/microsoft/vscode/wiki/File-Watcher-Internals)、[npm cacache 内容读取实现](https://github.com/npm/cacache/blob/main/lib/content/read.js)。资料支持目录枚举/监听局限和读取缓存实体时校验的设计参考,本地缺陷结论来自本轮回执;未引入这些项目的依赖。 +- 验收判断:新确认四类缺陷、五个触发场景;证据正确性与异常时工作连续性尚不满足稳定多 Agent 使用标准。优先修复旧代码缓存,并与已有 N1 任务级项目绑定一起作为正确性门槛;随后处理同根取消/状态阻塞、缓存附件生命周期和完整 N3 准入预算。将本轮反例转为正式回归后,再进行真实消费者及长期驻留验证。既有共享连接跨项目问题、无准入容量上限、未定位的 WPF/托盘偶发失败仍未解决,不重复计为本轮新增发现。 +- 限制:本轮没有重复未修改生产代码的完整 364 核心/35 桌面套件;这些数字属于上一轮基线。本轮隔离探针使用真实文件系统、真实缓存进程、真实 Roslyn Host,但部分竞态采用确定性调度、缓存阈值缩小;不能估计生产发生率,不能证明长期无泄漏/OOM,也不是跨权限安全渗透或三个真实软件客户端接入认证。脚本入口为 [主探针](../test-tmp/runtime-audit/probe.mjs)、[Git 输入探针](../test-tmp/runtime-audit/git-input-probe.mjs)、[状态延迟探针](../test-tmp/runtime-audit/confirmation-latency.mjs)。本次为作者反证审查,不是独立模型或人工审核;未实施修复,不静默改写之前的成功记录。 + + +## 2026-09-10 14:01 — 验收缺陷修复与 PR 交付准备(北京时间) + +- 用户明确授权继续修复,完成后推送 PR 并合并。本轮收敛为验收新增四类缺陷,保留上一轮未提交的 N2/Mutex/诊断增量;未实施 N1 固定项目和完整 N3 准入限制,也未修改实际消费者配置、安装依赖或开启自动释放。拉取发现 origin/main 已通过 #34 合并同内容基线,确认树与 bf220a9 无差异后,在 origin/main dda0203 创建 codex/runtime-cache-continuity,避免重复引入已合并基线。 +- 文本检索每次沿用现有 8 MiB/5000 项等扫描预算枚举并读取输入,避免新建/删除文件和监听遗漏被结果缓存隐藏;声明解析以实际内容 SHA-256 复用,使用 CacheManager 既有 LRU/条目与字节预算,仅存解析结果,不新增独立内存池或持久 AST。简单文本引用直接扫描。旧有界 WorkspaceFingerprint 保留作为变更提示,不能再决定这些源码结果是否有效。 +- 内置打包读取实际选中文件后,以有顺序的路径/内容元组计算身份,再复用相同内容的打包/附件;不额外执行一次全仓内容哈希。没有可核验输入清单的 CLI 打包不复用旧结果;显式禁用 CLI 和取消隔离继续生效。缓存读取检查 overflow 实体,peer prune 后内存/磁盘命中均降为未命中重建。本轮未建立跨调用附件租约,不承诺返回的临时文件永久存在或跨文件编辑具有原子快照。 +- 健康同根确认在切换屏障之前执行,保留会话、Host 和新鲜度提示;不重复改写相同根的 trash/config,取消确认不进入部分重绑定恢复。已知重启/清理失败、真实 A→B 切换继续使用排空和恢复路径;没有无限放行真正切换期间的状态请求,完整状态准入仍属 N3。 +- 新增 [正式回归](../tests/runtime-cache-regressions.test.ts) 8/8,通过公开 MCP 的未跟踪目录/深层文件/超过提示预算、同长度且还原 mtime 的修改、新建删除文件、引用失效、内存预算、缺失附件的内存/磁盘重建、真实双进程淘汰,以及取消/慢查询交错。实际双进程使用 [既有工具链夹具](../tests/fixtures/cache-peer.mjs),缓存阈值缩小但文件系统/进程均真实。已加入 package.json 的核心 inventory;原有相关测试 87/87。新夹具 windowsHide 在 Node ForkOptions 类型中不受支持,移除该多余属性(子进程 stdio 为管道),属于低级类型错误;之后完整 typecheck 通过。 +- [完整核心 372/372](../test-tmp/check/2026-09-10T05-56-37-721Z-core/report.json)通过类型、构建、原生锁定构建、stdio 与交付。E4 [16 场景](../test-tmp/error-contracts/run-mgJSec/report.json)通过;[完整桌面](../test-tmp/check/2026-09-10T05-57-42-090Z-desktop/report.json) 35/35、owner-death、托盘和贯通检查通过。[实际托盘/Roslyn 贯通](../test-tmp/tray-workflow/run-bImEg6/report.json)包含忙碌拒绝、七次间隔保温、仅释放 A、退出托盘不影响 MCP,最终无观察到的残留。这次通过不宣称已定位历史 WPF/托盘偶发失败。 +- 修复前后使用同一隔离审查探针:[修复后主回执](../test-tmp/runtime-audit/run-5F2F0c/report.json)中深层旧代码、双进程悬空附件、取消导致健康 Host 重启均 observed=false;真实 Host PID 19200 和 snapshot 79046bfdeee342e99a784779daf617aa 均保留、业务可用。[Git 对照](../test-tmp/runtime-audit/git-p1dvGU/report.json)仍有相同旧提示指纹和 ?? src/,但已返回新源码、Gone 符号消失,证明没有依赖改提示算法或要求用户暂存。报告 observed=false 本身不是通用无缺陷证明,正式断言见新增回归。 +- 真实 Roslyn 专项首次 [nWBtRL](../test-tmp/roslyn-gateway/run-nWBtRL/report.json)在第 19 个场景报 Owned process survived: 11848。按要求先查 [MCP 取消规范](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation)、[Process.Kill 文档](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.kill?view=net-10.0)及 [dotnet/runtime #107992](https://github.com/dotnet/runtime/issues/107992),再检查客户端取消与真实 Gateway 收尾。定位到旧测试把同根打开当作 drain 屏障,与本轮只读确认语义冲突;现场稍后 PID 已不存在。改为在原有 8 秒预算内观察 Gateway inFlightRequests 回到只有 hello 的 1,再严格核对所有已记录进程退出,未延长预算、主动杀进程帮助断言或放宽退出条件。该调整只在验收脚本,生产清理代码未改。 +- 作者反证自审:相同文件长度/mtime、监听遗漏、新增文件、磁盘条目尚存但正文附件缺失,以及取消客户端先结束而服务端仍在收尾。保留真实修改后的输入失效/SDK 重启/清理失败恢复测试;不因同根保温跳过实际故障。规划仅移除已取得证据的完成项,N1、完整 N3、N4 其他存储/UI/源码竞争、N5 真实消费者和长期样本仍保留。 + +- 14:02 收尾:调整观察方式后的 [真实 Roslyn 22 场景](../test-tmp/roslyn-gateway/run-wNNpAi/report.json)全部通过,包括主动 MSBuild 取消/崩溃/超时及进程退出。最终本地 delivery matched=true,contentId=ace3a662543d5c6df01bda9f23bea28199031c420f976e6a5f4fdb39d3ba19fb;Gateway buildId=717352bcd195475eed76cab88e7c1f96701c8fbaaab8e105a1d34dcccdc34b75。该本地构建在切换等内容基线前产生,revision 元数据为 bf220a9;不把它冒充最终 PR 提交构建,远端 CI 将核验具体提交。git diff --check 通过;推送/合并状态以随后 GitHub 回执为准。 diff --git a/package.json b/package.json index 081478c..f20a0a1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "node scripts/build.mjs", "start": "node dist/index.js", "dev": "tsx src/index.ts --development", - "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts", + "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts", "test:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", diff --git a/scripts/roslyn/gateway-lifecycle.mjs b/scripts/roslyn/gateway-lifecycle.mjs index ee70dca..27669a3 100644 --- a/scripts/roslyn/gateway-lifecycle.mjs +++ b/scripts/roslyn/gateway-lifecycle.mjs @@ -14,8 +14,11 @@ export async function verifyGatewayLifecycle({ root, a, host, appProject, client for (const mode of ['cancel', 'crash', 'timeout']) { console.log(`[roslyn-gateway] active MSBuild ${mode}`); await call('workspace_open', { path: a }); + const beforeChange = await integerTarget(); await fs.rm(marker, { force: true }); await fs.writeFile(path.join(a, 'App/App.csproj'), appProject.replace('', targetXml + '')); + const stale = await call('wincode_find_references', { symbolName: 'Save', symbolLocation: beforeChange.location }, true); + assert.ok(['SNAPSHOT_STALE', 'INPUTS_CHANGED'].includes(stale.errorCode)); const controller = new AbortController(); const pending = client.callTool({ name: 'wincode_find_code_symbol', arguments: { query: 'Api' } }, { timeout: 30000, signal: controller.signal }); const settled = pending.then(value => ({ value }), error => ({ error: String(error) })); @@ -36,8 +39,20 @@ export async function verifyGatewayLifecycle({ root, a, host, appProject, client assert.equal(outcome.value?.isError, true, JSON.stringify(outcome)); assert.equal(JSON.parse(outcome.value.content[0].text).errorCode, mode === 'timeout' ? 'HOST_TIMEOUT' : 'HOST_CRASHED'); } - // 客户端取消会先结束本地等待;同根打开等待 Gateway 占用清理完成后,才应确认恢复。 + // Client cancellation ends its own wait first. Same-root confirmation is now read-only, + // so observe actual Gateway completion instead of treating workspace_open as a drain barrier. await call('workspace_open', { path: a }); + const cleanupStarted = Date.now(); + let health = (await call('wincode_hello_world')).health; + const initialInFlight = health.inFlightRequests; + while (health.inFlightRequests > 1 && Date.now() - cleanupStarted < 8000) { + await new Promise(resolve => setTimeout(resolve, 25)); + health = (await call('wincode_hello_world')).health; + } + report.processes.at(-1).cleanupObservation = { + initialInFlight, finalInFlight: health.inFlightRequests, waitMs: Date.now() - cleanupStarted, + }; + assert.equal(health.inFlightRequests, 1, 'only the heartbeat may remain after bounded cancellation cleanup'); assertExited(processes); await fs.writeFile(path.join(a, 'App/App.csproj'), appProject); await references(await integerTarget(), 1, a); diff --git a/scripts/verify-multi-agent.mjs b/scripts/verify-multi-agent.mjs index a78eaa1..ebdb87e 100644 --- a/scripts/verify-multi-agent.mjs +++ b/scripts/verify-multi-agent.mjs @@ -150,20 +150,20 @@ try { protectedLocationError: stale.data.errorCode, description: 'Per-request locking does not bind a multi-call agent workflow to its workspace; ordinary names and relative paths follow the last workspace_open.' }); return { directory }; }); - await scenario('same-path workspace_open invalidates a healthy warm Host', async () => { + await scenario('same-path workspace_open preserves a healthy warm Host', async () => { const target = await search(a, 'B'); const before = ownedProcesses(a.transport.pid); remember(before); const oldHost = before.filter(p => p.ParentProcessId === a.transport.pid && p.CommandLine?.includes(host)); assert.equal(oldHost.length, 1); await ok(a, 'workspace_open', { path: path.join(root, 'B') }); const hello = await ok(a, 'wincode_hello_world'); - assert.equal(hello.health.roslyn.processAlive, false); - assert.equal(observedSurvivors(oldHost).length, 0); + assert.equal(hello.health.roslyn.processAlive, true); + assert.equal(observedSurvivors(oldHost).length, 1); const next = await search(a, 'B'); remember(ownedProcesses(a.transport.pid)); - assert.notEqual(next.location.snapshotId, target.location.snapshotId); - report.findings.push({ id: 'same-workspace-reopen', observed: true, oldHostPid: oldHost[0].ProcessId, + assert.equal(next.location.snapshotId, target.location.snapshotId); + report.findings.push({ id: 'same-workspace-reopen', observed: false, oldHostPid: oldHost[0].ProcessId, beforeSnapshot: target.location.snapshotId, afterSnapshot: next.location.snapshotId, - description: 'Repeated opening of the same healthy workspace explicitly resets Roslyn and causes the next search to reload.' }); + description: 'Repeated opening of the same healthy workspace preserves its live Host and snapshot.' }); }); await scenario('closing one client leaves the other processes and snapshots usable', async () => { await a.client.close(); a.closed = true; diff --git a/scripts/verify-roslyn-gateway.mjs b/scripts/verify-roslyn-gateway.mjs index 2b2c04a..7f70849 100644 --- a/scripts/verify-roslyn-gateway.mjs +++ b/scripts/verify-roslyn-gateway.mjs @@ -192,6 +192,26 @@ try { assert.notEqual(edited.location.snapshotId, target.location.snapshotId); await references(edited, 1, a); report.scenarios.push('edit rejects old evidence; explicit new search reloads and returns changed references'); + const warmProcesses = codeProcesses(); + const warmHealth = (await call('wincode_hello_world')).health; + for (let iteration = 0; iteration < 10; iteration++) { + await call('workspace_open', { path: a }); + const confirmed = await integerTarget(); + assert.equal(confirmed.location.snapshotId, edited.location.snapshotId); + await references(edited, 1, a); + } + await Promise.all([ + ...Array.from({ length: 4 }, () => call('workspace_open', { path: a })), + references(edited, 1, a), + ]); + const confirmedHealth = (await call('wincode_hello_world')).health; + assert.equal(confirmedHealth.session.id, warmHealth.session.id); + assert.deepEqual(confirmedHealth.workspaceWatch, warmHealth.workspaceWatch); + assert.equal(confirmedHealth.roslyn.snapshotId, warmHealth.roslyn.snapshotId); + assert.deepEqual(codeProcesses().map(item => item.ProcessId).sort(), warmProcesses.map(item => item.ProcessId).sort()); + report.warmConfirmations = { sequential: 10, concurrent: 4, snapshotId: confirmedHealth.roslyn.snapshotId, + processes: warmProcesses, sessionId: confirmedHealth.session.id }; + report.scenarios.push('ten same-root opens and four concurrent confirmations preserve the real Host and observed owned-process PIDs, snapshot, watcher and session while references remain valid'); const beforeSwitch = codeProcesses(); report.beforeSwitch = { gatewayPid: transport.pid, processes: beforeSwitch }; await call('workspace_open', { path: b }); @@ -229,7 +249,18 @@ try { assert.equal((await call('wincode_hello_world')).health.lastAdapterError.provider, 'roslyn'); report.scenarios.push('configured extra input changes and absence invalidate evidence; explicit repair and search recover'); + // SDK input changes really require a fresh process. This also establishes an + // actual cold state for the following initial-load failure assertion. + const beforeSdk = await integerTarget(); + const sdkProcesses = codeProcesses(); + await fs.writeFile(path.join(a, 'global.json'), JSON.stringify({ sdk: { version: '10.0.303', rollForward: 'disable' } })); + const sdkChanged = await call('wincode_find_references', { symbolName: 'Save', symbolLocation: beforeSdk.location }, true); + assert.equal(sdkChanged.errorCode, 'HOST_RESTART_REQUIRED'); + assert.equal(sdkChanged.recoveryAction, 'workspace_open'); await call('workspace_open', { path: a }); + assertExited(sdkProcesses); + assert.equal((await call('wincode_hello_world')).health.roslyn.processAlive, false); + report.scenarios.push('real SDK-selection input change requires workspace_open, closes the old Host and leaves a cold reusable adapter'); await fs.writeFile(path.join(a, 'App/App.csproj'), ' { checkOperation(operation); - const fingerprint = await this.cache.computeWorkspaceFingerprint(this.config.workspaceRoot); - const key = `local_text_symbols_v2_${JSON.stringify([query, kind, relativePath, this.config.workspaceRoot])}`; - const cached = await this.cache.get(key, fingerprint); - checkOperation(operation); - if (cached?.queryComplete) return cached; + // Re-enumerate the bounded scan so additions, deletions and missed watch events cannot hide behind a workspace hint. const scan = await scanLocalFiles(this.config.workspaceRoot, this.config.timeouts.fileScanMs, ['.cs', '.ts', '.tsx', '.js', '.jsx', '.py'], 500, - (content, file, extension) => parseTextDeclarations(content, file, extension, () => checkOperation(operation)).filter(symbol => + (content, file, extension) => this.cache.memoizeContent( + `text_declarations_v1_${this.config.workspaceRoot}_${file}`, + crypto.createHash('sha256').update(content).digest('hex'), + () => parseTextDeclarations(content, file, extension, () => checkOperation(operation))).filter(symbol => symbol.name.toLowerCase().includes(query.toLowerCase()) && (!kind || symbol.kind.toLowerCase() === kind.toLowerCase())), relativePath, operation); const stats = computeTypeMatchStats(scan.items, query); @@ -60,7 +60,6 @@ export class LocalTextAdapter { uniqueTypeMatch: scan.complete && !scan.truncated && stats.uniqueTypeMatch, typeMatchCount: stats.typeMatchCount, }; checkOperation(operation); - if (scan.complete) await this.cache.set(key, result, { fingerprint, ttlMs: 300000 }); return result; } @@ -72,11 +71,6 @@ export class LocalTextAdapter { async findReferencesDetailed(symbolName: string, relativePath?: string, operation?: OperationContext): Promise { checkOperation(operation); if (symbolName.includes('/') || /\[\d+\]/.test(symbolName)) throw new CodeQueryError('LEGACY_SYMBOL_ID', 'Legacy Serena identities are retired; supply a plain name or configure Roslyn and search again.'); - const fingerprint = await this.cache.computeWorkspaceFingerprint(this.config.workspaceRoot); - const key = `local_text_references_v1_${JSON.stringify([symbolName, relativePath, this.config.workspaceRoot])}`; - const cached = await this.cache.get(key, fingerprint); - checkOperation(operation); - if (cached?.queryComplete) return cached; const escaped = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = new RegExp(`\\b${escaped}\\b`); const declaration = new RegExp(`(^|\\s)(class|interface|struct|enum)\\s+${escaped}\\b`); @@ -97,7 +91,6 @@ export class LocalTextAdapter { queryComplete: scan.complete, queryError: scan.error, truncated: scan.truncated, }; checkOperation(operation); - if (scan.complete) await this.cache.set(key, result, { fingerprint, ttlMs: 300000 }); return result; } } diff --git a/src/Adapters/RepomixAdapter.ts b/src/Adapters/RepomixAdapter.ts index a1a0312..324aac5 100644 --- a/src/Adapters/RepomixAdapter.ts +++ b/src/Adapters/RepomixAdapter.ts @@ -206,38 +206,11 @@ export class RepomixAdapter implements IAdapter { */ async packWorkspace(options?: RepomixPackOptions, operation?: OperationContext): Promise { checkOperation(operation); - // A disabled request must neither read a CLI snapshot nor join an enabled CLI pack. const allowCli = this.config.adapters.repomix.useCli; - const policy = allowCli ? 'cli-enabled' : 'builtin-only'; - const cacheKey = `repomix_pack_v5_${policy}_${this.config.adapters.repomix.customCliPath ?? ''}_${JSON.stringify(options || {})}_${this.config.workspaceRoot}`; - const fingerprint = await this.cache.computeWorkspaceFingerprint(this.config.workspaceRoot); - - const cached = await this.cache.get(cacheKey, fingerprint); + // CLI output has no verified input manifest. Only the builtin path caches, after reading its actual inputs. + const result = await this.packWorkspaceUncached(options, allowCli, operation); checkOperation(operation); - if (cached) { - return { ...cached, fromCache: true }; - } - - const inflightKey = `${cacheKey}:${fingerprint}`; - const existing = this.inflightPacks.get(inflightKey); - if (existing && !operation) { - const shared = await existing; - return { ...shared, fromCache: true }; - } - - const pending = this.packWorkspaceUncached(options, allowCli, operation).then(async (result) => { - checkOperation(operation); - const spilled = await this.spillIfOversized(result); - checkOperation(operation); - await this.cache.set(cacheKey, spilled, { fingerprint, ttlMs: 1000 * 60 * 10 }); - return spilled; - }); - if (!operation) this.inflightPacks.set(inflightKey, pending); - try { - return await pending; - } finally { - if (!operation) this.inflightPacks.delete(inflightKey); - } + return result.contentOmitted ? result : this.spillIfOversized(result); } private async packWorkspaceUncached(options: RepomixPackOptions | undefined, allowCli: boolean, operation?: OperationContext): Promise { @@ -414,7 +387,7 @@ export class RepomixAdapter implements IAdapter { } } checkOperation(operation); - return this.formatPackedResult(collectedFiles, root, options?.outputFormat); + return this.cacheCollectedFiles(collectedFiles, root, options, operation); } const defaultExcludes = new Set([ @@ -497,7 +470,31 @@ export class RepomixAdapter implements IAdapter { await walk(root); checkOperation(operation); - return this.formatPackedResult(collectedFiles, root, options?.outputFormat); + return this.cacheCollectedFiles(collectedFiles, root, options, operation); + } + + private async cacheCollectedFiles(files: { relPath: string; content: string }[], root: string, + options?: RepomixPackOptions, operation?: OperationContext): Promise { + // Length-delimited tuples bind both file selection and the exact bytes decoded for this pack. + const digest = crypto.createHash('sha256'); + for (const file of files) digest.update(JSON.stringify([file.relPath, file.content])); + const fingerprint = digest.digest('hex'); + const cacheKey = `repomix_inputs_v1_${root}_${JSON.stringify(options ?? {})}`; + const cached = await this.cache.get(cacheKey, fingerprint); + checkOperation(operation); + if (cached) return { ...cached, fromCache: true }; + const inflightKey = `${cacheKey}:${fingerprint}`; + const existing = this.inflightPacks.get(inflightKey); + if (!operation && existing) return { ...await existing, fromCache: true }; + const pending = (async () => { + const result = await this.spillIfOversized(this.formatPackedResult(files, root, options?.outputFormat)); + checkOperation(operation); + await this.cache.set(cacheKey, result, { fingerprint, ttlMs: 600_000 }); + return result; + })(); + if (!operation) this.inflightPacks.set(inflightKey, pending); + try { return await pending; } + finally { if (!operation) this.inflightPacks.delete(inflightKey); } } /** diff --git a/src/Core/Cache.ts b/src/Core/Cache.ts index 44df45e..283907a 100644 --- a/src/Core/Cache.ts +++ b/src/Core/Cache.ts @@ -131,6 +131,31 @@ export class CacheManager { } } + /** Content-addressed parsing reuse within the existing memory budget; never persists source-derived AST hints. */ + memoizeContent(key: string, fingerprint: string, create: () => T): T { + const memKey = this.namespacedKey(key); + const entry = this.memoryCache.get(memKey); + if (entry?.fingerprint === fingerprint) { + this.memoryCache.delete(memKey); + this.memoryCache.set(memKey, entry); + return entry.data as T; + } + const data = create(); + const byteSize = this.estimateBytes(data); + if (byteSize <= this.maxEntryBytes) this.setMemoryEntry(memKey, { data, fingerprint, byteSize, timestamp: Date.now() }); + else this.deleteMemory(memKey); + return data; + } + + private async backingFileExists(data: unknown): Promise { + const file = (data as { overflowPath?: unknown } | null)?.overflowPath; + if (file === undefined) return true; + if (typeof file !== 'string') return false; + const relative = path.relative(path.join(this.cacheDir, 'overflow'), file); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false; + return fs.stat(file).then(stat => stat.isFile(), () => false); + } + /** * Retrieves data from memory or disk cache with LRU access refresh. * Disk files larger than maxEntryBytes are deleted instead of being loaded. @@ -144,6 +169,10 @@ export class CacheManager { if (memEntry.ttlMs && now - memEntry.timestamp > memEntry.ttlMs) { this.deleteMemory(memKey); } else if (!currentFingerprint || memEntry.fingerprint === currentFingerprint) { + if (!await this.backingFileExists(memEntry.data)) { + this.deleteMemory(memKey); + return null; + } this.memoryCache.delete(memKey); this.memoryCache.set(memKey, memEntry); return memEntry.data as T; @@ -174,6 +203,8 @@ export class CacheManager { return null; } + if (!await this.backingFileExists(entry.data)) return null; + this.setMemoryEntry(memKey, { ...entry, // Disk metadata is not authoritative for heap accounting. diff --git a/src/Core/ResourceManager.ts b/src/Core/ResourceManager.ts index 3759b91..d23faff 100644 --- a/src/Core/ResourceManager.ts +++ b/src/Core/ResourceManager.ts @@ -111,7 +111,10 @@ export class GatewayRestartRequiredError extends AggregateError { * releasing this lock on abort before fn settles would permit concurrent owners. */ export class Mutex { - private tail: Promise = Promise.resolve(); + private running = false; + private readonly waiting = new Set<() => void>(); + + get pendingCount(): number { return this.waiting.size; } runExclusive(fn: () => Promise, signal?: AbortSignal): Promise { if (signal?.aborted) { @@ -120,45 +123,31 @@ export class Mutex { ); } - let onAbort: (() => void) | undefined; - let skipped = false; - - const previousTail = this.tail; - - const execute = async (): Promise => { - if (onAbort && signal) { - signal.removeEventListener('abort', onAbort); - } - if (skipped || signal?.aborted) { - throw new AbortError(signal?.reason ? String(signal.reason) : 'The operation was aborted'); - } - return fn(); - }; - - const run = previousTail.then(execute, execute); - this.tail = run.then( - () => undefined, - () => undefined - ); - - if (!signal) { - return run; - } - - const abortPromise = new Promise((_, reject) => { - onAbort = () => { - skipped = true; - reject( - new AbortError(signal.reason ? String(signal.reason) : 'The operation was aborted') - ); + return new Promise((resolve, reject) => { + const cancelled = () => { + // Set insertion order is FIFO; removal releases the closure immediately. + if (!this.waiting.delete(execute)) return; + signal?.removeEventListener('abort', cancelled); + reject(new AbortError(signal?.reason ? String(signal.reason) : 'The operation was aborted')); }; - signal.addEventListener('abort', onAbort, { once: true }); - }); - - return Promise.race([run, abortPromise]).finally(() => { - if (onAbort && signal) { - signal.removeEventListener('abort', onAbort); - } + const execute = () => { + this.waiting.delete(execute); + signal?.removeEventListener('abort', cancelled); + this.running = true; + // Keep asynchronous entry and recheck cancellation before invoking work. + void Promise.resolve().then(() => { + if (signal?.aborted) throw new AbortError(signal.reason ? String(signal.reason) : 'The operation was aborted'); + return fn(); + }).then(resolve, reject).finally(() => { + // An active caller owns the lock until its work AND cleanup settle. + const next = this.waiting.values().next().value; + if (next) next(); + else this.running = false; + }); + }; + this.waiting.add(execute); + signal?.addEventListener('abort', cancelled, { once: true }); + if (!this.running) execute(); }); } } diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index 576abdb..9bafb3c 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -379,13 +379,29 @@ export class ToolRouter { if (this.workspaceRecovery?.recoveryAction === 'restart_gateway') throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); + const previousRoot = this.config.workspaceRoot; + // A healthy same-root confirmation is read-only. Do not put business/status requests behind a drain barrier. + const resolved = path.relative(previousRoot, targetPath) === '' ? path.resolve(previousRoot) : path.resolve(targetPath); + const knownRoslyn = this.roslyn?.getKnownHealth(); + const sameWorkspace = !this.workspaceRecovery && this.watch.getStatus().active && + Boolean(previousRoot) && path.resolve(previousRoot) === resolved && Boolean(this.session.current); + if (sameWorkspace && !knownRoslyn?.restartRequired && !knownRoslyn?.cleanupFailed) { + checkOperation({ signal }); + const fp = await this.cache.computeWorkspaceFingerprint(resolved, { fresh: true }); + checkOperation({ signal }); + const result = await this.workspace.openWorkspace(resolved, options); + checkOperation({ signal }); + this.session.touch(); + this.session.setFingerprint(fp); + return result; + } + if (!this.switchingPromise) { this.switchingPromise = new Promise((resolve) => { this.resolveSwitching = resolve; }); } - const previousRoot = this.config.workspaceRoot; let rootPrepared = false; let phase = 'drain'; try { @@ -398,22 +414,23 @@ export class ToolRouter { ); } - const resolved = path.resolve(targetPath); - const sameWorkspace = - !this.workspaceRecovery && this.watch.getStatus().active && Boolean(previousRoot) && path.resolve(previousRoot) === resolved && Boolean(this.session.current); - phase = 'fingerprint'; const fp = await this.cache.computeWorkspaceFingerprint(resolved, { fresh: true }); checkOperation({ signal }); phase = 'workspace'; - const result = await this.workspace.openWorkspace(targetPath, options); + const result = await this.workspace.openWorkspace(resolved, options); rootPrepared = true; checkOperation({ signal }); if (sameWorkspace) { phase = 'refresh'; - // 同根 workspace_open 是显式恢复入口;停止旧 Host 后由下一次搜索按新 SDK/输入加载。 - if (this.roslyn) { phase = 'roslyn-reset'; await this.roslyn.resetConnection(); } + // 重复确认保留健康 Host;只有已知的重启要求走显式恢复。 + // INPUTS_CHANGED 等重载状态仍由下一次搜索处理,不能在此丢弃。 + const knownRoslyn = this.roslyn?.getKnownHealth(); + if (this.roslyn && (knownRoslyn?.restartRequired || knownRoslyn?.cleanupFailed)) { + phase = 'roslyn-reset'; + await this.roslyn.resetConnection(); + } const previousFp = this.session.current?.fingerprint ?? null; this.session.touch(); this.session.setFingerprint(fp); diff --git a/src/Core/Workspace.ts b/src/Core/Workspace.ts index 2d96274..6293e1d 100644 --- a/src/Core/Workspace.ts +++ b/src/Core/Workspace.ts @@ -203,7 +203,8 @@ export class WorkspaceManager { const previousTrash = this.config.trashDir; // Metadata collection can fail after validation (e.g. the directory disappears). // Restore both mutable paths on failure so callers never observe a rejected root. - this.setRoot(resolvedPath); + // Same-root overview must not mutate trash/config while business requests are running. + if (path.relative(previousRoot, resolvedPath) !== '') this.setRoot(resolvedPath); try { const { identity, complete, discovery, entryPoints } = await this.discoverProject(); const git = await this.getGitStatus(); diff --git a/src/Gateway/WorkspaceTools.ts b/src/Gateway/WorkspaceTools.ts index 69c8130..e349192 100644 --- a/src/Gateway/WorkspaceTools.ts +++ b/src/Gateway/WorkspaceTools.ts @@ -7,7 +7,7 @@ import { contractHash } from './ContractHash.js'; export const WORKSPACE_TOOLS = [ defineTool({ name: 'workspace_open', - description: 'Opens a workspace and returns a compact project summary and at most 8 entry paths. Default output is bounded to 8000 UTF-16 characters; counts describe bounded discovery, not a complete inventory. Directory tree is opt-in and bounded; use wincode_list_directory for focused browsing.', + description: 'Opens a workspace and returns a compact project summary and at most 8 entry paths. Reopening the same healthy workspace preserves the Roslyn Host and snapshot; known HOST_RESTART_REQUIRED or workspace recovery still requires explicit recovery. Default output is bounded to 8000 UTF-16 characters; counts describe bounded discovery, not a complete inventory. Directory tree is opt-in and bounded; use wincode_list_directory for focused browsing.', inputSchema: { type: 'object', additionalProperties: true, diff --git a/tests/fixtures/cache-peer.mjs b/tests/fixtures/cache-peer.mjs new file mode 100644 index 0000000..d70cbdb --- /dev/null +++ b/tests/fixtures/cache-peer.mjs @@ -0,0 +1,32 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +const repo = process.cwd(); +const { getDefaultConfig } = await import(pathToFileURL(path.join(repo, 'src/Core/Config.ts'))); +const { CacheManager } = await import(pathToFileURL(path.join(repo, 'src/Core/Cache.ts'))); +const { RepomixAdapter } = await import(pathToFileURL(path.join(repo, 'src/Adapters/RepomixAdapter.ts'))); +const config = getDefaultConfig(process.argv[2]); +config.adapters.repomix.useCli = false; +config.cacheLimits.maxEntryBytes = 8192; +config.cacheLimits.maxDiskEntries = 1; +const cache = new CacheManager(config.cacheDir, 500, 1, config.cacheLimits); +cache.setNamespace(config.workspaceRoot); await cache.initialize(); +const packer = new RepomixAdapter(config, cache); await packer.initialize(); +process.on('message', async ({ id, operation }) => { + try { + let result; + if (operation === 'pack') { + const packed = await packer.packWorkspace({ candidateFiles: ['Large.cs'], outputFormat: 'markdown' }); + result = { fromCache: packed.fromCache, contentOmitted: packed.contentOmitted, overflowPath: packed.overflowPath, + backingFileExists: packed.overflowPath ? !!await fs.stat(packed.overflowPath).catch(() => null) : null }; + } else if (operation === 'prune') { + await cache.set('newer-small-entry', { marker: 'second process' }); + await cache.pruneDiskCache(); result = await cache.getStats(); + } else if (operation === 'close') { + await packer.dispose(); await cache.flush(); + process.send({ id, result: 'closed' }, () => process.disconnect()); return; + } + process.send({ id, result }); + } catch (error) { process.send({ id, error: String(error.stack) }); } +}); +process.send({ ready: true, pid: process.pid }); diff --git a/tests/flaui-adapter.test.ts b/tests/flaui-adapter.test.ts index 177a13f..8c379f5 100644 --- a/tests/flaui-adapter.test.ts +++ b/tests/flaui-adapter.test.ts @@ -100,7 +100,8 @@ describe('FlaUiAdapter Unit & Lifecycle Suite', () => { maxNodes: 50, }); - assert.strictEqual(res.success, true); + assert.strictEqual(res.success, true, JSON.stringify({ requestId: res.requestId, + errorCode: res.errorCode, errorMessage: res.errorMessage, captureMethod: res.captureMethod })); assert.ok(res.tree, 'Result must contain tree root'); assert.strictEqual(res.tree?.controlType, 'Window'); assert.ok((res.totalNodes ?? 0) >= 15, `Expected >= 15 nodes, got ${res.totalNodes}`); diff --git a/tests/local-text.test.ts b/tests/local-text.test.ts index c4b656c..bde3b65 100644 --- a/tests/local-text.test.ts +++ b/tests/local-text.test.ts @@ -12,7 +12,7 @@ import { getDefaultConfig } from '../src/Core/Config.js'; async function fixture(run: (adapter: LocalTextAdapter, root: string, cached: Map) => Promise) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-text-bounds-')); const cached = new Map(); - const cache = { computeWorkspaceFingerprint: async () => 'fixture', get: async (key: string) => cached.get(key), + const cache = { memoizeContent: (_key: string, _fp: string, create: () => unknown) => create(), computeWorkspaceFingerprint: async () => 'fixture', get: async (key: string) => cached.get(key), set: async (key: string, value: unknown) => { cached.set(key, value); } }; const config = getDefaultConfig(root); diff --git a/tests/repomix-disabled.test.ts b/tests/repomix-disabled.test.ts index bc9103d..bc6876b 100644 --- a/tests/repomix-disabled.test.ts +++ b/tests/repomix-disabled.test.ts @@ -259,27 +259,28 @@ it('disabled packing does not join an enabled CLI operation already in flight', }); for (const initiallyEnabled of [false, true]) { - it(`keeps the entry policy while fingerprinting when CLI changes from ${initiallyEnabled}`, async () => { + it(`keeps the entry policy across asynchronous preparation when CLI changes from ${initiallyEnabled}`, async () => { await fixture(async (adapter, config) => { - let release!: (fingerprint: string) => void; + let release!: () => void; let cliCalls = 0; const internals = adapter as any; - const originalFingerprint = internals.cache.computeWorkspaceFingerprint; - internals.cache.computeWorkspaceFingerprint = () => new Promise((resolve) => { release = resolve; }); + const originalPack = internals.packWorkspaceUncached.bind(adapter); + const gate = new Promise(resolve => { release = resolve; }); + internals.packWorkspaceUncached = async (...args: unknown[]) => { await gate; return originalPack(...args); }; Object.assign(adapter, { isCliAvailable: true, packWithCli: async () => { cliCalls++; return cliSnapshot; } }); config.adapters.repomix.useCli = initiallyEnabled; const first = adapter.packWorkspace(); config.adapters.repomix.useCli = !initiallyEnabled; - release('unchanged-fixture'); + release(); const result = await first; assert.equal(result.source, 'builtin-fallback'); assert.equal(cliCalls, 0); - internals.cache.computeWorkspaceFingerprint = originalFingerprint; + internals.packWorkspaceUncached = originalPack; config.adapters.repomix.useCli = false; const disabled = await adapter.packWorkspace(); assert.equal(disabled.source, 'builtin-fallback'); assert.match(disabled.content, /builtinEvidence/); - assert.equal(disabled.fromCache, !initiallyEnabled); + assert.equal(disabled.fromCache, true, 'only verified builtin content is reusable across policy changes'); }); }); } diff --git a/tests/request-concurrency.test.ts b/tests/request-concurrency.test.ts index cace672..e4fa7ae 100644 --- a/tests/request-concurrency.test.ts +++ b/tests/request-concurrency.test.ts @@ -13,6 +13,64 @@ import { ToolRouter } from '../src/Core/ToolRouter.js'; import { Mutex } from '../src/Core/ResourceManager.js'; import { SessionManager } from '../src/Core/SessionManager.js'; +const deferred = () => { let resolve!: () => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; + +it('queued cancellations physically remove waiters and preserve FIFO among surviving requests', async () => { + const mutex = new Mutex(), hold = deferred(), entered = deferred(); + const order: number[] = []; + const owner = mutex.runExclusive(async () => { entered.resolve(); await hold.promise; }); + await entered.promise; + const controllers = Array.from({ length: 128 }, () => new AbortController()); + const pending = controllers.map((controller, index) => mutex.runExclusive(async () => { + order.push(index); + }, controller.signal).then(() => 'completed', error => { assert.equal(error.name, 'AbortError'); return 'cancelled'; })); + assert.equal(mutex.pendingCount, 128); + controllers.forEach((controller, index) => { if (index % 2 === 0) controller.abort(); }); + assert.equal(mutex.pendingCount, 64, 'cancelled closures must disappear while the owner is still blocked'); + assert.equal(order.length, 0); + hold.resolve(); await owner; + const results = await Promise.all(pending); + assert.equal(results.filter(value => value === 'cancelled').length, 64); + assert.deepStrictEqual(order, Array.from({ length: 64 }, (_, index) => index * 2 + 1)); + assert.equal(mutex.pendingCount, 0); + await mutex.runExclusive(async () => { order.push(128); }); + assert.equal(order.at(-1), 128); +}); + +it('cancelling an active owner does not release the lock before asynchronous cleanup', async () => { + const mutex = new Mutex(), entered = deferred(), cleanup = deferred(), controller = new AbortController(); + const owner = mutex.runExclusive(async () => { + entered.resolve(); + await new Promise(resolve => controller.signal.addEventListener('abort', () => resolve(), { once: true })); + await cleanup.promise; + throw new Error('work failed after cleanup'); + }, controller.signal); + const rejected = assert.rejects(owner, /after cleanup/); + await entered.promise; + let nextStarted = false; + const next = mutex.runExclusive(async () => { nextStarted = true; }); + controller.abort(); + await Promise.resolve(); + assert.equal(nextStarted, false); + assert.equal(mutex.pendingCount, 1); + cleanup.resolve(); await rejected; await next; + assert.equal(nextStarted, true); + assert.equal(mutex.pendingCount, 0); +}); + +it('cancellation before entry and synchronous work failure both leave the mutex reusable', async () => { + const mutex = new Mutex(), controller = new AbortController(); + let invoked = false; + const pending = mutex.runExclusive(async () => { invoked = true; }, controller.signal); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(invoked, false); + await assert.rejects(mutex.runExclusive(() => { throw new Error('sync failure'); }), /sync failure/); + await mutex.runExclusive(async () => { invoked = true; }); + assert.equal(invoked, true); + assert.equal(mutex.pendingCount, 0); +}); + const execAsync = promisify(exec); async function pidAlive(pid: number): Promise { diff --git a/tests/roslyn-contracts.test.ts b/tests/roslyn-contracts.test.ts index 8362d67..af11358 100644 --- a/tests/roslyn-contracts.test.ts +++ b/tests/roslyn-contracts.test.ts @@ -13,6 +13,58 @@ import { ImpactAnalyzer } from '../src/CompositeTools/ImpactAnalyzer.js'; import { RefactorAssistant } from '../src/CompositeTools/RefactorAssistant.js'; import type { CodeReferenceQuery, SymbolReference } from '../src/Core/CodeQueries.js'; +it('same-root confirmations preserve warm identity, reload state and perform a required restart only once', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-warm-中文 空格-')); + const config = getDefaultConfig(root); + config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', + targetFramework: 'net10.0', dotnetPath: process.execPath, hostPath: path.join(root, 'host.dll') }; + const router = new ToolRouter(config); + try { + await router.initialize(); + const adapter = router.roslyn!, state = adapter as any; + let closes = 0; + const host = { active: true, close: async () => { closes++; } }; + state.client = host; state.snapshot = 'a'.repeat(32); + const before = await router.getRuntimeHealth(); + const reset = t.mock.method(adapter, 'resetConnection', adapter.resetConnection.bind(adapter)); + const aliases = [root, path.join(root, '.'), root + path.sep]; + if (process.platform === 'win32') aliases.push(root.toUpperCase().replaceAll('\\', '/')); + for (let index = 0; index < 10; index++) await router.openWorkspace(aliases[index % aliases.length]); + assert.equal(reset.mock.callCount(), 0); + assert.equal(state.client, host); + assert.equal(adapter.getKnownHealth().snapshotId, before.roslyn?.snapshotId); + assert.equal(router.session.current?.id, before.session?.id); + assert.deepEqual((await router.getRuntimeHealth()).workspaceWatch, before.workspaceWatch); + assert.equal(config.workspaceRoot, root); + state.reloadRequired = true; + await fs.writeFile(path.join(root, 'Changed.cs'), 'class Changed {}'); + await router.openWorkspace(root); + assert.equal(adapter.getKnownHealth().reloadRequired, true, 'confirmation must not consume required input reload'); + assert.equal(state.client, host); + assert.equal(closes, 0); + state.restartRequired = true; + await Promise.all(Array.from({ length: 10 }, () => router.openWorkspace(root))); + assert.equal(reset.mock.callCount(), 1, 'queued confirmations must recheck state after the first recovery'); + assert.equal(closes, 1); + assert.equal(adapter.getKnownHealth().restartRequired, false); + assert.equal(adapter.getKnownHealth().snapshotId, null); + // A failed cleanup remains terminal even if another path set only the typed cleanup state. + state.client = { active: true, close: async () => { throw new Error('fixture cleanup failed'); } }; + state.restartRequired = true; + await assert.rejects(router.openWorkspace(root), WorkspaceRecoveryRequiredError); + const resetCount = reset.mock.callCount(); + await assert.rejects(router.openWorkspace(root), WorkspaceRecoveryRequiredError); + assert.equal(reset.mock.callCount(), resetCount); + assert.equal(router.workspaceRecoveryState?.recoveryAction, 'restart_gateway'); + } finally { + if (router.workspaceRecoveryState?.recoveryAction === 'restart_gateway') await assert.rejects(router.dispose()); + else await router.dispose(); + assert.ok(path.relative(os.tmpdir(), root).startsWith('wincode-warm-')); + await fs.rm(root, { recursive: true, force: true }); + } +}); + /** 用固定语义身份提供真实聚合器的输入;工作区故意不同于进程启动目录。 */ function impactFixture(references: SymbolReference[]) { const config = getDefaultConfig(path.resolve('test-tmp/impact-identity-fixture')); diff --git a/tests/runtime-cache-regressions.test.ts b/tests/runtime-cache-regressions.test.ts new file mode 100644 index 0000000..3cf8753 --- /dev/null +++ b/tests/runtime-cache-regressions.test.ts @@ -0,0 +1,185 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync, fork, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { CacheManager } from '../src/Core/Cache.js'; +import { RepomixAdapter } from '../src/Adapters/RepomixAdapter.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; + +const deferred = () => { let resolve!: () => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; +const body = (result: any) => result.structuredContent ?? JSON.parse(result.content[0].text); +async function fixture(run: (router: ToolRouter, root: string, client: Client) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-runtime-cache-')); + const config = getDefaultConfig(root); + config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + const router = new ToolRouter(config), server = new WinCodeMcpServer(router); + const client = new Client({ name: 'runtime-cache-regression', version: '1' }); + const [a, b] = InMemoryTransport.createLinkedPair(); + try { + await router.initialize(); + await Promise.all([client.connect(a), (server as any).server.connect(b)]); + await run(router, root, client); + } finally { + await client.close(); await server.stop(); + assert.equal(path.dirname(root), os.tmpdir()); + await fs.rm(root, { recursive: true, force: true }); + } +} + +for (const scenario of ['untracked-directory', 'deep-file', 'beyond-hint-budget']) { + it(`public MCP returns current source and declarations without relying on watcher events: ${scenario}`, async () => fixture(async (router, root, client) => { + if (scenario === 'untracked-directory') { + const git = (...args: string[]) => execFileSync('git', args, { cwd: root, windowsHide: true, stdio: 'pipe' }); + git('init'); await fs.writeFile(path.join(root, 'README.md'), 'fixture'); + git('add', 'README.md'); + git('-c', 'user.name=WinCode Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'fixture'); + } + if (scenario === 'beyond-hint-budget') { + for (let i = 0; i < 110; i++) await fs.writeFile(path.join(root, `a${i}.txt`), 'hint'); + } + const relative = scenario === 'deep-file' ? 'src/a/b/c/Target.cs' : 'src/Target.cs'; + const file = path.join(root, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, 'public class Gone { string Value = "OLD_VALUE"; }'); + const args = { task: 'Inspect source', scopeFiles: [relative], includeFullText: true, maxTokens: 8000 }; + const pack = async () => body(await client.callTool({ name: 'wincode_prepare_context', arguments: args })); + const symbols = async () => body(await client.callTool({ name: 'wincode_find_code_symbol', arguments: { query: 'Gone' } })); + assert.match((await pack()).packedContent, /OLD_VALUE/); + assert.equal((await symbols()).totalFound, 1); + // Same length and restored mtime make stat-only validation insufficient too. + const stat = await fs.stat(file); + await fs.writeFile(file, 'public class Live { string Value = "NEW_VALUE"; }'); + await fs.utimes(file, stat.atime, stat.mtime); + const updated = await pack(); + assert.match(updated.packedContent, /NEW_VALUE/); + assert.doesNotMatch(updated.packedContent, /OLD_VALUE/); + assert.equal(updated.queryComplete, true); + assert.equal((await symbols()).totalFound, 0); + assert.equal((await pack()).metrics.fromCache, true, 'unchanged actual inputs still reuse the pack'); + await fs.writeFile(path.join(root, 'Added.cs'), 'class Gone {}'); + assert.equal((await symbols()).totalFound, 1, 'new files must join the scan'); + await fs.unlink(path.join(root, 'Added.cs')); + assert.equal((await symbols()).totalFound, 0, 'deleted files must leave the scan'); + })); +} + +it('cached references disappear after their file changes, while declaration parsing shares the existing byte budget', async () => fixture(async (router, root) => { + const file = path.join(root, 'Caller.cs'); + await fs.writeFile(file, 'class Caller { void Run() { Gone(); } }'); + assert.equal((await router.text.findReferencesDetailed('Gone')).totalReferences, 1); + await fs.writeFile(file, 'class Caller { void Run() { Live(); } }'); + assert.equal((await router.text.findReferencesDetailed('Gone')).totalReferences, 0); + const cache = new CacheManager(path.join(root, 'limited'), 2, 2, { maxMemoryBytes: 256, maxEntryBytes: 256 }); + let parses = 0; + const parse = () => { parses++; return ['Caller']; }; + cache.memoizeContent('file', 'old', parse); cache.memoizeContent('file', 'old', parse); + assert.equal(parses, 1); + cache.memoizeContent('file', 'new', parse); + assert.equal(parses, 2); + for (let i = 0; i < 20; i++) cache.memoizeContent(String(i), 'input', () => ['x'.repeat(30)]); + assert.ok(cache.memoryEntryCount <= 2); assert.ok(cache.estimatedMemoryBytes <= 256); +})); + +it('peer eviction causes recomputation for both memory and disk hits with missing overflow', async () => fixture(async (router, root) => { + await fs.writeFile(path.join(root, 'Large.cs'), 'class Large {}\n' + '// evidence\n'.repeat(2000)); + const cache = new CacheManager(router.config.cacheDir, 20, 1, { maxEntryBytes: 8192 }); + const peer = new CacheManager(router.config.cacheDir, 20, 1, { maxEntryBytes: 8192 }); + await cache.initialize(); await peer.initialize(); + const adapter = new RepomixAdapter(router.config, cache); + const options = { candidateFiles: ['Large.cs'] }; + try { + const first = await adapter.packWorkspace(options); + assert.equal(first.contentOmitted, true); assert.ok(first.overflowPath); + await peer.set('newer', { marker: 'peer' }); await peer.pruneDiskCache(); + await assert.rejects(fs.stat(first.overflowPath), { code: 'ENOENT' }); + const second = await adapter.packWorkspace(options); + assert.equal(second.fromCache, false); assert.ok(second.overflowPath); + assert.match(await fs.readFile(second.overflowPath, 'utf8'), /class Large/); + // Persisted metadata alone must not be accepted by a new process-equivalent manager either. + await fs.unlink(second.overflowPath); + const cold = new RepomixAdapter(router.config, new CacheManager(router.config.cacheDir, 20, 1, { maxEntryBytes: 8192 })); + try { + const third = await cold.packWorkspace(options); + assert.equal(third.fromCache, false); assert.ok(third.overflowPath); + assert.match(await fs.readFile(third.overflowPath, 'utf8'), /class Large/); + } finally { await cold.dispose(); } + } finally { await adapter.dispose(); await cache.flush(); await peer.flush(); } +})); + +it('cancelling same-root metadata confirmation leaves the healthy session usable', async () => fixture(async (router, root) => { + const original = router.workspace.openWorkspace.bind(router.workspace); + const entered = deferred(), finish = deferred(), controller = new AbortController(); + const session = router.session.current?.id, namespace = router.cache.currentNamespace; + router.workspace.openWorkspace = async (...args) => { const result = await original(...args); entered.resolve(); await finish.promise; return result; }; + const pending = router.openWorkspace(root, {}, controller.signal); + const cancelled = assert.rejects(pending, { name: 'AbortError' }); + try { + await entered.promise; controller.abort(); finish.resolve(); await cancelled; + assert.equal(router.workspaceRecoveryState, null); + assert.equal(router.session.current?.id, session); assert.equal(router.cache.currentNamespace, namespace); + await router.acquireRequestSlot(); router.endRequest(); + } finally { finish.resolve(); router.workspace.openWorkspace = original; } +})); + +it('two real processes recover after peer eviction without returning a dangling cache hit', async () => fixture(async (_router, root) => { + await fs.writeFile(path.join(root, 'Large.cs'), 'class Large {}\n' + '// evidence\n'.repeat(2000)); + const children: ChildProcess[] = []; + const peers: Array<{ request: (operation: string) => Promise }> = []; + const start = async () => { + const child = fork(path.resolve('tests/fixtures/cache-peer.mjs'), [root], { + execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + children.push(child); + let sequence = 0; + const pending = new Map void; reject: (error: Error) => void }>(); + child.on('message', (message: any) => { + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error)); else waiter.resolve(message.result); + }); + child.on('exit', () => { for (const waiter of pending.values()) waiter.reject(new Error('Cache peer exited')); pending.clear(); }); + assert.equal((await once(child, 'message'))[0].ready, true); + const peer = { request: (operation: string) => new Promise((resolve, reject) => { + const id = ++sequence; pending.set(id, { resolve, reject }); child.send({ id, operation }); + }) }; + peers.push(peer); return peer; + }; + try { + const a = await start(), b = await start(); + const first = await a.request('pack'); + assert.equal(first.backingFileExists, true); + await b.request('prune'); + await assert.rejects(fs.stat(first.overflowPath), { code: 'ENOENT' }); + const second = await a.request('pack'); + assert.equal(second.fromCache, false); assert.equal(second.backingFileExists, true); + assert.notEqual(second.overflowPath, first.overflowPath); + assert.equal((await a.request('pack')).fromCache, true); + } finally { + for (const peer of peers) await peer.request('close'); + for (const child of children) if (child.exitCode === null) await once(child, 'exit'); + } +})); + +it('same-root confirmation and heartbeat finish while an earlier business query remains blocked', async () => fixture(async (router, root, client) => { + const entered = deferred(), finish = deferred(); + const original = router.text.findSymbolsDetailed.bind(router.text); + router.text.findSymbolsDetailed = async (...args) => { entered.resolve(); await finish.promise; return original(...args); }; + const query = client.callTool({ name: 'wincode_find_code_symbol', arguments: { query: 'Target' } }); + try { + await entered.promise; + const results = await Promise.all([ + client.callTool({ name: 'workspace_open', arguments: { path: root } }), + client.callTool({ name: 'wincode_hello_world', arguments: {} }), + ]); + for (const result of results) assert.notEqual(result.isError, true); + assert.equal(router.inFlightRequests, 1, 'slow query is still active, rather than secretly drained/cancelled'); + assert.equal(router.workspaceRecoveryState, null); + } finally { finish.resolve(); await query; router.text.findSymbolsDetailed = original; } +})); diff --git a/tools/WinCode.Tray/TrayAcceptance.cs b/tools/WinCode.Tray/TrayAcceptance.cs index 66a3bbe..c35fbaa 100644 --- a/tools/WinCode.Tray/TrayAcceptance.cs +++ b/tools/WinCode.Tray/TrayAcceptance.cs @@ -42,6 +42,8 @@ public static async Task RunWorkflow(SettingsWindow window, PipeHub hub, string public static async Task Run(SettingsWindow window, PipeHub hub, string endpoint, string root) { var scenarios = new List(); string? failure = null; + var clock = System.Diagnostics.Stopwatch.StartNew(); + void Stage(string name) => Console.Error.WriteLine($"[tray-ui] {clock.ElapsedMilliseconds}ms thread={Environment.CurrentManagedThreadId} {name}"); try { Directory.CreateDirectory(root); @@ -54,13 +56,13 @@ public static async Task Run(SettingsWindow window, PipeHub hub, string endpoint await window.RefreshStatus(); var idle = hub.Peers.Single(peer => GatewayPeer.Text(peer.Status, "state") == "idle"); var busy = hub.Peers.Single(peer => GatewayPeer.Text(peer.Status, "state") == "busy"); - Console.Error.WriteLine("[tray-ui] stale status and release preflight"); + Stage("stale status and release preflight"); await VerifyStaleStatus(window, hub, endpoint, idle.Status); scenarios.Add("Connected but unresponsive peer becomes unknown, disables release, preserves observation time, and recovers on explicit refresh without replaying control"); - Console.Error.WriteLine("[tray-ui] repeated rejected registrations"); + Stage("repeated rejected registrations"); for (int attempt = 0; attempt < 12; attempt++) await VerifyRejectedRegistration(hub, endpoint, idle.Status); scenarios.Add("Authenticated incompatible registration receives a bounded rejection reason visible to the settings hub"); - Console.Error.WriteLine("[tray-ui] selected instance controls"); + Stage("selected instance controls"); Require(!window.SelectInstance(busy.Id), "Busy instance must disable release"); await window.ReleaseSelected(); Require(window.LastResult.Contains("工作"), "Backend must refuse release while busy"); @@ -79,15 +81,17 @@ public static async Task Run(SettingsWindow window, PipeHub hub, string endpoint await window.ReleaseSelected(); Require(window.LastResult.Contains("无需释放"), "Repeated release is not a no-op"); scenarios.Add("Repeated manual release is harmless"); - Console.Error.WriteLine("[tray-ui] hide and show acknowledgement"); - window.Hide(); await Task.Delay(300); + Stage("before Hide"); + window.Hide(); Stage("after Hide"); await Task.Delay(300); Stage("after hidden delay"); Require(idle.Connected && busy.Connected, "Hiding settings disconnected Gateways"); scenarios.Add("Closing/hiding settings preserves independent Gateway connections"); var shown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - void OnVisible(object? sender, EventArgs args) { if (window.Visible) shown.TrySetResult(); } + void OnVisible(object? sender, EventArgs args) { if (window.Visible) { Stage("VisibleChanged true"); shown.TrySetResult(); } } window.VisibleChanged += OnVisible; try { - await Task.Run(() => Program.ShowExisting(endpoint)); + Stage("before ShowExisting worker"); + await Task.Run(() => { Stage("ShowExisting worker entered"); Program.ShowExisting(endpoint); Stage("ShowExisting worker returned"); }); + Stage("show acknowledgement received"); await shown.Task.WaitAsync(TimeSpan.FromSeconds(3)); Require(hub.Peers.Count(peer => peer.Connected) == 2, "Showing existing settings changed instance connections"); } finally { window.VisibleChanged -= OnVisible; } @@ -95,6 +99,7 @@ public static async Task Run(SettingsWindow window, PipeHub hub, string endpoint } catch (Exception error) { failure = error.ToString(); Environment.ExitCode = 1; } finally { + Stage("writing native report"); await File.WriteAllTextAsync(Path.Combine(root, "tray-ui-report.json"), JsonSerializer.Serialize(new { success = failure == null, scenarios, error = failure, limitation = "Actual WinForms controls and secured Named Pipe; simulated Roslyn backends. Physical UIA and real Roslyn tested separately." }, new JsonSerializerOptions { WriteIndented = true })); window.ExitTray(); From 5d1ae3749976a9995fadbd8c2fe24de8f67ed0d8 Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:48:41 +0800 Subject: [PATCH 2/2] fix: reject reused parent PIDs in acceptance and align project documentation --- CHANGELOG.md | 4 +++ CONTRIBUTING.md | 4 ++- README.md | 8 ++--- SECURITY.md | 6 ++-- ...15\347\275\256\346\214\207\345\215\227.md" | 12 +++++--- ...43\350\256\241\345\210\222\344\271\246.md" | 6 ++-- ...56\346\265\201\350\257\264\346\230\216.md" | 24 ++++++++++----- ...43\350\267\257\347\272\277\345\233\276.md" | 4 +-- docs/codex_worklog.md | 19 ++++++++++++ scripts/lib/owned-processes.mjs | 29 +++++++++++++++++-- skills/wincode/SKILL.md | 2 +- skills/wincode/references/code.md | 4 +++ skills/wincode/references/diagnostics.md | 6 +++- skills/wincode/references/ui.md | 4 ++- tests/runtime-cache-regressions.test.ts | 13 +++++++++ tools/WinCode.UIA.Host/README.md | 6 ++-- 16 files changed, 120 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c96a42e..f88ae66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.14.0 (unreleased) +- Validate parent/child creation times in acceptance process-tree snapshots, rejecting stale parent-PID edges after Windows PID reuse. Keep strict exit checks for the real observed Code Host descendants. + +- Synchronize bilingual setup/cache guidance, architecture/data flow, managed Skill manuals, native Host guidance and remaining roadmap with the verified 0.14.0 behavior. Separate healthy confirmation from recovery and current implementation from planned project binding/admission limits. + - Bind builtin context cache reuse to the selected file contents; rescan bounded local-text inputs and reuse declarations by content within the existing memory budget. Untracked directories, deep files, additions/deletions and same-size edits no longer reuse stale query results. CLI output without an input manifest is not reused as a cached snapshot. - Treat a missing overflow attachment as a cache miss for memory and disk readers, including after another process prunes the shared cache. - Confirm healthy same-root workspaces without draining business requests. Cancelling read-only confirmation does not enter recovery or reset a healthy Host; real rebind and cleanup failures retain the recovery gate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4a2de0..00eda38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,12 +35,14 @@ On 2026-09-08, the owner authorized applying main protection. The read-back conf SDK policy follows [Microsoft global.json guidance](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json); dependency locking uses [NuGet locked restore](https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#locking-dependencies). -Current implementation is described in the [architecture guide](WinCode-架构与数据流说明.md). Remaining work is maintained in the [active engineering plan](WinCode-下一轮工程化迭代计划书.md); completed work belongs in CHANGELOG and the append-only work log. For documentation-only changes, verify local links, commands, version claims and evidence boundaries; do not claim a new runtime regression without running it. +Current implementation is described in the [architecture guide](WinCode-架构与数据流说明.md). Remaining work is maintained in the [active engineering plan](WinCode-下一轮工程化迭代计划书.md); completed work belongs in CHANGELOG and the append-only work log. For documentation-only changes, verify local links, commands, version claims and evidence boundaries; do not claim a new runtime regression without running it. The four managed Skill files are delivery inputs, so regenerate/verify the manifest after updating them; this does not synchronize an installed client Skill or reconnect its MCP process. Node 22 CI runs `npm run test:error-contracts` and uploads its bounded report. It exercises protocol errors, matching tool-error text/structured payloads, real generated-file trash failures and workspace recovery; injected UI images test serialization only. Run it locally after changes to these boundaries. `npm run test:manual-release` verifies ten actual Roslyn release/reload cycles in generated C# projects, old-location rejection, stable owned resources, retained cache/watcher, edits while cold and A/B workspace reuse. The optional Tray is built and version/fingerprint checked by `check`; `check:desktop` additionally runs `npm run test:tray`, exercising actual WinForms and secured Named Pipes with two isolated stdio MCP clients and simulated Roslyn lifetimes. Reports are under `test-tmp/manual-release/` and `test-tmp/tray/`. UI screenshots are local only; these checks do not enable autostart or alter installed MCP client configuration. +The core inventory includes `tests/runtime-cache-regressions.test.ts`: actual input freshness despite same-size/restored-mtime writes, additions/deletions, bounded parsing reuse, missing overflow in memory/disk caches, two actual cache processes, and same-root cancellation/slow-query interleaving. Keep these adversarial cases when changing caching or workspace lifecycle. Process-tree observation also tests PID reuse: every parent edge must respect creation order, so an old system process cannot become a new Helper descendant merely through a recycled PID. A client cancellation may settle before Gateway cleanup; same-root confirmation is not a drain barrier. Observe actual in-flight completion within the existing budget, then retain strict owned-process exit assertions. + After a nontrivial test failure, investigate official documentation and relevant real GitHub implementations/issues before choosing a fix. Record the observed failure, applicability of the reference and actual rerun result; do not replace verification with copied examples or arbitrary longer sleeps. Obvious syntax, object-shape and path mistakes can be corrected directly. `npm run test:tray-workflow` (also in `check:desktop`) uses two compiled stdio MCP instances, real C# fixtures, the native settings handlers and authenticated pipes. It checks warm-state continuity across spaced queries, busy refusal, targeted release/recovery and Tray exit. No active Codex configuration is changed. diff --git a/README.md b/README.md index d1dbaf7..67dd8eb 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Add WinCode as a stdio MCP server in your agent client configuration (for client } ``` -Without `--workspace`, WinCode initially uses the server process's current working directory, which may differ from your intended project. Before querying, ask the agent to call `workspace_open` with the target project's absolute path, for example `workspace_open({"path":"C:/path/to/project"})`. Repeat this when switching projects; the server installation path stays the same. One server process has one active workspace, so calls sharing that process must not interleave queries for different projects. For concurrent independent projects, configure separate server instances with distinct names and explicit workspace paths. +Without `--workspace`, WinCode initially uses the server process's current working directory, which may differ from your intended project. Before querying, ask the agent to call `workspace_open` with the target project's absolute path, for example `workspace_open({"path":"C:/path/to/project"})`. Repeat this when switching projects; the server installation path stays the same. Confirming the same healthy workspace keeps the Roslyn Host/snapshot warm and does not drain active queries. Cancelling that confirmation does not force recovery; known restart/cleanup failures still follow the explicit recovery path. One server process has one active workspace, so calls sharing that process must not interleave queries for different projects. For concurrent independent projects, configure separate server instances with distinct names and explicit workspace paths. **Specify a project at startup:** Add `--workspace` followed by the project directory: @@ -180,7 +180,7 @@ Coding agent ── stdio MCP ── WinCode - **Owned-process cleanup:** UI inspection executes out-of-process via an isolated helper (`tools/WinCode.UIA.Host`). All process cleanups target only the owned helper process tree via Windows `taskkill /T`; the inspected target application is never terminated or injected. - **Concurrency Protection:** UI inspection and health checks share a serial execution mutex to prevent native UIA message pump deadlocks. Workspace switches safely drain in-flight calls before changing cache namespaces. -- **Byte-Bounded Cache:** Memory and disk caches enforce strict byte caps (default 32 MiB serialized memory, 128 MiB disk quota including disk-spilled overflow snapshots). Debounced file watching (150 ms) and index probing invalidate the ~2.5s fingerprint memo upon disk changes. +- **Byte-Bounded Cache:** The shared cache manager bounds retained serialized data (default 32 MiB memory, 128 MiB disk including overflow); these are not process RSS limits. Local-text queries re-enumerate bounded inputs and reuse declarations by content hash. Builtin packs validate the actual selected contents before reuse; CLI output without a verified input manifest is not cached. Missing overflow files become cache misses. Watch/index probes invalidate the ~2.5s change-hint memo; that hint is not proof of source identity or a guarantee that watcher events are complete. | UI budget | Limit / behavior | | --- | --- | @@ -283,7 +283,7 @@ npm run delivery:verify } ``` -省略 `--workspace` 时,WinCode 初始使用服务进程的当前工作目录,它不一定是你要分析的项目。查询前,让 Agent 调用 `workspace_open` 并传入目标项目的绝对路径,例如 `workspace_open({"path":"C:/path/to/project"})`。换项目时再次调用即可,服务安装路径无需修改。一个服务进程只有一个活动工作区,共享该进程的调用不能交错查询不同项目;如需同时独立查询多个项目,应配置名称不同、各自明确指定工作区路径的服务实例。 +省略 `--workspace` 时,WinCode 初始使用服务进程的当前工作目录,它不一定是你要分析的项目。查询前,让 Agent 调用 `workspace_open` 并传入目标项目的绝对路径,例如 `workspace_open({"path":"C:/path/to/project"})`。换项目时再次调用即可,服务安装路径无需修改。同一健康工作区的重复确认保留 Roslyn Host/快照,不等待在途业务排空;取消该确认不会强制进入恢复。已知重启要求和清理失败仍走显式恢复路径。一个服务进程只有一个活动工作区,共享该进程的调用不能交错查询不同项目;如需同时独立查询多个项目,应配置名称不同、各自明确指定工作区路径的服务实例。 **启动时指定项目:**添加 `--workspace`,并在其后填写项目目录: @@ -406,7 +406,7 @@ Coding Agent ── stdio MCP ── WinCode - **目标进程绝对免疫:**UI 取证由独立的 C# 辅助进程(`tools/WinCode.UIA.Host`)在进程外执行。所有清理操作严格仅终止自身派生的 Helper 辅助进程树(通过 Windows `taskkill /T`),**被测目标应用进程受绝对免疫保护,绝不被终止或注入**。 - **防死锁与并发保护:**UI 自动化访问与健康检查共用串行互斥锁,杜绝底层 Win32/UIA 消息泵死锁。切换工作区前会先等待排空在途请求,超时则拒绝切换,保证会话隔离安全。 -- **按字节硬封顶缓存:**代码缓存按工作区物理隔离,采用序列化内存估算与字节上限清理策略(默认内存预算 32 MiB、磁盘配额 128 MiB,包含超大快照落盘文件)。结合 150 ms 去抖监听与 Git 索引探测,文件变更时指纹缓存及时失效。 +- **按字节约束缓存:**缓存条目按工作区 namespace 隔离,多个实例仍可能共用磁盘目录;默认序列化内存预算 32 MiB、磁盘配额 128 MiB(含 overflow),不等于进程 RSS 上限。local-text 每次有界扫描实际输入,按内容哈希复用声明解析;内置打包核对实际选中文件的内容后复用,没有可核验输入清单的 CLI 结果不缓存。附件缺失按缓存未命中重建。150 ms 去抖监听与索引探测只使约 2.5 秒的变更提示 memo 失效,不能证明源码完整身份或保证监听事件无遗漏。 | 取证预算指标 | 限制值与行为策略 | | --- | --- | diff --git a/SECURITY.md b/SECURITY.md index 4b7f0bc..2b0951d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,8 @@ # Security policy / 安全策略 -The latest 0.13.x version and current `main` are maintained. Older versions do not have a separate backport commitment. Supported runtimes are Node 24 (primary) and Node 22 (compatibility), on Windows x64; build requirements are in [CONTRIBUTING](CONTRIBUTING.md). +The latest 0.13.x version and current `main` are maintained; the current development line on `main` is 0.14.0. Older versions do not have a separate backport commitment. Supported runtimes are Node 24 (primary) and Node 22 (compatibility), on Windows x64; build requirements are in [CONTRIBUTING](CONTRIBUTING.md). -目前维护最新 0.13.x 版本与 `main`,不承诺对旧版本单独回补。Windows x64 上以 Node 24 为主要环境、22 为兼容环境;构建要求见贡献指南。 +目前维护最新 0.13.x 版本与 `main`;`main` 当前开发线为 0.14.0,不承诺对旧版本单独回补。Windows x64 上以 Node 24 为主要环境、22 为兼容环境;构建要求见贡献指南。 Report suspected vulnerabilities through [GitHub private vulnerability reporting](https://github.com/linnnn89/WinCode/security/advisories/new). Include the affected version/build identity, reproduction steps, expected and observed behavior, and a minimal sanitized example. Do not include credentials, personal databases or private source unnecessarily. Avoid publishing exploit details in a public issue before coordination with the maintainer. @@ -12,4 +12,4 @@ Reports are triaged as maintainer availability permits. There is no guaranteed r 维护者按实际可用时间评估与复现,不承诺固定响应或修复时限。确认的问题及缓解措施通过报告沟通,并按需发布补丁或安全公告。扫描任务成功不等于已有告警已关闭。 -Relevant boundaries include workspace path containment, shell arguments and process ownership, bounded resource consumption, and UI audit integrity. WinCode may terminate helper processes it owns during cleanup; inspected application PIDs must remain outside that ownership. UI inspection and screenshots can expose application data, so reports should use isolated fixtures. Local audit logs and content hashes are diagnostic evidence, not tamper-proof records or release signatures. Report a violation of these boundaries even when a test currently passes. +Relevant boundaries include workspace path containment, shell arguments and process ownership, bounded resource consumption, and UI audit integrity. WinCode may terminate helper processes it owns during cleanup; inspected application PIDs must remain outside that ownership. UI inspection and screenshots can expose application data, so reports should use isolated fixtures. Local audit logs and content hashes are diagnostic evidence, not tamper-proof records or release signatures. Report a violation of these boundaries even when a test currently passes. Workspace namespaces and separate PIDs do not prove cross-process storage isolation. A bounded change hint is not source identity; current builtin cache reuse validates selected content and treats missing overflow as a miss, without promising an atomic workspace snapshot or permanent file lease. Unified admission limits and task-level workspace binding remain planned; existing cache budgets are not process memory limits. diff --git "a/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" "b/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" index 47c902a..9fd6c0e 100644 --- "a/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" +++ "b/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" @@ -1,11 +1,11 @@ # WinCode Skill 安装、维护与 MCP 配置指南 -适用于 **0.13.1**,核对日期 2026-09-09(北京时间)。以下使用本机 `I:/WinCode` 路径举例;其他机器必须替换路径。客户端界面名称随版本变化,以实际界面为准。 +适用于 **0.14.0**,核对日期 2026-09-10(北京时间)。以下使用本机 `I:/WinCode` 路径举例;其他机器必须替换路径。客户端界面名称随版本变化,以实际界面为准。 ## 1. 三个独立对象 - **Skill 手册**指导 Agent 选择工具和使用规范字段,不启动服务器。 -- **磁盘交付物**包含 Gateway、原生 UIA/Code Host 的完整发布目录、构建身份和交付清单。 +- **磁盘交付物**包含 Gateway、原生 UIA/Code Host 及可选 Tray 的完整发布目录、构建身份和交付清单。 - **MCP 连接实例**是客户端已经启动的进程;更新源码、构建或复制 Skill 都不会自动更新这个进程。 架构与数据流见 [架构说明](WinCode-架构与数据流说明.md)。待办见 [当前计划](WinCode-下一轮工程化迭代计划书.md),不要按历史计划重复安装和升级。 @@ -22,7 +22,7 @@ npm run check npm run delivery:verify ``` -`check` 进行类型检查、Gateway 构建、锁定 NuGet restore、Release UIA/Code Host 与控制台夹具构建、核心回归、新 stdio 验证与交付清单生成。交互桌面验收另执行 `npm run check:desktop`,需要可用 Windows 桌面。真实 Roslyn/TavernDesk 验收按对应入口执行,详见 [CONTRIBUTING](CONTRIBUTING.md)。Roslyn 维护脚本会锁定还原并构建/发布 Code Host,也会还原生成夹具,不还原用户目标应用;NuGet 缓存缺包时可能访问包源,不能将“不自动安装 SDK”理解为完全离线。 +`check` 进行类型检查、Gateway 构建、锁定 NuGet restore、Release UIA/Code Host/Tray 与控制台夹具构建、核心回归、新 stdio 验证与交付清单生成。交互桌面验收另执行 `npm run check:desktop`,需要可用 Windows 桌面。真实 Roslyn/TavernDesk 验收按对应入口执行,详见 [CONTRIBUTING](CONTRIBUTING.md)。Roslyn 维护脚本会锁定还原并构建/发布 Code Host,也会还原生成夹具,不还原用户目标应用;NuGet 缓存缺包时可能访问包源,不能将“不自动安装 SDK”理解为完全离线。 `npm run build` 只构建 Gateway,不能单独证明原生 Host、Skill 和整个交付物一致。生产使用发布的 Release Host;`npm run dev` 才显式启用开发回退。报告位于 `test-tmp/check/`,内容哈希不是发布签名。 @@ -67,7 +67,9 @@ npm run skill:check -- C:/Users/40218/.agents/skills/wincode } ``` -不同客户端配置格式可能不同;本例不能直接替代 Codex 自身配置文件格式。不要重复注册多个同名或路径不同的旧实例。WinCode 走 stdio,无需另设 HTTP 服务。 +不同客户端配置格式可能不同;本例不能直接替代 Codex 自身配置文件格式。不要重复注册多个同名或路径不同的旧实例。WinCode 走 stdio,无需另设 HTTP 服务。一个实例仍只有一个可切换的活动工作区,N1 固定项目尚未实施;不同项目并发应各自配置明确的 --workspace 和不同实例名,不能在同一连接交错切换。健康同根 workspace_open 保留热态,不是清理屏障;已知故障按 diagnostics 的恢复动作处理。 + +可选托盘按 README 启动,并仅给需要管理的实例添加独立参数 --tray。默认不连接托盘、不设置自启动;自动释放关闭,设置内只允许用户手动释放,忙碌时拒绝且不延后执行。本文仅更新配置说明,不代表已修改任何已安装客户端。 ## 5. 验证实际连接 @@ -87,6 +89,8 @@ npm run skill:check -- C:/Users/40218/.agents/skills/wincode | --- | --- | | 源码是新版,hello 返回旧版 | 核对实际命令、路径、instanceId 和启动时间;通过客户端重连,不以强杀宿主或复制文件冒充完成 | | 字段被忽略,结果不像预期 | 对照规范字段表和实际工具 schema;容忍未知字段并不赋予其语义 | +| 修改文件后仍看到旧正文 | 核对实际连接 buildId;本轮修复按实际内容校验缓存,旧运行进程需重连。文件或附件引用跨调用不保证永久有效,重新取证而非要求用户先 git add | +| 取消后客户端已返回但 Host 尚在收尾 | 客户端取消不等于服务端清理完成;同根打开也不再等待清理,检查实际在途状态,不反复重启 | | Host 缺失或身份不符 | 完整执行锁定构建和 delivery:verify;不混用旧 DLL、新 Gateway 或开发 Host | | Roslyn/Repomix 不可用 | 先核对 provider、显式配置、项目求值许可、已知健康和恢复动作;Roslyn 失败不会暗中换成本地文本,Repomix 降级不代表语义验收成功 | | Roslyn 返回候选且 references 为空 | 简单名请求未查询引用;按 signature/file/project 消歧,再传返回的 name 与完整 symbolLocation,不能当作零引用 | diff --git "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" index 06fefec..8bf62b2 100644 --- "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" +++ "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" @@ -26,7 +26,7 @@ 已从开发待办移除:本机交付重建、原生 owner guard、UIA 启动探测延后、可逆手动释放、最小 WinForms 托盘/安全管道、状态过期与注册拒绝反馈、原生源码与交付绑定,以及已经运行的双实例托盘/Roslyn 贯通。对应核心 360/360、桌面 35/35 和后续专项回执见 [11:32 稳定性记录](docs/codex_worklog.md#2026-09-10-1132--0140-稳定性收尾工作流连续性状态可信度与原生交付北京时间)。这些是已有回归基线,不是下一轮重新建设任务。 -N2 已取得核心 364/364、真实 Roslyn MCP 22 场景和 E4 错误契约 16 场景证据;同根十次重复打开、四次并发确认保持实际 Host PID/snapshot,SDK 变化仍要求新进程,冷加载与热重载损坏、取消/崩溃/超时分别验证。互斥队列取消会立即删除等待节点,执行中的清理仍持锁,但这还不是完整有界准入。当前交付身份与补充桌面/多实例回执统一记录在工作日志;不能据此声称正在运行的旧消费者已更新。 +N2 已取得此前核心 364/364;本轮完整核心 373/373、桌面 35/35、真实 Roslyn MCP 22 场景和 E4 错误契约 16 场景证据;同根十次重复打开、四次并发确认保持实际 Host PID/snapshot,SDK 变化仍要求新进程,冷加载与热重载损坏、取消/崩溃/超时分别验证。互斥队列取消会立即删除等待节点,执行中的清理仍持锁,但这还不是完整有界准入。当前交付身份与补充桌面/多实例回执统一记录在工作日志;不能据此声称正在运行的旧消费者已更新。 | 现象 | 当前证据等级 | 下一步 | | --- | --- | --- | @@ -91,11 +91,11 @@ N2 已取得核心 364/364、真实 Roslyn MCP 22 场景和 E4 错误契约 16 ### N4:补同项目共享存储与编辑竞争证据 -已完成并移出本节待办:实际双进程 peer prune 删除附件后的内存命中重建,以及新 CacheManager 从磁盘读取缺失附件后的重建;内置打包按实际文件内容复用,本地文本扫描不再信任有界工作区指纹作为完整输入身份。新增 8 项回归见 `tests/runtime-cache-regressions.test.ts`。取消同根确认保持健康 Host、慢查询期间同根确认/hello 不等待 drain 也已验证;这些结论不覆盖真正切换期间的轻量状态旁路(仍属 N3)。 +已完成并移出本节待办:实际双进程 peer prune 删除附件后的内存命中重建,以及新 CacheManager 从磁盘读取缺失附件后的重建;内置打包按实际文件内容复用,本地文本扫描不再信任有界工作区指纹作为完整输入身份。新增 8 项运行回归及 1 项 PID 复用观察器回归见 `tests/runtime-cache-regressions.test.ts`。取消同根确认保持健康 Host、慢查询期间同根确认/hello 不等待 drain 也已验证;这些结论不覆盖真正切换期间的轻量状态旁路(仍属 N3)。 N1 固定项目只解决实例内根变化;两个独立连接仍可能指向同一物理项目和默认 cacheDir。缓存有原子临时文件写入、每实例写队列和附件存在性复核;仍未建立附件长期租约,不能保证返回引用在未来任意时刻不被清理。 -1. 复用隔离生成项目,两个真实 Gateway 同时走当前缓存/内置打包路径:同键和不同键写入、读取、prune、overflow 大内容引用,以及其中一个退出。只调用已安装工具链,不为此下载安装真实 Repomix。 +1. 在已通过的 peer prune 后重建之外,继续用两个真实 Gateway 验证同键/不同键并发写入、清理与退出交错,特别是已经返回的附件在后续读取时的生命周期。只调用已安装工具链,不为此下载安装真实 Repomix。 2. 记录是否有损坏 JSON、跨项目正文、另一实例仍使用的 overflow 被清除、退出后队列不排空。普通缓存未命中可以重算;错误正文或看似命中却指向错误/缺失实体必须阻断验收。 3. 最终倾向明确可变临时文件的拥有者:本轮已对失效附件按缓存未命中重建;若后续证据要求跨调用保护附件,再明确仅隔离正在写入的临时/overflow 产物及其引用归属;不要先把整个持久缓存改成每次启动一个 UUID 目录,避免磁盘复制与失去复用。若现有做法已安全,保留并补回归;需要跨进程锁、新清理政策或数据迁移时再明确具体变更。 4. 同项目两个实例分别查询时修改生成夹具:旧定位明确失效,新搜索获得对应输入的结果,不把同时编辑或跨文件非原子变更说成完全一致快照。 diff --git "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" index 99d320c..cc5261c 100644 --- "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" +++ "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" @@ -1,8 +1,8 @@ # WinCode 架构、数据流与检查关口 -**源码契约:0.13.1;基于已合并的 main@a23740c,结构更新日期:2026-09-09(北京时间)。交付验收见工作记录。** +**源码契约:0.14.0;基于 #34 合并基线及 PR #35 的缓存/工作连续性修复,更新日期:2026-09-10(北京时间)。具体提交、测试与合并状态见工作记录和 GitHub PR。** -本说明描述当前源码中已实现的结构。GitHub 分支保护的历史只读核查日期为 2026-09-08,本轮未重新查询远端;历史实测结果见[工作记录](docs/codex_worklog.md)。源码版本、磁盘构建和客户端当前连接是三个不同对象,不能互相替代。 +本说明描述当前源码中已实现的结构。GitHub 分支保护的历史只读核查日期为 2026-09-08;本轮核对 PR 检查状态,不把它等同重新审计全部保护设置。历史实测结果见[工作记录](docs/codex_worklog.md)。源码版本、磁盘构建和客户端当前连接是三个不同对象,不能互相替代。 ## 1. 整体定位与结构 @@ -31,6 +31,7 @@ flowchart TB Adapters <-->|"stdin 请求 / stdout JSON"| Host[".NET UIA Host\nFlaUI · Win32 · 截图 · 审计"] Host -->|"只读取证"| App["目标 Windows 应用\n独立 PID / HWND"] State <--> Disk["本地文件系统\n源码 · 项目文件 · 缓存 · trash"] + Tray["可选 WinForms 托盘/设置\n被动状态 · 用户手动释放"] <-->|"同用户/会话认证 Named Pipe"| Router Use -->|"有界读取"| Disk CodeHost --> Disk Repomix --> Disk @@ -76,8 +77,13 @@ sequenceDiagram G-->>A: MCP 文本 / 可选图片 G->>R: finally 释放在途计数 else workspace_open - G->>R: 专用切换流程,避免等待自身在途计数 - R-->>G: 新工作区摘要或切换失败 + G->>R: 工作区互斥锁,不计入普通在途请求 + alt 同根健康确认 + R->>R: 读取概览,保留 Host,不等待业务排空 + else 切换或已知故障恢复 + R->>R: 排空、重绑或重置;失败保留恢复状态 + end + R-->>G: 工作区摘要或领域错误 G-->>A: 有界摘要 end ``` @@ -86,6 +92,8 @@ sequenceDiagram **容忍未知字段,严格校验已知字段。** 未声明字段可出现在协议请求中,但会在递归整理参数时被剔除,不能影响业务或原生请求;声明字段不做字符串→数字等隐式类型转换。例如,拼错 `lineRanges` 不会自动启用范围检索。 +互斥等待节点采用 FIFO,排队取消会立即删除实际节点,正在执行的任务仍在清理完成后归还执行权。被动 hello 在健康同根确认期间可响应,但真正切换屏障和完整过载准入仍待 N3。 + **准入不是全局限流器。** 在途计数主要用于保护工作区切换和关闭;当前没有一个统一的“全部请求最多并发 N 个”策略。UI 请求及 UI 健康探测另有适配器互斥锁。 ## 3. 代码证据的数据流 @@ -173,13 +181,15 @@ flowchart TB | `test-tmp/` | 检查报告、隔离夹具、本轮获准安装的真实上游 | 开发验收数据,不提交到 Git;真实上游安装不等于默认连接已配置 | | 已安装 Skill 目录 | Agent 使用手册 | 独立于源码和运行进程;同步前备份,之后核对内容 | -缓存同时使用工作区 namespace、fingerprint 与 TTL。Watcher 使文件变化能失效短期 fingerprint 记忆;内存默认预算 32 MiB,磁盘默认 128 MiB(含 overflow),单项默认 2 MiB。这些是缓存数据预算,**不是整个 Node 进程 RSS 的硬上限**。 +缓存按工作区 namespace 分区。local-text 每次沿用 8 MiB 总读取、5000 个目录项等现有扫描预算,按文件路径和内容 SHA-256 复用声明解析;不再用有界工作区提示复用整份查询结果。内置打包读取候选后,以有序路径/内容元组计算身份,再命中相同内容;没有可核验输入清单的 CLI 结果不复用。解析结果使用同一内存 LRU,不另开无界缓存。内存默认预算 32 MiB,磁盘默认 128 MiB(含 overflow),单项默认 2 MiB;这些**不是整个 Node 进程 RSS 的硬上限**。 -源码、磁盘状态和多个调用之间不存在数据库式快照事务;Watcher/fingerprint/TTL 也不能保证每次观察均与外部写入同步。需要判断新鲜度时应结合实际文件哈希、结果范围及变更时间。 +源码、磁盘状态和多个调用之间不存在数据库式快照事务;Watcher/fingerprint/TTL 也不能保证每次观察均与外部写入同步。工作区 fingerprint 只作有界变更提示,watcher 不能成为唯一新鲜度依据。跨进程清理后,内存和磁盘读取都复核 overflow 是否存在,缺失即重建;这不是跨调用租约,引用未来仍可能过期,需要重新请求。 ### 5.2 工作区切换 -`工作区互斥锁 → 暂停普通请求进入 → 等待旧请求结束 → 校验/打开目标 → 更新 namespace/session/fingerprint → 重绑 watcher → 关闭并重建相关上游状态 → 恢复请求准入`。 +健康同根确认:`工作区互斥锁 → 读取概览/刷新提示 → 保留 Host、快照、watcher 和 session`。它不设置切换屏障、不等待普通查询排空;取消只读确认不会制造恢复门。 + +真正换根或已知恢复:`工作区互斥锁 → 暂停普通请求进入 → 等待旧请求结束 → 校验/打开目标 → 更新 namespace/session/fingerprint → 重绑 watcher → 按状态重置相关上游 → 恢复请求准入`。SDK 重启要求、清理失败及部分重绑定不能走健康确认捷径。 旧请求不能在限定时间内结束时,拒绝切换;等待期间可以取消。开始提交切换后完成必要收尾,当前实现不承诺跨文件系统、适配器与缓存的事务性回滚。 diff --git "a/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" "b/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" index b84f6ab..1ad4f01 100644 --- "a/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" +++ "b/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" @@ -14,9 +14,9 @@ 实际消费者最新构建/Roslyn 接入、既有未定位原生验收失败、共享存储竞争、长期资源趋势等仍保留为待验,不能随已完成功能删除。 -本轮移除 N2:健康同路径重复打开保留 Host/snapshot,Windows 大小写/分隔符别名保持身份;已知 SDK 重启要求和清理失败门禁继续生效。核心 364/364、真实 Roslyn 22 场景、E4 16 场景通过。N3 的物理取消/FIFO 基础已实现,但统一容量与过载尚未完成;具体回执和测试前提修正保留在工作日志。 +本轮移除 N2:健康同路径重复打开保留 Host/snapshot,Windows 大小写/分隔符别名保持身份;已知 SDK 重启要求和清理失败门禁继续生效。此前核心 364/364;本轮完整核心 373/373、桌面 35/35、真实 Roslyn 22 场景、E4 16 场景通过。N3 的物理取消/FIFO 基础已实现,但统一容量与过载尚未完成;具体回执和测试前提修正保留在工作日志。 -补充验收修复:实际内容绑定的上下文缓存、按内容复用解析的有界文本扫描、缺失附件命中重建,以及同根确认取消/慢查询边界已通过新增 8 项回归。真实 Roslyn 取消后 PID/snapshot 保留;不把这些结果称作 N1 或完整 N3 已完成。 +补充验收修复:实际内容绑定的上下文缓存、按内容复用解析的有界文本扫描、缺失附件命中重建,以及同根确认取消/慢查询边界已通过新增 8 项运行回归及 1 项 PID 复用观察器回归。真实 Roslyn 取消后 PID/snapshot 保留;不把这些结果称作 N1 或完整 N3 已完成。 ## 接下来的开发与验证 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index 0cf09bc..be2ce86 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -962,3 +962,22 @@ - 作者反证自审:相同文件长度/mtime、监听遗漏、新增文件、磁盘条目尚存但正文附件缺失,以及取消客户端先结束而服务端仍在收尾。保留真实修改后的输入失效/SDK 重启/清理失败恢复测试;不因同根保温跳过实际故障。规划仅移除已取得证据的完成项,N1、完整 N3、N4 其他存储/UI/源码竞争、N5 真实消费者和长期样本仍保留。 - 14:02 收尾:调整观察方式后的 [真实 Roslyn 22 场景](../test-tmp/roslyn-gateway/run-wNNpAi/report.json)全部通过,包括主动 MSBuild 取消/崩溃/超时及进程退出。最终本地 delivery matched=true,contentId=ace3a662543d5c6df01bda9f23bea28199031c420f976e6a5f4fdb39d3ba19fb;Gateway buildId=717352bcd195475eed76cab88e7c1f96701c8fbaaab8e105a1d34dcccdc34b75。该本地构建在切换等内容基线前产生,revision 元数据为 bf220a9;不把它冒充最终 PR 提交构建,远端 CI 将核验具体提交。git diff --check 通过;推送/合并状态以随后 GitHub 回执为准。 + + +## 2026-09-10 14:10 — 全项目 Markdown 同步(北京时间) + +- 用户在 PR #35 等待 CI 时要求同步更新本项目各个 md。核对 Git 管理的 14 份 Markdown,按相关范围更新 README 中英说明、架构和时序图、配置/Skill 指南、四份受管 Skill 文档、贡献与安全说明、UIA Host README、CHANGELOG、规划/路线图及本日志。没有同步已安装手册、修改实际客户端配置或连接。 +- 纠正旧 0.13.1 架构/指南/Host 当前版本标题;安全策略保留既有维护承诺,补充 main 的 0.14.0 开发线。源码版本、磁盘产物和运行实例继续分别核验,不把本次 PR 合并写成已发布版本。补充独立托盘/Helper 所有权与手动释放边界,自动释放仍关闭。 +- 统一缓存文案:namespace 不等于跨进程物理隔离;有界 fingerprint 不是源码身份;声明按内容复用、内置打包按实际候选内容复用、CLI 无输入清单不缓存、附件失效可重建但没有永久租约。纠正“监听一定及时失效”和“已有生产缓存等于 UI 证据跨调用有效”的过度表述。 +- 同根健康确认不排空、不重启、取消不制造恢复状态;真实重绑定/已知故障仍需恢复。N1、完整 N3、N4 剩余交错/UI/源码边界及 N5 消费者/长期验证继续保留。此前 364/360 等历史结果不改写,当前基线明确为核心 372/372、桌面 35/35、真实 Roslyn 22/E4 16。 +- 四份受管 Skill 文档属于交付输入,本轮会重跑完整核心检查并重建交付清单;不因只改 Markdown 便沿用旧清单冒充匹配。旧 CI 结果只属于 bcd2fe5,文档提交后的合并等待对应新提交检查。 + + +## 2026-09-10 14:47 — Node 22 远端进程归属误判修复(北京时间) + +- PR #35 首个提交 bcd2fe5 的 [CI 34443549607](https://github.com/linnnn89/WinCode/actions/runs/34443549607)中,Node 24 与全部 CodeQL 通过,Node 22 核心/E4/独立 Roslyn Host 58 场景通过,但 Roslyn Gateway 的 timeout 退出断言失败。随后 owner-death、Repomix owner-death、manual-release 因脚本立即退出而没有执行,不能把这些项目写成该轮已通过。 +- 下载有界回执后定位:所谓残留 PID 752 是 csrss.exe,创建于 05:59:48;其 ParentProcessId=744,而真正测试中 PID 744 是 06:08:39 才创建的 conhost.exe。旧 ownedProcesses 只用 ParentProcessId 展开,沿这条错误边把 138 个进程纳入 Code Host 子树。此证据支持验收观察器 PID 复用误判,不支持 Roslyn 清理失败;不能通过无限重跑隐藏。 +- 按用户要求先查 [Win32_Process 官方 ParentProcessId/CreationDate 说明](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-process)及 [MicrosoftDocs 原文](https://github.com/MicrosoftDocs/win32/blob/docs/desktop-src/CIMWin32Prov/win32-process.md)。修复 scripts/lib/owned-processes.mjs:同一 CIM 快照中按父子创建时间校验每条边,缺少相关创建身份时报错,不能悄悄省略未知后代。重放远端回执,138 项缩为实际 7 项,保留 dotnet Code Host、BuildHost、cmd、blocker node 及对应 conhost;没有增加清理宽限或修改产品清理代码。 +- 新增确定性 PID 复用/缺少创建身份回归,专项现为 9/9。修改受管文档后的 [核心 372/372](../test-tmp/check/2026-09-10T06-10-43-228Z-core/report.json)通过;加上 PID 回归后的最终 [完整核心 373/373](../test-tmp/check/2026-09-10T06-14-00-192Z-core/report.json)通过类型、构建、原生交付、stdio 和清单校验。修正观察器后的 [真实 Roslyn Gateway 22 场景](../test-tmp/roslyn-gateway/run-qoTd5O/report.json)通过。新增规则依旧严格断言真实子进程退出,不把只读过滤回放当成新的清理证据。 +- 全项目 14 份 Markdown 已核对,93 个本地链接、UTF-8、代码围栏通过(历史工作日志只核对新增段,不静默改写旧失败)。文档检查初次因 Git 对中文文件名引用导致路径解析失败,改用 git ls-files -z 取得真实文件名后通过,属于检查脚本路径处理错误。 +- 用户补充确认:修复观察器之后,必须在最新提交上完整运行 Node 22 专项链,若真实 Code Host/BuildHost/cmd/blocker 仍存活才继续查产品生命周期。在该链与全部必需检查通过前,PR #35 保持未合并;不沿用 bcd2fe5 的部分 CI 结果。 diff --git a/scripts/lib/owned-processes.mjs b/scripts/lib/owned-processes.mjs index 5f8813f..27b35ab 100644 --- a/scripts/lib/owned-processes.mjs +++ b/scripts/lib/owned-processes.mjs @@ -1,16 +1,41 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +/** ParentProcessId survives the parent; reject edges to an older process after PID reuse. */ +export function selectOwnedProcesses(all, pid) { + const created = item => { + const match = /^\/Date\((\d+)\)\/$/.exec(item.CreationDate ?? ''); + const value = match ? Number(match[1]) : Date.parse(item.CreationDate); + assert.ok(Number.isFinite(value), `Missing process creation identity: ${item.ProcessId}`); + return value; + }; + const root = all.find(item => item.ProcessId === pid); + if (!root) return []; + const selected = new Map([[pid, root]]); + created(root); + let added; + do { + added = false; + for (const item of all) { + const parent = selected.get(item.ParentProcessId); + if (selected.has(item.ProcessId) || !parent) continue; + if (created(item) < created(parent)) continue; + selected.set(item.ProcessId, item); added = true; + } + } while (added); + return all.filter(item => selected.has(item.ProcessId)); +} + /** 只读记录测试所有进程树;命令只插入正整数 PID,创建时间用于排除 PID 复用。 */ export function ownedProcesses(pid) { assert.ok(Number.isSafeInteger(pid) && pid > 0); - const command = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $all = @(Get-CimInstance Win32_Process); $ids = @(${pid}); do { $more = @($all | Where-Object { $_.ParentProcessId -in $ids -and $_.ProcessId -notin $ids }); $ids += @($more | ForEach-Object { $_.ProcessId }) } while ($more.Count -gt 0); @($all | Where-Object { $_.ProcessId -in $ids } | Select-Object ProcessId,ParentProcessId,CreationDate,Name,CommandLine) | ConvertTo-Json -Compress`; + const command = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,Name,CommandLine) | ConvertTo-Json -Compress`; const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { encoding: 'utf8', windowsHide: true, timeout: 20000, }); assert.equal(result.status, 0, result.error?.message ?? result.stderr); const value = JSON.parse(result.stdout || '[]'); - return Array.isArray(value) ? value : [value]; + return selectOwnedProcesses(Array.isArray(value) ? value : [value], pid); } /** 同时核对 PID 和创建时间;此函数只断言退出,不终止任何进程。 */ diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index bfa91ac..cfcba9e 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -17,6 +17,6 @@ description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面 使用客户端已连接的 WinCode MCP 工具;名称前缀以实际暴露为准。 参数采用兼容容忍模式:未声明字段会被忽略,不表示相应功能已生效;已声明字段仍校验类型、必填项和范围。按对应手册的规范字段表构造请求,使用真正的 JSON 数字/布尔值,不传字符串替代。以当前连接 tools/list 的 schema 为准;手册比连接新时,不反复尝试旧实例未支持的参数。 工具不可用时读诊断手册,不用临时脚本绕过 MCP 或审计。 -源码操作前确认活动工作区;仅在未知或切换项目时调用 workspace_open。 +源码操作前确认活动工作区;仅在未知或切换项目时调用 workspace_open。健康同根确认保留 Host,不是强制重启或清理完成屏障;多个项目并发使用不同连接,固定项目和统一过载限制仍未实施。 按需获取小结果,不例行探测、遍历全仓、截图或重复枚举。 保留降级、截断与歧义,不把源码候选当作确定的运行时映射。 diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index 17ed96f..28fbbf6 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -6,6 +6,8 @@ `analyze_change_impact` 及其别名返回一个 JSON 文本块,formattedReport 保留在对象中,不再返回第二份重复 Markdown。没有新增 responseFormat 参数,不要给该工具传 context 专用的格式字段。 +0.14.0 的 local-text 查询每次有界扫描实际输入,按内容哈希复用声明解析;新建、删除或修改文件后重新查询,不依据 Git 暂存状态或监听事件认定旧结果有效。内置打包先读取选中文件,再校验内容身份复用;CLI 输出没有可核验输入清单时不缓存。overflow 缺失会重建,但返回的临时文件不是永久存档,跨调用读取失败应重新获取。以上不提供多文件原子快照,也不把文本定位升级为 Roslyn 精确身份。 + ## 后端与能力边界 默认 Gateway 使用 WinCode 内置文本能力,查询结果 `source=local-text`。`hello.health.text.semanticConfigured=false` 只描述文本适配器,即使启用 Roslyn 仍为 false;整个实例的提供方看 `hello.codeProvider`,Roslyn 状态看 `hello.health.roslyn`。显式启用 Roslyn 后使用直接 Code Host,失败会报错,不会偷偷改换提供方。外部 Serena 连接配置、启动器及旧 `serena-adapter-fallback` 来源已退役;旧调用方须适配。`hello.codeProvider` 标明实例选择,不能根据仓库中存在 Host 推断当前连接已更新。 @@ -119,6 +121,8 @@ lineRanges 查看最终 coverage.allRequestedCovered、completeLines 和 details 选定 Roslyn 重载后,将其 name 和 location 原样传给后续工具:引用使用 symbolName,影响分析及重构使用 target,同时传 symbolLocation。后两者先验证定位再分析,不按名字重选目标;SNAPSHOT_STALE/INPUTS_CHANGED 时须重新搜索。简单名称歧义检查 resolution/candidateCount/candidatesTruncated,不能选第一项。queryComplete=false 不等于零引用。 +健康同根 workspace_open 保留 Host/snapshot,不等待业务排空;取消概览确认不会使健康实例进入恢复。真实换根、已知 SDK 重启要求或清理失败仍遵守诊断手册;当前仍允许切换活动根,不能在同一连接交错处理不同项目。 + workspace_open 默认返回项目摘要和最多 8 个入口,整份 JSON 默认不超过 8000 个 UTF-16 字符;不生成目录树或统计全仓大小。检查 projectScanComplete,null 统计不等于零。需要目录时用 wincode_list_directory 指定窄路径,查看 scanComplete/truncated/omissions。includeTree:true 可显式取得有界兼容树,不能当成完整仓库清单。maxOutputChars 为 2048–32768;目录 maxDepth 为 1–5,maxEntries 为 1–500。需要生成目录时显式 includeIgnored:true,但不能越过工作区边界。 ```json diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index b7b56f4..5562115 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -48,7 +48,7 @@ Roslyn 运行中已观察到的加载、查询或清理错误也纳入 health.la Code Host 内部协议 v2 的失败包含 success=false、errorCode 和 error,且不附带旧引用。SNAPSHOT_STALE/INPUTS_CHANGED 在内部协议层要求等写入稳定后显式 reload,再用新身份定位;MCP 客户端应重新调用 wincode_find_code_symbol,由适配器执行所需重载,不存在 wincode_reload 工具;PROJECT_LOAD_FAILED 表示结构化 MSBuild 加载失败,先修复项目输入,再 reload,不能继续使用最后一次成功快照。源码的 compilationErrors 可随有用的部分引用返回,不能据此宣称完整。 -Roslyn 的已知领域错误通过 MCP 的 isError=true 和 JSON 文本 success=false/errorCode/errorMessage 返回;失败的 JSON 文本与 structuredContent 一致,仍保留领域差异。健康状态下同根 workspace_open 保留 Host 和 snapshot;它不是强制冷启动命令。HOST_RESTART_REQUIRED(SDK/监听状态)应对当前路径执行 workspace_open,此时才关闭旧 Host,再由显式搜索重新选择 SDK。INPUTS_CHANGED/SNAPSHOT_STALE 按 search_again 重新搜索,普通打开不清除已知重载要求。启动时的项目、Configuration、TFM 和可执行文件配置固定于 Adapter;修改客户端启动配置后须正常重建连接,workspace_open 不热应用配置文件。清理失败则按 WORKSPACE_RECOVERY_REQUIRED 的 restart_gateway 处理,不能通过再次打开恢复。HOST_TIMEOUT/HOST_CRASHED 后旧定位不可用,下一次显式搜索才启动新 Host;不会重放失败引用。 +Roslyn 的已知领域错误通过 MCP 的 isError=true 和 JSON 文本 success=false/errorCode/errorMessage 返回;失败的 JSON 文本与 structuredContent 一致,仍保留领域差异。健康状态下同根 workspace_open 保留 Host 和 snapshot;它不是强制冷启动命令,也不是等待在途业务清理完成的屏障。普通同根确认取消不进入恢复;客户端取消先返回时,后台操作仍可能正在清理,不能把客户端 Promise 结束当作 Host 已退出。HOST_RESTART_REQUIRED(SDK/监听状态)应对当前路径执行 workspace_open,此时才关闭旧 Host,再由显式搜索重新选择 SDK。INPUTS_CHANGED/SNAPSHOT_STALE 按 search_again 重新搜索,普通打开不清除已知重载要求。启动时的项目、Configuration、TFM 和可执行文件配置固定于 Adapter;修改客户端启动配置后须正常重建连接,workspace_open 不热应用配置文件。清理失败则按 WORKSPACE_RECOVERY_REQUIRED 的 restart_gateway 处理,不能通过再次打开恢复。HOST_TIMEOUT/HOST_CRASHED 后旧定位不可用,下一次显式搜索才启动新 Host;不会重放失败引用。 INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项目路径,以及 additionalInputs 中的文件是否存在;补充文件缺失时,重载也会失败,恢复文件后再显式搜索。不要为恢复查询而静默移除真实构建输入。HOST_VERSION_MISMATCH 先核对 Code Host 与 Gateway 的版本、Release 配置和协议;不要继续使用混合交付。HOST_PROTOCOL_ERROR 同时检查协议 v2、inputPolicy.version=1 和实际补充列表;旧 Host 没有确认新策略时不能绕过。LEGACY_SYMBOL_ID 要求重新搜索 Roslyn 身份;UNSUPPORTED_SYMBOL_LOCATION 表示该实例未配置 Roslyn;SYMBOL_MISMATCH 表示名称和定位不一致。INPUT_BUDGET_EXCEEDED 区分枚举规模与受跟踪输入字节限制,先缩小受支持范围,不能接受截断指纹。内部 BUSY 表示队列已满,DUPLICATE_REQUEST 要求新的 id;CANCELLED 是目标终止结果,取消确认不替代它。OUTSIDE_WORKSPACE/UNSUPPORTED_LINK 拒绝越界或链接路径,不放松校验来恢复。 @@ -57,6 +57,10 @@ INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项 `npm run test:error-contracts` 使用生成夹具验证错误、部分完成与恢复;Node 22 CI 执行该专项并保存有界报告。UI 图片场景使用注入响应,只验证序列化,不冒充真实屏幕验收。 +## 源码缓存与附件(0.14.0) + +本地文本查询每次重新枚举有界输入,声明解析按实际内容复用;内置上下文打包按实际选中文件的内容复用。工作区 fingerprint 和 watcher 只是变更提示,不能证明完整源码身份。旧连接仍返回过时正文时,先核对运行 buildId,再由客户端正常重连,不以 git add 或反复释放 Host 代替升级。overflow 在命中时复核存在性,缺失则重建;它没有永久租约,后续读取失败可重新请求上下文,不全局删除其他实例的缓存。 + ## 连接关闭(0.13.2) 正式入口在 stdin EOF/close、传输关闭或管道错误时停止接收请求,取消初始化和活动操作,并按统一 8 秒预算清理自有资源。关闭失败保留非零退出结果;缓存写入不能无限延迟退出。不能把此行为等同于 Codex 当前连接已更新,也不能承诺强杀 Gateway 时所有后代均受同一个 Windows Job 保护。升级后刷新对应 MCP 连接,不必一概重启整个 Codex。 diff --git a/skills/wincode/references/ui.md b/skills/wincode/references/ui.md index 9272cd6..e27050e 100644 --- a/skills/wincode/references/ui.md +++ b/skills/wincode/references/ui.md @@ -1,5 +1,7 @@ # 窗口与 UI 取证 +0.14.0 的可选托盘与 UIA 取证 Host 是独立组件:隐藏或退出托盘不终止 MCP,也不卸载正在使用的 Roslyn;设置内手动释放仅影响选定实例的 Code Host。源码缓存修复不证明 UI 候选对应同一运行时状态;多实例选择不同 HWND 的隔离仍需单独验收。 + ## 规范字段 参数名称区分大小写;额外字段仅被容忍和忽略,不会被当成筛选条件。不能把 `automationID`、`AutomationId` 或 `title` 当作下面的规范字段。数字、布尔值必须使用真正的 JSON 类型。 @@ -47,6 +49,6 @@ captureQuality 在标注前检查原始像素,最多采样 1024 点;suspect- - runtimeBuildSourceIdentity=unknown、templateResolution=unsupported;不推定 DataContext 或 CanExecute,也不把构造参数相似当执行链证明。多个同名声明继续保留歧义,不能自动选择首项。 - nextRequest 先读候选所在精确行;relatedSymbol 仅为构造参数文字线索,可能是变量。核对赋值后再明确请求方法,不能因该字段自动认定为方法或完整调用链。复杂插值原始字符串/超出嵌套限制的文字扫描报告不支持。 -多个控件若指向同一文件、相邻赋值,可在确认文件未变化后复用当前会话已展示的精确行;有缺口时合并为一次有界 lineRanges 请求。不要因为每个候选都带 nextRequest 就机械重复读取。复用仅限已经核对的正文,不代表 WinCode 有生产级跨请求缓存,也不扩大运行时绑定结论。 +多个控件若指向同一文件、相邻赋值,可在确认文件未变化后复用当前会话已展示的精确行;有缺口时合并为一次有界 lineRanges 请求。不要因为每个候选都带 nextRequest 就机械重复读取。复用仅限已经核对的正文,不代表这些运行时 UI 证据获得了跨调用有效期保证,也不扩大运行时绑定结论。 内置 Host 强制显示半透明 REC/WinCoding 标志并记录极简审计,不绕过。审计提示按诊断手册处理。取证不授权点击、输入或读取其他窗口。启动测试应用时使用专用隔离数据模式;已有明确的固定测试 profile 时复用它,避免每次重复初始化,禁止使用个人数据库。 diff --git a/tests/runtime-cache-regressions.test.ts b/tests/runtime-cache-regressions.test.ts index 3cf8753..8318067 100644 --- a/tests/runtime-cache-regressions.test.ts +++ b/tests/runtime-cache-regressions.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import os from 'node:os'; import { execFileSync, fork, type ChildProcess } from 'node:child_process'; import { once } from 'node:events'; +import { pathToFileURL } from 'node:url'; import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { getDefaultConfig } from '../src/Core/Config.js'; import { CacheManager } from '../src/Core/Cache.js'; @@ -14,6 +15,18 @@ import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; const deferred = () => { let resolve!: () => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; const body = (result: any) => result.structuredContent ?? JSON.parse(result.content[0].text); + +it('process observation excludes stale parent PID edges without hiding real descendants', async () => { + const { selectOwnedProcesses } = await import(pathToFileURL(path.resolve('scripts/lib/owned-processes.mjs')).href); + const proc = (ProcessId: number, ParentProcessId: number, time: number) => ({ ProcessId, ParentProcessId, CreationDate: `/Date(${time})/` }); + const rows = [proc(752, 744, 100), proc(876, 744, 101), proc(972, 876, 102), + proc(2648, 7864, 1000), proc(5696, 2648, 1100), proc(7640, 5696, 1200), + proc(744, 7640, 1300), proc(7020, 7640, 1301)]; + assert.deepEqual(selectOwnedProcesses(rows, 2648).map((item: any) => item.ProcessId), [2648, 5696, 7640, 744, 7020]); + assert.deepEqual(selectOwnedProcesses(rows, 9999), []); + assert.throws(() => selectOwnedProcesses([...rows, { ProcessId: 777, ParentProcessId: 744 }], 2648), /Missing process creation identity/); +}); + async function fixture(run: (router: ToolRouter, root: string, client: Client) => Promise) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-runtime-cache-')); const config = getDefaultConfig(root); diff --git a/tools/WinCode.UIA.Host/README.md b/tools/WinCode.UIA.Host/README.md index 1e6852a..0dd157f 100644 --- a/tools/WinCode.UIA.Host/README.md +++ b/tools/WinCode.UIA.Host/README.md @@ -1,10 +1,10 @@ # WinCode.UIA.Host -0.13.1 的 Windows UI Automation(FlaUI.UIA3)一次性取证进程。实现入口为 [Program.cs](Program.cs),面向 Agent 的规范参数见 [UI 手册](../../skills/wincode/references/ui.md),整体数据流见 [架构说明](../../WinCode-架构与数据流说明.md)。 +0.14.0 的 Windows UI Automation(FlaUI.UIA3)一次性取证进程。实现入口为 [Program.cs](Program.cs),面向 Agent 的规范参数见 [UI 手册](../../skills/wincode/references/ui.md),整体数据流见 [架构说明](../../WinCode-架构与数据流说明.md)。 ## 职责和边界 -接收 stdin JSON,执行有界窗口发现或 UIA 取证,输出 stdout JSON 后退出。Gateway 的 FlaUIAdapter 管理自有 Host 的超时、取消和进程回收;被检查的应用不属于其进程所有权。 +接收 stdin JSON,执行有界窗口发现或 UIA 取证,输出 stdout JSON 后退出。Gateway 的 FlaUIAdapter 管理自有 Host 的超时、取消和进程回收;被检查的应用不属于其进程所有权。原生 Helper 在开始工作前验证所属 Gateway 的进程身份;所属进程退出时取消并按既有宽限清理自身。Gateway 启动保留配置/交付检查,实际 UIA 健康探测延后到显式诊断或首次操作。 Host 不点击、不输入、不写目标控件属性,不主动启动或终止目标应用。它会产生自身进程、审计日志及按策略显示的取证提示,因此不应描述为“零副作用”。审计和内容哈希是诊断证据,不是防篡改或来源签名。 @@ -41,6 +41,8 @@ Host 不点击、不输入、不写目标控件属性,不主动启动或终止 读取 `captureMethod` 和 `captureQuality`,区分截图 API 返回成功与画面内容可信;质量检测不是语义识别。树查询、截图、审计分别有自己的结果边界,不因获得一张 PNG 就宣称全部取证完成。 +可选 WinForms Tray 是另一个发布组件,不是本 UIA Host 的常驻模式;关闭托盘不应关闭 MCP 或被检查应用。跨实例 HWND 隔离、非默认 DPI 与历史偶发捕获失败的验证范围见工作记录,不能由核心测试通过推断全部支持。 + ## 构建与验证 从仓库根目录运行 `npm run check` 完成锁定还原、Release Host 构建与交付校验;需要交互桌面时另运行 `npm run check:desktop`。构建要求固定 SDK 10.0.303,framework-dependent Host 需要 .NET 10 Windows Desktop 运行时。完整命令与报告边界见 [贡献指南](../../CONTRIBUTING.md)。