From bf220a96944e3156c22b5199a12f310784d42ec3 Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:01:17 +0800 Subject: [PATCH] feat: checkpoint 0.14.0 tray controls and workflow stability Add native owner guards, deferred UIA probing, manual Roslyn release, an optional secured Windows tray, delivery source binding, and multi-agent diagnostics. Refresh the remaining roadmap and remove completed milestones. Validation: prior core 360/360, desktop 35/35 and targeted Roslyn/tray scenarios; current delivery verification, test inventory and diff checks pass. Full suites were not rerun for this quick checkpoint. Local generated receipts and build artifacts are excluded. Known gaps: shared-instance workspace switching can redirect later unscoped calls; same-root reopen resets a healthy Host; admission queues remain unbounded. N1-N5 are recommendations, not implemented fixes. One native tray acceptance failure remains unexplained. Latest real-client Roslyn integration, shared storage races and long-running resource behavior remain unverified. Remote CI status is not yet known. --- .github/workflows/ci.yml | 8 + CHANGELOG.md | 22 ++ CONTRIBUTING.md | 12 +- README.md | 38 ++- ...43\350\256\241\345\210\222\344\271\246.md" | 166 ++++++++--- ...43\350\267\257\347\272\277\345\233\276.md" | 42 ++- docs/codex_worklog.md | 117 ++++++++ package-lock.json | 4 +- package.json | 10 +- scripts/check.mjs | 13 +- scripts/delivery-manifest.mjs | 73 ++++- scripts/lib/owned-processes.mjs | 27 ++ scripts/measure-runtime-baseline.mjs | 116 ++++++++ scripts/owner-death/scenarios.mjs | 100 +++++++ scripts/publish-native.mjs | 21 ++ scripts/verify-manual-release.ts | 99 +++++++ scripts/verify-multi-agent.mjs | 269 ++++++++++++++++++ scripts/verify-owner-death.mjs | 98 +++++++ scripts/verify-tray-workflow.mjs | 156 ++++++++++ scripts/verify-tray.mjs | 123 ++++++++ skills/wincode/SKILL.md | 2 +- skills/wincode/references/diagnostics.md | 16 ++ src/Adapters/FlaUiAdapter.ts | 16 +- src/Adapters/RoslynAdapter.ts | 19 +- src/Adapters/RoslynHostClient.ts | 2 +- src/Core/Config.ts | 2 +- src/Core/ToolRouter.ts | 65 ++++- src/Gateway/TrayClient.ts | 121 ++++++++ src/index.ts | 11 + tests/delivery-contract.test.ts | 40 ++- tests/fixtures/owner-guard-check/Program.cs | 45 +++ .../owner-guard-check.csproj | 8 + .../owner-guard-check/packages.lock.json | 6 + .../fixtures/wpf-ui-review/MainWindow.xaml.cs | 20 ++ tests/manual-release.test.ts | 118 ++++++++ tests/owner-process-guard.test.ts | 133 +++++++++ tests/tray-client.test.ts | 116 ++++++++ tests/ui-hardening.test.ts | 48 ++++ tests/ui-inspect-mcp.test.ts | 3 +- tests/watch-invalidation.test.ts | 28 +- tools/Shared/OwnerProcessGuard.cs | 138 +++++++++ tools/WinCode.Code.Host/Program.cs | 13 +- .../WinCode.Code.Host.csproj | 3 +- tools/WinCode.Tray/PipeHub.cs | 231 +++++++++++++++ tools/WinCode.Tray/Program.cs | 66 +++++ tools/WinCode.Tray/SettingsWindow.cs | 156 ++++++++++ tools/WinCode.Tray/TrayAcceptance.cs | 146 ++++++++++ tools/WinCode.Tray/WinCode.Tray.csproj | 13 + tools/WinCode.Tray/packages.lock.json | 7 + tools/WinCode.UIA.Host/Program.cs | 7 +- .../WinCode.UIA.Host/WinCode.UIA.Host.csproj | 3 +- 51 files changed, 3019 insertions(+), 97 deletions(-) create mode 100644 scripts/measure-runtime-baseline.mjs create mode 100644 scripts/owner-death/scenarios.mjs create mode 100644 scripts/publish-native.mjs create mode 100644 scripts/verify-manual-release.ts create mode 100644 scripts/verify-multi-agent.mjs create mode 100644 scripts/verify-owner-death.mjs create mode 100644 scripts/verify-tray-workflow.mjs create mode 100644 scripts/verify-tray.mjs create mode 100644 src/Gateway/TrayClient.ts create mode 100644 tests/fixtures/owner-guard-check/Program.cs create mode 100644 tests/fixtures/owner-guard-check/owner-guard-check.csproj create mode 100644 tests/fixtures/owner-guard-check/packages.lock.json create mode 100644 tests/manual-release.test.ts create mode 100644 tests/owner-process-guard.test.ts create mode 100644 tests/tray-client.test.ts create mode 100644 tools/Shared/OwnerProcessGuard.cs create mode 100644 tools/WinCode.Tray/PipeHub.cs create mode 100644 tools/WinCode.Tray/Program.cs create mode 100644 tools/WinCode.Tray/SettingsWindow.cs create mode 100644 tools/WinCode.Tray/TrayAcceptance.cs create mode 100644 tools/WinCode.Tray/WinCode.Tray.csproj create mode 100644 tools/WinCode.Tray/packages.lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b5d9bb..fed1434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,12 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } npm run test:roslyn-gateway if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run test:owner-death + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/verify-owner-death.mjs --repomix + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run test:manual-release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve bounded check report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -63,5 +69,7 @@ jobs: test-tmp/error-contracts/**/report.json test-tmp/roslyn-host/**/report.json test-tmp/roslyn-gateway/**/report.json + test-tmp/owner-death/**/report.json + test-tmp/manual-release/**/report.json if-no-files-found: warn retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0fbf8..6b1c6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.14.0 (unreleased) + +- 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. + +- Keep automatic Roslyn release off; add a reversible manual release that preserves Gateway, watcher, bounded cache and diagnostics. Reject busy/cleanup/workspace-switch races, invalidate old symbol locations and reuse the existing recovery gate if cleanup fails. Explicit searches can reload afterward. +- Add an optional independent Windows WinForms tray/settings entry with passive instance state, manual release and selected-instance shutdown. Enable each Gateway with `--tray`; start Tray manually. No idle timer, automatic startup, global settings file or additional MCP tools. +- Bound the local Named Pipe protocol, authenticate local user/session and client PID, retain disconnected/unknown states, and reconnect without replaying control actions. Closing settings or Tray does not stop MCP. +- Include the optional Tray in locked builds and delivery fingerprints. Add concurrency/IPC regression, actual WinForms with two isolated MCP fixtures, and ten actual Roslyn release/reload cycles with owned-process and resource evidence. Acceptance details and platform limits are recorded in the work log. + +## 0.13.4 (unreleased) + +- Retain startup checks for UIA platform/configuration/published files, while deferring the native health probe until explicit diagnosis. Actual UI requests execute directly and update the known observation from their response. +- Preserve unknown availability before any runtime observation and retain first-operation errors separately. Coalesce concurrent non-forced health probes using the existing mutex and cached observation. +- Add a three-sample runtime/startup measurement script and first-use/concurrency/recovery regression coverage. Cache, fingerprints, watchers and Roslyn initialization policies remain as before. + +## 0.13.3 (unreleased) + +- Verify each native Helper's owning Gateway through its actual ancestor chain, creation times and a held Windows process handle before project evaluation or UI access. The private launch environment supports development wrappers without identifying client applications by name. +- Cancel work when the owner exits, then terminate only the orphaned Helper after a two-second grace period even if native calls or cancellation callbacks block. Code Host retains its existing Job coverage for descendants; this does not cover a live but unresponsive Gateway or the separate Repomix process path. +- Preserve UIA stdin EOF as the request boundary and retain the Code Host protocol. Add isolated owner-lifetime, handle-disposal and multi-instance tests plus real initial-MSBuild Gateway-death acceptance and CI receipts. + ## 0.13.2 - Exit the production stdio entry on EOF/closed pipes, unify shutdown events and terminate on process-level fatal errors. Connect transport before initialization so disconnect can cancel startup. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35ff41c..d4a2de0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,9 +15,13 @@ Reports and bounded stage logs are under `test-tmp/check//`. CI uploads onl `npm run test:roslyn-host` and `npm run test:roslyn-gateway` use generated C# projects and an already installed SDK selected by `scripts/lib/dotnet.mjs`. The gateway check copies the entire published Code Host into a Chinese path with spaces and checks real overloads, stale identities and owned MSBuild descendants. This is not a clean-machine test or verification of the current Codex connection. +`npm run test:owner-death` kills only a generated Gateway during confirmed initial MSBuild work and checks all previously observed process identities for survivors. `node scripts/verify-owner-death.mjs --repomix` audits the actual adapter with a controlled Node CLI, without installing Repomix. `check:desktop` also runs the `--desktop` owner-death scenario against the isolated WPF fixture: the UIA Helper must exit while the target remains alive; the fixture is closed separately after recording that result. Reports are kept under `test-tmp/owner-death/`. These checks never identify client applications by process name. + +`node scripts/measure-runtime-baseline.mjs` creates a small C# project, runs three fresh stdio clients, measures unused/cold/warm/exit phases, and profiles source Router startup separately. It records snapshots under `test-tmp/runtime-baseline/`, using the existing SDK. Run it without competing check jobs for a comparison; samples share OS/SDK caches and do not establish a p95, a clean-machine benchmark, or installed-client acceptance. Instrumented startup I/O counts cover the selected asynchronous Node filesystem methods and `child_process.spawn`, not all native/kernel I/O. + For opt-in fixed-profile TavernDesk acceptance, use `npm run test:tavern-context -- --ui-pid= --ui-hwnd=` and `npm run test:product -- `. The latter checks the fixed profile receipt and performs six navigation-to-source tasks without source filenames supplied in advance. Native candidate discovery is counted, source reads used only as the oracle are separate, and all returned bodies are checked against current file hashes. Source candidates remain distinct from verified runtime bindings. The scripts do not install prerequisites, launch the target application or use personal databases. -The delivery manifest covers Gateway JavaScript, all published Host files including dependency sidecars, four managed Skill documents, and package/SDK/Host lock configuration. It records the Git revision and toolchains. Timestamps and checkout paths do not participate in content identity. Hashes detect local mismatches; they are not signatures. Run a complete check after changing delivery inputs. Keep a complete previous checkout/artifact set for rollback; do not mix old DLLs with a new Gateway. +The delivery manifest covers Gateway JavaScript, all published Host files including dependency sidecars, four managed Skill documents, and package/SDK/Host lock configuration. It records the Git revision and toolchains. Timestamps and checkout paths do not participate in content identity. Native Release builds run through `scripts/publish-native.mjs host|codeHost|tray` after locked restore (automatically inside `check`). The build records repository source/shared files and build settings before compilation, verifies they stayed unchanged, and seals the complete publish output. Delivery verification rejects changed source or artifacts; regenerating a delivery manifest cannot bless old DLLs. Direct `dotnet publish` is suitable for isolated fixtures; use the canonical script for delivery. This is a repository input fingerprint, not an attestation of arbitrary external MSBuild imports or the SDK installation. Hashes detect local mismatches; they are not signatures. Run a complete check after changing delivery inputs. Keep a complete previous checkout/artifact set for rollback; do not mix old DLLs with a new Gateway. Production startup uses `npm start` and the published Release Host. `npm run dev` explicitly enables Debug/dotnet-run fallback. `customHostPath` remains an explicit configuration override; an old helper must not be mistaken for a verified current release. Rebuilds do not replace a running client's MCP connection. @@ -34,3 +38,9 @@ SDK policy follows [Microsoft global.json guidance](https://learn.microsoft.com/ 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. 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. + +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 e481f08..d1dbaf7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

English · 简体中文
- Windows x64 + Windows 11 x64 MCP stdio MIT license

@@ -22,7 +22,9 @@ WinCode is a local MCP server built for Windows and .NET engineering. It bridges - **Inspect the running app:** Enumerate visible windows, query specific controls or subtrees, and capture numbered visual overlays without activating or stealing focus from the target. - **Review with evidence:** Trace on-screen widgets back to literal XAML declaration tags, line numbers, and file hashes, with transparent reporting for ambiguity, truncation, or degraded upstreams. -Current source version: **0.13.2**. All UI tools are strictly read-only and non-destructive. See [CHANGELOG](CHANGELOG.md) for full version history. +Current source version: **0.14.0**. All UI tools are strictly read-only and non-destructive. See [CHANGELOG](CHANGELOG.md) for full version history. + +**Platform and compatibility:** Windows 11 x64 is the baseline for this project's local development and testing. Identical functionality, behavior, and performance are not guaranteed on other operating systems, other Windows versions, or different dependency versions. macOS and Linux users are encouraged to **fork this repository and adapt and validate it locally** for their platform. Use the dependency versions documented and pinned in this repository as the reference environment. ### Quick start @@ -84,6 +86,20 @@ Add each argument as a separate entry, without extra surrounding quotes even whe For prompt engineering and token-efficient skill routing, refer to the optional [Skill and MCP setup guide](WinCode-Skill制作与MCP配置指南.md). +### Optional tray and manual memory release + +Automatic Roslyn release is **off**. This version provides no idle timer or automatic-release switch. A loaded semantic workspace stays warm for successive Agent calls. To release it when you decide it is no longer needed: + +1. Build with `npm run check`, then run `tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe --show` from the repository. It requires the .NET 10 Windows Desktop runtime and does not install itself or enable Windows startup. +2. Add `--tray` as a separate argument to each Gateway you want to see, then refresh that MCP connection. For example: `"args": ["C:/path/to/WinCode/dist/index.js", "--workspace", "C:/path/to/project", "--tray"]`. Keep your existing explicit `--roslyn-config` arguments if using Roslyn. +3. Open **设置 / 内存管理**, refresh the observed state, select an idle instance and click **释放 Roslyn 内存**. A busy instance refuses the action; it does not queue a release for later. Requests arriving after a release has started wait for it to finish. + +“暂无在途请求” means no request is currently in flight, not that the Agent has finished its task. Failed refreshes or observations older than 30 seconds are shown as unknown; manual release first obtains a new passive status. Opening, refreshing, hiding, or reconnecting Tray never releases or reloads Roslyn. Registration errors are reported in settings and Gateway diagnostics. + +Release closes only that instance's owned Roslyn Host and invalidates its symbol locations. The next explicit symbol search reloads the project; old `symbolLocation` values require a new search. Gateway, workspace watcher, bounded cache, and last diagnostics remain. Local-text instances have no Roslyn memory to release. + +Tray and Gateway are independent. Hiding settings or exiting Tray leaves MCP running; **停止此实例** requests that selected Gateway's normal shutdown after confirmation. Start Tray manually when needed; it can connect before or after an opted-in Gateway. The current limit is eight connected Gateways per Windows user/session. Use the same Windows user and privilege level. State is observed on registration/open/refresh, not continuously polled; disconnected means unknown, and the connection count does not include old or unregistered instances. Remove `--tray` and reconnect to disable integration. Windows 11 is the tested platform; alternate permissions, Explorer recovery and other DPI configurations need separate validation. + ### Practical walkthrough: Targeted control inspection Query specific controls directly rather than dumping an entire window's visual tree (which can easily span thousands of nodes and exhaust context limits): @@ -234,7 +250,9 @@ WinCode 是面向 Windows 与 .NET 工程研发的本地 MCP 服务。它将项 - **观察实际界面:**发现系统可见窗口,按条件定向查询目标控件或子树,并在不激活、不抢占前台焦点的前提下获取数字标注截图。 - **源码双向印证:**将运行时抓取的控件关联回 XAML 源码声明的起始行号、代码片段与文件哈希,清晰报告歧义、截断与降级状态。 -当前源码版本为 **0.13.2**。所有 UI 取证工具均为纯只读与非侵入设计。版本历史见 [CHANGELOG](CHANGELOG.md)。 +当前源码版本为 **0.14.0**。所有 UI 取证工具均为纯只读与非侵入设计。版本历史见 [CHANGELOG](CHANGELOG.md)。 + +**平台与兼容性说明:**本项目以 **Windows 11 x64** 为本地开发与测试基准。其他操作系统、其他 Windows 版本或不同依赖版本下,功能表现、运行行为与性能不保证完全一致。建议 **macOS、Linux 用户通过 fork 本仓库进行本地适配与验证**;请以本项目文档和锁定文件中列出的依赖版本作为参考环境。 ### 快速上手 @@ -296,6 +314,20 @@ npm run delivery:verify 如需配合 Agent Skill 获得低 Token 开销的精准任务路由,请参阅可选的 [Skill 与 MCP 配置指南](WinCode-Skill制作与MCP配置指南.md)。 +### 可选托盘与手动释放内存 + +**自动释放保持关闭**,本版没有 idle 定时器或自动释放开关。Roslyn 加载后会保留,优先保障 Agent 连续工作;确实不再需要时,由你在设置里主动释放。 + +1. 完成 `npm run check` 后,运行仓库内 `tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe --show`。使用已有 .NET 10 Windows Desktop 运行时,不安装服务,不设置 Windows 自启动。 +2. 给需要管理的 MCP 启动参数单独加上 `--tray`,再刷新该 MCP 连接。例如 `"args": ["C:/path/to/WinCode/dist/index.js", "--workspace", "C:/path/to/project", "--tray"]`。已配置 Roslyn 时保留原有 `--roslyn-config` 参数。 +3. 打开“设置 / 内存管理”,刷新状态、选择空闲实例,点击“释放 Roslyn 内存”。实例忙碌或仍在收尾时拒绝本次释放,不排队延后释放;释放开始后到来的请求等待其完成。 + +只关闭所选实例拥有的 Roslyn Host 并失效旧符号定位;下一次显式搜索才重新加载,旧 `symbolLocation` 必须重新搜索。Gateway、工作区 watcher、现有受限缓存和最后诊断保留。local-text 实例没有 Roslyn 内存可释放。 + +“暂无在途请求”不代表 Agent 已结束任务。刷新失败或观察超过 30 秒时显示状态未知;手动释放前先获取新状态,超时不会接着释放。打开、刷新、隐藏设置及托盘重连均不触发 Roslyn 启停。注册失败原因会显示在设置和 Gateway 诊断输出中。 + +关闭设置窗口会收回托盘;“退出托盘”不影响 MCP。“停止此实例”经确认后请求该 Gateway 正常退出,客户端可能重新建立一个新实例。托盘和 Gateway 可按任意顺序手动启动;每个 Windows 用户/登录会话目前最多连接八个 Gateway,应使用同一用户和权限级别。状态仅在注册、打开或手动刷新时更新,不持续轮询;失联表示未知,连接数不含旧版或未注册实例。移除 `--tray` 并刷新 MCP 连接即可禁用集成。其他权限、Explorer 重启和不同 DPI 仍需单独验证。 + ### 实战示例:精准定位并分析目标控件 大型桌面应用的完整控件树动辄包含成百上千个视觉节点。若直接全量导出,不仅耗尽 Agent 上下文,还会增加定位干扰。WinCode 支持按条件精准定位目标控件子树: 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 46b1d4b..a4456a7 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,59 +1,147 @@ # WinCode 下一轮工程化迭代计划书 -更新日期:2026-09-09(北京时间)。本轮实现版本 **0.13.2**,从 main@a23740c 推进;代码、专项验证及 CI 配置已完成,远端检查和合并以对应 PR 回执为准。实际客户端 Roslyn 验收已按用户授权恢复;隔离项目和配置已就绪,等待客户端重连,不能宣称消费端已经验收。 +更新:2026-09-10 11:57(北京时间)。当前工作区:D:/CODEX PROJECT/WinCode MCP;分支 codex/m1-parent-ownership,HEAD 6e27e06,工作树为 0.14.0 未发布增量。 -已完成实现从待办移除:LocalText 非代码区屏蔽、有限 TSX/JSX 声明、旧缓存失效、E4 恢复与领域失败专项、未知工具协议错误、影响报告去重及仓内手册同步。过程和失败修复保留在[工作日志](docs/codex_worklog.md),对外变化见 [CHANGELOG](CHANGELOG.md)。 +本版依据当前代码、2026-09-10 多实例诊断和 GitHub 一手实现重新收敛。用户本次授权回顾、推荐和修改规划文档;下面的新行为是最终推荐方案,尚未实施,也不将文档写入视为新公共契约或真实客户端配置已获实施授权。已确认完成的旧 M0–M4 工作项已从待办中移除,历史证据保留在 [工作日志](docs/codex_worklog.md) 与 [CHANGELOG](CHANGELOG.md)。 -## 1. 工作方针与稳定边界 +## 1. 最终推荐 -先通过实际消费者验证 0.13 系列,再用真实任务决定下一项能力。沿用[当前分层](WinCode-架构与数据流说明.md)的 Registry、Router、适配器及独立 UIA/Code Host;不为潜在需求新增通用平台、共享服务或持久语义数据库。 +采用“独立 Gateway、连接固定项目、健康 Host 保持热态、有界排队”的路线。以 Agent 连续工作为第一优先级,通过减少无效重载、约束积压和手动释放平衡内存。 -已经确认的边界继续有效: +- 一个 MCP 连接对应一个 Gateway,该连接的业务范围固定为一个明确工作区。同一个连接内的多个 Agent 可以处理同一项目;并发处理不同项目或使用不同软件时,由各自客户端建立独立连接。实例数量按实际连接决定,不按工具调用或每个子 Agent 自动增殖。 +- 普通同路径 workspace_open 只确认/刷新概览,不停止健康 Roslyn。真正的已知恢复状态沿现有恢复路径处理;文件变化继续由现有语义输入检查和新搜索处理,不能拿“保持热态”掩盖陈旧证据。 +- 同一实例只保留当前一个 Roslyn Host,语义查询继续按现有互斥顺序执行。有限突发先等待,超过容量再返回明确的过载错误;取消、结束、清理失败均有确定归属,不自动重放或重启。 +- 自动释放继续关闭,只保留已实现的设置内手动释放。Gateway、watcher、当前缓存预算及托盘独立生命周期保持。 +- Windows 11 x64 是开发和测试基准;其他系统、Windows 或依赖版本不保证同样效果,macOS/Linux 用户 fork 自行适配。跨平台移植不进入本轮。 -- 默认 local-text;C# 语义通过显式配置的直接 Roslyn,不恢复 Serena 安装链。 -- Node 24 主支持、22 兼容;保持锁定依赖和 SDK,新增依赖与环境变化另行确认。 -- 未知请求字段容忍并忽略,规范字段及类型继续校验;以[仓内 Skill](skills/wincode/SKILL.md)说明为准。 -- hello 只读取已知观察,不主动求值或探测。工作区恢复失败时阻断业务请求;trash 部分完成保留实际位置,不自动移回。 -- 未知工具在正常受理状态返回 JSON-RPC -32602;已知工具失败保留 isError,JSON 文本与 structuredContent 同源。影响分析只有一个 JSON 文本块,保留 formattedReport 和全部证据字段。 -- LocalText 仍是有限文本分析:插值/JSX 内表达式省略,复杂语法可能漏检;无法确定词法边界的文件标记 lexical-uncertainty 且不缓存。文本引用不等于语义引用,完整扫描不等于全语言语义完整。 +| 路线 | 收益 | 代价与边界 | 本版选择 | +| --- | --- | --- | --- | +| 每连接固定一个项目,独立 Gateway | 消除实例内部可变当前根造成的任务串线;保留热 Host;复用现有进程/快照所有权 | 多连接会重复占用部分内存;需要明确每个连接的启动项目 | **推荐**,先解决已复现问题 | +| 一个 Gateway 动态管理多项目,所有请求携带工作区身份 | 可保留单连接跨项目能力 | 全部项目相关工具、嵌套调用、缓存、恢复与排队都必须携带身份;单个 Host 来回切换仍冷启动,多 Host 又增加管理复杂度 | 本轮不采用;若真实客户端无法建立项目连接,再重新评估 | +| 全局共享 Roslyn 服务/Host 池、自动内存回收 | 理论上可减少同项目重复加载 | 需跨客户端公平调度、配置隔离、故障域与缓存一致性;自动回收可能造成反复唤醒 | 当前证据不足,不进入开发队列 | -## 2. 进行中:实际客户端 Roslyn 验收 +固定项目不等于解决所有协作冲突:两个实例仍可能读写同一源码、缓存或目标窗口。N4 专门验证这些边界,不能仅凭 PID 不同声称全部隔离。 -用户在代码与 CI 完成后已授权恢复验收,随后完成 Skill 核对同步。当前已生成隔离 C# 项目、准备 Debug/net10.0 和已安装 SDK 10.0.303,更新现有 wincode 启动参数并备份原服务器配置。Codex CLI 已验证新配置可读取;当前会话仍为 0.12.5,等待刷新对应 MCP 连接。新 stdio、InMemory、磁盘清单及真实 Host/Gateway 测试均不能代替当前 Codex 连接验收。 +## 2. 已完成基线与真正剩余问题 -本次固定入口为生成夹具的 App/App.csproj,求值范围仅为其生成的 App/Lib 项目。包源已清空,fixture restore/build 通过,无新依赖下载。配置及回执位于 test-tmp/client-roslyn-20260909;验收后恢复原 wincode 启动参数。若改用 TavernDesk,另行确认真实项目配置和求值授权。 +已从开发待办移除:本机交付重建、原生 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 回执证明其后所有原生修改都重跑过核心测试,也不代表正在运行的旧消费者已更新。 -1. 建立新连接,hello 核对版本、构建、实例、Schema 和 provider;未探测状态保持 unknown。 -2. 搜索重载/同名声明,明确选择返回的 symbolLocation。 -3. 同一连接执行引用、影响分析与重构建议,验证始终使用同一声明;歧义、零引用或部分结果不能解释成可安全删除。 -4. 仅修改隔离夹具,用旧 location 验证明确的 stale/输入变化错误;按 recoveryAction 处理并重新搜索,新位置应恢复成功。 -5. 记录实际连接身份、位置、错误恢复及结果。手册同步与磁盘文件一致不能替代重连;不得用终止 Codex 或修改真实项目制造验收条件。 - -**当前待办:** 对应 MCP 连接刷新后,核对新实例及当前交付版本、codeProvider=roslyn、实际隔离工作区和 symbolLocation Schema,再执行上述闭环。配置与求值范围已获授权,不重复申请;未重连前不向旧工具传递新定位字段。 +| 现象 | 当前证据等级 | 下一步 | +| --- | --- | --- | +| 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 先用实际缓存/打包路径并发验证 | +| 一个原生托盘专项没有生成 UI 回执 | **既有未定位失败**;旧脚本未保留退出码,超时只是推测,随后三次未复现 | N5 利用已补诊断复查,不能写成已修复 | +| 实际 Codex 的最新构建/Roslyn 消费闭环、其他软件接入、Node 22/远端 CI、长期大项目资源趋势 | **仍未完成或范围不足** | N5 保留并逐项完成,不从旧计划中误删 | -## 3. 已授权的生命周期迭代 +诊断依据:[三实例八场景](test-tmp/multi-agent/run-9uve7M/report.json)、[第二轮堆栈和保留的测试假设失败](test-tmp/multi-agent/run-ffxcyO/report.json)、[修正后帧与托盘容量边界](test-tmp/multi-agent/run-PxHPQo/report.json)、[十轮混合负载](test-tmp/mixed-load/run-HT12AB/report.json)。success 表示诊断场景完成,不能解释为未发现问题。SDK 超大输入的正确行为是报错并关闭通道,第二轮最初期待继续读取是测试假设错误。 -- 0.13.2:已实现 EOF/传输断开、初始化取消、统一关闭预算和故障后的独立资源回收;完整检查及 PR 合并状态以工作日志为准。 -- 下一阶段:验证父进程异常终止、活动 Host/UI 辅助进程回收,明确 Windows Job 的实际保护范围;不把观察到的 PID 退出扩大为所有外部进程保证。 -- 随后:Roslyn 空闲 120 秒实验值、工作区初始化延迟及监听/缓存休眠;唤醒必须核对新鲜度,旧 symbolLocation 明确失效。 -- 保留现有 MCP 接口进程;客户端按需卸载、事件循环完全卡死时的外部兜底须依据实际客户端能力判断。 +## 3. GitHub 案例如何用于本项目 -## 4. 条件性下一轮工作 +以下资料于 2026-09-10 读取;链接 main/master 会随上游变化。以实际代码与测试为依据,不用星数或 issue 提议代替已合入实现。只借鉴职责和验证方式,不安装新依赖或复制大型架构。 -| 方向 | 进入条件与最小实验 | 验收边界 | +| 一手案例 | 已核实做法 | 对 WinCode 的具体启示与限制 | | --- | --- | --- | -| UI → 源码候选 → Roslyn | 消费端验收后,以一个固定 WPF 场景比较手动续查和候选续查的调用数、证据及定位正确性 | 保留 XAML/C# 哈希、位置、歧义和快照;静态声明不证明运行时 Binding/CanExecute | -| 等待与背压 | 实际出现等待堆积,或需要声明并发容量时,用 16–32 个受控请求测取消、等待上界和恢复 | Host 队列有界不等于整个入口有界;复用现有准入层,不凭推测重构 | -| 增量扫描与 Repo Map | 固定任务证明冷/热查询、变化恢复或未知文件定位是主要成本后,再比较读取量、正确率、时延和资源 | 不降低输入新鲜度、不忽略 Compile 排除、不预建大型索引或向量库 | -| 更完整的文本解析 | 真实源码持续暴露现有词法/声明限制,并影响任务完成时,收集最小反例后评估成熟 parser | 新依赖、支持语言和资源成本须先确认;不把局部修补宣传成完整语法分析 | +| [Microsoft Playwright MCP README](https://github.com/microsoft/playwright-mcp/blob/main/README.md#user-profile) | 项目根参与 profile 隔离;同项目并发客户端仍可能冲突,文档要求隔离模式或独立数据目录 | 进程独立和可变存储隔离必须分别考虑,支持 N1 与 N4。浏览器 profile 的锁不等于 WinCode 缓存已经存在同样缺陷 | +| [rust-analyzer reload.rs](https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/reload.rs) 的 switch_workspaces | 比较工作区和构建数据;工作区未变且无新构建数据/强制重载时可直接返回,同时保留需要更新或强制重载的分支 | N2 区分重复确认与真实恢复,不因重复打开就清空健康状态。它并非 MCP/Roslyn 实现,不移植 Cargo、Salsa 或多项目架构 | +| [.NET ConcurrencyLimiter](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/ConcurrencyLimiter.cs) | 执行许可和队列容量分开;FIFO 模式满队列拒绝新请求;取消与许可归还避免重复计数 | N3 的有界受理、公平等待、取消收尾。使用现有 TypeScript 工具链做局部实现,不把 .NET 限流层接到 Node 前面 | +| [ConcurrencyLimiterTests](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Threading.RateLimiting/tests/ConcurrencyLimiterTests.cs) | 覆盖排队前后取消、取消释放队列容量、取消与归还竞态 | 测试不能只断言报错;还要断言没有启动副作用、容量归还一次、后来请求可继续 | +| [MCP SDK #842](https://github.com/modelcontextprotocol/typescript-sdk/issues/842) 与 [客户端 stdio.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/stdio.ts) | issue 记录批量通知导致 drain 警告;本机客户端 send 在写入积压时等待 drain | 只作为相似背压机制参考;本机责任端由实际堆栈确认。N3 不能声称服务器有界队列必然消除客户端已经发生的发送积压 | + +以上事实支撑的是设计原则;下面的固定项目契约、32 请求起始容量等均为针对 WinCode 的推荐,不是上游给出的通用最优参数。 + +## 4. 只包含未完成工作的实施顺序 + +推荐顺序:N1 → N2 → N3 → N4 → N5。N1/N2 可在同一开发批次交付,但分别保留验收。具体版本号在实施时确定,不按旧 M0–M5 的建议版本重发已有功能。 + +### N1:连接固定项目,阻止任务串线 + +目标:从根本上去除同一连接“最后一次 workspace_open 决定全部后续调用”的隐式切换。 + +1. 新推荐契约为 Gateway 在启动时绑定当前配置工作区;客户端配置必须优先提供明确的绝对 --workspace。现有 cwd 回退若保留,hello 必须标明根及来源,迁移检查不得把未知 cwd 当用户选择。第一版不增加自动发现项目或后台按项目新建进程的管理器。 +2. workspace_open 指向同一已绑定根时走 N2;指向另一根时返回拟定 WORKSPACE_MISMATCH,附 activeWorkspace/requestedWorkspace 和选择正确项目连接的恢复建议。拒绝发生在修改 config、watcher、缓存命名空间、trash、求值或重置 Host 之前;不能仅在结果返回时补校验。 +3. 绑定根由 Router/WorkspaceManager 共同遵守,不能只保护 MCP 外层而允许内部调用换根;检查别名、组合工具和所有项目相关调用。用户目标是不同目录下的整个项目,同一工作区内部已有的项目引用规则保持。 +4. 路径身份复用当前安全规则,覆盖 Windows 大小写/分隔符、中文空格、父目录穿越和目录联接。不能为了把路径“认成同一个”而绕过现有链接拒绝或扩大项目求值边界。 +5. 不同时引入“默认固定根”和“后台仍可随意切换”的双重语义。现有 A→B→A 正常切换是公共行为,改变它属于明确迁移:更新 Schema 描述、错误契约、Skill 源文件、示例及相应测试;旧诊断报告继续保留。 + +关键文件:[入口](src/index.ts)、[配置](src/Core/Config.ts)、[ToolRouter](src/Core/ToolRouter.ts)、[Workspace](src/Core/Workspace.ts)、[WorkspaceTools](src/Gateway/WorkspaceTools.ts)、[ToolDefinition](src/Gateway/ToolDefinition.ts)。 + +验收:受控交错的 B 打开请求被拒绝后,A 的根、快照、Host PID、watcher 和缓存命名空间均不变;A 后续查询仍为 A;另一个 B 连接继续得到 B。同项目两个独立实例的精确定位继续互相拒绝。普通目录/文本/组合工具也必须覆盖,不能只测 Roslyn。 + +限制:固定根不能替 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 是待实测起点,不是现有限制,也不是用户机器适用的固定最优值。 + +- 在进入昂贵准备/扫描/适配器工作之前统一做有界受理。外层准入与内层 Roslyn mutex 复用一份请求归属,组合工具内部调用不重复占用外层容量;取消后的逻辑容量和实际等待节点都需要释放,避免“计数变小但 Promise 链仍无限增长”。 +- 正常 FIFO 排队,保留先来请求,不用新请求挤掉已受理工作。满额时返回拟定 SERVER_BUSY 和有限队列状态,只有确认尚未执行才标记可稍后重试;不伪造准确 retryAfter,不在服务端自动循环重试。 +- 等待受取消和总时间预算约束,排队时间纳入预算;进入 Adapter 后只能使用剩余预算,不能逐层重新起算。没有收到客户端截止时间时使用当前对应操作的有界预算,不假设所有软件都使用测试 SDK 的 60 秒配置。 +- 排队取消应立即移除等待项且不启动 Host;执行中取消继续等待当前操作及资源收尾,之后才归还执行权,不能提前让新请求进入仍在使用的 Host。 +- 对请求参数增加统一序列化预算,建议先用 **64 KiB/tool arguments** 并对已有合法调用做兼容性检查;补齐缺少长度限制的字段。该值限制被接受并保留的业务负载,不能消除 SDK 已解析至 10 MiB 帧时的瞬时分配,也不是进程 RSS 上限。 +- tools/list、被动状态、取消和关闭不能排在慢查询后。MCP 状态请求可预留最多 **4 个**有界轻量槽,不能无限放行;托盘继续只读内存状态。workspace_open/恢复进入同一受理约束,但不能先把自身计入 inFlight 再等待自己清空而形成死锁。 +- 增加已受理/执行/等待/拒绝/取消数量及队列等待耗时。手动释放在任何排队、执行或取消收尾期间均拒绝,不排队等空闲、不自动释放。hello 的完整磁盘统计不能变成饱和时的高频旁路扫描。 + +关键文件:[McpServer](src/Gateway/McpServer.ts)、[ToolRouter](src/Core/ToolRouter.ts)、[ResourceManager](src/Core/ResourceManager.ts)、[ToolDefinition](src/Gateway/ToolDefinition.ts)、[TrayClient](src/Gateway/TrayClient.ts)及已有并发测试。优先局部扩展现有互斥/准入,不新增队列服务或依赖。 + +验收:4/8/16 个正常突发不被无故拒绝;32 附近容量边界及 64/128 个突发能够解释每个成功、排队、拒绝或取消;慢首次加载、队首取消、连续取消补入、断连、工作区确认、清理失败交错无死锁/重复执行/重复归还。分别记录等待和执行耗时、Node/原生内存、Host 重启次数及结束后等待节点;没有等待任务时恢复基线。客户端发送端的 drain 警告单独记录,不能以抬高 setMaxListeners 或客户端限流隐藏服务端过载问题。 + +### N4:补同项目共享存储与编辑竞争证据 + +N1 固定项目只解决实例内根变化;两个独立连接仍可能指向同一物理项目和默认 cacheDir。目前缓存有原子临时文件写入和每实例写队列,但没有因此证明跨进程清理安全。 + +1. 复用隔离生成项目,两个真实 Gateway 同时走当前缓存/内置打包路径:同键和不同键写入、读取、prune、overflow 大内容引用,以及其中一个退出。只调用已安装工具链,不为此下载安装真实 Repomix。 +2. 记录是否有损坏 JSON、跨项目正文、另一实例仍使用的 overflow 被清除、退出后队列不排空。普通缓存未命中可以重算;错误正文或看似命中却指向错误/缺失实体必须阻断验收。 +3. 最终倾向明确可变临时文件的拥有者:若复现共享可变产物竞争,优先仅隔离正在写入的临时/overflow 产物并维护引用归属;不要先把整个持久缓存改成每次启动一个 UUID 目录,避免磁盘复制与失去复用。若现有做法已安全,保留并补回归;需要跨进程锁、新清理政策或数据迁移时再明确具体变更。 +4. 同项目两个实例分别查询时修改生成夹具:旧定位明确失效,新搜索获得对应输入的结果,不把同时编辑或跨文件非原子变更说成完全一致快照。 +5. UIA 是独立边界:有固定 WPF 夹具后验证两个实例明确选择不同窗口是否混淆;同窗口的写操作尚未验证前不承诺多 Agent 同时操作安全,不因代码工作区不同推断桌面也隔离。 + +所有源文件修改只发生在 test-tmp 夹具;不安排共享缓存一键删除、审计轮转或目标应用自动停止。此阶段是有结果的验证任务,不预判一定需要新存储架构。 + +### N5:真实消费者、残余失败与交付验收 + +- 在配置变更前检查实际客户端能否为不同项目提供独立 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 案例再做最小修复,不以增加超时或忽略错误宣称解决。没有复现时写“未再现”,不能写“根因已修复”。 +- 正常负载先做短时小项目验证,确认成本后再用一个经授权的代表项目观察连续工作与空档;分开记录 Node、Code Host/已观测后代的内存、CPU、句柄、队列和启动次数。长期样本/大项目未跑就明确留空,不用短时结果证明无泄漏。 +- 依变更执行针对性测试、npm run check、真实 Roslyn/E4;涉及托盘/原生行为再跑对应桌面验收。测试 inventory、错误形状、Schema/Skill 与交付清单必须一致。Node 22、具体提交的远端 CI 和实际发布状态单独核实。 +- 托盘八注册容量及恢复已验证,移出开发待办;跨权限/Windows 会话拒绝矩阵、DPI/Explorer 重建仍属平台待验范围,不把它们重新包装为功能开发。九个真实 Host 的压力测试只有出现实际需求才扩大。 + +## 5. 验收与反证规则 + +| 反例 | 必须证明的结果 | +| --- | --- | +| 测试都正确,但两个工具调用之间有人换根 | N1 在变更之前拒绝,不出现成功返回另一项目正文 | +| 为了保温跳过恢复,SDK/输入已变化 | N2 不绕过已有 restart_gateway/HOST_RESTART_REQUIRED/输入新鲜度检查 | +| 取消返回很快,但 Host 还在用;新请求进入 | N3 待实际操作收尾才归还执行权,不仅看客户端 Promise | +| 计数有上限,但取消的等待节点不断积累 | N3 连续取消/补入后节点、监听器和容量都回到基线 | +| 单帧未超限,但大量小请求占满内存 | N3 参数预算、总受理数量和资源观测分别验证 | +| PID 隔离了,但共享缓存/窗口仍相互影响 | N4 使用对应存储和 UI 路径验证,不能用 Roslyn 搜索代替 | +| 本机 dist 最新,客户端仍持有旧进程 | N5 对真实连接核验,不只看源码版本 | + +生产修改后的失败不得通过削弱断言或自动重放掩盖;非简单语法/对象错误首次失败就查官方文档和真实 GitHub 代码/案例。发现清理越界、错误项目结果、恢复门失效或无限等待时停止该增量交付。以上是作者自审,不能替代独立审核。 + +## 6. 推荐方案的确认边界与延后范围 + +USER_DECISION_REQUIRED:N1 将“可切换当前根”改为“连接固定项目”,N2 调整同路径重开语义,N3 引入可观察的过载/参数预算。这三项是本版明确推荐的同一批次契约变更;后续实施前确认接受其兼容性影响。本次只编写规划,不修改这些生产行为。N3 的起始参数在确认的方向内按测试调整,若需要显著降低并发能力或改变使用方式,应重新对齐。 -正式对照保持相同初始信息与任务,字符数不当作实际 token,不将不同模型/提示词的耗时归因于工具。 +用户此前已经确定的自动释放关闭、手动释放、Windows 11 基准和优先连续工作继续有效,不重复审批。实际客户端配置、下载、安装、发布或推送仍按当时有效授权执行;不把“规划已写好”当外部操作批准。 -## 5. 持续关口 +延后:自动 idle 释放及其 2/5/10 分钟开关、pause/resume、全局停止/未来实例策略、自启动、审计自动轮转、跨实例一键清缓存、共享 Host/数据库、通用 Lease/FSM、跨平台移植和大型 UI 功能扩展。UI → XAML/C# → Roslyn 的产品深化,等上述并发与实际消费闭环稳定后再按真实任务价值安排。 -- 每个版本先针对性验证和 debug,再执行完整 check、必要的桌面/真实 Roslyn 验收,经过 PR 检查后合并。保留失败证据,不用旧成功覆盖新失败。 -- Node 22 CI 执行 E4 专项与真实 Roslyn,Node 22/24 执行核心与交付检查;对应提交的有效 CodeQL 语言均须通过。当前 default-setup 已移除无源码 Python,核对新运行而非为历史红叉添加假源码。 -- 当前历史中文文件指纹测试曾偶发失败,后续复现时保留现场,区分测试竞争与真实失效漏洞;不靠删测试或延长 sleep 掩盖问题。 -- 版本、Host、仓内 Skill 与完整磁盘交付必须一致。短时有界负载不等于长期耐久性,异地发布目录不等于干净机器安装,客户端闭环完成前保留“消费者尚未验收”的限制。 +维护规则:本文件只保留未完成工作和最小基线。每次确认实现并取得对应证据后,从本计划和简版路线图中移除该待办,详细结果追加至既有工作日志;未定位失败和未验证范围不能随已完成功能一起删除。 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 9a481f9..24c83d7 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,17 +1,41 @@ # WinCode 迭代路线图 -更新:2026-09-09(北京时间)。实现版本 **0.13.1**;远端 CI/合并状态查看对应 PR,实际客户端 Roslyn 验收已恢复,隔离项目与配置准备完成,等待客户端重连。 +更新:2026-09-10(北京时间)。当前为 codex/m1-parent-ownership 上的 0.14.0 未发布增量,HEAD 6e27e06;本文件只列未完成工作。完整推荐、兼容性取舍、GitHub 依据和验收标准见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md),历史实施及失败见 [工作日志](docs/codex_worklog.md)。 -详细方针和验收条件以[下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md)为准;完成内容记在 [CHANGELOG](CHANGELOG.md)和[工作日志](docs/codex_worklog.md),不再作为待办重复实施。 +## 最终推荐 -## Next:恢复消费端验收 +独立 Gateway、连接固定项目、健康 Host 保持热态、有界排队。多个 Agent 可通过同一连接处理同一项目;并发独立项目/不同软件使用各自连接。减少无效重载,以已实现的设置内手动释放平衡内存,自动释放继续关闭。 -隔离 C# 项目及 MSBuild 求值范围已获准,配置固定 Debug/net10.0,项目构建通过;本地 Skill 已同步,wincode 启动参数已更新并备份。正常重启客户端后完成版本核对、精确符号、引用、影响和旧快照拒绝闭环,结束时恢复原启动参数。当前连接仍为旧版,尚未完成消费端验收。 +固定项目、同路径重开以及过载规则是推荐的新公共行为,尚未实施。相比目前可随意切换当前根的方式,它需要明确的客户端项目配置;若目标软件只支持一个全局连接,先验证兼容性再决定是否改用每请求工作区身份,不能悄悄保留不安全切换。 -## Later:由真实任务决定 +## 已移出待办的基线 -- UI → 源码候选 → Roslyn:一个固定 WPF 场景验证定位正确性及续查成本。 -- 等待/性能:出现具体问题后测量冷、热查询、变化恢复和准入等待,再决定局部优化。 -- 更完整文本解析/Repo Map:收集现有能力不足的反例及成本,明确范围后设计;新增依赖单独确认。 +本机交付重建、原生 owner guard、UIA 启动探测延后、可逆手动释放、最小独立托盘与安全管道、状态可信度、原生交付源码绑定、真实托盘/Roslyn 贯通及托盘八注册容量/空位恢复已实现或取得对应验证,详细证据保留在工作日志。本轮不重复建设旧 M0–M4,也不再把自动释放分钟数开关放进设置计划。 -持续执行已确认的工程约束:未知字段容忍、hello 被动、显式 Roslyn、诚实的不完整结果、锁定交付、验证后 PR/合并。架构继续沿用[现有分层](WinCode-架构与数据流说明.md),不预建共享 Host、向量库或额外平台。 +实际消费者最新构建/Roslyn 接入、既有未定位原生验收失败、共享存储竞争、长期资源趋势等仍保留为待验,不能随已完成功能删除。 + +## 接下来的开发与验证 + +| 顺序 | 尚未完成的目标 | 完成判据 | +| --- | --- | --- | +| 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 若没有复现存储缺陷,保留现有缓存并补回归,不预先引入跨进程锁或按每次启动复制全部持久缓存。 + +## 必须保留的边界 + +- Windows 11 x64 是开发/测试基准;其他系统、Windows 或依赖版本不保证同样效果,macOS/Linux 用户 fork 适配。 +- 默认 local-text,Roslyn 继续显式配置与项目求值许可;hello/托盘观察不触发加载。 +- Gateway 仅清理自建资源;可选托盘退出不影响 MCP,手动释放在排队/执行/取消收尾时拒绝。 +- 10 MiB 传输帧上限、单实例缓存预算、队列容量、Node RSS 和原生内存分别评估;短时通过不等于长期无泄漏。 +- 实现公共契约变化前确认推荐方向;实际客户端配置、依赖/环境、发布和推送另按有效授权执行。 + +## 延后 + +自动 idle 释放、pause/resume、自启动、全局停止策略、审计自动删除、跨实例一键清缓存、共享 Roslyn 服务/数据库、通用 Lease/FSM、跨平台移植和大范围 UI 扩展。待并发与实际消费闭环稳定,再决定 UI → XAML/C# → Roslyn 的产品深化。 + +维护方式:有代码与对应验证证据后删除已完成待办,结果追加至既有工作日志;本路线图和详细计划同步,不保留互相矛盾的旧 Next 表。 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index ad01700..995a081 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -804,3 +804,120 @@ - 纠正前记录:无需一概重启整个 Codex,刷新对应 MCP 连接即可;当前工具未提供已验证可调用的重连入口,未强杀用户宿主。异常进程终止和空闲回收未在本阶段宣称完成。 - 完整检查第一次因已有 40ms TTL 测试在并行负载下先过期而失败(保留 2026-09-09T14-29-49-098Z-core 报告);改用受控 Date.now,保持过期前/后断言,不延长 TTL。重跑核心 322 项:321 通过、1 可选场景跳过;桌面 35/35。正式入口及生命周期最新针对性 27/27,通过交付清单校验。 + +## 2026-09-10 08:19 — 本地同步 GitHub 昨晚最新版(北京时间) + +- 用户要求对齐本地落后版本。fetch origin 后确认远端 main 为 6e27e06(2026-09-09 22:39,0.13.2)。同步前工作区干净,本地 a2d76f6 与远端 #30 的 2235a42 为补丁等价提交,实际缺少 #31、#32、#33 三次更新。 +- 先创建 codex/backup-before-sync-20260910 保留旧 main,再通过 git reset --keep origin/main 对齐;HEAD 与 origin/main 完全一致。无依赖安装、全局配置修改或远端推送。 +- npm run typecheck、npm run build 通过;生产入口 EOF 针对性测试 2/2 通过。dist 从旧 0.13.0 刷新为 0.13.2。 +- 反证核对:源码同步不能证明旧构建或已运行 MCP 已更新,因此补做 Gateway 构建;本轮未重建 .NET Host、未验证完整交付清单、未执行完整回归或刷新现有客户端连接。上述边界不作为已完成的运行时部署报告。 +- 同步后仅本条工作记录为本地未提交变更,源代码保持与 GitHub 一致。 + +## 2026-09-10 08:29 — 结合网页版讨论更新下一轮迭代计划(北京时间) + +- 用户要求读取昨晚本地迭代文件并结合“架构分析优化建议”形成更具体计划。通过 read_thread 取得 9 轮对话(无更多分页),区分早期建议、GPT 后续撤回与用户最终的客户端无关/托盘偏好;只更新既有计划书、路线图和本日志,没有实施生产代码或客户端配置变更。 +- 基于 main@6e27e06/0.13.2 核对 Router、RoslynAdapter/Host、UIA、Cache/UiAudit、Gateway、CI 和交付入口。确认可逆释放尚缺、Code Host 初始加载先于 EOF 循环、UIA EOF 是输入边界;已有 Job 不能直接当作 Gateway 死亡即全树退出的证明。 +- 纠正跨机器就绪状态:旧记录涉及 C:/Users/40218 与 I:/WinCode,本项目没有 test-tmp/client-roslyn-20260909 的 preparation.json/client-config-change.json;不沿用“本机只等重连”。状态统计会枚举磁盘缓存,现行审计为 1 MiB 提醒/2 MiB 阻断且不自动删除,分别纳入托盘开销与存储政策边界。 +- 新计划按 M0 本机基线、M1 异常所有权、M2 实测后局部延迟、M3 Roslyn 可逆释放、M4 托盘 MVP、M5 策略/暂停/存储细化模块、验收、停止与回退条件。版本号为建议,不当作已发布。GPT 已撤回的通用 Lease/FSM、全面工作区休眠不继续安排。 +- USER_DECISION_REQUIRED:独立 WinForms/Named Pipe 与启动方式、idle 默认策略、暂停范围、日志保留,以及本机配置/安装/外部操作仍在相应阶段确认;本次未修改真实设置、安装依赖、运行进程强杀或推送。 +- 官方只读核查:Microsoft Job Objects、WaitForSingleObject、NotifyIcon、PipeOptions 与 Node net/timers,链接置于计划相关段落;没有沿用网页聊天的隐藏引用标记。 +- 反证自审纳入:管道断开不等于进程退出、仅 inFlight 不覆盖切换等待、后台 idle 错误不能绕过 E1、托盘状态不能持续扫盘、配置保存不等于所有实例生效、缓存目录可能被多实例共用。属于作者自审,不是独立模型或运行验收。 + +- 2026-09-10 08:31 用户补充明确平台范围:README 中英文新增 Windows 11 x64 本地开发/测试基准,说明其他操作系统、Windows 版本与依赖版本不保证一致效果,建议 macOS/Linux 用户 fork 后本地适配;同步平台徽标、计划书和路线图,不安排本轮跨系统移植。 +- 文档验证:四个变更文件 UTF-8 可读,README/计划书/路线图代码围栏闭合,51 个本地 Markdown 链接目标存在,中英文平台/依赖/fork 说明完整,git diff --check 通过。没有运行代码测试、跨平台验证或未来功能实验;保留前一轮同步日志,最终变更仅 README、既有计划书、路线图及本日志。 + +## 2026-09-10 09:17 — 按计划实施 M0/M1,自有原生 Helper 的所属进程退出保护(北京时间) + +- 用户授权“开始按计划进行迭代和测试”。保留之前四份文档修改,在 codex/m1-parent-ownership 本地分支实施;使用已有 Node 24.19.0、项目内 .NET SDK 10.0.303 和锁定依赖,没有安装/升级或修改客户端配置。 +- M0 原始 0.13.2 完整 check 322/322、生产 stdio 和完整 delivery 校验通过,回执 [core baseline](../test-tmp/check/2026-09-10T00-42-56-585Z-core/report.json)。本机 App/Lib 和后续回执由 scripts/verify-roslyn-gateway.mjs 重建,不使用旧机器路径。 +- M0 当前真实 Codex 连接两次 hello 均仍为 0.13.0/local-text,instance fcff4dad-c746-48ed-ac78-c995ea47d54e,build f9860e16528a104cb939d8a53c4e64d9411561287197e531a89860c78bd1b23f。隔离 stdio 的新交付不等于该实例更新;实际消费者语义闭环、客户端重连及已安装 Skill 同步仍未完成。 +- M1 新增 tools/Shared/OwnerProcessGuard.cs,由两个 Host 链接共享源文件。Gateway 只向自有子进程传 WINCODE_OWNER_PID;Host 在项目求值/UI 访问前校验最多八层真实祖先、创建时间和存活状态,再持有 owner 进程对象句柄。没有客户端进程名称判断、周期全机监控、新依赖或新增 MCP 工具。 +- owner 死亡先 CancelAsync 广播;独立线程宽限两秒后仅 TerminateProcess 当前 Helper,避免原生调用/取消回调卡住兜底。Code Host 继续由既有 Job 覆盖其后代;UIA EOF 保留输入结束语义。Code Host 初始加载与请求取消接入 owner token,内部协议仍 v2。 +- 新增 owner-guard-check 原生夹具、13 项隔离测试,涵盖原生阻塞、取消回调阻塞、正常协作退出、父进程在 Attach 前退出、启动包装链、非法/无关 owner、自指 owner、重复释放句柄、两个实例隔离及生产 UIA EOF。夹具 Helper 使用 detached 以防 Windows 控制台连带退出掩盖测试;生产启动方式未改。 +- 真实 Code Host/MSBuild 初始加载和 UIA 读取分别通过目标写入握手进入故障阶段,再仅强杀测试 Gateway。按预先记录的每个 PID/创建时间查残留,不能依赖已断开的祖先链。失败清理持有实际 Process.SafeHandle 并核对创建时间;不按名称终止。0.13.4 最终 [MSBuild owner receipt](../test-tmp/owner-death/run-hnEJ0Y/report.json) 无观测残留;[UIA owner receipt](../test-tmp/owner-death/run-6OH4q7/report.json) 无 Helper 残留、目标进程仍在,随后才单独关闭测试目标。 +- Repomix 使用真实 RepomixAdapter + 生成 Node CLI 审查,在本机未观测残留,见 [controlled Repomix receipt](../test-tmp/owner-death/run-liokKo/report.json)。没有安装/运行真实 Repomix;这个结果不等于它继承了 .NET guard 或其全部第三方后代受到保障。CLI 路径未改。 +- 失败过程:首次强杀后 transport.pid 已清空导致脚本断言失败,已改为强杀前保存 PID;随后发现残留查询不能只从死亡的根递归,改为匹配全部预观测身份。旧 0.13.2 在本机初始加载样本中也未观测残留,因此没有把该样本记作已复现产品缺陷。早期清理脚本把预期的“PID 不存在”当错误退出,修正错误处理后重跑。句柄重复测试原有 CLR Thread 对象延迟回收和 JIT 最后引用滞留,采用测试侧预热、NoInlining 分组与终结器回收后原阈值通过;生产不调用 GC。 +- 0.13.3 阶段完整 core 335/335、desktop 35/35、[直接 Roslyn Host 58 场景](../test-tmp/roslyn-host/fixture-6JyqoD/report.json)、Gateway 19 场景及 E4 16 场景通过;CI 文件加入 owner-death 与受控 Repomix 场景/报告上传,但没有推送或运行远端 CI。 +- 作者反证自审:正常关闭通过不能证明原生阻塞也退出,因此加入独立阻塞夹具;Helper 退出不能证明全部后代退出,因此按身份逐类报告;目标窗口存活与测试收尾分开记录。真实 PID 复用/权限差异及其他 Windows 版本未实测;仍存活但卡死的 Gateway 不触发本机制。 +- 原生接口核对采用 Microsoft [Process32FirstW](https://learn.microsoft.com/en-us/windows/win32/api/tlhelp32/nf-tlhelp32-process32firstw)、[PROCESSENTRY32W](https://learn.microsoft.com/en-us/windows/win32/api/tlhelp32/ns-tlhelp32-processentry32w)、[GetProcessTimes](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getprocesstimes) 与 [WaitForSingleObject](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject);这些文档不是本机运行验收的替代。 + +## 2026-09-10 09:19 — M2 按启动测量延后 UIA 探测,0.13.4 本地交付(北京时间) + +- 在 M1 后做三次 stdio 和源码 Router 分段样本,[改前报告](../test-tmp/runtime-baseline/run-msaLjT/report.json):连接到 hello 426.7–434.8 ms;Router 初始化 165.7–168.1 ms,其中 FlaUI 158.9–161.2 ms、每次创建一个健康探测进程。缓存约 2–3 ms、指纹约 1–2 ms、watcher 小于 1 ms,没有证据支持把它们一并休眠/延迟。生成夹具规模不能代表大仓库。 +- M2 仅修改 FlaUiAdapter 初始化为平台/配置/发布文件校验,不运行原生 probe。尚无运行观察保持 available=null/source=unknown;首次 UI 请求直接执行且成功响应更新已知身份;主动 diagnose 保持探测,非强制并发 probe 在既有 mutex 内复查 memo。ToolRouter 单独保留首次 UI 失败,即使健康仍 unknown;未改 watcher/cache/Roslyn 的启动策略或 MCP Schema。 +- 新增 3 项测试,覆盖初始化不探测、并发首次非强制诊断共享观察、缺失文件报告/恢复后首用,以及 first-use timeout 的 unknown/lastAdapterError 边界。作者反证自审修正:失败响应不能自动把未探测 Host 标为已确认不可用,也不能因 health=null 丢掉错误。桌面 hello 测试按真实未探测状态断言 null/unknown,随后原有真实 UI 首用测试照常执行。 +- 最终 [core 338/338](../test-tmp/check/2026-09-10T01-13-24-684Z-core/report.json)、[desktop 35/35 + UIA owner-death](../test-tmp/check/2026-09-10T01-15-11-141Z-desktop/report.json)、[真实 Gateway 19 场景](../test-tmp/roslyn-gateway/run-orlRgW/report.json)、[E4 16 场景](../test-tmp/error-contracts/run-CRw4pt/report.json) 全部通过。直接 Host 的 58 场景在 M1/0.13.3 阶段运行,此后 Code Host 仅同步产品版本;没有把它记作再次运行。 +- 首组改后采样与其他测试竞争资源,单独保留 [并行负载样本](../test-tmp/runtime-baseline/run-CUJxNy/report.json),不用于安静对照。待重测试结束后,[改后独立三样本](../test-tmp/runtime-baseline/run-6cXWUA/report.json):连接到 hello 273.8–289.8 ms,Router 6.7–6.9 ms,FlaUI 静态校验约 0.32 ms,启动原生 probe 为 0。语义冷查询 3.58–4.29 s,热查询 102–108 ms,客户端关闭约 28–32 ms;全部观测进程在关闭后退出。样本共享 OS/SDK 缓存,不给 p95/跨机器性能承诺,未声称改善 Roslyn 语义查询耗时。 +- 改后进程 working-set 合计:未用语义约 84.6–85.2 MiB,语义操作后约 193.9–211.9 MiB;进程求和可能重复计算共享页,只是瞬时快照。启动统计只覆盖被包裹的 Node 异步文件方法/spawn,不含全部原生或内核 I/O。Roslyn 可逆释放仍未实现,不能把上述数据当作已节省驻留内存。 +- Node 24.19.0/Windows 11 本机最终源码、Gateway 和两个 Host 版本 0.13.4;buildId 07ee0681a89cdc8035d3038f8710a43e923f7e44385a2aaa3ce38eadab90ae19;delivery contentId 7131f0943f56b81aa5033458765c5cb320cb0b37723a1054c8fee1e8f83f14d2 再次校验 matched=true。工具数 15,Schema 哈希与基线相同。test inventory 45 个文件完整覆盖,git diff --check 通过。 +- README 保留用户的 Windows 11/其他平台 fork 说明,同步源码版本;CHANGELOG、受管 Skill 源文件、既有计划/路线图和 CONTRIBUTING 更新。未提交/推送、未安装全局 Skill、未改客户端/自启动、未删除用户审计记录。Node 22/远端 CI、跨权限/系统版本、真实 Codex 新版本语义消费尚未验证。 +- USER_DECISION_REQUIRED:已发出 D2(建议默认关闭 idle,显式开启后比较 120/300/600 秒)和 D1(建议独立 WinForms/Named Pipe、首版手动启动)的选择题,尚未收到答复。按计划第 9 节及用户协作契约的重大路线边界,M3 自动释放策略和 M4 托盘不据沉默启动;M5 暂停/存储仍按 D3/D4 决定。当前本地增量可评审,当前连接升级仍单独待验。 + +## 2026-09-10 10:31 — M3 手动释放与 M4 最小设置,0.14.0 本地交付(北京时间) + +- 用户已确定“默认关闭自动释放、设置内手动释放;优先 Agent 工作流畅度,同时平衡后台内存;其他细节按最小方案”。D1/D2 已确认,M3 手动路径与 M4 合并为 0.14.0;本轮没有 idle timer/自动释放开关、开机自启动、全局配置写入、暂停、停止全部或存储清理。沿用现有 SDK/框架,没有新增 NuGet 包或安装依赖。 +- RoslynAdapter.releaseWarmState 复用现有 mutex/关闭链,覆盖操作排队与异步清理;释放只清 Host/snapshot,保留配置、诊断并保持可再次加载。ToolRouter 保护 MCP 在途、直接语义操作、待执行切换、shutdown/recovery;忙碌直接拒绝,不排队延后释放。被接纳释放之后的新请求等待其完成;关闭失败进入现有 restart_gateway 恢复门。缓存/watcher/Gateway 保留。 +- 新增可选 WinCode.Tray,独立 WinForms NotifyIcon 与设置窗口,手动启动;Gateway 仅显式 --tray 时接入。读取内存快照,不启动 Host 或扫描磁盘缓存,隐藏窗口不轮询。八个实例槽加一个唤出窗口槽,控制绑定活连接及 instanceId,管道受当前用户/会话、本机/实际客户端 PID 和额外 User SID 校验保护;限制帧、待决操作、连接退避,断开/超时保留未知,不重放控制。退出 Tray 后 MCP 独立运行。 +- 首批失败包括测试夹具相对 Host 路径、管道地址转义、时序断言、测试变量类型标注和文档脚本语法。明显路径/语法/类型问题直接修正;初始超时测试按本轮已记录 PID/创建时间关闭自有测试进程,随后确认无观测残留,没有按客户端名称清理。所有失败回执保留在 test-tmp,不改写为成功。 +- 用户新增要求:非简单语法/object 等错误,首次测试失败即主动查官方文档和真实 GitHub 实现/问题记录。已写入 CONTRIBUTING。时序断言依据 [Node net 回调契约](https://github.com/nodejs/node/blob/main/doc/api/net.md)改为等待真实 shutdown 回调,不以额外固定 sleep 粉饰通过。管道本机连接实际返回 229,核对 [Microsoft ERROR_PIPE_LOCAL](https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-) 后只接纳这一明确本机结果;其他失败或远程查询成功均拒绝。 +- 另查 [dotnet/runtime #123903](https://github.com/dotnet/runtime/issues/123903),CurrentUserOnly 的历史 Owner SID 行为不能替代实际 User SID,故在读取首帧后按 [RunAsClient 官方用法](https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeserverstream.runasclient?view=net-10.0)同步核验客户端 User SID。重复唤出使用 Identification 身份级别并等确认后关闭短连接;不模拟客户端操作文件或执行程序。依据 [构造函数说明](https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeclientstream.-ctor?view=net-10.0)核对默认 None 与显式 Identification 的差别。 +- [核心 check 356/356](../test-tmp/check/2026-09-10T02-26-26-551Z-core/report.json)通过:包含 8 项手动释放、9 项 TrayClient 和可选 Tray 交付校验;TypeScript、锁定还原/构建、生产 stdio 均通过。核心之后的 Tray 发送前失联复查和重复唤出细化已重新 Release 发布,并以最终 Tray 验收验证;没有把此前核心回执中的旧 delivery ID 称为最终交付 ID。 +- [桌面 check 35/35 + UIA owner + Tray](../test-tmp/check/2026-09-10T02-28-08-797Z-desktop/report.json)通过;最终 [Tray 7 个场景](../test-tmp/tray/run-z65Kr8/report.json)再验证真实 WinForms、同用户安全管道、两个独立 stdio MCP、忙碌拒绝/仅选中实例释放/重复释放/隐藏与退出、重复唤出和实际 dist/index.js --tray 的确认后退出。UI 使用模拟 Roslyn 生命周期,真实 Roslyn 由下述独立验收覆盖;不把截图或模拟后端说成真实项目 UI/Roslyn 全链路验证。 +- [真实 Roslyn 十轮报告](../test-tmp/manual-release/run-umaOsZ/report.json)通过:每轮查询及引用正确、新 snapshot、旧定位在无 Host 时明确拒绝、所有预观测 Code Host/BuildHost 按 PID/创建时间确认退出,资源登记始终为 3;缓存标记和 namespace、watcher 保持。另覆盖冷态新增源码、释放后 A→B→A。没有观测残留。未重跑未受修改的纯 Host 58 场景;本次 [真实 MCP/Roslyn 19 场景](../test-tmp/roslyn-gateway/run-F36HcB/report.json)和 [E4 16 场景](../test-tmp/error-contracts/run-USNVaz/report.json)已重跑通过。 +- 十轮生成小项目:冷查询 3.717–4.112 秒,热查询 109–148 毫秒,手动关闭 21.6–27.2 毫秒;进程工作集合计约 200.2–206.9 MiB → 67.7–69.6 MiB,被关闭的 Host/BuildHost 合计约 132.0–137.5 MiB。共享页可能重复计数,不等同精确回收的独占 RAM;样本共用系统缓存,非大型仓库/p95/跨机承诺。独立 Tray 打开时单次工作集约 51.5 MiB、private bytes 12.7 MiB;Tray 自身有成本,因此继续采用可选手动启动/退出,无自动高频监测。 +- 最终 Gateway buildId=458dc48f0d60f0d2cebf98e3b989efafddfcf74646da46c8c871ed475b1bb6bd;完整 delivery contentId=0f4bd8f371eb74d040b64fd7683ca033e6aaaa6d4c1fe0972b79aa058141cefa,0.14.0 matched=true。15 个 MCP 工具和 Schema 哈希保持原值。最终托盘截图已人工视检,无当前截图范围的裁切/重叠;默认系统 DPI 下的程序化渲染不代表全部 DPI 或人工鼠标操作验收。 +- 作者反证自审:Agent 两次调用之间可能仍在规划,界面空闲不等于整个任务结束,因此只提供用户手动释放且提示旧定位失效;状态陈旧时后端仍重新判断。畸形帧与随后合法帧同批到达必须停止处理,已有回归;断线重连前的旧待发控制重新检查连接状态。普通 Node 和 .NET 唤出客户端均已本机联调,同权限/跨用户拒绝矩阵、Explorer 重建、八实例上限压力及长期驻留仍未全面实测。 +- README 中英文、既有计划书/路线图、CHANGELOG、Skill 源文件及 CONTRIBUTING 同步;真实 Codex 连接、已安装 Skill、客户端 --tray 接入没有改动或升级。本轮没有提交/推送或运行远端 CI;Node 22 与其他 Windows/依赖版本仍未实测。M5/自动策略为延后范围,实际消费者接入保持待验。 +- 文档收尾:8 份相关 Markdown 为有效 UTF-8、代码围栏闭合;现行文档及本轮新增日志的 66 个本地链接目标存在,版本/锁文件一致,git diff --check 通过。全历史日志扫描另发现 15 个旧机器 test-tmp 回执未随源码来到本机;保留历史记录,不据此引用其结果作为本轮验证。首次链接脚本把 chatgpt-conversation URI 当成本地路径,已按 URI 类型修正校验器。 + + +## 2026-09-10 11:32 — 0.14.0 稳定性收尾:工作流连续性、状态可信度与原生交付(北京时间) + +- 用户确认按分析继续,再次强调优先工作流畅、避免反复启停。保持无自动释放/自动加载策略;仅用户手动释放,忙碌不排队。未增加依赖、全局配置、自启动或 M5 能力,未修改实际 Codex 启动参数、重启客户端或推送远端。 +- 设置改为“暂无在途请求”,明确不代表 Agent 整个任务结束。连接保留但刷新失败、观察超过 30 秒时标为未知,保留上次观察时间;释放前重新获取被动状态,超时不继续控制。可见窗口的一秒计时器只重绘时效,隐藏时停止,不向 Gateway 轮询或改变 Host 生命周期。操作结果绑定实例,切换选择不会显示另一实例的结果;调整说明文字换行及表格宽度,避免 PID 列/页脚裁切。 +- 本地管道新增注册接纳/拒绝反馈及可见原因;修复 InvalidDataException 未纳入 IOException 过滤导致监听任务退出的问题。连续 12 次不兼容注册后继续服务。拒绝和唤出回复均有界等待客户端读取/关闭,避免立即 Disconnect 丢掉未读回复,不使用不可取消的 WaitForPipeDrain。安全身份校验及正常 MCP 退出入口保持。 +- 原生发布改由 scripts/publish-native.mjs 在构建前采集输入、构建后复核并绑定完整产物,check 自动使用该入口。覆盖项目目录(排除 bin/obj)、仓内 Shared、仓内 props/targets/NuGet 配置和现有构建脚本;delivery:verify 复查原生输入/产物,不能通过重新生成交付清单接纳改过 .cs 的旧 DLL。范围是仓内已知输入,不是任意外部 MSBuild 导入/SDK 二进制的签名证明。加入源码/共享源码变化、继承配置变化、构建中变化和输出排除的回归。 +- [核心检查 360/360](../test-tmp/check/2026-09-10T03-20-58-314Z-core/report.json)通过,包含类型检查、锁定构建、生产 stdio 与交付验证;此前 [失败回执](../test-tmp/check/2026-09-10T03-19-06-872Z-core/report.json) 保留。旧 watch-invalidation 测试在整个仓库写探针并固定等 500ms,受并行写入/尾沿 debounce 影响;改成隔离 Git 工作区并等待真实 noteFilesystemChange 回调,原失效逻辑继续执行且仍断言指纹不同,未改生产 watcher/缓存或绕过 memo。针对性 5/5 通过后完整重跑通过。 +- [桌面 35/35、UIA owner、托盘与贯通验收](../test-tmp/check/2026-09-10T03-22-29-403Z-desktop/report.json)通过。[真实贯通回执](../test-tmp/tray-workflow/run-Ho43hD/report.json)使用正式 dist 的 Router/MCP/TrayClient、原生设置处理函数、同用户安全管道与两个实际 Roslyn 项目:实际并发语义请求拒绝释放且不延后执行;七次间隔查询保持 A/B snapshot 及 Code Host 身份;只释放 A、B 保持热态;旧定位失败不预热,重新搜索后精确引用正确;退出 Tray 后两个 MCP/语义快照继续可用,关闭后全部预观测进程无残留。测试只替换了隔离管道的启动接线,未模拟 Roslyn 生命周期;不是当前 Codex 连接验收。 +- 同一贯通运行含冷态、双热态、隐藏驻留、只释放 A、退出 Tray 的进程样本。七次隐藏热态样本的工作集合计约 484.2–491.0 MiB,句柄合计 1981–1988,进程数保持 9,无自动启停。所有工作集合计可能重复计算共享页;这是短时、小项目样本,不能外推大型项目、长期泄漏或唯一物理 RAM 回收。 +- 核心之后的托盘显示/回复等待修订已通过上述桌面检查。其后仅实例结果显示隔离及验收诊断修订重新 Release 发布,最终本机 Tray 九场景连续通过三次:[1](../test-tmp/tray/run-mKPJrp/report.json)、[2](../test-tmp/tray/run-rdZu4z/report.json)、[3](../test-tmp/tray/run-xohNR5/report.json)。没有将较早 core/desktop 的交付 ID 称为最终 ID,也未重复未受影响的纯 Host/E4/十轮测试。最终 Gateway buildId=5d8c46b2d68c4c0c490633b0bb4fb8ec56787e9105b756917c94652f7997763f;delivery contentId=132e047e7d81a73a26b3b1cc24623ee464fa653f718049f30af1d1873c54fa7d,matched=true。 +- 失败及外部参考:管道两次 EOF 失败回执为 [Apop33](../test-tmp/tray/run-Apop33/report.json)、[WMQdEX](../test-tmp/tray/run-WMQdEX/report.json)。先依据 [DisconnectNamedPipe](https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-disconnectnamedpipe) 和 [dotnet/runtime Windows 管道实现](https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Windows.cs)处理未读回复,再由 [InvalidDataException 类型定义](https://learn.microsoft.com/en-us/dotnet/api/system.io.invaliddataexception?view=net-10.0)定位异常过滤遗漏;修正后重复拒绝验收通过。贯通首次 [nHnQyk](../test-tmp/tray-workflow/run-nHnQyk/report.json) 错把至少两个长期 Host 进程作为条件;查询真实进程树、检索 Roslyn 案例后改为定位实际 Code Host 及预观测子树,不把 conhost 或已退出的临时求值进程误记为常驻 BuildHost。watch 测试参考 [Node fs.watch 契约](https://nodejs.org/api/fs.html)和 [Node 测试指南](https://github.com/nodejs/node/blob/main/doc/contributing/writing-tests.md)。布局参考 [WinForms 布局约束](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/layout)。 +- 待观察失败:[VcfIp2](../test-tmp/tray/run-VcfIp2/report.json) 在原生验收时超时,未留下 UI 回执,根因尚未证实。参考 [WinForms 异常处理](https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.setunhandledexceptionmode) 和 [官方实现](https://github.com/dotnet/winforms/blob/main/src/System.Windows.Forms/System/Windows/Forms/Application.cs),在仅验收模式启用异常直出、输出阶段和退出/超时原因,后续三次未复现;不能将诊断改进称为已修复该偶发超时。 +- 当前 Codex 被动 hello 实测为上一轮 0.14.0、buildId 458dc48f…、local-text;codex mcp get 的实际参数只有 dist/index.js,未启用 Roslyn 或 --tray。[实际客户端验收参数预览](../test-tmp/tray-workflow/run-Ho43hD/client-configuration-preview.json) 与 [Roslyn 配置](../test-tmp/tray-workflow/run-Ho43hD/client-roslyn.json) 已准备,仅指向隔离夹具,applied=false。实际消费者启用/重连、Node 22/远端 CI、跨权限/系统/DPI 和长期驻留仍未验证。当前已构建本地增量可评审,未提交/发布。 +- 作者反证自审覆盖:连通但观察失效、失效状态下直接进入释放处理函数、拒绝注册耗尽监听、切换选择混入别的实例结果、全部分层测试通过但真实设置/Roslyn未串联、C#改过而旧DLL仍被清单接纳,以及刷新/隐藏期间意外重启。README、CONTRIBUTING、CHANGELOG、既有计划/路线图同步;不把作者自审称作独立审核。 +- 收尾校验:最终 delivery 再次 matched=true;git diff --check 通过;本次六份 Markdown(日志只检查新增段落)的 70 个本地链接均存在,UTF-8 与代码围栏通过。最终 settings.png 已目视核对,PID 列和页脚完整、切换实例不显示其他实例的操作结果;仅覆盖本机默认 DPI。 +- VcfIp2 证据补充:旧验收脚本有 35 秒兜底终止,但当时未保存退出码/超时标志,回执只记录缺少原生 UI 报告。因此“超时”属于基于旧脚本行为的推测,不能据此认定具体根因;现已补齐这两个诊断字段,后续三次正常退出且未复现。 + +## 2026-09-10 11:45 — 多项目、多 Agent 并发诊断(北京时间) + +- 用户要求测试多个项目或不同软件 Agent 同时调用 MCP 是否混淆、溢出。本轮只增加 [隔离诊断脚本](../scripts/verify-multi-agent.mjs) 和本日志,未修改生产代码、接口、依赖、实际消费者配置、自动释放策略或远端。使用当前 dist/index.js 真实 stdio、已发布 Code Host、项目内 SDK 和两个离线还原的生成项目;同一测试驱动内的三个 SDK Client 各自启动独立 Gateway,分别代表 A、B、另一软件的 A。不是三个实际第三方软件的接入认证。共享实例情景通过同一合法 stdio Client 交错发送两个逻辑 Agent 的调用,不声称 stdio 本身支持多个独立连接。 +- [第一轮八场景回执](../test-tmp/multi-agent/run-9uve7M/report.json)全部完成:三个进程并发冷加载、同名方法引用分别为 A=1/B=2;跨实例定位(包括同项目两个实例)返回 SNAPSHOT_STALE 且不改变健康快照;96 次交错精确引用;单进程 128 个并发搜索和其他实例查询;64 个请求中取消 16 个,余下 48 个正确;共享工作区复现;同路径重开复现;关闭一个客户端后另外两个继续工作。预观测进程最终均无残留。此处 success 指诊断场景完成,不代表没有发现缺陷。 +- **已复现:共享进程的多调用任务不具备项目隔离。** A 打开 A,另一个逻辑 Agent 打开 B 后,A 只按 Save 搜索成功返回 B.Api.Save(int),相对目录查询也返回 only-B.txt;没有自动 WORKSPACE_MISMATCH。传旧精确 symbolLocation 则明确 SNAPSHOT_STALE,说明精确定位有保护,普通名称/相对路径没有任务级绑定。ToolRouter.config.workspaceRoot 是实例全局状态,切换锁只保护在途请求与切换,不覆盖完整 Agent 任务。不能把这个结果泛化为独立进程串项目;另一 A 实例保持原快照和查询结果。 +- **已复现:同路径 workspace_open 会主动关闭健康 Code Host。** 明确观察旧 Host PID/创建时间退出、hello 的 processAlive=false、下一次搜索获得新 snapshot,另一个实例仍热态。此行为来自 ToolRouter.ts 的同路径显式 resetConnection,原意是恢复入口,但多个 Agent 重复初始化会造成冷启动,与优先连续工作存在矛盾。第二轮该重新搜索约 3.915 秒;这是小项目单次观察,不是性能承诺。 +- **负载边界:有限突发成功,但准入队列缺少长度上限。** 第一轮单实例 128 请求时被动 hello 观察 inFlightRequests=129(包括 hello 本身);请求结束回到只有 hello 的 1。代码中的 acquireRequestSlot 和 Mutex 计数/串行等待没有队列容量限制,仍有单次操作超时。第二轮集中搜索最长约 13.948 秒;不能把单帧上限、32 MiB 单实例缓存预算或超时等同于进程总内存/队列容量上限。本轮没有制造 OOM,也未证明持续洪峰下不会耗尽内存。Roslyn 精确查询不走语义磁盘缓存,本轮 cache entries=0,不能据此声称已验证跨进程共享缓存写入/清理安全。 +- 128 请求场景两轮出现 drain MaxListenersExceededWarning。按用户要求先查 [MCP SDK 真实问题 #842](https://github.com/modelcontextprotocol/typescript-sdk/issues/842)、[官方客户端 stdio 实现](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/stdio.ts)和 [Node 流背压规则](https://nodejs.org/api/stream.html),再增加测试诊断。第二轮 [完整警告堆栈与前八场景](../test-tmp/multi-agent/run-ffxcyO/report.json)确定警告来自本机 @modelcontextprotocol/client 2.0.0 的 StdioClientTransport.send,事件数超过默认 10,场景结束后监听器为 0;三个 Gateway stderr 没有同类警告。外部 issue 场景是服务端批量通知,只作为相似背压机制参考,不能视为本机相同根因的证据。不抬高监听器阈值、不隐藏警告、不声称已证实持续泄漏。 +- 第二轮新增 SDK 帧测试曾失败:测试错误地期待超限后同一连接还能读下一条合法消息,实际为 0 条。核对 [官方服务端 stdio.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/server/src/server/stdio.ts) 和本机包后确认超限应报错并关闭传输,是测试假设错误;生产代码未改。修正为明确断言错误、关闭、后续不执行,并只重跑受影响边界,未反复重跑已通过真实 Host 场景。[边界两场景回执](../test-tmp/multi-agent/run-PxHPQo/report.json)通过:按 64 KiB 分块发送 10 MiB + 64 KiB 后 SDK 发出 10485760 字节上限错误并关闭;该测试用已安装真实 transport 和内存流,未向实际 Codex 连接发大包。 +- 同一边界回执通过真实原生托盘安全管道的八注册接纳、第九注册明确拒绝、满员后 show 仍可用、断开一个连接后新注册成功。九条注册通道来自一个自有 Node 测试进程并使用各自 UUID,状态是夹具;这验证原生容量及恢复,不等同九个真实 Roslyn MCP 的资源压力,也不将托盘八槽称为 MCP 全局进程上限。测试托盘使用隔离命名空间,最终无预观测残留。 +- 补跑既有 [混合负载十轮 / 70 调用](../test-tmp/mixed-load/run-HT12AB/report.json),5.754 秒通过,覆盖 Router 在途请求、交错切换、取消、命名空间和释放;该脚本有控制门且不启动外部适配器,作为 Router 层补充,不能冒充真实三客户端 Roslyn 测试。 +- 验证:新脚本 node --check、git diff --check、delivery:verify 通过;delivery contentId 仍为 132e047e7d81a73a26b3b1cc24623ee464fa653f718049f30af1d1873c54fa7d,matched=true。生产未变,未重复无关核心/桌面全量构建。可复现命令:node scripts/verify-multi-agent.mjs;仅帧/托盘容量为 node scripts/verify-multi-agent.mjs --boundaries-only。 +- 建议次序:目前让并发独立项目使用独立 Gateway,任务期间固定工作区,避免每次查询前重复 workspace_open;下一轮先解决同路径重开的幂等与显式恢复语义,再设计请求工作区绑定和有界排队/取消/可观察等待。**USER_DECISION_REQUIRED(后续实现)**:是否改变现有 workspace_open 恢复契约、增加每请求工作区身份或固定实例模式、采用什么超载拒绝/等待规则;本轮不擅自改变这些公共行为。不建议为了省内存直接合并成全局多项目 Host 或引入自动启停。 +- 作者反证自审:单次请求全部正确仍可能在两个调用之间串项目;实例隔离仍可能有共享磁盘缓存或共同源文件的竞争;64 个取消测试只保证指定取消与余下查询正确,不涵盖任意 Host 硬故障;10 MiB 帧防护不能限制大量小请求的总队列;警告消失与进程回收也不能证明长时无泄漏。并发源码写入/共享缓存清理、真实软件接入、UIA 多 Agent 操作同一窗口、长期大项目驻留尚未实测,不能给出“任意多 Agent 并发绝对安全”的结论。 + +## 2026-09-10 11:57 — 回顾并精简下一轮规划,形成并发治理最终推荐(北京时间) + +- 用户要求结合 GitHub 优秀案例给出最终推荐,并写入原规划书、删除已经确定实现的部分。本轮仅修改 [详细计划](../WinCode-下一轮工程化迭代计划书.md)、[简版路线图](../WinCode-迭代路线图.md) 和本日志,没有修改生产代码、测试、依赖、实际客户端配置或远端。 +- 复核当前 0.14.0 未发布工作树及现有回执:核心 360/360、桌面 35/35、真实托盘/Roslyn 贯通、最近托盘专项、三实例诊断、第二轮失败、修正后边界和混合负载报告。delivery:verify 再次 matched=true,contentId=132e047e7d81a73a26b3b1cc24623ee464fa653f718049f30af1d1873c54fa7d;不是重跑这些代码测试,不把旧回执套到未经验证的新生产修改。 +- 从未来待办删除旧 M0–M4 已完成的本机重建、owner guard、UIA 延迟探测、手动释放、托盘/安全 IPC、状态可信度和原生交付绑定步骤;已验证托盘八注册/拒绝/空位恢复也移出开发队列。删除已过期版本建议、相互矛盾的“没有托盘/缺少可逆释放/仍主动启动探测”等基线,以及 M5 中 2/5/10 分钟自动释放设置的旧执行方案。只保留简短基线和历史链接,不删除既有日志与失败回执。 +- 最终推荐为独立 Gateway、连接固定项目、健康 Host 保持热态、有界排队。N1 固定根和错误目标零副作用;N2 同根确认与必要恢复分流;N3 有界受理/公平等待/取消收尾/明确过载;N4 真实共享缓存/源码变更和窗口边界验证;N5 实际消费者、原生未定位失败及交付验收。每项列关键模块和可反证验收。 +- GitHub 一手参考:[Playwright MCP](https://github.com/microsoft/playwright-mcp/blob/main/README.md#user-profile)明确同项目并发 profile 需额外隔离;[rust-analyzer reload.rs](https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/reload.rs)区分相同工作区、构建数据变化和强制重载;[.NET ConcurrencyLimiter](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/ConcurrencyLimiter.cs)及 [测试](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Threading.RateLimiting/tests/ConcurrencyLimiterTests.cs)提供队列容量/FIFO/取消归还竞态参考;[SDK #842](https://github.com/modelcontextprotocol/typescript-sdk/issues/842)与 [客户端 stdio](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/stdio.ts)用于区分发送积压警告。仅借鉴机制,不声称这些仓库证明了 WinCode 的实现安全;链接为查阅当日 main/master,不宣称固定发布版本。 +- 32 个未完成业务请求、4 个状态槽、64 KiB 参数预算仅为明确的实测起点,需合法调用兼容性与资源验证;传输帧、队列、缓存和进程 RSS 分开。固定根不自动解决同项目共享磁盘或同窗口操作;N4 未预判需要新缓存架构,也不预先复制每个实例的持久缓存。默认自动释放关闭及手动释放保持既有用户选择。 +- 保留的真实未完成项:当前消费者最新构建/Roslyn 闭环、两种目标软件项目连接兼容性、共享缓存并发清理/编辑、VcfIp2 根因未定位、Node 22/远端 CI、长期/大项目及其他权限/DPI/Explorer 范围。特别修正旧“超时已定位”式措辞:VcfIp2 原始回执只证明缺少 UI 报告,旧超时归因仍为推测。 +- USER_DECISION_REQUIRED 为后续实现的固定根迁移、同根重开语义和过载错误/预算这组推荐契约;本次文档更新不代表已经实现或批准真实客户端操作。若目标软件只支持全局单连接但必须多项目切换,应先重新选择每请求工作区身份,不悄加不安全兼容模式。 +- 作者反证自审:固定根后选错工具连接仍需客户端尊重错误;同根快捷返回不得绕过恢复门或输入新鲜度;取消逻辑容量降低不等于等待节点释放;独立 PID 不等于共享存储或 UI 隔离;本地交付身份不等于活动客户端已升级。规划按用户要求删除已完成待办,历史只追加不改写。 +- 文档收尾:详细计划 148 行、简版路线图 42 行;两计划及本次新增日志的 UTF-8、代码围栏、27 个本地链接、1 个标题锚点通过,旧 M0–M4 实施段落已移除,N1–N5 两文档一致,git diff --check 通过。仅文档修改,未重复运行代码全套测试。 + +## 2026-09-10 12:00 — 按用户要求快速推送 GitHub 检查点(北京时间) + +- 用户明确授权快速推送当前状态。提交范围为当前 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 已通过。 diff --git a/package-lock.json b/package-lock.json index 70d87ec..b00fa1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wincode-mcp", - "version": "0.13.2", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wincode-mcp", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/client": "2.0.0", diff --git a/package.json b/package.json index 903fab1..081478c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wincode-mcp", - "version": "0.13.2", + "version": "0.14.0", "description": "Windows-first MCP gateway: .NET project graph, evidence-bounded context, honest change-impact, long-running process hygiene", "main": "dist/index.js", "type": "module", @@ -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", + "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:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", @@ -20,6 +20,7 @@ "test:repomix-real": "tsx scripts/verify-repomix-real.ts", "test:mixed-load": "tsx scripts/verify-mixed-load.ts", "test:error-contracts": "tsx scripts/verify-error-contracts.ts", + "test:owner-death": "node scripts/verify-owner-death.mjs", "test:roslyn-host": "node scripts/verify-roslyn-host.mjs", "test:tavern-context": "tsx scripts/verify-tavern-context.ts", "test:e2e": "tsx scripts/test-mcp-client.ts", @@ -30,7 +31,10 @@ "test:ui-code": "tsx --test tests/ui-code-runtime.test.ts", "skill:check": "node scripts/sync-skill.mjs", "skill:sync": "node scripts/sync-skill.mjs --apply", - "test:roslyn-gateway": "node scripts/verify-roslyn-gateway.mjs" + "test:roslyn-gateway": "node scripts/verify-roslyn-gateway.mjs", + "test:manual-release": "tsx scripts/verify-manual-release.ts", + "test:tray": "node scripts/verify-tray.mjs", + "test:tray-workflow": "node scripts/verify-tray-workflow.mjs" }, "keywords": [ "mcp", diff --git a/scripts/check.mjs b/scripts/check.mjs index b3e862f..b40dadb 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -58,8 +58,10 @@ if (inventoryOnly) { const tsx = path.join(root, 'node_modules/tsx/dist/cli.mjs'); const native = 'tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj'; const codeHost = 'tools/WinCode.Code.Host/WinCode.Code.Host.csproj'; + const tray = 'tools/WinCode.Tray/WinCode.Tray.csproj'; const audit = 'tests/fixtures/ui-audit-check/ui-audit-check.csproj'; const query = 'tests/fixtures/ui-query-check/ui-query-check.csproj'; + const ownerGuard = 'tests/fixtures/owner-guard-check/owner-guard-check.csproj'; const wpf = 'tests/fixtures/wpf-ui-review/wpf-ui-review.csproj'; const deterministic = ['-p:ContinuousIntegrationBuild=true', `-p:PathMap=${root}=/_/WinCode`]; try { @@ -69,15 +71,20 @@ if (inventoryOnly) { await run('restore-wpf', 'dotnet', ['restore', wpf, '--locked-mode']); await run('publish-wpf', 'dotnet', ['publish', wpf, '-c', 'Release', '-r', 'win-x64', '--no-self-contained', '--no-restore', ...deterministic]); report.tests = testTotals(await node('desktop-tests', [tsx, '--test', '--test-reporter=tap', '--test-concurrency=1', ...groups['test:ui'], ...groups['test:ui-code']])); + await node('desktop-owner-death', ['scripts/verify-owner-death.mjs', '--desktop']); + await node('desktop-tray', ['scripts/verify-tray.mjs']); + await node('desktop-tray-workflow', ['scripts/verify-tray-workflow.mjs']); } else { await node('typecheck', [tsc, '-p', 'tsconfig.test.json']); await node('build-gateway', ['scripts/build.mjs']); - for (const [name, project] of [['host', native], ['code-host', codeHost], ['audit', audit], ['query', query]]) + for (const [name, project] of [['host', native], ['code-host', codeHost], ['tray', tray], ['audit', audit], ['query', query], ['owner-guard', ownerGuard]]) await run(`restore-${name}`, 'dotnet', ['restore', project, '--locked-mode']); - await run('publish-host', 'dotnet', ['publish', native, '-c', 'Release', '-r', 'win-x64', '--no-self-contained', '--no-restore', ...deterministic]); - await run('publish-code-host', 'dotnet', ['publish', codeHost, '-c', 'Release', '--no-self-contained', '--no-restore', ...deterministic]); + await node('publish-host', ['scripts/publish-native.mjs', 'host']); + await node('publish-code-host', ['scripts/publish-native.mjs', 'codeHost']); + await node('publish-tray', ['scripts/publish-native.mjs', 'tray']); await run('build-audit', 'dotnet', ['build', audit, '-c', 'Debug', '--no-restore', ...deterministic]); await run('build-query', 'dotnet', ['build', query, '-c', 'Release', '--no-restore', ...deterministic]); + await run('build-owner-guard', 'dotnet', ['build', ownerGuard, '-c', 'Release', '--no-restore', ...deterministic]); report.tests = testTotals(await node('regression', [tsx, '--test', '--test-reporter=tap', ...groups.test])); const stdio = JSON.parse(await node('stdio', [tsx, 'scripts/test-mcp-client.ts'])); report.runtime = { build: stdio.runtime?.build, schemaHash: stdio.schemaHash, toolCount: stdio.toolCount, diff --git a/scripts/delivery-manifest.mjs b/scripts/delivery-manifest.mjs index 2c62875..cf9e1c1 100644 --- a/scripts/delivery-manifest.mjs +++ b/scripts/delivery-manifest.mjs @@ -9,6 +9,7 @@ import { resolveDotnet } from './lib/dotnet.mjs'; export const hostDirectory = 'tools/WinCode.UIA.Host/bin/Release/net10.0-windows/win-x64/publish'; export const codeHostDirectory = 'tools/WinCode.Code.Host/bin/Release/net10.0/publish'; +export const trayDirectory = 'tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish'; const rootDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const sha256 = data => createHash('sha256').update(data).digest('hex'); const settings = ['package.json', 'package-lock.json', 'global.json', @@ -26,13 +27,14 @@ async function fileRecord(root, relative) { return { path: relative, bytes: stat.size, sha256: sha256(await fs.readFile(full)) }; } -async function inventory(root, directory) { +async function inventory(root, directory, source = false) { const files = []; let entries = 0; async function visit(relative, depth) { if (depth > 8) throw new Error('Delivery directory depth exceeded.'); const handle = await fs.opendir(path.join(root, relative)); for await (const entry of handle) { + if (source && entry.isDirectory() && ['bin', 'obj'].includes(entry.name.toLowerCase())) continue; if (++entries > 512) throw new Error('Delivery directory entry limit exceeded.'); if (entry.isSymbolicLink()) throw new Error('Delivery links are unsupported.'); const child = `${relative}/${entry.name}`; @@ -56,7 +58,52 @@ async function records(root, files) { return result; } -export async function collectDelivery(root, hostIdentity, toolchains, codeHostIdentity) { +export const nativeComponents = { + host: { project: 'tools/WinCode.UIA.Host', output: hostDirectory }, + codeHost: { project: 'tools/WinCode.Code.Host', output: codeHostDirectory }, + tray: { project: 'tools/WinCode.Tray', output: trayDirectory }, +}; +const nativeReceipt = 'native-build-manifest.json'; +export async function collectNativeInputs(root, component) { + const spec = nativeComponents[component]; + if (!spec) throw new Error('Unknown native component.'); + const files = await inventory(root, spec.project, true); + // 保守记录仓内共享源码及构建约定;不把 bin/obj 生成文件当作输入。 + if (await fs.stat(path.join(root, 'tools/Shared')).catch(e => { if (e.code === 'ENOENT') return null; throw e; })) + files.push(...await inventory(root, 'tools/Shared', true)); + for (const file of ['global.json', 'scripts/publish-native.mjs', 'scripts/delivery-manifest.mjs', 'scripts/lib/dotnet.mjs', + 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.Config', 'nuget.config', + 'tools/Directory.Build.props', 'tools/Directory.Build.targets', 'tools/Directory.Packages.props', 'tools/NuGet.Config']) { + if (await fs.stat(path.join(root, file)).catch(e => { if (e.code === 'ENOENT') return null; throw e; })) files.push(file); + } + return records(root, [...new Set(files)]); +} + +export async function sealNativeBuild(root, component, inputsBefore) { + const inputs = await collectNativeInputs(root, component); + if (fingerprint(inputsBefore) !== fingerprint(inputs)) throw new Error('Native source changed during build; rebuild from stable inputs.'); + const output = nativeComponents[component].output; + const artifacts = await records(root, (await inventory(root, output)).filter(file => file !== `${output}/${nativeReceipt}`)); + const receipt = { formatVersion: 1, component, inputs, artifacts }; + await fs.writeFile(path.join(root, output, nativeReceipt), JSON.stringify(receipt, null, 2) + '\n'); + return receipt; +} + +async function verifyNativeBuild(root, component) { + const output = nativeComponents[component].output; + const receipt = JSON.parse(await fs.readFile(path.join(root, output, nativeReceipt), 'utf8').catch(error => { + if (error.code === 'ENOENT') throw new Error(`Missing ${component} build receipt; run npm run check or node scripts/publish-native.mjs ${component}.`); + throw error; + })); + if (receipt.formatVersion !== 1 || receipt.component !== component || + fingerprint(await collectNativeInputs(root, component)) !== fingerprint(receipt.inputs)) + throw new Error(`Native ${component} source changed after build; rebuild before delivery.`); + const artifacts = await records(root, (await inventory(root, output)).filter(file => file !== `${output}/${nativeReceipt}`)); + if (fingerprint(artifacts) !== fingerprint(receipt.artifacts)) throw new Error(`Native ${component} artifacts changed after build.`); + return fingerprint(receipt.inputs); +} + +export async function collectDelivery(root, hostIdentity, toolchains, codeHostIdentity, trayIdentity) { const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); const gateway = JSON.parse(await fs.readFile(path.join(root, 'dist/build-manifest.json'), 'utf8')); if (gateway.version !== pkg.version || hostIdentity?.version !== pkg.version || hostIdentity.configuration !== 'Release') @@ -82,16 +129,26 @@ export async function collectDelivery(root, hostIdentity, toolchains, codeHostId 'BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.runtimeconfig.json']) { if (!files.includes(`${codeHostDirectory}/${required}`)) throw new Error(`Missing Code Host sidecar: ${required}`); } - codeHost = { identity: codeHostIdentity, files: await records(root, files) }; + codeHost = { identity: codeHostIdentity, sourceHash: await verifyNativeBuild(root, 'codeHost'), files: await records(root, files) }; + } + let tray; + if (trayIdentity !== undefined) { + if (trayIdentity.version !== pkg.version || trayIdentity.configuration !== 'Release' || trayIdentity.protocolVersion !== 1) + throw new Error('Tray version, Release configuration and protocol must agree with the Gateway.'); + const files = await inventory(root, trayDirectory); + for (const required of ['WinCode.Tray.exe', 'WinCode.Tray.dll', 'WinCode.Tray.deps.json', 'WinCode.Tray.runtimeconfig.json']) + if (!files.includes(`${trayDirectory}/${required}`)) throw new Error(`Missing Tray sidecar: ${required}`); + tray = { identity: trayIdentity, sourceHash: await verifyNativeBuild(root, 'tray'), files: await records(root, files) }; } return { version: pkg.version, toolchains, components: { gateway: { buildId: gateway.buildId, files: gatewayFiles }, - host: { identity: hostIdentity, files: await records(root, hostFiles) }, + host: { identity: hostIdentity, sourceHash: await verifyNativeBuild(root, 'host'), files: await records(root, hostFiles) }, ...(codeHost ? { codeHost } : {}), + ...(tray ? { tray } : {}), skill: { files: await records(root, managedFiles.map(file => `skills/wincode/${file}`)) }, configuration: { files: await records(root, [...settings, ...(codeHost ? [ 'tools/WinCode.Code.Host/WinCode.Code.Host.csproj', 'tools/WinCode.Code.Host/packages.lock.json', - ] : [])]) }, + ] : []), ...(tray ? ['tools/WinCode.Tray/WinCode.Tray.csproj', 'tools/WinCode.Tray/packages.lock.json'] : [])]) }, } }; } @@ -101,7 +158,7 @@ export async function verifyDelivery(root, manifest) { if (manifest.formatVersion !== 1 || !manifest.delivery || deliveryId(manifest.delivery) !== manifest.contentId) throw new Error('Invalid delivery manifest identity.'); const actual = await collectDelivery(root, manifest.delivery.components.host.identity, manifest.delivery.toolchains, - manifest.delivery.components.codeHost?.identity); + manifest.delivery.components.codeHost?.identity, manifest.delivery.components.tray?.identity); if (deliveryId(actual) !== manifest.contentId) throw new Error('Delivery contents changed or are incomplete.'); return { contentId: manifest.contentId, version: actual.version, matched: true }; } @@ -126,7 +183,9 @@ export async function writeDelivery(root = rootDirectory) { }); const codeResponse = installed ? JSON.parse(output(sdk.dotnet, [codeHostPath, '--identity'], root, undefined, sdk.env)) : undefined; if (codeResponse && codeResponse.success !== true) throw new Error('Code Host identity probe failed.'); - const delivery = await collectDelivery(root, hostResponse.hostIdentity, toolchains, codeResponse?.hostIdentity); + const trayInstalled = await fs.stat(path.join(root, trayDirectory)).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + const trayIdentity = trayInstalled ? JSON.parse(output(path.join(root, trayDirectory, 'WinCode.Tray.exe'), ['--identity'], root, undefined, sdk.env)) : undefined; + const delivery = await collectDelivery(root, hostResponse.hostIdentity, toolchains, codeResponse?.hostIdentity, trayIdentity); const git = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true, timeout: 3000 }); const revision = git.status === 0 && /^[a-f0-9]{40,64}$/.test(git.stdout.trim()) ? git.stdout.trim() : null; const manifest = { formatVersion: 1, contentId: deliveryId(delivery), delivery, revision, createdAt: new Date().toISOString() }; diff --git a/scripts/lib/owned-processes.mjs b/scripts/lib/owned-processes.mjs index 26f605e..5f8813f 100644 --- a/scripts/lib/owned-processes.mjs +++ b/scripts/lib/owned-processes.mjs @@ -21,3 +21,30 @@ export function assertExited(processes) { assert.ok(!alive, `Owned process survived: ${process.ProcessId}`); } } + +/** 按预先记录的每个进程身份查残留;祖先进程消失后不能再依靠完整父链找到孤儿。 */ +export function observedSurvivors(processes) { + assert.ok(processes.length <= 256); + const ids = processes.map(process => { + assert.ok(Number.isSafeInteger(process.ProcessId) && process.ProcessId > 0); + return process.ProcessId; + }); + if (!ids.length) return []; + const command = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $ids = @(${ids.join(',')}); @((Get-CimInstance Win32_Process) | Where-Object { $_.ProcessId -in $ids } | Select-Object ProcessId,ParentProcessId,CreationDate,Name) | 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]).filter(current => + processes.some(old => old.ProcessId === current.ProcessId && old.CreationDate === current.CreationDate)); +} + +/** 测试故障后的兜底:持有实际进程句柄再核对创建时间,绝不按名称清理或追逐复用的 PID。 */ +export function terminateObserved(process) { + assert.ok(Number.isSafeInteger(process.ProcessId) && process.ProcessId > 0); + const timestamp = /^\/Date\((\d+)\)\/$/.exec(process.CreationDate)?.[1]; + assert.ok(timestamp && Number.isSafeInteger(Number(timestamp)), 'Expected the Windows CIM creation timestamp'); + const command = `$ErrorActionPreference = 'Stop'; $p = Get-Process -Id ${process.ProcessId} -ErrorAction SilentlyContinue; if ($p) { try { $handle = $p.SafeHandle; $created = [DateTimeOffset]::new($p.StartTime.ToUniversalTime()).ToUnixTimeMilliseconds(); if ($created -eq ${timestamp}) { $p.Kill(); $p.WaitForExit(3000) | Out-Null; 'terminated' } } finally { $p.Dispose() } }; exit 0`; + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { encoding: 'utf8', windowsHide: true, timeout: 8000 }); + assert.equal(result.status, 0, result.error?.message ?? result.stderr); + return result.stdout.trim() === 'terminated'; +} diff --git a/scripts/measure-runtime-baseline.mjs b/scripts/measure-runtime-baseline.mjs new file mode 100644 index 0000000..9cbb1c4 --- /dev/null +++ b/scripts/measure-runtime-baseline.mjs @@ -0,0 +1,116 @@ +/** 三次隔离 stdio 样本;连接/冷查询/热查询计时不包含外部进程快照的开销。 */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { resolveDotnet, runDotnet } from './lib/dotnet.mjs'; +import { ownedProcesses, observedSurvivors } from './lib/owned-processes.mjs'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const toolchain = resolveDotnet(repo); +await fs.mkdir(path.join(repo, 'test-tmp/runtime-baseline'), { recursive: true }); +const root = await fs.mkdtemp(path.join(repo, 'test-tmp/runtime-baseline/run-')); +const report = { root, version: JSON.parse(await fs.readFile(path.join(repo, 'package.json'), 'utf8')).version, + startedAt: new Date().toISOString(), samples: [], success: false, + limitations: ['Three local generated-project samples; filesystem/SDK caches are warm across runs. No p95 or clean-machine promise.', + 'Owned-process working sets are snapshots and may count shared pages more than once. No installed Codex connection change.'] }; +const config = path.join(root, 'roslyn.json'); +function startupProfile() { + const moduleUrl = name => JSON.stringify(pathToFileURL(path.join(repo, `src/${name}.ts`)).href); + // 仅在独立测量进程包裹现有方法;不改变生产代码、启动顺序或模块行为。 + const code = `import childProcess from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module'; +import fs from 'node:fs/promises'; +let spawns = 0, reads = 0, readBytes = 0, stats = 0, enumerations = 0; +const spawn = childProcess.spawn; childProcess.spawn = function(...args) { spawns++; return spawn.apply(this, args); }; syncBuiltinESMExports(); +for (const name of ['readFile', 'stat', 'lstat', 'readdir']) { const original = fs[name]; fs[name] = async function(...args) { +const result = await original.apply(this, args); if (name === 'readFile') { reads++; readBytes += Buffer.byteLength(result); } +else if (name === 'readdir') enumerations++; else stats++; return result; }; } +const { ToolRouter } = await import(${moduleUrl('Core/ToolRouter')}); +const { getDefaultConfig } = await import(${moduleUrl('Core/Config')}); +const router = new ToolRouter(getDefaultConfig(${JSON.stringify(root)})); +spawns = reads = readBytes = stats = enumerations = 0; +const stages = []; +for (const [object, method, label] of [[router.cache,'initialize','cache'], [router.repomix,'initialize','repomix'], +[router.text,'initialize','local-text'], [router.flaui,'initialize','flaui'], [router.extensions,'initializeAll','extensions'], +[router.cache,'computeWorkspaceFingerprint','fingerprint']]) { +const original = object[method]; object[method] = async function(...args) { const start = performance.now(); +const before = { spawns, reads, readBytes, stats, enumerations }; try { return await original.apply(this, args); } +finally { stages.push({ label, ms: performance.now() - start, spawns: spawns-before.spawns, reads: reads-before.reads, +readBytes: readBytes-before.readBytes, stats: stats-before.stats, enumerations: enumerations-before.enumerations }); } }; } +const watchStart = router.watch.start; router.watch.start = function(...args) { const start = performance.now(); +try { return watchStart.apply(this, args); } finally { stages.push({ label: 'watch', ms: performance.now()-start }); } }; +const start = performance.now(); try { await router.initialize(); +console.log(JSON.stringify({ totalMs: performance.now()-start, stages, spawns, reads, readBytes, stats, enumerations })); } +finally { await router.dispose(); }`; + const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', code], { + cwd: repo, env: toolchain.env, windowsHide: true, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout.trim().split('\n').at(-1)); +} +async function snapshot(pid) { + const processes = ownedProcesses(pid); + const ids = processes.map(item => item.ProcessId); + assert.ok(ids.length <= 256 && ids.every(id => Number.isSafeInteger(id) && id > 0)); + const command = `@(Get-Process -Id ${ids.join(',')} -ErrorAction SilentlyContinue | Select-Object Id,WorkingSet64,PrivateMemorySize64,HandleCount) | ConvertTo-Json -Compress`; + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { windowsHide: true, encoding: 'utf8', timeout: 15000 }); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout || '[]'); + return { processes, memory: Array.isArray(parsed) ? parsed : [parsed] }; +} +try { + const project = 'net10.0false'; + await fs.writeFile(path.join(root, 'App.csproj'), project); + await fs.writeFile(path.join(root, 'Api.cs'), 'public class Api { public static void Save(int value) {} public void Run() { Save(1); } }'); + await fs.writeFile(path.join(root, 'NuGet.Config'), ''); + runDotnet(toolchain, ['restore', path.join(root, 'App.csproj'), '--nologo'], root); + await fs.writeFile(config, JSON.stringify({ enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', + targetFramework: 'net10.0', dotnetPath: toolchain.dotnet, + hostPath: path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll') })); + for (let index = 0; index < 3; index++) { + const sample = { index: index + 1 }; + report.samples.push(sample); + const client = new Client({ name: 'runtime-baseline', version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, + args: [path.join(repo, 'dist/index.js'), '--workspace', root, '--roslyn-config', config], cwd: root, env: toolchain.env, stderr: 'pipe' }); + const call = async (name, args = {}) => { + const response = await client.callTool({ name, arguments: args }, { timeout: 30000 }); + assert.notEqual(response.isError, true, JSON.stringify(response)); + return JSON.parse(response.content[0].text); + }; + try { + let start = performance.now(); + await client.connect(transport); + transport.stderr?.on('data', () => {}); + const hello = await call('wincode_hello_world'); + sample.connectThroughHelloMs = performance.now() - start; + sample.identity = { version: hello.version, runtime: hello.runtime, codeProvider: hello.codeProvider }; + sample.unused = await snapshot(transport.pid); + assert.ok(!sample.unused.processes.some(item => item.CommandLine?.includes('WinCode.Code.Host')), 'Hello unexpectedly started Roslyn'); + start = performance.now(); + const symbols = await call('wincode_find_code_symbol', { query: 'Save', kind: 'method' }); + sample.coldQueryMs = performance.now() - start; + assert.equal(symbols.source, 'roslyn'); + sample.warm = await snapshot(transport.pid); + start = performance.now(); + await call('wincode_find_code_symbol', { query: 'Save', kind: 'method' }); + sample.warmQueryMs = performance.now() - start; + start = performance.now(); + await client.close(); + sample.clientCloseMs = performance.now() - start; + sample.survivors = observedSurvivors(sample.warm.processes); + assert.equal(sample.survivors.length, 0); + } finally { await client.close(); } + } + report.startupProfiles = Array.from({ length: 3 }, startupProfile); + report.limitations.push('Startup profiles use source ToolRouter/local-text in isolated Node processes; counts cover Node calls, not native Host or kernel I/O. Timed method wrappers add measurement overhead.'); + report.success = true; +} catch (error) { report.error = String(error); process.exitCode = 1; } +finally { + report.finishedAt = new Date().toISOString(); + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify({ success: report.success, error: report.error, report: path.join(root, 'report.json'), + samples: report.samples.map(({ index, connectThroughHelloMs, coldQueryMs, warmQueryMs }) => ({ index, connectThroughHelloMs, coldQueryMs, warmQueryMs })) })); +} diff --git a/scripts/owner-death/scenarios.mjs b/scripts/owner-death/scenarios.mjs new file mode 100644 index 0000000..e0322e1 --- /dev/null +++ b/scripts/owner-death/scenarios.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { spawn } from 'node:child_process'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { ownedProcesses, observedSurvivors, terminateObserved } from '../lib/owned-processes.mjs'; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function waitFor(test, message) { + const deadline = Date.now() + 15000; + while (!(await test())) { if (Date.now() > deadline) throw new Error(message); await sleep(25); } +} +async function clean(processes, report, role = 'owner-tree') { + for (const old of [...processes].reverse()) { + try { if (terminateObserved(old)) report.cleanup.push({ pid: old.ProcessId, forced: true, role }); } + catch (error) { report.cleanup.push({ pid: old.ProcessId, error: String(error), role }); } + } +} + +export async function verifyDesktopOwner({ root, repo, toolchain, report }) { + const marker = path.join(root, 'uia-entered'); + const fixture = path.join(repo, 'tests/fixtures/wpf-ui-review/bin/Release/net10.0-windows/win-x64/publish/wpf-ui-review.exe'); + const target = spawn(fixture, ['--background-fixture', '--auto-close=60000'], { + cwd: root, env: { ...toolchain.env, WINCODE_TEST_OWNER_UI_MARKER: marker }, windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = '', observed = [], targetProcesses = [], pending; + target.stdout.on('data', data => { stdout = (stdout + data.toString()).slice(-8192); }); + target.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-8192); }); + let spawnError; + target.on('error', error => { spawnError = error; }); + const client = new Client({ name: 'owner-death-desktop', version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(repo, 'dist/index.js'), '--workspace', root], + cwd: root, env: toolchain.env, stderr: 'pipe' }); + try { + await waitFor(() => { if (spawnError) throw spawnError; return /READY\s+(\d+)\s+(0x[0-9a-fA-F]+)/.test(stdout); }, `WPF readiness missing: ${stderr}`); + const match = stdout.match(/READY\s+(\d+)\s+(0x[0-9a-fA-F]+)/); + targetProcesses = ownedProcesses(target.pid); + await client.connect(transport); + transport.stderr?.on('data', () => {}); + await fs.writeFile(marker + '.armed', 'controlled UIA access'); + pending = client.callTool({ name: 'wincode_ui_inspect', arguments: { + pid: Number(match[1]), hwnd: match[2], capture: 'none', backgroundOnly: true, maxNodes: 10, timeoutMs: 30000, + } }, { timeout: 40000 }).then(value => ({ value }), error => ({ error: String(error) })); + await waitFor(() => fs.stat(marker).catch(() => null), 'The target UIA provider was not entered'); + observed = ownedProcesses(transport.pid); + assert.ok(observed.some(item => item.CommandLine?.includes('WinCode.UIA.Host')), 'Active UIA Helper missing'); + const started = Date.now(); + process.kill(transport.pid, 'SIGKILL'); + await sleep(8000); + const survivors = observedSurvivors(observed); + const targetStillAlive = observedSurvivors(targetProcesses).some(item => item.ProcessId === target.pid); + report.scenarios.push({ name: 'Gateway dies while the actual UIA provider is blocked', elapsedMs: Date.now() - started, + processes: observed, survivors, targetStillAlive, success: survivors.length === 0 && targetStillAlive }); + assert.equal(survivors.length, 0, 'Owned UIA Helper survived'); + assert.equal(targetStillAlive, true, 'Owner cleanup must preserve the target application'); + } finally { + await clean(observed, report); + await client.close().catch(error => report.cleanup.push({ client: String(error) })); + await pending; + // 夹具是验收目标,不属于 Gateway 后代;仅在验收记录完成后单独关闭它。 + if (!targetProcesses.length && target.pid) targetProcesses = ownedProcesses(target.pid); + await clean(targetProcesses, report, 'target-fixture-after-acceptance'); + } +} + +/** 通过实际 RepomixAdapter 调用生成的 Node CLI;不安装 Repomix,不改变真实配置。 */ +export async function auditRepomixOwner({ root, repo, toolchain, report }) { + const marker = path.join(root, 'pack-entered'); + const cli = path.join(root, 'controlled-cli.mjs'); + await fs.writeFile(cli, `import fs from 'node:fs'; if (process.argv.includes('--version')) { console.log('1.0.0'); } +else { fs.writeFileSync(${JSON.stringify(marker)}, String(process.pid)); setInterval(() => {}, 1000); }\n`); + await fs.writeFile(path.join(root, 'input.ts'), 'export const marker = 1;'); + const source = name => JSON.stringify(pathToFileURL(path.join(repo, `src/${name}.ts`)).href); + const bootstrap = `import { RepomixAdapter } from ${source('Adapters/RepomixAdapter')}; +import { getDefaultConfig } from ${source('Core/Config')}; import { CacheManager } from ${source('Core/Cache')}; +const config = getDefaultConfig(${JSON.stringify(root)}); config.adapters.repomix.useCli = true; +config.adapters.repomix.customCliPath = ${JSON.stringify(cli)}; config.timeouts.repomixPackMs = 60000; +const adapter = new RepomixAdapter(config, new CacheManager(config.cacheDir)); await adapter.initialize(); +await adapter.packWorkspace({ include: ['input.ts'] });`; + const owner = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', bootstrap], { + cwd: repo, env: toolchain.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = '', observed = []; + owner.stdout.resume(); owner.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-8192); }); + try { + await waitFor(() => fs.stat(marker).catch(() => null), `Controlled Repomix did not start: ${stderr}`); + observed = ownedProcesses(owner.pid); + assert.ok(observed.some(item => item.CommandLine?.includes(cli)), 'Controlled Node CLI missing'); + process.kill(owner.pid, 'SIGKILL'); + await sleep(8000); + const survivors = observedSurvivors(observed); + report.scenarios.push({ name: 'Actual RepomixAdapter owner dies during controlled CLI work', processes: observed, + survivors, success: survivors.length === 0, scope: 'Controlled Node CLI, not installed Repomix or full Gateway' }); + assert.equal(survivors.length, 0, 'Repomix owner-death protection is not supplied by the native Helper guard'); + } finally { + if (!observed.length) observed = ownedProcesses(owner.pid); + await clean(observed, report); + } +} diff --git a/scripts/publish-native.mjs b/scripts/publish-native.mjs new file mode 100644 index 0000000..af7245a --- /dev/null +++ b/scripts/publish-native.mjs @@ -0,0 +1,21 @@ +/** Canonical native Release publish: bind source inputs and the complete output at build time. */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { collectNativeInputs, nativeComponents, sealNativeBuild } from './delivery-manifest.mjs'; +import { resolveDotnet, runDotnet } from './lib/dotnet.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const component = process.argv[2]; +if (process.argv.length !== 3 || !Object.hasOwn(nativeComponents, component)) throw new Error('Usage: node scripts/publish-native.mjs host|codeHost|tray (locked restore must already be complete)'); +const spec = nativeComponents[component]; +// 删除的只是已知发布目录内的旧回执;发布失败时不能留下旧的成功证明。 +await fs.rm(path.join(root, spec.output, 'native-build-manifest.json'), { force: true }); +const inputs = await collectNativeInputs(root, component); +const sdk = resolveDotnet(root); +const output = runDotnet(sdk, ['publish', `${spec.project}/${path.basename(spec.project)}.csproj`, '-c', 'Release', + ...(component === 'host' ? ['-r', 'win-x64'] : []), '--no-self-contained', '--no-restore', + '-p:ContinuousIntegrationBuild=true', `-p:PathMap=${root}=/_/WinCode`], root, 180000); +process.stdout.write(output); +await sealNativeBuild(root, component, inputs); +console.log(`[native-build] ${component}: source and published artifacts recorded`); diff --git a/scripts/verify-manual-release.ts b/scripts/verify-manual-release.ts new file mode 100644 index 0000000..c4f73a4 --- /dev/null +++ b/scripts/verify-manual-release.ts @@ -0,0 +1,99 @@ +/** Ten real Roslyn release/reload cycles in generated projects; does not touch the active Codex connection. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { getDefaultConfig, WINCODE_VERSION } from '../src/Core/Config.js'; +import { CodeQueryError, type FindSymbolsResult, type FindReferencesResult } from '../src/Core/CodeQueries.js'; + +const repo = path.resolve(import.meta.dirname, '..'); +const { resolveDotnet, runDotnet } = await import(pathToFileURL(path.join(repo, 'scripts/lib/dotnet.mjs')).href); +const { ownedProcesses, observedSurvivors, terminateObserved } = await import(pathToFileURL(path.join(repo, 'scripts/lib/owned-processes.mjs')).href); +const toolchain = resolveDotnet(repo); +const parent = path.join(repo, 'test-tmp/manual-release'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const report: any = { version: WINCODE_VERSION, root, success: false, cycles: [], scenarios: [], observedProcesses: [], + limitations: ['Generated small C# project, source Router and actual published Roslyn Host on this Windows 11 computer. Working sets include shared pages and do not equal uniquely reclaimed RAM.'] }; +let router: ToolRouter | undefined; +const project = 'net10.0false'; +function memory(pids: number[]) { + assert.ok(pids.every(pid => Number.isSafeInteger(pid) && pid > 0)); + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', `@(${pids.join(',')}) | ForEach-Object { $p = Get-Process -Id $_ -ErrorAction SilentlyContinue; if ($p) { try { [PSCustomObject]@{pid=$p.Id;workingSetBytes=$p.WorkingSet64;privateBytes=$p.PrivateMemorySize64;cpuSeconds=$p.TotalProcessorTime.TotalSeconds} } finally {$p.Dispose()} } } | ConvertTo-Json -Compress`], + { encoding: 'utf8', windowsHide: true, timeout: 10000 }); + assert.equal(result.status, 0, result.stderr); + const value = JSON.parse(result.stdout || '[]'); return Array.isArray(value) ? value : [value]; +} +try { + for (const name of ['A', 'B']) { + const workspace = path.join(root, name); await fs.mkdir(workspace); + await fs.writeFile(path.join(workspace, 'App.csproj'), project); + await fs.writeFile(path.join(workspace, 'Api.cs'), `namespace ${name}; public class Api { public static void Save() {} } public class Use { public void Run() { Api.Save(); } }`); + runDotnet(toolchain, ['restore', path.join(workspace, 'App.csproj'), '--ignore-failed-sources', '--nologo'], repo, 60000); + } + const config = getDefaultConfig(path.join(root, 'A')); + config.cacheDir = path.join(root, 'cache'); + config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', targetFramework: 'net10.0', + hostPath: path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll'), dotnetPath: toolchain.dotnet, loadTimeoutMs: 15000, queryTimeoutMs: 10000 }; + router = new ToolRouter(config); await router.initialize(); + const resourcesBefore = router.resources.list().length; + const watchBefore = (router as any).watch.getStatus(); + const namespace = router.cache.currentNamespace; + await router.cache.set('manual-release-marker', { retained: true }); + assert.equal(router.getMemoryControlStatus().roslynLoaded, false); + const snapshots = new Set(); + for (let cycle = 1; cycle <= 10; cycle++) { + const started = performance.now(); + const result: FindSymbolsResult = await router.findCodeSymbols('Save', 'method'); + const coldMs = performance.now() - started; + assert.equal(result.source, 'roslyn'); assert.equal(result.symbols.length, 1); + const location = result.symbols[0].location!; + assert.ok(location); assert.ok(!snapshots.has(location.snapshotId)); snapshots.add(location.snapshotId); + const warmStart = performance.now(); await router.findCodeSymbols('Save', 'method'); + const warmMs = performance.now() - warmStart; + const references: FindReferencesResult = await router.findCodeReferences('Save', undefined, undefined, location); + assert.equal(references.totalReferences, 1); + const pid: number = (router.roslyn as any).client.child.pid; + const owned: any[] = ownedProcesses(pid); assert.ok(owned.length >= 2, 'Actual Code Host and BuildHost must be observed'); + report.observedProcesses.push(...owned); + const before = memory([process.pid, ...owned.map((p: any) => p.ProcessId)]); + const releaseStart = performance.now(); + assert.equal((await router.releaseRoslynMemory()).status, 'released'); + const releaseMs = performance.now() - releaseStart; + assert.equal(router.getMemoryControlStatus().roslynLoaded, false); + assert.equal(router.getMemoryControlStatus().automaticRelease, false); + assert.equal(router.resources.childProcessCount(), 0); + assert.deepEqual(observedSurvivors(owned), []); + assert.equal((await router.releaseRoslynMemory()).status, 'already-cold'); + await assert.rejects(router.findCodeReferences('Save', undefined, undefined, location), (error: unknown) => error instanceof CodeQueryError && error.errorCode === 'SNAPSHOT_STALE'); + assert.equal(router.resources.childProcessCount(), 0, 'Stale location must not start a Host'); + assert.equal(router.resources.list().length, resourcesBefore); + assert.equal(router.cache.currentNamespace, namespace); + assert.deepEqual(await router.cache.get('manual-release-marker'), { retained: true }); + assert.deepEqual((router as any).watch.getStatus(), watchBefore); + report.cycles.push({ cycle, coldMs, warmMs, releaseMs, before, after: memory([process.pid]), resourceCount: router.resources.list().length, survivors: [] }); + console.log(`[manual-release] ${cycle}/10 cold=${Math.round(coldMs)}ms warm=${Math.round(warmMs)}ms release=${Math.round(releaseMs)}ms`); + } + await fs.writeFile(path.join(root, 'A/Added.cs'), 'namespace A; public class AddedWhileCold {}'); + const changed = await router.findCodeSymbols('AddedWhileCold', 'class'); assert.equal(changed.symbols.length, 1); + await router.releaseRoslynMemory(); + report.scenarios.push('Ten cycles: re-created snapshots, correct references, actual Host/BuildHost exit, stale locations rejected without warming, stable resource count, preserved cache and watcher'); + await router.openWorkspace(path.join(root, 'B')); + assert.equal((await router.findCodeSymbols('Save', 'method')).symbols[0].signature, 'B.Api.Save()'); + await router.releaseRoslynMemory(); + await router.openWorkspace(path.join(root, 'A')); + assert.equal((await router.findCodeSymbols('Save', 'method')).symbols[0].signature, 'A.Api.Save()'); + await router.releaseRoslynMemory(); + report.scenarios.push('Editing while cold is seen on the next search; release and A to B to A workspace switching remain reusable'); + report.success = true; +} catch (error) { report.error = error instanceof Error ? error.stack : String(error); process.exitCode = 1; } +finally { + try { await router?.dispose(); } catch (error) { report.cleanupError = String(error); report.success = false; process.exitCode = 1; } + const survivors = observedSurvivors(report.observedProcesses); + report.survivors = survivors; + if (survivors.length) { report.success = false; process.exitCode = 1; for (const item of survivors) terminateObserved(item); } + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(`[manual-release] ${report.success ? 'passed' : 'failed'}: ${path.join(root, 'report.json')}`); +} diff --git a/scripts/verify-multi-agent.mjs b/scripts/verify-multi-agent.mjs new file mode 100644 index 0000000..a78eaa1 --- /dev/null +++ b/scripts/verify-multi-agent.mjs @@ -0,0 +1,269 @@ +/** Bounded multi-client diagnosis using production stdio and disposable C# projects. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { resolveDotnet, runDotnet } from './lib/dotnet.mjs'; +import { ownedProcesses, observedSurvivors, terminateObserved } from './lib/owned-processes.mjs'; + +const repo = path.resolve(import.meta.dirname, '..'); +const sdk = resolveDotnet(repo); +const parent = path.join(repo, 'test-tmp/multi-agent'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const report = { root, success: false, scenarios: [], findings: [], timings: [], samples: [], observed: [], warnings: [], + limitations: ['Three fresh SDK clients model separate software processes; not the active Codex connection or a real third-party client integration.', + 'Shared-instance agents are multiplexed through one supported stdio client; stdio itself does not accept multiple client connections.', + 'Generated small projects and bounded bursts; no unlimited-load, long-term leak, concurrent source editing or native UI automation proof.'] }; +const clients = []; +let auxiliaryTray; +report.mode = process.argv.includes('--boundaries-only') ? 'boundaries-only' : 'full'; +let stage = 'setup'; +const warningEmitters = []; +process.on('warning', warning => { + const record = { stage, name: warning.name, message: warning.message, stack: warning.stack, + event: warning.type, count: warning.count }; + report.warnings.push(record); + if (warning.emitter) warningEmitters.push({ emitter: warning.emitter, record }); +}); +const remember = items => { for (const item of items) if (!report.observed.some(p => p.ProcessId === item.ProcessId && p.CreationDate === item.CreationDate)) report.observed.push(item); }; +async function call(c, name, args = {}, options = {}) { + const start = performance.now(); + const response = await c.client.callTool({ name, arguments: args }, { timeout: 60000, ...options }); + const data = JSON.parse(response.content[0].text); + report.timings.push({ stage, client: c.name, tool: name, ms: performance.now() - start, errorCode: data.errorCode }); + return { error: response.isError === true, data }; +} +async function ok(c, name, args = {}) { + const r = await call(c, name, args); assert.equal(r.error, false, JSON.stringify(r.data)); return r.data; +} +async function search(c, tag = c.tag) { + const data = await ok(c, 'wincode_find_code_symbol', { query: 'Save', kind: 'method' }); + assert.equal(data.source, 'roslyn'); assert.equal(data.symbols.length, 1); + assert.equal(data.symbols[0].signature, `${tag}.Api.Save(int)`); + return data.symbols[0]; +} +async function references(c, target, expected) { + const data = await ok(c, 'wincode_find_references', { symbolName: 'Save', symbolLocation: target.location }); + assert.equal(data.totalReferences, expected); return data; +} +async function sample(label) { + const values = await Promise.all(clients.filter(c => !c.closed).map(async c => { + const h = await ok(c, 'wincode_hello_world'); + return { name: c.name, pid: c.transport.pid, instanceId: h.runtime.instanceId, health: h.health }; + })); + report.samples.push({ label, values }); +} +async function scenario(name, work) { + stage = name; + console.log(`[multi-agent] ${name}`); + const detail = await work(); report.scenarios.push({ name, passed: true, ...detail }); + for (const { emitter, record } of warningEmitters) record.listenersAfterStage = emitter.listenerCount(record.event); + await fs.writeFile(path.join(root, 'progress.json'), JSON.stringify(report, null, 2)); +} +try { + if (report.mode === 'full') { + const host = path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll'); + for (const tag of ['A', 'B']) { + const workspace = path.join(root, tag); await fs.mkdir(workspace); + await fs.writeFile(path.join(workspace, 'NuGet.Config'), ''); + await fs.writeFile(path.join(workspace, 'App.csproj'), 'net10.0false'); + await fs.writeFile(path.join(workspace, 'Api.cs'), `namespace ${tag}; public class Api { public static void Save(int x) {} } public class Use { public void Run() { Api.Save(1); ${tag === 'B' ? 'Api.Save(2);' : ''} } }`); + await fs.writeFile(path.join(workspace, `only-${tag}.txt`), tag); + runDotnet(sdk, ['restore', path.join(workspace, 'App.csproj'), '--nologo'], repo, 60000); + } + const config = path.join(root, 'roslyn.json'); + await fs.writeFile(config, JSON.stringify({ enabled: true, allowProjectEvaluation: true, project: 'App.csproj', + configuration: 'Debug', targetFramework: 'net10.0', dotnetPath: sdk.dotnet, hostPath: host, loadTimeoutMs: 15000, queryTimeoutMs: 10000 })); + for (const [name, tag] of [['software-one-A', 'A'], ['software-two-B', 'B'], ['software-three-A', 'A']]) { + const c = { name, tag, stderr: '', client: new Client({ name, version: '1' }) }; + c.transport = new StdioClientTransport({ command: process.execPath, + args: ['--trace-warnings', path.join(repo, 'dist/index.js'), '--workspace', path.join(root, tag), '--roslyn-config', config], cwd: root, env: sdk.env, stderr: 'pipe' }); + clients.push(c); await c.client.connect(c.transport); + c.transport.stderr?.on('data', chunk => { c.stderr = (c.stderr + chunk).slice(-16384); }); + } + const [a, b, a2] = clients; + await scenario('three independent processes: parallel cold load and exact references', async () => { + await sample('cold'); + const targets = await Promise.all(clients.map(c => search(c))); + assert.equal(new Set(targets.map(t => t.location.snapshotId)).size, 3); + await Promise.all(clients.map((c, i) => references(c, targets[i], c.tag === 'A' ? 1 : 2))); + clients.forEach((c, i) => { c.target = targets[i]; c.tree = ownedProcesses(c.transport.pid); remember(c.tree); }); + await sample('warm'); + assert.equal(new Set(report.samples.at(-1).values.map(v => v.instanceId)).size, 3); + }); + await scenario('cross-instance snapshot rejection, including two processes on the same project', async () => { + const codes = []; + for (const c of [b, a2]) { + const result = await call(c, 'wincode_find_references', { symbolName: 'Save', symbolLocation: a.target.location }); + assert.equal(result.error, true); assert.equal(result.data.errorCode, 'SNAPSHOT_STALE'); codes.push(result.data.errorCode); + assert.equal((await search(c)).location.snapshotId, c.target.location.snapshotId); + } + return { codes }; + }); + await scenario('96 interleaved exact reference requests across three processes', async () => { + await Promise.all(Array.from({ length: 32 }, () => Promise.all(clients.map(c => references(c, c.target, c.tag === 'A' ? 1 : 2))))); + await sample('after-96'); + for (const c of clients) assert.equal((await search(c)).location.snapshotId, c.target.location.snapshotId); + }); + await scenario('128 queued semantic searches in one process with independent sibling traffic', async () => { + const work = Promise.all(Array.from({ length: 128 }, () => search(a))); + const health = await ok(a, 'wincode_hello_world'); + await Promise.all([work, search(b), search(a2)]); + await sample('after-128'); + return { sampledInFlight: health.health.inFlightRequests }; + }); + await scenario('64-request burst with 16 cancellations preserves sibling work and warm snapshots', async () => { + const controls = Array.from({ length: 64 }, () => new AbortController()); + // Attach rejection handlers before issuing cancellation to avoid harness-level unhandled promises. + const pending = controls.map((ctl, i) => call(a, 'wincode_find_code_symbol', { query: 'Save', kind: 'method' }, { signal: ctl.signal }) + .then(r => ({ index: i, response: r }), e => ({ index: i, rejected: String(e) }))); + controls.forEach((ctl, i) => { if (i % 4 === 0) ctl.abort(); }); + const values = await Promise.all(pending); + for (const v of values.filter(v => v.index % 4 !== 0)) { + assert.ok(v.response && !v.response.error, JSON.stringify(v)); + assert.equal(v.response.data.symbols[0].signature, 'A.Api.Save(int)'); + } + for (const c of clients) assert.equal((await search(c)).location.snapshotId, c.target.location.snapshotId); + return { requestedCancellations: 16, rejected: values.filter(v => v.rejected).length, responses: values.filter(v => v.response).length }; + }); + await scenario('shared-instance interleaving: A opens A, B opens B, A queries by name', async () => { + await ok(a, 'workspace_open', { path: path.join(root, 'A') }); + const before = await search(a); + await ok(a, 'workspace_open', { path: path.join(root, 'B') }); + const after = await ok(a, 'wincode_find_code_symbol', { query: 'Save', kind: 'method' }); + assert.equal(after.symbols[0].signature, 'B.Api.Save(int)'); + const stale = await call(a, 'wincode_find_references', { symbolName: 'Save', symbolLocation: before.location }); + assert.equal(stale.error, true); assert.equal(stale.data.errorCode, 'SNAPSHOT_STALE'); + const directory = await ok(a, 'wincode_list_directory', { path: '.' }); + assert.ok(JSON.stringify(directory).includes('only-B.txt')); + assert.ok(!JSON.stringify(directory).includes('only-A.txt')); + assert.equal((await search(a2)).location.snapshotId, a2.target.location.snapshotId); + report.findings.push({ id: 'shared-workspace-context', observed: true, expectedAgentProject: 'A', actualSignature: after.symbols[0].signature, + 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 () => { + 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); + 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, + 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.' }); + }); + await scenario('closing one client leaves the other processes and snapshots usable', async () => { + await a.client.close(); a.closed = true; + for (const c of [b, a2]) { + assert.equal((await search(c)).location.snapshotId, c.target.location.snapshotId); + await references(c, c.target, c.tag === 'A' ? 1 : 2); + } + await sample('one-client-closed'); + }); + } + await scenario('installed SDK transport rejects an oversized unfinished frame and closes the channel', async () => { + const input = new PassThrough(), output = new PassThrough(); + const transport = new StdioServerTransport(input, output); + const errors = [], messages = []; let closed = false; + transport.onerror = e => errors.push(String(e)); transport.onmessage = m => messages.push(m); + transport.onclose = () => { closed = true; }; + await transport.start(); + try { + // 10 MiB + 64 KiB, streamed in bounded chunks; isolates framing from application and actual clients. + const chunk = Buffer.alloc(65536, 120); + for (let i = 0; i < 161; i++) if (!input.write(chunk)) await once(input, 'drain'); + input.write('\n' + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(errors.length, 1); assert.match(errors[0], /maximum size of 10485760 bytes/); + assert.equal(messages.length, 0); assert.equal(closed, true); + return { layer: 'installed SDK transport with in-memory streams', submittedBytes: 161 * chunk.length, closed, errors }; + } finally { await transport.close(); input.destroy(); output.destroy(); } + }); + await scenario('native Tray accepts eight registrations, rejects the ninth, and recovers a freed slot', async () => { + const folder = path.join(root, 'tray-capacity'); await fs.mkdir(folder); + const tray = spawn(path.join(repo, 'tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe'), ['--workflow-test', folder], + { cwd: repo, env: sdk.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + auxiliaryTray = tray; + const exit = once(tray, 'exit'), sockets = []; + let stderr = ''; + tray.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); + const start = await new Promise((resolve, reject) => { + let text = ''; const timer = setTimeout(() => reject(new Error('Capacity Tray startup timeout')), 10000); + tray.stdout.on('data', chunk => { text += chunk; if (text.includes('\n')) { clearTimeout(timer); try { resolve(JSON.parse(text.split('\n')[0])); } catch (e) { reject(e); } } }); + exit.then(([code]) => { clearTimeout(timer); reject(new Error(`Capacity Tray exited: ${code}: ${stderr}`)); }, reject); + }); + remember(ownedProcesses(tray.pid)); + async function peer(show = false) { + const id = randomUUID(); + const socket = net.createConnection(`\\\\.\\pipe\\${start.pipeName}`); sockets.push(socket); + const ack = await new Promise((resolve, reject) => { + let text = ''; const timer = setTimeout(() => { socket.destroy(); reject(new Error('Capacity handshake timeout')); }, 5000); + socket.on('error', e => { clearTimeout(timer); reject(e); }); + socket.on('connect', () => socket.write(JSON.stringify(show ? { v: 1, type: 'show' } : { + v: 1, type: 'register', instanceId: id, pid: process.pid, version: '0.14.0', buildId: 'capacity-fixture', + status: { workspace: folder, provider: 'local-text', state: 'idle', automaticRelease: false, roslynLoaded: false }, + }) + '\n')); + socket.on('data', chunk => { + text += chunk; + let line; + while ((line = text.indexOf('\n')) >= 0) { + const value = JSON.parse(text.slice(0, line)); text = text.slice(line + 1); + if (value.type === 'request') socket.write(JSON.stringify({ v: 1, type: 'response', id: value.id, instanceId: id, + result: { workspace: folder, provider: 'local-text', state: 'idle', automaticRelease: false, roslynLoaded: false } }) + '\n'); + else { clearTimeout(timer); resolve(value); } + } + }); + }); + return { socket, ack }; + } + try { + const peers = []; + for (let i = 0; i < 8; i++) { const p = await peer(); assert.equal(p.ack.type, 'register-accepted'); peers.push(p); } + const ninth = await peer(); assert.equal(ninth.ack.type, 'register-rejected'); assert.match(ninth.ack.message, /八/); ninth.socket.destroy(); + const show = await peer(true); assert.equal(show.ack.type, 'show-accepted'); show.socket.destroy(); + const closed = once(peers[0].socket, 'close'); peers[0].socket.destroy(); await closed; + // Barrier: a file-driven native refresh sees the disconnected peer before re-registering. + await fs.writeFile(path.join(folder, 'command-1.json.tmp'), JSON.stringify({ operation: 'refresh' })); + await fs.rename(path.join(folder, 'command-1.json.tmp'), path.join(folder, 'command-1.json')); + const deadline = Date.now() + 12000; + while (true) { + try { await fs.readFile(path.join(folder, 'reply-1.json')); break; } + catch (e) { if (e.code !== 'ENOENT' || Date.now() > deadline) throw e; await new Promise(resolve => setTimeout(resolve, 25)); } + } + const replacement = await peer(); assert.equal(replacement.ack.type, 'register-accepted'); + return { layer: 'real native secured pipe; nine channels from one test process, not nine actual MCPs', accepted: 8, + ninth: ninth.ack, fullCapacityShow: show.ack.type, replacement: replacement.ack.type }; + } finally { + sockets.forEach(s => s.destroy()); + if (tray.exitCode == null && tray.signalCode == null) tray.kill(); await exit; + } + }); + report.success = true; +} catch (error) { report.error = error.stack ?? String(error); process.exitCode = 1; } +finally { + if (auxiliaryTray && auxiliaryTray.exitCode == null && auxiliaryTray.signalCode == null) { + const exit = once(auxiliaryTray, 'exit'); auxiliaryTray.kill(); await exit; + } + for (const c of clients) { + if (c.transport.pid) remember(ownedProcesses(c.transport.pid)); + if (!c.closed) await c.client.close().catch(error => { report.cleanupError = String(error); report.success = false; process.exitCode = 1; }); + } + report.survivors = observedSurvivors(report.observed); + if (report.survivors.length) { report.success = false; process.exitCode = 1; report.survivors.forEach(terminateObserved); } + report.stderr = clients.map(c => ({ name: c.name, text: c.stderr })); + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(`[multi-agent] ${report.success ? 'diagnosis completed' : 'failed'}: ${path.join(root, 'report.json')}`); +} diff --git a/scripts/verify-owner-death.mjs b/scripts/verify-owner-death.mjs new file mode 100644 index 0000000..f520264 --- /dev/null +++ b/scripts/verify-owner-death.mjs @@ -0,0 +1,98 @@ +/** 用生成项目复现 Gateway 在 Roslyn 初始加载期间死亡;只终止本测试拥有且身份仍匹配的进程。 */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { resolveDotnet, runDotnet } from './lib/dotnet.mjs'; +import { ownedProcesses, observedSurvivors, terminateObserved } from './lib/owned-processes.mjs'; +import { verifyDesktopOwner, auditRepomixOwner } from './owner-death/scenarios.mjs'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +if (process.argv.length > 3 || process.argv.slice(2).some(arg => !['--desktop', '--repomix'].includes(arg))) + throw new Error('Usage: node scripts/verify-owner-death.mjs [--desktop|--repomix]'); +const toolchain = resolveDotnet(repo); +const parent = path.join(repo, 'test-tmp/owner-death'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const report = { version: JSON.parse(await fs.readFile(path.join(repo, 'package.json'), 'utf8')).version, + root, startedAt: new Date().toISOString(), scenarios: [], cleanup: [], success: false }; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const xml = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); +let client, transport, observed = [], pending; +try { + if (process.argv.includes('--desktop')) { + await verifyDesktopOwner({ root, repo, toolchain, report }); + report.success = true; + } else if (process.argv.includes('--repomix')) { + await auditRepomixOwner({ root, repo, toolchain, report }); + report.success = true; + } else { + const workspace = path.join(root, 'workspace'); + await fs.mkdir(path.join(workspace, '.cache'), { recursive: true }); + const marker = path.join(workspace, '.cache', 'loading.marker'); + const blocker = path.join(root, 'blocker.mjs'); + await fs.writeFile(blocker, "import fs from 'node:fs'; fs.writeFileSync(process.argv[2], String(process.pid)); setInterval(() => {}, 1000);\n"); + const project = 'net10.0false'; + const projectPath = path.join(workspace, 'App.csproj'); + await fs.writeFile(projectPath, project); + await fs.writeFile(path.join(workspace, 'Api.cs'), 'public class Api { public void Save() {} }'); + await fs.writeFile(path.join(workspace, 'NuGet.Config'), ''); + runDotnet(toolchain, ['restore', projectPath, '--nologo'], workspace); + const blocking = ``; + await fs.writeFile(projectPath, project.replace('', blocking + '')); + const config = path.join(root, 'roslyn.json'); + const host = path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll'); + await fs.writeFile(config, JSON.stringify({ enabled: true, allowProjectEvaluation: true, project: 'App.csproj', + configuration: 'Debug', targetFramework: 'net10.0', dotnetPath: toolchain.dotnet, hostPath: host, + loadTimeoutMs: 30000, queryTimeoutMs: 10000 })); + client = new Client({ name: 'owner-death-acceptance', version: '1' }); + transport = new StdioClientTransport({ command: process.execPath, + args: [path.join(repo, 'dist/index.js'), '--workspace', workspace, '--roslyn-config', config], + cwd: root, env: toolchain.env, stderr: 'pipe' }); + transport.stderr?.on('data', () => {}); + await client.connect(transport); + transport.stderr?.on('data', () => {}); + pending = client.callTool({ name: 'wincode_find_code_symbol', arguments: { query: 'Api' } }, { timeout: 40000 }) + .then(value => ({ value }), error => ({ error: String(error) })); + const deadline = Date.now() + 20000; + while (!(await fs.stat(marker).catch(() => null))) { + if (Date.now() > deadline) throw new Error('MSBuild loading marker missing.'); + await sleep(50); + } + observed = ownedProcesses(transport.pid); + assert.ok(observed.some(item => item.CommandLine?.includes(host)), 'Code Host not observed'); + assert.ok(observed.some(item => item.CommandLine?.includes('BuildHost')), 'BuildHost not observed'); + assert.ok(observed.some(item => item.CommandLine?.includes(blocker)), 'Blocking child not observed'); + report.processes = observed; + const gatewayPid = transport.pid; + const started = Date.now(); + process.kill(gatewayPid, 'SIGKILL'); // 单个受控 Gateway;不能用 /T 代替被测清理。 + await sleep(8000); + const survivors = observedSurvivors(observed); + report.scenarios.push({ name: 'Gateway dies during initial MSBuild load', elapsedMs: Date.now() - started, + observedCount: observed.length, survivors, success: survivors.length === 0 }); + assert.equal(survivors.length, 0, `Owned descendants survived Gateway death: ${survivors.map(p => p.ProcessId).join(', ')}`); + report.success = true; + } +} catch (error) { + report.error = String(error); + process.exitCode = 1; +} finally { + // 清理不计入验收成功,PID/创建时间不匹配时绝不终止复用该 PID 的进程。 + for (const old of [...observed].reverse()) { + try { if (terminateObserved(old)) report.cleanup.push({ pid: old.ProcessId, forced: true }); } + catch (error) { report.cleanup.push({ pid: old.ProcessId, error: String(error) }); } + } + await client?.close().catch(error => { report.cleanup.push({ client: String(error) }); }); + await pending; + if (report.cleanup.some(item => item.error || item.client)) { + report.success = false; + report.error ??= 'Test cleanup failed; inspect cleanup records.'; + process.exitCode = 1; + } + report.finishedAt = new Date().toISOString(); + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify({ success: report.success, error: report.error, report: path.join(root, 'report.json') })); +} diff --git a/scripts/verify-tray-workflow.mjs b/scripts/verify-tray-workflow.mjs new file mode 100644 index 0000000..675cc84 --- /dev/null +++ b/scripts/verify-tray-workflow.mjs @@ -0,0 +1,156 @@ +/** Real compiled MCP components + secured native Tray + real Roslyn, isolated from the active Codex connection. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { resolveDotnet, runDotnet } from './lib/dotnet.mjs'; +import { ownedProcesses, observedSurvivors, terminateObserved } from './lib/owned-processes.mjs'; + +const repo = path.resolve(import.meta.dirname, '..'); +const sdk = resolveDotnet(repo); +const parent = path.join(repo, 'test-tmp/tray-workflow'); await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const report = { root, success: false, scenarios: [], samples: [], timings: [], observed: [], + limitations: ['Compiled Gateway components with an isolated stdio SDK client and test-only pipe namespace; not the active Codex connection. Generated small C# projects; short residency observation is not a long-term leak proof. Working set sums may count shared pages more than once.'] }; +let tray, exited, sequence = 0, stderr = ''; +const clients = []; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function command(operation, instanceId = '') { + const n = ++sequence, file = path.join(root, `command-${n}.json`); + await fs.writeFile(file + '.tmp', JSON.stringify({ operation, instanceId })); await fs.rename(file + '.tmp', file); + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { return JSON.parse(await fs.readFile(path.join(root, `reply-${n}.json`), 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (tray.exitCode != null) throw new Error(`Tray exited: ${await fs.readFile(path.join(root, 'workflow-error.txt'), 'utf8').catch(() => stderr)}`); + await delay(25); + } + throw new Error(`Tray ${operation} deadline exceeded`); +} +async function call(client, name, args = {}, expectedError = false) { + const started = performance.now(); + const response = await client.callTool({ name, arguments: args }, { timeout: 60000 }); + const data = JSON.parse(response.content[0].text); + assert.equal(response.isError === true, expectedError, JSON.stringify(data)); + report.timings.push({ tool: name, ms: performance.now() - started }); + return data; +} +const search = client => call(client, 'wincode_find_code_symbol', { query: 'Save', kind: 'method' }); +function remember(items) { for (const item of items) if (!report.observed.some(old => old.ProcessId === item.ProcessId && old.CreationDate === item.CreationDate)) report.observed.push(item); } +function processSample(label) { + const ids = [...new Set([...(tray.exitCode == null ? [tray.pid] : []), ...clients.flatMap(({ transport }) => transport.pid ? ownedProcesses(transport.pid).map(p => p.ProcessId) : [])])]; + assert.ok(ids.every(id => Number.isSafeInteger(id) && id > 0)); + const output = spawnSync('powershell.exe', ['-NoProfile', '-Command', + `@(${ids.join(',')}) | ForEach-Object { $p=Get-Process -Id $_ -ErrorAction SilentlyContinue; if($p){ try{[PSCustomObject]@{pid=$p.Id;workingSetBytes=$p.WorkingSet64;privateBytes=$p.PrivateMemorySize64;cpuSeconds=$p.TotalProcessorTime.TotalSeconds;handles=$p.HandleCount}}finally{$p.Dispose()}} } | ConvertTo-Json -Compress`], + { encoding: 'utf8', timeout: 10000, windowsHide: true }); + assert.equal(output.status, 0, output.stderr); + const value = JSON.parse(output.stdout || '[]'); report.samples.push({ label, at: new Date().toISOString(), processes: Array.isArray(value) ? value : [value] }); +} +try { + const host = path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll'); + for (const tag of ['A', 'B']) { + const workspace = path.join(root, tag); await fs.mkdir(workspace); + await fs.writeFile(path.join(workspace, 'NuGet.Config'), ''); + await fs.writeFile(path.join(workspace, 'App.csproj'), 'net10.0false'); + await fs.writeFile(path.join(workspace, 'Api.cs'), `namespace ${tag}; public class Api { public static void Save(int x) {} public static void Save(string x) {} } public class Use { public void Run() { Api.Save(1); Api.Save("x"); } }`); + runDotnet(sdk, ['restore', path.join(workspace, 'App.csproj'), '--nologo'], repo, 60000); + } + tray = spawn(path.join(repo, 'tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe'), ['--workflow-test', root], + { cwd: repo, env: sdk.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + exited = once(tray, 'exit'); + tray.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); + const ready = await new Promise((resolve, reject) => { + let text = ''; const timer = setTimeout(() => reject(new Error('Tray startup timed out')), 10000); + tray.stdout.on('data', chunk => { text += chunk; if (text.includes('\n')) { clearTimeout(timer); try { resolve(JSON.parse(text.split('\n')[0])); } catch (error) { reject(error); } } }); + exited.then(([code]) => { clearTimeout(timer); reject(new Error(`Tray exited at startup: ${code}`)); }, reject); + }); + assert.equal(ready.pid, tray.pid); remember(ownedProcesses(tray.pid)); + for (const tag of ['A', 'B']) { + const workspace = path.join(root, tag); + // Only test wiring differs from main: same compiled Router, MCP server and TrayClient, no lifecycle mocks. + const bootstrap = ` + import { ToolRouter } from './dist/Core/ToolRouter.js'; + import { getDefaultConfig } from './dist/Core/Config.js'; + import { WinCodeMcpServer } from './dist/Gateway/McpServer.js'; + import { TrayClient } from './dist/Gateway/TrayClient.js'; + const config=getDefaultConfig(${JSON.stringify(workspace)}); config.cacheDir=${JSON.stringify(path.join(workspace, '.cache'))}; + config.adapters.roslyn={enabled:true,allowProjectEvaluation:true,project:'App.csproj',configuration:'Debug',targetFramework:'net10.0',dotnetPath:${JSON.stringify(sdk.dotnet)},hostPath:${JSON.stringify(host)},loadTimeoutMs:15000,queryTimeoutMs:10000}; + const router=new ToolRouter(config),server=new WinCodeMcpServer(router); await server.start(); + let stopping=false; const control=new TrayClient(${JSON.stringify(ready.pipeName)},router,()=>void stop()); + async function stop(){if(stopping)return;stopping=true;control.dispose();await server.stop();} + server.onDisconnect=()=>void stop();process.stdin.once('end',()=>void stop());control.start(); + `; + const client = new Client({ name: `tray-workflow-${tag}`, version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, args: ['--input-type=module', '--eval', bootstrap], cwd: repo, env: sdk.env, stderr: 'pipe' }); + clients.push({ client, transport }); await client.connect(transport); + transport.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); + } + const [a, b] = clients; + for (const c of clients) { + const hello = await call(c.client, 'wincode_hello_world'); c.id = hello.runtime.instanceId; + assert.equal(hello.codeProvider, 'roslyn'); assert.equal(hello.health.roslyn.processAlive, false); + c.build = hello.runtime.build; assert.equal(c.build.status, 'verified'); + } + let state = await command('refresh'); assert.equal(state.peers.filter(p => p.connected).length, 2); + processSample('tray-open-both-cold'); + const symbolsA = await search(a.client), symbolsB = await search(b.client); + const target = symbolsA.symbols.find(s => s.signature === 'A.Api.Save(int)'); assert.ok(target?.location); + const snapshotB = symbolsB.symbols[0].location.snapshotId; + const refs = await call(a.client, 'wincode_find_references', { symbolName: 'Save', symbolLocation: target.location }); assert.equal(refs.totalReferences, 1); + const beforeA = ownedProcesses(a.transport.pid), beforeB = ownedProcesses(b.transport.pid); remember(beforeA); remember(beforeB); + const codeA = beforeA.find(p => p.ParentProcessId === a.transport.pid && p.CommandLine?.includes(host)); + assert.ok(codeA, 'The actual owned Code Host must be present'); + // MSBuild's temporary evaluation host may already be gone; track the observed Code Host subtree by identity. + const hostsA = ownedProcesses(codeA.ProcessId); remember(hostsA); processSample('tray-open-both-warm'); + // Actual semantic MCP requests remain queued/running; no fake busy counters or timer-triggered release. + const burst = Promise.all(Array.from({ length: 24 }, () => search(a.client))); + const during = await command('release', a.id); + assert.match(during.result, /工作/); await burst; + assert.equal((await search(a.client)).symbols[0].location.snapshotId, target.location.snapshotId); + assert.equal(observedSurvivors(hostsA).length, hostsA.length); + report.scenarios.push('Actual concurrent semantic MCP work rejects the native Settings release command; draining work does not trigger a deferred release'); + await command('hide'); + // Cross the UI observation lifetime twice while keeping genuine queries and both Host identities warm. + for (let sample = 0; sample < 7; sample++) { + await delay(10000); + assert.equal((await search(a.client)).symbols[0].location.snapshotId, target.location.snapshotId); + assert.equal((await search(b.client)).symbols[0].location.snapshotId, snapshotB); + assert.equal(observedSurvivors(hostsA).length, hostsA.length); + processSample(`hidden-warm-${sample + 1}`); + console.log(`[tray-workflow] warm residency ${sample + 1}/7: snapshots and Host identities retained`); + } + report.scenarios.push('Hidden Tray and repeated semantic work retain the same snapshots and actual Host identities across seven spaced observations; no automatic stop/start'); + await command('show'); + state = await command('release', a.id); assert.match(state.result, /已释放/); + assert.equal(state.peers.find(p => p.instanceId === a.id).status.roslynLoaded, false); + assert.equal(state.peers.find(p => p.instanceId === b.id).status.snapshotId, snapshotB); + assert.deepEqual(observedSurvivors(hostsA), []); processSample('manual-release-a-only'); + state = await command('release', a.id); assert.match(state.result, /无需释放/); + const stale = await call(a.client, 'wincode_find_references', { symbolName: 'Save', symbolLocation: target.location }, true); + assert.equal(stale.errorCode, 'SNAPSHOT_STALE'); + assert.equal((await call(a.client, 'wincode_hello_world')).health.roslyn.processAlive, false); + const fresh = (await search(a.client)).symbols.find(s => s.signature === 'A.Api.Save(int)'); + assert.notEqual(fresh.location.snapshotId, target.location.snapshotId); + assert.equal((await call(a.client, 'wincode_find_references', { symbolName: 'Save', symbolLocation: fresh.location })).totalReferences, 1); + remember(ownedProcesses(a.transport.pid)); + assert.equal((await search(b.client)).symbols[0].location.snapshotId, snapshotB); + report.scenarios.push('Native Settings releases only A; old locations fail without warming; explicit new search reloads once and restores precise references; B remains warm'); + await command('exit'); assert.equal((await exited)[0], 0); + assert.equal((await search(a.client)).symbols[0].location.snapshotId, fresh.location.snapshotId); + assert.equal((await search(b.client)).symbols[0].location.snapshotId, snapshotB); + processSample('tray-exited-both-warm'); + report.scenarios.push('Exiting native Tray preserves both live MCP connections, semantic snapshots and successful queries'); + report.success = true; +} catch (error) { report.error = error.stack ?? String(error); process.exitCode = 1; } +finally { + for (const { client } of clients) await client.close().catch(error => { report.cleanupError = String(error); report.success = false; process.exitCode = 1; }); + if (tray && tray.exitCode == null && tray.signalCode == null) { tray.kill(); await exited.catch(() => {}); } + report.survivors = observedSurvivors(report.observed); + if (report.survivors.length) { report.success = false; process.exitCode = 1; for (const p of report.survivors) terminateObserved(p); } + report.stderr = stderr; + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(`[tray-workflow] ${report.success ? 'passed' : 'failed'}: ${path.join(root, 'report.json')}`); +} diff --git a/scripts/verify-tray.mjs b/scripts/verify-tray.mjs new file mode 100644 index 0000000..4b8a949 --- /dev/null +++ b/scripts/verify-tray.mjs @@ -0,0 +1,123 @@ +/** Isolated real WinForms + secured Named Pipe + real stdio MCP, simulated Roslyn lifetimes. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, execFileSync } from 'node:child_process'; +import net from 'node:net'; +import { once } from 'node:events'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { resolveDotnet } from './lib/dotnet.mjs'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { env } = resolveDotnet(repo); +const parent = path.join(repo, 'test-tmp/tray'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const children = []; +let tray, stderr = ''; +const report = { root, success: false, scenarios: [] }; +async function verifyProductionEntry() { + const exe = path.join(repo, 'tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe'); + const endpoint = JSON.parse(execFileSync(exe, ['--endpoint'], { env, encoding: 'utf8', windowsHide: true, timeout: 3000 })).pipeName; + const server = net.createServer(); + let socket, client; + try { + // Use the real opt-in CLI. Bind fails rather than taking over an existing user's Tray. + server.listen('\\\\.\\pipe\\' + endpoint); await once(server, 'listening'); + const connection = once(server, 'connection'); + client = new Client({ name: 'tray-production-entry', version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(repo, 'dist/index.js'), '--workspace', root, '--tray'], cwd: repo, env, stderr: 'pipe' }); + transport.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); + await client.connect(transport); + const hello = await client.callTool({ name: 'wincode_hello_world', arguments: {} }); assert.notEqual(hello.isError, true); + [socket] = await connection; + let input = '', frames = [], waiters = []; + socket.on('data', chunk => { input += chunk; let newline; while ((newline = input.indexOf('\n')) >= 0) { + const value = JSON.parse(input.slice(0, newline)); input = input.slice(newline + 1); + const next = waiters.shift(); if (next) next(value); else frames.push(value); + } }); + const read = () => frames.length ? Promise.resolve(frames.shift()) : new Promise(resolve => waiters.push(resolve)); + const registration = await read(); assert.equal(registration.pid, transport.pid); + const closed = once(socket, 'close'); + socket.write(JSON.stringify({ v: 1, type: 'request', id: 'stop', instanceId: registration.instanceId, operation: 'shutdown' }) + '\n'); + assert.equal((await read()).result.status, 'accepted'); await closed; + const deadline = Date.now() + 5000; + while (transport.pid != null && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(transport.pid, null, 'Production Gateway must exit after the acknowledged shutdown'); + report.scenarios.push('Actual dist/index.js --tray registers after MCP readiness and exits through its existing shutdown path on a targeted acknowledged request'); + } finally { + await client?.close().catch(() => {}); socket?.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} +try { + tray = spawn(path.join(repo, 'tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe'), ['--self-test', root], + { cwd: repo, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const exited = new Promise((resolve, reject) => { tray.once('error', reject); tray.once('exit', resolve); }); + tray.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); + const ready = await new Promise((resolve, reject) => { + let input = ''; + const timer = setTimeout(() => reject(new Error('Tray ready timeout')), 8000); + tray.stdout.on('data', chunk => { + input += chunk; + if (input.includes('\n')) { clearTimeout(timer); try { resolve(JSON.parse(input.split('\n')[0])); } catch (error) { reject(error); } } + }); + exited.then(code => { clearTimeout(timer); reject(new Error(`Tray exited before ready: ${code} ${stderr}`)); }, reject); + }); + report.tray = ready; + assert.equal(ready.pid, tray.pid); + report.trayMemoryAtOpen = JSON.parse(execFileSync('powershell.exe', ['-NoProfile', '-Command', + `$p=Get-Process -Id ${tray.pid}; try { [PSCustomObject]@{workingSetBytes=$p.WorkingSet64;privateBytes=$p.PrivateMemorySize64;cpuSeconds=$p.TotalProcessorTime.TotalSeconds} | ConvertTo-Json -Compress } finally {$p.Dispose()}`], + { encoding: 'utf8', windowsHide: true, timeout: 8000 })); + for (const mode of ['idle', 'busy']) { + const workspace = path.join(root, mode); await fs.mkdir(workspace); + // Fixed test bootstrap; no public CLI argument can inject a backend into production. + const bootstrap = ` + import { ToolRouter } from './src/Core/ToolRouter.ts'; + import { getDefaultConfig } from './src/Core/Config.ts'; + import { RoslynAdapter } from './src/Adapters/RoslynAdapter.ts'; + import { WinCodeMcpServer } from './src/Gateway/McpServer.ts'; + import { TrayClient } from './src/Gateway/TrayClient.ts'; + const config=getDefaultConfig(${JSON.stringify(workspace)}); + config.adapters.roslyn={enabled:true,allowProjectEvaluation:true,project:'App.csproj',configuration:'Debug',targetFramework:'net10.0',dotnetPath:process.execPath,hostPath:${JSON.stringify(path.join(root, 'fixture.dll'))}}; + const router=new ToolRouter(config), server=new WinCodeMcpServer(router); + await server.start(); + router.roslyn.client={active:true,close:async()=>{}}; router.roslyn.snapshot='a'.repeat(32); + const busy=${mode === 'busy'}; if(busy) router.beginRequest(); + let stopping=false; + const control=new TrayClient(${JSON.stringify(ready.pipeName)},router,()=>stop()); + async function stop(){if(stopping)return;stopping=true;control.dispose();if(busy)router.endRequest();await server.stop();} + server.onDisconnect=()=>void stop(); process.stdin.once('end',()=>void stop()); control.start(); + `; + const client = new Client({ name: `tray-${mode}`, version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, args: ['--import', 'tsx', '--input-type=module', '--eval', bootstrap], cwd: repo, env, stderr: 'pipe' }); + children.push({ client, transport }); + transport.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); + await client.connect(transport); + } + const timeout = setTimeout(() => { report.uiTimedOut = true; tray.kill(); }, 35000); + let code; try { code = await exited; } finally { clearTimeout(timeout); } + report.uiExitCode = code; + report.ui = JSON.parse(await fs.readFile(path.join(root, 'tray-ui-report.json'), 'utf8').catch(error => { + throw new Error(`Native UI report unavailable (exit=${code}, timeout=${report.uiTimedOut === true}): ${stderr}`, { cause: error }); + })); + assert.equal(code, 0, JSON.stringify(report.ui)); assert.equal(report.ui.success, true); + for (const [index, { client }] of children.entries()) { + const response = await client.callTool({ name: 'wincode_hello_world', arguments: {} }); + assert.notEqual(response.isError, true); + const hello = JSON.parse(response.content[0].text); + assert.equal(hello.health.roslyn.processAlive, index === 1); + } + report.scenarios.push('Both independent stdio MCP connections remain usable after Tray exits; only the selected fixture was released'); + await verifyProductionEntry(); + report.success = true; +} catch (error) { report.error = String(error.stack ?? error); process.exitCode = 1; } +finally { + for (const { client } of children) await client.close().catch(() => {}); + if (tray && tray.exitCode === null && tray.signalCode === null) tray.kill(); + report.stderr = stderr; + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(`[tray] ${report.success ? 'passed' : 'failed'}: ${path.join(root, 'report.json')}`); +} diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index 5150aa2..bfa91ac 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -5,7 +5,7 @@ description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面 # WinCode -源码契约:0.13.2(连接退出与限时资源清理,见对应手册);手册修订:2026-09-09。外部 Serena 入口与旧 source 已退役,不能将此版本号当作当前连接已升级。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行。以当前连接实际 Schema 为准。 +源码契约:0.14.0(新增可选托盘/手动 Roslyn 释放,自动释放关闭,见对应手册);手册修订:2026-09-10。外部 Serena 入口与旧 source 已退役,不能将此版本号当作当前连接已升级。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行。以当前连接实际 Schema 为准。 默认以本地文本模式启动,source=local-text;明确配置 Roslyn 后,才通过 WinCode.Code.Host 提供 C# 语义证据。搜索返回的 location 可作为引用、影响分析和重构工具的 symbolLocation;不要猜测定位、复用旧快照或使用已退役的 namePath。内部 reload/cancel 不是 MCP 工具字段。配置与验收边界见代码手册。 diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index 97c6fc2..dd698b8 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -60,3 +60,19 @@ INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项 ## 连接关闭(0.13.2) 正式入口在 stdin EOF/close、传输关闭或管道错误时停止接收请求,取消初始化和活动操作,并按统一 8 秒预算清理自有资源。关闭失败保留非零退出结果;缓存写入不能无限延迟退出。不能把此行为等同于 Codex 当前连接已更新,也不能承诺强杀 Gateway 时所有后代均受同一个 Windows Job 保护。升级后刷新对应 MCP 连接,不必一概重启整个 Codex。 + +## 原生 Helper 所属进程退出(0.13.3) + +Gateway 通过子进程私有环境传递所属 PID;两个 .NET Host 在项目求值或 UI 读取前核验真实祖先链和创建时间,并持有该进程对象句柄。直接运行 Host 时使用实际父进程。无法核验时拒绝开始重操作,不按 Codex/Claude 等客户端名称扫描。所属进程死亡后先取消,独立线程宽限两秒后仅硬退出当前 Helper;Code Host 的既有 Job 处理其覆盖的后代,UIA 不终止目标窗口应用。UIA 的 stdin EOF 仍表示请求输入结束。此机制不检测仍存活但卡死的 Gateway,也不自动覆盖独立 Repomix 子进程。开发启动包装链最多八层;不要手工设置任意 WINCODE_OWNER_PID 绕过核验。 + +## UIA 首用与被动状态(0.13.4) + +启动只核验 UIA 平台、配置和发布文件,不运行健康探测进程。文件存在且尚无运行观察时,hello 的 flaui.available=null、source=unknown,不能理解为已安装 Host 不可用。首次 UI 请求直接执行请求;成功响应更新已知 Host 观察,失败保留 lastAdapterError,即使健康状态仍 unknown。需要主动验证时使用现有 wincode_diagnose_project;hello 不补发探测。缺失文件仍可在启动被报告,文件恢复后显式 UI 请求重新解析发布路径,不必重新初始化整个 Gateway。 + +## 手动 Roslyn 释放与可选托盘(0.14.0) + +自动释放关闭,本版不创建 idle timer。用户可按 README 手动启动独立 Tray,并给希望管理的 Gateway 启动参数添加 --tray 后刷新连接。托盘只管理已注册的实例,不扫描/终止外部客户端或目标应用;MCP 仍为原有 15 个工具,没有让 Agent 自动代替用户释放的管理工具。默认不启用托盘连接、不设置自启动。 + +手动释放遇到业务在途、语义排队/收尾、工作区切换或恢复门时拒绝,不自动延后执行。被接纳的释放完成后,新 MCP 请求继续;旧 symbolLocation 返回 SNAPSHOT_STALE,显式重新搜索再取得当前定位。保留 Gateway、watcher、缓存与最后诊断。清理失败进入 restart_gateway 恢复门,不能靠反复点击清除错误。local-text 没有可释放的 Roslyn。 + +概览只读内存快照,不为状态启动 Host 或枚举缓存目录。状态是注册/打开/刷新时的观察,不代表 Agent 在两次请求之间已结束整个任务。失联/超时表示未知,控制命令不自动重放;退出 Tray 不停止 Gateway。首版最多八个同用户/会话实例,按同权限级别使用;版本必须匹配。需要停止时由用户确认“停止此实例”,走该 Gateway 既有关闭路径,客户端可能重新建立新实例。 diff --git a/src/Adapters/FlaUiAdapter.ts b/src/Adapters/FlaUiAdapter.ts index 137d22d..155f81a 100644 --- a/src/Adapters/FlaUiAdapter.ts +++ b/src/Adapters/FlaUiAdapter.ts @@ -46,7 +46,9 @@ export class FlaUiAdapter implements IAdapter { } async initialize(): Promise { - const health = await this.checkHealth(); + // 启动只核对平台、配置和发布文件;首次 UI 请求自行执行,显式 diagnose 才发 health。 + // 通过文件检查不能证明原生 Host 可运行,因此不写入成功健康观察。 + const health = await this.probeHealth(undefined, true); if (!health.available && health.lastError) { this.lastError = health.lastError; } @@ -107,7 +109,7 @@ export class FlaUiAdapter implements IAdapter { health: this.healthCache ? { ...this.healthCache.value, lastError: this.lastError ?? this.healthCache.value.lastError } : null }; } - private async probeHealth(timeoutMs?: number): Promise { + private async probeHealth(timeoutMs?: number, validateOnly = false): Promise { if (this.shuttingDown) return { available: false, source: 'unavailable', details: 'FlaUI is shutting down.' }; if (timeoutMs === undefined && this.healthCache && Date.now() - this.healthCache.at < 5_000) { return this.healthCache.value; @@ -175,6 +177,8 @@ export class FlaUiAdapter implements IAdapter { return val; } + if (validateOnly) return { available: false, source: 'unavailable', details: 'UIA runtime has not been probed.' }; + const probeTimeout = timeoutMs ?? this.config.timeouts?.healthProbeMs ?? 3_000; const probeAbortController = new AbortController(); const probeTimer = setTimeout(() => probeAbortController.abort(), probeTimeout); @@ -182,6 +186,9 @@ export class FlaUiAdapter implements IAdapter { try { return await this.mutex.runExclusive(async () => { + // 并发首次诊断排队后复查;显式 timeout 仍表示调用方要求新探测。 + if (timeoutMs === undefined && this.healthCache && Date.now() - this.healthCache.at < 5_000) + return this.healthCache.value; const res = await this.executeHost( { schemaVersion: '1.0', @@ -251,6 +258,10 @@ export class FlaUiAdapter implements IAdapter { result.errorCode === UiErrorCodes.CANCELLED ? 'cancelled' : 'error', message: `${result.errorCode}: ${result.errorMessage ?? 'Inspection failed.'}`.slice(0, 500), recoverable: true, }; + if (result.success && result.hostIdentity) { + this.healthCache = { at: Date.now(), value: { available: true, source: 'installed', + version: result.hostIdentity.version, details: 'UIA Host responded to the requested UI operation.' } }; + } return result; } @@ -522,6 +533,7 @@ export class FlaUiAdapter implements IAdapter { childProc = spawn(host.command, host.args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, WINCODE_OWNER_PID: String(process.pid) }, cwd, }); diff --git a/src/Adapters/RoslynAdapter.ts b/src/Adapters/RoslynAdapter.ts index 4a4b13c..a951367 100644 --- a/src/Adapters/RoslynAdapter.ts +++ b/src/Adapters/RoslynAdapter.ts @@ -23,6 +23,7 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { private restartRequired = false; private cleanupFailure?: GatewayRestartRequiredError; private disposed = false; + private operations = 0; private health?: AdapterHealth; private observedAt: string | null = null; private lastError?: AdapterLastError; @@ -216,6 +217,7 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { /** 持有串行占用直到协议失败的进程清理结束;清理失败优先传播给 Router 的 E1 恢复门。 */ private perform(operation: OperationContext | undefined, work: () => Promise): Promise { + this.operations++; return this.lock.runExclusive(async () => { try { return await work(); } catch (error) { @@ -225,7 +227,7 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { await this.stopClient(true); throw error; } - }, operation?.signal); + }, operation?.signal).finally(() => { this.operations--; }); } /** 名称搜索不读语义缓存;过期时要求下一次显式搜索重载,不重放本次失败请求。 */ @@ -301,6 +303,21 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { }); } + /** 手动释放复用当前生命周期锁;忙碌时不排队等待任务结束后突然释放。 */ + async releaseWarmState(canRelease: () => boolean = () => true): Promise<'released' | 'already-cold' | 'busy'> { + if (this.operations || !canRelease()) return 'busy'; + return this.lock.runExclusive(async () => { + if (this.operations || !canRelease()) return 'busy'; + if (this.cleanupFailure) throw this.cleanupFailure; + if (this.disposed) throw new CodeQueryError('HOST_UNAVAILABLE', 'Roslyn adapter is disposed.'); + if (!this.client) return 'already-cold'; + await this.stopClient(); + // 保留故障/配置;不绕过已有 restartRequired,也不把最终 dispose 用作休眠。 + this.observe(false, 'Roslyn memory released manually; an explicit symbol search loads a new snapshot.'); + return 'released'; + }); + } + /** 关闭后的失败不可通过清空引用隐藏;后续 dispose/reset 必须重抛同一恢复要求。 */ private async stopClient(force = false): Promise { this.snapshot = undefined; diff --git a/src/Adapters/RoslynHostClient.ts b/src/Adapters/RoslynHostClient.ts index a5a83e8..b70623c 100644 --- a/src/Adapters/RoslynHostClient.ts +++ b/src/Adapters/RoslynHostClient.ts @@ -33,7 +33,7 @@ export class RoslynHostClient { void this.ready.catch(() => {}); this.child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false, // 只固定本子进程的 SDK 安装根;避免继承的 DOTNET_HOST_PATH 将 MSBuild 引向另一套 dotnet。 - env: { ...process.env, DOTNET_HOST_PATH: command, DOTNET_ROOT: path.dirname(command) }, + env: { ...process.env, DOTNET_HOST_PATH: command, DOTNET_ROOT: path.dirname(command), WINCODE_OWNER_PID: String(process.pid) }, detached: process.platform !== 'win32' }); resources.registerProcess('roslyn', this.child); this.child.stdout.setEncoding('utf8'); diff --git a/src/Core/Config.ts b/src/Core/Config.ts index a35bcb4..3798245 100644 --- a/src/Core/Config.ts +++ b/src/Core/Config.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -export const WINCODE_VERSION = '0.13.2'; +export const WINCODE_VERSION = '0.14.0'; /** * Bounded waits for every external process/RPC. None of these may be Infinity. diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index 020e7a9..576abdb 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -29,6 +29,12 @@ export interface WorkspaceRecovery { recoveryAction: 'workspace_open' | 'restart_gateway'; } +export interface MemoryReleaseResult { + success: boolean; + status: 'released' | 'already-cold' | 'not-configured' | 'busy' | 'shutting-down' | 'recovery-required'; + message: string; +} + export class WorkspaceRecoveryRequiredError extends Error { constructor(readonly recovery: WorkspaceRecovery) { super(recovery.recoveryAction === 'restart_gateway' @@ -103,6 +109,8 @@ export class ToolRouter { private watchRegistered = false; private workspaceRecovery: WorkspaceRecovery | null = null; private readonly codeOperations = new Set(); + private pendingWorkspaceChanges = 0; + private releasing: Promise | null = null; private async runCode(signal: AbortSignal | undefined, work: (operation: OperationContext) => Promise): Promise { const controller = new AbortController(); @@ -113,7 +121,7 @@ export class ToolRouter { this.codeOperations.add(controller); signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted || this.shuttingDown) cancel(); - try { checkOperation(operation); const result = await work(operation); checkOperation(operation); return result; } + try { await this.releasing; checkOperation(operation); const result = await work(operation); checkOperation(operation); return result; } catch (error) { // 查询清理失败同样会留下不可信的自有 Host 状态;按 E1 阻止后续业务,不能只返回一次错误。 if (this.roslyn && error instanceof GatewayRestartRequiredError) { @@ -175,6 +183,47 @@ export class ToolRouter { return this.workspaceRecovery ? { ...this.workspaceRecovery } : null; } + /** 托盘只读取内存中的已知事实;不能为了展示状态启动 Host 或枚举磁盘缓存。 */ + getMemoryControlStatus() { + const roslyn = this.roslyn?.getKnownHealth(); + return { version: WINCODE_VERSION, workspace: this.config.workspaceRoot, + provider: this.roslyn ? 'roslyn' as const : 'local-text' as const, + automaticRelease: false as const, state: this.shuttingDown ? 'shutting-down' : this.workspaceRecovery ? 'recovery-required' : + this.releasing ? 'releasing' : this.inFlight || this.codeOperations.size || this.pendingWorkspaceChanges ? 'busy' : 'idle', + roslynLoaded: roslyn?.processAlive ?? false, snapshotId: roslyn?.snapshotId ?? null, + activeRequests: this.inFlight, managedChildProcesses: this.resources.childProcessCount(), nodeRssBytes: process.memoryUsage().rss, + lastError: this.workspaceRecovery?.message ?? roslyn?.health?.lastError?.message ?? null }; + } + + /** 本地设置入口;默认无自动释放定时器,调用者不能指定 PID 或改变工作区/求值配置。 */ + releaseRoslynMemory(): Promise { + const reply = (status: MemoryReleaseResult['status'], message: string): MemoryReleaseResult => + ({ success: ['released', 'already-cold', 'not-configured'].includes(status), status, message }); + if (this.shuttingDown) return Promise.resolve(reply('shutting-down', '实例正在退出。')); + if (this.workspaceRecovery) return Promise.resolve(reply('recovery-required', '请先按已有恢复提示处理实例故障。')); + if (this.releasing || this.inFlight || this.codeOperations.size || this.pendingWorkspaceChanges) + return Promise.resolve(reply('busy', 'Agent 正在工作或收尾,本次未释放;任务结束后可再次点击。')); + if (!this.roslyn) return Promise.resolve(reply('not-configured', '此实例使用本地文本,没有 Roslyn 内存需要释放。')); + const adapter = this.roslyn; + this.releasing = this.workspaceLock.runExclusive(async () => { + const canRelease = () => !this.shuttingDown && !this.workspaceRecovery && !this.inFlight && !this.codeOperations.size && !this.pendingWorkspaceChanges; + if (!canRelease()) return reply('busy', '已有新任务或工作区切换,本次未释放。'); + try { + const status = await adapter.releaseWarmState(canRelease); + return reply(status, status === 'released' ? '已释放 Roslyn 内存;下次搜索会重新加载。先前的符号定位需要重新搜索。' : + status === 'already-cold' ? 'Roslyn 尚未加载,无需释放。' : 'Agent 正在工作或收尾,本次未释放。'); + } catch (error) { + if (error instanceof GatewayRestartRequiredError) { + this.workspaceRecovery = { activeWorkspace: this.config.workspaceRoot, attemptedWorkspace: this.config.workspaceRoot, + phase: 'roslyn-manual-release', message: error.message.slice(0, 1024), recoveryAction: 'restart_gateway' }; + return reply('recovery-required', 'Roslyn 退出未能确认;请检查自有进程并重启此 Gateway。'); + } + throw error; + } + }).finally(() => { this.releasing = null; }); + return this.releasing; + } + findCodeSymbols(query: string, kind?: string, signal?: AbortSignal) { return this.runCode(signal, operation => this.code.findSymbolsDetailed(query, kind, undefined, operation)); } @@ -223,14 +272,15 @@ export class ToolRouter { async acquireRequestSlot(signal?: AbortSignal, allowDuringRecovery = false): Promise { if (this.shuttingDown) throw new Error('WinCode is shutting down; tool call rejected.'); if (signal?.aborted) throw new AbortError('The tool call was cancelled.'); - while (this.switchingPromise) { + while (this.switchingPromise || this.releasing) { + const barrier = this.switchingPromise ?? this.releasing!; if (!signal) { - await this.switchingPromise; + await barrier; } else { await new Promise((resolve, reject) => { const onAbort = () => reject(new AbortError('The tool call was cancelled.')); signal.addEventListener('abort', onAbort, { once: true }); - this.switchingPromise!.then( + barrier.then( () => { signal.removeEventListener('abort', onAbort); resolve(); @@ -321,6 +371,7 @@ export class ToolRouter { */ async openWorkspace(targetPath: string, options: WorkspaceOpenOptions = {}, signal?: AbortSignal) { signal = signal ? AbortSignal.any([signal, this.shutdownSignal]) : this.shutdownSignal; + this.pendingWorkspaceChanges++; return this.workspaceLock.runExclusive(async () => { if (this.shuttingDown) { throw new Error('WinCode is shutting down; workspace_open rejected.'); @@ -425,7 +476,7 @@ export class ToolRouter { this.resolveSwitching = null; resolve?.(); } - }, signal); + }, signal).finally(() => { this.pendingWorkspaceChanges--; }); } private bindCompositeTools(): void { @@ -453,7 +504,8 @@ export class ToolRouter { const unknown = { available: null, source: 'unknown', details: 'Not probed; use wincode_diagnose_project for an active check.', lastError: undefined }; const textHealth = snapshots.text.health; const repomixHealth = snapshots.repomix.health ?? unknown; - const flauiHealth = snapshots.flaui.health ?? unknown; + const flauiHealth = { ...(snapshots.flaui.health ?? unknown), + lastError: this.flaui.lastError ?? snapshots.flaui.health?.lastError }; const cache = await this.cache.getStats(); const lastAdapterError = this.pickLastError( { error: repomixHealth.lastError, provider: 'repomix' }, @@ -550,6 +602,7 @@ export class ToolRouter { await attempt('shutdown-drain', async () => { await this.initialization?.catch(() => {}); await this.switchingPromise?.catch(() => {}); + await this.releasing?.catch(() => {}); const drained = await this.waitForIdle(Math.max(1, Math.min(3_000, softDeadline - Date.now()))); if (!drained) throw new Error('Requests did not settle before shutdown.'); }, Math.min(3_000, (softDeadline - Date.now()) / 3)); diff --git a/src/Gateway/TrayClient.ts b/src/Gateway/TrayClient.ts new file mode 100644 index 0000000..2f9fea2 --- /dev/null +++ b/src/Gateway/TrayClient.ts @@ -0,0 +1,121 @@ +import net from 'node:net'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RUNTIME_IDENTITY } from '../Core/RuntimeIdentity.js'; +import type { ToolRouter } from '../Core/ToolRouter.js'; + +const maxFrame = 64 * 1024; +const validPipe = (value: unknown): value is string => typeof value === 'string' && + /^WinCode\.Tray\.v1\.S-1-[0-9-]+\.s\d+(?:\.test-[a-f0-9]{32})?$/.test(value) && value.length <= 240; + +/** 只在显式 --tray 时执行一次当前用户/登录会话解析;不启动托盘、不读写真实客户端设置。 */ +export async function resolveTrayEndpoint(signal: AbortSignal): Promise { + if (process.platform !== 'win32') throw new Error('Tray integration requires Windows.'); + const executable = path.resolve(path.dirname(fileURLToPath(import.meta.url)), + '../../tools/WinCode.Tray/bin/Release/net10.0-windows/win-x64/publish/WinCode.Tray.exe'); + const result = await promisify(execFile)(executable, ['--endpoint'], { windowsHide: true, timeout: 3000, maxBuffer: 4096, signal }); + const data = JSON.parse(result.stdout); + if (data.version !== RUNTIME_IDENTITY.build.version || !validPipe(data.pipeName)) throw new Error('Tray endpoint/version mismatch; rebuild the optional Tray.'); + return data.pipeName; +} + +/** 可选本地控制连接。托盘离线不影响 MCP;无自动释放、进程名称扫描或磁盘状态轮询。 */ +export class TrayClient { + private socket?: net.Socket; + private retry?: NodeJS.Timeout; + private disposed = false; + private retryMs = 1000; + private lastWarning = ''; + constructor(private readonly pipeName: string, private readonly router: ToolRouter, + private readonly shutdown: () => void, private readonly warn: (message: string) => void = message => console.error(`[WinCode Tray] ${message}`)) { + if (!validPipe(pipeName)) throw new Error('Invalid local Tray pipe name.'); + } + + start(): void { + if (this.disposed || this.socket || this.retry) return; + this.connect(); + } + + private connect(): void { + if (this.disposed || this.router.isShuttingDown) return; + const socket = net.createConnection(`\\\\.\\pipe\\${this.pipeName}`); + this.socket = socket; + socket.unref(); + let input = Buffer.alloc(0), pending = 0; + const seen = new Set(); + const connecting = setTimeout(() => socket.destroy(new Error('Tray connection timed out')), 2000); + connecting.unref(); + const send = (value: unknown, done?: () => void) => { + if (this.socket !== socket || socket.destroyed) return; + const bytes = Buffer.from(JSON.stringify(value) + '\n'); + if (bytes.length > maxFrame || socket.writableLength + bytes.length > 2 * maxFrame) { socket.destroy(new Error('Tray output budget exceeded')); return; } + socket.write(bytes, error => { if (error) socket.destroy(); else done?.(); }); + }; + const receive = (frame: unknown): Promise => { + if (!frame || typeof frame !== 'object' || Array.isArray(frame)) throw new Error('Invalid Tray frame'); + const value = frame as Record; + if (value.v === 1 && value.instanceId === RUNTIME_IDENTITY.instanceId && + (value.type === 'register-accepted' || value.type === 'register-rejected')) { + if (value.type === 'register-rejected') { + if (typeof value.message !== 'string') throw new Error('Invalid Tray registration rejection'); + throw new Error(`托盘拒绝连接:${value.message.slice(0, 500)};MCP 继续独立运行。`); + } + this.retryMs = 1000; this.lastWarning = ''; + return Promise.resolve(); + } + if (value.v !== 1 || value.type !== 'request' || value.instanceId !== RUNTIME_IDENTITY.instanceId || + typeof value.id !== 'string' || !/^[a-zA-Z0-9-]{1,64}$/.test(value.id) || + !['status', 'releaseRoslyn', 'shutdown'].includes(String(value.operation)) || + Object.keys(value).some(key => !['v', 'type', 'id', 'instanceId', 'operation'].includes(key))) throw new Error('Invalid Tray request'); + if (seen.has(value.id)) throw new Error('Tray command ID was replayed'); + seen.add(value.id); if (seen.size > 64) seen.delete(seen.values().next().value!); + if (++pending > 8) { pending--; throw new Error('Tray request budget exceeded'); } + this.retryMs = 1000; this.lastWarning = ''; + return (async () => { try { + const result = value.operation === 'status' ? this.router.getMemoryControlStatus() : value.operation === 'releaseRoslyn' + ? await this.router.releaseRoslynMemory() : { success: true, status: 'accepted', message: '已接纳停止请求;最终退出需以连接状态确认。' }; + send({ v: 1, type: 'response', id: value.id, instanceId: RUNTIME_IDENTITY.instanceId, result }, + value.operation === 'shutdown' ? () => { setImmediate(this.shutdown); } : undefined); + } finally { pending--; } })(); + }; + socket.on('connect', () => { + clearTimeout(connecting); + send({ v: 1, type: 'register', instanceId: RUNTIME_IDENTITY.instanceId, pid: process.pid, + version: RUNTIME_IDENTITY.build.version, buildId: RUNTIME_IDENTITY.build.buildId, startedAt: RUNTIME_IDENTITY.startedAt, + status: this.router.getMemoryControlStatus() }); + }); + socket.on('data', (chunk: Buffer) => { + // 一次最多接收两帧预算;逐行解析后只保留不足一帧的尾部。 + if (input.length + chunk.length > 2 * maxFrame) { socket.destroy(new Error('Tray input budget exceeded')); return; } + input = Buffer.concat([input, chunk]); + let newline: number; + while ((newline = input.indexOf(10)) >= 0) { + if (newline > maxFrame) { socket.destroy(new Error('Tray frame too large')); return; } + const line = input.subarray(0, newline); input = input.subarray(newline + 1); + try { void receive(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line))).catch(error => socket.destroy(error)); } + catch (error) { socket.destroy(error as Error); return; } + } + if (input.length > maxFrame) socket.destroy(new Error('Tray frame too large')); + }); + socket.on('error', error => { + const message = (error as NodeJS.ErrnoException).code === 'ENOENT' ? '托盘尚未启动;MCP 继续独立运行。' : error.message.slice(0, 500); + if (!this.disposed && message !== this.lastWarning) { this.lastWarning = message; this.warn(message); } + }); + socket.on('close', () => { + clearTimeout(connecting); + if (this.socket === socket) this.socket = undefined; + if (this.disposed || this.router.isShuttingDown) return; + this.retry = setTimeout(() => { this.retry = undefined; this.connect(); }, this.retryMs); + this.retry.unref(); this.retryMs = Math.min(this.retryMs * 2, 60000); + }); + } + + dispose(): void { + this.disposed = true; + if (this.retry) clearTimeout(this.retry); + this.retry = undefined; + this.socket?.destroy(); this.socket = undefined; + } +} diff --git a/src/index.ts b/src/index.ts index 078125a..3953d0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ToolRouter } from './Core/ToolRouter.js'; import { WinCodeMcpServer } from './Gateway/McpServer.js'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { resolveTrayEndpoint, TrayClient } from './Gateway/TrayClient.js'; async function main() { let workspaceRoot = process.cwd(); @@ -40,9 +41,11 @@ async function main() { let shuttingDown = false; let shutdownPromise: Promise | undefined; + let tray: TrayClient | undefined; const shutdown = (signal: string, exitCode = 0): Promise => { if (shutdownPromise) return shutdownPromise; shuttingDown = true; + tray?.dispose(); shutdownPromise = (async () => { console.error(`[WinCode Gateway] ${signal}: shutting down...`); const force = setTimeout(() => { @@ -86,6 +89,14 @@ async function main() { try { await server.start(); + if (args.includes('--tray') && !shuttingDown) { + // 可选界面不可延迟 MCP 就绪或导致 Gateway 退出;解析过程受同一 shutdown signal 约束。 + void resolveTrayEndpoint(router.shutdownSignal).then(pipe => { + if (shuttingDown) return; + tray = new TrayClient(pipe, router, () => { void shutdown('settings requested stop'); }); + tray.start(); + }).catch(error => { if (!shuttingDown) console.error(`[WinCode Tray] ${error.message}; MCP continues without Tray integration.`); }); + } console.error(`[WinCode Gateway] v${WINCODE_VERSION} ready.`); } catch (err) { if (shuttingDown) { await shutdownPromise; return; } diff --git a/tests/delivery-contract.test.ts b/tests/delivery-contract.test.ts index 6b2429e..4c76fb2 100644 --- a/tests/delivery-contract.test.ts +++ b/tests/delivery-contract.test.ts @@ -14,7 +14,7 @@ const version = '1.2.3'; const identity = { version, configuration: 'Release', informationalVersion: version, framework: '.NET fixture' }; const toolchains = { node: 'fixture', dotnet: 'fixture', npm: null }; -async function fixture(run: (root: string, manifest: any) => Promise, includeCodeHost = false) { +async function fixture(run: (root: string, manifest: any) => Promise, includeCodeHost = false, includeTray = false) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-delivery-')); async function write(file: string, content: string) { const target = path.join(root, file); @@ -26,6 +26,7 @@ async function fixture(run: (root: string, manifest: any) => Promise, incl 'package.json': JSON.stringify({ version }), 'package-lock.json': '{}', 'global.json': '{}', 'tsconfig.json': '{}', 'scripts/build.mjs': '// fixture', 'src/Main.ts': 'export const fixture = true;', 'dist/Main.js': 'export const fixture = true;', 'tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj': '', 'tools/WinCode.UIA.Host/packages.lock.json': '{}', + 'tools/WinCode.UIA.Host/Program.cs': '// host', 'tools/Shared/OwnerProcessGuard.cs': '// shared', 'skills/wincode/SKILL.md': 'fixture skill', 'skills/wincode/references/code.md': 'code', 'skills/wincode/references/ui.md': 'ui', 'skills/wincode/references/diagnostics.md': 'diagnostics', })) await write(file, content); @@ -41,10 +42,18 @@ async function fixture(run: (root: string, manifest: any) => Promise, incl await write(`${delivery.codeHostDirectory}/${file}`, 'fixture Code Host bytes; never executed'); } } + if (includeTray) { + await write('tools/WinCode.Tray/WinCode.Tray.csproj', ''); + await write('tools/WinCode.Tray/packages.lock.json', '{}'); + for (const file of ['WinCode.Tray.exe', 'WinCode.Tray.dll', 'WinCode.Tray.deps.json', 'WinCode.Tray.runtimeconfig.json']) + await write(`${delivery.trayDirectory}/${file}`, 'fixture Tray bytes; never executed'); + } const gateway = await build.createBuildManifest(root, await build.collectBuildInputs(root), version); await write('dist/build-manifest.json', JSON.stringify(gateway)); + for (const component of ['host', ...(includeCodeHost ? ['codeHost'] : []), ...(includeTray ? ['tray'] : [])]) + await delivery.sealNativeBuild(root, component, await delivery.collectNativeInputs(root, component)); const contents = await delivery.collectDelivery(root, identity, toolchains, - includeCodeHost ? { ...identity, protocolVersion: 2 } : undefined); + includeCodeHost ? { ...identity, protocolVersion: 2 } : undefined, includeTray ? { ...identity, protocolVersion: 1 } : undefined); await run(root, { formatVersion: 1, contentId: delivery.deliveryId(contents), delivery: contents, revision: null, createdAt: 'first' }); } finally { await fs.rm(root, { recursive: true, force: true }); } } @@ -100,6 +109,33 @@ it('optional Code Host records all runtime files and rejects damaged BuildHost a await assert.rejects(delivery.verifyDelivery(root, manifest), /Missing Code Host sidecar/); }, true)); +for (const file of ['tools/WinCode.UIA.Host/Program.cs', 'tools/Shared/OwnerProcessGuard.cs']) { + it(`native source edits cannot be blessed by regenerating delivery: ${file}`, async () => fixture(async root => { + await fs.appendFile(path.join(root, file), '\n// edited after publish'); + await assert.rejects(delivery.collectDelivery(root, identity, toolchains), /source changed after build/); + })); +} + +it('native inputs include added source and inherited build settings, excluding build outputs', async () => fixture(async root => { + const before = await delivery.collectNativeInputs(root, 'host'); + await fs.mkdir(path.join(root, 'tools/WinCode.UIA.Host/obj'), { recursive: true }); + await fs.writeFile(path.join(root, 'tools/WinCode.UIA.Host/obj/generated.cs'), '// generated'); + assert.deepEqual(await delivery.collectNativeInputs(root, 'host'), before); + await fs.writeFile(path.join(root, 'Directory.Build.targets'), ''); + await assert.rejects(delivery.collectDelivery(root, identity, toolchains), /source changed after build/); + await assert.rejects(delivery.sealNativeBuild(root, 'host', before), /changed during build/); +})); + +it('optional Tray delivery validates version and all required sidecars', async () => fixture(async (root, manifest) => { + assert.equal((await delivery.verifyDelivery(root, manifest)).matched, true); + await assert.rejects(delivery.collectDelivery(root, identity, toolchains, undefined, { ...identity, protocolVersion: 99 }), /Tray version/); + const sidecar = path.join(root, delivery.trayDirectory, 'WinCode.Tray.deps.json'); + await fs.appendFile(sidecar, 'changed'); + await assert.rejects(delivery.verifyDelivery(root, manifest), /changed|incomplete/); + await fs.unlink(sidecar); + await assert.rejects(delivery.verifyDelivery(root, manifest), /Missing Tray sidecar/); +}, false, true)); + it('Code Host rejects added unrecorded dependencies and incompatible release identities', async () => fixture(async (root, manifest) => { await fs.writeFile(path.join(root, delivery.codeHostDirectory, 'extra.dll'), 'extra'); await assert.rejects(delivery.verifyDelivery(root, manifest), /changed|incomplete/); diff --git a/tests/fixtures/owner-guard-check/Program.cs b/tests/fixtures/owner-guard-check/Program.cs new file mode 100644 index 0000000..db9b983 --- /dev/null +++ b/tests/fixtures/owner-guard-check/Program.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using WinCode.Native; + +internal static class Program +{ + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static void GuardCycles(int count) { for (int i = 0; i < count; i++) { using var guard = OwnerProcessGuard.Attach(); } } + [DllImport("kernel32.dll")] private static extern void Sleep(uint duration); + private static void Report(string stage) { using var p = Process.GetCurrentProcess(); Console.WriteLine(JsonSerializer.Serialize(new { + stage, pid = p.Id, created = p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })); Console.Out.Flush(); } + private static int Main(string[] args) + { + try + { + var mode = args.FirstOrDefault() ?? "native-block"; + if (mode == "early-owner-death") { Report("before-attach"); Sleep(3000); } + if (mode == "self-owner") Environment.SetEnvironmentVariable("WINCODE_OWNER_PID", Environment.ProcessId.ToString()); + using var guard = OwnerProcessGuard.Attach(); + Report("attached"); + if (mode == "normal") return 0; + if (mode == "repeat") + { + using var process = Process.GetCurrentProcess(); + // CLR 的 Thread 对象包含由终结器释放的等待句柄;先预热,再比较回收后的稳定值。 + // GC 只在测试中执行,生产 Host 每个进程仅创建一次 owner guard。 + GuardCycles(3); + GC.Collect(); GC.WaitForPendingFinalizers(); + process.Refresh(); var initialHandles = process.HandleCount; + GuardCycles(20); + GC.Collect(); GC.WaitForPendingFinalizers(); + process.Refresh(); + if (process.HandleCount > initialHandles + 2) throw new InvalidOperationException($"Guard handles accumulate: {initialHandles} -> {process.HandleCount}."); + Report("repeat-complete"); return 0; + } + if (mode == "cooperative") { guard!.Token.WaitHandle.WaitOne(); return 0; } + if (mode == "blocked-callback") guard!.Token.Register(() => Sleep(uint.MaxValue)); + Sleep(uint.MaxValue); // 模拟不响应托管取消的原生 UI/MSBuild 调用。 + return 0; + } + catch (Exception error) { Console.Error.WriteLine(error.Message); return 1; } + } +} + diff --git a/tests/fixtures/owner-guard-check/owner-guard-check.csproj b/tests/fixtures/owner-guard-check/owner-guard-check.csproj new file mode 100644 index 0000000..f6c6024 --- /dev/null +++ b/tests/fixtures/owner-guard-check/owner-guard-check.csproj @@ -0,0 +1,8 @@ + + + Exenet10.0 + enableenable + true + + + diff --git a/tests/fixtures/owner-guard-check/packages.lock.json b/tests/fixtures/owner-guard-check/packages.lock.json new file mode 100644 index 0000000..4a91a8c --- /dev/null +++ b/tests/fixtures/owner-guard-check/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/tests/fixtures/wpf-ui-review/MainWindow.xaml.cs b/tests/fixtures/wpf-ui-review/MainWindow.xaml.cs index 5723727..1d3b415 100644 --- a/tests/fixtures/wpf-ui-review/MainWindow.xaml.cs +++ b/tests/fixtures/wpf-ui-review/MainWindow.xaml.cs @@ -12,6 +12,26 @@ namespace wpf_ui_review; /// public partial class MainWindow : Window { + protected override System.Windows.Automation.Peers.AutomationPeer OnCreateAutomationPeer() + { + var marker = Environment.GetEnvironmentVariable("WINCODE_TEST_OWNER_UI_MARKER"); + return marker == null ? base.OnCreateAutomationPeer() : new OwnerDeathPeer(this, marker); + } + + // 仅此隔离夹具启用:实际 UIA 读取进入后提供握手,再模拟不返回的目标提供方。 + private sealed class OwnerDeathPeer(MainWindow window, string marker) : System.Windows.Automation.Peers.WindowAutomationPeer(window) + { + protected override string GetNameCore() + { + if (System.IO.File.Exists(marker + ".armed")) + { + System.IO.File.WriteAllText(marker, Environment.ProcessId.ToString()); + System.Threading.Thread.Sleep(60000); + } + return base.GetNameCore(); + } + } + public System.Windows.Input.ICommand ReviewActionCommand { get; private set; } = null!; [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); diff --git a/tests/manual-release.test.ts b/tests/manual-release.test.ts new file mode 100644 index 0000000..2a092c8 --- /dev/null +++ b/tests/manual-release.test.ts @@ -0,0 +1,118 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { ToolRouter, WorkspaceRecoveryRequiredError } from '../src/Core/ToolRouter.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { GatewayRestartRequiredError, ResourceManager } from '../src/Core/ResourceManager.js'; +import { RoslynAdapter } from '../src/Adapters/RoslynAdapter.js'; + +const deferred = () => { let resolve!: () => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; +function adapterFixture() { + const config = getDefaultConfig(process.cwd()); + config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', targetFramework: 'net10.0', + dotnetPath: process.execPath, hostPath: path.resolve('fixture.dll') }; + const resources = new ResourceManager(); + return { adapter: new RoslynAdapter(config, resources, () => []), resources }; +} + +it('manual release keeps the adapter reusable, invalidates old locations and preserves the last error', async () => { + const { adapter } = adapterFixture(); + const state = adapter as any; + let closed = 0; + state.client = { active: true, close: async () => { closed++; } }; + state.snapshot = 'a'.repeat(32); + state.lastError = { message: 'earlier failure', reason: 'error', at: new Date().toISOString(), recoverable: true }; + assert.equal(await adapter.releaseWarmState(), 'released'); + assert.equal(closed, 1); + assert.equal(state.disposed, false); + assert.equal(adapter.getKnownHealth().snapshotId, null); + assert.equal(adapter.getKnownHealth().health?.lastError?.message, 'earlier failure'); + assert.throws(() => state.validateLocation({ snapshotId: 'a'.repeat(32), project: 'App.csproj', file: 'Api.cs', position: 13 }), /expired/); + assert.equal(await adapter.releaseWarmState(), 'already-cold'); + await adapter.dispose(); +}); + +it('release refuses an active operation including its asynchronous cleanup', async () => { + const { adapter } = adapterFixture(); + const active = deferred(), cleanup = deferred(); + const operation = (adapter as any).perform(undefined, async () => { active.resolve(); await cleanup.promise; }); + await active.promise; + assert.equal(await adapter.releaseWarmState(), 'busy'); + cleanup.resolve(); await operation; + assert.equal(await adapter.releaseWarmState(), 'already-cold'); + await adapter.dispose(); +}); + +it('release checks admission again after waiting for the adapter lock', async () => { + const { adapter } = adapterFixture(); + const blocked = deferred(); + let closed = false, allowed = true; + (adapter as any).client = { active: true, close: async () => { closed = true; } }; + const hold = (adapter as any).lock.runExclusive(() => blocked.promise); + const release = adapter.releaseWarmState(() => allowed); + allowed = false; blocked.resolve(); await hold; + assert.equal(await release, 'busy'); + assert.equal(closed, false); + await adapter.dispose(); +}); + +it('memory status is passive and default policy never creates an automatic release timer', async () => { + const router = new ToolRouter(getDefaultConfig(process.cwd())); + (router.cache as any).getStats = () => { throw new Error('Storage enumeration is forbidden'); }; + try { + assert.equal(router.getMemoryControlStatus().automaticRelease, false); + assert.equal((await router.releaseRoslynMemory()).status, 'not-configured'); + assert.equal(router.resources.childProcessCount(), 0); + } finally { await router.dispose(); } +}); + +it('settings release refuses MCP work and direct code operations without queuing a later release', async () => { + const router = new ToolRouter(getDefaultConfig(process.cwd())); + let releases = 0; + router.roslyn = { releaseWarmState: async () => { releases++; return 'released'; }, dispose: async () => {} } as any; + router.beginRequest(); + assert.equal((await router.releaseRoslynMemory()).status, 'busy'); + router.endRequest(); + const active = deferred(); + const work = (router as any).runCode(undefined, () => active.promise); + assert.equal((await router.releaseRoslynMemory()).status, 'busy'); + active.resolve(); await work; + assert.equal(releases, 0); + await router.dispose(); +}); + +it('an arriving MCP request waits for an accepted manual release and then proceeds', async () => { + const router = new ToolRouter(getDefaultConfig(process.cwd())); + const entered = deferred(), close = deferred(); + router.roslyn = { releaseWarmState: async () => { entered.resolve(); await close.promise; return 'released'; }, dispose: async () => {} } as any; + const release = router.releaseRoslynMemory(); await entered.promise; + let admitted = false; + const request = router.acquireRequestSlot().then(() => { admitted = true; router.endRequest(); }); + await Promise.resolve(); assert.equal(admitted, false); + close.resolve(); assert.equal((await release).status, 'released'); await request; + assert.equal(admitted, true); + await router.dispose(); +}); + +it('a queued workspace switch wins over a settings release', async () => { + const router = new ToolRouter(getDefaultConfig(process.cwd())); + const gate = deferred(), controller = new AbortController(); + const lock = (router as any).workspaceLock.runExclusive(() => gate.promise); + const switching = router.openWorkspace(process.cwd(), {}, controller.signal); + const settled = switching.catch(error => error); + assert.equal((await router.releaseRoslynMemory()).status, 'busy'); + controller.abort(); gate.resolve(); await lock; await settled; + await router.dispose(); +}); + +it('failed manual cleanup enters the sticky recovery gate and cannot be retried as a release', async () => { + const router = new ToolRouter(getDefaultConfig(process.cwd())); + let releases = 0; + router.roslyn = { releaseWarmState: async () => { releases++; throw new GatewayRestartRequiredError([new Error('close failed')], 'Close failed'); }, dispose: async () => {} } as any; + assert.equal((await router.releaseRoslynMemory()).status, 'recovery-required'); + assert.equal(router.workspaceRecoveryState?.recoveryAction, 'restart_gateway'); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + assert.equal((await router.releaseRoslynMemory()).status, 'recovery-required'); + assert.equal(releases, 1); + await router.dispose(); +}); diff --git a/tests/owner-process-guard.test.ts b/tests/owner-process-guard.test.ts new file mode 100644 index 0000000..02d021c --- /dev/null +++ b/tests/owner-process-guard.test.ts @@ -0,0 +1,133 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { promisify } from 'node:util'; +import { killProcessTree, withTimeout } from '../src/Core/ResourceManager.js'; + +const fixture = path.resolve('tests/fixtures/owner-guard-check/bin/Release/net10.0/owner-guard-check.dll'); +const dotnet = process.env.DOTNET_HOST_PATH || path.resolve('.deps/dotnet-10.0.303/dotnet.exe'); +const options = { skip: process.platform !== 'win32', timeout: 30000 }; +const bootstrap = ` +const { spawn } = require('node:child_process'); +const env = { ...process.env, WINCODE_OWNER_PID: process.env.TEST_OWNER_OVERRIDE || String(process.pid) }; +let child; +if (process.env.TEST_WRAPPER === '1') { + env.TEST_WRAPPER = '0'; env.TEST_OWNER_OVERRIDE = env.WINCODE_OWNER_PID; + child = spawn(process.execPath, ['-e', process.env.TEST_BOOTSTRAP], { env, stdio: ['ignore', 1, 2], windowsHide: true, detached: true }); +} else { + child = spawn(process.env.TEST_DOTNET, [process.env.TEST_GUARD, process.env.TEST_MODE], { env, stdio: ['ignore', 1, 2], windowsHide: true, detached: true }); +} +child.on('error', e => { console.error(e); process.exit(1); }); +child.on('exit', code => process.exit(code || 0)); +process.stdin.resume(); +`; +interface Record { stage: string; pid: number; created: string } + +async function scenario(mode: string, run: (owner: ChildProcessWithoutNullStreams, closed: Promise, records: Record[], logs: () => string) => Promise, extra: NodeJS.ProcessEnv = {}) { + const owner = spawn(process.execPath, ['-e', bootstrap], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, TEST_MODE: mode, TEST_DOTNET: dotnet, TEST_GUARD: fixture, TEST_BOOTSTRAP: bootstrap, ...extra } }); + const closed = new Promise((resolve, reject) => { owner.on('close', resolve); owner.on('error', reject); }); + owner.stdin.on('error', () => {}); + let buffer = '', stderr = ''; + const records: Record[] = []; + owner.stdout.on('data', data => { + buffer += data.toString(); + const lines = buffer.split('\n'); buffer = lines.pop()!; + for (const line of lines) if (line.trim()) records.push(JSON.parse(line)); + }); + owner.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-8192); }); + try { await run(owner, closed, records, () => stderr); } + finally { + if (owner.exitCode === null && owner.signalCode === null) await killProcessTree(owner); + for (const record of records.filter((value, index, all) => all.findIndex(item => item.pid === value.pid) === index)) { + assert.ok(Number.isSafeInteger(record.pid) && record.pid > 0 && /^\d+$/.test(record.created)); + // 捕获的创建时间必须匹配;先持有实际对象句柄,避免在核验与清理之间复用 PID。 + const script = `$ErrorActionPreference = 'Stop'; $p = Get-Process -Id ${record.pid} -ErrorAction SilentlyContinue; if ($p) { try { $h = $p.SafeHandle; if ($p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() -eq '${record.created}') { $p.Kill(); $p.WaitForExit(3000) | Out-Null } } finally { $p.Dispose() } }; exit 0`; + await promisify(execFile)('powershell.exe', ['-NoProfile', '-Command', script], { windowsHide: true, timeout: 5000 }); + } + await withTimeout(closed, 5000, 'owner fixture cleanup'); + } +} +async function stage(records: Record[], name: string, logs: () => string) { + const deadline = Date.now() + 8000; + while (!records.some(record => record.stage === name)) { + if (Date.now() >= deadline) throw new Error(`Missing ${name}: ${logs()}`); + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +for (const mode of ['normal', 'repeat']) it(`owner guard supports ${mode} disposal without handle accumulation`, options, async () => { + await scenario(mode, async (_owner, closed, records, logs) => { + assert.equal(await withTimeout(closed, 10000, 'normal guard exit'), 0, logs()); + assert.ok(records.some(record => record.stage === (mode === 'repeat' ? 'repeat-complete' : 'attached'))); + }); +}); +for (const mode of ['native-block', 'blocked-callback', 'cooperative', 'early-owner-death']) + it(`owner death stops the Helper during ${mode} without a tree kill`, options, async () => { + await scenario(mode, async (owner, closed, records, logs) => { + await stage(records, mode === 'early-owner-death' ? 'before-attach' : 'attached', logs); + process.kill(owner.pid!, 'SIGKILL'); + // 子进程继承同一 stdout pipe;close 只有在 Helper 也关闭写端后才触发。 + await withTimeout(closed, 7000, 'orphaned Helper exit'); + for (const record of records) assert.throws(() => process.kill(record.pid, 0), /ESRCH/, 'Helper survived'); + }); + }); +it('owner identity follows an explicit ancestor through a startup wrapper', options, async () => { + await scenario('native-block', async (owner, closed, records, logs) => { + await stage(records, 'attached', logs); + process.kill(owner.pid!, 'SIGKILL'); + await withTimeout(closed, 7000, 'wrapped Helper exit'); + assert.throws(() => process.kill(records[0].pid, 0), /ESRCH/); + }, { TEST_WRAPPER: '1' }); +}); +for (const invalid of ['0', 'not-a-pid', '4294967295']) it(`owner guard rejects invalid or unrelated owner ${invalid}`, options, async () => { + await scenario('normal', async (_owner, closed, records, logs) => { + assert.equal(await withTimeout(closed, 8000, 'invalid owner exit'), 1); + assert.equal(records.length, 0, 'No work may start without a verified owner'); + assert.match(logs(), /owner|owning|process/i); + }, { TEST_OWNER_OVERRIDE: invalid }); +}); +it('the Helper cannot declare itself as its owner', options, async () => { + await scenario('self-owner', async (_owner, closed, records) => { + assert.equal(await withTimeout(closed, 8000, 'self owner rejection'), 1); + assert.equal(records.length, 0); + }); +}); + +it('owner death leaves a second independent owner and Helper alive', options, async () => { + await scenario('native-block', async (otherOwner, _otherClosed, otherRecords, otherLogs) => { + await stage(otherRecords, 'attached', otherLogs); + await scenario('native-block', async (owner, closed, records, logs) => { + await stage(records, 'attached', logs); + process.kill(owner.pid!, 'SIGKILL'); + await withTimeout(closed, 7000, 'first Helper exit'); + assert.throws(() => process.kill(records[0].pid, 0), /ESRCH/); + assert.doesNotThrow(() => process.kill(otherOwner.pid!, 0)); + assert.doesNotThrow(() => process.kill(otherRecords[0].pid, 0)); + }); + }); +}); + +it('production UIA still treats stdin EOF as the request boundary', options, async () => { + const host = path.resolve('tools/WinCode.UIA.Host/bin/Release/net10.0-windows/win-x64/publish/WinCode.UIA.Host.dll'); + const child = spawn(dotnet, [host], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, WINCODE_OWNER_PID: String(process.pid) } }); + const closed = new Promise((resolve, reject) => { child.on('close', resolve); child.on('error', reject); }); + let stdout = '', stderr = ''; + child.stdout.on('data', data => { stdout += data.toString(); }); + child.stderr.on('data', data => { stderr += data.toString(); }); + child.stdin.on('error', () => {}); + try { + child.stdin.end(JSON.stringify({ action: 'health', requestId: 'owner-eof' })); + assert.equal(await withTimeout(closed, 8000, 'UIA EOF response'), 0, stderr); + const response = JSON.parse(stdout); + assert.equal(response.success, true); + assert.equal(response.status, 'healthy'); + assert.equal(response.requestId, 'owner-eof'); + } finally { + if (child.exitCode === null && child.signalCode === null) await killProcessTree(child); + await withTimeout(closed, 5000, 'UIA cleanup'); + } +}); + diff --git a/tests/tray-client.test.ts b/tests/tray-client.test.ts new file mode 100644 index 0000000..5332eb3 --- /dev/null +++ b/tests/tray-client.test.ts @@ -0,0 +1,116 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import { TrayClient, resolveTrayEndpoint } from '../src/Gateway/TrayClient.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; + +async function fixture() { + const pipe = `WinCode.Tray.v1.S-1-5-21-0.s0.test-${randomUUID().replaceAll('-', '')}`; + const address = `\\\\.\\pipe\\${pipe}`; + const server = net.createServer(); server.listen(address); await once(server, 'listening'); + const router = new ToolRouter(getDefaultConfig(process.cwd())); + let shutdown = 0, release = 0; + let stopped!: () => void; + const stoppedEvent = new Promise(resolve => { stopped = resolve; }); + const original = router.releaseRoslynMemory.bind(router); + router.releaseRoslynMemory = () => { release++; return original(); }; + const warnings: string[] = []; + const client = new TrayClient(pipe, router, () => { shutdown++; stopped(); }, message => warnings.push(message)); + const connected = once(server, 'connection'); client.start(); + const [socket] = await connected as [net.Socket]; + let input = '', lines: any[] = [], waiters: Array<(value: any) => void> = []; + socket.on('data', chunk => { input += chunk; let next; while ((next = input.indexOf('\n')) >= 0) { + const value = JSON.parse(input.slice(0, next)); input = input.slice(next + 1); + const waiter = waiters.shift(); if (waiter) waiter(value); else lines.push(value); + } }); + const read = () => lines.length ? Promise.resolve(lines.shift()) : new Promise(resolve => waiters.push(resolve)); + const registration = await read(); + const request = (operation: string, id = randomUUID()) => ({ v: 1, type: 'request', id, instanceId: registration.instanceId, operation }); + return { server, socket, client, router, registration, request, read, stoppedEvent, warnings, + counts: () => ({ shutdown, release }), + close: async () => { client.dispose(); socket.destroy(); await new Promise(resolve => server.close(() => resolve())); await router.dispose(); } }; +} + +it('Tray endpoint resolves the published current-user helper without starting a Tray', { timeout: 8000 }, async () => { + assert.match(await resolveTrayEndpoint(new AbortController().signal), /^WinCode\.Tray\.v1\.S-1-/); +}); + +it('registration acknowledgement is passive and refusal reports its reason without stopping MCP', { timeout: 8000 }, async () => { + const f = await fixture(); + try { + f.socket.write(JSON.stringify({ v: 1, type: 'register-accepted', instanceId: f.registration.instanceId }) + '\n'); + f.socket.write(JSON.stringify(f.request('status')) + '\n'); + assert.equal((await f.read()).result.roslynLoaded, false); + const closed = once(f.socket, 'close'); + f.socket.write(JSON.stringify({ v: 1, type: 'register-rejected', instanceId: f.registration.instanceId, message: '产品版本不符' }) + '\n'); + await closed; + assert.ok(f.warnings.some(message => message.includes('产品版本不符'))); + assert.deepEqual(f.counts(), { release: 0, shutdown: 0 }); + assert.equal(f.router.isShuttingDown, false); + } finally { await f.close(); } +}); + +it('Tray registration and status never warm Roslyn; shutdown occurs after acknowledgement', { timeout: 8000 }, async () => { + const f = await fixture(); + try { + assert.equal(f.registration.pid, process.pid); + assert.equal(f.registration.status.automaticRelease, false); + assert.equal(f.registration.status.roslynLoaded, false); + f.socket.write(JSON.stringify(f.request('status')) + '\n'); + assert.equal((await f.read()).result.automaticRelease, false); + f.socket.write(JSON.stringify(f.request('shutdown')) + '\n'); + assert.equal((await f.read()).result.status, 'accepted'); + await f.stoppedEvent; + assert.equal(f.counts().shutdown, 1); + } finally { await f.close(); } +}); + +it('control refuses a busy instance without queuing a later release', { timeout: 8000 }, async () => { + const f = await fixture(); + try { + f.router.beginRequest(); + f.socket.write(JSON.stringify(f.request('releaseRoslyn')) + '\n'); + assert.equal((await f.read()).result.status, 'busy'); + f.router.endRequest(); assert.equal(f.counts().release, 1); + } finally { await f.close(); } +}); + +it('replayed command IDs are disconnected without executing the operation twice', { timeout: 8000 }, async () => { + const f = await fixture(); + try { + const frame = JSON.stringify(f.request('releaseRoslyn')) + '\n'; + f.socket.write(frame); assert.equal((await f.read()).result.status, 'not-configured'); + const closed = once(f.socket, 'close'); f.socket.write(frame); await closed; + assert.equal(f.counts().release, 1); + assert.equal(f.router.isShuttingDown, false); + } finally { await f.close(); } +}); + +for (const kind of ['wrong-instance', 'oversize', 'invalid-utf8', 'invalid-then-valid']) it(`Tray rejects ${kind} without changing the Gateway`, { timeout: 8000 }, async () => { + const f = await fixture(); + try { + const closed = once(f.socket, 'close'); + if (kind === 'oversize') f.socket.write(Buffer.alloc(65537, 32)); + else if (kind === 'invalid-utf8') f.socket.write(Buffer.from([0xff, 10])); + else if (kind === 'invalid-then-valid') f.socket.write(JSON.stringify({ ...f.request('shutdown'), instanceId: 'wrong' }) + '\n' + JSON.stringify(f.request('shutdown')) + '\n'); + else f.socket.write(JSON.stringify({ ...f.request('shutdown'), instanceId: randomUUID() }) + '\n'); + await closed; assert.deepEqual(f.counts(), { release: 0, shutdown: 0 }); + assert.equal(f.router.isShuttingDown, false); + } finally { await f.close(); } +}); + +it('Tray reconnect registers fresh passive state without replaying a completed release', { timeout: 8000 }, async () => { + const f = await fixture(); + let second: net.Socket | undefined; + try { + f.socket.write(JSON.stringify(f.request('releaseRoslyn')) + '\n'); await f.read(); + const connection = once(f.server, 'connection'); f.socket.destroy(); + [second] = await connection as [net.Socket]; + const [bytes] = await once(second, 'data'); + assert.equal(JSON.parse(bytes.toString()).type, 'register'); + assert.equal(f.counts().release, 1); + } finally { second?.destroy(); await f.close(); } +}); diff --git a/tests/ui-hardening.test.ts b/tests/ui-hardening.test.ts index 4bf54c1..cbfee41 100644 --- a/tests/ui-hardening.test.ts +++ b/tests/ui-hardening.test.ts @@ -7,6 +7,54 @@ import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; import { Client } from '@modelcontextprotocol/client'; import { InMemoryTransport } from '@modelcontextprotocol/client'; +it('UIA initialization validates files without launching a probe; concurrent first diagnostics share the observation', async () => { + const adapter = new FlaUiAdapter(getDefaultConfig(process.cwd())); + let calls = 0; + (adapter as any).resolveHostCommand = () => ({ command: process.execPath, args: [] }); + (adapter as any).executeHost = async () => { + calls++; + await new Promise(resolve => setTimeout(resolve, 20)); + return { success: true, status: 'healthy', hostIdentity: { version: 'fixture', configuration: 'Release' } }; + }; + try { + await Promise.all([adapter.initialize(), adapter.initialize()]); + assert.equal(calls, 0); + assert.deepEqual(adapter.getKnownHealth(), { observedAt: null, health: null }); + const results = await Promise.all([adapter.checkHealth(), adapter.checkHealth()]); + assert.equal(calls, 1); + assert.ok(results.every(result => result.available)); + } finally { await adapter.dispose(); } +}); + +it('missing UIA files are reported at initialization and the first use can recover after files return', async () => { + const adapter = new FlaUiAdapter(getDefaultConfig(process.cwd())); + let calls = 0; + (adapter as any).resolveHostCommand = () => null; + try { + await adapter.initialize(); + assert.equal(adapter.getKnownHealth().health?.available, false); + assert.equal((await adapter.inspect({ pid: 1 })).errorCode, 'HOST_UNAVAILABLE'); + (adapter as any).executeHost = async () => { calls++; return { success: true, hostIdentity: { version: 'fixture' } }; }; + assert.equal((await adapter.inspect({ pid: 1 })).success, true); + assert.equal(calls, 1, 'First use must execute only the requested operation'); + assert.equal(adapter.getKnownHealth().health?.available, true); + assert.ok(adapter.getKnownHealth().health?.lastError, 'Successful use must preserve the earlier failure observation'); + } finally { await adapter.dispose(); } +}); + +it('a failed first UI operation preserves unknown health and remains visible in runtime errors', async () => { + const router = new ToolRouter(getDefaultConfig(process.cwd())); + (router.flaui as any).executeHost = async () => ({ success: false, errorCode: 'TIMEOUT', errorMessage: 'fixture timeout' }); + try { + await router.flaui.inspect({ pid: 1 }); + const health = await router.getRuntimeHealth(); + assert.equal(health.healthObservation.flaui.state, 'unknown'); + assert.equal(health.flaui.available, null); + assert.equal(health.lastAdapterError?.provider, 'flaui'); + assert.equal(health.lastAdapterError?.reason, 'timeout'); + } finally { await router.dispose(); } +}); + it('helper pipe preserves Chinese characters split across UTF-8 chunks', async () => { const adapter = new FlaUiAdapter(getDefaultConfig(process.cwd())); const code = `process.stdin.resume(); process.stdin.on('end', () => { diff --git a/tests/ui-inspect-mcp.test.ts b/tests/ui-inspect-mcp.test.ts index 27e944d..c9f77b3 100644 --- a/tests/ui-inspect-mcp.test.ts +++ b/tests/ui-inspect-mcp.test.ts @@ -147,7 +147,8 @@ describe('WinCode MCP UI Inspect Protocol & End-to-End Suite', () => { assert.ok(!res.isError); const data = JSON.parse(getContent(res)[0].text!); assert.ok(data.adapters.flaui, 'flaui adapter must be reported'); - assert.strictEqual(data.adapters.flaui.available, true); + assert.strictEqual(data.adapters.flaui.available, null, 'Unused UIA must remain unprobed'); + assert.strictEqual(data.adapters.flaui.source, 'unknown'); assert.ok(data.capabilities.includes('wincode_ui_inspect')); }); diff --git a/tests/watch-invalidation.test.ts b/tests/watch-invalidation.test.ts index b3e0902..b581b5c 100644 --- a/tests/watch-invalidation.test.ts +++ b/tests/watch-invalidation.test.ts @@ -2,7 +2,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { spawn, execFile } from 'node:child_process'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; @@ -67,19 +67,31 @@ describe('watch-invalidation', () => { assert.ok(fired >= 1, `expected watch callback, fired=${fired}`); }); - it('filesystem watch invalidates fingerprint memo after a write', async () => { - const config = getDefaultConfig(root); + it('filesystem watch invalidates fingerprint memo after a write', async (t) => { + // 隔离被监视的工作区,避免其他并行测试写文件不断推迟尾沿 debounce。 + const workspace = path.join(testCacheDir, 'watch-workspace'); + await fs.mkdir(workspace, { recursive: true }); + await promisify(execFile)('git', ['init', '--quiet', workspace], { windowsHide: true, timeout: 5000 }); + const config = getDefaultConfig(workspace); config.cacheDir = path.join(testCacheDir, 'watch'); const router = new ToolRouter(config); await router.initialize(); - const before = await router.cache.computeWorkspaceFingerprint(root); - const probe = path.join(root, 'v051_watch_probe.txt'); - await fs.writeFile(probe, `watch-${Date.now()}`); + const before = await router.cache.computeWorkspaceFingerprint(workspace); + const probe = path.join(workspace, 'watch_probe.txt'); + let observed!: () => void; + const changed = new Promise(resolve => { observed = resolve; }); + const noteChange = router.cache.noteFilesystemChange.bind(router.cache); + t.mock.method(router.cache, 'noteFilesystemChange', (directory: string) => { noteChange(directory); observed(); }); + let timer: ReturnType | undefined; try { - await new Promise((r) => setTimeout(r, 500)); - const after = await router.cache.computeWorkspaceFingerprint(root); + await fs.writeFile(probe, `watch-${Date.now()}`); + await Promise.race([changed, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Actual filesystem invalidation callback was not observed')), 3000); + })]); + const after = await router.cache.computeWorkspaceFingerprint(workspace); assert.notStrictEqual(after, before, 'watch or cheap probe must drop memo after a working-tree write'); } finally { + clearTimeout(timer); await fs.unlink(probe).catch(() => { }); await router.dispose(); } diff --git a/tools/Shared/OwnerProcessGuard.cs b/tools/Shared/OwnerProcessGuard.cs new file mode 100644 index 0000000..d62e3c0 --- /dev/null +++ b/tools/Shared/OwnerProcessGuard.cs @@ -0,0 +1,138 @@ +using System.ComponentModel; +using System.Globalization; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace WinCode.Native; + +/// +/// 在重操作前核验自有启动链并持有 owner 的实际进程句柄。只等待该对象,不轮询客户端名称。 +/// owner 死亡先广播取消,独立等待线程在两秒后硬退出本 Helper;阻塞的 UI/MSBuild/取消回调不能阻止兜底。 +/// +internal sealed class OwnerProcessGuard : IDisposable +{ + private readonly SafeProcessHandle owner; + private readonly ManualResetEvent stopped = new(false); + private readonly CancellationTokenSource cancellation = new(); + private readonly Thread watcher; + private int disposed; + public CancellationToken Token => cancellation.Token; + + private OwnerProcessGuard(SafeProcessHandle owner) + { + this.owner = owner; + watcher = new Thread(Watch) { IsBackground = true, Name = "WinCode owner lifetime" }; + try { watcher.Start(); } + catch { owner.Dispose(); stopped.Dispose(); cancellation.Dispose(); throw; } + } + + /// + /// Gateway 只向自己的子进程传 WINCODE_OWNER_PID;直接运行 Host 时使用实际父进程。 + /// 最多验证八层,支持 dotnet run 包装;每层校验创建时间以拒绝已被复用的祖先 PID。 + /// 无法打开/核实的 owner 在项目求值和 UI 读取前失败,绝不降级到任意存活 PID。 + /// + public static OwnerProcessGuard? Attach() + { + if (!OperatingSystem.IsWindows()) return null; + var parents = ParentSnapshot(); + uint current = (uint)Environment.ProcessId; + if (!parents.TryGetValue(current, out var directParent) || directParent == 0) + throw new InvalidOperationException("Cannot establish the Helper parent identity."); + var declared = Environment.GetEnvironmentVariable("WINCODE_OWNER_PID"); + var expected = directParent; + if (declared != null && (!uint.TryParse(declared, NumberStyles.None, CultureInfo.InvariantCulture, out expected) || expected == 0)) + throw new ArgumentException("WINCODE_OWNER_PID must identify the owning process."); + var childCreated = Created(GetCurrentProcess()); + for (var depth = 0; depth < 8; depth++) + { + if (!parents.TryGetValue(current, out var parent) || parent == 0 || parent == current) + break; + var handle = OpenProcess(0x00100000 | 0x1000, false, parent); // SYNCHRONIZE | QUERY_LIMITED_INFORMATION + try + { + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "Cannot open the owning process."); + var created = Created(handle.DangerousGetHandle()); + if (created >= childCreated || WaitForSingleObject(handle, 0) != 258) + throw new InvalidOperationException("Owning process exited or its PID was reused."); + if (parent == expected) return new OwnerProcessGuard(handle); + childCreated = created; + current = parent; + } + catch { handle.Dispose(); throw; } + handle.Dispose(); + } + throw new InvalidOperationException("Declared owner is outside the Helper startup chain."); + } + + /// 一次性收集父子关系;只保留 PID,不使用进程名称或命令行。条目/祖先深度均有上限。 + private static Dictionary ParentSnapshot() + { + using var snapshot = CreateToolhelp32Snapshot(2, 0); + if (snapshot.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); + var entry = new ProcessEntry { Size = (uint)Marshal.SizeOf(), ExeFile = "" }; + if (!Process32FirstW(snapshot, ref entry)) throw new Win32Exception(Marshal.GetLastWin32Error()); + var parents = new Dictionary(); + do + { + if (parents.Count >= 32768) throw new InvalidOperationException("Process identity snapshot budget exceeded."); + parents[entry.ProcessId] = entry.ParentProcessId; + } while (Process32NextW(snapshot, ref entry)); + var error = Marshal.GetLastWin32Error(); + if (error != 18) throw new Win32Exception(error); // ERROR_NO_MORE_FILES + return parents; + } + + private static long Created(IntPtr process) + { + if (!GetProcessTimes(process, out var created, out _, out _, out _)) throw new Win32Exception(Marshal.GetLastWin32Error()); + return created; + } + + private void Watch() + { + try + { + // owner 句柄一直持有到等待线程结束;不能在另一个线程的 pending wait 中 CloseHandle。 + var result = WaitForMultipleObjects(2, [owner.DangerousGetHandle(), stopped.SafeWaitHandle.DangerousGetHandle()], false, uint.MaxValue); + if (result == 1) return; + // owner 已退出或原生等待失败,均不能继续无保护工作;取消回调不能卡住此线程。 + _ = cancellation.CancelAsync().ContinueWith(task => { _ = task.Exception; }, TaskContinuationOptions.OnlyOnFaulted); + if (!stopped.WaitOne(2000)) + if (!TerminateProcess(GetCurrentProcess(), 72)) // 只终止当前 Helper;Code Host 的 Job 负责其后代。 + Environment.FailFast("Unable to terminate this orphaned WinCode Helper."); + } + finally { owner.Dispose(); stopped.Dispose(); } + } + + /// 正常退出不触发硬终止;等待线程退出后才释放其正在使用的句柄。 + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) return; + stopped.Set(); + watcher.Join(); + cancellation.Dispose(); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry + { + public uint Size, Usage, ProcessId; + public UIntPtr DefaultHeapId; + public uint ModuleId, Threads, ParentProcessId; + public int BasePriority; + public uint Flags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string ExeFile; + } + [DllImport("kernel32.dll", SetLastError = true)] private static extern SafeFileHandle CreateToolhelp32Snapshot(uint flags, uint process); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32FirstW(SafeFileHandle snapshot, ref ProcessEntry entry); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32NextW(SafeFileHandle snapshot, ref ProcessEntry entry); + [DllImport("kernel32.dll", SetLastError = true)] private static extern SafeProcessHandle OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetProcessTimes(IntPtr process, out long created, out long exited, out long kernel, out long user); + [DllImport("kernel32.dll", SetLastError = true)] private static extern uint WaitForSingleObject(SafeProcessHandle handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError = true)] private static extern uint WaitForMultipleObjects(uint count, IntPtr[] handles, [MarshalAs(UnmanagedType.Bool)] bool all, uint milliseconds); + [DllImport("kernel32.dll")] private static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool TerminateProcess(IntPtr process, uint exitCode); +} diff --git a/tools/WinCode.Code.Host/Program.cs b/tools/WinCode.Code.Host/Program.cs index 79e4723..0601d80 100644 --- a/tools/WinCode.Code.Host/Program.cs +++ b/tools/WinCode.Code.Host/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Threading.Channels; using Microsoft.Build.Locator; +using WinCode.Native; /// /// 自有 C# Host:直接调用 Roslyn,内部协议 v2,由显式启用的 Gateway RoslynAdapter 管理。 @@ -32,6 +33,7 @@ private static async Task Main(string[] args) } if (args.Length is not (5 or 6) || args[0] != "--allow-project-evaluation") throw new ArgumentException("Explicit project evaluation permission required: --allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK"); + using var owner = OwnerProcessGuard.Attach(); var root = Path.GetFullPath(args[1]); var project = WorkspaceInputs.Inside(root, args[2]); var additionalInputs = WorkspaceInputs.ParseAdditionalInputs(root, args.Length == 6 ? args[5] : "[]"); @@ -42,7 +44,7 @@ private static async Task Main(string[] args) Directory.SetCurrentDirectory(Path.GetDirectoryName(project)!); OwnedProcessJob.Attach(); MSBuildLocator.RegisterDefaults(); - return await RunAsync(root, project, args[3], args[4], additionalInputs); + return await RunAsync(root, project, args[3], args[4], additionalInputs, owner?.Token ?? CancellationToken.None); } catch (Exception error) { WriteFailure(null, error, "hostError"); return 1; } } @@ -76,18 +78,21 @@ private static void WriteFailure(string? id, Exception error, string type = "res /// symbols/references 的 timeoutMs 默认 30000、上限 60000;reload 默认/上限 120000,均至少 1,包含排队时间。 /// limit 为 1–1000、默认 100,只约束返回量。协作取消不等于进程级硬截止。 /// - private static async Task RunAsync(string root, string project, string configuration, string framework, string[] additionalInputs) + private static async Task RunAsync(string root, string project, string configuration, string framework, string[] additionalInputs, CancellationToken ownerStopped) { var session = new WorkspaceSession(root, project, configuration, framework, additionalInputs); var queue = Channel.CreateBounded(new BoundedChannelOptions(8) { SingleReader = true, SingleWriter = true }); var requests = new ConcurrentDictionary(); - using var stopping = new CancellationTokenSource(); + using var stopping = CancellationTokenSource.CreateLinkedTokenSource(ownerStopped); string? shutdownId = null; Task worker = Task.CompletedTask; try { - using (var initialDeadline = new CancellationTokenSource(120000)) + using (var initialDeadline = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token)) + { + initialDeadline.CancelAfter(120000); Write(await session.ReloadAsync(null, initialDeadline.Token)); + } worker = Task.Run(async () => { await foreach (var pending in queue.Reader.ReadAllAsync()) { diff --git a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj index 13caf12..cca6c6f 100644 --- a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj +++ b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj @@ -1,6 +1,6 @@ - 0.13.2 + 0.14.0 Exe net10.0 enable @@ -8,6 +8,7 @@ true + diff --git a/tools/WinCode.Tray/PipeHub.cs b/tools/WinCode.Tray/PipeHub.cs new file mode 100644 index 0000000..e26fa3f --- /dev/null +++ b/tools/WinCode.Tray/PipeHub.cs @@ -0,0 +1,231 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using Microsoft.Win32.SafeHandles; + +namespace WinCode.Tray; + +internal sealed class FrameReader(Stream stream) +{ + private readonly byte[] buffer = new byte[65537]; + private int used; + public async Task Read(CancellationToken token) + { + while (true) + { + int newline = Array.IndexOf(buffer, (byte)10, 0, used); + if (newline >= 0) + { + using var json = JsonDocument.Parse(buffer.AsMemory(0, newline), new JsonDocumentOptions { MaxDepth = 12 }); + var value = json.RootElement.Clone(); + Buffer.BlockCopy(buffer, newline + 1, buffer, 0, used - newline - 1); used -= newline + 1; + return value; + } + if (used >= 65536) throw new InvalidDataException("控制消息超过容量限制。"); + int read = await stream.ReadAsync(buffer.AsMemory(used, buffer.Length - used), token); + if (read == 0) throw new EndOfStreamException(); + used += read; + } + } +} + +internal sealed class GatewayPeer(NamedPipeServerStream pipe, string id, int pid, string version, string build, JsonElement status) +{ + public string Id { get; } = id; + public int Pid { get; } = pid; + public string Version { get; } = version; + public string Build { get; } = build; + public JsonElement Status { get; private set; } = status; + public bool Connected { get; private set; } = true; + public DateTime ObservedAt { get; private set; } = DateTime.Now; + public string? ObservationError { get; private set; } + // 只影响界面的可信度,不轮询或改变 Gateway/Roslyn 生命周期。 + public bool StatusCurrent => Connected && ObservationError == null && DateTime.Now - ObservedAt < TimeSpan.FromSeconds(30); + private readonly SemaphoreSlim writer = new(1, 1); + private readonly ConcurrentDictionary> pending = new(); + public event Action? Changed; + + public async Task Request(string operation) + { + if (!Connected) throw new IOException("实例已失联;不能推断它已经退出。"); + if (pending.Count >= 8) throw new IOException("此实例已有过多待完成操作。"); + string requestId = Guid.NewGuid().ToString("N"); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!pending.TryAdd(requestId, result)) throw new IOException("请求身份冲突。"); + using var timeout = new CancellationTokenSource(operation == "status" ? 2000 : 10000); + try + { + var frame = JsonSerializer.SerializeToUtf8Bytes(new { v = 1, type = "request", id = requestId, instanceId = Id, operation }); + await writer.WaitAsync(timeout.Token); + try { + if (!Connected) throw new IOException("实例已失联,未发送控制操作。"); + await pipe.WriteAsync(frame, timeout.Token); await pipe.WriteAsync(new byte[] { 10 }, timeout.Token); + } + finally { writer.Release(); } + var value = await result.Task.WaitAsync(timeout.Token); + if (operation == "status") { ValidateStatus(value); Status = value; ObservedAt = DateTime.Now; ObservationError = null; Changed?.Invoke(); } + return value; + } + catch (OperationCanceledException) { + ObservationError = "操作等待超时,状态未知。请刷新状态;不会自动重发控制操作。"; Changed?.Invoke(); + throw new TimeoutException(ObservationError); + } + catch (Exception error) when (error is IOException or InvalidDataException or InvalidOperationException or KeyNotFoundException) { + ObservationError = error.Message; Changed?.Invoke(); throw; + } + finally { pending.TryRemove(requestId, out _); } + } + + public void Accept(JsonElement value) + { + if (value.GetProperty("v").GetInt32() != 1 || value.GetProperty("type").GetString() != "response" || value.GetProperty("instanceId").GetString() != Id) + throw new InvalidDataException("实例响应身份不符。"); + string requestId = value.GetProperty("id").GetString() ?? ""; + if (requestId.Length > 64) throw new InvalidDataException("无效响应身份。"); + // 已超时请求的迟到结果不触发操作重放,也不归入另一个请求。 + if (pending.TryGetValue(requestId, out var completion)) completion.TrySetResult(value.GetProperty("result").Clone()); + } + + public void Disconnect() + { + Connected = false; + ObservationError = "连接已断开,状态未知。"; + foreach (var item in pending.Values) item.TrySetException(new IOException("连接已断开,控制结果未知。")); + Changed?.Invoke(); + } + + public static string Text(JsonElement value, string name, int max = 4096) => + value.TryGetProperty(name, out var field) && field.ValueKind == JsonValueKind.String + ? (field.GetString() ?? "")[..Math.Min(max, field.GetString()!.Length)] : ""; + + public static void ValidateStatus(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object || Text(value, "workspace").Length == 0 || + Text(value, "provider") is not ("roslyn" or "local-text") || Text(value, "state") is not ("idle" or "busy" or "releasing" or "shutting-down" or "recovery-required") || + value.GetProperty("automaticRelease").ValueKind != JsonValueKind.False || + value.GetProperty("roslynLoaded").ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new InvalidDataException("实例状态不符合手动释放协议。"); + } +} + +internal sealed class PipeHub : IDisposable +{ + private static readonly int Session = GetSession(); + private static readonly string UserSid = GetUserSid(); + private static int GetSession() { using var process = Process.GetCurrentProcess(); return process.SessionId; } + private static string GetUserSid() { using var identity = WindowsIdentity.GetCurrent(); return identity.User!.Value; } + public static string Endpoint => $"WinCode.Tray.v1.{UserSid}.s{Session}"; + private readonly CancellationTokenSource stopped = new(); + private readonly SemaphoreSlim registrations = new(8, 8); + private readonly NamedPipeServerStream[] listeners; + private readonly ConcurrentDictionary peers = new(); + public event Action? Changed; + public event Action? ShowRequested; + public string? LastConnectionError { get; private set; } + public GatewayPeer[] Peers => peers.Values.OrderByDescending(value => value.Connected).ThenBy(value => value.Id).ToArray(); + + public PipeHub(string name) + { + var created = new List(); + try + { + // 八个 Gateway 加一个唤出窗口的槽;首个实例保持到 Hub 关闭,防止管道被中途重新抢占。 + for (int i = 0; i < 9; i++) created.Add(new NamedPipeServerStream(name, PipeDirection.InOut, 9, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly | (i == 0 ? PipeOptions.FirstPipeInstance : PipeOptions.None), 8192, 8192)); + listeners = created.ToArray(); + foreach (var pipe in listeners) _ = Listen(pipe); + } + catch { foreach (var pipe in created) pipe.Dispose(); stopped.Dispose(); throw; } + } + + private async Task Listen(NamedPipeServerStream pipe) + { + while (!stopped.IsCancellationRequested) + { + GatewayPeer? peer = null; + bool registeredSlot = false, authenticated = false; + string? registrationId = null; + try + { + await pipe.WaitForConnectionAsync(stopped.Token); + if (!GetNamedPipeClientSessionId(pipe.SafePipeHandle, out var session) || session != Session || + !GetNamedPipeClientProcessId(pipe.SafePipeHandle, out var pid)) throw new IOException("拒绝不同登录会话的连接。"); + var computer = new StringBuilder(256); + bool gotComputer = GetNamedPipeClientComputerNameW(pipe.SafePipeHandle, computer, (uint)computer.Capacity); + int computerError = Marshal.GetLastWin32Error(); + // 本机连接的 Win32 返回值是 ERROR_PIPE_LOCAL (229),而非返回本机名称的成功结果。 + // 查询成功意味着远程连接;其余查询失败均拒绝,不通过名称比较放宽边界。 + if (gotComputer || computerError != 229) throw new IOException($"仅支持本机连接(Win32 {computerError})。"); + var reader = new FrameReader(pipe); + using var handshake = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token); handshake.CancelAfter(3000); + var registration = await reader.Read(handshake.Token); + // CurrentUserOnly 在部分 .NET 版本使用 Owner SID;额外核验实际客户端 User SID。 + // 仅在同步委托内读身份;不以客户端身份操作文件或启动程序。 + string? clientSid = null; + pipe.RunAsClient(() => { using var identity = WindowsIdentity.GetCurrent(true); clientSid = identity?.User?.Value; }); + if (clientSid != UserSid) throw new IOException("拒绝不同用户或无法核验身份的连接。"); + authenticated = true; + registrationId = GatewayPeer.Text(registration, "instanceId", 64); + if (registration.GetProperty("v").GetInt32() != 1) throw new InvalidDataException("控制协议版本不符。"); + if (GatewayPeer.Text(registration, "type") == "show") { + ShowRequested?.Invoke(); + await pipe.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(new { v = 1, type = "show-accepted" }), handshake.Token); + await pipe.WriteAsync(new byte[] { 10 }, handshake.Token); + // 同样等待唤出客户端读完确认后关闭,避免 Disconnect 丢弃确认帧。 + _ = await pipe.ReadAsync(new byte[1], handshake.Token); + continue; + } + if (!(registeredSlot = registrations.Wait(0))) throw new IOException("最多连接八个实例。"); + var id = GatewayPeer.Text(registration, "instanceId", 64); + var version = GatewayPeer.Text(registration, "version", 64); + if (GatewayPeer.Text(registration, "type") != "register" || !Guid.TryParseExact(id, "D", out _) || + registration.GetProperty("pid").GetInt32() != pid || version != Program.Version) + throw new InvalidDataException("注册身份或产品版本不符。"); + var status = registration.GetProperty("status").Clone(); GatewayPeer.ValidateStatus(status); + if (peers.TryGetValue(id, out var existing) && existing.Connected) throw new InvalidDataException("重复的实例身份。"); + peer = new GatewayPeer(pipe, id, (int)pid, version, GatewayPeer.Text(registration, "buildId", 64), status); + peer.Changed += OnChanged; + await pipe.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(new { v = 1, type = "register-accepted", instanceId = id }), handshake.Token); + await pipe.WriteAsync(new byte[] { 10 }, handshake.Token); + peers[id] = peer; + foreach (var old in peers.Values.Where(item => !item.Connected).OrderBy(item => item.ObservedAt).Take(Math.Max(0, peers.Count - 32))) peers.TryRemove(old.Id, out _); + OnChanged(); + while (!stopped.IsCancellationRequested) peer.Accept(await reader.Read(stopped.Token)); + } + catch (Exception error) when (error is IOException or InvalidDataException or OperationCanceledException or JsonException or InvalidOperationException or KeyNotFoundException or ObjectDisposedException or FormatException or UnauthorizedAccessException or System.Security.SecurityException) { + if (!stopped.IsCancellationRequested && error is not EndOfStreamException) { + LastConnectionError = error.Message[..Math.Min(500, error.Message.Length)]; OnChanged(); + if (authenticated && peer == null && registrationId != null) { + try { + using var replyDeadline = new CancellationTokenSource(1000); + await pipe.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(new { v = 1, type = "register-rejected", instanceId = registrationId, message = LastConnectionError }), replyDeadline.Token); + await pipe.WriteAsync(new byte[] { 10 }, replyDeadline.Token); + // DisconnectNamedPipe 会丢弃尚未读取的数据。让客户端读完拒绝原因后关闭, + // 最多等一秒;不使用不可取消的 WaitForPipeDrain 阻塞托盘。 + _ = await pipe.ReadAsync(new byte[1], replyDeadline.Token); + } catch (Exception replyError) when (replyError is IOException or OperationCanceledException or ObjectDisposedException) { } + } + } + } + finally + { + peer?.Disconnect(); + if (registeredSlot) registrations.Release(); + if (!stopped.IsCancellationRequested) { try { if (pipe.IsConnected) pipe.Disconnect(); } catch (IOException) { } } + } + } + } + + private void OnChanged() => Changed?.Invoke(); + public void Dispose() { stopped.Cancel(); foreach (var pipe in listeners) pipe.Dispose(); } + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint pid); + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetNamedPipeClientSessionId(SafePipeHandle pipe, out uint session); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetNamedPipeClientComputerNameW(SafePipeHandle pipe, StringBuilder name, uint length); +} diff --git a/tools/WinCode.Tray/Program.cs b/tools/WinCode.Tray/Program.cs new file mode 100644 index 0000000..74a89a9 --- /dev/null +++ b/tools/WinCode.Tray/Program.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection; +using System.Security.Principal; +using System.Text.Json; + +namespace WinCode.Tray; + +internal static class Program +{ + public static string Version => typeof(Program).Assembly.GetName().Version!.ToString(3); + internal static void ShowExisting(string endpoint) + { + using var existing = new NamedPipeClientStream(".", endpoint, PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, TokenImpersonationLevel.Identification); + existing.Connect(2000); + existing.Write(JsonSerializer.SerializeToUtf8Bytes(new { v = 1, type = "show" })); existing.WriteByte(10); + using var timeout = new CancellationTokenSource(3000); + var reply = new FrameReader(existing).Read(timeout.Token).GetAwaiter().GetResult(); + if (reply.GetProperty("v").GetInt32() != 1 || reply.GetProperty("type").GetString() != "show-accepted") + throw new IOException("已有托盘没有确认显示请求。"); + } + [STAThread] + private static int Main(string[] args) + { + try + { + if (args.SequenceEqual(new[] { "--endpoint" })) { Console.WriteLine(JsonSerializer.Serialize(new { version = Version, pipeName = PipeHub.Endpoint })); return 0; } + if (args.SequenceEqual(new[] { "--identity" })) { + Console.WriteLine(JsonSerializer.Serialize(new { version = Version, configuration = typeof(Program).Assembly.GetCustomAttribute()?.Configuration, protocolVersion = 1 })); return 0; + } + var selfTest = args.Length == 2 && args[0] is "--self-test" or "--workflow-test" && Path.IsPathFullyQualified(args[1]); + if (!selfTest && args.Length != 0 && !args.SequenceEqual(new[] { "--show" })) throw new ArgumentException("Supported: --show, --endpoint, --identity, --self-test ABSOLUTE_REPORT_DIRECTORY"); + string endpoint = PipeHub.Endpoint + (selfTest ? ".test-" + Guid.NewGuid().ToString("N") : ""); + using var single = new Mutex(true, "Local\\" + endpoint, out var created); + if (!created) { + ShowExisting(endpoint); + return 0; + } + if (selfTest) Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); + ApplicationConfiguration.Initialize(); + using var hub = new PipeHub(endpoint); + using var window = new SettingsWindow(hub); + if (selfTest) { + window.Shown += async (_, _) => { + if (args[0] == "--workflow-test") await TrayAcceptance.RunWorkflow(window, hub, endpoint, args[1]); + else await TrayAcceptance.Run(window, hub, endpoint, args[1]); + }; + Application.Run(window); + } else { + var context = new ApplicationContext(); + window.FormClosed += (_, _) => { if (window.ExitRequested) context.ExitThread(); }; + _ = window.Handle; // 隐藏时也接受“显示设置”和实例变化消息。 + if (args.Contains("--show")) window.Open(); + Application.Run(context); + } + single.ReleaseMutex(); + return Environment.ExitCode; + } + catch (Exception error) { + Console.Error.WriteLine(args.Any(arg => arg is "--self-test" or "--workflow-test") ? error.ToString() : error.Message); + if (args.Length == 0 || args.SequenceEqual(new[] { "--show" })) MessageBox.Show(error.Message, "WinCode 托盘启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + return 1; + } + } +} diff --git a/tools/WinCode.Tray/SettingsWindow.cs b/tools/WinCode.Tray/SettingsWindow.cs new file mode 100644 index 0000000..fbfa5e4 --- /dev/null +++ b/tools/WinCode.Tray/SettingsWindow.cs @@ -0,0 +1,156 @@ +using System.Text.Json; + +namespace WinCode.Tray; + +internal sealed class SettingsWindow : Form +{ + private readonly PipeHub hub; + private readonly DataGridView grid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, + AllowUserToDeleteRows = false, RowHeadersVisible = false, MultiSelect = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, BackgroundColor = SystemColors.Window, BorderStyle = BorderStyle.FixedSingle, + AccessibleName = "已连接的 WinCode 实例" }; + private readonly Label summary = new() { AutoSize = true, MaximumSize = new Size(1000, 0) }; + private readonly Label detail = new() { Dock = DockStyle.Fill, AutoSize = true, MaximumSize = new Size(1100, 0), AccessibleName = "操作结果" }; + private readonly Button refresh = new() { Text = "刷新状态", AutoSize = true, AccessibleName = "刷新状态" }; + private readonly Button release = new() { Text = "释放 Roslyn 内存", AutoSize = true, Enabled = false, AccessibleName = "手动释放 Roslyn 内存" }; + private readonly Button stop = new() { Text = "停止此实例", AutoSize = true, Enabled = false }; + private readonly NotifyIcon icon; + private bool exiting, updating, acting, refreshing; + private readonly System.Windows.Forms.Timer freshness = new() { Interval = 1000 }; + public bool ExitRequested => exiting; + public string LastResult { get; private set; } = ""; + private string? lastResultInstanceId; + internal string SelectedDetail => detail.Text; + + public SettingsWindow(PipeHub hub) + { + this.hub = hub; + Text = $"WinCode 设置 · {Program.Version}"; AccessibleName = "WinCode 设置"; + Size = new Size(920, 570); MinimumSize = new Size(780, 500); StartPosition = FormStartPosition.CenterScreen; + Font = new Font("Microsoft YaHei UI", 10); AutoScaleMode = AutoScaleMode.Dpi; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(22), ColumnCount = 1, RowCount = 7 }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 108)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(new Label { Text = "内存管理", AutoSize = true, Font = new Font(Font.FontFamily, 17, FontStyle.Bold), Margin = new Padding(0, 0, 0, 12) }); + layout.Controls.Add(new Label { Text = "自动释放:关闭。保留 Roslyn 热状态,需腾出内存时再手动释放。", + AutoSize = true, MaximumSize = new Size(1000, 0), Margin = new Padding(0, 0, 0, 10) }); + layout.Controls.Add(summary); + grid.Columns.Add("workspace", "工作区"); grid.Columns.Add("provider", "代码能力"); grid.Columns.Add("state", "状态"); + grid.Columns.Add("memory", "Roslyn"); grid.Columns.Add("pid", "PID"); + grid.Columns[0].FillWeight = 240; grid.Columns[4].FillWeight = 55; + layout.Controls.Add(grid); + var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(0, 10, 0, 8) }; + buttons.Controls.AddRange([refresh, release, stop]); layout.Controls.Add(buttons); layout.Controls.Add(detail); + layout.Controls.Add(new Label { Text = "暂无在途请求不代表 Agent 任务结束。释放后首次语义查询需要重新加载,旧定位需重新搜索;在途请求或收尾期间拒绝释放。", + AutoSize = true, MaximumSize = new Size(1000, 0), ForeColor = SystemColors.GrayText }); + Controls.Add(layout); + layout.SizeChanged += (_, _) => { + int width = Math.Max(200, layout.ClientSize.Width - layout.Padding.Horizontal - 12); + foreach (var label in layout.Controls.OfType