diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fed1434..a0856bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,8 @@ jobs: regression: name: Windows regression (Node ${{ matrix.node }}) runs-on: windows-2025 - timeout-minutes: 15 + # Cold setup plus the full Node 22 native matrix can exceed 15 minutes. + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -43,6 +44,9 @@ jobs: run: npm ci - name: Build and verify delivery run: npm run check + - name: Verify shared cache contracts + if: matrix.node == '22' + run: node scripts/verify-shared-cache.mjs - name: Verify tool errors and recovery contracts if: matrix.node == '22' run: npm run test:error-contracts @@ -59,17 +63,28 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } npm run test:manual-release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Preserve bounded check report + - name: Verify concurrent SDK clients + if: matrix.node == '22' + run: node scripts/verify-multi-agent.mjs --roslyn-only + - name: Verify design-time output ownership + if: matrix.node == '22' + run: node scripts/verify-design-time-concurrency.mjs + - name: Preserve check reports and stage logs if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: check-node-${{ matrix.node }} path: | test-tmp/check/**/report.json + test-tmp/check/**/*.log + test-tmp/check/**/*.xml 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 + test-tmp/multi-agent/**/report.json + test-tmp/design-time-production/**/report.json + test-tmp/shared-cache/**/report.json if-no-files-found: warn retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a4b6c..bd122a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.15.0 (unreleased) + +- Add per-Host design-time intermediate outputs, original project exclusion/import preservation, conservative generated-input filtering, and owned output cleanup. Internal Host input policy is now 2; missing/old policy handshakes are rejected and their processes reaped. The local 2026-09-11 build passes core regression and real MCP concurrent startup; remote CI and actual consumer rollout for these changes remain pending. +- Preserve `PROJECT_LOAD_FAILED` when original project evaluation rejects malformed project XML. Validate Configuration and TargetFramework as literal directory segments before Host admission, and verify normalized design-time output containment inside the owning UUID namespace. +- Replace the prototype comparison acceptance entry with verification of the current published Host and production client. Use the actual Host UUID for blockers and ownership checks; fail on selected-case errors, empty selection, changed delivery, cleanup failures or surviving observed processes. CI now includes simultaneous A/B/A startup and the production semantic/concurrency/input matrix, including prebuilt custom outputs and two target frameworks. +- Bind cached payload integrity to the namespaced key and validate overflow size/SHA-256 before memory or disk reuse. Rebuild on missing/corrupt attachments, swapped or changed JSON payloads, and older entries without integrity metadata. Keep the existing directory layout, public MCP shape and cleanup ownership. +- Bound JSON reads by the opened file size plus one detection byte and stream attachment checks in 64 KiB chunks within the existing disk budget. Preserve managed metadata for uncacheable attachments so existing capacity/TTL cleanup can reclaim them. Disk limits remain periodic cleanup targets, and returned attachments have no cross-call retention lease. +- Recheck the exact memory entry after asynchronous validation, and invalidate in-flight disk reads across writes, pruning and workspace resets. Drain accepted writes before disk reads and serialize stale-entry cleanup with a state check. Concurrent reads can no longer resurrect cleared/evicted values, overwrite replacements, miscount memory, or unlink newer local writes. Failed attachment validation only removes its own memory entry. +- Add eight real SDK/Gateway shared-cache scenarios and extend the published-Host matrix to 21 scenarios with two peers rejecting old locators after an edit, reloading to updated references and retaining the survivor's snapshot. CI includes both matrices and their failure reports; the current local increment has not been pushed to run remote CI. + +- Bound admission to 32 unfinished business calls and four shared lightweight status calls per instance, with 64 KiB raw UTF-8 JSON arguments before normalization. Report SERVER_BUSY before execution; preserve FIFO in existing mutexes, cancellation through actual cleanup, and a deadline that includes queue wait. Do not automatically replay calls or restart a Host on overload. +- Reuse the admitted request's timer when the code operation has the same deadline. Preserve REQUEST_TIMEOUT through shorter adapter queue budgets, reject results completed after the request deadline, and count synchronous deadline failures even before the lease timer runs. Capacity is still returned only after the call finishes its cleanup. +- Remove cancelled startup/recovery waiters, expose admission counters/timings, and keep status available during business saturation. Passive hello uses known cache observations, with explicit unknown/incomplete disk values; diagnosis refreshes disk statistics. Retain manual-release and shutdown barriers through pending work and cleanup. +- Retain the original shared `obj` collision reports and simultaneous same-root startup as regression coverage. The production private-output implementation passes that bounded case; the separated-startup diagnostic mode still cannot establish concurrency safety. Arbitrary target-generated project references, power-loss orphan cleanup, UI concurrency and long-term storage behavior remain unverified. + +- Bind each connection to its startup workspace. `workspace_open` now only confirms or recovers that root; other roots return `WORKSPACE_MISMATCH` with the active/requested paths and `select_workspace_connection`. Configure a separate connection for each project. Both the MCP and core entry points reject mismatches before queueing or changing resources. +- Expose `health.workspaceBinding` with the fixed root and binding source. Explicit `--workspace` / `-w` requires an absolute path; omission fixes the launch directory. Validate the existing, link-free root before initializing caches. Preserve healthy same-root Host/snapshot reuse and failure-gated recovery. +- Migrate lifecycle and real Roslyn/UI verification to independent fixed connections, retain same-root fault injection, and add startup, mismatch, relative-path and Windows alias regression coverage. Synchronize managed manuals and native version metadata; native UI inspection behavior is unchanged. + +- Preserve failed check-stage exit status, TAP totals, captured logs and native Node JUnit assertions before returning failure; upload these bounded diagnostics in CI. Reject incomplete test summaries and mark interrupted output capture explicitly. +- Register cleanup before resource-owning regression tests start so assertion failures release watchers and allow a complete failure report. Keep the non-Git fixture valid when TEMP is inside the repository by limiting Git discovery in that test process. +- Skip snapshotted resources unregistered before disposal starts, release process listeners on natural exit, and stop probing or signalling retained ChildProcess PIDs after a known exit. Preserve real owned-process cleanup and deadline behavior; OS-level atomic PID identity validation is not added. + ## 0.14.0 (unreleased) - Resolve Git from launch-time installation paths outside the workspace and use absolute argv-based execution. Disable executable fsmonitor configuration, require Git 2.36+, recognize linked worktrees and report unknown status when Git fails. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00eda38..db803c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,10 +11,12 @@ npm run delivery:verify `check` inventories every `*.test.ts`, type-checks, builds the Gateway, restores native dependencies in locked mode, builds the Release Host and console fixtures, runs non-interactive regression and fresh-process stdio checks, then creates and verifies `dist/delivery-manifest.json`. `check:desktop` verifies that delivery, publishes the isolated WPF fixture and runs UI plus UI-to-source tests in an interactive Windows session. `test:all` runs both. Real Roslyn Host and MCP checks run on Node 22 in CI; TavernDesk checks remain opt-in. No check installs global prerequisites or changes client configuration. -Reports and bounded stage logs are under `test-tmp/check//`. CI uploads only the compact report, including failures; it does not upload local workspaces or screenshots. A passing core check does not establish desktop or real-upstream acceptance. +Reports and bounded stage logs are under `test-tmp/check//`. CI retains the report, captured stage logs and Node's JUnit test reports for seven days, including failed runs; it does not upload local workspaces or screenshots. Test stages record exit status, TAP totals and the JUnit path before propagating failure, so an early assertion remains available after later passing output. Capture keeps the existing 8 MiB process-output budget; launch, timeout or overflow errors set `outputCaptureComplete=false`, and missing TAP totals cannot pass even with exit code zero. A JUnit path alone does not prove a run completed. A passing core check does not establish desktop or real-upstream acceptance. `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. +Windows CI jobs have a 20-minute overall budget for cold setup and native acceptance. Node 22 runs shared-cache checks early, then gives SDK concurrency and design-time output ownership separate steps. Design-time readiness uses the production Roslyn load budget of 120 seconds; it does not impose an additional cold-start performance target. Production request deadlines remain unchanged. + `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. @@ -37,12 +39,16 @@ 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. The four managed Skill files are delivery inputs, so regenerate/verify the manifest after updating them; this does not synchronize an installed client Skill or reconnect its MCP process. -Node 22 CI runs `npm run test:error-contracts` and uploads its bounded report. It exercises protocol errors, matching tool-error text/structured payloads, real generated-file trash failures and workspace recovery; injected UI images test serialization only. Run it locally after changes to these boundaries. +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, structured WORKSPACE_MISMATCH and same-root 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, rejection of other roots and independent fixed A/B connections. 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. -`npm run test:manual-release` verifies ten actual Roslyn release/reload cycles in generated C# projects, old-location rejection, stable owned resources, retained cache/watcher, edits while cold and A/B workspace reuse. The optional Tray is built and version/fingerprint checked by `check`; `check:desktop` additionally runs `npm run test:tray`, exercising actual WinForms and secured Named Pipes with two isolated stdio MCP clients and simulated Roslyn lifetimes. Reports are under `test-tmp/manual-release/` and `test-tmp/tray/`. UI screenshots are local only; these checks do not enable autostart or alter installed MCP client configuration. +The core inventory includes `tests/request-admission.test.ts`: MCP 4/8/16/128-call bursts, UTF-8 argument limits, status saturation, startup/recovery waiting, repeated cancellation and replacement, deadline carry-over and cleanup/shutdown ownership. `scripts/verify-multi-agent.mjs` separately measures production Roslyn across three instances, including accepted/busy/cancelled outcomes and client drain warnings. The core inventory includes `tests/runtime-cache-regressions.test.ts`: actual input freshness despite same-size/restored-mtime writes, additions/deletions, bounded parsing reuse, missing overflow in memory/disk caches, two actual cache processes, and same-root cancellation/slow-query interleaving. Keep these adversarial cases when changing caching or workspace lifecycle. Process-tree observation also tests PID reuse: every parent edge must respect creation order, so an old system process cannot become a new Helper descendant merely through a recycled PID. A client cancellation may settle before Gateway cleanup; same-root confirmation is not a drain barrier. Observe actual in-flight completion within the existing budget, then retain strict owned-process exit assertions. +`tests/resource-identity.test.ts` separately exercises production cleanup: recheck registration immediately before invoking a snapshotted disposer, and do not probe or signal the retained PID of a ChildProcess whose exit is already known. Natural exit removes both ownership listeners. These intercepted-OS regressions supplement actual owned-child exit checks; they do not establish an atomic Windows process-identity check across a later `taskkill` call. + 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 8da16fa..f22295e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 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.14.0**. All UI tools are strictly read-only and non-destructive. See [CHANGELOG](CHANGELOG.md) for full version history. +Current source version: **0.15.0**. All UI tools are read-only. See [CHANGELOG](CHANGELOG.md) for the fixed-workspace migration and 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. @@ -40,9 +40,9 @@ npm run check npm run delivery:verify ``` -Add WinCode as a stdio MCP server in your agent client configuration (for clients that support `mcpServers`). Choose either startup mode below. +Add WinCode as a stdio MCP server in your agent client configuration (for clients that support `mcpServers`). Each connection binds one project at startup; an explicit absolute `--workspace` is recommended. -**Choose a project when needed:** If the target project is not yet known, or you want to query different projects in sequence, configure only the server entry point: +**Bind the launch directory:** Use this only when the client reliably starts the server in the intended project: ```json { @@ -55,9 +55,13 @@ Add WinCode as a stdio MCP server in your agent client configuration (for client } ``` -Without `--workspace`, WinCode initially uses the server process's current working directory, which may differ from your intended project. Before querying, ask the agent to call `workspace_open` with the target project's absolute path, for example `workspace_open({"path":"C:/path/to/project"})`. Repeat this when switching projects; the server installation path stays the same. Confirming the same healthy workspace keeps the Roslyn Host/snapshot warm and does not drain active queries. Cancelling that confirmation does not force recovery; known restart/cleanup failures still follow the explicit recovery path. One server process has one active workspace, so calls sharing that process must not interleave queries for different projects. For concurrent independent projects, configure separate server instances with distinct names and explicit workspace paths. +Without `--workspace`, WinCode binds the launch directory for the lifetime of that connection. `health.workspaceBinding` reports the fixed root and its source. `workspace_open` confirms or recovers that root; a different root returns `WORKSPACE_MISMATCH` before draining requests or changing resources. Select a connection configured for the other project. Project-scoped configurations may reuse a server name; multiple instances in one shared configuration need distinct names. Healthy same-root confirmation preserves the Host/snapshot and does not drain active queries; known recovery failures still follow the explicit recovery path. -**Specify a project at startup:** Add `--workspace` followed by the project directory: +Each Roslyn Host now uses a private design-time intermediate directory while preserving the project's restore location, Compile exclusions and original import hook. Local production acceptance includes three independent MCP processes cold-loading A/B/A concurrently, exact references and owned output cleanup; the reproduced shared `obj` write collision passes this bounded regression. + +0.15.0 remains unreleased. After the 2026-09-11 cache-state and request-deadline fixes, the final local Node 24.19.0 build passes all 452 core tests and the complete non-desktop acceptance sequence: 59 Native Host cases, 22 Roslyn Gateway cases, 21 published-Host concurrency/input cases, ten simultaneous A/B/A SDK/Roslyn scenarios, eight shared-cache scenarios, 17 error-contract cases, ten release cycles and both owner-death checks. Delivery verification matches before and after acceptance. Earlier failures and the SDK client's transient burst warning remain recorded in the work log. The local changes still require submission and required checks on the new PR head; Node 22 is not locally verified. Generated fixtures do not establish an updated consumer connection, UI concurrency or long-term resource behavior. See the [remaining work](WinCode-下一轮工程化迭代计划书.md#2026-09-11-恢复顺序). + +**Specify a project at startup (recommended):** Add `--workspace` followed by the existing project directory's absolute path: ```json { @@ -74,7 +78,7 @@ Without `--workspace`, WinCode initially uses the server process's current worki For graphical configuration interfaces: -| Field | Choose a project when needed | Specify a project at startup | +| Field | Bind the launch directory | Specify a project at startup | | --- | --- | --- | | Name / Type | `wincode` / `stdio` | `wincode` / `stdio` | | Command | `node` | `node` | @@ -82,7 +86,7 @@ For graphical configuration interfaces: | Argument 2 | Omit | `--workspace` | | Argument 3 | Omit | `C:/path/to/project` | -Add each argument as a separate entry, without extra surrounding quotes even when a path contains spaces. To defer project selection, remove both `--workspace` and its value; do not leave an empty value. Ensure `node` is available in PATH, or specify its absolute executable path. No additional environment variables are required for this basic configuration. +Add each argument as a separate entry, without extra surrounding quotes even when a path contains spaces. Explicit `--workspace` (or `-w`) requires a nonempty absolute path; omission binds the launch directory. Ensure `node` is available in PATH, or specify its absolute executable path. No extra environment variables are required. For prompt engineering and token-efficient skill routing, refer to the optional [Skill and MCP setup guide](WinCode-Skill制作与MCP配置指南.md). @@ -149,7 +153,7 @@ The 2026-09-08 check of the current Codex connection against TavernDesk source p | Tool | Purpose | | --- | --- | -| `workspace_open` | Open or switch workspace, isolate caches and return a bounded project summary. | +| `workspace_open` | Confirm or recover the fixed workspace and return a bounded summary; reject other roots. | | `wincode_list_directory` | Browse a specific workspace directory with entry, depth and output limits. | | `wincode_analyze_workspace` | Parse solution structure and declared `.sln`/`.csproj` project references. | | `wincode_prepare_context` | Prepare scoped code evidence and actual line ranges within a character-based output budget. | @@ -170,6 +174,8 @@ Architecture analysis accepts integer depths 1–5 and returns `scanComplete`, ` Git probes use a detected absolute installation path outside the workspace, require Git 2.36 or later and disable executable fsmonitor configuration. Missing or failed Git status is `unknown`, with no assertion that the tree is clean. Cache/trash writes reject existing symlinks and junctions in their paths. Cache cleanup manages versioned WinCode JSON and reserved overflow names; legacy/unrecognized files remain untouched and are outside the managed quota. These checks do not provide an atomic sandbox against concurrent filesystem replacement. +Raw arguments, including unknown fields, are limited to 64 KiB of UTF-8 JSON before normalization. SERVER_BUSY includes workStarted:false, retryable:true and a capacity snapshot; retry only when needed, without automatic replay or Host restart. REQUEST_TIMEOUT includes queue time and does not prove work never started. health.admission exposes counters and timing. Passive hello uses known disk observations, with null values before an explicit diagnostic scan. These limits do not remove SDK parsed-frame allocation or bound process RSS. + ### Architecture and resource control See the [architecture, data-flow and verification-gate guide](WinCode-架构与数据流说明.md) for the current component boundaries, request sequences, storage lifecycle and delivery checks (Chinese). @@ -183,8 +189,8 @@ Coding agent ── stdio MCP ── WinCode ``` - **Owned-process cleanup:** UI inspection executes out-of-process via an isolated helper (`tools/WinCode.UIA.Host`). All process cleanups target only the owned helper process tree via Windows `taskkill /T`; the inspected target application is never terminated or injected. -- **Concurrency Protection:** UI inspection and health checks share a serial execution mutex to prevent native UIA message pump deadlocks. Workspace switches safely drain in-flight calls before changing cache namespaces. -- **Byte-Bounded Cache:** The shared cache manager bounds retained serialized data (default 32 MiB memory, 128 MiB disk including overflow); these are not process RSS limits. Local-text queries re-enumerate bounded inputs and reuse declarations by content hash. Builtin packs validate the actual selected contents before reuse; CLI output without a verified input manifest is not cached. Missing overflow files become cache misses. Watch/index probes invalidate the ~2.5s change-hint memo; that hint is not proof of source identity or a guarantee that watcher events are complete. +- **Concurrency Protection:** UI inspection and health checks share a serial mutex. Each connection keeps its startup workspace; other-root requests are rejected before lifecycle work. Same-root recovery drains in-flight calls within its deadline. Admission accepts at most 32 unfinished business calls and four shared hello/tools-list calls per instance. Existing adapter mutexes keep FIFO waiting; other work can still run in parallel. Queueing consumes the request deadline, and active cancellation retains capacity until cleanup completes. +- **Byte-Bounded Cache:** The shared cache manager budgets retained serialized data (default 32 MiB memory, 128 MiB disk including overflow); these are not process RSS limits, and periodic disk cleanup is not an instantaneous cross-process quota. Local-text queries re-enumerate bounded inputs and reuse declarations by content hash. Builtin packs validate the actual selected contents before reuse; CLI output without a verified input manifest is not cached. Cache reads check key-bound payload integrity and attachment size/SHA-256; missing, corrupt or older entries without integrity metadata become cache misses. Returned attachments remain subject to later eviction. Watch/index probes invalidate the ~2.5s change-hint memo; that hint is not proof of source identity or a guarantee that watcher events are complete. | UI budget | Limit / behavior | | --- | --- | @@ -254,7 +260,7 @@ WinCode 是面向 Windows 与 .NET 工程研发的本地 MCP 服务。它将项 - **观察实际界面:**发现系统可见窗口,按条件定向查询目标控件或子树,并在不激活、不抢占前台焦点的前提下获取数字标注截图。 - **源码双向印证:**将运行时抓取的控件关联回 XAML 源码声明的起始行号、代码片段与文件哈希,清晰报告歧义、截断与降级状态。 -当前源码版本为 **0.14.0**。所有 UI 取证工具均为纯只读与非侵入设计。版本历史见 [CHANGELOG](CHANGELOG.md)。 +当前源码版本为 **0.15.0**。UI 工具仅执行只读取证;固定工作区迁移和版本历史见 [CHANGELOG](CHANGELOG.md)。 **平台与兼容性说明:**本项目以 **Windows 11 x64** 为本地开发与测试基准。其他操作系统、其他 Windows 版本或不同依赖版本下,功能表现、运行行为与性能不保证完全一致。建议 **macOS、Linux 用户通过 fork 本仓库进行本地适配与验证**;请以本项目文档和锁定文件中列出的依赖版本作为参考环境。 @@ -272,9 +278,9 @@ npm run check npm run delivery:verify ``` -在 Agent 客户端配置文件中添加 stdio MCP 服务(以支持 `mcpServers` 的客户端为例),可按需要选择以下两种启动方式。 +在 Agent 客户端配置文件中添加 stdio MCP 服务(以支持 `mcpServers` 的客户端为例)。每条连接在启动时固定一个项目,推荐显式指定绝对路径 `--workspace`。 -**使用时再选择项目:**如果暂时不确定目标项目,或需要依次查询多个项目,只配置服务入口: +**绑定启动目录:**仅在客户端能够保证服务启动目录就是目标项目时使用: ```json { @@ -287,9 +293,13 @@ npm run delivery:verify } ``` -省略 `--workspace` 时,WinCode 初始使用服务进程的当前工作目录,它不一定是你要分析的项目。查询前,让 Agent 调用 `workspace_open` 并传入目标项目的绝对路径,例如 `workspace_open({"path":"C:/path/to/project"})`。换项目时再次调用即可,服务安装路径无需修改。同一健康工作区的重复确认保留 Roslyn Host/快照,不等待在途业务排空;取消该确认不会强制进入恢复。已知重启要求和清理失败仍走显式恢复路径。一个服务进程只有一个活动工作区,共享该进程的调用不能交错查询不同项目;如需同时独立查询多个项目,应配置名称不同、各自明确指定工作区路径的服务实例。 +省略 `--workspace` 会将启动目录固定为本连接的工作区,不能留待后续选择。`health.workspaceBinding` 返回固定根及其来源。`workspace_open` 仅确认或恢复同根;其他根返回 `WORKSPACE_MISMATCH`,不会排空请求或修改资源,应选择绑定该项目的连接。不同项目的局部配置可复用服务名;同一共享配置中的实例需要不同名称。同根健康确认保留 Host、快照及监听,不等待业务排空;已知故障仍按诊断手册恢复。 -**启动时指定项目:**添加 `--workspace`,并在其后填写项目目录: +每个 Roslyn Host 现在使用私有设计时中间目录,并保留项目的 restore 位置、Compile 排除规则和原导入 hook。本地生产验收已覆盖三个独立 MCP 进程同时冷加载 A/B/A、精确引用和所属产物回收;此前共享 `obj` 写入竞争的具体反例已通过回归。 + +0.15.0 仍未发布。2026-09-11 缓存状态及请求截止修复后的最终本地 Node 24.19.0 构建通过核心 452/452 及完整非桌面验收:Native Host 59 项、Roslyn Gateway 22 项、正式 Host 并发/输入矩阵 21 项、同时 A/B/A SDK/Roslyn 10 场景、共享缓存 8 场景、错误契约 17 项、手动释放 10 轮及两类 owner-death。验收前后交付核验一致。历史失败和 SDK 客户端突发警告保留在工作日志中。当前增量仍需提交及新 PR head 的必需检查;Node 22 未在本地验证。生成夹具不能证明既有消费者连接已更新、UI 并发或长期资源行为,见[待办清单](WinCode-下一轮工程化迭代计划书.md#2026-09-11-恢复顺序)。 + +**启动时指定项目(推荐):**添加 `--workspace` 和已存在的项目目录绝对路径: ```json { @@ -306,7 +316,7 @@ npm run delivery:verify 若通过图形界面添加: -| 配置字段 | 使用时再选择项目 | 启动时指定项目 | +| 配置字段 | 绑定启动目录 | 启动时指定项目 | | --- | --- | --- | | 服务名称 / 类型 | `wincode` / `stdio` | `wincode` / `stdio` | | 启动命令 | `node` | `node` | @@ -314,7 +324,7 @@ npm run delivery:verify | 参数 2 | 不添加 | `--workspace` | | 参数 3 | 不添加 | `C:/path/to/project` | -每个参数独立添加为一行,路径包含空格时也无需额外加引号。使用时再选择项目,应同时删除 `--workspace` 及其值,不要保留空值。确保系统环境变量 PATH 中包含 `node`,或直接填写 node.exe 的绝对路径。这一基础配置无需额外设置环境变量。 +每个参数独立添加为一行,路径包含空格时也无需额外加引号。显式 `--workspace`(或 `-w`)必须附带非空绝对路径;省略参数表示绑定启动目录。确保 PATH 中包含 `node`,或填写 node.exe 的绝对路径。无需额外设置环境变量。 如需配合 Agent Skill 获得低 Token 开销的精准任务路由,请参阅可选的 [Skill 与 MCP 配置指南](WinCode-Skill制作与MCP配置指南.md)。 @@ -369,7 +379,7 @@ npm run delivery:verify 默认以 `local-text` 启动,并明确报告语义能力未配置。显式配置直接 Roslyn 后,将搜索返回的完整 `location` 作为 `symbolLocation` 传给引用、影响分析或重构工具,名称使用原结果的简单名称。外部 Serena 启动配置及 namePath 身份已退役;过期快照须重新显式搜索。 -`wincode_hello_world` 返回启动时固定的实例 ID、构建指纹及当前注册工具定义的 hash。传 `toolName: "wincode_prepare_context"` 可按需查看单个工具参数,与同一连接的 `tools/list` 对照。`npm run build` 生成 manifest;直接运行 `tsc`、产物缺失/失配或源码开发模式会明确报告 `unknown`。构建指纹校验本地产物一致性,不证明发布来源可信;切换分析工作区不会改变运行构建。 +`wincode_hello_world` 返回启动时固定的实例 ID、构建指纹及当前注册工具定义的 hash。传 `toolName: "wincode_prepare_context"` 可按需查看单个工具参数,与同一连接的 `tools/list` 对照。`npm run build` 生成 manifest;直接运行 `tsc`、产物缺失/失配或源码开发模式会明确报告 `unknown`。构建指纹校验本地产物一致性,不证明发布来源可信;本连接的分析工作区在启动时固定,health.workspaceBinding 返回根及来源。 显式 `lineRanges` 的 `coverage` 按最终返回正文计算:请求/完整行数、实际返回区间、未返回区间及原因。尾行只有一部分字符(`endLineComplete:false`)不计完整覆盖;可补取缺口可带有界 `nextRequest`,EOF/缺文件不建议盲重试。明细超预算会记录 `omittedItemCount` 并保留总计。其他请求 `coverage:null`,`taskCoverage` 始终为 null,片段非空不证明整个方法或任务证据充足。`npm run test:tavern-context -- ` 在新 stdio 进程执行显式启动的只读源码验收。 @@ -381,7 +391,7 @@ npm run delivery:verify | 工具名称 | 功能描述 | | --- | --- | -| `workspace_open` | 打开或切换工作区、隔离缓存,并返回有界项目摘要。 | +| `workspace_open` | 确认或恢复本连接固定的工作区,返回有界摘要;拒绝其他根目录。 | | `wincode_list_directory` | 按指定目录浏览,限制条目、深度与整份输出。 | | `wincode_analyze_workspace` | 解析工程依赖拓扑,提取 `.sln`/`.csproj` 项目引用关系。 | | `wincode_prepare_context` | 在基于字符数估算的输出预算内,按文件、符号或行号范围提供代码证据。 | @@ -402,6 +412,8 @@ npm run delivery:verify Git 探测从工作区外的安装位置取得绝对可执行路径,要求 Git 2.36 及以上,并禁用可执行的 fsmonitor 配置;缺失或查询失败明确为 `unknown`,不报告干净。缓存和回收写入拒绝路径中已有的符号链接/junction。缓存仅管理带版本标记的 WinCode JSON 与保留命名的 overflow;旧版及无法识别的文件保留,不计入受管配额。这些校验不提供对抗并发路径替换的原子沙盒保证。 +原始参数(含未知字段)按 UTF-8 JSON 限制为 64 KiB。SERVER_BUSY 附 workStarted=false、retryable=true 和容量快照;按需稍后重试,不自动重放或重启 Host。REQUEST_TIMEOUT 包括排队时间,不证明业务尚未执行。health.admission 提供计数与耗时;被动 hello 仅读取最近磁盘观察,显式诊断前磁盘数值为 null。这些限制不能消除 SDK 解析帧的瞬时分配,也不是 RSS 硬上限。 + ### 架构设计与资源管控 ```text @@ -413,8 +425,8 @@ Coding Agent ── stdio MCP ── WinCode ``` - **目标进程绝对免疫:**UI 取证由独立的 C# 辅助进程(`tools/WinCode.UIA.Host`)在进程外执行。所有清理操作严格仅终止自身派生的 Helper 辅助进程树(通过 Windows `taskkill /T`),**被测目标应用进程受绝对免疫保护,绝不被终止或注入**。 -- **防死锁与并发保护:**UI 自动化访问与健康检查共用串行互斥锁,杜绝底层 Win32/UIA 消息泵死锁。切换工作区前会先等待排空在途请求,超时则拒绝切换,保证会话隔离安全。 -- **按字节约束缓存:**缓存条目按工作区 namespace 隔离,多个实例仍可能共用磁盘目录;默认序列化内存预算 32 MiB、磁盘配额 128 MiB(含 overflow),不等于进程 RSS 上限。local-text 每次有界扫描实际输入,按内容哈希复用声明解析;内置打包核对实际选中文件的内容后复用,没有可核验输入清单的 CLI 结果不缓存。附件缺失按缓存未命中重建。150 ms 去抖监听与索引探测只使约 2.5 秒的变更提示 memo 失效,不能证明源码完整身份或保证监听事件无遗漏。 +- **并发保护:**UI 访问与健康检查共用串行互斥锁。每条连接固定启动工作区,其他根在生命周期操作前被拒绝。同根恢复限时等待在途请求排空;每实例最多受理 32 个未完成业务请求,hello/tools/list 共享 4 个轻量槽。既有适配器互斥保持 FIFO;排队计入请求预算,执行中取消须在实际清理后归还容量。 +- **按字节约束缓存:**缓存条目按工作区 namespace 隔离,多个实例仍可能共用磁盘目录;默认序列化内存预算 32 MiB、磁盘清理目标 128 MiB(含 overflow),不等于进程 RSS 上限,周期磁盘清理也不是跨进程瞬时硬配额。local-text 每次有界扫描实际输入,按内容哈希复用声明解析;内置打包核对实际选中文件的内容后复用,没有可核验输入清单的 CLI 结果不缓存。缓存读取核验绑定键的正文摘要及附件大小/SHA-256;缺失、损坏或旧条目没有校验元数据时重算。已返回的附件仍可能被后续清理。150 ms 去抖监听与索引探测只使约 2.5 秒的变更提示 memo 失效,不能证明源码完整身份或保证监听事件无遗漏。 | 取证预算指标 | 限制值与行为策略 | | --- | --- | @@ -452,7 +464,7 @@ Coding Agent ── stdio MCP ── WinCode 已知行号用 `lineRanges`;只需声明及附近上下文时用 `scopeFiles` 加 `symbol`,预算裁剪前为 24 行窗口。审核已知方法的异常处理、取消或资源释放时,若已有文件读取工具,优先结合有界 `rg` 上下文一次读到所需分支。小文件也可用 `scopeFiles` 加 `includeFullText:true` 在预算内读取正文。仅知道文件时用 `scopeFiles` 预览。需要发现候选之外的文件时再用 `candidateFiles`,它仍然是优先列表,不是排他范围。限定范围的符号定位目前使用 C#/TS/JS/Python 本地声明模式,会明确保留语义不完整、重名和缺失提示。 -默认 `compact` 返回一个 JSON 文本块;`responseFormat: "legacy"` 返回 JSON 加 Markdown。`maxTokens` 接受 512–65536,以全部返回文本的 UTF-16 字符数除以四估算,包含元数据,不等于真实模型 Token 数。结合实际行号、`queryComplete`、截断信息和 `bodyStatus` 判断证据是否够用;满足后继续分析,文件修改或工作区切换后重新取证。WinCode 没有跨调用证据有效期保证,基准中的复用策略也不是生产缓存。参数组合与限制见[代码手册](skills/wincode/references/code.md)。 +默认 `compact` 返回一个 JSON 文本块;`responseFormat: "legacy"` 返回 JSON 加 Markdown。`maxTokens` 接受 512–65536,以全部返回文本的 UTF-16 字符数除以四估算,包含元数据,不等于真实模型 Token 数。结合实际行号、`queryComplete`、截断信息和 `bodyStatus` 判断证据是否够用;满足后继续分析,文件修改或更换连接后重新取证。WinCode 没有跨调用证据有效期保证,基准中的复用策略也不是生产缓存。参数组合与限制见[代码手册](skills/wincode/references/code.md)。 ### 本地开发与测试验证 diff --git a/SECURITY.md b/SECURITY.md index 2b0951d..9419463 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,8 @@ # Security policy / 安全策略 -The latest 0.13.x version and current `main` are maintained; the current development line on `main` is 0.14.0. Older versions do not have a separate backport commitment. Supported runtimes are Node 24 (primary) and Node 22 (compatibility), on Windows x64; build requirements are in [CONTRIBUTING](CONTRIBUTING.md). +The latest 0.13.x version and current `main` are maintained; `main` is the 0.14.0 baseline, with fixed workspaces in the local 0.15.0 development branch. Older versions do not have a separate backport commitment. Supported runtimes are Node 24 (primary) and Node 22 (compatibility), on Windows x64; build requirements are in [CONTRIBUTING](CONTRIBUTING.md). -目前维护最新 0.13.x 版本与 `main`;`main` 当前开发线为 0.14.0,不承诺对旧版本单独回补。Windows x64 上以 Node 24 为主要环境、22 为兼容环境;构建要求见贡献指南。 +目前维护最新 0.13.x 版本与 `main`;`main` 基线为 0.14.0,本地 0.15.0 开发分支引入固定工作区,不承诺对旧版本单独回补。Windows x64 上以 Node 24 为主要环境、22 为兼容环境;构建要求见贡献指南。 Report suspected vulnerabilities through [GitHub private vulnerability reporting](https://github.com/linnnn89/WinCode/security/advisories/new). Include the affected version/build identity, reproduction steps, expected and observed behavior, and a minimal sanitized example. Do not include credentials, personal databases or private source unnecessarily. Avoid publishing exploit details in a public issue before coordination with the maintainer. @@ -12,4 +12,4 @@ Reports are triaged as maintainer availability permits. There is no guaranteed r 维护者按实际可用时间评估与复现,不承诺固定响应或修复时限。确认的问题及缓解措施通过报告沟通,并按需发布补丁或安全公告。扫描任务成功不等于已有告警已关闭。 -Relevant boundaries include workspace path containment, shell arguments and process ownership, bounded resource consumption, and UI audit integrity. WinCode may terminate helper processes it owns during cleanup; inspected application PIDs must remain outside that ownership. UI inspection and screenshots can expose application data, so reports should use isolated fixtures. Local audit logs and content hashes are diagnostic evidence, not tamper-proof records or release signatures. Report a violation of these boundaries even when a test currently passes. Workspace namespaces and separate PIDs do not prove cross-process storage isolation. A bounded change hint is not source identity; current builtin cache reuse validates selected content and treats missing overflow as a miss, without promising an atomic workspace snapshot or permanent file lease. Unified admission limits and task-level workspace binding remain planned; existing cache budgets are not process memory limits. +Relevant boundaries include workspace path containment, shell arguments and process ownership, bounded resource consumption, and UI audit integrity. WinCode may terminate helper processes it owns during cleanup; inspected application PIDs must remain outside that ownership. UI inspection and screenshots can expose application data, so reports should use isolated fixtures. Local audit logs and content hashes are diagnostic evidence, not tamper-proof records or release signatures. Report a violation of these boundaries even when a test currently passes. Workspace namespaces and separate PIDs do not prove cross-process storage isolation. A bounded change hint is not source identity; current builtin cache reuse validates selected content and treats missing overflow as a miss, without promising an atomic workspace snapshot or permanent file lease. Startup workspace binding rejects other roots at both the MCP and core layers; it is not an OS security boundary. Each instance admits at most 32 unfinished business requests and four lightweight status requests. Raw arguments are capped at 64 KiB of UTF-8 JSON before normalization. Admission and cache budgets do not bound process RSS or preempt stuck OS I/O; the SDK may already have parsed a larger frame. diff --git "a/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" "b/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" index 9fd6c0e..7f6844e 100644 --- "a/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" +++ "b/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" @@ -1,6 +1,6 @@ # WinCode Skill 安装、维护与 MCP 配置指南 -适用于 **0.14.0**,核对日期 2026-09-10(北京时间)。以下使用本机 `I:/WinCode` 路径举例;其他机器必须替换路径。客户端界面名称随版本变化,以实际界面为准。 +适用于 **0.15.0**,核对日期 2026-09-10(北京时间)。以下使用本机 `I:/WinCode` 路径举例;其他机器必须替换路径。客户端界面名称随版本变化,以实际界面为准。 ## 1. 三个独立对象 @@ -67,7 +67,7 @@ npm run skill:check -- C:/Users/40218/.agents/skills/wincode } ``` -不同客户端配置格式可能不同;本例不能直接替代 Codex 自身配置文件格式。不要重复注册多个同名或路径不同的旧实例。WinCode 走 stdio,无需另设 HTTP 服务。一个实例仍只有一个可切换的活动工作区,N1 固定项目尚未实施;不同项目并发应各自配置明确的 --workspace 和不同实例名,不能在同一连接交错切换。健康同根 workspace_open 保留热态,不是清理屏障;已知故障按 diagnostics 的恢复动作处理。 +不同客户端配置格式可能不同;本例不能直接替代 Codex 自身配置文件格式。每条 stdio 连接启动时固定项目,推荐绝对路径 --workspace;缺省固定 cwd。核对 health.workspaceBinding,workspace_open 仅确认或恢复同根,其他根返回 WORKSPACE_MISMATCH 后应选择正确连接。不同项目的局部配置可复用服务名;同一共享配置中的实例需不同名称。健康同根确认保留热态,不是清理屏障;已知故障按 diagnostics 恢复。 可选托盘按 README 启动,并仅给需要管理的实例添加独立参数 --tray。默认不连接托盘、不设置自启动;自动释放关闭,设置内只允许用户手动释放,忙碌时拒绝且不延后执行。本文仅更新配置说明,不代表已修改任何已安装客户端。 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 d72f710..8a09286 100644 --- "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" +++ "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" @@ -1,8 +1,8 @@ # WinCode 下一轮工程化迭代计划书 -更新:2026-09-10(北京时间)。工作区 D:/CODEX PROJECT/WinCode MCP;本地修复分支 codex/architecture-boundaries 基于 main 7d53fda(已合并 #35)。本轮优先落实架构审查第 1–2 批:执行/磁盘边界、Git 状态、本地正则和有界结构扫描。实际验证回执持续追加至工作日志。 +更新:2026-09-11(北京时间)。分支 codex/runtime-baseline-and-cleanup 已与 PR #37 的 aa6fc7f 检查点对齐,并继续完成本地修复,源码版本 0.15.0。A9、N1、N3 及 N4 私有输出的受控生产验收已有证据;本轮增量尚未提交或推送,未合并或发布。PR #37 实际为 open、非 draft,历史“草稿”用语描述的是未完成检查点,不代表 GitHub draft 标志。 -本版依据当前代码、多实例诊断和 GitHub 一手实现维护。用户已明确要求按设计开始修复;N1–N3 的推荐方向继续有效,不重复申请方向审批。此前 N2 热态保留与 N3 的互斥取消基础已完成,固定根迁移和完整准入容量尚未实现。已完成的 M0–M4、N2 从待办移除,历史证据保留在 [工作日志](docs/codex_worklog.md) 与 [CHANGELOG](CHANGELOG.md)。真实客户端配置和对外发布另按有效授权执行。 +用户已确认本地修复、TDD 验证及公开经验核查,并要求继续自审至合理的 PR 合并标准、操作限于工程目录。本文件将已完成开发移出待办,保留其验收范围、未定位失败和未验证事项;详细历次结果见 [工作日志](docs/codex_worklog.md)。当前代码修复及本地非桌面回归已完成,下一步是授权提交/推送及新 PR head 的必需检查;N4 UI/剩余故障边界与 N5 消费/资源验收分别保留为后续范围。本轮使用现有工具链,不调用模型或修改真实客户端;Codex 单列待验、先不动 agy CLI。 ## 1. 最终推荐 @@ -24,25 +24,29 @@ ## 2. 已完成基线与真正剩余问题 -已从开发待办移除:本机交付重建、原生 owner guard、UIA 启动探测延后、可逆手动释放、最小 WinForms 托盘/安全管道、状态过期与注册拒绝反馈、原生源码与交付绑定,以及已经运行的双实例托盘/Roslyn 贯通。对应核心 360/360、桌面 35/35 和后续专项回执见 [11:32 稳定性记录](docs/codex_worklog.md#2026-09-10-1132--0140-稳定性收尾工作流连续性状态可信度与原生交付北京时间)。这些是已有回归基线,不是下一轮重新建设任务。 +当前本地 Node 24.19.0、锁定 SDK 10.0.303:最终修复后的核心 **452/452**、E4 **17/17**、Native Host **59/59**、Roslyn Gateway **22/22**、完整正式 Host 生产矩阵 **21/21**、共享缓存 **8/8**、同时冷启动 A/B/A 的 SDK/Roslyn **10/10**、手动释放 **10** 轮和两类 owner-death 均通过。完整非桌面验收前后交付身份一致,所有夹具与 TEMP 均留在工程目录。新增缓存交错/正常命中 15 项、共享截止 1 项及截止结果/计数 3 项回归;资源测试断言失败时的清理也有故障注入证据。SDK `--roslyn-only` 保留三个 Host 同时启动,仅排除托盘容量项。历史失败、SDK 突发警告与证据边界保留在工作日志;Node 22、新 head CI、桌面/托盘和实际消费者仍没有本轮验收结论。 -N2 曾取得核心 364/364,#35 历史基线为核心 373/373、桌面 35/35、真实 Roslyn MCP 22 场景和 E4 错误契约 16 场景。本轮第 1–2 批架构修复的最终核心为 385/385,真实 Roslyn 22 场景与 E4 16 场景重新通过,桌面未重跑。同根十次重复打开、四次并发确认保持实际 Host PID/snapshot,SDK 变化仍要求新进程,冷加载与热重载损坏、取消/崩溃/超时分别验证。互斥队列取消会立即删除等待节点,执行中的清理仍持锁,但这还不是完整有界准入。具体交付身份和历次回执统一记录在工作日志;不能据此声称正在运行的旧消费者已更新。 +- N1:启动绑定绝对工作区或明确的 cwd 来源;workspace_open 异根请求在副作用前返回 WORKSPACE_MISMATCH,同根确认保留健康 Host/snapshot。8 项固定根测试及实际 A/B 连接验证通过;故障测试迁移为独立连接或同根真实故障注入,保留恢复覆盖。 +- N3:每实例最多 32 个未完成业务请求、独立 4 个状态槽、64 KiB 原始 UTF-8 参数预算;共享总截止时间,排队取消删除实际节点,执行中的取消待实际清理后归还容量。满额返回 SERVER_BUSY,不自动重放或重启。hello 读取已知内存状态,磁盘未观察值为 null。实现契约见 [架构说明](WinCode-架构与数据流说明.md)和仓内 Skill。 +- 历史三实例 `run-IVKMMY`:工作日志记录 11 场景通过,但采用 A/B 并发后再启动第二个 A,未覆盖同根同时冷启动;原始报告未随 PR 下载到本机。本轮替代证据是 [同时冷启动 SDK 10 场景](test-tmp/multi-agent/run-qwMOna/report.json),不含原生托盘容量项。 +- 历史 Grok `grok-acceptance.json`:工作日志记录 N4 接入前构建的 7 次实际 MCP 调用、拒绝 B 后 A 原定位仍可查询、业务占用归零。原始报告本机缺失,本轮未重跑模型,不证明当前新构建已被 Grok 或 Codex 消费。 -| 现象 | 当前证据等级 | 下一步 | +| 尚存问题 | 当前证据 | 后续处理 | | --- | --- | --- | -| A 打开 A,B 在同一连接打开 B,A 普通名称查询得到 B.Api.Save(int) | **已复现**;旧精确 symbolLocation 会拒绝,但普通名称和相对路径跟随最后一次切换 | N1 固定连接工作区,错误目标在变更前拒绝 | -| 单实例 128 个搜索全部完成,但最长约 14 秒 | **已观察**;inFlight=129 包含 hello;代码无等待数量上限,未复现 OOM | N3 有界受理与等待,不把超时/缓存预算当总内存上限 | -| drain 监听器超过默认数量 | **已定位至测试客户端 SDK**;任务结束后监听器为 0,Gateway 没有同类警告 | 保留诊断;不抬高阈值或更换依赖冒充修复 | -| 独立实例共享同项目 cacheDir 的写入/清理 | 已复现并修复 peer prune 后悬空 overflow 命中;两进程回归通过 | N4 保留其他写入/退出交错与源码/UI 边界,不重复建设已通过用例 | -| 一个原生托盘专项没有生成 UI 回执 | **既有未定位失败**;旧脚本未保留退出码,超时只是推测,随后三次未复现 | N5 利用已补诊断复查,不能写成已修复 | -| PR #35 最终提交的 Node 22/24 与 CodeQL | 已通过并合并;属于 5d1ae37/7d53fda 历史基线 | 后续本地增量仍须取得自己的 CI,不沿用旧提交结果 | -| 实际 Codex 的最新构建/Roslyn 消费闭环、其他软件接入、长期大项目资源趋势 | **仍未完成或范围不足** | N5 保留并逐项完成,不从旧计划中误删 | +| 同项目两个 Host 并发冷加载竞争 MSBuild obj 文件 | 当前正式 Host 与同时冷启动 A/B/A 已通过,旧失败记录保留 | 具体反例本地关闭;保持生产矩阵和远端 CI 回归,不能外推任意项目图 | +| 共享缓存、源码和 UI 竞争 | 真实 Gateway 8 场景及双 Host 编辑失效/重载已通过;附件仍可被后续清理,磁盘预算为定期清理目标 | 保留 UI、写入中断/长期负载与附件保留需求的具体边界 | +| 客户端 SDK drain 监听器警告 | 本轮仍观察到 MaxListenersExceededWarning,未抬高阈值 | 单列客户端发送背压,不假称 N3 已消除 | +| Codex 最新构建完整业务调用 | 本次隔离 CLI 授权设置冲突,用户选择暂缓 | N5 单列待验,不归责截图配置 | +| 原生提示窗/托盘历史失败 | 后续通过不能确定旧失败根因 | N5 保留日志与根因状态 | +| 原生资源峰值、长时/大项目、Node 22 与远端 CI | 当前短时 Node RSS/进程身份样本不能覆盖 | N5 按实际授权和证据补验 | -诊断依据:[三实例八场景](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 超大输入的正确行为是报错并关闭通道,第二轮最初期待继续读取是测试假设错误。 +核心、Roslyn/E4 和全部失败→修正回执链接集中保留在工作日志。静态路径校验不保证对抗并发路径替换的原子性;协作取消不保证强制中断永久挂起的 OS I/O;A9 没有增加 OS 原子进程身份句柄校验。 + +前一轮 TDD 补验:在隔离副本中仅替换为 PR 原版 Cache.ts,同一组 7 个内容/旧缓存/读取增长反例全部按预期失败;分别去掉正文校验、键绑定、附件摘要及有界读取,四种退化均被对应测试检出。那一轮只新增 3 项测试。此次先写 12 个状态交错反例并确认全部失败,再修复 Cache.ts,追加写队列和正常并行命中后共 15 项通过;旧 Cache 在隔离重放中 14 项失败、正常命中 1 项通过,五种选定退化均被检出。共享截止的重复计时问题及后续发现的同步截止计数、状态返回后截止检查、更短适配器超时分类均先有失败反例,再局部修复,最终全套 **452/452**。这些实验不代表完整 mutation coverage,原始回执及失败过程见工作日志。 ## 3. GitHub 案例如何用于本项目 -以下资料于 2026-09-10 读取;链接 main/master 会随上游变化。以实际代码与测试为依据,不用星数或 issue 提议代替已合入实现。只借鉴职责和验证方式,不安装新依赖或复制大型架构。 +前五项资料于 2026-09-10 读取,最后两项为 2026-09-11 新核查的 npm 维护者实现和社区报告;链接 main/master 会随上游变化。以实际代码与测试为依据,不用星数或 issue 提议代替已合入实现。只借鉴职责和验证方式,不安装新依赖或复制大型架构。 | 一手案例 | 已核实做法 | 对 WinCode 的具体启示与限制 | | --- | --- | --- | @@ -51,67 +55,68 @@ N2 曾取得核心 364/364,#35 历史基线为核心 373/373、桌面 35/35、 | [.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 不能声称服务器有界队列必然消除客户端已经发生的发送积压 | +| [npm cacache 读取实现](https://github.com/npm/cacache/blob/main/lib/content/read.js) | 读取时校验大小和内容摘要,不能把存在性查询当作内容校验 | 在 WinCode 先复现同大小/恢复 mtime 的附件损坏,再以有界流式 SHA-256 校验命中;JSON 摘要同时绑定命名空间键与正文。没有引入 cacache 依赖或内容寻址存储迁移 | +| [write-file-atomic 实现](https://github.com/npm/write-file-atomic/blob/main/lib/index.js)、[跨进程 Windows 报告 #28](https://github.com/npm/write-file-atomic/issues/28) 与 [锁冲突提议 #227](https://github.com/npm/write-file-atomic/issues/227) | 写入使用临时文件后 rename;activeFiles 排队状态仅在单进程内。Windows issue 是故障报告/提议,不能当作已合入的重试保证 | 保留现有唯一临时文件和实例内队列,补真实多进程写入/清理/退出验证。本机没有复现同样 rename 故障,不凭 issue 添加跨进程锁或重试 | -以上事实支撑的是设计原则;下面的固定项目契约、32 请求起始容量等均为针对 WinCode 的推荐,不是上游给出的通用最优参数。 +以上事实支撑的是设计原则;下面的固定项目契约、32 请求容量等均为针对 WinCode 的工程选择,不是上游给出的通用最优参数。 ## 4. 只包含未完成工作的实施顺序 -剩余顺序:N1 → N3 → N4 → N5。N2 已先完成独立实现和验收;N1 改变跨项目公共行为时,仍须迁移已有切换及故障注入测试,不能以删掉故障覆盖换取通过。具体版本号在实施时确定,不按旧 M0–M5 的建议版本重发已有功能。 - -本轮第 1–2 批修复不等于完成 N1/N3。已收敛的静态路径检查没有解决恶意并发替换的原子性;协作取消没有证明能强制中断永久挂起的 OS I/O。A9 的 ResourceManager 旧清理列表与已注销进程身份交错纳入下一批生产清理回归;目前只有拦截 OS 动作的复现,尚未修改产品清理实现,不能报告真实 PID 复用误杀已经发生或已修复。 +接下来先完成当前修复的差异审核和交付,在后续提交/推送授权下更新 PR,并以新 head 的必需检查判断合并。N4 UI、未覆盖的存储故障边界及 N5 继续单列,不把它们全部扩成这次缓存修复的前置开发。同项目并发冷加载、共享缓存交错和双实例源码编辑已有受控证据;P0、N1 和 N3 保留回归。N3 的 32/4/64 KiB 是小项目功能配置,仍不是所有项目适用的最优参数或进程内存上限。 -### N1:连接固定项目,阻止任务串线 - -目标:从根本上去除同一连接“最后一次 workspace_open 决定全部后续调用”的隐式切换。 +### N4:补同项目共享存储与编辑竞争证据 -1. 新推荐契约为 Gateway 在启动时绑定当前配置工作区;客户端配置必须优先提供明确的绝对 --workspace。现有 cwd 回退若保留,hello 必须标明根及来源,迁移检查不得把未知 cwd 当用户选择。第一版不增加自动发现项目或后台按项目新建进程的管理器。 -2. workspace_open 指向同一已绑定根时沿用已实现的热态保留/恢复行为;指向另一根时返回拟定 WORKSPACE_MISMATCH,附 activeWorkspace/requestedWorkspace 和选择正确项目连接的恢复建议。拒绝发生在修改 config、watcher、缓存命名空间、trash、求值或重置 Host 之前;不能仅在结果返回时补校验。 -3. 绑定根由 Router/WorkspaceManager 共同遵守,不能只保护 MCP 外层而允许内部调用换根;检查别名、组合工具和所有项目相关调用。用户目标是不同目录下的整个项目,同一工作区内部已有的项目引用规则保持。 -4. 路径身份复用当前安全规则,覆盖 Windows 大小写/分隔符、中文空格、父目录穿越和目录联接。不能为了把路径“认成同一个”而绕过现有链接拒绝或扩大项目求值边界。 -5. 不同时引入“默认固定根”和“后台仍可随意切换”的双重语义。现有 A→B→A 正常切换是公共行为,改变它属于明确迁移:更新 Schema 描述、错误契约、Skill 源文件、示例及相应测试;旧诊断报告继续保留。 +**2026-09-11 状态:私有输出、共享缓存和双实例编辑已完成本地受控验收。**Host 验证直接使用通过 delivery 核验的发布 Host、生产 RoslynHostClient 和真实 Host UUID;共享缓存验证由夹具入口载入正式 Gateway/Cache 模块,通过 SDK stdio 调用公开工具,并给缓存设置小预算以触发实际清理。普通 C#/WPF/已有自定义目录、静态项目图变更、双目标框架、外部构建及实例回收已有生产证据;UI、复杂项目和未覆盖故障仍开放。 -关键文件:[入口](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)。 +#### 2026-09-11 恢复顺序 -验收:受控交错的 B 打开请求被拒绝后,A 的根、快照、Host PID、watcher 和缓存命名空间均不变;A 后续查询仍为 A;另一个 B 连接继续得到 B。同项目两个独立实例的精确定位继续互相拒绝。普通目录/文本/组合工具也必须覆盖,不能只测 Roslyn。 +当前自审发现的代码缺陷已修复,最终 Node 24 本地核心及非桌面验收通过。合并前剩余授权提交/推送、更新 PR 描述及新 head 的必需检查;下面保留具体回归入口与后续范围,原恢复步骤和失败过程见工作日志。最后一次远端核对为 2026-09-11 10:31:aa6fc7f、BLOCKED,Node 22 必需检查失败;这不是新的 head 状态,本地修复尚未进入 PR。工程目录没有现成 Node 22,未另行下载运行时,兼容性可由仓库现有 CI 验证。 -限制:固定根不能替 Agent 判断它是否选错了 MCP 工具连接。Skill 与客户端必须尊重 WORKSPACE_MISMATCH,不得忽略打开失败继续声称操作的是 B。若真实目标客户端只能提供全局单连接且必须动态切换多项目,本路线不满足该要求,应在 N5 迁移前核验并重新选择请求身份方案,不能暗加不安全兼容开关。 +1. **维护本轮生产回归。**原 XML 项目异常映射、字面目录段/UUID 边界和缺失/旧 inputPolicy 拒绝已修复并测试。`node scripts/verify-design-time-concurrency.mjs` 现在只验证当前正式发布,不再接受原型 `--mode`/`--reuse`;先运行 `npm run check` 生成有效交付,默认全场景或用 `--phase`/`--filter` 定位,空选择与任何场景失败均非零退出。旧原型源码保留作历史材料,不能用当前源码冒充旧基线。 +2. **补 N4 的其他具体边界。**共享缓存 8 场景和双 Host 源码编辑已完成;按下方剩余清单补 UI、写入中断与长期/大输入验证,不把通过的缓存与 Roslyn 场景等同窗口隔离。显式 ProjectReference 的变化及两个 TFM 已测,任意 target 在执行时生成的 ProjectReference、复杂 source generator 和未知外部输入仍待验证。 +3. **保留故障边界。**正常退出、Host 取消/强杀、Gateway 在加载中死亡的已观测后代退出已通过;Gateway 与 Host 同时硬退出/断电后的孤儿产物未自动回收。若需要新的持久扫描、租约或清理政策,先提出具体范围与代价,再确认实施。 +4. **交付与后续消费分开验收。**本 PR 以新 head 的 Node 22/24、CodeQL 必需检查及最终差异判断是否可合并;独立评审按仓库要求处理,不额外要求新模型调用。CI 已加入同时启动 SDK、完整 Host 生产矩阵和共享缓存 8 场景,尚未推送触发。PR #37 为 open、非 draft;推送、修改 PR 状态、合并/发布按后续有效授权执行。真实消费者属于 N5,Codex 仍单列待验,先不动 agy CLI。 -### N3:有限突发先排队,过载可解释且不重启 +本次最终[完整检查](test-tmp/check/2026-09-11T02-46-37-493Z-core/report.json) **452/452**,44.088 秒;[完整非桌面验收流水线](test-tmp/merge-review/acceptance-5hKQAM/report.json) 的 11 个步骤全部成功,490.255 秒。逐项回执包括[完整 Host 生产矩阵](test-tmp/design-time-production/run-Q5tOWZ/report.json) **21/21**、201.575 秒,[共享缓存](test-tmp/shared-cache/run-wJWGon/report.json) **8/8**、8.381 秒,[同时 A/B/A SDK](test-tmp/multi-agent/run-WfBmSB/report.json) **10/10**,以及 Native 59、Gateway 22、E4 17、释放和 owner-death;Host/共享缓存清理失败与已观测残留均为空,验收前后交付一致。[最终汇总](test-tmp/merge-review/final-receipt.json) 按原回执分别统计 Host 21 个 cases、手动释放 10 个 cycles 与 2 类 scenarios。`test-tmp` 回执仅保留本机、不随 Git 提交;历史原型摘要与新证据分开记录,远端读者不能将本地链接视为可下载的验证包。 -推荐起点是每实例最多受理 **32 个未完成业务请求(执行中与排队合计)**;Roslyn 的执行并发仍为 1,其他已有并行能力不强制全局串行。32 是待实测起点,不是现有限制,也不是用户机器适用的固定最优值。 +已完成并移出本节待办:实际双进程 peer prune 删除附件后的内存命中重建,以及新 CacheManager 从磁盘读取缺失附件后的重建;内置打包按实际文件内容复用,本地文本扫描不再信任有界工作区指纹作为完整输入身份。新增 8 项运行回归及 1 项 PID 复用观察器回归见 `tests/runtime-cache-regressions.test.ts`。取消同根确认保持健康 Host、慢查询期间同根确认/hello 不等待 drain 也已验证;N3 另已验证同根恢复等待、取消和状态旁路。 -- 在进入昂贵准备/扫描/适配器工作之前统一做有界受理。外层准入与内层 Roslyn mutex 复用一份请求归属,组合工具内部调用不重复占用外层容量;取消后的逻辑容量和实际等待节点都需要释放,避免“计数变小但 Promise 链仍无限增长”。 -- 正常 FIFO 排队,保留先来请求,不用新请求挤掉已受理工作。满额时返回拟定 SERVER_BUSY 和有限队列状态,只有确认尚未执行才标记可稍后重试;不伪造准确 retryAfter,不在服务端自动循环重试。 -- 等待受取消和总时间预算约束,排队时间纳入预算;进入 Adapter 后只能使用剩余预算,不能逐层重新起算。没有收到客户端截止时间时使用当前对应操作的有界预算,不假设所有软件都使用测试 SDK 的 60 秒配置。 -- 将已实现的 Mutex 物理取消/FIFO/清理后交还执行权接入统一准入,补连续取消和补入的端到端容量验收;不能仅凭互斥单测声称 Gateway 已有总受理上限。 -- 对请求参数增加统一序列化预算,建议先用 **64 KiB/tool arguments** 并对已有合法调用做兼容性检查;补齐缺少长度限制的字段。该值限制被接受并保留的业务负载,不能消除 SDK 已解析至 10 MiB 帧时的瞬时分配,也不是进程 RSS 上限。 -- tools/list、被动状态、取消和关闭不能排在慢查询后。MCP 状态请求可预留最多 **4 个**有界轻量槽,不能无限放行;托盘继续只读内存状态。workspace_open/恢复进入同一受理约束,但不能先把自身计入 inFlight 再等待自己清空而形成死锁。 -- 增加已受理/执行/等待/拒绝/取消数量及队列等待耗时。手动释放在任何排队、执行或取消收尾期间均拒绝,不排队等空闲、不自动释放。hello 的完整磁盘统计不能变成饱和时的高频旁路扫描。 +历史失败:两个 Host 并发冷加载同一个 A/App.csproj 时,MSBuild 同时写入 obj 内生成文件,返回 PROJECT_LOAD_FAILED。原 editorconfig 的 `run-zJc2aM` 与 AssemblyAttributes.cs 的 `run-BkHrBq` 原始报告本机缺失,历史摘要保留在工作日志;本轮以正式 Host 和真实同时冷启动完成相应回归。默认测试继续同根并发;--serialize-same-root-startup 仅区分 N3 准入验收,不是并发修复证据。 -关键文件:[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)及已有并发测试。优先局部扩展现有互斥/准入,不新增队列服务或依赖。 +首轮比较两个隔离原型:每 Host 私有 IntermediateOutputPath,以及仅在加载/重载期间持有的工作区根文件锁。**下表为修订前的历史对照**,不代表后续原型或最后源码的结果。首批 39 个场景为 30 通过、9 未达到候选要求;这是包含原实现反例的诊断实验,不是生产验收通过率。历史语义 12 项 `run-UcG4RR`、并发/退出 13 项 `run-HdGxkl`、构建干扰/编辑 14 项 `run-k5CDWZ` 的原始报告本机缺失;摘要、针对性复核及限制见工作日志。 -验收:4/8/16 个正常突发不被无故拒绝;32 附近容量边界及 64/128 个突发能够解释每个成功、排队、拒绝或取消;慢首次加载、队首取消、连续取消补入、断连、工作区确认、清理失败交错无死锁/重复执行/重复归还。分别记录等待和执行耗时、Node/原生内存、Host 重启次数及结束后等待节点;没有等待任务时恢复基线。客户端发送端的 drain 警告单独记录,不能以抬高 setMaxListeners 或客户端限流隐藏服务端过载问题。 +| 已测边界 | 私有设计时输出原型 | 工作区根加载锁原型 | +| --- | --- | --- | +| 普通 C#、项目引用、WPF 的编译与精确引用 | 通过 | 通过 | +| 已有自定义 intermediate 目录 | CS0579:原目录生成的特性文件重新进入 Compile 候选;单改路径不保持项目语义 | 通过,保留原求值属性 | +| 同项目冷加载、相同项目的父/子工作区根 | 本轮通过 | 同根基本项目通过;父/子根使用不同锁文件,实际加载重叠 | +| 同根先后加载不同入口/Debug 与 Release | 原热快照保持 | 新 obj 生成文件触发 SNAPSHOT_STALE;原实现固定先后加载也有同类问题,显式重载恢复 | +| 外部独占默认 editorconfig | 能完成加载 | PROJECT_LOAD_FAILED;此为受控文件句柄注入,不是真实 VS 验收 | +| 实际 dotnet build 与热查询交错 | 外部构建新增默认 obj 文件后 SNAPSHOT_STALE,显式重载恢复 | 本次内容稳定的小项目通过;不构成对外部构建的互斥保证 | +| 等待取消、持有者取消/崩溃、重载期间兄弟查询 | 对应场景通过 | 对应场景通过 | -### N4:补同项目共享存储与编辑竞争证据 +后续用户已确认私有输出的兼容性、输入判定和产物回收修订,本轮正式构建也通过实际生成文件变化/新增显式 obj 源文件的反向验证。没有忽略整个 obj 或关闭特性生成。文件事件恰好发生在输入读取期间时,首次可返回 `INPUTS_CHANGED`;验收继续要求拒绝证据、随后固定 `SNAPSHOT_STALE`,显式重载后新引用正确。已有自定义目录的预构建使用与 Host 一致的 Configuration/TargetFramework,并先连续普通构建两次证明夹具自身有效。该方向原有 USER_DECISION_REQUIRED 已获确认;远端 CI 与消费者边界仍待验收。 -已完成并移出本节待办:实际双进程 peer prune 删除附件后的内存命中重建,以及新 CacheManager 从磁盘读取缺失附件后的重建;内置打包按实际文件内容复用,本地文本扫描不再信任有界工作区指纹作为完整输入身份。新增 8 项运行回归及 1 项 PID 复用观察器回归见 `tests/runtime-cache-regressions.test.ts`。取消同根确认保持健康 Host、慢查询期间同根确认/hello 不等待 drain 也已验证;这些结论不覆盖真正切换期间的轻量状态旁路(仍属 N3)。 +N1 固定项目只解决实例内根变化;两个独立连接仍可能指向同一物理项目和默认 cacheDir。缓存保留唯一临时文件后 rename、每实例写队列;现在以命名空间键/正文摘要及附件大小/SHA-256 校验命中,缺失、损坏或旧版缺少校验元数据时重算。JSON 按已打开文件大小加一个探测字节限量读取;附件用 64 KiB 缓冲区流式校验。没有增加跨进程锁、持久租约或数据迁移。 -N1 固定项目只解决实例内根变化;两个独立连接仍可能指向同一物理项目和默认 cacheDir。缓存有原子临时文件写入、每实例写队列和附件存在性复核;仍未建立附件长期租约,不能保证返回引用在未来任意时刻不被清理。 +已完成:两个真实 Gateway 同键/不同键并发、peer 自动容量清理后重建、两个热读者拒绝同大小损坏附件、同项目编辑、跨项目共享物理目录正文隔离、退出与兄弟查询交错、全新 Gateway 持久缓存命中;同项目双 Roslyn Host 编辑后都拒绝旧定位,重载后引用数均从 1 变为 2,关闭兄弟后保留新快照。没有观察到损坏 JSON、错误正文、关闭后业务占用或残留进程。 -1. 在已通过的 peer prune 后重建之外,继续用两个真实 Gateway 验证同键/不同键并发写入、清理与退出交错,特别是已经返回的附件在后续读取时的生命周期。只调用已安装工具链,不为此下载安装真实 Repomix。 -2. 记录是否有损坏 JSON、跨项目正文、另一实例仍使用的 overflow 被清除、退出后队列不排空。普通缓存未命中可以重算;错误正文或看似命中却指向错误/缺失实体必须阻断验收。 -3. 最终倾向明确可变临时文件的拥有者:本轮已对失效附件按缓存未命中重建;若后续证据要求跨调用保护附件,再明确仅隔离正在写入的临时/overflow 产物及其引用归属;不要先把整个持久缓存改成每次启动一个 UUID 目录,避免磁盘复制与失去复用。若现有做法已安全,保留并补回归;需要跨进程锁、新清理政策或数据迁移时再明确具体变更。 -4. 同项目两个实例分别查询时修改生成夹具:旧定位明确失效,新搜索获得对应输入的结果,不把同时编辑或跨文件非原子变更说成完全一致快照。 -5. UIA 是独立边界:有固定 WPF 夹具后验证两个实例明确选择不同窗口是否混淆;同窗口的写操作尚未验证前不承诺多 Agent 同时操作安全,不因代码工作区不同推断桌面也隔离。 +1. **附件保留与容量契约仍有限。**测试实际删除了已返回的附件,后续请求校验后重建;返回路径没有跨调用长期有效保证。`maxDiskEntries`/`maxDiskBytes` 是定期清理目标,不是多个进程共同遵守的瞬时硬配额;本次 4 条目标曾达到 14 条,全新 Gateway 启动清理后回到 4 条。需要长期引用保护或硬配额时再确认具体政策,不能只修改文案宣称已实现。 +2. **剩余存储故障与成本。**当前是小输入、正常退出/清理交错的受控证据,写入中途硬退出、掉电持久性、大附件摘要开销及长期磁盘趋势未验证。只有具体反例要求时才评估跨进程锁、新清理政策或存储迁移。 +3. **源码观察边界。**已测双读者在一次实际编辑前后的失效和重载,未证明多个写者同时编辑、跨文件非原子变更具有一致快照。任意动态项目图仍按上方待办处理。 +4. **UIA 独立验证。**用固定 WPF 夹具验证两个实例明确选择不同窗口是否混淆,并验证同窗口并发只读取证。当前产品 UI 工具只读,不新增同窗口写操作或从代码工作区推断桌面隔离。 -所有源文件修改只发生在 test-tmp 夹具;不安排共享缓存一键删除、审计轮转或目标应用自动停止。此阶段是有结果的验证任务,不预判一定需要新存储架构。 +验收中对被测项目的源码编辑只发生在 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) 和本轮 [xaa7Wf 超时回执](test-tmp/tray/run-xaa7Wf/report.json)。新回执确认 uiTimedOut=true,最后阶段为隐藏/唤出;不能据此确定旧 VcfIp2 同因。已补 Hide 前后、隐藏延迟、ShowExisting 工作线程/确认、VisibleChanged、写报告的时间与线程标识;两次专项通过仍不是根因修复。后续按阶段定位,不增加总超时或忽略错误。 -- 保留本轮 [桌面首次失败](test-tmp/check/2026-09-10T04-59-31-835Z-desktop/report.json):WPF 完整树/截图场景返回 success=false,但旧断言未记录领域错误。已补 requestId/errorCode/errorMessage/captureMethod 诊断,单套件复测 13/13;尚无底层失败回执,不能归因为窗口就绪、截图环境或 Mutex。后续复现按诊断定位,不增加固定 sleep 或放宽成功断言。 +历史 run-Ho43hD、run-VcfIp2、run-xaa7Wf、04-59/10-55 桌面失败及后续诊断复测的原始报告均未随 PR 下载到本机;保留 [工作日志](docs/codex_worklog.md)中的历史记录。以下客户端/桌面项目为历史交接,不能当作本轮重新读取或重跑的证据。 + +- Codex CLI A/B 项目配置解析、Grok A/B MCP 握手已完成。Grok 最新 0.15.0 已完成模型驱动的固定根 Roslyn 查询与精确引用;仍需补两种实际软件的完整消费闭环,以及模型驱动的 A/B 同时任务、编辑后旧定位拒绝/新搜索恢复。用户指定继续 Grok/SDK、Codex 单列待验,并要求先不动 agy CLI。 +- Codex 本次隔离 CLI 使用 --ignore-user-config 与 read-only,工具需要审批而生效策略为 never;这是本次验收启动设置冲突,不能归为用户截图配置或 WinCode 服务故障。只读核对截图中的 node/参数排列与保存配置一致;没有修改其全局配置,也未绕过审批。用户已选择暂缓。后续在具备有效调用授权的会话,对 Codex 完成最新 build/instance/provider/schema → 搜索 → 精确引用 → 影响/重构 → 夹具修改后的旧定位拒绝 → 新搜索恢复。前一轮仅准备的 配置预览(历史 run-Ho43hD,本工作区无原始 JSON)不是已经应用的配置;实际配置修改、重连和已安装 Skill 更新按有效授权执行。 +- 保留 VcfIp2 未定位失败的历史记录 和本轮 xaa7Wf 超时的历史记录。新回执确认 uiTimedOut=true,最后阶段为隐藏/唤出;不能据此确定旧 VcfIp2 同因。已补 Hide 前后、隐藏延迟、ShowExisting 工作线程/确认、VisibleChanged、写报告的时间与线程标识;两次专项通过仍不是根因修复。后续按阶段定位,不增加总超时或忽略错误。 +- 保留本轮 桌面首次失败记录(2026-09-10T04-59-31-835Z):WPF 完整树/截图场景返回 success=false,但旧断言未记录领域错误。已补 requestId/errorCode/errorMessage/captureMethod 诊断,单套件复测 13/13;尚无底层失败回执,不能归因为窗口就绪、截图环境或 Mutex。后续复现按诊断定位,不增加固定 sleep 或放宽成功断言。 +- I:/WinCode 的历史桌面失败 `2026-09-10T10-55-28-895Z` 记录为停在录制提示窗;工作日志另记原生 stderr 诊断后单套件 13/13,但底层异常未复现,原始报告本机缺失。不能确定是初始绘制、线程就绪超时或其他 Win32 调用失败,也不能推定与旧失败同因;该未定位问题继续保留。 - 正常负载先做短时小项目验证,确认成本后再用一个经授权的代表项目观察连续工作与空档;分开记录 Node、Code Host/已观测后代的内存、CPU、句柄、队列和启动次数。长期样本/大项目未跑就明确留空,不用短时结果证明无泄漏。 - 依变更执行针对性测试、npm run check、真实 Roslyn/E4;涉及托盘/原生行为再跑对应桌面验收。测试 inventory、错误形状、Schema/Skill 与交付清单必须一致。Node 22、具体提交的远端 CI 和实际发布状态单独核实。 - 托盘八注册容量及恢复已验证,移出开发待办;跨权限/Windows 会话拒绝矩阵、DPI/Explorer 重建仍属平台待验范围,不把它们重新包装为功能开发。九个真实 Host 的压力测试只有出现实际需求才扩大。 @@ -132,7 +137,7 @@ N1 固定项目只解决实例内根变化;两个独立连接仍可能指向 ## 6. 推荐方案的确认边界与延后范围 -已确认方向:N1 将“可切换当前根”改为“连接固定项目”,N2 调整同路径重开语义,N3 引入可观察的过载/参数预算。用户已要求按计划工作;本轮 N2 已实现,N1 与完整 N3 仍待实施。N3 的起始参数在确认方向内按测试调整;若实际客户端不支持项目连接,或需要显著降低并发能力、改变使用方式,则标记 USER_DECISION_REQUIRED 并重新对齐。 +已确认方向:N1 将“可切换当前根”改为“连接固定项目”,N2 调整同路径重开语义,N3 引入可观察的过载/参数预算,N4 按 Host 隔离设计时输出。N1、N2、N3 及 N4 私有输出、共享缓存交错、双实例编辑的本地实现和受控验收已完成;N4 UI/剩余故障边界与 N5 仍有验收缺口。N3 的起始参数在确认方向内按测试调整;若实际客户端不支持项目连接,或需要显著降低并发能力、改变使用方式,则标记 USER_DECISION_REQUIRED 并重新对齐。 用户此前已经确定的自动释放关闭、手动释放、Windows 11 基准和优先连续工作继续有效,不重复审批。实际客户端配置、下载、安装、发布或推送仍按当时有效授权执行;不把“规划已写好”当外部操作批准。 diff --git "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" index 2d2aa77..bcc5790 100644 --- "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" +++ "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" @@ -1,21 +1,23 @@ # WinCode 架构、数据流与检查关口 -**源码契约:0.14.0;基于已合并 PR #35 的 main 7d53fda,本地架构边界修复位于 codex/architecture-boundaries,更新日期:2026-09-10(北京时间)。具体提交、测试与合并状态见工作记录和 GitHub PR。** +**源码契约:0.15.0;分支 codex/runtime-baseline-and-cleanup,PR #37 检查点 aa6fc7f 的本地增量,更新日期:2026-09-11(北京时间)。N4 私有输出、共享缓存交错和双实例源码编辑已有本地受控验收;当前增量未提交、推送、合并或发布,远端 CI 与实际消费者仍单列待验。** 本说明描述当前源码中已实现的结构。GitHub 分支保护的历史只读核查日期为 2026-09-08;本轮核对 PR 检查状态,不把它等同重新审计全部保护设置。历史实测结果见[工作记录](docs/codex_worklog.md)。源码版本、磁盘构建和客户端当前连接是三个不同对象,不能互相替代。 +N4 实现:`DesignTimeBuild` 使用已有 SDK 的 ProjectCollection 做原项目求值,保留原中间目录的 Compile 排除规则及自定义导入;目标运行仍交给 MSBuildWorkspace,使用每 Host UUID 的私有 IntermediateOutputPath。Configuration/TargetFramework 必须为字面目录段,规范化后的输出必须位于所属 UUID 内;原求值的无效项目异常保持 `PROJECT_LOAD_FAILED`。输入扫描仅过滤已判定不参与默认编译的原中间产物,实际文档/显式输入仍校验,自定义 Compile 保守处理。`OwnedBuildOutputs` 记录原生所有权清单,正常关闭回收;`RoslynHostClient` 在实际退出后调用 `DesignTimeArtifacts` 回收所属命名空间。内部 inputPolicy 为 2,缺失/旧策略 Host 的拒绝和进程回收已有专项测试。当前发布目录通过交付身份核验;复杂 target、任意动态项目图及断电后的孤儿产物仍不在本地通过范围内。 + ## 1. 整体定位与结构 WinCode 是一个运行在本机的 **MCP 工具网关**:接收编码 Agent 的结构化请求,组织代码或桌面证据,再把正文与证据边界一起返回。Agent 的模型推理在客户端侧;WinCode 自身没有模型推理服务或向量数据库。 -主体采用**分层单体 + 外部工具适配器 + 进程外桌面取证**。每个 Gateway 进程只有一个活动工作区;外部上游与 UI Helper 各有独立生命周期。 +主体采用**分层单体 + 外部工具适配器 + 进程外桌面取证**。每个 Gateway 进程在启动时固定一个工作区;外部上游与 UI Helper 各有独立生命周期。 ```mermaid flowchart TB Client["Codex / 其他 MCP 客户端\n模型推理、请求选择、用户授权"] subgraph Node["WinCode Node 进程"] Gate["Gateway\nMCP 接入 · 工具契约 · 参数校验 · 响应封装"] - Router["ToolRouter\n组件装配 · 用例入口 · 工作区切换 · 生命周期"] + Router["ToolRouter\n组件装配 · 用例入口 · 固定根与资源恢复 · 生命周期"] Use["用例与证据处理\nContext / Architecture / Impact / Refactor / UiReview"] State["横向状态与资源\nWorkspace · Session · Cache · Watch · ResourceManager"] Adapters["适配器\nLocalTextAdapter / RoslynAdapter · RepomixAdapter · FlaUiAdapter"] @@ -66,9 +68,13 @@ sequenceDiagram G->>G: 剔除未知字段,检查已知字段组合 alt 参数无效 G-->>A: 错误;业务能力不执行 + else tools/list 或 hello + G->>G: 获取 4 个轻量槽之一,直接读取已知状态 + G-->>A: 状态或 SERVER_BUSY else 普通请求 + G->>G: 校验 64 KiB 参数并获取 32 个业务槽之一 G->>R: acquireRequestSlot(signal) - R->>R: 等待工作区切换结束,增加在途计数 + R->>R: 等待同根恢复结束,增加在途计数 G->>U: 经 Router 执行对应能力 U->>E: 有界读取 / 上游 RPC / Helper 请求 E-->>U: 数据、错误或不完整结果 @@ -77,10 +83,12 @@ sequenceDiagram G-->>A: MCP 文本 / 可选图片 G->>R: finally 释放在途计数 else workspace_open - G->>R: 工作区互斥锁,不计入普通在途请求 - alt 同根健康确认 + G->>R: 先校验固定根;一致时才进入工作区互斥锁 + alt 请求其他根 + R-->>G: WORKSPACE_MISMATCH,不排空、不修改资源 + else 同根健康确认 R->>R: 读取概览,保留 Host,不等待业务排空 - else 切换或已知故障恢复 + else 同根已知故障恢复 R->>R: 排空、重绑或重置;失败保留恢复状态 end R-->>G: 工作区摘要或领域错误 @@ -92,9 +100,11 @@ sequenceDiagram **容忍未知字段,严格校验已知字段。** 未声明字段可出现在协议请求中,但会在递归整理参数时被剔除,不能影响业务或原生请求;声明字段不做字符串→数字等隐式类型转换。例如,拼错 `lineRanges` 不会自动启用范围检索。 -互斥等待节点采用 FIFO,排队取消会立即删除实际节点,正在执行的任务仍在清理完成后归还执行权。被动 hello 在健康同根确认期间可响应,但真正切换屏障和完整过载准入仍待 N3。 +互斥等待节点采用 FIFO,排队取消会立即删除实际节点,正在执行的任务仍在清理完成后归还执行权。运行中取消在实际清理后才归还受理容量。启动、同根恢复和适配器等待共用一份受理归属与 deadline;启动等待取消后从 Set 删除实际节点,不为每轮取消保留 Promise 回调。 + +**有界受理和实际执行分开。** 每实例最多 32 个未完成业务请求(含 workspace_open),hello/tools/list 共享 4 个轻量槽。既有 Roslyn/UI/恢复互斥决定 FIFO 等待;其他已有并行能力继续并行。满额在执行前返回 SERVER_BUSY,不驱逐先来者或自动重放。恢复占用业务容量,但不计入它自己等待排空的 inFlight;关闭和手动释放同时考虑未完成受理与实际清理。 -**准入不是全局限流器。** 在途计数主要用于保护工作区切换和关闭;当前没有一个统一的“全部请求最多并发 N 个”策略。UI 请求及 UI 健康探测另有适配器互斥锁。 +原始参数含未知字段,在归一化前按 UTF-8 JSON 限制为 64 KiB。外层预算包含排队,Router/Adapter 使用剩余 deadline。Router 与准入租约使用同一截止时复用计时器;独立更短的预算保留自己的计时器,异常路径按实际操作上下文保留 REQUEST_TIMEOUT。MCP 在工具执行返回后再次检查截止,不返回已过期的成功结果;租约收尾同时读取实际失败和取消原因,使同步截止检查或更短的适配器预算也计入 timedOut,实际清理完成后才归还容量。health.admission 给出计数和等待/执行耗时;hello 仅读取缓存磁盘观察,诊断才刷新统计。这些限制不能消除 SDK 解析帧的瞬时内存,也不提供挂起 OS I/O 的强制终止保证。 ## 3. 代码证据的数据流 @@ -173,7 +183,7 @@ flowchart TB | 数据位置 | 保存内容 | 生命周期 / 边界 | |---|---|---| | Node 进程内 | 当前 session、请求计数、适配器连接状态、内存缓存、资源记录 | 每 Gateway 一个活动工作区;退出后不保留这些内存状态 | -| 启动配置的 `cacheDir`,默认 `.cache/wincode` | 缓存 JSON、打包临时文件、overflow 正文 | 按工作区 namespace 隔离;切换项目保留缓存目录,避免向每个项目散写缓存 | +| 启动配置的 `cacheDir`,默认 `.cache/wincode` | 缓存 JSON、打包临时文件、overflow 正文 | 使用固定根的 namespace;不同连接可共享物理目录,真实 Gateway 8 个交错场景已验证正文完整性/归属和失效重建,附件长期保留与瞬时硬配额未实现 | | 工作区源码与项目文件 | 输入证据 | 代码分析通常读取;不会因为生成重构计划就自动修改源码 | | 配置的 `trashDir` | 被移动的文件和 `.meta.json` 元数据 | `safe_move_to_trash` 是实际写操作;路径/真实路径检查后移动,非永久删除 | | `%LOCALAPPDATA%/WinCode/logs/ui-audit` | UI 取证审计 | 有容量准入;不自动删除审计来恢复访问 | @@ -181,21 +191,29 @@ flowchart TB | `test-tmp/` | 检查报告、隔离夹具、本轮获准安装的真实上游 | 开发验收数据,不提交到 Git;真实上游安装不等于默认连接已配置 | | 已安装 Skill 目录 | Agent 使用手册 | 独立于源码和运行进程;同步前备份,之后核对内容 | -缓存按工作区 namespace 分区。local-text 每次沿用 8 MiB 总读取、5000 个目录项等现有扫描预算,按文件路径和内容 SHA-256 复用声明解析;不再用有界工作区提示复用整份查询结果。内置打包读取候选后,以有序路径/内容元组计算身份,再命中相同内容;没有可核验输入清单的 CLI 结果不复用。解析结果使用同一内存 LRU,不另开无界缓存。内存默认预算 32 MiB,磁盘默认 128 MiB(含 overflow),单项默认 2 MiB;这些**不是整个 Node 进程 RSS 的硬上限**。 +缓存按工作区 namespace 分区。local-text 每次沿用 8 MiB 总读取、5000 个目录项等现有扫描预算,按文件路径和内容 SHA-256 复用声明解析;不再用有界工作区提示复用整份查询结果。内置打包读取候选后,以有序路径/内容元组计算身份,再命中相同内容;没有可核验输入清单的 CLI 结果不复用。解析结果使用同一内存 LRU,不另开无界缓存。内存默认预算 32 MiB,磁盘默认 128 MiB(含 overflow),单项默认 2 MiB;这些**不是整个 Node 进程 RSS 的硬上限**。磁盘字节/条目限制通过启动和周期维护收敛,每实例写队列不是跨进程锁,多个进程写入期间可能超过清理目标。 + +缓存 JSON 的完整性摘要绑定命名空间键、时间/TTL、输入 fingerprint、正文和附件身份,不能把另一个键的合法 JSON 换到当前文件名后仍算命中。内存和磁盘命中均核对附件大小及 SHA-256,使用同一个文件句柄、64 KiB 缓冲区和既有磁盘预算限量读取;只核验存在性/mtime 不足以发现同大小内容损坏。JSON 以已打开文件的大小加一个探测字节限定读取,读取期间增长或缩小即未命中。缺失、损坏或旧缓存缺少摘要元数据时重算,目录布局、公开 MCP 格式和清理所有权保持。无法复用的附件仍保留受管元数据供既有 TTL/容量清理处理。 -源码、磁盘状态和多个调用之间不存在数据库式快照事务;Watcher/fingerprint/TTL 也不能保证每次观察均与外部写入同步。工作区 fingerprint 只作有界变更提示,watcher 不能成为唯一新鲜度依据。跨进程清理后,内存和磁盘读取都复核 overflow 是否存在,缺失即重建;这不是跨调用租约,引用未来仍可能过期,需要重新请求。 +同一 CacheManager 内的异步读取也受状态校验保护:内存校验恢复后,只有 Map 中仍为原条目时才能刷新 LRU 或删除失败条目;磁盘读取先等待已接受的写入/清空完成,回填前核对状态代次。写入、内容记忆更新、清理和工作区重置会使正在进行的磁盘读取失效;即使改的是其他键,也保守返回未命中。代次仅用一个计数器,正常命中/磁盘回填不会使并行读取互相失效。过期/超大文件的删除进入既有写队列并在执行时核对代次,防止旧读取删除本实例较新的落盘值。15 项回归覆盖这些交错和正常并行命中;这项保护不提供跨进程事务或附件保留租约。 + +源码、磁盘状态和多个调用之间不存在数据库式快照事务;Watcher/fingerprint/TTL 也不能保证每次观察均与外部写入同步。工作区 fingerprint 只作有界变更提示,watcher 不能成为唯一新鲜度依据。摘要校验不是跨调用租约;已返回的附件未来仍可能过期,需要重新请求。双 Gateway 编辑回读及双 Host 旧定位拒绝/重载已验证,不代表多文件并发写入具有一致快照。大附件摘要读取成本和长期磁盘趋势仍需实测。 本轮磁盘边界收敛:`GitClient` 从启动环境的工作区外安装位置解析绝对 Git,使用 argv、禁止 shell,并要求支持布尔 fsmonitor 配置的 Git 2.36+;查询强制关闭 fsmonitor。linked worktree 与 Git 管理子目录由 Git 判断;状态失败返回 unknown,不推断 clean。`FileSystemBoundary` 检查路径及实际目标;Cache 初始化/维护/overflow 和 trash 写入拒绝路径中的链接,Cache 同时固定已打开目录的文件系统身份。版本化 JSON 名称与头标记约束清理所有权;旧版/无法识别的文件保留、重算,不计入受管磁盘配额。预先存在的 junction 已有回归,并发恶意替换的原子隔离没有实现。 架构概览不再另走无总量限制的旧树/项目读取:共用 ProjectDiscovery、WorkspaceBrowser 和 OperationContext。发现上限 2000 项、树 500 项;图上限 16 个项目/64 KiB 单文件/256 KiB 合计、入口枚举 2000 项,返回完整性与遗漏;整份报告上限 32768 UTF-16 字符。取消后的读取在实际返回并关闭句柄后结束归属,不靠外层超时提前释放。文本声明先规范化空白并拒绝超过 16384 字符的规范化单行,避免原有重叠可选空白匹配;不是完整语法分析器。 -### 5.2 工作区切换 +### 5.2 固定工作区与同根恢复 + +启动时捕获并保护 config.workspaceRoot,内部 setRoot 与 openWorkspace 也校验固定根。显式 CLI 路径须为绝对路径;缺省绑定 cwd。初始化前验证目录已存在且路径无链接,其他根或 junction 别名不能作为切换入口。这不是对抗并发文件系统替换的原子沙盒。 + +独立实例指向同一物理项目时,设计时输出按 Host UUID 隔离。原 editorconfig/AssemblyAttributes.cs 写入竞争已有本地生产回归:真实 A/B/A 三个 MCP 进程同时冷加载,项目引用、嵌套根、外部构建、取消/崩溃及兄弟查询分别验证。分阶段启动同根 Host 的 N3 诊断结果仍不用于证明并发隔离;共享持久缓存和目标窗口属于另外的边界。 -健康同根确认:`工作区互斥锁 → 读取概览/刷新提示 → 保留 Host、快照、watcher 和 session`。它不设置切换屏障、不等待普通查询排空;取消只读确认不会制造恢复门。 +健康同根确认:`固定根校验 → 工作区互斥锁 → 读取概览/刷新提示 → 保留 Host、快照、watcher 和 session`。它不设置恢复屏障、不等待普通查询排空;取消只读确认不会制造恢复门。 -真正换根或已知恢复:`工作区互斥锁 → 暂停普通请求进入 → 等待旧请求结束 → 校验/打开目标 → 更新 namespace/session/fingerprint → 重绑 watcher → 按状态重置相关上游 → 恢复请求准入`。SDK 重启要求、清理失败及部分重绑定不能走健康确认捷径。 +同根已知恢复:`固定根校验 → 工作区互斥锁 → 暂停普通请求进入 → 等待旧请求结束 → 验证固定根 → 更新 namespace/session/fingerprint → 重绑 watcher → 按状态重置相关上游 → 恢复请求准入`。SDK 重启要求、清理失败及部分重绑定不能走健康确认捷径。 -旧请求不能在限定时间内结束时,拒绝切换;等待期间可以取消。开始提交切换后完成必要收尾,当前实现不承诺跨文件系统、适配器与缓存的事务性回滚。 +其他根在以上步骤前返回 WORKSPACE_MISMATCH。旧请求不能在限定时间内结束时,拒绝同根恢复;等待期间可取消。开始恢复后完成必要收尾,失败保留恢复门;当前实现不承诺跨文件系统、适配器与缓存的事务性回滚。 ### 5.3 取消与退出 @@ -208,7 +226,7 @@ flowchart TB | 关口 | 位置 | 检查 / 处理 | 不能据此声称什么 | |---|---|---|---| | G1 工具契约 | ToolRegistry | 名称、类型、范围、字段组合;未知字段剔除 | 容忍拼写错误不表示对应能力生效 | -| G2 请求与工作区 | Gateway / ToolRouter | 取消/关闭检查;切换互斥与在途排空 | 不是所有请求统一串行,也不是多租户隔离 | +| G2 请求与工作区 | Gateway / ToolRouter | 固定根校验;32/4 受理容量;64 KiB 参数;共享 deadline;同根恢复排空 | 不是 OS 多租户安全隔离或 RSS 硬上限 | | G3 文件与范围 | Workspace、Context、UI 源码 mapper | 相对/真实路径、工作区边界、候选数量、文件/读取预算 | 路径检查不是 OS 沙盒或完整文件事务 | | G4 上游启动 | 各 Adapter | 配置禁用、可用性、超时;Repomix Node 直启 JS | 已安装脚本本身的可信性没有因此被证明 | | G5 语义身份 | LocalTextAdapter / RoslynAdapter / ImpactAnalyzer | 完整身份、重载、歧义、协议错误、完成状态 | fallback、零引用或非空结果不等于安全重构 | 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 789f8a1..1b167c8 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,47 +1,40 @@ # WinCode 迭代路线图 -更新:2026-09-10(北京时间)。本地修复分支 codex/architecture-boundaries 基于 main 7d53fda(已合并 #35 的 0.14.0 基线);本轮优先修执行/磁盘边界、Git 状态、声明正则和有界架构扫描。本文件只列未完成工作。完整推荐与验收标准见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md),历史实施及失败见 [工作日志](docs/codex_worklog.md)。 +更新:2026-09-11(北京时间)。PR #37 的 aa6fc7f 检查点已同步到本地,分支 codex/runtime-baseline-and-cleanup,源码版本 0.15.0。本轮继续完成修复、正式构建及受控生产验收;增量未提交、推送、合并或发布。剩余顺序见 [详细计划](WinCode-下一轮工程化迭代计划书.md#2026-09-11-恢复顺序),历次结果见 [工作日志](docs/codex_worklog.md)。GitHub 上 PR #37 为 open、非 draft,历史“草稿”是工作进度描述。 -## 最终推荐 +## 已确认路线与完成情况 -独立 Gateway、连接固定项目、健康 Host 保持热态、有界排队。多个 Agent 可通过同一连接处理同一项目;并发独立项目/不同软件使用各自连接。减少无效重载,以已实现的设置内手动释放平衡内存,自动释放继续关闭。 +独立 Gateway、连接固定项目、健康 Host 保持热态、有界受理。多个 Agent 可通过同一连接处理同一项目;不同项目或软件建立各自连接。自动释放继续关闭,保留设置内手动释放。 -用户已确认按计划实施。N2 同路径重开保留热态已完成;固定项目与完整过载规则尚未实施。固定项目需要明确的客户端项目配置;若目标软件只支持一个全局连接,先验证兼容性再决定是否改用每请求工作区身份,不能悄悄保留不安全切换。 +N1、N2、N3 的本地实现已完成:异根打开在副作用前拒绝;同根保留热态并保留真实故障恢复;每实例业务请求上限 32、状态槽 4、参数预算 64 KiB,排队使用共享截止时间,取消后按实际收尾归还容量。既有交付、owner guard、托盘、附件重建、A9 清理身份和 CI 失败证据留存继续作为基线,不重复建设。 -## 已移出待办的基线 +当前本地 Node 24.19.0/SDK 10.0.303:最终源码通过核心 **452/452**,以及同一交付上的完整非桌面验收:E4 **17/17**、Native Host **59/59**、Gateway **22/22**、正式 Host 生产矩阵 **21/21**、共享缓存 **8/8**、三实例 SDK/Roslyn **10/10**、手动释放 **10** 轮和两类 owner-death。验收前后交付身份一致。新增 15 项缓存时序/正常命中、4 项共享截止/结果回归;资源测试断言失败后的清理有故障注入证据,历史失败均保留。SDK 仍为 A/B/A 三 Host 同时冷启动,`--roslyn-only` 仅排除托盘容量项;桌面/托盘、Node 22、新 head CI 和真实消费者未在本轮验收。 -本机交付重建、原生 owner guard、UIA 启动探测延后、可逆手动释放、最小独立托盘与安全管道、状态可信度、原生交付源码绑定、真实托盘/Roslyn 贯通及托盘八注册容量/空位恢复已实现或取得对应验证,详细证据保留在工作日志。本轮不重复建设旧 M0–M4,也不再把自动释放分钟数开关放进设置计划。 +历史工作日志记录 Grok 在 N4 接入前的 0.15.0 完成 7 次 MCP 调用;其原始 `grok-acceptance.json` 未随 PR 下载到本机,不能证明本轮新构建已被真实消费者使用。Codex 单列待验,先前隔离 CLI 设置与 MCP 审批冲突不归为用户截图配置问题。按用户要求先不动 agy CLI;本轮未调用模型或改动真实客户端。 -实际消费者最新构建/Roslyn 接入、既有未定位原生验收失败、共享存储竞争、长期资源趋势等仍保留为待验,不能随已完成功能删除。 - -此前已移除 N2:健康同路径重复打开保留 Host/snapshot,Windows 大小写/分隔符别名保持身份;已知 SDK 重启要求和清理失败门禁继续生效。#35 历史基线为核心 373/373、桌面 35/35、真实 Roslyn 22 场景、E4 16 场景。本轮架构第 1–2 批修复最终通过核心 385/385、真实 Roslyn 22 和 E4 16 场景,桌面未重跑。N3 的物理取消/FIFO 基础已实现,但统一容量与过载尚未完成;具体回执和测试前提修正保留在工作日志。 - -补充验收修复:实际内容绑定的上下文缓存、按内容复用解析的有界文本扫描、缺失附件命中重建,以及同根确认取消/慢查询边界已通过新增 8 项运行回归及 1 项 PID 复用观察器回归。真实 Roslyn 取消后 PID/snapshot 保留;不把这些结果称作 N1 或完整 N3 已完成。 - -## 接下来的开发与验证 +## 接下来的工作 | 顺序 | 尚未完成的目标 | 完成判据 | | --- | --- | --- | -| N1 | 连接固定工作区,错误目标在副作用前拒绝 | 同一连接打开 B 不改变已绑定 A 的根、Host、快照、watcher、cache/trash;独立 B 连接继续正确 | -| N3 | 有界受理、FIFO 等待、取消归还、明确过载 | 建议从每实例 32 个未完成业务请求、4 个状态槽及 64 KiB 参数预算实测;正常突发顺畅,超载不无限积压、不重启、不自动重放 | -| A9 | 清理快照与已注销进程身份交错 | 旧清理列表不盲目终止已失去归属的 PID;区分模拟 OS 拦截与真实进程证据 | -| N4 | 同项目多实例的共享存储/源码变更边界 | 实际缓存/打包读写、prune/overflow 与退出交错无错误正文;源码变化后旧定位失效;UIA 窗口隔离另验 | -| N5 | 实际客户端闭环、残余失败和交付验收 | 明确项目配置与实际 build/provider/schema;至少两种目标软件接入;旧失败保留根因状态,Node 22/远端 CI 与长期观测按实际证据报告 | - -N2 已先完成;接下来实施 N1 时保留已有热态与故障恢复覆盖。N3 参数只是起始建议,需按现有合法请求和相同任务对照调整。N4 已复现并修复 peer prune 后的悬空附件命中,新增双进程回归;剩余源码/UI 竞争与其他存储交错继续验证,不预先引入跨进程锁或按每次启动复制全部持久缓存。 +| 合并前 | 当前修复的提交与 CI | 本地已完成缺陷修复、自审及核心/非桌面验收;获得提交/推送授权后更新 PR,以新 head 的必需检查及最终差异判断合并,旧 head 的 BLOCKED 不能由本地通过替代 | +| N4 优先 | UI 与尚未覆盖的共享存储边界 | 共享缓存 8 场景和双 Host 编辑失效/重载已完成;剩余为不同窗口/同窗口并发只读取证、写入中断和大附件/长期负载,不扩展 UI 写操作 | +| N4 边界 | 复杂项目与未覆盖的故障 | 显式项目引用变更和两个 TFM 已测;任意 target 动态项目图、自定义生成器、双重硬退出/断电孤儿产物单列验证,需要新清理政策时再确认 | +| N5 | 实际客户端完整消费闭环 | Codex 最新 build/provider/schema 与业务查询;两种实际软件、模型 A/B 并发和编辑闭环按真实证据补验 | +| N5 | 资源、原生残余失败与交付 | 原生内存/CPU/句柄峰值与长期趋势,历史提示窗/托盘根因,Node 22 和当前提交远端 CI 分别核实 | -PR #35 最终提交的 Node 22/24/CodeQL 已通过并合并,属于历史基线;本轮本地增量的远端 CI 与实际消费者仍待验证。静态链接拒绝不提供对抗并发路径替换的原子保证,扫描预算/协作取消不保证能强制中断永久挂起的 OS I/O。 +同根同时冷启动仍是默认验收路径;--serialize-same-root-startup 仅为隔离 N3 与 N4 的诊断选项。当前 dist/native 已重新构建并核验源码/产物身份,具体共享 obj 冲突已有生产回归。`verify-design-time-concurrency.mjs` 默认验证全部生产场景,任何失败、空选择、清理失败或交付变化均非零退出。新增 `verify-shared-cache.mjs` 使用正式 Gateway/Cache 模块和 SDK stdio,验证同键/不同键写入、容量清理、损坏附件、源码编辑、跨项目正文、退出与冷读。CI 已包含三个并发验收入口,本轮未推送触发。 ## 必须保留的边界 -- Windows 11 x64 是开发/测试基准;其他系统、Windows 或依赖版本不保证同样效果,macOS/Linux 用户 fork 适配。 -- 默认 local-text,Roslyn 继续显式配置与项目求值许可;hello/托盘观察不触发加载。 -- Gateway 仅清理自建资源;可选托盘退出不影响 MCP,手动释放在排队/执行/取消收尾时拒绝。 -- 10 MiB 传输帧上限、单实例缓存预算、队列容量、Node RSS 和原生内存分别评估;短时通过不等于长期无泄漏。 -- N1–N3 推荐方向已经确认;实际客户端配置、依赖/环境、发布和推送另按有效授权执行。 +- Windows 11 x64 是当前开发/测试基准;其他系统与依赖版本不保证同样效果,跨平台移植延后。 +- 默认 local-text,Roslyn 继续要求显式配置和项目求值许可;hello/托盘观察不触发加载,未知磁盘统计不是零。 +- Gateway 仅清理自建资源;手动释放在排队、执行或取消收尾时拒绝。请求容量、10 MiB SDK 帧、缓存预算、Node RSS 和原生内存分别评估。 +- 缓存命中现在核验绑定命名空间键的正文摘要和附件大小/SHA-256;缺失、损坏或缺少校验元数据即重算。借鉴 npm 维护者实现的依据见详细计划。返回附件仍可被未来清理;磁盘数量/字节限制为定期清理目标,不是跨进程瞬时硬配额。小预算测试曾从 4 条目标达到 14 条,后续启动清理回到 4 条。若要长期附件租约或硬配额,先明确需求和新政策。 +- 本轮 SDK 客户端在 128 请求突发时仍出现 11 个 drain 监听器警告,阶段结束为 0;没有抬高阈值。短时通过不等于长期无泄漏,原生复测通过不等于历史根因已修复。 +- PR #37 及失败日志已在前序只读核对,最后记录为 2026-09-11 10:31 的旧 head aa6fc7f、BLOCKED。项目错误码回归已在本地修复并通过;当前增量的 Node 22/新 head CI 尚未完成,自审不能替代仓库要求的独立评审。实际全局客户端设置、已安装 Skill、依赖和外部发布按有效授权处理。 ## 延后 -自动 idle 释放、pause/resume、自启动、全局停止策略、审计自动删除、跨实例一键清缓存、共享 Roslyn 服务/数据库、通用 Lease/FSM、跨平台移植和大范围 UI 扩展。待并发与实际消费闭环稳定,再决定 UI → XAML/C# → Roslyn 的产品深化。 +自动 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 b7ff7f0..d2d5321 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -997,3 +997,296 @@ - 17:03 补充验证:[真实 Roslyn MCP 22 场景](../test-tmp/roslyn-gateway/run-NDrihh/report.json)全部通过,包括 Host 热态/精确身份、实际 MSBuild 取消/崩溃/超时后的已观测 Host/BuildHost/后代清理及恢复、最终 Gateway 退出。只生成/求值 test-tmp C# 夹具并使用既有 SDK;不是当前 Codex 连接或干净机器验收。其后 `delivery:verify` 仍 matched=true、contentId 不变;本轮未改原生/UIA/托盘生产代码,未重复桌面验收,历史未定位桌面失败继续保留。当前代码、预算、Schema 与手册经作者自审,未进行独立模型或人工审核。 - 用户随后明确要求先提交目前版本并合并。发布范围为本轮第 1–2 批修复和对应测试/文档;发布前交付清单再次 matched=true,远端 main 仍为 7d53fda。沿用相同生产源码的 385/385、真实 Roslyn 22 和 E4 16 回执;新提交的必需 CI 完成后才合并,真实消费者配置不在本次发布范围内。 + +## 2026-09-10 19:04 — 下一轮第一批:失败证据、清理身份与客户端预检(北京时间) + +- 用户要求“按照你的规划开始工作”。核对 I:/WinCode 初始为干净 main `fb3cd48df3f38b209565b906fbfe3485df48461d`(#36),建立 `codex/runtime-baseline-and-cleanup`。先实现失败证据保留与 A9,真实客户端项目配置验证前置到 N1;保持现有公共切换行为,固定根、完整准入及后续竞争验证尚未实施。本轮未提交、推送、安装依赖或改变实际客户端配置。 +- CI 证据:从 `scripts/check.mjs` 抽出小型 `scripts/lib/check-stage.mjs`,在抛出失败前写入阶段退出码/信号、TAP 总数、日志路径与采集完整性;用 [Node 官方多报告器](https://nodejs.org/docs/latest-v24.x/api/test.html#multiple-reporters)同时输出 TAP 与原生 JUnit。CI 的 always 上传保留报告、阶段日志与 XML 七天。维持原有 8 MiB 捕获预算,ENOBUFS、启动失败及超时明确标为采集不完整;缺少完整 TAP 汇总即使退出码为零也失败。JUnit 文件存在不等于该运行完成。 +- [四项日志回归](../tests/check-reporting.test.ts)调用真实 Node 测试子进程,覆盖早期失败被大量后续输出挤出旧尾部摘要、JUnit 转义/断言、成功汇总、零退出但缺汇总,以及输出溢出/启动失败。失败测试夹具的 62 项中 60 通过、1 失败、1 跳过,是有意构造的报告验证数据,不是产品回归结果。 +- A9:`ResourceManager.disposeOnce` 在实际调用清理函数的微任务内重查注册归属;等待前一清理期间或进入微任务前已经注销的资源不再执行,也不生成虚假的 closed 记录。`killProcessTree` 在入口及异步终止后的检查点优先使用 ChildProcess 的 exitCode/signalCode,不再探测已知退出对象留存的数字 PID;自然退出移除 exit/close 两个归属监听器。保留现有进程树清理、实际退出验证与总时间预算。 +- [四项身份回归](../tests/resource-identity.test.ts)在生产修改前均失败,见[修前日志](../test-tmp/baseline-cleanup/resource-identity-before.log),修后全部通过,见[修后日志](../test-tmp/baseline-cleanup/resource-identity-after.log)。PID 复用反例拦截了 OS 动作,未对真实复用 PID 执行信号。与既有生命周期回归合并[22/22 通过](../test-tmp/baseline-cleanup/targeted.log),其中真实已拥有子进程退出覆盖仍保持。未增加 Windows 原子进程句柄身份校验,不能把此修复说成消除了全部 processExists/taskkill 之间的 PID 竞争。 +- [完整核心检查](../test-tmp/check/2026-09-10T10-52-38-371Z-core/report.json)通过:Windows x64、Node 24.19.0、.NET SDK 10.0.303;共 **393 项,392 pass、0 fail、1 skip、0 cancelled**。跳过项为未提供固定工作区的可选 TavernDesk 集成,未自动访问个人项目或数据库。类型、锁定构建、全量回归、新 stdio 实例和交付清单均通过;不是正在使用的 Codex 连接验收。 +- [真实 Roslyn Gateway 22 场景](../test-tmp/roslyn-gateway/run-5FREl5/report.json)、[E4 错误契约 16 场景](../test-tmp/error-contracts/run-D1fPmY/report.json)通过。涉及实际 MSBuild 取消/崩溃/超时后的已观测进程清理及恢复,均使用生成夹具与既有工具链。 +- [桌面完整检查失败](../test-tmp/check/2026-09-10T10-55-28-895Z-desktop/report.json):35 项中 34 通过、1 失败;新 [TAP 日志](../test-tmp/check/2026-09-10T10-55-28-895Z-desktop/desktop-tests.log)及 [JUnit](../test-tmp/check/2026-09-10T10-55-28-895Z-desktop/desktop-tests.xml)保留 `FlaUiAdapter` 第 4 项的 `HOST_ERROR: Recording indicator could not be displayed; UI access refused.`。原生 `RecordingIndicator.cs` 未修改;失败发生在提示窗确认阶段,不能据此断言是截图、WPF 就绪或 Mutex 问题。 +- 对该非平凡失败查阅 [Microsoft UpdateWindow](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-updatewindow)、[WM_PAINT](https://learn.microsoft.com/en-us/windows/win32/gdi/wm-paint)及其链接的 [Windows Classic Samples 源码](https://github.com/microsoft/Windows-classic-samples/blob/18cbd05ee44455cd7552804dcf2c9d6db619b412/Samples/Win7Samples/begin/LearnWin32/HelloWorld/cpp/main.cpp)。UpdateWindow 是否发送绘制消息取决于更新区域,资料只能说明要观察绘制/消息循环,不能证明本次失败原因。增加仅在 test-tmp 的原生 stderr 采集预载器后[单套件 13/13](../test-tmp/baseline-cleanup/flaui-native-diagnostic.log)通过,[原生进程记录](../test-tmp/baseline-cleanup/uia-native-stderr.log)无该异常;保留首次失败,不加 sleep、不增加总超时、不放宽断言。根因仍未定位,也未认定与历史桌面失败同因。 +- 首次 check:desktop 在失败后未进入后续阶段;随后分别运行[真实 UIA owner-death](../test-tmp/owner-death/run-zFyy30/report.json)、[原生托盘](../test-tmp/tray/run-7T1I4d/report.json)、[真实 Tray/Roslyn 工作流](../test-tmp/tray-workflow/run-WXEXn1/report.json),均通过。owner-death 的已观测 Helper 子树无存活者且目标夹具仍活着,验收后另行关闭夹具;工作流覆盖两实例、七次间隔观察中的热态保留、忙时拒绝、定向释放/恢复和托盘退出。这些专项不能覆盖掉完整桌面检查的失败,短样本不能证明长期无泄漏。 +- 当前磁盘 buildId=`3faff18fa1baf36b130867a1a466837d41314372ea389ea647d8309eaffb60c9`、Schema=`304d4030ad9e3ba8ad55159273a7b9b892bd8f6366980d3b2f9ce2d75a0fe2a9`、delivery contentId=`938bb0acd84d4b65ed1489d3eea806236bf80182cd9c97eab89e741964dc1df4`。文档收尾后再次 `delivery:verify` matched=true。revision 元数据是 fb3cd48,未提交源码增量由 sourceHash/artifactHash 标识,不能把它称为该提交的纯净构建。 +- 19:00 前置客户端核验:[预检回执](../test-tmp/baseline-cleanup/client-configuration-preflight.json)记录当前真实 hello 为 **0.13.2**、instance=`2b4f5ded-525e-45bf-a84d-0bf041132345`、buildId=`b3b4024ac8f367e429cc32b7951a16b2d4cec4716a95402911091c154863cc5d`、schema=`ede768d54559a7d33b582ee13fcdb7a29173597eddad10ceceb24fcc940fc84e`、provider=roslyn、根为 `I:/WinCode/test-tmp/client-roslyn-20260909/workspace`。安全读取实际 Codex MCP 配置确认它启动仓库 dist、绑定该旧夹具;未改全局配置或重连。 +- Codex CLI 对两个隔离目录的 `.codex/config.toml` 实际解析通过:同名 `wincode_project_preflight` 分别使用 A/B 绝对根,仓库根下查询不存在;未运行模型任务或产生新用户任务。配置样本保存在预检回执同目录,仅作用于两个生成目录。[Codex 官方文档](https://learn.chatgpt.com/docs/extend/mcp?surface=cli)与[配置优先级](https://learn.chatgpt.com/docs/config-file/config-basic)支持受信任项目配置;[Claude Code 文档](https://code.claude.com/docs/en/mcp)说明项目 `.mcp.json` 及 Desktop Code tab 同名用户级 stdio 优先级例外。CLI 解析通过不等于真实桌面或第二种软件接入通过。 +- `USER_DECISION_REQUIRED`:第二种实际客户端已集中询问 Claude Code / Antigravity / Grok,等待选择;当前 PATH 未找到 Claude CLI,不据此推断整个机器未安装。真实客户端配置变更/重连与必要安装仍需对应授权。固定根改造需先完成这一兼容性前提;N3 完整容量、N4 其余竞争及 N5 长期/大项目证据仍保留。 +- GitHub 连接器读取当前 CI 时被服务端 HTTP 403 阻断,未改走其他鉴权路线或重复尝试;#36 最新 CI 及 Node 22 本轮验证均未核实。不把本地通过写成已修复聊天中未取得完整日志的远端失败。更新现有 CONTRIBUTING、CHANGELOG、详细计划和路线图,不修改旧工作日志,也未同步四份受管 Skill 或已安装手册。 +- 作者反证自审保留两类“看似通过”的风险:晚些出现的大量成功输出掩盖早期失败;资源已注销但旧清理快照仍持有对象。新增测试分别通过真实失败子进程和受控微任务交错验证。实际连线仍旧、完整桌面失败未定、Node 22/远端未验均明确保留;未进行独立模型或人工审核。 + +## 2026-09-10 19:28 — 第二客户端改为 Grok,完成一次真实查询(北京时间) + +- 用户先选择 Antigravity,随后要求“你测一次 GROK 吧”“换成 GROK,先不动 agy cli”。19:04 记录中的第二客户端选择问题已解决;按最新指示以 Grok 继续验收,不再启动或修改 agy CLI。本节追加后续事实,保留前一时点的记录。 +- Antigravity 的 A/B 生成目录使用各自 `.agents/mcp_config.json`。`mcp list` 未列出项目项,但两个实际 TUI 的 `/mcp` 均发现 15 工具,观察到 Node PID 30232/28484 分别绑定 A/B。一次 A 模型请求在 MCP 业务调用前失败:`FAILED_PRECONDITION (code 400): User location is not supported for the API use.`;未改账号、代理或模型绕过。B 只做握手,两会话均正常退出。摘要保存在[客户端预检回执](../test-tmp/baseline-cleanup/client-configuration-preflight.json);客户端日志包含无关历史片段,不复制到文档或外部服务。 +- Antigravity 首次启动过程中,其内置后台更新器自行将 CLI 1.1.27 替换为 1.2.0;日志记录启动更新进程,后续文件时间和 B 会话版本一致。没有发出安装/更新命令,但确实发生了环境变化,已向用户明确报告。[官方故障排查](https://antigravity.google/docs/cli/troubleshooting)说明内置后台更新机制。按用户后续指示停止 agy 操作,没有为处理更新而再改配置。 +- Grok 使用现有 `C:/Users/40218/.grok/bin/grok.exe`,版本前后均为 1.0.13(5e9a58528b76),SHA-256 均为 `BF43DC75F5478A106EAB1E86D422C963E4DBE9666CF14DAB363733D27BF1E672`。依据[官方无界面运行文档](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md),仅为测试进程设置 `GROK_DISABLE_AUTOUPDATER=1`、`GROK_MEMORY=0`;未写全局环境变量或选择新模型/服务。既有 A/B 生成目录各自初始化空 Git 根和 `.grok/config.toml`,用于限定项目配置发现范围;没有改真实仓库或全局 MCP 定义。 +- 首次 Grok doctor 因文件夹未受信任拒绝启动;提示建议的 `--trust` 被本机 1.0.13 参数解析器拒绝。没有反复尝试该参数或手写信任配置,改用正常 TUI 在两个生成目录分别确认信任,然后退出。后续 [A doctor](../test-tmp/baseline-cleanup/grok-doctor-a.json)和 [B doctor](../test-tmp/baseline-cleanup/grok-doctor-b.json)均为 healthy=1/failing=0,确认绝对启动根、协议 2025-11-25、15 工具。受信任目录记录及测试会话历史是本次真实客户端运行产生的状态。 +- 只运行一次 A 项目模型任务,保持 default 权限模式,以[官方权限规则](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/22-permissions-and-safety.md)定向允许候选服务的 hello 和 find_code_symbol,关闭子代理与 Web 搜索。实际共 5 次工具调用:读取已安装 WinCode SKILL、两次工具发现、两次 MCP 调用;`--tools ''` 没有将客户端能力严格裁剪为仅两个 MCP 工具,不能据参数声称隔离了全部本机能力。没有出现 shell、编辑、workspace_open 或子代理调用;B 不追加模型任务。 +- [Grok 验收回执](../test-tmp/baseline-cleanup/grok-acceptance.json)及[原始 MCP 结果](../test-tmp/baseline-cleanup/grok-tool-results.json)经过本地断言核对:hello 为 0.14.0、instance=`2eddc10a-9d01-48f6-a521-c9d937249ca3`,buildId/schemaHash 与本节之前的磁盘构建一致,workspace 为 project-a;Api 查询唯一返回 `Api.cs:2`。provider 为 local-text,结果明确为降级文本声明扫描。本次没有验证 Grok Roslyn、A/B 同时模型查询、同名全局/项目配置冲突或当前 Codex 新构建重连。 +- 模型任务 exit=0、stopReason=end_turn,客户端报告默认模型 `grok-4.6-build`、4 个模型回合、累计 74572 tokens(含 37632 cache-read tokens),费用 **0.01651856 USD**;金额为客户端回报,未独立核对账单。两个 Grok TUI 和模型任务均已退出;收尾查询没有发现绑定这两个生成目录的 Node Gateway 残留。临时回执与断言脚本留在既有 test-tmp,不写入已安装 Skill。 +- 同步现有详细计划和路线图:第二客户端已选定且最小真实调用通过,N1 固定根、完整 N3 仍未实施;当前 Codex 0.13.2、新构建消费者 Roslyn、完整桌面 34/35 的未定位失败、Node 22/远端 CI 等缺口继续保留。此次客户端验收未修改生产代码,不重复完整测试,也未提交或推送。 +- 文档收尾:本轮改动中的 29 个本地 Markdown 链接均存在,`git diff --check` 通过;源码和新增回归经作者复核,没有扩大已通过测试的结论。 + +## 2026-09-10 20:27 — N1 固定工作区实现与验收(北京时间) + +- 用户确认继续,执行已接受的 N1 → 真实客户端检查 → N3 顺序。第二客户端使用 Grok;未再启动或修改 agy CLI。 +- 源码契约升级为 0.15.0:Gateway 启动时固定根,显式 --workspace/-w 需要已有目录的绝对路径,缺省固定 cwd 并报告来源。Router 和 WorkspaceManager 共同拒绝其他根;config.workspaceRoot 在运行期不可写。WORKSPACE_MISMATCH 同源文本/structuredContent 附 active/requestedWorkspace 和 select_workspace_connection。拒绝在排队、指纹读取、排空、watcher/cache/trash/Host 变更前发生;保留同根健康热态与故障恢复。此规则不是 OS 原子安全隔离。 +- 迁移原切换测试为独立 A/B 实例及同根实际停止监听后的恢复;没有删除清理、取消、故障注入门禁。新增 8 项固定根测试覆盖核心入口、普通文本/相对路径/组合工具、中文空格、Windows 大小写与分隔符、父子目录及 junction、CLI 缺省/非法路径;另补 2 项恢复准备阶段失败门禁。诊断脚本退役未对应当前 LocalText 实现的 text-reset 注入项,保留真实 text-initialize,并在诊断报告注明。 +- 最初 5 项新增回归在旧实现全部失败,修改后 5/5;第一轮相关迁移 99/99。首次完整检查 [403 项中 401 通过、1 失败、1 跳过](../test-tmp/check/2026-09-10T12-13-29-501Z-core/report.json):监听已停止时,排空超时现在保留恢复门,旧断言仍匹配顶层文本。补强断言验证 recovery.phase=drain、原始原因、持有请求未被释放及新业务仍被阻止;[最终核心](../test-tmp/check/2026-09-10T12-14-51-064Z-core/report.json) 403 项中 402 通过、1 项可选 TavernDesk 跳过,0 失败。类型检查、三个 native Release 发布、stdio 及交付校验通过。 +- [真实 Roslyn 22 场景](../test-tmp/roslyn-gateway/run-JYtTC8/report.json)、[E4 17 场景](../test-tmp/error-contracts/run-1gdOpi/report.json)、[十次手动释放/重载](../test-tmp/manual-release/run-ArU1cg/report.json)通过。真实 A/B 连接验证拒绝错误根后 A 的 Host/PID、快照、session/watch/cache 不变,B 可继续查询,精确定位跨连接拒绝。故障诊断 12 项无未解决发现;[混合负载](../test-tmp/mixed-load/run-HLx4ss/report.json) 10 轮、70 调用、0 自有存活进程。 +- [本次完整桌面检查](../test-tmp/check/2026-09-10T12-21-13-763Z-desktop/report.json)通过,含 35 项 UI 测试和 owner-death、Tray、真实 Tray/Roslyn 工作流。本轮 native 仅同步版本,未修改提示窗绘制;19:04 的提示窗失败及既有托盘偶发失败根因仍未定位,不能以本次通过宣布修复。 +- [真实客户端回执](../test-tmp/fixed-workspace/client-acceptance-n1.json)核验 Grok 的 0.15.0 build/schema、固定 A 根、Roslyn Save(int) → 1 引用 → B 根 WORKSPACE_MISMATCH → 原定位仍为 1 引用 → 影响分析。客户端重复读取旧已安装手册及参数 schema,触及预设 12 模型回合后退出;重构步骤未执行,不能称完整八步脚本成功。Grok 默认模型未更换,报告费用 0.03867738 USD;可执行文件哈希前后一致,进程环境禁用自动更新和 memory。 +- Codex 首次 CLI 测试无法解析桌面 cua_repl transport;后用官方命令级 --ignore-user-config,仅加载测试 MCP,保留现有 gpt-6-astra/medium 和登录。首次 hello 被权限系统拒绝:MCP tool call requires approval, but approval policy is never。立即停止,没有更改审批策略或绕过;已询问继续 Grok/SDK 并保留待验,或由用户在客户端批准后补验。当前 Codex 桌面连接仍未重连。项目 .codex/.grok 测试配置修改前各保留 config.pre-n1.toml;不修改全局 MCP 或已安装 Skill。 +- 同步 README、架构说明、配置指南、CHANGELOG、CONTRIBUTING、安全边界及仓库内四份受管 Skill;历史报告不改写。尚未提交/推送;Node 22、远端 CI、长时大项目、多模型同时 A/B 及完整当前 Codex 消费者闭环仍未验。接着实施 N3,后续源码变化需新的验证报告,不能沿用本节构建身份声称新代码通过。 + +### 2026-09-10 20:30 — Codex 验收设置澄清 + +用户选择继续 Grok/SDK,并提供当前 WinCode 配置截图质疑是否为本次 CLI 设置问题。只读核对 codex mcp get 与截图一致:node + dist/index.js + 旧 client-roslyn-20260909 工作区/配置,参数排列正常。用户全局 sandbox_mode 为 danger-full-access,而验收命令显式采用 --ignore-user-config 和 read-only;这不是用户截图配置造成的工具审批失败。OpenAI 官方 MCP/配置参考将工具审批模式与运行审批策略分开,CLI 返回的 never 拒绝只证明本次隔离启动的授权设置不满足调用条件,不能归为 WinCode 兼容性或用户设置错误。已向用户澄清,停止 Codex 补验,不更改其策略。参考:https://learn.chatgpt.com/docs/extend/mcp?surface=cli 及 https://learn.chatgpt.com/docs/config-file/config-reference 。 + +## 2026-09-10 21:10 — N3 有界准入完成,Grok/SDK 验收与 N4 实际失败(北京时间) + +- 按已确认 N1 → 实际客户端验收 → N3 顺序完成当前增量。用户选择继续 Grok/SDK、Codex 单列待验,并指出本次 CLI 设置问题;20:30 澄清继续有效:截图参数未发现错误,隔离 CLI 的 MCP 审批要求与生效 never 策略冲突,不能归为 WinCode 兼容性失败。未再启动 Codex 验收、未改全局配置,先不动 agy CLI。 +- 新增 [RequestAdmission](../src/Core/RequestAdmission.ts):32 个未完成业务请求、4 个 hello/tools/list 状态槽、64 KiB 原始 UTF-8 JSON 参数。容量在准备/执行前获取;复用既有 Mutex 的 FIFO 与物理取消节点,启动共享等待不保留连续取消的 Promise 链。排队/准备/适配器共用总截止时间;SERVER_BUSY 只用于尚未执行的满额请求,REQUEST_TIMEOUT 不自动重试。实际清理完成后才释放占用。workspace_open 占业务容量但不计入其自身等待排空的 inFlight;关闭/手动释放保留等待与清理约束。 +- 状态路径不等候慢业务;hello 只读已知缓存状态,未观察的磁盘统计为 null,主动诊断才刷新。统计包含 accepted/completed/rejected/cancelled/timedOut、active/executing/waiting 和等待/执行耗时;executionMs 是非队列墙钟时间(含 I/O/清理),不是 CPU 时间。FlaUI/诊断/Repomix 健康检查沿用调用剩余预算和取消信号。 +- [14 项准入回归](../tests/request-admission.test.ts)覆盖原始参数预算、4/8/16 正常突发、128 请求、连续取消补入、执行取消未完成收尾、启动与恢复等待、关闭和状态容量。首轮核心 [415 通过/1 失败/1 条件跳过](../test-tmp/check/2026-09-10T12-47-55-000Z-core/report.json)被架构规则抓到 Gateway 直接访问 workspace;改为 Router.assertWorkspace 后完整重跑通过,没有弱化规则。此前错误契约夹具同步固定根与诊断 signal;真实 Roslyn 脚本的旧“心跳占 1 个业务请求”假设造成 [run-1gklkM 提前检查 PID](../test-tmp/roslyn-gateway/run-1gklkM/report.json),改为 inFlight=0、business.active=0、waiting=0 后保留严格进程退出断言。新取消场景实际等待 1082 ms 后归零,不能以客户端 Promise 先返回当作清理完成。 + +| 当前构建的实际验证 | 结果与回执 | +| --- | --- | +| Node 24.19.0 核心/构建/交付 | [417 项,416 通过、1 项可选 TavernDesk 条件跳过](../test-tmp/check/2026-09-10T12-50-13-167Z-core/report.json);typecheck、三原生组件、stdio 与 delivery 均通过 | +| 真实 Roslyn | [22 场景通过](../test-tmp/roslyn-gateway/run-ystDNe/report.json),含实际 MSBuild 取消/崩溃/超时及自有后代退出 | +| E4 错误契约 | [17 场景通过](../test-tmp/error-contracts/run-SZG6AN/report.json) | +| 桌面完整检查 | [35/35,后续 owner-death、托盘与双实例工作流全部通过](../test-tmp/check/2026-09-10T13-02-41-136Z-desktop/report.json) | +| 手动释放 | [10 轮通过](../test-tmp/manual-release/run-WQi8n9/report.json),实际退出、旧定位拒绝/新搜索恢复,已观测 survivors=[] | +| 三实例 SDK | [11 场景通过](../test-tmp/multi-agent/run-IVKMMY/report.json),限定 A/B 并发冷启动后再启动第二 A Host;已观测 survivors=[] | +| Grok Build 1.0.13 | [7 次真实 MCP 调用通过](../test-tmp/request-admission/grok-acceptance.json),9 个模型回合正常结束;当前 build/schema/Roslyn 一致,拒绝 B 后 A 原定位仍返回 1 项引用,最终业务占用/等待为 0;客户端报告费用 USD 0.03721436 | + +- SDK 具体结果:普通 4/8/16 突发和 96 次交错精确引用通过;128 次单实例搜索受理 32、SERVER_BUSY 96,兄弟实例仍可查询且 Host/快照保持;64 次突发中成功 32、客户端取消 16、过载 16。队列结束归零。采样 Node RSS 约 92.66–111.05 MiB,三实例完成 96 请求时累计最大等待约 12.68 秒;这是离散小项目样本,不是实时峰值或长期趋势。手动释放回执另有 Node/已观测原生进程工作集、私有字节与累计 CPU 快照;并发原生峰值/句柄趋势仍未测。客户端 SDK 发送端仍出现 11 个 drain 监听器警告,阶段后监听器为 0;未抬高阈值或增加客户端限流。 +- N4 新失败必须保留:[默认三个 Host 并行冷启动 run-zJc2aM](../test-tmp/multi-agent/run-zJc2aM/report.json)在同物理 A/App.csproj 上竞争 obj/Debug/net10.0/App.GeneratedMSBuildEditorConfig.editorconfig,返回 PROJECT_LOAD_FAILED;结束后已观测 survivors=[]。这是实际共享构建输出冲突,尚未修复。verify-multi-agent 默认继续同根并发;--serialize-same-root-startup 仅分离 N3 与 N4,报告显式记录其限制。未添加跨进程锁、改 MSBuild 属性或复制持久缓存。此前 [run-fgPCWl](../test-tmp/multi-agent/run-fgPCWl/report.json)另因 Tray 夹具硬编码旧 0.14 版本失败,已改读 package.json,不能把该失败和 MSBuild 冲突混同。 +- 交付:version=0.15.0;buildId=be08ba26c3d0f82e596161057a595ddb152b7d253d46adc86273ff7c841e151e;schemaHash=4f8a6424c23978ebffe77111f4687c87336de64320f7d24cde8bdeede8ab804f;delivery contentId=24a58650420658f71f3a88c5d2b91bb4a69d5846affccbb3da30c511fdc7376c,收尾 delivery:verify matched=true。版本仍为本地未发布增量,revision 元数据 fb3cd48 不代表干净提交。更新既有 README/架构/配置/Skill 源与计划路线图;未同步已安装 Skill,未安装依赖、提交或推送。 +- 作者反证自审:取消响应快但清理尚未结束,可能错误放入新工作;受控测试与真实 MSBuild 退出检查分别覆盖。另一个反例是实例/PID/快照隔离均正确,但 MSBuild 输出仍共享,已用实际失败证据保留 N4。未做独立模型/人工审核。Codex 完整新构建消费、模型 A/B 并发/编辑闭环、同根并发冷加载、共享存储与 UI 其余交错、原生峰值/长期资源、Node 22 和当前增量远端 CI 仍未完成;历史录制提示窗/托盘根因没有因本轮通过而关闭。 +- 收尾文档链接检查发现 4 份旧回执在当前工作区缺失(Ho43hD 配置预览、VcfIp2/xaa7Wf 托盘、04-59 桌面检查);计划改为注明缺失并指向保留的历史工作记录,未补造文件或修改历史日志。当前 SDK/释放 50 个已观测进程身份重新只读核验 survivors=[]。 + +## 2026-09-10 22:05 — N4 两种隔离原型对照完成,正式方案仍待修订(北京时间) + +- 授权来源:用户质疑“是否最优”后,已同意先比较私有设计时输出与跨进程加载锁,并回复“开始吧”。本轮只新增 [比较入口](../scripts/verify-design-time-concurrency.mjs)、[构建/夹具辅助](../scripts/roslyn/design-time-prototypes.mjs)和 [C# 原型插桩](../tests/fixtures/design-time-comparison/PrototypeCoordination.cs),复制当前 Host 源码到 test-tmp 编译。复用锁定 SDK 10.0.303、已有 NuGet 缓存和生产 RoslynHostClient;没有引入依赖、模型调用或共享服务。正式 Host 源码、Gateway、dist、当前原生构建、全局客户端设置和 agy CLI 均未改动,没有提交/推送。 +- 两种原型的实际范围:private 仅覆盖每项目相对 IntermediateOutputPath 为 `.cache/wincode-msbuild////`,保持 restore 的 BaseIntermediateOutputPath/MSBuildProjectExtensionsPath;lock 仅在 ReloadAsync 从加载到快照冻结期间持有工作区根下零字节文件的 FileShare.None,25 ms 可取消等待,仅重试 sharing/lock 错误。正常语义查询不获取该锁;进程结束由 OS 关闭句柄。两者都不是已经进入生产的通用并发协议。 +- [首轮 run-elfe3J](../test-tmp/design-time-comparison/run-elfe3J/report.json)为 **验证脚本错误**:从符号顶层读取 project/position,实际定位字段在 location;Save 子串查询还匹配 WPF HandleSave。修正为精确名称/签名及实际 location,并要求基线有效后继续比较。其 12 项失败不计入候选结论,原始回执保留。 + +| 实际对照 | 结果与证据 | +| --- | --- | +| 语义 12 项 | [run-UcG4RR](../test-tmp/design-time-comparison/run-UcG4RR/report.json):11 通过、1 候选失败;原实现与锁在普通 C#、项目引用、WPF、自定义目录全部通过;private 在已有自定义 intermediate 目录时 CS0579 特性重复 | +| 并发/退出 13 项 | [run-HdGxkl](../test-tmp/design-time-comparison/run-HdGxkl/report.json):11 通过、2 候选失败;同根基本项目两种候选通过;根锁在不同入口共享 Lib 时使先加载快照失效,父/子根打开同一实际项目则越过协调;等待取消、实际 MSBuild 后代存在时取消/崩溃、兄弟 Host 继续查询均通过 | +| 外部干扰/编辑 14 项 | [run-k5CDWZ](../test-tmp/design-time-comparison/run-k5CDWZ/report.json):8 通过、6 未达到候选要求,含原实现的 2 个反例;外部文件占用、真实 dotnet build、先后加载共享项目、不同配置、源码修改及重载期间热查询分别记录 | +| 并发 6 项复核 | [run-BkHrBq](../test-tmp/design-time-comparison/run-BkHrBq/report.json):修正一处同值自比的快照断言,改比实际引用响应的 snapshot,并保留已关闭 Host 的 trace;4 通过、2 失败。原实现再次在同项目冷加载发生 PROJECT_LOAD_FAILED,这次是 `.NETCoreApp,Version=v10.0.AssemblyAttributes.cs` 写入竞争;根锁共享入口的 SNAPSHOT_STALE 再现。两种候选基本并发通过,private 在关闭/回收兄弟实例后仍保留真实快照及引用 | +| 原精确定位 2 项复核 | [run-zB6QMV](../test-tmp/design-time-comparison/run-zB6QMV/report.json):两种候选均在编辑后及重载后拒绝原 references 定位,返回 SNAPSHOT_STALE;重新定位后引用由 1 变为 2,编译无错误 | + +- [最终汇总](../test-tmp/design-time-comparison/run-zB6QMV/comparison-summary.json)包括原始回执 SHA-256、各场景结果、进程记录和剩余私有产物统计。首批是 39 个不同场景(30 通过、9 未达要求),另有 8 次针对性复核;completed=true/命令正常结束表示诊断实验完成,不能当作候选通过或加入生产核心 417 项计数。原始失败未改写,两个候选均未完成全部验收。 +- 私有输出的具体反例:普通项目、跨项目引用、WPF 的 InitializeComponent/事件/精确引用编译通过;自定义 `artifacts/int/...` 已有生成文件后,覆盖 IntermediateOutputPath 改变默认 Compile 排除规则,旧目录生成的特性文件与新目录一起进入编译,CS0579。没有关闭 GenerateAssemblyInfo、删除原构建产物或放宽 compilationErrors 断言以求通过。 +- 根锁的具体反例:父根与子根生成不同锁文件,trace 显示同一个实际 csproj 在前一个 Host 的 MSBuild 阻塞期间被另一个 Host 完整加载。对相同工作区,先加载 App 再加载 Peer 或先 Debug 后 Release,新 obj 下的 `.cs` 被现有 WorkspaceInputs 自动候选枚举纳入指纹,前一个 Host 返回 SNAPSHOT_STALE。原实现固定先后加载同样再现,说明它还涉及既有输入判定,不能全部归因于锁本身。 +- 外部干扰分开解释:PowerShell 子进程持有默认 editorconfig 的独占句柄时,原实现/锁都 PROJECT_LOAD_FAILED,private 能加载;这是受控故障注入,不是真实 Visual Studio 验收。另一个真实 `dotnet build --no-restore -p:UseSharedCompilation=false -nodeReuse:false` 在指定 target 暂停,Host 加载后放行;private 的快照因默认 obj 新增特性 `.cs` 失效,显式重载后恢复正确 1 项引用;锁原型在该基本项目的共享生成内容未变,本次通过,但未协调外部构建。所有这些拒绝都保留既有输入检查,没有接受陈旧引用。 +- 生命周期与成本:两个原型都通过真实 MSBuild 后代的取消/强杀及兄弟继续工作;等待根锁的 Host 取消前尚无 BuildHost 后代。全部 5 份有效报告共记录 207 个进程身份,结束时均 survivors=[]、cleanupFailures=[]。比较父进程在确认实例退出后回收指定私有 UUID 前缀,基本项目 7 文件/1994 字节、图项目 14 文件/4001 字节;兄弟仍能查询。其他私有产物作为取证文件留存,**生产自动回收未实现**,不能用进程退出代替磁盘生命周期验收。工作集约 130–147 MiB 为语义调用时的离散采样;基本双冷加载中锁的后一个 ready 约 6.17 s(含等待约 2.96 s),private 约 3.15–3.59 s。运行顺序/JIT/缓存未控制,不主张性能最优、实时峰值或长期稳定。 +- 当前判断:优先继续完善私有输出原型,其隔离覆盖比“按工作区根加锁”更适合现有多连接目标;但不将其直接落入正式代码。下一步需要保留原项目 Compile 排除语义、准确划分实际编译输入与其他构建产物,并明确实例产物回收。反证验收必须含“实际被编译的生成文件改变后旧定位仍失效”,不能直接忽略整个 obj。**USER_DECISION_REQUIRED:本轮授权的两原型比较已完成,正式改变 MSBuild 求值、输入判定或产物生命周期前确认具体修订方案。** 不扩展为全局 Host 池、项目复制或通用跨进程 Lease/FSM。 +- 验证范围:脚本语法检查与 git diff --check 通过(另有既存 CRLF 提示);delivery:verify 再次 matched=true,contentId 仍为 `24a58650420658f71f3a88c5d2b91bb4a69d5846affccbb3da30c511fdc7376c`,报告 productionChanged=false。没有重复完整核心/桌面测试;本轮直接调用隔离 Host,不冒充 Grok/Codex 新 MCP 验收。未测真实 VS、大型项目、多目标框架、自定义 source generator、长期磁盘回收和故障峰值,也未作独立模型/人工复审。详细计划和路线图同步这些实测边界,N4 未关闭。 + +## 2026-09-10 22:28 — N4 修订原型与正式接入中途交接(北京时间) + +- 用户随后以“开始吧”“继续”确认继续修订私有输出方案,22:05 的方案确认项因此已获授权;最新指令为结束今晚工作、写好文档与待办、提交当前进度到 PR。按该指令停止开发和进一步运行测试,保存草稿检查点,不合并、不发布。 +- 修订方案复用已安装 SDK 的 Microsoft.Build.dll,通过原项目求值保留自定义 intermediate 的 Compile 排除及原 CustomBefore hook,再为每个 Host 分配 UUID 私有设计时输出。自动候选过滤同时保留实际编译输入、显式 Compile 和无法可靠判定的保守路径,未整体忽略 obj。Native 使用所属 UUID 的清单和活动句柄记录产物,正常关闭回收;Gateway 在确认自有 Host 退出后补偿清理,逐项校验根、UUID、清单及链接边界,不能删除兄弟实例产物。未新增依赖安装或全局服务。 + +| 修订原型的实际验证 | 结果与本地回执 | +| --- | --- | +| 普通 C#、项目引用、WPF、自定义 intermediate 的三种实现对照 | [run-PpetSI](../test-tmp/design-time-comparison/run-PpetSI/report.json):12/12 通过,私有输出的原自定义目录 CS0579 反例恢复 | +| 私有输出外部干扰与编辑 | [run-LHMyyy](../test-tmp/design-time-comparison/run-LHMyyy/report.json):6/6 通过,含外部文件占用、真实构建、共享项目先后加载、不同配置、编辑和重载期间查询 | +| 输入判定反例 | [run-ejjK26](../test-tmp/design-time-comparison/run-ejjK26/report.json):3/3 通过,实际生成文件修改、新显式 obj/Manual/*.cs 均拒绝旧快照并可重载恢复,原 CustomBefore hook 保留 | +| 并发与产物生命周期 | [run-hJPqME](../test-tmp/design-time-comparison/run-hJPqME/report.json):5/5 通过,含基本/共享图/嵌套根并发、取消与崩溃;正常及父进程补偿清理后所属私有文件为 0,兄弟仍可查询,survivors=[]、cleanupFailures=[] | + +- 正式源码已接入 [DesignTimeBuild](../tools/WinCode.Code.Host/DesignTimeBuild.cs)、[OwnedBuildOutputs](../tools/WinCode.Code.Host/OwnedBuildOutputs.cs)、[DesignTimeArtifacts](../src/Adapters/DesignTimeArtifacts.ts),并连接 WorkspaceSession、WorkspaceInputs、RoslynHostClient。输入策略协议升为 2,适配器和已有契约夹具同步;旧策略 Host 的专项拒绝测试尚待补充。最后加入的重叠输出目录候选合并、从实际 Imports 解析原 hook 两项修改在本轮原型回执之后,尚未构建验证。 +- 已实际执行 `node node_modules/tsx/dist/cli.mjs --test tests/design-time-artifacts.test.ts tests/roslyn-contracts.test.ts`:**23/23 通过**,含仅回收所属 UUID、全量预校验、越根/其他 UUID/链接/损坏清单拒绝及 Roslyn 契约。该命令未生成持久报告;不得将这些 TypeScript 测试当作正式 Native 编译或 MCP 并发验收。 +- **当前最终源码为 WIP**:尚未执行正式接入后的 typecheck、Native Release 构建、完整核心检查、默认三 Host 并发冷加载和交付身份核对。磁盘 dist/Native 及前文 417 项、真实客户端等结果对应 N4 接入前构建,不覆盖最后源码;AssemblyAttributes.cs/editorconfig 并发问题仍不能在生产验收层面关闭。 +- 明日首先处理两个明确的验证入口问题:旧 buildPrototype 文本锚点已不匹配正式接入后的 WorkspaceSession;原型 WINCODE_N4_INSTANCE 与正式 WINCODE_BUILD_INSTANCE 必须统一到被测 Host 的真实 UUID,避免 blocker/产物清理检查错位。不得把当前源码作为原实现基线。然后正式构建,并按 [2026-09-11 恢复顺序](../WinCode-下一轮工程化迭代计划书.md#2026-09-11-恢复顺序)完成并发、输入、生命周期与交付验收。 +- 反证自审保留:原型通过不代表最后两个源码修订正确;动态 ProjectReference、多目标框架、自定义 Compile 仍需检查保守失效行为;Gateway 与 Host 同时硬退出或断电后的孤儿目录未实现自动回收。尚未独立审查、Node 22 验证或当前 PR CI 验证。Codex 继续单列待验,先不动 agy CLI,没有再次更改客户端或权限策略。 +- 同步 README、CHANGELOG、架构说明、路线图和计划的当前状态,保留历史失败日志。test-tmp 原始回执仅保留本机、受 Git 忽略,不上传原始模型配置、日志或运行产物;远端 PR 提供结果摘要及可继续执行的待办。当前进度按用户明确授权提交并推送为草稿 PR,等待明日继续。 + +## 2026-09-11 08:47 — PR #37 本地续作:错误契约修复与私有输出生产验收(北京时间) + +- 授权与范围:用户先要求下载昨晚 PR 的进度、与本地对齐并分析计划缺口,随后明确“继续开展工作”。在 `D:/CODEX PROJECT/WinCode MCP` 对齐 `codex/runtime-baseline-and-cleanup` 的 `aa6fc7f457f5a18b122fd791aec2824ed121195d` 后继续本地修复及验证;原 `codex/architecture-boundaries` 分支保留。本轮没有提交、推送、修改 PR 状态、合并或发布,没有安装新依赖、调用模型或改动真实客户端/已安装 Skill/agy CLI。PR #37 实际为 open、draft=false;昨晚“草稿”描述的是 WIP 检查点,不是 GitHub draft 状态。 +- 证据对齐:通过 GitHub 连接器读取 [PR #37](https://github.com/linnnn89/WinCode/pull/37) 和 [CI run 34489311570](https://github.com/linnnn89/WinCode/actions/runs/34489311570)。Node 22 核心为 422 项、421 通过/1 条件跳过,构建已完成;真实 Host 在 39 个已完成场景后因损坏项目重载预期 `PROJECT_LOAD_FAILED`、实际 `QUERY_FAILED` 而失败,后续同一步网关/owner-death/释放未执行。Node 24 构建与核心通过。CI 合成 merge 的 tree 与 PR aa6fc7f 相同,这些是原 PR 的证据,不是本轮未推送增量的远端结果。下载的受控 CI JSON 保留在 `test-tmp/pr37-audit-20260911`。昨晚四份修订原型 `run-PpetSI/run-LHMyyy/run-ejjK26/run-hJPqME` 及其他历史 test-tmp 未随 Git 下载到本机,历史工作日志不改写。 +- 生产修复:[DesignTimeBuild.cs](../tools/WinCode.Code.Host/DesignTimeBuild.cs) 在实际 `ProjectCollection.LoadProject` 边界将 `InvalidProjectFileException` 映射为 `PROJECT_LOAD_FAILED`,不改变 Program 的 MSBuildLocator 先后顺序。Gateway 与 Native 同时验证 Configuration/TargetFramework 的字面目录段,拒绝点段、尾部点/空白、分隔符、MSBuild 属性/列表/转义字符;私有输出只生成一次并核对规范化后仍在所属 UUID 内。新增缺失/旧 inputPolicy 的拒绝与进程回收测试,错误码保持 `HOST_PROTOCOL_ERROR`,不能持有成功快照。 +- 验证入口:[verify-design-time-concurrency.mjs](../scripts/verify-design-time-concurrency.mjs) 改为当前正式发布 Host 和生产 RoslynHostClient 的验收,不再复制/插桩当前源码作为原基线。运行前后核对 delivery/source 身份,blocker 与 owner.json 使用 client 的实际 `WINCODE_BUILD_INSTANCE`;每个选中场景、编译/引用结果、退出及产物清理分别留证。取消/强杀后由生产关闭逻辑清理,验证脚本不通过手工删除私有目录替代被测回收。完成全部选中场景、非空选择、无失败/交付变化/清理失败/已观测残留才 success=true,否则非零退出。旧原型辅助和 C# 夹具保留作历史材料;正式入口仅接受 `--phase`、`--filter`。 +- [verify-multi-agent.mjs](../scripts/verify-multi-agent.mjs) 增加交付前置核验和 `--roslyn-only`;该选项保留真实 A/B/A 三 Host 同时冷启动、全部 Roslyn/准入/传输验证,只排除原生 Tray 容量项,默认完整模式不变。CI Node 22 已加入该入口及完整私有输出矩阵,失败报告按 always 上传,15 分钟作业预算未提高;本轮未推送或触发 CI。 + +| 本轮实际验证 | 结果与本地证据 | +| --- | --- | +| 核心/类型检查/构建/交付 | [core report](../test-tmp/check/2026-09-11T00-17-41-069Z-core/report.json):425/425,0 跳过;现有锁定 SDK 10.0.303、Node 24.19.0,包含 Gateway 与三 Native 组件的正式发布和交付核验 | +| 产物归属及 Roslyn 契约专项 | `test-tmp/n4-production/contracts-after.log`:26/26;目录段测试先在修改前实际失败,`contracts-before.log` 保留 | +| 真实 Native Host | [fixture-3Rpx5Q](../test-tmp/roslyn-host/fixture-3Rpx5Q/report.json):59 场景通过,含损坏项目失败/修复链路、原生 16 组非法配置/框架组合在私有目录副作用前拒绝 | +| 真实 MCP Roslyn Gateway | [run-Rse2Ni](../test-tmp/roslyn-gateway/run-Rse2Ni/report.json):22 场景通过,含实际 MSBuild 取消/崩溃/超时及恢复 | +| E4 错误契约 | [run-bWLzfr](../test-tmp/error-contracts/run-bWLzfr/report.json):17 场景通过 | +| 真实三个 SDK 客户端 | [run-qwMOna](../test-tmp/multi-agent/run-qwMOna/report.json):10 场景通过,startupMode=all three hosts parallel,survivors=[];精确引用 A/B/A=1/2/1,128 请求受理 32、SERVER_BUSY 96,结束占用归零 | +| 完整私有输出生产矩阵 | [run-1toEyr](../test-tmp/design-time-production/run-1toEyr/report.json):20/20,228.291 秒;语义 5、并发/退出 5、构建/编辑干扰 6、输入反例 4,productionChanged=false、cleanupFailures=[]、survivors=[] | +| 验收入口负例 | [run-GkatiK](../test-tmp/design-time-production/run-GkatiK/report.json):无匹配场景的 filter 实际退出 1,success=false、cases=[]、observed=[],该失败符合预期 | +| 手动释放 | [run-TG4ZUS](../test-tmp/manual-release/run-TG4ZUS/report.json):10 轮通过,已观测残留为空 | +| Gateway 在加载中死亡 | [run-MayNi6](../test-tmp/owner-death/run-MayNi6/report.json):9 个已观测进程身份全部退出,survivors=[]、cleanup=[] | +| RepomixAdapter owner 死亡 | [run-DS7jFN](../test-tmp/owner-death/run-DS7jFN/report.json):通过,survivors=[]、cleanup=[];使用受控 Node CLI,不声称真实 Repomix 或完整 Gateway 集成 | + +- 矩阵反证:已有自定义 intermediate 先按与 Host 相同的 Configuration/TargetFramework 连续普通构建两次,再检查生产 Host 编译无错误;双 TFM `net10.0/net10.0-windows` 按条件分别得到 1/2 项引用,关闭兄弟后保留原快照。源码编辑使旧定位失效,重载后引用从 1 变为 2,旧定位继续 `SNAPSHOT_STALE`。项目文件中显式引用 App→Lib 改为 App→Peer→Lib 后,先更新生成夹具的 restore 输入,再重载为 3 个项目/2 项引用;新 Peer 输出进入所属清单并随关闭回收。这不是任意 target 动态生成引用的证明。另按报告内容复核 65 份记录的 compilationErrors 均为空。 +- 失败过程保留:`run-Lux8mM` 的输入反例首次返回 `INPUTS_CHANGED`,符合 watcher 事件落在 Capture 期间的既有契约;另一个引用变更场景尚未 restore 更新项目图。修正夹具与断言后要求首次拒绝且不返回证据、紧接着严格 `SNAPSHOT_STALE`,显式重载后引用正确。`run-WDKrAC/run-Y20hsP` 暴露验证脚本遗漏 `runDotnet` 导入,补齐后分别针对性通过,最终由完整 20 项再次覆盖。`run-KuzB7O` 的预构建未显式传入配置/TFM,早期 Directory.Build.props 求值到另一目录;原 MSBuild 求值同样将旧 `.cs` 纳入 Compile。这是夹具条件不一致,未通过改生产排除规则、删除旧产物或关闭特性生成解决;参数统一后 `run-LZbC6v` 及最终矩阵通过。临时筛选变量拼写错误 `run-d411ZJ` 也以失败保留。所有日志位于 `test-tmp/n4-production`,未改写失败回执。 +- 交付身份:version `0.15.0`;buildId `65102c51f7d53138fe7874ba656d7a5e9938168dc9f32c5e2c54ad400387baaa`;schemaHash `4f8a6424c23978ebffe77111f4687c87336de64320f7d24cde8bdeede8ab804f`;delivery contentId `0fded67d16aed5d7ca3c98b566fd6ebe57cdaabda8ea7d18ece422de8dc3c522`;发布 Code Host DLL SHA-256 `631814d5b9fd0997b6c952d56a05c412a989123225b8a4fbb35326418d45f5fd`。收尾 `delivery --verify` matched=true。revision 元数据仍为 aa6fc7f,当前源码是该提交上的本地未提交增量,不能称为干净提交构建。同步来源与客户端部署继续区分,codexConnectionVerified=false。 +- 剩余范围:具体同项目 MSBuild 输出竞争可以在本地生产回归层面关闭,N4 整体未关闭。共享缓存并发写入/清理/读取、双实例源码编辑、UI 窗口交错、任意 target 动态项目图、自定义生成器及双重硬退出/断电孤儿产物继续开放。当前增量未做 Node 22、远端 CI、真实 Grok/Codex 消费、完整桌面/托盘复验、长期资源或独立模型/人工审核。SDK 在 128 请求突发中仍有 11 个 drain 监听器警告,阶段结束为 0;未调整阈值,短时归零不证明长期无泄漏。本轮无新的 USER_DECISION_REQUIRED;后续若需新的清理政策、依赖、真实客户端配置或外部交付,应明确范围后按有效授权执行。 +- 文档同步:更新既有 README、CHANGELOG、架构说明、详细计划和路线图,把已完成的恢复工作移出待办,标记本机缺失的历史报告。历史日志保持原文;本轮回执是本地可核验文件,不随源码提交,也不冒充已上传 CI artifact。 +- 最终检查:四个改动的 `.mjs` 入口/辅助文件 `node --check`、`git diff --check` 和当前文档/本节报告链接存在性检查通过。HEAD 与已抓取远端分支提交差异 0/0,工作区保留 14 个文件的本地增量。交付与完整矩阵结束后未再修改生产源码,也未重复无关完整测试。 + +## 2026-09-11 09:18 — 借鉴维护者经验,补齐缓存完整性与双实例编辑验收(北京时间) + +- 授权与范围:按用户“吸取网友的优秀经验,继续工作”,继续 PR #37 检查点上的本地修复。只核查公开一手资料,使用现有 Node 24.19.0、锁定 SDK 10.0.303 和已安装依赖;没有新增存储架构/依赖、调用模型、修改真实客户端、提交、推送、合并或发布。上一节的本地修改全部保留。 +- 经验落地:[npm cacache 的读取实现](https://github.com/npm/cacache/blob/main/lib/content/read.js) 将大小和内容摘要纳入校验,启发本轮把“附件还存在”改为可验证的内容完整性。[write-file-atomic 实现](https://github.com/npm/write-file-atomic/blob/main/lib/index.js) 的 activeFiles 排队只在单进程内;[Windows 多进程 #28](https://github.com/npm/write-file-atomic/issues/28) 和 [锁冲突 #227](https://github.com/npm/write-file-atomic/issues/227) 是报告/提议,不当作已合入保证或 WinCode 已复现的故障。保留现有唯一临时文件后 rename 和实例内队列,用真实 Gateway 验证跨进程交错,没有因此增加锁或重试。 +- 先复现再修复:[integrity-before.log](../test-tmp/n4-cache/integrity-before.log) 的四个负例在原实现全部失败:同大小且恢复 mtime 的损坏 overflow 被内存/磁盘读者继续当作命中;合法 JSON 正文被修改,或另一个键的整份 JSON 复制到当前文件名,在相同 fingerprint 下返回错误正文。这说明输入身份正确、文件存在和 JSON 可解析都不足以证明缓存正文正确。 +- 生产修复:[Cache.ts](../src/Core/Cache.ts) 增加绑定命名空间键、时间/TTL、fingerprint、正文和附件身份的 SHA-256 元数据,内存/磁盘命中都核验;附件通过同一文件句柄,以 64 KiB 缓冲区在既有磁盘预算内流式校验大小/摘要。缺失、损坏或旧条目没有摘要时重算。JSON 读取按已打开大小加一个探测字节限定,读取期间检测到增长/缩小即未命中;新增文件增长反例验证读取量没有随追加内容膨胀。目录格式、MCP 公开契约和清理所有权未改变。 +- 修复过程保留:[cleanup-integrity.log](../test-tmp/n4-cache/cleanup-integrity.log) 暴露了第一版补丁提前放弃元数据写入,使超出缓存预算的附件无法立即由原容量清理识别。修正为拒绝缓存复用但保留有界受管元数据,既有 TTL/容量回收恢复;没有扩大孤儿扫描或清理权限。相关反例和有界读取测试共新增 5 项,写入既有 [runtime-cache-regressions.test.ts](../tests/runtime-cache-regressions.test.ts)。 +- 新增 [verify-shared-cache.mjs](../scripts/verify-shared-cache.mjs) 和 [cache-gateway.mjs](../tests/fixtures/cache-gateway.mjs):由测试入口载入正式发布的 ToolRouter/WinCodeMcpServer/Cache 模块,使用真实 SDK stdio 公开工具调用,每个 hello 核对版本、buildId、固定工作区和独立实例。小预算生成夹具用来触发真实写入与自动清理,不是替代 Cache 实现,也不代表实际消费者或标准 CLI 启动配置已验收。关闭回执要求业务占用归零和资源已释放;清理失败、已观测残留或交付变化使验收失败。 +- 共享缓存 8 场景:同键 8 个并发请求;不同键 8 个并发请求;兄弟 20 次实际写入触发容量清理并重建已返回附件;两个热 Gateway 拒绝同大小损坏附件;同项目源码编辑后两端回读更新;A/B 共享物理目录无跨项目正文;一端退出时兄弟继续命中;两个写者退出后全新 Gateway 命中已验证的持久缓存。报告没有损坏 JSON、错误正文、遗留临时文件、关闭后占用或已观测残留;四个客户端 stderr 仅有正常启动消息,没有错误或警告。 +- 验收入口失败保留:[run-5iBtOC](../test-tmp/shared-cache/run-5iBtOC/report.json) 首次在 hello 断言使用错误字段 runtime.version,尚未进入业务场景;实际版本在 hello.version,构建在 hello.runtime.build。按公开返回结构修正断言后重跑完整 8 场景,没有削弱版本/build 核验;失败运行也取得正常停止回执。 +- [verify-design-time-concurrency.mjs](../scripts/verify-design-time-concurrency.mjs) 新增 inputs/peer-source-edit:同项目两个正式 Host 同时加载,初始引用数各为 1;实际编辑生成夹具后,两端旧定位均返回 SNAPSHOT_STALE 且无证据;同时重载后引用各为 2,旧定位继续无效,关闭一个 Host 后另一个保留新快照。默认完整矩阵由 20 增为 21,未改动生产 Roslyn 源码。 + +| 本次缓存增量后的实际验证 | 结果与本地证据 | +| --- | --- | +| 缓存/预算/运行回归专项 | [integrity-after.log](../test-tmp/n4-cache/integrity-after.log):24/24;含内容损坏和错误键反例 | +| 清理/边界相关专项 | [cleanup-integrity-corrected.log](../test-tmp/n4-cache/cleanup-integrity-corrected.log):44/44;包含原容量回收回归与有界读取反例 | +| 完整核心/类型/构建/交付 | [core report](../test-tmp/check/2026-09-11T00-59-55-956Z-core/report.json):430/430,0 跳过;typecheck、Gateway、Native 发布、stdio、delivery 全部通过,日志为 test-tmp/n4-cache/core-check.log | +| 真实 SDK/Gateway 共享缓存 | [run-xTRbJS](../test-tmp/shared-cache/run-xTRbJS/report.json):8/8,9.501 秒,8 个已观测进程身份,cleanupFailures=[]、survivors=[] | +| 完整正式 Host 生产矩阵 | [run-CJF7mW](../test-tmp/design-time-production/run-CJF7mW/report.json):21/21,229.448 秒;89 个已观测进程身份,productionChanged=false、cleanupFailures=[]、survivors=[] | +| E4 错误契约 | [run-pD7oeb](../test-tmp/error-contracts/run-pD7oeb/report.json):17/17,通过 | + +- 当前交付:version=0.15.0,buildId=`09f71cba0339b2bf9f3f9e7d28cd727df7815aa230af1565cda3a04bce6d3187`,sourceHash=`1bd9aa91805d05f3b04955ed4786cc84dffe8e2ea7a2b49f8036a64aadec84c1`,artifactHash=`0924b27f2ce659f5e30cd677513c3fc1c8fedc65f6f388b37e7afe19766d1a6b`,delivery contentId=`8ec5572d5b85ecbc25601208e6f115038d57b2572e1a0fc36ae4303d52b56f5e`。revision 仍为 aa6fc7f,本地生产修改由源码摘要区分;不把当前构建说成干净提交或已部署连接。15 工具/schema 保持,codexConnectionVerified=false。 +- 反证自审:两端校验通过仍不能保证已返回附件永远存在。实际容量清理删除了原附件,后续请求重建;这保留现有可过期引用契约。磁盘条目/字节限制是定期清理目标,不是跨进程瞬时硬配额:本次配置 4 条时一度 14 条,全新 Gateway 启动清理后回到 4 条。没有用测试通过掩盖这个边界,也没有擅自新增租约或全局锁。 +- 未验证事项:写入中断/掉电持久性、超大附件摘要读取成本、长期缓存/原生资源趋势、多文件多写者原子快照、任意动态项目图、UI 并发取证仍未覆盖。上一节 59/22/SDK 10/释放 10/owner-death 是同日上一构建证据,未在缓存增量后逐项重跑;此前 SDK drain 警告也没有因这 8 个小场景无警告就视为修复。Node 22、远端 CI、独立审核和真实消费者仍待验。当前局部修复没有新的 USER_DECISION_REQUIRED;长期附件保留、硬配额或新清理政策需要先明确需求。 +- 文档与持续验收:同步既有 README、CHANGELOG、架构说明、路线图和详细计划,加入一手经验来源并把已完成的存储/编辑验收移出待办。CI Node 22 的并发步骤新增共享缓存入口及 always 报告收集,15 分钟预算不变;当前没有推送触发。test-tmp 回执仅保留本机,历史日志原文保留。 +- 最终核对:3 个本轮验收入口/夹具的 node --check、git diff --check、75 个当前文档/本节相对链接存在性检查通过,交付再次 matched=true。HEAD 与已抓取 PR 分支仍为 aa6fc7f、提交差异 0/0;当前保留 16 个已跟踪文件修改和 2 个新增文件,含上一节的未提交增量。完整验证后未再修改生产源码。 + +## 2026-09-11 09:31 — TDD 红—绿重放、退化检验与完整回归(北京时间) + +- 目标与范围:用户明确要求“进行TDD测试验证代码”。针对本轮缓存完整性及双实例编辑进行验证,保留全部已有工作区修改;没有新增依赖、改动生产实现、操作真实消费者或推送外部变更。由于实现已经存在,本次采用隔离副本重放修复前后行为,并刻意移除关键保护检验用例能否发现退化,不声称这是从零开始的测试先行开发。 +- 新增 [runtime-cache-regressions.test.ts](../tests/runtime-cache-regressions.test.ts) 的 3 项行为回归:inline/overflow 两类旧缓存缺少 integrity/backingFile 元数据时必须重算,重算后正文正确且再次命中;JSON 在路径大小检查之后追加合法空白,超过 maxEntryBytes 时必须未命中。追加空白不改变 JSON 数据,专门验证读取预算,而非借助正文损坏间接失败。原有文件句柄检查后增长的读取量断言继续保留。 +- 可重放实验:[replay.mjs](../test-tmp/tdd-cache/replay.mjs) 复制当前 55 个 TypeScript 源文件(460581 字节)及该测试文件到 test-tmp 独立目录,仅替换 Cache.ts 为 PR aa6fc7f 的版本。其余模块保持当前代码,用相同 7 个反例验证差异;这不是完整历史 PR 或 Native 的重建。测试仍使用现有 tsx 和本机依赖。每个阶段核对实际 TAP 用例/通过/失败/跳过数量及退出码,失败类型均为 ERR_ASSERTION,没有以编译、导入或环境错误充当红阶段。 + +| 红—绿/退化验证阶段 | 实际结果 | +| --- | --- | +| PR 原版 Cache.ts | 7/7 按预期失败:内存/磁盘同大小附件损坏 2,JSON 正文修改/换键 2,旧缓存两类 2,路径检查后文件增长 1 | +| 当前 Cache.ts | 同一组 7/7 通过,0 跳过 | +| 移除正文完整性校验 | 对应 2 个反例均失败 | +| 摘要不再绑定缓存键 | 换键反例失败 | +| 移除附件摘要比对 | 内存/磁盘两个损坏附件反例均失败 | +| 把有界 JSON 读取改为 readFile | 大小检查后增长反例失败 | +| 恢复当前实现 | 同一组再次 7/7 通过,主工作区 Cache.ts 哈希始终不变 | + +- [完整红—绿报告 run-kLuIQ8](../test-tmp/tdd-cache/run-kLuIQ8/report.json):success=true,7 个阶段,6.627 秒;四种选定退化全部被检出,不作为全项目 mutation coverage。报告包含每阶段日志、实际失败名称、源码/测试 SHA-256 和 productionSourceUnchanged/testsUnchanged=true;副本最后恢复当前实现。正式 Cache.ts 文件摘要为 bbae056552bb3a3eba3a1362fa3ce3dcdb5cd4be3957cee033dbed41ea8212a6,PR 原版为 e84c10c07c856d2b3d389930f5d56adada2ce991023b92f19924f52e7eb1111c。 +- 测试自身的失败也保留:[run-XfrwD0](../test-tmp/tdd-cache/run-XfrwD0/report.json) 首轮当前实现 6/7 通过,失败是新增 overflow 用例把包含随机附件路径的 preview 文本要求完全相同。重建会产生新路径,因此改为验证新旧路径不同、实际附件正文逐字节内容相等,inline 仍比较完整正文;继续要求首次未命中和重建后命中。该失败不归为生产缺陷,未通过修改实现迎合测试。 + +| 追加的当前实现验证 | 结果与证据 | +| --- | --- | +| 完整核心、类型、Gateway/Native 构建、stdio、delivery | [2026-09-11T01-27-26-632Z-core](../test-tmp/check/2026-09-11T01-27-26-632Z-core/report.json):433/433,0 失败/取消/跳过,45.874 秒;日志 test-tmp/tdd-cache/core-check.log | +| 真实 SDK/Gateway 共享缓存 | [run-awRGKn](../test-tmp/shared-cache/run-awRGKn/report.json):8/8,7.541 秒,8 个已观测进程身份,cleanupFailures=[]、survivors=[] | +| 双正式 Roslyn Host 编辑/重载专项 | [run-xhNUyA](../test-tmp/design-time-production/run-xhNUyA/report.json):1/1,10.990 秒,4 个已观测身份;编辑前引用 1/1,旧定位均 SNAPSHOT_STALE,重载后 2/2,关闭一端后兄弟仍为 2;cleanupFailures=[]、survivors=[] | + +- 交付与边界:buildId 仍为 09f71cba0339b2bf9f3f9e7d28cd727df7815aa230af1565cda3a04bce6d3187,sourceHash、artifactHash 和 delivery contentId 均与上一节相同,matched=true。没有发现需要修改生产实现的新缺陷;源码只新增上述 3 项测试,并同步 README/路线图/计划当前计数与证据。上一节完整 Host 21 场景和 E4 17 是同一生产构建的既有结果,本次 Host 只重跑 peer-source-edit,不冒充再跑完整 21 场景。 +- 反证自审:仅断言未命中可能让“禁用所有缓存”错误实现通过;用例同时要求其他键仍可读取、旧缓存重建后再次命中,并比较实际正文,保留成功路径。隔离副本中的四种刻意退化只证明对应保护被这些测试覆盖,不外推掉电一致性、跨调用附件租约、磁盘瞬时硬配额、UI 并发、长期资源、Node 22、远端 CI 或真实消费者。 + +## 2026-09-11 09:58 — 整体架构复核与 PR #37 合并就绪判断(北京时间) + +- 任务边界:用户要求再次复核整体代码架构、确定下一步并判断是否可以合并。本次检查源码、调用链、已有回执和 GitHub 实时状态,并在 test-tmp 生成小型反例;没有修改生产源码、已有测试或远端 PR,也没有提交/推送/合并。这里只追加复核记录。 +- 架构结论:继续保留单连接固定工作区的 Gateway,由 ToolRegistry/McpServer 统一参数与准入,ToolRouter 组织恢复/释放,现有 Adapter 隔离 Roslyn 与 UIA,Native 以快照和 UUID 归属管理语义及输出。静态扫描 55 个 TypeScript 文件、139 条本地非显式 type-only 导入边,没有发现循环;该扫描不覆盖动态依赖或运行时正确性。重点复查了 RequestAdmission/OperationContext/Mutex、Workspace 固定根、Router drain/恢复、Roslyn Host 协议及退出、私有输出输入策略、缓存、UI 请求截止和交付/CI。现有方向可保留,下一步应先收口并发正确性与交付,不需要为了合并扩大架构。 +- 新发现 [P2]:[Cache.ts](../src/Core/Cache.ts) 的 get 在保存 memEntry 后 await backingFileMatches,再无条件执行 memoryCache.delete/set。等待期间,set、淘汰或 clear 已可能改变同一 Map;恢复时旧对象会覆盖新值或复活已删除条目,且没有相应恢复 memoryBytes。失败校验分支同样需核对自己删除的是否还是原条目。origin/main 也存在同样 await 后 delete/set 结构;本次是发现此前未覆盖的操作交错,不归因于上一节新增测试。 +- 当前正式构建上的确定性反例:[repro-cache-race.mjs](../test-tmp/architecture-review/repro-cache-race.mjs) 直接使用已验证的 dist/Core/Cache.js,只在生成的缓存目录执行公开 CacheManager 方法,无 mock、无生产文件回滚。[run-XoxWYs/report.json](../test-tmp/architecture-review/run-XoxWYs/report.json) 记录三个 reproduced=true:①读旧值与 set(new) 交错,set 已完成后内存仍返回 old,冷读磁盘为 new;②maxMemoryEntries=1 却保留 2 条,报告 2 字节而两个字符串按既有估算合计 4 字节;③clear 已完成后仍返回 old,内存 1 条而统计 0 字节。这是模块行为反例,没有声称已复现跨项目 MCP 错误正文或整个进程 RSS 失控。 +- 测试结论修正:上一节 433/433、7 个红—绿反例和四种退化检验仍是其实际覆盖范围内的通过结果;本次新增反例证明它们没有覆盖异步读取与内存状态修改交错。因此不能据旧测试通过直接判定当前本地代码可合并。建议在本 PR 收尾中先将上述 3 个反例纳入正式回归,异步边界后校验条目身份/状态代次,再修改缓存状态;同时检查磁盘回填的同类交错,避免仅修成功内存命中这一条分支。 +- GitHub 实时状态:通过 GitHub 连接器及现有 gh 的只读查询核对 [PR #37](https://github.com/linnnn89/WinCode/pull/37)。head=aa6fc7f457f5a18b122fd791aec2824ed121195d,base=fb3cd48df3f38b209565b906fbfe3485df48461d,state=open、draft=false、merged=false、mergeable=MERGEABLE,但 mergeStateStatus=BLOCKED;review/review thread 均为空。Git 无冲突不能代替必需检查通过,本地未提交修复也不是 PR 当前内容。 +- 必需检查:gh pr checks --required 返回 5 项,其中 Windows regression (Node 22) 为 FAILURE;Node 24、Analyze (actions/csharp/javascript-typescript) 为 SUCCESS。另一个汇总 CodeQL 也为 SUCCESS。[Node 22 作业 102911583651](https://github.com/linnnn89/WinCode/actions/runs/34489311570/job/102911583651) 的实际日志确认:真实 Host 第 39 个已完成场景后,预期 PROJECT_LOAD_FAILED、实际 QUERY_FAILED,退出 1;该错误映射的本地修复尚未推送。GitHub 状态与昨晚 PR 描述只是远端检查点事实,不作为新的用户指令。 +- 当前交付再核验:delivery matched=true,contentId 仍为 8ec5572d5b85ecbc25601208e6f115038d57b2572e1a0fc36ae4303d52b56f5e;读取现有核心 433/433、完整 Host 21/21、共享缓存 8/8 和 TDD 回执确认其成功/清理状态。本次没有重复无关全套测试,三个新的缓存时序反例才是本轮新运行的验证。 +- 合并前建议顺序:①修复缓存状态交错并以反例驱动回归,确认正常命中、替换/清空结果和容量计数;②重建/核验最终源码,按影响复跑核心、共享缓存及必要 Host 用例;③在获得提交/推送授权后,把当前增量纳入同一个 PR,更新过时的 WIP 描述与证据;④以新的实际 PR head 核对 5 个必需检查、最终差异和分支保护,再决定合并。当前既有 BLOCKED 状态也有尚未修复的相关缺陷,不建议立即合并。 +- 后续验收分层:UI 多实例只读取证、真实消费者/模型闭环、长期/大项目资源和任意动态项目图可以作为后续明确范围的验收;已公开的附件可过期、定期磁盘清理和掉电孤儿产物限制不自动转成此次合并必须完成的新功能。发布或对外承诺相关能力时仍需对应证据。本次为当前助手的架构复核与反例验证,不替代独立模型/人工评审。 + +## 2026-09-11 10:28 — 缓存状态交错修复、共享截止退化修复与 TDD 验收(北京时间) + +- 授权与范围:用户明确要求“好,你开始修复吧”。在原工作区保留全部已有增量,修复上一节缓存 P2;完整检查又暴露一个直接阻碍验收的 N3 超时分类问题,以受控反例确认后局部修复。此次生产代码只新增修改 [Cache.ts](../src/Core/Cache.ts) 和 [ToolRouter.ts](../src/Core/ToolRouter.ts),未改变公共 MCP 参数/工具数量、缓存布局/清理所有权或并发参数,未新增依赖、服务、跨进程锁或模型调用。没有提交、推送、合并、发布或切换真实消费者。 +- Cache 修复:异步附件校验后核对 Map 中是否仍为原条目,失效则未命中,成功刷新及失败删除都不能作用于替换后的条目;旧写入的附件校验失败也只删除自身。磁盘读取先排空已接受写入,使用单一状态代次阻止旧读回填到新值、清空后的内存或新的 namespace/目录。写入、内容记忆更新、prune 和工作区重置使正在进行的磁盘读失效;无每键永久 tombstone。过期/超大记录的删除进入原写队列,在队内再次核对代次,避免旧读删除新落盘值。并行有效读取继续返回数据并遵守同一 LRU 字节/条目预算;发生其他键的修改时,磁盘读可以保守未命中。 +- 正式回归位于 [runtime-cache-regressions.test.ts](../tests/runtime-cache-regressions.test.ts):先添加 12 个交错用例,在修改生产实现之前运行 [red.log](../test-tmp/cache-state/red.log),12/12 为断言失败;修复后相同 12/12 通过。再补已接受 clear/disk-only write 的排空顺序 2 项及正常并行命中 1 项,共 [15/15](../test-tmp/cache-state/green-final.log)。前三类公开方法交错直接复现,无 mock;需要固定磁盘时序的用例只在真实读完/关闭文件后暂停,再执行真实替换/清空。 + +| 缓存 TDD / 反证阶段 | 实际结果 | +| --- | --- | +| 修复前 Cache.ts,隔离重放最终 15 项 | 14 项断言失败,正常并行命中 1 项通过;没有编译、导入或环境错误充当红阶段 | +| 修复后相同 15 项 | 15/15,0 取消/跳过 | +| 移除异步内存条目身份检查 | 5 个对应交错均失败 | +| 移除状态代次更新 | 6 个磁盘回填/删除反例均失败 | +| 移除失败写入的条目身份检查 | 1 个反例失败 | +| 磁盘读绕过已接受写队列 | 2 个顺序反例失败 | +| 强制所有读取未命中 | 正常并行命中反例失败 | +| 恢复当前 Cache.ts | 15/15,主工作区 Cache.ts 和缓存测试文件哈希始终未变 | + +- [隔离重放脚本](../test-tmp/cache-state/replay.mjs) 与 [run-bH8Dhp/report.json](../test-tmp/cache-state/run-bH8Dhp/report.json):success=true,8 个阶段。旧 Cache 使用前次已保存副本并验证 SHA-256=bbae056552bb3a3eba3a1362fa3ce3dcdb5cd4be3957cee033dbed41ea8212a6;只复制当前 TypeScript 源码/目标测试并替换隔离 Cache,不回滚主工作区。五种选定退化全部被检出,不等同全项目 mutation coverage;该缓存实验在下面 Router 修复之前完成,不能当作完整最终 Gateway 的旧版本重建。 +- 完整检查失败过程保留:[第一轮](../test-tmp/check/2026-09-11T02-12-43-939Z-core/report.json) regression 在 300 秒超时,TAP 总结不完整,日志中 MCP architecture/symbol 两项分别触发原有 8 秒超时,最后仍存活的测试子进程属于 resource-cleanup,超时结束后已不存活。未把它报告为完整通过或确定为缓存死锁。随后单独运行 [resource-cleanup 8/8](../test-tmp/cache-state/resource-cleanup-diagnostic.log) 和 [MCP stdio 12/12](../test-tmp/cache-state/mcp-stdio-diagnostic.log) 均通过,首次全套超时的具体根因仍未确认。 +- [第二轮完整检查](../test-tmp/check/2026-09-11T02-19-09-748Z-core/report.json) 正常结束但 **447/448**,唯一失败为排队过期请求预期 REQUEST_TIMEOUT、实际 CANCELLED;15 个新缓存回归均通过。调查发现 RequestLease 与 runCode 对同一截止各设置一次定时器,内层先触发会经 Mutex 转成 AbortError,而准入层尚未标记超时,导致分类和计数错误。 +- 在 [request-admission.test.ts](../tests/request-admission.test.ts) 新增受控时序:准入后、适配器入队前推进观察时钟,使重复的剩余预算定时器确定先触发;仍要求旧 owner 保持、队列节点移除、REQUEST_TIMEOUT、timedOut=1、cancelled=0。[修复前](../test-tmp/cache-state/deadline-red.log) 确定得到 CANCELLED 并失败。ToolRouter 仅在截止与准入租约相同的时候复用其计时与原因,独立更短的操作仍保留定时器。修复后缓存、准入、请求并发和生命周期取消 [67/67](../test-tmp/cache-state/cache-admission-green.log);没有延长超时、放宽错误码或降低计数断言。 + +| 最终源码/构建验证 | 结果与证据 | +| --- | --- | +| 完整核心、类型、Gateway/Native 构建、stdio、delivery | [2026-09-11T02-23-40-026Z-core](../test-tmp/check/2026-09-11T02-23-40-026Z-core/report.json):**449/449**,0 失败/取消/跳过,43.543 秒;原命令 node scripts/check.mjs | +| 真实 SDK/Gateway 共享缓存 | [run-08Twxo](../test-tmp/shared-cache/run-08Twxo/report.json):**8/8**,7.589 秒,8 个已观测进程身份,cleanupFailures=[]、survivors=[] | +| 三个正式 Gateway/Roslyn 同时 A/B/A 冷启动与突发/取消 | [run-aQPfmx](../test-tmp/multi-agent/run-aQPfmx/report.json):**10/10**,12 个已观测身份,survivors=[];--roslyn-only 仅排除托盘容量,未串行同根启动 | +| E4 公开错误契约 | [run-0H1tDE](../test-tmp/error-contracts/run-0H1tDE/report.json):**17/17**;生成输入及现有 SDK,不使用真实 UI/外部适配器 | + +- 最终交付:Node 24.19.0、项目锁定 SDK 10.0.303;buildId=9e644bce6df5c01716938b5f3c923cc2ba80f71f24a2323abda93de4d149bb88,sourceHash=c9dda2872d859b0061a9464ab5e43091e36be589e9d6796c015b12cbfc907589,artifactHash=35b9990e8accd58e64b14e7b6b46c9576432dc887c0fb1e8bd24db8df1a8b587。delivery contentId=1f86b5a058d22be12e60b1cf2a2b0c823069d7bf239bdf29692d000aea7fd1de,matched=true;15 tools 与 schemaHash=4f8a6424c23978ebffe77111f4687c87336de64320f7d24cde8bdeede8ab804f 保持。revision 仍是 aa6fc7f 加本地未提交增量,不是一个已推送新提交;codexConnectionVerified=false。 +- 保留的限制与反证:首次完整运行超时不因随后通过就被解释为已修复;SDK 128 请求突发仍有 11 个 drain 监听器警告,阶段结束为 0,未调整阈值。全套 449 与上述受控场景不证明 Node 22、真实消费者、UI 并发、长期 RSS/原生资源或断电一致性。前次 Host 21/21 仍作为本次 Cache/Router 修复前的结果保留;本次 Native 源码未改,但未再执行完整 21 场景。身份/代次失效允许保守未命中,不增加附件租约或跨进程硬配额。 +- 远端与下一步:本轮 10:17 通过现有 gh 只读核对 PR #37 仍为 OPEN、非 draft、head=aa6fc7f、mergedAt=null、mergeStateStatus=BLOCKED;五个必需检查中 Node 22 FAILURE,Node 24 与三项 Analyze SUCCESS。此次修复已在本地完成,不能声称 PR 当前源码已包含修复或可立即合并。下一步在明确提交/推送授权下整理同一 PR 的新提交与描述,再按新 head 核对检查和最终差异;本轮无新增架构/依赖决策。README、CHANGELOG、架构说明、计划和路线图已同步当前结果及历史证据边界。 +- 最后复核(10:31):远端 head、BLOCKED 与五项必需检查结果保持上述状态。git diff --check、当前交付 matched=true、本节 18 个本地链接存在性通过;保留 18 个已跟踪文件修改及 2 个新增文件,含此前所有未提交工作。最终全套通过后未再修改生产源码或测试,只同步说明与验收记录。 + +## 2026-09-11 11:02 — 截止结果修复、失败收尾验证与合并前本地收口(北京时间) + +- 目标与范围:按用户要求继续自审、修复,达到合理的 PR 合并标准,操作不超出工程项目文件夹。保留上一节全部修改;使用现有 Node 24.19.0、项目 SDK 10.0.303 和已安装依赖,测试进程的 TEMP/TMP 指向工程内 test-tmp/project-temp。没有修改全局配置、下载新运行时、调用模型、操作真实消费者或提交/推送/合并。以已确认的固定工作区、准入、缓存及 Roslyn 输出隔离为本 PR 范围,不要求新增共享服务、通用租约、UI 全覆盖或无限负载证明。 +- 本次自审:复查 RequestAdmission/OperationContext/McpServer/ToolRouter 的预算和恢复调用链,Workspace 固定根,Cache 身份与代次,Native DesignTimeBuild/OwnedBuildOutputs/WorkspaceSession/WorkspaceInputs,以及 Gateway Host 生命周期、ResourceManager、Repomix/FlaUi 取消传播、CI 和交付边界。保留原架构和公开 15 工具契约;上一节缓存修复没有再改动。 +- 新增三个截止反例,均先在正式 [request-admission.test.ts](../tests/request-admission.test.ts) 中失败,再修生产代码:①准入后推进观察时钟,截止检查先于定时器抛出时仍需计入 timedOut;②状态工具完成时已超出截止,不能返回成功;③更短的适配器排队预算不能经 Mutex 被误报为 CANCELLED,且不得执行已过期回调。原 [deadline-red.log](../test-tmp/merge-review/deadline-red.log) 为 3/3 断言失败,0 跳过,错误和计数断言均未放宽。 +- 生产修复:[RequestAdmission.ts](../src/Core/RequestAdmission.ts) 的 release 接收实际失败原因,补齐同步截止和较短预算的超时计数;[McpServer.ts](../src/Gateway/McpServer.ts) 执行返回后再检查截止,并将失败传入租约收尾;[ToolRouter.ts](../src/Core/ToolRouter.ts) 在保留显式恢复错误之后检查实际 operation,保留适配器超时分类。相同截止继续复用租约定时器,独立较短截止仍有自己的定时器。取消/超时不提前归还尚在清理的容量,不增加自动重放。相关准入、取消、并发和恢复 [57/57](../test-tmp/merge-review/deadline-green.log) 通过。 +- 资源测试失败收尾:[resource-cleanup.test.ts](../tests/resource-cleanup.test.ts) 四个创建 Router/Server 的用例改为创建后立即注册 t.after,在初始化或断言失败时也释放 watcher。隔离故障注入 [verify-cleanup-failure.mjs](../test-tmp/merge-review/verify-cleanup-failure.mjs) 保持正式生产源码,只把目标断言替换为明确的 intentional failure:旧测试 [cleanup-vR6KmS](../test-tmp/merge-review/cleanup-vR6KmS/report.json) 到 4024 ms 仍不能退出/给出完整 TAP;修正后 [cleanup-PUaE5c](../test-tmp/merge-review/cleanup-PUaE5c/report.json) 在 1091 ms 正常以 exit 1 结束,并完整报告 1 个预期失败。正常专项 [8/8](../test-tmp/merge-review/resource-cleanup-green.log)。这证明失败收尾缺陷已修复,不声称首次 8 秒业务超时的全部性能根因已定位。 +- 工程内 TEMP 的一次真实失败:[完整检查 02-43-17](../test-tmp/check/2026-09-11T02-43-17-174Z-core/report.json) 为 451/452,唯一失败是非 Git 夹具在工程内创建后,Git 正确发现了父仓库。[process-failures.test.ts](../tests/process-failures.test.ts) 在该夹具作用域内设置 GIT_CEILING_DIRECTORIES、finally 恢复原值,使测试明确模拟非 Git 工作区;未改变生产 Git 行为或全局环境。相关 [6/6](../test-tmp/merge-review/project-temp-green.log),随后按原命令重跑全套,没有降低并发或延长超时。 + +| 最终源码上的本地验证 | 结果与证据 | +| --- | --- | +| 核心、类型、Gateway/Native 构建、stdio、交付 | [02-46-37 core report](../test-tmp/check/2026-09-11T02-46-37-493Z-core/report.json):452/452,0 失败/取消/跳过;44.088 秒;[完整日志](../test-tmp/merge-review/core-check-final.log) | +| 完整非桌面 CI 命令序列 | [acceptance-5hKQAM](../test-tmp/merge-review/acceptance-5hKQAM/report.json):11 个步骤退出 0,490.255 秒;每项成功回执和前后交付一致,manifest 文件未变化 | +| E4 错误契约 | [run-TW1FjA](../test-tmp/error-contracts/run-TW1FjA/report.json):17/17 | +| Native Host / Roslyn Gateway | [fixture-o4h1VG](../test-tmp/roslyn-host/fixture-o4h1VG/report.json):59/59;[run-HR1YxZ](../test-tmp/roslyn-gateway/run-HR1YxZ/report.json):22/22 | +| 两类 owner-death | [Roslyn 加载](../test-tmp/owner-death/run-dUQFCo/report.json)、[Repomix 工作](../test-tmp/owner-death/run-aRHJzX/report.json):各 1/1 | +| 手动释放 | [run-RsbRP1](../test-tmp/manual-release/run-RsbRP1/report.json):10 个 cycles、2 类 scenarios;每轮及最终已观测 survivors=[] | +| 同时 A/B/A SDK/Roslyn | [run-WfBmSB](../test-tmp/multi-agent/run-WfBmSB/report.json):10/10;survivors=[];128 突发受理 32、拒绝 96,业务 active/executing/waiting 最终均 0 | +| 完整正式 Host 生产矩阵 | [run-Q5tOWZ](../test-tmp/design-time-production/run-Q5tOWZ/report.json):21/21,201.575 秒;89 个已观测身份,productionChanged=false、cleanupFailures=[]、survivors=[] | +| 真实 SDK/Gateway 共享缓存 | [run-wJWGon](../test-tmp/shared-cache/run-wJWGon/report.json):8/8,8.381 秒;8 个已观测身份,cleanupFailures=[]、survivors=[] | + +- 报告口径:原聚合脚本的 scenarios 字段只处理 scenarios/observations,因 Host 使用 cases 而显示 0;实际执行及成功判断由原 Host 回执的 21 个 passed cases 证明。手动释放按 10 cycles 和 2 scenarios 分别报告,不混用计数。原始回执保留,不静默改写历史。 +- 最终交付:buildId=`ff308c972a7296bce88891287958c73c1c06fd4766c9c6ecab075f302272dad1`,sourceHash=`0a125d874b28457e70e8d231ac65a72b874236798ae5b659eefe88d077adf273`,artifactHash=`308066c6e065f303f85d539c7eaf84ce6a021a4201e5aabab465f83f03c0b519`,delivery contentId=`9ccaeb481c2f716deeb624b2a7de6363663ca3f0f86fed122680a35926af9096`。version=0.15.0 未发布,revision 仍为 aa6fc7f 加本地未提交增量;不能把它当作已推送新提交或已更新消费者。 +- 反证与合理边界:SDK 客户端 @modelcontextprotocol/client 的 StdioClientTransport.send 在 128 请求突发时仍出现 11 个 drain 监听器警告,阶段结束为 0;没有抬高阈值或改依赖来掩盖。已返回附件可被未来容量清理删除,磁盘预算是周期清理目标,现有超时不提供 OS I/O 强制终止。短时受控验收不证明长期原生资源、UI 并发、掉电持久性或任意动态项目图;这些不自动成为本 PR 的新增实现条件。 +- 合并判断:当前自审发现的代码缺陷已修复,本地 Node 24 核心及非桌面验收完成,未发现需要进一步扩展架构的阻塞缺陷。PR 当前仍不能据此认定可直接合并:工程目录没有现成 Node 22,未下载新运行时;最新本地修复尚未进入远端。最后远端证据仍是前序 10:31 的旧 head aa6fc7f、BLOCKED/Node 22 FAILURE,本段未重新查询远端。后续只需在新 head 上完成仓库必需检查与最终差异复核,不额外设定完美目标。 +- USER_DECISION_REQUIRED:依据用户提供的 AGENTS.md 第四节,需要明确提交/推送授权,才能把本地增量送入同一个 PR #37、更新说明并使用现有 Node 22/24 和 CodeQL CI。推荐直接用仓库现有 CI 获取兼容性证据,避免为了这一步新增本地运行时。合并、发布和真实客户端更新不包含在该建议授权中。README、CHANGELOG、架构、计划及路线图已同步本次结果;所有原始 test-tmp 证据仅保留本地。 +- 可审阅交付:[PR 说明草案](../test-tmp/merge-review/pr-body.md) 已按最终范围准备,尚未发送;建议标题为 feat: isolate workspaces and Roslyn builds with bounded request admission。[最终汇总](../test-tmp/merge-review/final-receipt.json) 从原始回执独立读取并断言 452 项、各集成场景数和同一交付身份,保留 pending 与 mergeReady=false;没有修改原始报告。汇总脚本为 [summarize.mjs](../test-tmp/merge-review/summarize.mjs)。 +- 收尾核对:git diff --check、delivery matched=true、当前文档及本节 59 个本地链接存在性均通过。HEAD 仍为 aa6fc7f;保留 22 个已跟踪修改和 2 个新增文件,包含前序已完成的本地增量。最终核心/非桌面验证之后仅同步说明和本地证据汇总,没有再改生产源码或正式测试。已提出上述提交/推送授权申请,尚未执行外部变更。 + +## 2026-09-11 11:39 — PR #37 提交与 CI 清理修正(北京时间) + +- 用户已授权提交当前版本 PR,随后只读评估近几个版本的工程复杂度。提交 2d4ee56 已推送同一 PR #37,标题和说明已更新;没有合并。 +- 该 head 的 Node 24 与三项 CodeQL 通过;Node 22.23.2 为 450 通过、1 失败、1 可选 TavernDesk 跳过。失败位于 owner-process-guard.test.ts 的 finally:Helper 正常退出后仍启动 PowerShell 查询清理,命令超过 5000 ms,被 SIGTERM 终止;JUnit 明确记录 killed=true、code=null、空 stdout/stderr。原始 [CI report](../test-tmp/pr37-ci-2d4ee56/node22/check/2026-09-11T03-29-15-957Z-core/report.json) 与 regression.xml 保留在本机。未把该失败归为生产 OwnerProcessGuard 故障。 +- 对直接阻塞交付的问题只修测试收尾:先用 signal 0 检查 PID,只有 ESRCH 才跳过;存活或未知 PID 仍进入原有持句柄/创建时间核验,保持清理预算。正常/repeat 两个既有用例增加 Helper 已退出断言,没有新增测试。依据 [Node process 文档](https://nodejs.org/api/process.html#processkillpid-signal) 的无副作用存在性检查及 [child_process 文档](https://nodejs.org/api/child_process.html) 的 timeout/killSignal 行为。 +- 修正后 owner-guard 原 13 项全部通过(13.757 秒),类型检查和 git diff --check 通过;没有重复无关全套或修改生产源码。新的远端 head 仍需取得自己的必需检查结果。复杂度评估不新增设计文档或直接重构,结果在本次回复交付。 + +## 2026-09-11 — PR #37 共享缓存验收契约修正 + +- head 3a3d176 的 Node 22 在 13 分 55 秒后因共享缓存第 3 场景断言失败;未触发总超时。核心、真实 Roslyn/清理、多实例和正式 Host 21 项矩阵均已通过。原始 [CI 回执](../test-tmp/pr37-ci-3a3d176/node22/shared-cache/run-ho390i/report.json) 报 No overflow record for A_CURRENT_0,调用内容断言已通过,清理失败及残留均为空。 +- 旧验收要求每次有效内存命中后仍能查到磁盘 JSON 索引,超出现有可淘汰缓存契约。Cache.get 在验证内存条目及附件后直接返回;同类 [npm/cacache get 实现](https://github.com/npm/cacache/blob/main/lib/get.js) 也先返回 memoized 数据再查询索引。该参考仅用于核对层次关系,不替代本地验证或引入依赖。 +- 在原场景内确定性删除自有索引、保留有效热条目,旧断言复现同一失败([red](../test-tmp/shared-cache/run-sO4Dlm/report.json))。修正为验证实际旧附件淘汰、至少一次重建、索引删除后的正确热命中;其余损坏/修改/跨根/退出场景保留,没有新增自动化测试。 +- 原共享缓存验收修正后 8/8 通过([green](../test-tmp/shared-cache/run-F11W1L/report.json),8.303 秒;observedRebuilds=1,warmReadAfterIndexEviction=true,cleanupFailures=[]、survivors=[]);node --check 和 git diff --check 通过。生产源码和 CI 超时预算未改,下一 head 的远端必需检查仍待运行。 + +## 2026-09-11 — CI 冷启动与完整验收预算核对 + +- head 211799f 首轮 Node 22 的 Tray --endpoint 子进程超过生产代码的 3000 ms 预算,被 SIGTERM 终止,stdout/stderr 为空;同一用例本地 375.322 ms 通过。该轮 restore-host/publish-host 分别 33.773/38.214 秒,前一轮为 6.009/8.338 秒,因此只重跑一次相同 head 的失败任务验证环境/冷启动波动线索,没有修改 Tray 或重复重跑已成功的检查。重跑核心、真实 Roslyn/清理及多实例通过;这不证明 Tray 首次超时的根因已经解决。 +- 重跑的 [逐场景回执](../test-tmp/pr37-ci-211799f-retry/node22/design-time-production/run-1wXhX2/report.json) 显示 WPF 等待就绪触发了验收脚本独有的 20000 ms 限制;生产 RoslynAdapter 默认加载预算为 120000 ms。外层 job 同时被 GitHub 的 15 分钟限制取消,检查注释明确为 The job has exceeded the maximum execution time of 15m0s,完整矩阵未完成。不能只把取消解释为业务用例全部正常,也不能用旧 head 的通过替代此次失败。 +- 根据上述新证据,将设计时语义验收加载预算对齐既有生产默认 120 秒;Windows CI 整项预算设为 20 分钟容纳冷启动和完整矩阵。共享缓存移至构建后优先运行,SDK 并发和设计时输出各占独立步骤。依据 [GitHub workflow timeout 定义](https://github.com/github/docs/blob/main/content/actions/reference/workflows-and-actions/workflow-syntax.md#jobsjob_idtimeout-minutes) 区分 job 总预算与业务截止;生产请求/查询/清理的超时和取消行为均未修改,没有新增测试或依赖。 +- 验证:YAML 解析通过,并比较确认所有验收命令及 Node 条件、job 名称、报告保留设置与修改前一致;脚本语法与 git diff --check 通过。正式发布 Host 的 WPF 定向场景 [run-nKcqEc](../test-tmp/design-time-production/run-nKcqEc/report.json) 1/1 通过,productionChanged=false。新 head 仍需远端完成五项必需检查;当前未合并。 diff --git a/package-lock.json b/package-lock.json index b00fa1c..09441c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wincode-mcp", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wincode-mcp", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/client": "2.0.0", diff --git a/package.json b/package.json index ebe2666..4efaf99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wincode-mcp", - "version": "0.14.0", + "version": "0.15.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 tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts", + "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts tests/resource-identity.test.ts tests/check-reporting.test.ts tests/fixed-workspace.test.ts tests/request-admission.test.ts tests/design-time-artifacts.test.ts", "test:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", diff --git a/scripts/check.mjs b/scripts/check.mjs index b40dadb..a54014c 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -1,8 +1,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolveDotnet } from './lib/dotnet.mjs'; +import { runCheckStage, testReporters } from './lib/check-stage.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); @@ -34,26 +34,10 @@ if (inventoryOnly) { environment: { node: process.versions.node, platform: process.platform, arch: process.arch }, stages: [], success: false }; async function run(name, command, args) { console.log(`[check] ${name}`); - const started = Date.now(); - const result = spawnSync(command === 'dotnet' ? toolchain.dotnet : command, args, { - cwd: root, env: toolchain.env, encoding: 'utf8', windowsHide: true, timeout: 300000, maxBuffer: 8 * 1024 * 1024, - }); - const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; - await fs.writeFile(path.join(directory, `${name}.log`), output); - const success = !result.error && result.status === 0; - report.stages.push({ name, command: [command, ...args].map(arg => arg.replaceAll(root, '').slice(0, 512)), success, durationMs: Date.now() - started, - ...(success ? {} : { error: (result.error?.message ?? output.slice(-2000)).slice(0, 2000) }) }); - if (!success) throw new Error(`${name} failed; see ${path.join(directory, `${name}.log`)}`); - return result.stdout; + return runCheckStage({ report, directory, root, name, command: command === 'dotnet' ? toolchain.dotnet : command, + args, env: toolchain.env }); } const node = (name, args) => run(name, process.execPath, args); - const testTotals = output => { - const totals = Object.fromEntries([...output.matchAll(/^# (tests|pass|fail|cancelled|skipped) (\d+)\r?$/gm)] - .map(match => [match[1], Number(match[2])])); - if (!(totals.tests > 0) || ['pass', 'fail', 'cancelled', 'skipped'].some(key => totals[key] === undefined)) - throw new Error('Test process did not return a complete TAP summary.'); - return totals; - }; const tsc = path.join(root, 'node_modules/typescript/bin/tsc'); const tsx = path.join(root, 'node_modules/tsx/dist/cli.mjs'); const native = 'tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj'; @@ -70,7 +54,7 @@ if (inventoryOnly) { await node('verify-delivery', ['scripts/delivery-manifest.mjs', '--verify']); 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-tests', [tsx, '--test', ...testReporters(directory, 'desktop-tests'), '--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']); @@ -85,7 +69,7 @@ if (inventoryOnly) { 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])); + await node('regression', [tsx, '--test', ...testReporters(directory, 'regression'), ...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, resourceCleanup: stdio.resourceCleanup, codexConnectionVerified: false }; diff --git a/scripts/lib/check-stage.mjs b/scripts/lib/check-stage.mjs new file mode 100644 index 0000000..a58d873 --- /dev/null +++ b/scripts/lib/check-stage.mjs @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +export function testTotals(output) { + const totals = Object.fromEntries([...output.matchAll(/^# (tests|pass|fail|cancelled|skipped) (\d+)\r?$/gm)] + .map(match => [match[1], Number(match[2])])); + return totals.tests > 0 && ['tests', 'pass', 'fail', 'cancelled', 'skipped'] + .every(key => Number.isSafeInteger(totals[key]) && totals[key] >= 0) ? totals : null; +} + +export const testReporters = (directory, name) => ['--test-reporter=tap', '--test-reporter-destination=stdout', + '--test-reporter=junit', `--test-reporter-destination=${path.join(directory, `${name}.xml`)}`]; + +/** Persist the captured output and failed-stage summary before propagating failure to check.mjs. */ +export async function runCheckStage({ report, directory, root, name, command, args, env, + timeout = 300000, maxBuffer = 8 * 1024 * 1024 }) { + const started = Date.now(); + const result = spawnSync(command, args, { cwd: root, env, encoding: 'utf8', windowsHide: true, timeout, maxBuffer }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + const logFile = `${name}.log`; + await fs.writeFile(path.join(directory, logFile), output); + const isTest = args.includes('--test'); + const tests = isTest ? testTotals(result.stdout ?? '') : null; + const junitFile = isTest && await fs.stat(path.join(directory, `${name}.xml`)).then(stat => stat.isFile(), + error => { if (error.code === 'ENOENT') return false; throw error; }) ? `${name}.xml` : null; + const summaryError = isTest && !tests ? 'Test process did not return a complete TAP summary.' : null; + const success = !result.error && result.status === 0 && !summaryError && (!tests || (!tests.fail && !tests.cancelled)); + const stage = { name, command: [command, ...args].map(arg => arg.replaceAll(root, '').slice(0, 512)), + success, durationMs: Date.now() - started, exitCode: result.status, signal: result.signal, logFile, + outputCaptureComplete: !result.error, + ...(isTest ? { tests, testSummaryComplete: tests !== null, junitFile } : {}), + ...(result.error ? { processError: { code: result.error.code ?? null, message: result.error.message.slice(0, 2000) } } : {}), + ...(success ? {} : { error: (result.error?.message ?? summaryError ?? output.slice(-2000)).slice(0, 2000) }) }; + report.stages.push(stage); + if (isTest) report.tests = tests; + if (!success) throw new Error(`${name} failed; see ${path.join(directory, logFile)}`); + return result.stdout; +} diff --git a/scripts/roslyn/design-time-prototypes.mjs b/scripts/roslyn/design-time-prototypes.mjs new file mode 100644 index 0000000..f94a5e6 --- /dev/null +++ b/scripts/roslyn/design-time-prototypes.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { runDotnet } from '../lib/dotnet.mjs'; + +export const hash = data => createHash('sha256').update(data).digest('hex'); +export async function prototypeIdentity(repo) { + const files = ['scripts/roslyn/design-time-prototypes.mjs', ...['PrototypeCoordination', 'BuildLayout', 'OwnedBuildOutputs'] + .map(name => `tests/fixtures/design-time-comparison/${name}.cs`)]; + return hash((await Promise.all(files.map(file => fs.readFile(path.join(repo, file))))).map(hash).join('\n')); +} +export async function sourceIdentity(repo) { + const relative = (await fs.readdir(path.join(repo, 'tools/WinCode.Code.Host'))).filter(f => f.endsWith('.cs') || f.endsWith('.csproj')); + const files = [...relative.map(f => `tools/WinCode.Code.Host/${f}`), 'tools/Shared/OwnerProcessGuard.cs', 'dist/build-manifest.json']; + return Object.fromEntries(await Promise.all(files.map(async f => [f, hash(await fs.readFile(path.join(repo, f)))]))); +} + +/** Copy and instrument the current Host; production source, dist and native publish remain untouched. */ +export async function buildPrototype(repo, root, sdk) { + const directory = path.join(root, 'prototype'); await fs.mkdir(directory); + for (const name of await fs.readdir(path.join(repo, 'tools/WinCode.Code.Host'))) { + if (name.endsWith('.cs') || name.endsWith('.csproj') || name === 'packages.lock.json') + await fs.copyFile(path.join(repo, 'tools/WinCode.Code.Host', name), path.join(directory, name)); + } + await fs.copyFile(path.join(repo, 'tools/Shared/OwnerProcessGuard.cs'), path.join(directory, 'OwnerProcessGuard.cs')); + await fs.copyFile(path.join(repo, 'tests/fixtures/design-time-comparison/PrototypeCoordination.cs'), path.join(directory, 'PrototypeCoordination.cs')); + await fs.copyFile(path.join(repo, 'tests/fixtures/design-time-comparison/BuildLayout.cs'), path.join(directory, 'BuildLayout.cs')); + await fs.copyFile(path.join(repo, 'tests/fixtures/design-time-comparison/OwnedBuildOutputs.cs'), path.join(directory, 'OwnedBuildOutputs.cs')); + await fs.copyFile(path.join(repo, 'global.json'), path.join(root, 'global.json')); + await fs.writeFile(path.join(root, 'NuGet.Config'), ''); + const project = path.join(directory, 'WinCode.Code.Host.csproj'); + await fs.writeFile(project, (await fs.readFile(project, 'utf8')).replace(/\s*]*\/>/, '') + .replace('', '$(MSBuildBinPath)/Microsoft.Build.dllfalse')); + const session = path.join(directory, 'WorkspaceSession.cs'); + let text = (await fs.readFile(session, 'utf8')).replaceAll('\r\n', '\n'); + const properties = 'MSBuildWorkspace.Create(new Dictionary {\n ["Configuration"] = configuration, ["TargetFramework"] = framework,\n ["RunAnalyzers"] = "false", ["RunAnalyzersDuringBuild"] = "false"\n })'; + assert.ok(text.includes(properties)); + text = text.replace(properties, 'MSBuildWorkspace.Create(PrototypeCoordination.Properties(configuration, framework))'); + const entry = 'public async Task ReloadAsync(string? id, CancellationToken token)\n {'; + assert.ok(text.includes(entry)); + text = text.replace(entry, entry + '\n using var coordination = await PrototypeCoordination.EnterAsync(root, token);'); + text = text.replace('workspace = MSBuildWorkspace.Create(PrototypeCoordination.Properties(configuration, framework));', + 'if (PrototypeCoordination.Mode == "private2") BuildLayout.Prepare(root, projectPath, configuration, framework, PrototypeCoordination.Instance, token);\n workspace = MSBuildWorkspace.Create(PrototypeCoordination.Properties(configuration, framework));'); + text = text.replace('projects = candidate.ProjectIds.Count, configuration, framework, loadMs = clock.ElapsedMilliseconds,', + 'projects = candidate.ProjectIds.Count, configuration, framework, loadMs = clock.ElapsedMilliseconds,\n prototype = new { mode = PrototypeCoordination.Mode, instance = PrototypeCoordination.Instance, waitMs = PrototypeCoordination.LastWaitMs, intermediate = PrototypeCoordination.LastIntermediate },'); + text = text.replace('finally { ReleaseWorkspace(); }', 'finally { ReleaseWorkspace(); OwnedBuildOutputs.Current?.Dispose(); }'); + await fs.writeFile(session, text); + const inputs = path.join(directory, 'WorkspaceInputs.cs'); + await fs.writeFile(inputs, (await fs.readFile(inputs, 'utf8')).replace('else if (IsAutomaticInput(entry)) paths.Add(entry);', + 'else if (IsAutomaticInput(entry) && BuildLayout.IsCandidate(entry)) paths.Add(entry);')); + const restore = runDotnet(sdk, ['restore', project, '--locked-mode', '--configfile', path.join(root, 'NuGet.Config'), '--nologo'], repo); + const output = path.join(directory, 'publish'); + const build = runDotnet(sdk, ['publish', project, '-c', 'Release', '--no-restore', '-o', output, '--nologo'], repo); + await fs.writeFile(path.join(root, 'prototype-build.log'), restore + '\n' + build); + const host = path.join(output, 'WinCode.Code.Host.dll'); + return { host, assemblyHash: hash(await fs.readFile(host)), productionInputs: await sourceIdentity(repo), + instrumentationHash: await prototypeIdentity(repo) }; +} + +export async function fixture(root, sdk, name, type = name) { + const directory = path.join(root, name); await fs.mkdir(directory, { recursive: true }); + const write = async (file, text) => { await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true }); await fs.writeFile(path.join(directory, file), text); }; + const props = 'net10.0enableenablefalse'; + const api = 'namespace Probe; public static class Api { public static void Save(int x) {} }'; + let project = 'App.csproj', framework = 'net10.0', projects = 1, references = 1; + const use = 'namespace Probe; public class Use { public void Run() { Api.Save(1); } }'; + if (type === 'graph') { + project = 'App/App.csproj'; projects = 2; + await write('Lib/Lib.csproj', `${props}`); + await write('Lib/Api.cs', api); + await write(project, `${props}`); + await write('App/Use.cs', use); + await write('Peer/Peer.csproj', `${props}`); + await write('Peer/Use.cs', use); + } else if (type === 'wpf') { + framework = 'net10.0-windows'; + await write(project, `${props.replace('net10.0','net10.0-windows')}true`); + await write('Api.cs', api); + await write('MainWindow.xaml', ''); + await write('MainWindow.xaml.cs', 'namespace Probe; public partial class MainWindow : System.Windows.Window { public MainWindow() { InitializeComponent(); } private void HandleSave(object sender, System.Windows.RoutedEventArgs e) { Api.Save(1); } }'); + } else { + if (type === 'custom') await write('Directory.Build.props', 'artifacts/obj/artifacts/int/$(Configuration)/$(TargetFramework)/'); + await write(project, `${type === 'multi' + ? props.replace('net10.0', 'net10.0;net10.0-windows') : props}${type === 'multi' + ? '$(DefineConstants);SECOND_FRAMEWORK' : ''}`); + await write('Api.cs', api); await write('Use.cs', type === 'multi' + ? 'namespace Probe; public class Use { public void Run() { Api.Save(1);\n#if SECOND_FRAMEWORK\nApi.Save(2);\n#endif\n} }' : use); + } + const restore = runDotnet(sdk, ['restore', path.join(directory, project), '--configfile', path.join(root, 'NuGet.Config'), '--nologo'], root, 30000); + if (type === 'graph') runDotnet(sdk, ['restore', path.join(directory, 'Peer/Peer.csproj'), '--configfile', path.join(root, 'NuGet.Config'), '--nologo'], root, 30000); + await fs.writeFile(path.join(root, `${name}-restore.log`), restore); + return { root: directory, project, framework, projects, references, type, + projectDirectories: type === 'graph' ? ['App', 'Lib', 'Peer'].map(p => path.join(directory, p)) : [directory] }; +} + +/** Real MSBuild Exec descendant, enabled only in the selected prototype child environment. */ +export async function installBlocker(project) { + const directory = path.join(project.root, '.cache/n4-blockers'); await fs.mkdir(directory, { recursive: true }); + const script = path.join(directory, 'block.mjs'); + await fs.writeFile(script, "import fs from 'node:fs'; import path from 'node:path'; const id=process.env.WINCODE_BUILD_INSTANCE ?? process.env.WINCODE_N4_INSTANCE; if(!/^[a-f0-9]{32}$/.test(id)) throw new Error('Missing Host identity'); const root=process.argv[2]; fs.writeFileSync(path.join(root,id+'.json'),JSON.stringify({pid:process.pid,parent:process.ppid})); const timer=setInterval(()=>{if(fs.existsSync(path.join(root,id+'.release'))){clearInterval(timer);process.exit(0)}},20);\n"); + const escape = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); + const target = ``; + const file = path.join(project.root, project.project); + await fs.writeFile(file, (await fs.readFile(file, 'utf8')).replace('', target + '')); + return { directory, marker: id => path.join(directory, id + '.json'), release: id => fs.writeFile(path.join(directory, id + '.release'), '') }; +} + +/** Inspect and remove only the current test Host's private directories after its actual process cleanup. */ +export async function privateOutputs(project, identity, remove = false) { + assert.match(identity, /^[a-f0-9]{32}$/); + const records = []; let bytes = 0; + for (const projectDirectory of project.projectDirectories) { + const parent = path.resolve(projectDirectory, '.cache/wincode-msbuild'); + const directory = path.resolve(parent, identity); + assert.equal(path.dirname(directory), parent); + const stat = await fs.lstat(directory).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (!stat) continue; + assert.equal((await fs.realpath(directory)).toLowerCase(), directory.toLowerCase(), 'private cleanup must not follow links'); + async function walk(dir) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + assert.equal(entry.isSymbolicLink(), false); assert.ok(records.length < 1000); + const file = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(file); + else { const data = await fs.readFile(file); bytes += data.length; assert.ok(bytes <= 64 * 1024 * 1024); + records.push({ path: path.relative(project.root, file), bytes: data.length, sha256: hash(data) }); } + } + } + await walk(directory); + if (remove) await fs.rm(directory, { recursive: true }); + } + return { bytes, files: records.length, records, removed: remove }; +} diff --git a/scripts/roslyn/gateway-lifecycle.mjs b/scripts/roslyn/gateway-lifecycle.mjs index 27669a3..1cbabcf 100644 --- a/scripts/roslyn/gateway-lifecycle.mjs +++ b/scripts/roslyn/gateway-lifecycle.mjs @@ -45,14 +45,20 @@ export async function verifyGatewayLifecycle({ root, a, host, appProject, client const cleanupStarted = Date.now(); let health = (await call('wincode_hello_world')).health; const initialInFlight = health.inFlightRequests; - while (health.inFlightRequests > 1 && Date.now() - cleanupStarted < 8000) { + const initialAdmitted = health.admission.business.active; + // Hello uses the status lane and is excluded from business in-flight accounting. + while ((health.inFlightRequests > 0 || health.admission.business.active > 0) && Date.now() - cleanupStarted < 8000) { await new Promise(resolve => setTimeout(resolve, 25)); health = (await call('wincode_hello_world')).health; } report.processes.at(-1).cleanupObservation = { - initialInFlight, finalInFlight: health.inFlightRequests, waitMs: Date.now() - cleanupStarted, + initialInFlight, finalInFlight: health.inFlightRequests, + initialAdmitted, finalAdmitted: health.admission.business.active, + finalWaiting: health.admission.business.waiting, waitMs: Date.now() - cleanupStarted, }; - assert.equal(health.inFlightRequests, 1, 'only the heartbeat may remain after bounded cancellation cleanup'); + assert.equal(health.inFlightRequests, 0, 'cancelled business work must finish actual cleanup'); + assert.equal(health.admission.business.active, 0, 'business capacity must be released only after actual cleanup'); + assert.equal(health.admission.business.waiting, 0, 'no cancelled business request may remain queued'); assertExited(processes); await fs.writeFile(path.join(a, 'App/App.csproj'), appProject); await references(await integerTarget(), 1, a); diff --git a/scripts/verify-design-time-concurrency.mjs b/scripts/verify-design-time-concurrency.mjs new file mode 100644 index 0000000..115dba1 --- /dev/null +++ b/scripts/verify-design-time-concurrency.mjs @@ -0,0 +1,454 @@ +/** Production N4 acceptance. Every selected scenario must pass against the verified published Host. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { RoslynHostClient } from '../dist/Adapters/RoslynHostClient.js'; +import { ResourceManager } from '../dist/Core/ResourceManager.js'; +import { codeHostDirectory, verifyDelivery } from './delivery-manifest.mjs'; +import { resolveDotnet, runDotnet } from './lib/dotnet.mjs'; +import { ownedProcesses, observedSurvivors, terminateObserved } from './lib/owned-processes.mjs'; +import { fixture, sourceIdentity, installBlocker, privateOutputs, hash } from './roslyn/design-time-prototypes.mjs'; + +const repo = path.resolve(import.meta.dirname, '..'); +const sdk = resolveDotnet(repo); +const options = {}; +for (let i = 2; i < process.argv.length; i += 2) { + assert.ok(['--phase', '--filter'].includes(process.argv[i]) && process.argv[i + 1], 'Production acceptance supports only --phase and --filter.'); + options[process.argv[i].slice(2)] = process.argv[i + 1]; +} +const phase = options.phase ?? 'all'; assert.ok(['all', 'semantics', 'concurrency', 'interference', 'inputs'].includes(phase)); +const modes = ['production']; +const parent = path.join(repo, 'test-tmp/design-time-production'); await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const before = await sourceIdentity(repo); +const report = { root, phase, filter: options.filter, startedAt: new Date().toISOString(), completed: false, success: false, productionChanged: false, + build: null, cases: [], observed: [], cleanupFailures: [], limitations: [ + 'Verified published Host and production RoslynHostClient with generated projects; not an active consumer connection.', + 'Generated projects, existing locked SDK/packages, no new dependencies or model calls.', + 'No large-project, arbitrary custom target, live Visual Studio or power-loss recovery proof.' + ] }; +const live = new Set(), auxiliaries = new Set(); +let currentCase; +let deliveryManifest; +const remember = items => { for (const p of items) if (!report.observed.some(x => x.ProcessId === p.ProcessId && x.CreationDate === p.CreationDate)) report.observed.push(p); }; +const save = () => fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); +function start(mode, project, extra = {}) { + assert.equal(mode, 'production'); + const vars = { ...extra.env }; + const previous = Object.fromEntries(Object.keys(vars).map(key => [key, process.env[key]])); + Object.assign(process.env, vars); + const resources = new ResourceManager(); + let client; + try { client = new RoslynHostClient(sdk.dotnet, [report.build.host, '--allow-project-evaluation', extra.root ?? project.root, + path.join(project.root, extra.project ?? project.project), extra.configuration ?? 'Debug', project.framework, '[]'], repo, resources); } + finally { + for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + const value = { client, resources, identity: client.buildInstance, project, workspaceRoot: extra.root ?? project.root, started: performance.now() }; live.add(value); + currentCase?.hosts.push(value); + return value; +} +async function ready(c, signal) { + // Match RoslynAdapter's production load budget; this matrix verifies semantics, not a 20-second cold-start target. + const loadBudgetMs = 120000; + const result = await c.client.waitReady(loadBudgetMs, { signal, deadline: Date.now() + loadBudgetMs }); + c.ready = result; c.readyMs = performance.now() - c.started; + remember(ownedProcesses(c.client.child.pid)); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(result.inputPolicy?.version, 2); + assert.equal(result.hostIdentity?.version, report.build.version); + assert.equal(result.hostIdentity?.configuration, 'Release'); + const owner = JSON.parse(await fs.readFile(path.join(c.workspaceRoot, '.cache/wincode-build', c.identity, 'owner.json'), 'utf8')); + assert.equal(owner.instance, c.identity); + assert.ok(owner.paths.length >= 1); + for (const directory of owner.paths) assert.ok(directory.replaceAll('\\', '/').endsWith(`.cache/wincode-msbuild/${c.identity}`)); + c.owner = owner; + return result; +} +async function semantics(c) { + const request = (value, ms = 10000) => c.client.request(value, ms, { deadline: Date.now() + ms }); + const result = await request({ operation: 'symbols', snapshot: c.ready.snapshot, query: 'Save', kind: 'method' }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(result.snapshot, c.ready.snapshot); + const targets = result.symbols.filter(s => s.name === 'Save' && s.signature === 'Probe.Api.Save(int)'); + assert.equal(targets.length, 1, JSON.stringify(result)); + const target = targets[0], location = target.location; + const references = await request({ operation: 'references', snapshot: c.ready.snapshot, + project: location.project, file: location.file, position: location.position, symbolName: 'Save' }); + assert.equal(references.success, true, JSON.stringify(references)); + assert.equal(references.snapshot, c.ready.snapshot); + const evidence = { projects: c.ready.projects, compilationErrors: c.ready.compilationErrors, loadDiagnostics: c.ready.loadDiagnostics, + signature: target.signature, project: location.project, file: location.file, position: location.position, + snapshot: references.snapshot, references: references.references, totalReferences: references.totalReferences, + workingSetBytes: references.workingSetBytes, queryMs: references.queryMs, readyMs: c.readyMs, owner: c.owner }; + c.lastSemantics = evidence; + assert.equal(references.totalReferences, c.project.references, JSON.stringify(evidence)); + return evidence; +} +async function close(c) { + if (!live.delete(c)) return; + let ownership; + try { + remember(ownedProcesses(c.client.child.pid)); + ownership = await fs.readFile(path.join(c.workspaceRoot, '.cache/wincode-build', c.identity, 'owner.json'), 'utf8') + .then(JSON.parse, error => { if (error.code === 'ENOENT') return undefined; throw error; }); + } finally { + try { await c.client.close(); } + finally { await c.resources.dispose(); } + } + assert.equal((await privateOutputs(c.project, c.identity)).files, 0, 'production close must reclaim its own outputs'); + if (ownership) { + assert.equal(ownership.instance, c.identity); + for (const directory of ownership.paths) + assert.equal(await exists(path.resolve(c.workspaceRoot, directory)), false, 'every recorded project output must be reclaimed'); + } + assert.equal(await exists(path.join(c.workspaceRoot, '.cache/wincode-build', c.identity)), false, 'production close must reclaim its manifest'); +} +async function trial(label, work) { + if (options.filter && !label.includes(options.filter)) return { label, status: 'not-selected' }; + const item = { label, status: 'running' }; currentCase = { hosts: [] }; + report.cases.push(item); console.log(`[design-time] ${label}`); + try { Object.assign(item, await work(item), { status: 'passed' }); } + catch (error) { item.status = 'failed'; item.error = error.stack ?? String(error); } + finally { + for (const c of [...live]) try { await close(c); } catch (error) { report.cleanupFailures.push(String(error)); } + for (const auxiliary of [...auxiliaries]) try { await closeAuxiliary(auxiliary); } catch (error) { report.cleanupFailures.push(String(error)); } + item.hosts = currentCase.hosts.map(c => ({ identity: c.identity, pid: c.client.child.pid, snapshot: c.ready?.snapshot, owner: c.owner, lastSemantics: c.lastSemantics })); + currentCase = null; + await save(); + } + return item; +} + +const settle = promise => promise.then(value => ({ value }), error => ({ error: String(error) })); +async function until(check, message, ms = 10000) { + const deadline = Date.now() + ms; + while (!await check()) { if (Date.now() >= deadline) throw new Error(message); await new Promise(r => setTimeout(r, 20)); } +} +const exists = file => fs.access(file).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e; }); + +function auxiliary(command, args, env) { + const child = spawn(command, args, { cwd: root, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + const value = { child, output: '', release: null, owned: [] }; auxiliaries.add(value); + child.stdin.on('error', () => {}); // Cleanup may race a process that has already closed stdin. + for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { value.output = (value.output + data).slice(-65536); }); + value.done = new Promise(resolve => { + child.once('error', error => resolve({ error: String(error) })); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + if (child.pid) { value.owned = ownedProcesses(child.pid); remember(value.owned); } + return value; +} +async function closeAuxiliary(value) { + if (!auxiliaries.delete(value)) return; + if (value.release) await value.release(); + if (!value.child.stdin.destroyed) value.child.stdin.end('\n'); + let timer; + const outcome = await Promise.race([value.done, new Promise(resolve => { timer = setTimeout(() => resolve(null), 5000); })]); + clearTimeout(timer); + if (!outcome) { + const owned = value.child.pid ? ownedProcesses(value.child.pid) : []; + remember(owned); + for (const process of observedSurvivors([...value.owned, ...owned]).reverse()) terminateObserved(process); + await value.done; + throw new Error('Owned test helper exceeded its cleanup deadline.'); + } +} +const request = (c, value, ms = 20000) => c.client.request(value, ms, { deadline: Date.now() + ms }); +const probe = c => request(c, { operation: 'symbols', snapshot: c.ready.snapshot, query: 'Save', kind: 'method' }); +async function assertExpired(c, response) { + assert.equal(response.success, false); + // A watcher event during capture reports INPUTS_CHANGED and permanently expires the snapshot too. + assert.ok(['SNAPSHOT_STALE', 'INPUTS_CHANGED'].includes(response.errorCode), JSON.stringify(response)); + assert.equal(response.symbols, undefined); assert.equal(response.references, undefined); + const confirmed = await probe(c); + assert.equal(confirmed.errorCode, 'SNAPSHOT_STALE'); + assert.equal(confirmed.symbols, undefined); assert.equal(confirmed.references, undefined); + return confirmed; +} +async function reload(c) { + const result = await request(c, { operation: 'reload' }); + assert.equal(result.success, true, JSON.stringify(result)); + c.owner = JSON.parse(await fs.readFile(path.join(c.workspaceRoot, '.cache/wincode-build', c.identity, 'owner.json'), 'utf8')); + assert.equal(c.owner.instance, c.identity); + c.ready = result; return result; +} +/** Diagnostic file deltas only; the Host's own unchanged fingerprint remains authoritative. */ +async function inventory(project) { + const result = {}; + async function walk(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + assert.equal(entry.isSymbolicLink(), false); + if (entry.isDirectory() && ['.cache', 'bin'].includes(entry.name)) continue; + const file = path.join(directory, entry.name); + if (entry.isDirectory()) await walk(file); + else if (/\.(cs|csproj|props|targets|xaml|json|editorconfig)$/i.test(file)) { + assert.ok(Object.keys(result).length < 1000); + result[path.relative(project.root, file)] = hash(await fs.readFile(file)); + } + } + } + await walk(project.root); return result; +} +const delta = (before, after) => ({ + added: Object.keys(after).filter(file => !(file in before)), + changed: Object.keys(after).filter(file => file in before && before[file] !== after[file]), + removed: Object.keys(before).filter(file => !(file in after)) +}); + +async function interference() { + // A deterministic external handle is fault injection, not a Visual Studio integration test. + for (const mode of modes) await trial(`external-handle/${mode}`, async item => { + const p = await fixture(root, sdk, `external-handle-${mode}`, 'basic'); + runDotnet(sdk, ['build', path.join(p.root, p.project), '--no-restore', '--nologo', '-p:UseSharedCompilation=false', '-nodeReuse:false'], repo); + const file = path.join(p.root, 'obj/Debug/net10.0/App.GeneratedMSBuildEditorConfig.editorconfig'); + assert.ok(await exists(file)); + const holder = auxiliary('powershell.exe', ['-NoProfile', '-Command', + "$ErrorActionPreference='Stop'; $stream=[IO.File]::Open($env:WINCODE_N4_LOCK_FILE,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None); try { [Console]::WriteLine('LOCKED'); [Console]::Out.Flush(); [Console]::ReadLine() | Out-Null } finally { $stream.Dispose() }"], + { ...process.env, WINCODE_N4_LOCK_FILE: file }); + await until(() => holder.output.includes('LOCKED'), 'external helper must hold the exact generated editorconfig'); + const c = start(mode, p); item.load = await settle(ready(c)); + if (item.load.value) item.result = await semantics(c); + await closeAuxiliary(holder); item.holderExit = await holder.done; + assert.equal(item.holderExit.code, 0, holder.output); + assert.ok(item.load.value, 'candidate cannot load while an external process owns the shared generated file'); + assert.deepEqual(item.result.compilationErrors, []); + }); + for (const mode of modes) await trial(`external-build/${mode}`, async item => { + const p = await fixture(root, sdk, `external-build-${mode}`, 'basic'), block = await installBlocker(p); + const identity = randomUUID().replaceAll('-', ''); + const build = auxiliary(sdk.dotnet, ['build', path.join(p.root, p.project), '--no-restore', '--nologo', + '-p:UseSharedCompilation=false', '-nodeReuse:false'], { ...sdk.env, WINCODE_N4_EXTERNAL_HOLD: '1', WINCODE_BUILD_INSTANCE: identity }); + build.release = () => block.release(identity); + await until(() => exists(block.marker(identity)), 'real external dotnet build must reach its MSBuild target'); + const owned = ownedProcesses(build.child.pid); build.owned.push(...owned); remember(owned); + const c = start(mode, p); item.load = await settle(ready(c)); + if (item.load.value) item.beforeBuildFinishes = await semantics(c); + const beforeBuild = await inventory(p); + await block.release(identity); + await until(() => build.child.exitCode !== null || build.child.signalCode !== null, 'external build must finish', 15000); + item.buildExit = await build.done; + await fs.writeFile(path.join(p.root, 'external-build.log'), build.output); + assert.equal(item.buildExit.code, 0, build.output); + item.fileDelta = delta(beforeBuild, await inventory(p)); + assert.ok(item.load.value, JSON.stringify(item.load)); + item.afterBuild = await probe(c); + if (!item.afterBuild.success) { await reload(c); item.recovery = await semantics(c); } + assert.equal(item.afterBuild.success, true, 'external build invalidated the otherwise unchanged warm Host snapshot'); + }); + // A second entry can share a referenced project without invalidating the first snapshot. + for (const mode of modes) await trial(`sequential-peer/${mode}`, async item => { + const p = await fixture(root, sdk, `sequential-peer-${mode}`, 'graph'); + const a = start(mode, p); await ready(a); item.first = await semantics(a); + const beforePeer = await inventory(p); + const b = start(mode, p, { project: 'Peer/Peer.csproj' }); await ready(b); item.second = await semantics(b); + item.fileDelta = delta(beforePeer, await inventory(p)); item.firstAfterPeerLoad = await probe(a); + if (!item.firstAfterPeerLoad.success) { await reload(a); item.recovery = await semantics(a); } + assert.equal(item.firstAfterPeerLoad.success, true, 'peer project evaluation invalidated the first Host snapshot'); + assert.equal(item.firstAfterPeerLoad.snapshot, item.first.snapshot); + }); + for (const mode of modes) await trial(`different-config/${mode}`, async item => { + const p = await fixture(root, sdk, `different-config-${mode}`, 'basic'); + const a = start(mode, p); await ready(a); item.debug = await semantics(a); + const beforePeer = await inventory(p); + const b = start(mode, p, { configuration: 'Release' }); await ready(b); item.release = await semantics(b); + item.fileDelta = delta(beforePeer, await inventory(p)); item.debugAfterRelease = await probe(a); + if (!item.debugAfterRelease.success) { await reload(a); item.recovery = await semantics(a); } + assert.equal(item.debugAfterRelease.success, true, 'another configuration invalidated the first Host snapshot'); + assert.deepEqual(item.release.compilationErrors, []); + }); + for (const mode of modes) await trial(`source-edit/${mode}`, async item => { + const p = await fixture(root, sdk, `source-edit-${mode}`, 'basic'); + const c = start(mode, p); await ready(c); item.before = await semantics(c); + const oldLocator = { operation: 'references', snapshot: item.before.snapshot, project: item.before.project, + file: item.before.file, position: item.before.position, symbolName: 'Save' }; + await fs.writeFile(path.join(p.root, 'Use.cs'), 'namespace Probe; public class Use { public void Run() { Api.Save(1); Api.Save(2); } }'); + item.stale = await request(c, oldLocator); item.confirmedStale = await assertExpired(c, item.stale); + await reload(c); p.references = 2; item.after = await semantics(c); + assert.notEqual(item.after.snapshot, item.before.snapshot); assert.deepEqual(item.after.compilationErrors, []); + item.oldLocatorAfterReload = await request(c, oldLocator); + assert.equal(item.oldLocatorAfterReload.errorCode, 'SNAPSHOT_STALE'); + }); + for (const mode of modes) await trial(`query-during-reload/${mode}`, async item => { + const p = await fixture(root, sdk, `query-during-reload-${mode}`, 'basic'), block = await installBlocker(p); + const a = start(mode, p); await ready(a); + const b = start(mode, p, { env: { WINCODE_N4_BLOCK: '1' } }); + await block.release(b.identity); await ready(b); item.before = await semantics(a); + // Remove only this fixture's marker and release signal; the next real reload must block. + await fs.rm(block.marker(b.identity)); await fs.rm(path.join(block.directory, b.identity + '.release')); + const reloading = settle(reload(b)); + await until(() => exists(block.marker(b.identity)), 'peer must reach actual MSBuild reload'); + remember(ownedProcesses(b.client.child.pid)); + item.whilePeerBlocked = await semantics(a); + assert.equal(item.whilePeerBlocked.snapshot, item.before.snapshot); + await block.release(b.identity); item.reload = await reloading; assert.ok(item.reload.value); + item.afterPeerReload = await semantics(a); assert.equal(item.afterPeerReload.snapshot, item.before.snapshot); + }); +} + +async function inputCounterexamples() { + await trial('inputs/peer-source-edit', async item => { + const p = await fixture(root, sdk, 'peer-source-edit', 'basic'); + const a = start('production', p), b = start('production', p); + await Promise.all([ready(a), ready(b)]); + item.before = await Promise.all([semantics(a), semantics(b)]); + const locators = item.before.map(value => ({ operation: 'references', snapshot: value.snapshot, + project: value.project, file: value.file, position: value.position, symbolName: 'Save' })); + await fs.writeFile(path.join(p.root, 'Use.cs'), 'namespace Probe; public class Use { public void Run() { Api.Save(1); Api.Save(2); } }'); + item.expired = await Promise.all([a, b].map(async (peer, index) => assertExpired(peer, await request(peer, locators[index])))); + await Promise.all([reload(a), reload(b)]); p.references = 2; + item.after = await Promise.all([semantics(a), semantics(b)]); + assert.ok(item.after.every(value => value.compilationErrors.length === 0 && value.totalReferences === 2)); + for (let index = 0; index < 2; index++) { + assert.notEqual(item.before[index].snapshot, item.after[index].snapshot); + assert.equal((await request([a, b][index], locators[index])).errorCode, 'SNAPSHOT_STALE'); + } + await close(b); item.survivingPeer = await semantics(a); + assert.equal(item.survivingPeer.snapshot, item.after[0].snapshot); + }); + await trial('inputs/loaded-generated', async item => { + const p = await fixture(root, sdk, 'loaded-generated', 'basic'); + const c = start('production', p); await ready(c); item.before = await semantics(c); + const file = path.join(p.root, '.cache/wincode-msbuild', c.identity, 'Debug/net10.0/App.AssemblyInfo.cs'); + await fs.appendFile(file, '\n// Generated input changed after snapshot.\n'); + item.changed = await probe(c); item.confirmedStale = await assertExpired(c, item.changed); + await reload(c); item.after = await semantics(c); assert.notEqual(item.before.snapshot, item.after.snapshot); + }); + await trial('inputs/new-explicit-glob', async item => { + const p = await fixture(root, sdk, 'explicit-glob', 'basic'); + const project = path.join(p.root, p.project); + await fs.writeFile(project, (await fs.readFile(project, 'utf8')).replace('', + '')); + const c = start('production', p); await ready(c); item.before = await semantics(c); + await fs.mkdir(path.join(p.root, 'obj/Manual'), { recursive: true }); + await fs.writeFile(path.join(p.root, 'obj/Manual/Extra.cs'), 'namespace Probe; class Extra { void Run() { Api.Save(2); } }'); + item.changed = await probe(c); item.confirmedStale = await assertExpired(c, item.changed); + await reload(c); p.references = 2; item.after = await semantics(c); assert.deepEqual(item.after.compilationErrors, []); + }); + await trial('inputs/original-import-hook', async item => { + const p = await fixture(root, sdk, 'original-hook', 'basic'); + await fs.writeFile(path.join(p.root, 'original.targets'), '$(DefineConstants);N4_ORIGINAL_HOOK'); + const project = path.join(p.root, p.project); + await fs.writeFile(project, (await fs.readFile(project, 'utf8')).replace('', + '$(MSBuildProjectDirectory)/original.targets')); + await fs.writeFile(path.join(p.root, 'Use.cs'), '#if N4_ORIGINAL_HOOK\nnamespace Probe; class Use { void Run() { Api.Save(1); } }\n#endif'); + const c = start('production', p); await ready(c); item.result = await semantics(c); assert.deepEqual(item.result.compilationErrors, []); + }); + await trial('inputs/project-reference-change', async item => { + const p = await fixture(root, sdk, 'reference-change', 'graph'); + const c = start('production', p); await ready(c); item.before = await semantics(c); + const project = path.join(p.root, p.project); + await fs.writeFile(project, (await fs.readFile(project, 'utf8')).replace('../Lib/Lib.csproj', '../Peer/Peer.csproj')); + item.changed = await probe(c); item.confirmedStale = await assertExpired(c, item.changed); + item.fixtureRestore = runDotnet(sdk, ['restore', project, '--configfile', path.join(root, 'NuGet.Config'), '--nologo'], root, 30000); + await reload(c); p.references = 2; p.projects = 3; + item.after = await semantics(c); + assert.equal(item.after.projects, p.projects); assert.deepEqual(item.after.compilationErrors, []); + assert.ok(c.owner.paths.some(directory => directory.replaceAll('\\', '/') === `Peer/.cache/wincode-msbuild/${c.identity}`)); + }); +} + +async function concurrency() { + for (const type of ['basic', 'graph']) for (const mode of modes) { + await trial(`parallel/${type}/${mode}`, async () => { + const p = await fixture(root, sdk, `parallel-${type}-${mode}`, type); + const a = start(mode, p), b = start(mode, p, type === 'graph' ? { project: 'Peer/Peer.csproj' } : {}); + const loads = await Promise.all([settle(ready(a)), settle(ready(b))]); + assert.ok(loads.every(x => x.value), JSON.stringify(loads)); + const values = await Promise.all([semantics(a), semantics(b)]); + assert.ok(values.every(v => v.compilationErrors.length === 0)); + assert.notEqual(a.ready.snapshot, b.ready.snapshot); + const first = values[0]; + await close(b); + const after = await semantics(a); + assert.deepEqual(after.references, first.references); assert.equal(after.snapshot, first.snapshot); + return { values, survivingPeerReferences: after.totalReferences, survivingPeerSnapshot: after.snapshot }; + }); + } + for (const mode of modes) await trial(`nested-root/${mode}`, async () => { + const p = await fixture(root, sdk, `nested-${mode}/Project`, 'basic'), block = await installBlocker(p); + const a = start(mode, p, { root: path.dirname(p.root), env: { WINCODE_N4_BLOCK: '1' } }); + const loading = settle(ready(a)); + await until(() => exists(block.marker(a.identity)), 'first Host must reach the actual MSBuild blocker'); + remember(ownedProcesses(a.client.child.pid)); + const b = start(mode, p); const second = await settle(ready(b)); + await block.release(a.identity); const first = await loading; + assert.ok(first.value && second.value, JSON.stringify({ first, second })); + return { values: await Promise.all([semantics(a), semantics(b)]), secondReadyWhileFirstBlocked: true }; + }); + for (const mode of modes) for (const failure of ['cancel', 'crash']) await trial(`owner-${failure}/${mode}`, async () => { + const p = await fixture(root, sdk, `${mode}-${failure}`, 'basic'), block = await installBlocker(p); + const controller = new AbortController(); + const a = start(mode, p, { env: { WINCODE_N4_BLOCK: '1' } }), first = settle(ready(a, controller.signal)); + await until(() => exists(block.marker(a.identity)), 'owner must have an actual MSBuild descendant'); + const owned = ownedProcesses(a.client.child.pid); remember(owned); + assert.ok(owned.some(p => p.CommandLine?.includes('block.mjs'))); + const b = start(mode, p), second = settle(ready(b)); + assert.ok((await second).value, 'private peer should load while the owner is blocked'); + if (failure === 'cancel') controller.abort(); else a.client.child.kill('SIGKILL'); + const failed = await first; assert.ok(failed.error); + assert.ok((await second).value); assert.deepEqual(observedSurvivors(owned), []); + await close(a); + return { failed, descendantSurvivors: [], peer: await semantics(b) }; + }); +} + +try { + console.log('[design-time] verify current production delivery before starting any Host'); + deliveryManifest = JSON.parse(await fs.readFile(path.join(repo, 'dist/delivery-manifest.json'), 'utf8')); + const delivery = await verifyDelivery(repo, deliveryManifest); + const gateway = JSON.parse(await fs.readFile(path.join(repo, 'dist/build-manifest.json'), 'utf8')); + const host = path.join(repo, codeHostDirectory, 'WinCode.Code.Host.dll'); + report.build = { ...delivery, buildId: gateway.buildId, revision: deliveryManifest.revision, host, assemblyHash: hash(await fs.readFile(host)) }; + await fs.copyFile(path.join(repo, 'global.json'), path.join(root, 'global.json')); + await fs.writeFile(path.join(root, 'NuGet.Config'), ''); + if (phase === 'all' || phase === 'semantics') for (const type of ['basic', 'graph', 'wpf', 'custom']) { + for (const mode of modes) { + await trial(`semantics/${type}/${mode}`, async item => { + const project = await fixture(root, sdk, type); + if (type === 'custom') { + // Match the Host's global properties: early Directory.Build.props values can otherwise differ. + const args = ['build', path.join(project.root, project.project), '--no-restore', '--nologo', + '-p:Configuration=Debug', `-p:TargetFramework=${project.framework}`, '-p:UseSharedCompilation=false', '-nodeReuse:false']; + item.baselineBuilds = [runDotnet(sdk, args, repo), runDotnet(sdk, args, repo)]; + } + const c = start(mode, project); await ready(c); + const result = await semantics(c); + assert.equal(result.projects, project.projects); + assert.deepEqual(result.compilationErrors, [], JSON.stringify(result)); + return result; + }); + } + } + if (phase === 'all' || phase === 'semantics') await trial('semantics/multi-framework/production', async item => { + const p = await fixture(root, sdk, 'multi-framework', 'multi'); + const a = start('production', p), b = start('production', { ...p, framework: 'net10.0-windows', references: 2 }); + await Promise.all([ready(a), ready(b)]); + item.frameworks = await Promise.all([semantics(a), semantics(b)]); + assert.ok(item.frameworks.every(value => value.compilationErrors.length === 0)); + await close(b); + item.afterPeerClose = await semantics(a); + assert.equal(item.afterPeerClose.snapshot, item.frameworks[0].snapshot); + }); + if (phase === 'all' || phase === 'concurrency') await concurrency(); + if (phase === 'all' || phase === 'interference') await interference(); + if (phase === 'all' || phase === 'inputs') await inputCounterexamples(); + report.completed = true; +} catch (error) { report.error = error.stack ?? String(error); process.exitCode = 1; } +finally { + for (const c of [...live]) try { await close(c); } catch (error) { report.cleanupFailures.push(String(error)); } + for (const auxiliary of [...auxiliaries]) try { await closeAuxiliary(auxiliary); } catch (error) { report.cleanupFailures.push(String(error)); } + report.survivors = observedSurvivors(report.observed); + if (report.survivors.length) { for (const p of report.survivors) terminateObserved(p); process.exitCode = 1; } + report.productionChanged = JSON.stringify(before) !== JSON.stringify(await sourceIdentity(repo)); + try { if (deliveryManifest) await verifyDelivery(repo, deliveryManifest); } + catch (error) { report.productionChanged = true; report.error ??= String(error); } + report.success = report.completed && report.cases.length > 0 && report.cases.every(c => c.status === 'passed') && + !report.productionChanged && !report.cleanupFailures.length && !report.survivors.length; + if (!report.success) process.exitCode = 1; + report.finishedAt = new Date().toISOString(); await save(); + console.log(JSON.stringify({ success: report.success, completed: report.completed, cases: report.cases.map(c => ({ label: c.label, status: c.status })), + productionChanged: report.productionChanged, report: path.join(root, 'report.json') })); +} diff --git a/scripts/verify-error-contracts.ts b/scripts/verify-error-contracts.ts index cf5d3ad..1b6024a 100644 --- a/scripts/verify-error-contracts.ts +++ b/scripts/verify-error-contracts.ts @@ -98,9 +98,16 @@ try { } finally { uiFault.mock.restore(); } const nextRoot = path.join(root, 'next'); await fs.mkdir(nextRoot); + await observe('wrong workspace rejected without recovery or mutation', 'workspace_open', { path: nextRoot }, (result, body) => { + assert.equal(result.isError, true); assert.equal(body.errorCode, 'WORKSPACE_MISMATCH'); + assert.equal(body.activeWorkspace, root); assert.equal(body.requestedWorkspace, nextRoot); + assert.equal(body.recoveryAction, 'select_workspace_connection'); + assert.equal(router.workspaceRecoveryState, null); + }); + await (router as any).watch.stop(); const switchFault = mock.method(router.text, 'initialize', async () => { throw new Error('isolated rebind failure'); }); try { - await observe('workspace commit failure', 'workspace_open', { path: nextRoot }, (result, body) => { + await observe('same-root resource recovery failure', 'workspace_open', { path: root }, (result, body) => { assert.equal(result.isError, true); assert.equal(body.errorCode, 'WORKSPACE_RECOVERY_REQUIRED'); assert.equal(body.recoveryAction, 'workspace_open'); assert.equal(body.workspaceRecovery.recoveryAction, 'workspace_open'); }); diff --git a/scripts/verify-failure-recovery.ts b/scripts/verify-failure-recovery.ts index 25440de..221b4b6 100644 --- a/scripts/verify-failure-recovery.ts +++ b/scripts/verify-failure-recovery.ts @@ -36,10 +36,12 @@ async function fixture(name: string, work: (router: ToolRouter, a: string, b: st } const stages = ['root-before', 'root-after', 'namespace', 'session', 'watch', - 'repomix-dispose', 'text-reset', 'repomix-initialize', 'text-initialize', 'composites', 'cancel-after-root']; + 'repomix-dispose', 'repomix-initialize', 'text-initialize', 'composites', 'cancel-after-root']; for (const stage of stages) { await fixture(stage, async (router, a, b) => { + await assert.rejects(router.openWorkspace(b), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + await (router as any).watch.stop(); const controller = new AbortController(); const targets: Record = { 'root-before': [router.workspace, 'openWorkspace'], @@ -48,7 +50,6 @@ for (const stage of stages) { namespace: [router.cache, 'setNamespace'], session: [router.session, 'open'], watch: [router as any, 'bindWatch'], 'repomix-dispose': [router.repomix, 'dispose'], - 'text-reset': [router.text, 'resetConnection'], 'repomix-initialize': [router.repomix, 'initialize'], 'text-initialize': [router.text, 'initialize'], composites: [router as any, 'bindCompositeTools'], @@ -63,7 +64,7 @@ for (const stage of stages) { } : function () { throw new Error(`injected:${stage}`); }; let switchOutcome = 'resolved'; - try { await router.openWorkspace(b, {}, controller.signal); } + try { await router.openWorkspace(a, {}, controller.signal); } catch (error) { switchOutcome = String(error); } finally { target[method] = original; } @@ -78,12 +79,13 @@ for (const stage of stages) { root: router.config.workspaceRoot, sessionRoot: health.session?.workspaceRoot, watchRoot: health.workspaceWatch.root, cacheNamespace: router.cache.currentNamespace, rootsAgree, switching: router.isSwitchingWorkspace }); - if (switchOutcome !== 'resolved' && router.config.workspaceRoot !== a && accepted) - failures.push(`${stage}: switch failed after root changed but next request was admitted`); + if (switchOutcome === 'resolved') failures.push(`${stage}: injected recovery failure was not observed`); + if (router.config.workspaceRoot !== a) failures.push(`${stage}: fixed root changed`); + if (switchOutcome !== 'resolved' && accepted) failures.push(`${stage}: failed recovery admitted a business request`); if (accepted && !rootsAgree) failures.push(`${stage}: admitted request with inconsistent roots`); if (controller.signal.aborted && switchOutcome === 'resolved') failures.push(`${stage}: cancellation after root change was not observed`); - // A subsequent normal switch must at least release admission and clean up. + // A subsequent same-root recovery must restore admission and consistent resources. await router.openWorkspace(a); assert.equal(router.config.workspaceRoot, a); assert.equal(router.isSwitchingWorkspace, false); @@ -117,6 +119,7 @@ for (const stage of ['rename', 'metadata']) { const report = { node: process.version, generatedAt: new Date().toISOString(), externalAdapters: 'disabled; local fallback only', observations, failures, + retiredStages: [{ name: 'text-reset', reason: 'LocalTextAdapter has no resetConnection; real text-initialize remains covered.' }], scope: 'Injected local failure semantics; does not validate real upstream binding or endurance.' }; const reportFile = path.join(runRoot, 'report.json'); await fs.writeFile(reportFile, JSON.stringify(report, null, 2) + '\n'); diff --git a/scripts/verify-manual-release.ts b/scripts/verify-manual-release.ts index c4f73a4..fe75a6a 100644 --- a/scripts/verify-manual-release.ts +++ b/scripts/verify-manual-release.ts @@ -80,13 +80,21 @@ try { 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 assert.rejects(router.openWorkspace(path.join(root, 'B')), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + const peerConfig = getDefaultConfig(path.join(root, 'B')); + peerConfig.adapters.roslyn = config.adapters.roslyn; + peerConfig.adapters.flaui.enabled = false; peerConfig.adapters.repomix.useCli = false; + const peer = new ToolRouter(peerConfig); + try { + await peer.initialize(); + assert.equal((await peer.findCodeSymbols('Save', 'method')).symbols[0].signature, 'B.Api.Save()'); + report.observedProcesses.push(...ownedProcesses((peer.roslyn as any).client.child.pid)); + assert.equal((await peer.releaseRoslynMemory()).status, 'released'); + } finally { await peer.dispose(); } 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.scenarios.push('Editing while cold is seen on the next search; wrong-root open is rejected and independent A/B connections remain releasable and reusable'); report.success = true; } catch (error) { report.error = error instanceof Error ? error.stack : String(error); process.exitCode = 1; } finally { diff --git a/scripts/verify-mixed-load.ts b/scripts/verify-mixed-load.ts index fd8655e..4ea390b 100644 --- a/scripts/verify-mixed-load.ts +++ b/scripts/verify-mixed-load.ts @@ -37,11 +37,13 @@ for (const [index, directory] of roots.entries()) { await fs.writeFile(path.join(directory, `Only${index}.cs`), [`class Only${index} {}`, ...Array.from({ length: 10 }, (_, round) => `class Probe${round}Only${index} {}`)].join('\n')); } -const config = getDefaultConfig(roots[0]); - -config.adapters.flaui.enabled = false; -config.adapters.repomix.useCli = false; -const router = new ToolRouter(config); +const routers = roots.map(workspace => { + const config = getDefaultConfig(workspace); + config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; + return new ToolRouter(config); +}); +let router = routers[0]; const children: cp.ChildProcess[] = []; const originalSpawn = cp.spawn; cp.spawn = ((...args: any[]) => { @@ -61,22 +63,24 @@ async function call(name: string, work: () => Promise): Promise { return result; } async function query(index: number) { - await router.acquireRequestSlot(); + const active = routers[index]; + await active.acquireRequestSlot(); try { - const result = await router.findCodeSymbols(`Only${index}`); + const result = await active.findCodeSymbols(`Only${index}`); assert.ok(result.symbols.some(symbol => symbol.name === `Only${index}`)); assert.ok(result.symbols.every(symbol => !symbol.file.includes(`Only${1 - index}`))); return result; - } finally { router.endRequest(); } + } finally { active.endRequest(); } } try { - await router.initialize(); + for (const instance of routers) await instance.initialize(); for (let round = 0; round < 10; round++) { if (round && sampleIntervalMs) await new Promise(resolve => setTimeout(resolve, sampleIntervalMs)); const index = round % 2; - await call('switch', () => router.openWorkspace(roots[index])); + router = routers[index]; + await call('confirm-bound-workspace', () => router.openWorkspace(roots[index])); await call('query-before-interleaving', () => query(index)); - // 调度门只控制文本查询开始时刻;保留真实扫描及工作区排空逻辑。 + // The gate controls entry only; scanning and rejection use the real fixed-workspace implementation. const cancel = round % 2 === 0; const upstreamMetrics = processMetrics([process.pid]); const controller = new AbortController(); @@ -109,14 +113,17 @@ try { void queryWork.catch(() => {}); try { await withTimeout(ready, 5000, 'mixed-load-query-entry'); - switching = call('switch-during-query', () => router.openWorkspace(roots[1 - index])); + switching = call('reject-other-root-during-query', async () => { + await assert.rejects(router.openWorkspace(roots[1 - index]), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + return { rejected: true }; + }); void switching.catch(() => {}); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(router.isSwitchingWorkspace, true); + await switching; + assert.equal(router.isSwitchingWorkspace, false); assert.equal(router.inFlightRequests, 1); assert.equal(router.config.workspaceRoot, roots[index], 'root cannot change while the old query owns its slot'); interleavings.push({ round, mode: cancel ? 'cancel' : 'text-completion', - queryStarted: true, switchWaiting: true, oldRootPreserved: true }); + queryStarted: true, wrongRootRejected: true, oldRootPreserved: true }); if (cancel) controller.abort(); release(); await Promise.all([queryWork, switching]); @@ -127,13 +134,13 @@ try { } await router.text.initialize(); - await Promise.all([call('query-after-interleaving-1', () => query(1 - index)), + await Promise.all([call('query-after-interleaving-1', () => query(index)), call('query-after-interleaving-2', () => query(1 - index))]); const health = await call('health', () => router.getRuntimeHealth()); assert.equal(health.inFlightRequests, 0); assert.equal(health.workspaceRecovery, null); - assert.equal(health.session?.workspaceRoot, roots[1 - index]); - assert.equal(health.workspaceWatch.root, roots[1 - index]); + assert.equal(health.session?.workspaceRoot, roots[index]); + assert.equal(health.workspaceWatch.root, roots[index]); samples.push({ round, elapsedMs: Date.now() - started, gatewayPid: process.pid, upstreamMetrics, settledMetrics: processMetrics([process.pid]), memory: process.memoryUsage(), activeResources: process.getActiveResourcesInfo(), @@ -144,7 +151,7 @@ try { } catch (caught) { error = caught instanceof Error ? caught.stack : String(caught); } finally { - try { await router.dispose(); } + try { await Promise.all(routers.map(instance => instance.dispose())); } catch (caught) { error = `${error ?? ''}\nCleanup: ${String(caught)}`; } cp.spawn = originalSpawn; syncBuiltinESMExports(); diff --git a/scripts/verify-multi-agent.mjs b/scripts/verify-multi-agent.mjs index ebdb87e..8830e9f 100644 --- a/scripts/verify-multi-agent.mjs +++ b/scripts/verify-multi-agent.mjs @@ -11,9 +11,11 @@ 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 { verifyDelivery } from './delivery-manifest.mjs'; import { ownedProcesses, observedSurvivors, terminateObserved } from './lib/owned-processes.mjs'; const repo = path.resolve(import.meta.dirname, '..'); +const version = JSON.parse(await fs.readFile(path.join(repo, 'package.json'), 'utf8')).version; const sdk = resolveDotnet(repo); const parent = path.join(repo, 'test-tmp/multi-agent'); await fs.mkdir(parent, { recursive: true }); @@ -24,7 +26,13 @@ const report = { root, success: false, scenarios: [], findings: [], timings: [], 'Generated small projects and bounded bursts; no unlimited-load, long-term leak, concurrent source editing or native UI automation proof.'] }; const clients = []; let auxiliaryTray; +const roslynOnly = process.argv.includes('--roslyn-only'); report.mode = process.argv.includes('--boundaries-only') ? 'boundaries-only' : 'full'; +assert.ok(!roslynOnly || report.mode === 'full', '--roslyn-only cannot be combined with --boundaries-only'); +if (roslynOnly) report.limitations.push('Native Tray capacity is excluded; its dedicated acceptance remains separate.'); +const serialSameRoot = process.argv.includes('--serialize-same-root-startup'); +report.startupMode = serialSameRoot ? 'different roots parallel; second same-root host starts afterward' : 'all three hosts parallel'; +if (serialSameRoot) report.limitations.push('Same-project parallel cold startup is excluded in this mode: run-zJc2aM observed an MSBuild obj/editorconfig write collision. This run cannot close that N4 finding.'); let stage = 'setup'; const warningEmitters = []; process.on('warning', warning => { @@ -69,6 +77,7 @@ async function scenario(name, work) { await fs.writeFile(path.join(root, 'progress.json'), JSON.stringify(report, null, 2)); } try { + report.delivery = await verifyDelivery(repo, JSON.parse(await fs.readFile(path.join(repo, 'dist/delivery-manifest.json'), 'utf8'))); 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']) { @@ -90,9 +99,14 @@ try { 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 scenario(serialSameRoot ? 'A/B cold bursts in parallel, then second A host; exact references in all three' : 'three independent processes: parallel cold load and exact references', async () => { await sample('cold'); - const targets = await Promise.all(clients.map(c => search(c))); + const cold = c => Promise.all(Array.from({ length: 4 }, () => search(c))); + const coldBursts = serialSameRoot + ? [...await Promise.all(clients.slice(0, 2).map(cold)), await cold(clients[2])] + : await Promise.all(clients.map(cold)); + const targets = coldBursts.map(values => values[0]); + for (const values of coldBursts) assert.equal(new Set(values.map(t => t.location.snapshotId)).size, 1); 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); }); @@ -113,12 +127,30 @@ try { 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))); + await scenario('ordinary 4/8/16 bursts preserve FIFO capacity and the warm semantic snapshot', async () => { + for (const count of [4, 8, 16]) { + const values = await Promise.all(Array.from({ length: count }, () => search(a))); + assert.ok(values.every(value => value.location.snapshotId === a.target.location.snapshotId)); + } + const health = (await ok(a, 'wincode_hello_world')).health; + assert.equal(health.admission.business.active, 0); assert.equal(health.admission.business.rejected, 0); + return { bursts: [4, 8, 16], admission: health.admission }; + }); + await scenario('128 semantic calls have bounded admission while sibling traffic remains usable', async () => { + const work = Promise.all(Array.from({ length: 128 }, () => call(a, 'wincode_find_code_symbol', { query: 'Save', kind: 'method' }))); const health = await ok(a, 'wincode_hello_world'); - await Promise.all([work, search(b), search(a2)]); + const [values] = await Promise.all([work, search(b), search(a2)]); + const accepted = values.filter(value => !value.error), busy = values.filter(value => value.error); + assert.equal(accepted.length, 32); assert.equal(busy.length, 96); + for (const value of accepted) assert.equal(value.data.symbols[0].location.snapshotId, a.target.location.snapshotId); + for (const value of busy) { + assert.equal(value.data.errorCode, 'SERVER_BUSY'); assert.equal(value.data.workStarted, false); assert.equal(value.data.retryable, true); + } + assert.ok(health.health.admission.business.active <= 32); await sample('after-128'); - return { sampledInFlight: health.health.inFlightRequests }; + const after = (await ok(a, 'wincode_hello_world')).health.admission; + assert.equal(after.business.active, 0); assert.equal(after.business.waiting, 0); assert.equal(after.business.peakActive, 32); + return { sampledInFlight: health.health.inFlightRequests, accepted: accepted.length, busy: busy.length, admission: after }; }); await scenario('64-request burst with 16 cancellations preserves sibling work and warm snapshots', async () => { const controls = Array.from({ length: 64 }, () => new AbortController()); @@ -128,38 +160,42 @@ try { 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)'); + assert.ok(v.response, JSON.stringify(v)); + if (v.response.error) { assert.equal(v.response.data.errorCode, 'SERVER_BUSY'); assert.equal(v.response.data.workStarted, false); } + else 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 }; + return { requestedCancellations: 16, clientCancellations: values.filter(v => v.rejected).length, + completed: values.filter(v => v.response && !v.response.error).length, + busy: values.filter(v => v.response?.data.errorCode === 'SERVER_BUSY').length }; }); - await scenario('shared-instance interleaving: A opens A, B opens B, A queries by name', async () => { + await scenario('shared-instance interleaving: wrong-root open is rejected and A still queries A', 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 rejected = await call(a, 'workspace_open', { path: path.join(root, 'B') }); + assert.equal(rejected.error, true); assert.equal(rejected.data.errorCode, 'WORKSPACE_MISMATCH'); 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'); + assert.equal(after.symbols[0].signature, 'A.Api.Save(int)'); + assert.equal(after.symbols[0].location.snapshotId, before.location.snapshotId); + await references(a, before, 1); 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.ok(JSON.stringify(directory).includes('only-A.txt')); + assert.ok(!JSON.stringify(directory).includes('only-B.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.' }); + report.findings.push({ id: 'shared-workspace-context', observed: false, expectedAgentProject: 'A', actualSignature: after.symbols[0].signature, + rejection: rejected.data.errorCode, description: 'Fixed startup binding rejects the other project before mutation; names, relative paths and existing A locations remain in A.' }); return { directory }; }); await scenario('same-path workspace_open preserves a healthy warm Host', async () => { - const target = await search(a, 'B'); + const target = await search(a); 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') }); + await ok(a, 'workspace_open', { path: path.join(root, 'A') }); const hello = await ok(a, 'wincode_hello_world'); assert.equal(hello.health.roslyn.processAlive, true); assert.equal(observedSurvivors(oldHost).length, 1); - const next = await search(a, 'B'); remember(ownedProcesses(a.transport.pid)); + const next = await search(a); remember(ownedProcesses(a.transport.pid)); assert.equal(next.location.snapshotId, target.location.snapshotId); report.findings.push({ id: 'same-workspace-reopen', observed: false, oldHostPid: oldHost[0].ProcessId, beforeSnapshot: target.location.snapshotId, afterSnapshot: next.location.snapshotId, @@ -192,7 +228,7 @@ try { 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 () => { + if (!roslynOnly) 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'] }); @@ -213,7 +249,7 @@ try { 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', + v: 1, type: 'register', instanceId: id, pid: process.pid, version, buildId: 'capacity-fixture', status: { workspace: folder, provider: 'local-text', state: 'idle', automaticRelease: false, roslynLoaded: false }, }) + '\n')); socket.on('data', chunk => { @@ -231,7 +267,7 @@ try { } try { const peers = []; - for (let i = 0; i < 8; i++) { const p = await peer(); assert.equal(p.ack.type, 'register-accepted'); peers.push(p); } + for (let i = 0; i < 8; i++) { const p = await peer(); assert.equal(p.ack.type, 'register-accepted', JSON.stringify(p.ack)); 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; diff --git a/scripts/verify-roslyn-gateway.mjs b/scripts/verify-roslyn-gateway.mjs index 7f70849..10d9228 100644 --- a/scripts/verify-roslyn-gateway.mjs +++ b/scripts/verify-roslyn-gateway.mjs @@ -33,7 +33,7 @@ let stderr = ''; -/** 工作区切换只关闭 Code Host 子树;Gateway 自己的控制台宿主应保持到 Gateway 退出。 */ +/** Observe the owned Code Host subtree; the Gateway remains until its connection closes. */ function codeProcesses() { const all = owned(transport.pid); const code = all.find(item => item.CommandLine?.includes(host)); @@ -42,8 +42,8 @@ function codeProcesses() { } /** tools/call 使用真实 MCP 客户端;默认失败立即终止场景,故障测试显式读取错误响应。 */ -async function call(name, args = {}, failure = false) { - const response = await client.callTool({ name, arguments: args }, { timeout: 60000 }); +async function call(name, args = {}, failure = false, activeClient = client) { + const response = await activeClient.callTool({ name, arguments: args }, { timeout: 60000 }); const data = JSON.parse(response.content[0].text); if (!failure) assert.notEqual(response.isError, true, JSON.stringify(data)); else assert.equal(response.isError, true, JSON.stringify(data)); @@ -51,8 +51,8 @@ async function call(name, args = {}, failure = false) { } /** 选择真实重载签名;测试不人工填 UTF-16 位置,必须通过公共符号搜索取得定位。 */ -async function integerTarget() { - const result = await call('wincode_find_code_symbol', { query: 'Save', kind: 'method' }); +async function integerTarget(activeClient = client) { + const result = await call('wincode_find_code_symbol', { query: 'Save', kind: 'method' }, false, activeClient); assert.equal(result.source, 'roslyn'); assert.equal(result.queryComplete, false); assert.equal(result.semanticContext.freshness.status, 'checked'); @@ -63,8 +63,8 @@ async function integerTarget() { } /** 仅传回搜索结果里的身份;按实际源码字符串断言位置,避免自己重算同一实现作为真值。 */ -async function references(target, expected, expectedRoot) { - const result = await call('wincode_find_references', { symbolName: target.name, symbolLocation: target.location }); +async function references(target, expected, expectedRoot, activeClient = client) { + const result = await call('wincode_find_references', { symbolName: target.name, symbolLocation: target.location }, false, activeClient); assert.equal(result.source, 'roslyn'); assert.equal(result.resolution, 'resolved'); assert.equal(result.queryComplete, false); @@ -123,6 +123,7 @@ try { await client.connect(transport); transport.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); }); const initial = await call('wincode_hello_world'); + assert.deepEqual(initial.health.workspaceBinding, { mode: 'fixed', root: a, source: 'argument' }); assert.equal(initial.codeProvider, 'roslyn'); assert.equal(initial.health.roslyn.processAlive, false); assert.equal(initial.health.text.semanticConfigured, false); @@ -214,17 +215,36 @@ try { report.scenarios.push('ten same-root opens and four concurrent confirmations preserve the real Host and observed owned-process PIDs, snapshot, watcher and session while references remain valid'); const beforeSwitch = codeProcesses(); report.beforeSwitch = { gatewayPid: transport.pid, processes: beforeSwitch }; - await call('workspace_open', { path: b }); - assertExited(beforeSwitch); - assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: edited.location }, true)).errorCode, 'SNAPSHOT_STALE'); - const inB = await integerTarget(); - await references(inB, 1, b); - await call('workspace_open', { path: a }); + const rejected = await call('workspace_open', { path: b }, true); + assert.equal(rejected.errorCode, 'WORKSPACE_MISMATCH'); + assert.equal(rejected.activeWorkspace, a); assert.equal(rejected.requestedWorkspace, b); + assert.deepEqual(codeProcesses().map(item => item.ProcessId).sort(), beforeSwitch.map(item => item.ProcessId).sort()); + await references(edited, 1, a); + const peerClient = new Client({ name: 'roslyn-gateway-peer-B', version: '1' }); + const peerTransport = new StdioClientTransport({ command: process.execPath, + args: [path.join(repo, 'dist/index.js'), '--workspace', b, '--roslyn-config', config], cwd: root, env, stderr: 'pipe' }); + let peerProcesses = []; + try { + await peerClient.connect(peerTransport); + const peerHello = await call('wincode_hello_world', {}, false, peerClient); + assert.notEqual(peerHello.runtime.instanceId, initial.runtime.instanceId); + assert.equal(peerHello.workspace, b); + const [inB] = await Promise.all([integerTarget(peerClient), references(edited, 1, a)]); + await references(inB, 1, b, peerClient); + peerProcesses = owned(peerTransport.pid); report.processes.push(...peerProcesses); + assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: edited.location }, true, peerClient)).errorCode, 'SNAPSHOT_STALE'); + assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: inB.location }, true)).errorCode, 'SNAPSHOT_STALE'); + const preserved = (await call('wincode_hello_world')).health; + assert.equal(preserved.session.id, confirmedHealth.session.id); + assert.equal(preserved.cache.namespace, confirmedHealth.cache.namespace); + assert.deepEqual(preserved.workspaceWatch, confirmedHealth.workspaceWatch); + assert.equal(preserved.roslyn.snapshotId, edited.location.snapshotId); + assert.deepEqual(codeProcesses().map(item => item.ProcessId).sort(), beforeSwitch.map(item => item.ProcessId).sort()); + } finally { await peerClient.close(); assertExited(peerProcesses); } const againA = await integerTarget(); await references(againA, 1, a); - assert.notEqual(againA.location.snapshotId, edited.location.snapshotId); - assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: inB.location }, true)).errorCode, 'SNAPSHOT_STALE'); - report.scenarios.push('A to B to A closes the old Host and rejects identities from both prior sessions'); + assert.equal(againA.location.snapshotId, edited.location.snapshotId); + report.scenarios.push('wrong-root open preserves the warm A Host, snapshot, session and watcher; independent B queries work and both connections reject foreign locations'); // 配置经生产 CLI/Adapter/Host 三层传递;无关文件和显式输入必须产生相反的失效行为。 await fs.writeFile(path.join(a, 'README.md'), '# Unrelated notes\n'); diff --git a/scripts/verify-roslyn-host.mjs b/scripts/verify-roslyn-host.mjs index a40a8e7..8e7405f 100644 --- a/scripts/verify-roslyn-host.mjs +++ b/scripts/verify-roslyn-host.mjs @@ -90,6 +90,16 @@ try { assert.equal(denied.status, 1); assert.match(denied.stdout, /Explicit project evaluation permission required/); report.scenarios.push('missing evaluation permission rejected before load'); + for (const field of [4, 5]) for (const value of ['.', '..', 'Debug.', 'Debug ', 'x;y', '$(Configuration)', '%2e%2e', '@(Compile)']) { + const invalidArgs = [...args]; invalidArgs[field] = value; + const rejected = spawnSync(dotnet, invalidArgs, { cwd: repo, env, input: '', encoding: 'utf8', windowsHide: true, timeout: 10000 }); + assert.equal(rejected.error, undefined); + assert.equal(rejected.status, 1, `${field}: ${value}`); + assert.equal(JSON.parse(rejected.stdout.trim()).errorCode, 'INVALID_ARGUMENT'); + for (const directory of [root, path.join(root, 'App'), path.join(root, 'Lib')]) + await assert.rejects(fs.stat(path.join(directory, '.cache')), { code: 'ENOENT' }); + } + report.scenarios.push('nonliteral configuration and framework segments are rejected without private output side effects'); for (const [label, inputs, errorCode] of [ ['outside additional input', ['../outside.yaml'], 'OUTSIDE_WORKSPACE'], ['wildcard additional input', ['*.yaml'], 'INVALID_ARGUMENT'], @@ -112,7 +122,7 @@ try { const ready = await next(150000); assert.equal(ready.type, 'ready', JSON.stringify(ready)); assert.equal(ready.protocolVersion, 2); - assert.equal(ready.inputPolicy.version, 1); + assert.equal(ready.inputPolicy.version, 2); assert.deepEqual(ready.inputPolicy.additionalInputs.map(file => file.replaceAll('\\', '/')), additionalInputs); assert.equal(ready.freshness.scope, 'compilation-inputs-and-explicit-files'); assert.equal(ready.projects, 2); diff --git a/scripts/verify-shared-cache.mjs b/scripts/verify-shared-cache.mjs new file mode 100644 index 0000000..112f085 --- /dev/null +++ b/scripts/verify-shared-cache.mjs @@ -0,0 +1,168 @@ +/** Real SDK/Gateway acceptance for shared disk caches; fixture payloads and cleanup remain under test-tmp. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { verifyDelivery } from './delivery-manifest.mjs'; +import { ownedProcesses, observedSurvivors, terminateObserved } from './lib/owned-processes.mjs'; + +const repo = path.resolve(import.meta.dirname, '..'); +assert.equal(process.argv.length, 2, 'This acceptance always runs the complete bounded matrix.'); +const parent = path.join(repo, 'test-tmp/shared-cache'); await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')), sharedCache = path.join(root, 'shared-cache'); +const report = { root, startedAt: new Date().toISOString(), success: false, scenarios: [], observed: [], cleanupFailures: [], + limitations: ['Real published Gateway/Cache modules and SDK stdio, with a fixture entrypoint selecting small cache limits; not an active consumer connection.', + 'Automatic capacity eviction is exercised through real writes. Attachments remain evictable after a completed call; no permanent lease or power-loss durability guarantee.', + 'Generated small C# inputs, local-text context; no new dependencies, models, UI, or filesystem-wide cleanup.'] }; +const clients = []; +const hash = data => createHash('sha256').update(data).digest('hex'); +const exists = file => fs.access(file).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; }); +const remember = rows => { for (const row of rows) if (!report.observed.some(old => old.ProcessId === row.ProcessId && old.CreationDate === row.CreationDate)) report.observed.push(row); }; +async function save() { await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); } +async function scenario(name, run) { + console.log(`[shared-cache] ${name}`); + const item = { name, passed: false }; report.scenarios.push(item); + Object.assign(item, await run()); item.passed = true; await save(); +} +async function start(name, tag) { + const receipt = path.join(root, `${name}-stop.json`); + const client = new Client({ name, version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, + args: [path.join(repo, 'tests/fixtures/cache-gateway.mjs'), path.join(root, tag), sharedCache, receipt], cwd: repo, stderr: 'pipe' }); + const peer = { name, tag, client, transport, receipt, stderr: '', closed: false }; clients.push(peer); + await client.connect(transport); + transport.stderr?.on('data', chunk => { peer.stderr = (peer.stderr + chunk).slice(-16384); }); + remember(ownedProcesses(transport.pid)); + const hello = await call(peer, 'wincode_hello_world'); + assert.equal(hello.version, report.delivery.version); + assert.equal(hello.runtime.build.status, 'verified'); + assert.equal(hello.runtime.build.buildId, report.buildId); + assert.equal(hello.health.workspaceBinding.root, path.join(root, tag)); + peer.instance = hello.runtime.instanceId; + return peer; +} +async function call(peer, name, args = {}) { + const response = await peer.client.callTool({ name, arguments: args }, { timeout: 20000 }); + const data = response.structuredContent ?? JSON.parse(response.content[0].text); + assert.notEqual(response.isError, true, JSON.stringify(data)); return data; +} +async function context(peer, index = 0, version = 'CURRENT') { + const data = await call(peer, 'wincode_prepare_context', { task: 'Read selected source', candidateFiles: [`Item${index}.cs`], includeFullText: true, maxTokens: 4096 }); + assert.match(data.packedContent, new RegExp(`${peer.tag}_${version}_${index}`)); + assert.doesNotMatch(data.packedContent, new RegExp(`${peer.tag === 'A' ? 'B' : 'A'}_(CURRENT|UPDATED)_`)); + assert.equal(data.metrics.packedFiles, 1); return { fromCache: data.metrics.fromCache, content: data.packedContent }; +} +async function records() { + const names = (await fs.readdir(sharedCache)).filter(name => name.endsWith('.json')); + return Promise.all(names.map(async name => ({ file: path.join(sharedCache, name), entry: JSON.parse(await fs.readFile(path.join(sharedCache, name), 'utf8')) }))); +} +async function backingFor(marker) { + const found = (await records()).find(({ entry }) => entry.data?.content?.includes(marker)); + assert.ok(found?.entry.data.overflowPath, `No overflow record for ${marker}`); return found.entry.data.overflowPath; +} +async function audit() { + const entries = await records(); let verified = 0, evictedAttachments = 0; + for (const { entry } of entries) { + assert.equal(entry.format, 'wincode-cache-v1'); assert.match(entry.integrity, /^[a-f0-9]{64}$/); + const file = entry.data?.overflowPath; + if (!file) continue; + assert.equal(path.dirname(file), path.join(sharedCache, 'overflow')); + if (!await exists(file)) { evictedAttachments++; continue; } + const content = await fs.readFile(file); + assert.equal(hash(content), entry.backingFile.sha256); assert.equal(content.length, entry.backingFile.size); verified++; + } + return { diskEntries: entries.length, verifiedAttachments: verified, evictedAttachments }; +} +async function close(peer) { + if (peer.closed) return; + await peer.client.close(); peer.closed = true; + const receipt = JSON.parse(await fs.readFile(peer.receipt, 'utf8')); + assert.equal(receipt.success, true); assert.equal(receipt.inFlightRequests, 0); assert.equal(receipt.resourcesDisposed, true); + peer.stop = receipt; +} + +let manifest; +try { + manifest = JSON.parse(await fs.readFile(path.join(repo, 'dist/delivery-manifest.json'), 'utf8')); + report.delivery = await verifyDelivery(repo, manifest); + report.buildId = JSON.parse(await fs.readFile(path.join(repo, 'dist/build-manifest.json'), 'utf8')).buildId; + for (const tag of ['A', 'B']) { + const workspace = path.join(root, tag); await fs.mkdir(workspace); + for (let index = 0; index < 32; index++) await fs.writeFile(path.join(workspace, `Item${index}.cs`), + `public class Item${index} { public string Value = "${tag}_CURRENT_${index}"; }\n` + '// bounded fixture evidence\n'.repeat(1200)); + } + const a = await start('first-A', 'A'), a2 = await start('second-A', 'A'); + assert.notEqual(a.instance, a2.instance); + await scenario('same-key concurrent writes and warm reads preserve complete source', async () => { + const values = await Promise.all(Array.from({ length: 8 }, (_, i) => context(i % 2 ? a : a2))); + assert.equal((await context(a)).fromCache, true); assert.equal((await context(a2)).fromCache, true); + return { requests: values.length, ...await audit() }; + }); + const originalAttachment = await backingFor('A_CURRENT_0'); + await scenario('different keys share the cache during concurrent reads and writes', async () => { + await Promise.all(Array.from({ length: 8 }, (_, i) => context(i % 2 ? a : a2, i + 1))); + return { requests: 8, ...await audit() }; + }); + await scenario('peer capacity eviction expires old attachments and subsequent context rebuilds', async () => { + let misses = 0; + for (let index = 9; index < 29; index++) { + const [, value] = await Promise.all([context(a2, index), context(a)]); + if (!value.fromCache) misses++; + } + assert.equal(await exists(originalAttachment), false, 'peer automatic pruning must actually remove the original attachment'); + if (!(await context(a)).fromCache) misses++; + assert.ok(misses > 0, 'evicted backing content must cause a rebuild'); + // A peer can evict the disk index while this Gateway retains a valid warm entry. + for (const { file, entry } of await records()) + if (entry.data?.content?.includes('A_CURRENT_0')) await fs.unlink(file); + assert.equal((await context(a)).fromCache, true, 'valid warm reads do not require a retained disk index'); + return { peerWrites: 20, observedRebuilds: misses, originalAttachmentEvicted: true, warmReadAfterIndexEviction: true, ...await audit() }; + }); + await scenario('both warm Gateways reject same-size corrupted backing content', async () => { + await context(a, 29); await context(a2, 29); + const file = await backingFor('A_CURRENT_29'), stat = await fs.stat(file), original = await fs.readFile(file, 'utf8'); + await fs.writeFile(file, original.replace('A_CURRENT_29', 'Z'.repeat('A_CURRENT_29'.length))); + await fs.utimes(file, stat.atime, stat.mtime); + const values = await Promise.all([context(a, 29), context(a2, 29)]); + assert.ok(values.every(value => !value.fromCache)); + return { rebuiltByBoth: true, ...await audit() }; + }); + await scenario('editing source invalidates both clients and returns the new content', async () => { + const file = path.join(root, 'A/Item29.cs'); + await fs.writeFile(file, (await fs.readFile(file, 'utf8')).replace('A_CURRENT_29', 'A_UPDATED_29')); + const values = await Promise.all([context(a, 29, 'UPDATED'), context(a2, 29, 'UPDATED')]); + assert.ok(values.every(value => !value.fromCache)); return { updatedByBoth: true }; + }); + await close(a2); + const b = await start('other-B', 'B'); + await scenario('different workspaces sharing one physical cache do not exchange source', async () => { + const values = await Promise.all(Array.from({ length: 8 }, (_, i) => context(i % 2 ? a : b, 30))); + assert.equal((await context(a, 30)).fromCache, true); assert.equal((await context(b, 30)).fromCache, true); + return { requests: values.length, instances: [a.instance, b.instance], ...await audit() }; + }); + await scenario('one Gateway shuts down cleanly while its peer continues using shared files', async () => { + await Promise.all([close(b), context(a, 30)]); + assert.equal((await context(a, 30)).fromCache, true); + assert.equal((await fs.readdir(sharedCache)).some(name => name.includes('.tmp.')), false); + return { closed: b.stop, ...await audit() }; + }); + await close(a); + const cold = await start('cold-A', 'A'); + await scenario('fresh Gateway reads the validated persisted snapshot after both writers exit', async () => { + assert.equal((await context(cold, 30)).fromCache, true); return await audit(); + }); + await close(cold); + await verifyDelivery(repo, manifest); + report.success = true; +} catch (error) { report.error = error.stack ?? String(error); process.exitCode = 1; } +finally { + for (const peer of clients) if (!peer.closed) try { await close(peer); } catch (error) { report.cleanupFailures.push(String(error)); } + report.survivors = observedSurvivors(report.observed); + if (report.survivors.length || report.cleanupFailures.length) { report.success = false; process.exitCode = 1; } + for (const process of report.survivors) terminateObserved(process); + report.clients = clients.map(({ name, tag, instance, stop, stderr }) => ({ name, tag, instance, stop, stderr })); + report.finishedAt = new Date().toISOString(); await save(); + console.log(JSON.stringify({ success: report.success, scenarios: report.scenarios.length, error: report.error, report: path.join(root, 'report.json') })); +} diff --git a/scripts/verify-ui-runtime.ts b/scripts/verify-ui-runtime.ts index 4a35d84..ca95012 100644 --- a/scripts/verify-ui-runtime.ts +++ b/scripts/verify-ui-runtime.ts @@ -13,9 +13,9 @@ assert.ok(path.isAbsolute(options.workspace) && path.isAbsolute(options.output)) const count = options.iterations ?? 1; assert.ok(Number.isInteger(count) && count >= 1 && count <= 20); await fs.mkdir(options.output, { recursive: true }); -// Bootstrap outside the target project: gateway cache stays in WinCode. +// Bind the explicitly selected project at startup; output is not another workspace. const transport = new StdioClientTransport({ command: process.execPath, - args: [path.resolve('dist/index.js'), '--workspace', process.cwd()], cwd: process.cwd(), stderr: 'pipe' }); + args: [path.resolve('dist/index.js'), '--workspace', options.workspace], cwd: process.cwd(), stderr: 'pipe' }); const client = new Client({ name: 'ui-runtime-acceptance', version: '1' }); const samples: unknown[] = []; let gatewayPid: number | null = null; @@ -34,7 +34,11 @@ try { for (let index = 0; index < count; index++) { const started = Date.now(); if (index > 0 && index % 5 === 0) { - await call('workspace_open', { path: options.output }); + if (path.relative(options.workspace, options.output) !== '') { + const rejected = await client.callTool({ name: 'workspace_open', arguments: { path: options.output } }); + assert.equal(rejected.isError, true); + assert.equal(JSON.parse((rejected.content[0] as any).text).errorCode, 'WORKSPACE_MISMATCH'); + } await call('workspace_open', { path: options.workspace }); } let cancelled = false; diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index cfcba9e..f993cfd 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -5,7 +5,7 @@ description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面 # WinCode -源码契约:0.14.0(新增可选托盘/手动 Roslyn 释放,自动释放关闭,见对应手册);手册修订:2026-09-10。外部 Serena 入口与旧 source 已退役,不能将此版本号当作当前连接已升级。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行。以当前连接实际 Schema 为准。 +源码契约:0.15.0(连接固定启动工作区,其他根返回 WORKSPACE_MISMATCH);手册修订:2026-09-10。此版本号不代表当前连接已升级,以实际 Schema 为准。外部 Serena 已退役。仅维护时用 node scripts/sync-skill.mjs <安装目录绝对路径> 核对安装内容。 默认以本地文本模式启动,source=local-text;明确配置 Roslyn 后,才通过 WinCode.Code.Host 提供 C# 语义证据。搜索返回的 location 可作为引用、影响分析和重构工具的 symbolLocation;不要猜测定位、复用旧快照或使用已退役的 namePath。内部 reload/cancel 不是 MCP 工具字段。配置与验收边界见代码手册。 @@ -17,6 +17,6 @@ description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面 使用客户端已连接的 WinCode MCP 工具;名称前缀以实际暴露为准。 参数采用兼容容忍模式:未声明字段会被忽略,不表示相应功能已生效;已声明字段仍校验类型、必填项和范围。按对应手册的规范字段表构造请求,使用真正的 JSON 数字/布尔值,不传字符串替代。以当前连接 tools/list 的 schema 为准;手册比连接新时,不反复尝试旧实例未支持的参数。 工具不可用时读诊断手册,不用临时脚本绕过 MCP 或审计。 -源码操作前确认活动工作区;仅在未知或切换项目时调用 workspace_open。健康同根确认保留 Host,不是强制重启或清理完成屏障;多个项目并发使用不同连接,固定项目和统一过载限制仍未实施。 +源码操作前核对所选连接的工作区。workspace_open 只确认或恢复启动根,健康同根确认保留 Host,不是强制重启或清理完成屏障。WORKSPACE_MISMATCH 表示连接不属于目标项目;选择对应连接,不得忽略错误继续声称正在操作另一项目,也不自动改配置或重试。hello.health.workspaceBinding 标明固定根及来源(argument/cwd/configuration);已知根一致时直接查询,不例行重复打开。每实例最多 32 个未完成业务请求,hello/tools/list 共享 4 个轻量槽;原始参数含未知字段按 UTF-8 JSON 限制为 64 KiB。SERVER_BUSY 表示本次尚未执行,按需稍后重试,不自动重放或重启;REQUEST_TIMEOUT 包括排队耗时,不证明业务没有执行。 按需获取小结果,不例行探测、遍历全仓、截图或重复枚举。 保留降级、截断与歧义,不把源码候选当作确定的运行时映射。 diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index 5badf21..b7beaa7 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -2,10 +2,14 @@ 以下为 MCP 工具名和参数;以客户端实际 Schema 为准。 +0.15.0 起,每个连接固定到启动工作区。推荐显式传入 --workspace <绝对目录>;省略时固定到服务启动 cwd,不能稍后用 workspace_open 改根。hello.health.workspaceBinding 包含 mode=fixed、root 和 source(argument/cwd/configuration);其他项目使用独立连接。Windows 大小写/分隔符及规范化后同根拼写保留原身份,不接受 junction/链接别名。WORKSPACE_MISMATCH 返回 activeWorkspace/requestedWorkspace 与 select_workspace_connection;不要忽略错误继续操作或宣称目标根已改变。 + 0.13.1 的本地声明扫描覆盖 .cs/.ts/.tsx/.js/.jsx/.py,屏蔽注释、字符串以及整个 JSX 元素(含其中的表达式),签名与行号仍来自原文。无法可靠定界、未闭合或嵌套超限的文件标记 lexical-uncertainty,queryComplete=false 且不缓存完整空结果;这不是完整语法解析,复杂声明可能省略。引用搜索仍为文本线索,不提供编译器语义或精确身份。 `analyze_change_impact` 及其别名返回一个 JSON 文本块,formattedReport 保留在对象中,不再返回第二份重复 Markdown。没有新增 responseFormat 参数,不要给该工具传 context 专用的格式字段。 +原始参数按 UTF-8 JSON 共享 64 KiB 上限,包含未知字段。query/symbolName 最长 256,kind 最长 128,路径/target 最长 4096,goal 最长 8192;其他字段按当前 schema。各字段分别合法但合计超限仍拒绝。32 个业务槽包含排队与执行,组合工具不重复占用外层容量;SERVER_BUSY 不自动重试,排队计入整个请求预算。 + 0.14.0 的 local-text 查询每次有界扫描实际输入,按内容哈希复用声明解析;新建、删除或修改文件后重新查询,不依据 Git 暂存状态或监听事件认定旧结果有效。内置打包先读取选中文件,再校验内容身份复用;CLI 输出没有可核验输入清单时不缓存。overflow 缺失会重建,但返回的临时文件不是永久存档,跨调用读取失败应重新获取。以上不提供多文件原子快照,也不把文本定位升级为 Roslyn 精确身份。 ## 后端与能力边界 @@ -14,7 +18,7 @@ Roslyn 调用顺序:用 wincode_find_code_symbol 搜索(query 最长 256 字符),根据 signature、file 和 location.project 选择具体声明;再把该项的 name 作为 symbolName、完整 location 对象作为 symbolLocation 传给 wincode_find_references。location 包含 snapshotId(32 位小写十六进制)、project/file(工作区内相对路径)和 position(非负零基 UTF-16)。不手工猜偏移;同名/重载返回候选,不能自动选第一项。简单名称查询在当前不完整范围下只返回候选,单候选也需明确定位;candidatesTruncated=true 时 candidateCount 可能缺省,不能当作全量计数。 -受跟踪输入的变化、重载或工作区切换会使 location 失效;无关文件编辑不等于编译输入变化。SNAPSHOT_STALE/INPUTS_CHANGED 后,下一次显式符号搜索执行所需重载;失败请求不自动重放。若源码编辑后直接搜索,首个请求也可能报告过期,再显式搜索恢复。HOST_RESTART_REQUIRED 按诊断手册重新打开工作区。本地文本实例明确拒绝 symbolLocation;旧 namePath/重载序号不能迁移为 Roslyn 身份。semanticContext 保留快照、输入检查点、排除生成器数和范围,queryComplete=false 时零引用仍不能证明可删除。 +受跟踪输入变化、重载、手动释放或连接更换会使 location 失效;无关文件编辑不等于编译输入变化。SNAPSHOT_STALE/INPUTS_CHANGED 后,下一次显式符号搜索执行所需重载;失败请求不自动重放。源码编辑后首次搜索也可能报告过期,再显式搜索恢复。HOST_RESTART_REQUIRED 按诊断手册对同一工作区执行恢复。本地文本实例拒绝 symbolLocation;旧 namePath/重载序号不能迁移为 Roslyn 身份。semanticContext 保留快照、输入检查点、排除生成器数和范围,queryComplete=false 时零引用仍不能证明可删除。 维护者可用启动参数 `--roslyn-config <配置 JSON 的绝对路径>` 显式选择;不从目标仓库自动发现执行配置。JSON 对应宿主 WinCodeConfig.adapters.roslyn,最多 16 KiB,示例路径须替换成已安装/已构建的实际文件: @@ -31,11 +35,11 @@ Roslyn 调用顺序:用 wincode_find_code_symbol 搜索(query 最长 256 字 } ``` -allowProjectEvaluation 表示允许 MSBuild 设计时求值执行项目 targets,须符合用户授权;不会自动 restore 或下载 SDK。project 是相对当前工作区的固定入口;A→B 切换后使用 B 中同一路径,缺失就报错,不猜其他项目。配置和 TFM 当前固定于实例,要改变它们需更新启动配置并重启 Gateway。dotnetPath/hostPath 必须为绝对普通文件,重解析路径不支持;子进程使用指定 dotnet 的安装根,不改系统环境。可选 loadTimeoutMs 为 1–120000(默认 120000),queryTimeoutMs 为 1–60000(默认 30000),不属于 MCP 请求参数。 +allowProjectEvaluation 表示允许 MSBuild 设计时求值执行项目 targets,须符合用户授权;不会自动 restore 或下载 SDK。project 是相对固定启动工作区的入口,缺失就报错,不猜其他项目。工作区、配置和 TFM 固定于实例,改变它们需按授权更新启动配置并重新建立连接。dotnetPath/hostPath 必须为绝对普通文件,重解析路径不支持;子进程使用指定 dotnet 安装根,不改系统环境。可选 loadTimeoutMs 为 1–120000(默认 120000),queryTimeoutMs 为 1–60000(默认 30000),不属于 MCP 请求参数。 维护验收使用 `npm run test:roslyn-host`(独立 Host)和 `npm run test:roslyn-gateway`(真实 stdio MCP)。维护脚本按显式 WINCODE_DOTNET_PATH、项目 .deps、DOTNET_HOST_PATH、PATH 顺序寻找已安装 SDK,并核对 global.json 的精确版本;不下载安装。当前要求 10.0.303。这些脚本会锁定还原并构建/发布 WinCode Code Host,也会还原生成的测试夹具,保留 test-tmp 报告;不还原用户目标应用。现有 NuGet 缓存缺包时还原可能访问包源,不能将“不下载 SDK”理解成完全离线。Gateway 验收使用完整发布目录的异地副本。此验收不证明当前 Codex 连接已更新或无 SDK 的机器可运行。 -`additionalInputs` 是可选启动配置,默认空数组。例如自定义构建读取现存的 `schema.yaml` 和非标准导入 `build-inputs/custom.rules`,可填 `["schema.yaml","build-inputs/custom.rules"]`。最多 32 个工作区相对文件路径,数组 JSON 最长 4096 个 UTF-16 字符;不接受根外/绝对路径、重复项、目录、通配符或链接。缺失项报 INPUT_UNAVAILABLE,不静默删除;创建或恢复文件后再显式搜索。切换工作区后列表按新根解释,各根均须具备所列文件。修改列表需更新启动配置并重启 Gateway,普通 MCP 参数不能添加输入或获取项目执行许可。 +`additionalInputs` 是可选启动配置,默认空数组。例如构建读取 schema.yaml 和 build-inputs/custom.rules,可填 ["schema.yaml","build-inputs/custom.rules"]。最多 32 个固定工作区相对文件路径,数组 JSON 最长 4096 个 UTF-16 字符;不接受根外/绝对路径、重复项、目录、通配符或链接。缺失项报 INPUT_UNAVAILABLE,不静默删除;创建或恢复文件后再显式搜索。其他项目连接分别配置自己的列表。修改列表需更新启动配置并重启 Gateway,普通 MCP 参数不能添加输入或获取项目执行许可。 Host 通过独立进程的 JSON 行协议 v2 工作,非 MCP tools/call:启动参数为 `--allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK [ADDITIONAL_INPUTS_JSON]`;加载后 ready 帧给出 protocolVersion=2、snapshot 及 inputPolicy={version:1,additionalInputs:[...]}。Gateway 必须核对实际列表;旧 Host 缺少输入策略确认或列表不一致时拒绝接入,即使同为协议 v2 也不能假定兼容。项目求值可能执行 targets,不自动 restore;本维护验收只使用获准的生成夹具。协议及启动方式以源码 `tools/WinCode.Code.Host/Program.cs` 注释为准,尚非稳定公共接口。 @@ -109,7 +113,7 @@ lineRanges 查看最终 coverage.allRequestedCovered、completeLines 和 details | 目的 | 调用 | |---|---| -| 打开/切换项目 | workspace_open({path: "I:/project"}) | +| 确认固定项目 | workspace_open({path: "I:/project"});其他项目选择对应连接 | | 按需浏览目录 | wincode_list_directory({path: "src", maxDepth: 1, maxEntries: 100}) | | 项目依赖概览 | wincode_analyze_workspace({maxDepth: 2}) | | 找符号 | wincode_find_code_symbol({query: "Save"}) | @@ -121,7 +125,7 @@ lineRanges 查看最终 coverage.allRequestedCovered、completeLines 和 details 选定 Roslyn 重载后,将其 name 和 location 原样传给后续工具:引用使用 symbolName,影响分析及重构使用 target,同时传 symbolLocation。后两者先验证定位再分析,不按名字重选目标;SNAPSHOT_STALE/INPUTS_CHANGED 时须重新搜索。简单名称歧义检查 resolution/candidateCount/candidatesTruncated,不能选第一项。queryComplete=false 不等于零引用。 -健康同根 workspace_open 保留 Host/snapshot,不等待业务排空;取消概览确认不会使健康实例进入恢复。真实换根、已知 SDK 重启要求或清理失败仍遵守诊断手册;当前仍允许切换活动根,不能在同一连接交错处理不同项目。 +健康同根 workspace_open 保留 Host/snapshot,不等待业务排空;取消概览确认不会使健康实例进入恢复。已知 SDK 重启要求或清理失败仍遵守诊断手册;另一根在任何重置、缓存、watcher 或 trash 变更前被拒绝,内部 WorkspaceManager 也不能改根。 workspace_open 默认返回项目摘要和最多 8 个入口,整份 JSON 默认不超过 8000 个 UTF-16 字符;不生成目录树或统计全仓大小。检查 projectScanComplete,null 统计不等于零。需要目录时用 wincode_list_directory 指定窄路径,查看 scanComplete/truncated/omissions。includeTree:true 可显式取得有界兼容树,不能当成完整仓库清单。maxOutputChars 为 2048–32768;目录 maxDepth 为 1–5,maxEntries 为 1–500。需要生成目录时显式 includeIgnored:true,但不能越过工作区边界。 @@ -145,7 +149,7 @@ candidateFiles 最多 20 个,仅表示优先,仍可能追加符号检索结 lineRanges 为闭区间、1 起始行号,最多 8 个文件,每文件一个范围、最多 500 行;越界报告缺口,预算不足仍可能截断。它跳过符号搜索,仅返回指定范围;不能与 symbol 或 includeFullText=true 同用。 -取证路由与停止条件:已知行号直接 lineRanges;只需声明附近片段时用 scopeFiles+symbol。审核已知方法的异常处理、取消或资源释放时,默认 24 行窗口往往不足;若已有文件读取工具,优先用有界 rg 上下文和文件读取一起覆盖所需分支,不必先调用 MCP 再逐段续读。小文件也可用 scopeFiles+includeFullText:true 在预算内读取正文,仍检查截断。仅知道文件用 scopeFiles 预览;需要发现其他文件时才用 candidateFiles/关键词检索。先检查片段是否覆盖问题所需代码,覆盖则继续分析,不例行再拉全文或重复相同范围。重名/缺失时收窄文件或转向已知行号;语义完整性不足需要相应语义工具,重复同一正则请求不能补足。文件修改、工作区切换、截断或新问题需要不同代码时重新取证;本工具没有跨调用证据有效期保证,不能把旧片段当成当前文件。 +取证路由与停止条件:已知行号直接 lineRanges;只需声明附近片段时用 scopeFiles+symbol。审核已知方法的异常处理、取消或资源释放时,默认 24 行窗口往往不足;若已有文件读取工具,优先用有界 rg 上下文和文件读取一起覆盖所需分支,不必先调用 MCP 再逐段续读。小文件也可用 scopeFiles+includeFullText:true 在预算内读取正文,仍检查截断。仅知道文件用 scopeFiles 预览;需要发现其他文件时才用 candidateFiles/关键词检索。先检查片段是否覆盖问题所需代码,覆盖则继续分析,不例行再拉全文或重复相同范围。重名/缺失时收窄文件或转向已知行号;语义完整性不足需要相应语义工具,重复同一正则请求不能补足。文件修改、更换连接、截断或新问题需要不同代码时重新取证;本工具没有跨调用证据有效期保证,不能把旧片段当成当前文件。 维护者可运行 npm run benchmark:agent -- 1 做单轮检查,或 -- 3 做三轮对照;报告在 test-tmp/agent-efficiency。它比较十类固定脚本场景(含既有 C# 夹具)的调用、返回字符、重复显示行和证据断言,使用真实 MCP handler 与本地回退,关闭外部后端。数据不代表真实用户任务频率、模型完成率或缓存收益,不据此宣称通用提速。Schema v2 校验当前文件、行号、正文和状态,异常保留为失败记录;复用只依赖受控夹具的可信无变化事件,修改后必须重取,不能作为生产环境的新鲜度判断。 diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index a57ca23..9dbd957 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -1,6 +1,16 @@ # 诊断与审计 -0.13.0 彻底退役外部 Serena。默认本地文本模式可用,但不提供编译器语义;需要 C# 语义时按代码手册显式配置直接 Roslyn。维护入口为 test:roslyn-host 与 test:roslyn-gateway,不再有 test:serena-real。 +0.15.0 的 WORKSPACE_MISMATCH 是固定工作区拒绝:检查 activeWorkspace/requestedWorkspace,选择对应项目连接。错误发生在工作区资源变更之前,不表示旧根已切换或需要清空缓存。hello.health.workspaceBinding 给出固定根及启动来源;argument 是显式 CLI 参数,cwd 是启动目录回退,configuration 是嵌入式配置。显式 --workspace 必须有绝对目录值;已有连接不会因磁盘重建或配置保存自行更新。 + +0.15.0 的 health.admission 返回 business/status 的 active、executing、waiting、accepted、completed、rejected、cancelled、timedOut、peakActive,以及累计 waitMs/executionMs 和 maxWaitMs。每实例最多 32 个未完成业务请求、4 个共享轻量状态请求;内层互斥保持 FIFO,运行中取消须在实际清理后归还容量。workspace_open 占用业务容量,但不计入它自己等待排空的 inFlight。状态不等待慢查询或同根恢复;tools/list 满额以协议错误 data.errorCode=SERVER_BUSY 表达。 + +计时口径:waitMs/maxWaitMs 按已结束请求累计其显式队列等待;executionMs 是队列以外的墙钟耗时,包含 I/O 和取消清理,不是 CPU 用时。active/executing/waiting 为当前请求数;取消/超时计数是 completed 的子集。 + +SERVER_BUSY 附 workStarted=false、retryable=true 和容量快照,仅说明该次请求尚未开始。按需稍后重试,不自动重放或重启 Host。REQUEST_TIMEOUT 包括启动、排队和执行预算,retryable=false;核对实际结果和恢复状态。原始参数(含未知字段)按 UTF-8 JSON 限制为 64 KiB,超限为 INVALID_ARGUMENT;该限制不消除 SDK 已解析帧的瞬时分配,也不保证总 RSS 或挂起 OS I/O 的强制终止。 + +hello.health.cache 仅读取内存及最近显式磁盘观察:diskObservation=not-observed 时 diskEntries/estimatedDiskBytes/diskObservedAt 为 null;incomplete 表示读取不完整。known 及其 diskObservedAt 可能已过时。主动 diagnose_project 才刷新磁盘统计。 + +0.13.0 已退役外部 Serena。默认 local-text 不提供编译器语义,C# 语义需显式配置直接 Roslyn;维护入口为 test:roslyn-host 与 test:roslyn-gateway。 从 0.12.4 起 Repomix 健康探测和打包都由当前 Node 可执行文件直接启动已安装的 JavaScript CLI;不经过 cmd、npx 或 PATH 包装脚本,也不下载包。默认按目标工作区和 WinCode 安装目录的 Node 模块路径读取 repomix/package.json 的 bin 入口;不搜索 npx 缓存或 npm 自定义全局前缀。非标准安装需在宿主 WinCodeConfig.adapters.repomix.customCliPath 提供绝对 .js/.cjs/.mjs 路径;该字段不是 MCP 工具参数,不能传给 hello/prepare_context。显式路径无效时返回 builtin fallback,不执行另一份安装;useCli=false 仍完全禁止探测和启动。执行已安装脚本不提供沙盒或脚本可信性保证。 @@ -8,7 +18,7 @@ `wincode_diagnose_project` 会检查 SDK 并主动探测 Repomix/UIA;对 Roslyn 只读取已有加载状态,不会启动 Code Host 或执行项目加载。已授权配置 Roslyn 后,首次明确的符号搜索才触发加载。`health.healthObservation` 当前包含 text/repomix/flaui;Roslyn 的观察时间与快照状态在 `health.roslyn`,不要按旧 Serena 字段判断。 -代码查询、引用、上下文、影响分析和重构建议接收 MCP 客户端取消信号;停止后续扫描/打包,等待当前读操作或自有上游进程清理后释放请求占用。Roslyn 取消会传播到自有 Host;若合作取消未及时完成,则按既有超时策略清理自有进程树,不宣称其他请求已成功。磁盘单次 OS I/O 不能保证瞬时中断。工作区切换在等待和提交前可取消;已开始提交切换时完成一致性收尾,不声称已回滚。 +代码查询、引用、上下文、影响分析和重构建议接收 MCP 取消信号;停止后续扫描/打包,等待当前读操作或自有上游清理后释放请求占用。Roslyn 取消传播到自有 Host;合作取消未及时完成时按既有超时策略清理自有进程树。磁盘单次 OS I/O 不保证瞬时中断。同根资源恢复在等待和提交前可取消;已开始恢复时保留实际一致性状态,不声称已回滚。 `health.resourceCleanup` 是最多 100 条资源关闭记录(owner、kind、closed/failed 与最多 1024 字符错误),`omitted` 表示更早记录被省略。进程数量为零不能替代这些结果或真实 PID 退出证据。关闭失败会向调用方抛出,重复关闭保留失败;初始化失败会尝试释放已取得资源。记录只保存在当前进程内,不是持久审计或防篡改证明。 @@ -38,7 +48,7 @@ pwsh -NoProfile -File "/scripts/check-ui-audit.ps1" 仓内 `npm run check` 执行锁定构建、核心回归和生产 stdio,生成并校验 `dist/delivery-manifest.json`;`npm run check:desktop` 单独运行隔离桌面闭环。`npm run delivery:verify` 检查 Gateway、发布 Host 全部文件及四份受管手册的一致性,不启动 Host,也不验证另一个客户端实例或签名真实性。构建要求 Node 24(22 兼容)和 `global.json` 中锁定的 SDK;缺少环境时按授权安装,不自动修改环境。 -WORKSPACE_RECOVERY_REQUIRED 表示切换中途失败后工作区一致性尚未确认。此时业务工具被拒绝;被动 hello 仍可读取 health.workspaceRecovery,status=recovery_required。先检查 recoveryAction:workspace_open 表示可按原任务指定路径重新打开,只有完整重置/初始化及 watcher 绑定成功才恢复请求;同一路径也执行完整恢复。restart_gateway 表示清理失败被当前实例保留,重新打开无法恢复;先检查 Gateway 自有资源的清理情况,再按客户端正常流程重启 Gateway,不自动重启或终止目标应用。永久失败后的 workspace_open 不再反复改变根或会话。不要只修改路径字段、反复重试业务请求或把旧适配器状态当成已切换成功。CANCELLED 若附带 workspaceRecovery,同样按其 recoveryAction 处理;切换变更前失败且状态未改变时仍保留旧工作区。 +WORKSPACE_RECOVERY_REQUIRED 表示固定根内资源恢复尚未完成或自有资源清理失败。业务工具被拒绝;被动 hello 可读取 health.workspaceRecovery,status=recovery_required。recoveryAction=workspace_open 表示对同一绑定路径重试恢复,只有完整重置/初始化及 watcher 绑定成功才恢复请求;不能传另一根绕过恢复门。restart_gateway 表示当前实例保留清理失败,重新打开无法恢复;先检查 Gateway 自有资源清理,再按客户端正常流程重建连接,不自动重启或终止目标应用。永久失败后的同根确认不会反复重建会话。CANCELLED 若附带 workspaceRecovery,按其 recoveryAction 处理;健康概览取消仍保持原状态。 0.13.1 中,已知工具执行失败的 JSON 文本与 structuredContent 同源;Gateway 异常含 success=false、errorCode、errorMessage、provider 和 recoveryAction。UI/trash 保留领域字段及实际位置,不要求所有领域错误具有 Gateway 字段;图片保持独立 image 块。未知工具在正常受理状态下返回 JSON-RPC -32602 协议错误,不返回 isError 结果;关闭/取消的入口拒绝优先于工具查找。旧连接不能套用此契约,先核对实际版本。恢复动作不表示已经回滚或允许原样重试。 @@ -79,6 +89,6 @@ Gateway 通过子进程私有环境传递所属 PID;两个 .NET Host 在项目 自动释放关闭,本版不创建 idle timer。用户可按 README 手动启动独立 Tray,并给希望管理的 Gateway 启动参数添加 --tray 后刷新连接。托盘只管理已注册的实例,不扫描/终止外部客户端或目标应用;MCP 仍为原有 15 个工具,没有让 Agent 自动代替用户释放的管理工具。默认不启用托盘连接、不设置自启动。 -手动释放遇到业务在途、语义排队/收尾、工作区切换或恢复门时拒绝,不自动延后执行。被接纳的释放完成后,新 MCP 请求继续;旧 symbolLocation 返回 SNAPSHOT_STALE,显式重新搜索再取得当前定位。保留 Gateway、watcher、缓存与最后诊断。清理失败进入 restart_gateway 恢复门,不能靠反复点击清除错误。local-text 没有可释放的 Roslyn。 +手动释放遇到业务在途、语义排队/收尾、工作区确认或恢复门时拒绝,不自动延后执行。释放完成后新请求继续;旧 symbolLocation 返回 SNAPSHOT_STALE,显式重新搜索取得当前定位。保留 Gateway、watcher、缓存与最后诊断。清理失败进入 restart_gateway 恢复门,不能靠反复点击清除错误。local-text 没有可释放的 Roslyn。 概览只读内存快照,不为状态启动 Host 或枚举缓存目录。状态是注册/打开/刷新时的观察,不代表 Agent 在两次请求之间已结束整个任务。失联/超时表示未知,控制命令不自动重放;退出 Tray 不停止 Gateway。首版最多八个同用户/会话实例,按同权限级别使用;版本必须匹配。需要停止时由用户确认“停止此实例”,走该 Gateway 既有关闭路径,客户端可能重新建立新实例。 diff --git a/skills/wincode/references/ui.md b/skills/wincode/references/ui.md index e27050e..2e6c790 100644 --- a/skills/wincode/references/ui.md +++ b/skills/wincode/references/ui.md @@ -1,6 +1,8 @@ # 窗口与 UI 取证 -0.14.0 的可选托盘与 UIA 取证 Host 是独立组件:隐藏或退出托盘不终止 MCP,也不卸载正在使用的 Roslyn;设置内手动释放仅影响选定实例的 Code Host。源码缓存修复不证明 UI 候选对应同一运行时状态;多实例选择不同 HWND 的隔离仍需单独验收。 +UI 工具同样占用每实例 32 个业务受理槽;既有 UI/健康探测互斥保留,排队消耗请求预算。SERVER_BUSY 不表示已启动 Helper,不自动重试。hwnd 最长 32 字符;原始参数合计受 64 KiB UTF-8 JSON 预算限制。 + +0.15.0 中,每个 Gateway 的源码范围固定于启动根;换项目选择对应连接。目标 PID/HWND 不是工作区身份,UI 源码候选仍按所选连接解释。可选托盘与 UIA 取证 Host 独立,退出托盘不终止 MCP 或卸载正在使用的 Roslyn;手动释放仅影响选定实例的 Code Host。源码缓存修复不证明 UI 候选对应同一运行时状态,多实例窗口隔离仍需单独验收。 ## 规范字段 diff --git a/src/Adapters/DesignTimeArtifacts.ts b/src/Adapters/DesignTimeArtifacts.ts new file mode 100644 index 0000000..ac8c12f --- /dev/null +++ b/src/Adapters/DesignTimeArtifacts.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** Only call after the owned Host/process-tree shutdown has completed. Never accepts an external PID. */ +export async function cleanupDesignTimeArtifacts(root: string, instance: string): Promise { + if (!/^[a-f0-9]{32}$/.test(instance) || !path.isAbsolute(root)) throw new Error('Invalid build output owner.'); + root = path.resolve(root); + const storage = path.join(root, '.cache', 'wincode-build', instance); + const checked = async (target: string): Promise => { + const relative = path.relative(root, target); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + throw new Error('Build output cleanup escaped the workspace.'); + for (let current = target; ; current = path.dirname(current)) { + const stat = await fs.lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (stat?.isSymbolicLink()) throw new Error('Build output cleanup refuses linked paths.'); + if (current === root) break; + } + return fs.access(target).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; }); + }; + if (!await checked(storage)) return; + const file = path.join(storage, 'owner.json'); + if (!await checked(file)) throw new Error('Build output ownership manifest is missing.'); + if ((await fs.stat(file)).size > 512 * 1024) throw new Error('Build output ownership manifest exceeds budget.'); + const manifest = JSON.parse(await fs.readFile(file, 'utf8')); + if (manifest.version !== 1 || manifest.instance !== instance || !Array.isArray(manifest.paths) || manifest.paths.length > 128) + throw new Error('Invalid build output ownership manifest.'); + const directories: string[] = []; + for (const relative of manifest.paths) { + if (typeof relative !== 'string' || path.isAbsolute(relative)) throw new Error('Invalid private output path.'); + const directory = path.resolve(root, relative); + if (!directory.toLowerCase().endsWith(`${path.sep}.cache${path.sep}wincode-msbuild${path.sep}${instance}`)) + throw new Error('Private output identity does not match its owner.'); + if (await checked(directory)) directories.push(directory); + } + // Validate the entire bounded tree before deleting anything; reparse paths are never traversed. + const pending = [...directories, storage]; let entries = 0; + while (pending.length) { + const directory = pending.pop()!; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + if (++entries > 16384) throw new Error('Private cleanup entry budget exceeded.'); + if (entry.isSymbolicLink()) throw new Error('Private cleanup refuses linked entries.'); + if (entry.isDirectory()) pending.push(path.join(directory, entry.name)); + } + } + for (const directory of [...directories, storage]) { + if (await checked(directory)) await fs.rm(directory, { recursive: true, maxRetries: 4, retryDelay: 50 }); + } +} diff --git a/src/Adapters/FlaUiAdapter.ts b/src/Adapters/FlaUiAdapter.ts index 155f81a..7e6c632 100644 --- a/src/Adapters/FlaUiAdapter.ts +++ b/src/Adapters/FlaUiAdapter.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { IAdapter, AdapterHealth, AdapterLastError } from './IAdapter.js'; import { WinCodeConfig } from '../Core/Config.js'; +import { checkOperation, type OperationContext } from '../Core/OperationContext.js'; import { ResourceManager, Mutex, @@ -87,8 +88,9 @@ export class FlaUiAdapter implements IAdapter { return null; } - async checkHealth(timeoutMs?: number): Promise { - const health = await this.probeHealth(timeoutMs); + async checkHealth(timeoutMs?: number, operation?: OperationContext): Promise { + checkOperation(operation); + const health = await this.probeHealth(timeoutMs, false, operation); // Availability and recent operation failure are different facts. A successful/cached // health probe must not erase a recent inspect timeout or cleanup failure. const latest = [health.lastError, this.lastError].filter(Boolean) @@ -109,7 +111,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, validateOnly = false): Promise { + private async probeHealth(timeoutMs?: number, validateOnly = false, operation?: OperationContext): 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; @@ -179,8 +181,10 @@ export class FlaUiAdapter implements IAdapter { if (validateOnly) return { available: false, source: 'unavailable', details: 'UIA runtime has not been probed.' }; - const probeTimeout = timeoutMs ?? this.config.timeouts?.healthProbeMs ?? 3_000; + checkOperation(operation); + const probeTimeout = Math.max(1, Math.min(timeoutMs ?? this.config.timeouts?.healthProbeMs ?? 3_000, (operation?.deadline ?? Infinity) - Date.now())); const probeAbortController = new AbortController(); + const probeSignal = operation?.signal ? AbortSignal.any([operation.signal, probeAbortController.signal]) : probeAbortController.signal; const probeTimer = setTimeout(() => probeAbortController.abort(), probeTimeout); probeTimer.unref?.(); @@ -197,7 +201,7 @@ export class FlaUiAdapter implements IAdapter { pid: 0, }, probeTimeout, - probeAbortController.signal + probeSignal ); if (res.success && res.status === 'healthy') { @@ -212,7 +216,7 @@ export class FlaUiAdapter implements IAdapter { } throw new Error(res.errorMessage || 'Host probe failed'); - }, probeAbortController.signal); + }, probeSignal, operation?.queue); } catch (err) { const isAbort = err instanceof AbortError || probeAbortController.signal.aborted; const msg = isAbort @@ -238,20 +242,20 @@ export class FlaUiAdapter implements IAdapter { } } - async listWindows(request: UiListWindowsRequest, signal?: AbortSignal): Promise { + async listWindows(request: UiListWindowsRequest, signal?: AbortSignal, operation?: OperationContext): Promise { try { validateWindowQuery(request); } catch (error) { return { schemaVersion: '1.0', protocolVersion: '1.0', requestId: randomUUID(), success: false, errorCode: UiErrorCodes.INVALID_ARGUMENT, errorMessage: (error as Error).message }; } // Reuse the same queue, cancellation deadline and exit-confirmed cleanup as inspection. - return this.inspect({ ...request, action: 'listWindows', timeoutMs: 3000 }, signal); + return this.inspect({ ...request, action: 'listWindows', timeoutMs: 3000 }, signal, operation); } async inspect( - request: UiInspectRequest, signal?: AbortSignal + request: UiInspectRequest, signal?: AbortSignal, operation?: OperationContext ): Promise { - const result = await this.inspectOnce(request, signal); + const result = await this.inspectOnce(request, signal, operation); if (!result.success) this.lastError = { at: new Date().toISOString(), reason: result.errorCode === UiErrorCodes.TIMEOUT ? 'timeout' : @@ -267,7 +271,8 @@ export class FlaUiAdapter implements IAdapter { private async inspectOnce( request: UiInspectRequest, - signal?: AbortSignal + signal?: AbortSignal, + operation?: OperationContext ): Promise { const requestId = request.requestId || randomUUID(); try { validateUiQuery(request.query, request.readStates); } @@ -332,9 +337,9 @@ export class FlaUiAdapter implements IAdapter { this.config.timeouts?.flauiInspectMs ?? UI_INSPECT_DEFAULTS.TIMEOUT_MS; - const deadline = Date.now() + effectiveTimeout; + const deadline = Math.min(Date.now() + effectiveTimeout, operation?.deadline ?? Infinity); const deadlineController = new AbortController(); - const deadlineTimer = setTimeout(() => deadlineController.abort(), effectiveTimeout); + const deadlineTimer = setTimeout(() => deadlineController.abort(), Math.max(1, deadline - Date.now())); const executionSignal = signal ? AbortSignal.any([signal, deadlineController.signal]) : deadlineController.signal; @@ -368,7 +373,7 @@ export class FlaUiAdapter implements IAdapter { return { ...result, errorCode: UiErrorCodes.TIMEOUT, errorMessage: 'UI inspection deadline exceeded.' }; } return result; - }, executionSignal); + }, executionSignal, operation?.queue); } catch (err) { if (err instanceof AbortError) { return { diff --git a/src/Adapters/RepomixAdapter.ts b/src/Adapters/RepomixAdapter.ts index d79e6cd..6ae2b0f 100644 --- a/src/Adapters/RepomixAdapter.ts +++ b/src/Adapters/RepomixAdapter.ts @@ -51,7 +51,8 @@ export class RepomixAdapter implements IAdapter { health: this.healthCache ? { ...this.healthCache.value, lastError: this.lastError ?? this.healthCache.value.lastError } : null }; } - async checkHealth(timeoutMs?: number): Promise { + async checkHealth(timeoutMs?: number, operation?: OperationContext): Promise { + checkOperation(operation); // Configuration is authoritative even when an earlier probe found an installed CLI. if (!this.config.adapters.repomix.useCli) { this.isCliAvailable = false; @@ -64,14 +65,13 @@ export class RepomixAdapter implements IAdapter { }; } const defaultMs = this.config.timeouts?.repomixHealthMs ?? getDefaultTimeouts().repomixHealthMs; - const waitMs = timeoutMs ?? defaultMs; // Explicit timeout (tests / force) bypasses the short health memo. if (timeoutMs === undefined && this.healthCache && Date.now() - this.healthCache.at < 30_000) { return this.healthCache.value; } this.cliEntry = await this.resolveCliEntry(); - if (!this.config.adapters.repomix.useCli) return this.checkHealth(timeoutMs); + if (!this.config.adapters.repomix.useCli) return this.checkHealth(timeoutMs, operation); if (!this.cliEntry) { this.isCliAvailable = false; const health: AdapterHealth = { available: true, source: 'fallback', @@ -79,6 +79,8 @@ export class RepomixAdapter implements IAdapter { this.healthCache = { at: Date.now(), value: health }; return health; } + checkOperation(operation); + const waitMs = Math.max(1, Math.min(timeoutMs ?? defaultMs, (operation?.deadline ?? Infinity) - Date.now())); const health = await new Promise((resolve) => { let isSettled = false; const proc = spawn(process.execPath, [this.cliEntry!, '--version'], { diff --git a/src/Adapters/RoslynAdapter.ts b/src/Adapters/RoslynAdapter.ts index a951367..418a0d3 100644 --- a/src/Adapters/RoslynAdapter.ts +++ b/src/Adapters/RoslynAdapter.ts @@ -33,8 +33,9 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { private readonly textDeclarations: (content: string, file: string) => CodeSymbol[]) { const options = config.adapters.roslyn; if (options?.enabled !== true || options.allowProjectEvaluation !== true) throw new CodeQueryError('PROJECT_EVALUATION_NOT_ALLOWED', 'Explicit Roslyn project evaluation permission is required.'); - if (![options.configuration, options.targetFramework].every(value => typeof value === 'string' && value.trim().length > 0 && value.length <= 128)) - throw new CodeQueryError('INVALID_ARGUMENT', 'Explicit Configuration and TargetFramework are required.'); + if (![options.configuration, options.targetFramework].every(value => typeof value === 'string' && value.trim().length > 0 && value.length <= 128 && + !/[\\/:*?"<>|;$%@\u0000-\u001f]/.test(value) && !/[.\s]$/.test(value))) + throw new CodeQueryError('INVALID_ARGUMENT', 'Configuration and TargetFramework must be literal directory names.'); for (const value of [options.dotnetPath, options.hostPath]) if (typeof value !== 'string' || !path.isAbsolute(value)) throw new CodeQueryError('INVALID_ARGUMENT', 'Roslyn executable and Host paths must be absolute.'); for (const [value, maximum] of [[options.loadTimeoutMs, 120000], [options.queryTimeoutMs, 60000]] as const) @@ -130,7 +131,7 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { } // 必须确认 Host 实际采用了补充输入;旧 Host 或漏传配置不能被当成成功加载。 const policy = reply.inputPolicy as { version?: unknown; additionalInputs?: unknown } | undefined; - if (policy?.version !== 1 || !Array.isArray(policy.additionalInputs) || + if (policy?.version !== 2 || !Array.isArray(policy.additionalInputs) || policy.additionalInputs.length !== this.options.additionalInputs!.length || policy.additionalInputs.some((file, index) => typeof file !== 'string' || path.relative(this.localPath(file), this.localPath(this.options.additionalInputs![index])) !== '')) @@ -227,7 +228,7 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { await this.stopClient(true); throw error; } - }, operation?.signal).finally(() => { this.operations--; }); + }, operation?.signal, operation?.queue).finally(() => { this.operations--; }); } /** 名称搜索不读语义缓存;过期时要求下一次显式搜索重载,不重放本次失败请求。 */ diff --git a/src/Adapters/RoslynHostClient.ts b/src/Adapters/RoslynHostClient.ts index b70623c..671d83c 100644 --- a/src/Adapters/RoslynHostClient.ts +++ b/src/Adapters/RoslynHostClient.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { CodeQueryError } from '../Core/CodeQueries.js'; import { checkOperation, type OperationContext } from '../Core/OperationContext.js'; import { AbortError, ResourceManager, TimeoutError, killProcessTree, withTimeout } from '../Core/ResourceManager.js'; +import { cleanupDesignTimeArtifacts } from './DesignTimeArtifacts.js'; /** 已通过帧边界和基础信封校验的内部响应;业务字段仍须由适配器逐项校验。 */ export type HostReply = Record & { success: boolean; id?: string | null; errorCode?: string; error?: string }; @@ -14,6 +15,8 @@ interface Pending { resolve: (value: HostReply) => void; reject: (error: unknown * 取消先等待目标请求收尾,超过宽限才终止自有进程树;调用方在清理完成前不得释放请求占用。 */ export class RoslynHostClient { + readonly buildInstance = randomUUID().replaceAll('-', ''); + private readonly artifactRoot?: string; readonly child: ChildProcessWithoutNullStreams; private readonly pending = new Map(); private readonly cancelIds = new Set(); @@ -28,12 +31,14 @@ export class RoslynHostClient { /** 调用方先验证路径/许可;参数始终通过 argv 传递,禁用 shell 和可见窗口。 */ constructor(command: string, args: string[], cwd: string, resources: ResourceManager) { + if (args[1] === '--allow-project-evaluation' && path.isAbsolute(args[2] ?? '')) this.artifactRoot = args[2]; this.ready = new Promise((resolve, reject) => this.pending.set('@ready', { resolve, reject })); // 即使进程在调用 waitReady 前失败,也不会产生未处理的 Promise 拒绝。 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), WINCODE_OWNER_PID: String(process.pid) }, + env: { ...process.env, DOTNET_HOST_PATH: command, DOTNET_ROOT: path.dirname(command), WINCODE_OWNER_PID: String(process.pid), + WINCODE_BUILD_INSTANCE: this.buildInstance }, detached: process.platform !== 'win32' }); resources.registerProcess('roslyn', this.child); this.child.stdout.setEncoding('utf8'); @@ -137,10 +142,21 @@ export class RoslynHostClient { close(force = false): Promise { if (this.closePromise) return this.closePromise; this.closing = true; - this.closePromise = this.closeOnce(force); + this.closePromise = this.closeWithArtifacts(force); return this.closePromise; } + /** Recover this namespace only after actual process shutdown; preserve failures for the adapter. */ + private async closeWithArtifacts(force: boolean): Promise { + const failures: unknown[] = []; + try { await this.closeOnce(force); } catch (error) { failures.push(error); } + if (this.ended && this.artifactRoot) { + try { await cleanupDesignTimeArtifacts(this.artifactRoot, this.buildInstance); } catch (error) { failures.push(error); } + } + if (failures.length === 1) throw failures[0]; + if (failures.length) throw new AggregateError(failures, 'Code Host or build output cleanup failed.'); + } + /** 即使优雅关闭没有回复,也尝试终止;最终必须等到实际进程退出。 */ private async closeOnce(force: boolean): Promise { let cleanupFailure: unknown; diff --git a/src/CompositeTools/ProjectDiagnostics.ts b/src/CompositeTools/ProjectDiagnostics.ts index 1c49364..853cfb0 100644 --- a/src/CompositeTools/ProjectDiagnostics.ts +++ b/src/CompositeTools/ProjectDiagnostics.ts @@ -3,6 +3,7 @@ import { promisify } from 'node:util'; import { WorkspaceManager, ProjectIdentity } from '../Core/Workspace.js'; import { WinCodeConfig } from '../Core/Config.js'; import { AdapterHealthQuery } from '../Core/AdapterStatus.js'; +import { checkOperation, type OperationContext } from '../Core/OperationContext.js'; const execFileAsync = promisify(execFile); @@ -31,8 +32,9 @@ export class ProjectDiagnostics { this.queries = queries; } - async runDiagnostics(): Promise { - const identity: ProjectIdentity = await this.workspace.identifyProject(); + async runDiagnostics(operation?: OperationContext): Promise { + checkOperation(operation); + const identity: ProjectIdentity = await this.workspace.identifyProject(operation); const items: DiagnosticItem[] = []; // Check Windows platform @@ -52,10 +54,12 @@ export class ProjectDiagnostics { // Check .NET SDK availability — presence is not semantic analysis capability try { + checkOperation(operation); const executable = this.config.adapters.roslyn?.enabled ? this.config.adapters.roslyn.dotnetPath : 'dotnet'; const { stdout } = await execFileAsync(executable, ['--version'], { windowsHide: true, - timeout: this.config.timeouts?.dotnetMs ?? 5000, + timeout: Math.max(1, Math.min(this.config.timeouts?.dotnetMs ?? 5000, (operation?.deadline ?? Infinity) - Date.now())), + signal: operation?.signal, }); items.push({ category: 'Environment', @@ -63,6 +67,7 @@ export class ProjectDiagnostics { message: `.NET SDK detected (Version: ${stdout.trim()}). This does not mean semantic reference analysis is available.`, }); } catch { + checkOperation(operation); items.push({ category: 'Environment', status: identity.isDotNet ? 'FAIL' : 'WARN', @@ -72,6 +77,7 @@ export class ProjectDiagnostics { } if (this.queries) { + checkOperation(operation); const health = await this.queries.checkHealth(); if (this.config.adapters.roslyn?.enabled) { items.push({ category: 'Dependencies', status: health.available ? 'PASS' : 'WARN', diff --git a/src/Core/Cache.ts b/src/Core/Cache.ts index d5a88bb..4c2841e 100644 --- a/src/Core/Cache.ts +++ b/src/Core/Cache.ts @@ -18,6 +18,8 @@ export interface CacheEntry { fingerprint?: string; data: T; byteSize?: number; + integrity?: string; + backingFile?: { size: number; sha256: string }; } export interface CacheStats { @@ -28,6 +30,13 @@ export interface CacheStats { estimatedDiskBytes: number; } +export interface KnownCacheStats extends Omit { + diskEntries: number | null; + estimatedDiskBytes: number | null; + diskObservation: 'not-observed' | 'known' | 'incomplete'; + diskObservedAt: string | null; +} + /** * Memory LRU + disk JSON cache with byte caps. * Namespace isolates workspaces; fingerprint memo avoids repeating git status @@ -46,7 +55,11 @@ export class CacheManager { private memoryBytes = 0; private namespace = ''; private writeChain: Promise = Promise.resolve(); + // Invalidates in-flight disk reads without retaining per-key tombstones. + private mutationVersion = 0; private diskIdentity: string | null = null; + private diskStats: Pick = + { diskEntries: null, estimatedDiskBytes: null, diskObservation: 'not-observed', diskObservedAt: null }; constructor( cacheDir: string, @@ -92,6 +105,7 @@ export class CacheManager { setNamespace(workspaceRoot: string): void { const resolved = path.resolve(workspaceRoot); this.namespace = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 12); + this.mutationVersion++; this.memoryCache.clear(); this.memoryBytes = 0; this.workspaceFingerprint.reset(); @@ -164,6 +178,7 @@ export class CacheManager { async rebind(newCacheDir: string): Promise { await assertLinkFreePath(newCacheDir); await this.flush(); + this.mutationVersion++; this.memoryCache.clear(); this.memoryBytes = 0; this.workspaceFingerprint.reset(); @@ -202,6 +217,7 @@ export class CacheManager { this.memoryCache.set(memKey, entry); return entry.data as T; } + this.mutationVersion++; const data = create(); const byteSize = this.estimateBytes(data); if (byteSize <= this.maxEntryBytes) this.setMemoryEntry(memKey, { data, fingerprint, byteSize, timestamp: Date.now() }); @@ -223,6 +239,73 @@ export class CacheManager { } catch { return false; } } + private entryIntegrity(key: string, entry: CacheEntry): string { + return crypto.createHash('sha256').update(JSON.stringify([ + key, entry.timestamp, entry.ttlMs, entry.fingerprint, entry.data, entry.backingFile, + ])).digest('hex'); + } + + private hasValidIntegrity(key: string, entry: CacheEntry): boolean { + try { return typeof entry.integrity === 'string' && entry.integrity === this.entryIntegrity(key, entry); } + catch { return false; } + } + + /** Hash a bounded ordinary file through one handle; existence, size and mtime alone cannot prove content. */ + private async readBackingIdentity(data: unknown, expected?: CacheEntry['backingFile']): Promise['backingFile'] | null> { + const file = (data as { overflowPath?: unknown } | null)?.overflowPath; + if (typeof file !== 'string' || path.dirname(file) !== path.join(this.cacheDir, 'overflow') || !OVERFLOW_FILE.test(path.basename(file))) return null; + if (expected && (!Number.isSafeInteger(expected.size) || expected.size < 0 || !/^[a-f0-9]{64}$/.test(expected.sha256))) return null; + try { + await this.assertDiskBoundary('overflow'); + await assertLinkFreePath(file); + const before = await fs.lstat(file); + const limit = expected?.size ?? before.size; + if (!before.isFile() || before.isSymbolicLink() || before.size !== limit || limit > this.maxDiskBytes) return null; + const handle = await fs.open(file, 'r'); + try { + const opened = await handle.stat(); + if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) return null; + const digest = crypto.createHash('sha256'), buffer = Buffer.alloc(64 * 1024); + let size = 0; + while (true) { + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, limit - size + 1), null); + if (!bytesRead) break; + size += bytesRead; + if (size > limit) return null; + digest.update(buffer.subarray(0, bytesRead)); + } + const after = await handle.stat(); + if (size !== limit || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) return null; + const sha256 = digest.digest('hex'); + if (expected && expected.sha256 !== sha256) return null; + return { size, sha256 }; + } finally { await handle.close(); } + } catch { return null; } + } + + private async backingFileMatches(entry: CacheEntry): Promise { + if ((entry.data as { overflowPath?: unknown } | null)?.overflowPath === undefined) return entry.backingFile === undefined; + return !!entry.backingFile && !!await this.readBackingIdentity(entry.data, entry.backingFile); + } + + private async readCacheJson(file: string): Promise { + await assertLinkFreePath(file); + const handle = await fs.open(file, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > this.maxEntryBytes) throw new Error('Cache JSON exceeds its read budget.'); + const buffer = Buffer.alloc(stat.size + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null); + if (!bytesRead) break; + length += bytesRead; + } + if (length !== stat.size) throw new Error('Cache JSON changed size during reading.'); + return buffer.subarray(0, length).toString('utf8'); + } finally { await handle.close(); } + } + /** * Retrieves data from memory or disk cache with LRU access refresh. * Disk files larger than maxEntryBytes are deleted instead of being loaded. @@ -236,7 +319,10 @@ export class CacheManager { if (memEntry.ttlMs && now - memEntry.timestamp > memEntry.ttlMs) { this.deleteMemory(memKey); } else if (!currentFingerprint || memEntry.fingerprint === currentFingerprint) { - if (!await this.backingFileExists(memEntry.data)) { + const valid = this.hasValidIntegrity(memKey, memEntry) && await this.backingFileMatches(memEntry); + // Validation yields: replacement, eviction or reset may have invalidated this exact entry. + if (this.memoryCache.get(memKey) !== memEntry) return null; + if (!valid) { this.deleteMemory(memKey); return null; } @@ -247,25 +333,26 @@ export class CacheManager { } const filePath = this.getCacheFilePath(key); + const version = this.mutationVersion; try { + // A read started during an accepted write/clear must observe its disk result. + await this.writeChain; + if (version !== this.mutationVersion) return null; await this.assertDiskBoundary(); const stat = await fs.lstat(filePath); if (!stat.isFile() || stat.isSymbolicLink()) return null; if (stat.size > this.maxEntryBytes) { - await this.unlinkOwned(filePath); + await this.discardDiskEntry(filePath, version); return null; } - const content = await fs.readFile(filePath, 'utf-8'); + const content = await this.readCacheJson(filePath); const entry: CacheEntry = JSON.parse(content); if (entry.format !== 'wincode-cache-v1' || !Number.isFinite(entry.timestamp) || !Object.hasOwn(entry, 'data')) return null; + if (!this.hasValidIntegrity(memKey, entry)) return null; if (entry.ttlMs && now - entry.timestamp > entry.ttlMs) { - await this.unlinkOwned(filePath); - const p = (entry.data as any)?.overflowPath; - if (typeof p === 'string') { - await this.removeOverflow(p); - } + await this.discardDiskEntry(filePath, version, (entry.data as { overflowPath?: unknown } | null)?.overflowPath); return null; } @@ -273,7 +360,7 @@ export class CacheManager { return null; } - if (!await this.backingFileExists(entry.data)) return null; + if (version !== this.mutationVersion || !await this.backingFileMatches(entry) || version !== this.mutationVersion) return null; this.setMemoryEntry(memKey, { ...entry, @@ -291,6 +378,7 @@ export class CacheManager { * Oversized values are not retained in the heap and are not written to disk. */ async set(key: string, data: T, options?: { ttlMs?: number; fingerprint?: string }): Promise { + this.mutationVersion++; const byteSize = this.estimateBytes(data); const entry: CacheEntry = { format: 'wincode-cache-v1', @@ -319,6 +407,13 @@ export class CacheManager { const filePath = targetFilePath; const tmpPath = `${filePath}.tmp.${Date.now()}.${crypto.randomUUID().slice(0, 8)}`; try { + if ((entry.data as { overflowPath?: unknown } | null)?.overflowPath !== undefined) { + const backingFile = await this.readBackingIdentity(entry.data); + if (backingFile) entry.backingFile = backingFile; + // Keep bounded metadata for cleanup, but do not invalidate a later accepted value. + else if (this.memoryCache.get(memKey) === entry) this.deleteMemory(memKey); + } + entry.integrity = this.entryIntegrity(memKey, entry); await this.assertDiskBoundary('', true); await this.assertReplaceable(filePath); await fs.writeFile(tmpPath, JSON.stringify(entry), { encoding: 'utf8', flag: 'wx' }); @@ -346,6 +441,15 @@ export class CacheManager { return run; } + private async discardDiskEntry(filePath: string, version: number, overflowPath?: unknown): Promise { + // Check in the writer queue: no newer local file can be published between this check and unlink. + await this.enqueueWrite(async () => { + if (version !== this.mutationVersion) return; + await this.unlinkOwned(filePath); + if (typeof overflowPath === 'string') await this.removeOverflow(overflowPath); + }); + } + /** Drain accepted writes before the gateway releases its remaining resources. */ async flush(): Promise { await this.writeChain; @@ -429,6 +533,7 @@ export class CacheManager { } private async pruneDiskCacheOnce(options?: { orphanGraceMs?: number }): Promise { + this.mutationVersion++; await this.assertDiskBoundary(); try { const files = await fs.readdir(this.cacheDir); @@ -469,7 +574,7 @@ export class CacheManager { await this.unlinkOwned(jsonPath); continue; } - const content = await fs.readFile(jsonPath, 'utf-8'); + const content = await this.readCacheJson(jsonPath); const entry: CacheEntry = JSON.parse(content); if (entry.format !== 'wincode-cache-v1' || !Number.isFinite(entry.timestamp) || !Object.hasOwn(entry, 'data')) continue; @@ -586,6 +691,7 @@ export class CacheManager { async getStats(): Promise { let diskEntries = 0; let estimatedDiskBytes = 0; + let complete = true; try { await this.assertDiskBoundary(); const files = await fs.readdir(this.cacheDir); @@ -596,23 +702,28 @@ export class CacheManager { const s = await fs.stat(path.join(this.cacheDir, f)); estimatedDiskBytes += s.size; } catch { - // skip + complete = false; } } const overflowDir = path.join(this.cacheDir, 'overflow'); await this.assertDiskBoundary('overflow'); - const overflowFiles = await fs.readdir(overflowDir).catch(() => []); + const overflowFiles = await fs.readdir(overflowDir).catch(error => { + if (error.code !== 'ENOENT') complete = false; + return []; + }); for (const of of overflowFiles) { if (!OVERFLOW_FILE.test(of)) continue; diskEntries++; try { const s = await fs.stat(path.join(overflowDir, of)); estimatedDiskBytes += s.size; - } catch {} + } catch { complete = false; } } } catch { - // unreadable cache dir + complete = false; } + this.diskStats = { diskEntries: complete ? diskEntries : null, estimatedDiskBytes: complete ? estimatedDiskBytes : null, + diskObservation: complete ? 'known' : 'incomplete', diskObservedAt: new Date().toISOString() }; return { namespace: this.namespace, memoryEntries: this.memoryCache.size, @@ -622,6 +733,11 @@ export class CacheManager { }; } + /** Passive status reads memory and the last explicit disk observation, never the filesystem. */ + getKnownStats(): KnownCacheStats { + return { namespace: this.namespace, memoryEntries: this.memoryCache.size, estimatedMemoryBytes: this.memoryBytes, ...this.diskStats }; + } + /** 保留 CacheManager 的既有入口,由独立组件管理指纹观察。 */ computeWorkspaceFingerprint(root: string, options?: { fresh?: boolean }): Promise { return this.workspaceFingerprint.computeWorkspaceFingerprint(root, options); @@ -630,6 +746,7 @@ export class CacheManager { invalidateFingerprint(root?: string): void { this.workspaceFingerprint.invalidateFingerprint(root); } async clear(): Promise { + this.mutationVersion++; this.memoryCache.clear(); this.memoryBytes = 0; this.workspaceFingerprint.reset(); diff --git a/src/Core/Config.ts b/src/Core/Config.ts index 3798245..f3d4c97 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.14.0'; +export const WINCODE_VERSION = '0.15.0'; /** * Bounded waits for every external process/RPC. None of these may be Infinity. @@ -33,7 +33,7 @@ export interface WinCodeCacheLimits { export interface RoslynConfig { enabled: boolean; allowProjectEvaluation: boolean; - /** 工作区内入口 csproj 的相对路径;工作区切换后使用新根中的同一路径。 */ + /** 固定启动工作区内入口 csproj 的相对路径。其他项目使用独立连接。 */ project: string; configuration: string; targetFramework: string; @@ -47,7 +47,8 @@ export interface RoslynConfig { } export interface WinCodeConfig { - workspaceRoot: string; + readonly workspaceRoot: string; + workspaceRootSource?: 'argument' | 'cwd' | 'configuration'; cacheDir: string; trashDir: string; maxTokensPerContext: number; @@ -105,6 +106,7 @@ export function getDefaultConfig(workspaceRoot?: string): WinCodeConfig { const cacheLimits = getDefaultCacheLimits(); return { workspaceRoot: root, + workspaceRootSource: workspaceRoot ? 'configuration' : 'cwd', cacheDir: path.join(root, '.cache', 'wincode'), trashDir: path.join(root, 'trash'), maxTokensPerContext: 128000, diff --git a/src/Core/OperationContext.ts b/src/Core/OperationContext.ts index 848fdd8..d7ecdc4 100644 --- a/src/Core/OperationContext.ts +++ b/src/Core/OperationContext.ts @@ -1,7 +1,7 @@ -import { AbortError, TimeoutError } from './ResourceManager.js'; +import { AbortError, TimeoutError, type QueueObserver } from './ResourceManager.js'; /** Internal request lifetime; never part of the public JSON tool arguments. */ -export interface OperationContext { signal?: AbortSignal; deadline?: number } +export interface OperationContext { signal?: AbortSignal; deadline?: number; queue?: QueueObserver } export function checkOperation(operation?: OperationContext): void { if (operation?.signal?.aborted) { diff --git a/src/Core/RequestAdmission.ts b/src/Core/RequestAdmission.ts new file mode 100644 index 0000000..2abfebd --- /dev/null +++ b/src/Core/RequestAdmission.ts @@ -0,0 +1,137 @@ +import { AbortError, TimeoutError, type QueueObserver } from './ResourceManager.js'; +import type { OperationContext } from './OperationContext.js'; + +export const ADMISSION_LIMITS = Object.freeze({ business: 32, status: 4, argumentBytes: 64 * 1024 }); +export type RequestLane = 'business' | 'status'; +const counters = () => ({ accepted: 0, completed: 0, rejected: 0, cancelled: 0, timedOut: 0, + peakActive: 0, waitMs: 0, executionMs: 0, maxWaitMs: 0 }); + +export class ServerBusyError extends Error { + constructor(readonly lane: RequestLane, readonly admission: ReturnType) { + super(`WinCode ${lane} request capacity is full; this call has not started. Retry later without automatic replay.`); + this.name = 'ServerBusyError'; + } +} + +export class RequestLease { + readonly signal: AbortSignal; + readonly deadline: number; + readonly queue: QueueObserver; + readonly startedAt = performance.now(); + workStarted = false; + private waits = 0; + private waitingAt = 0; + private waitedMs = 0; + private released = false; + private readonly timer: ReturnType; + + constructor(readonly lane: RequestLane, parent: AbortSignal, budgetMs: number, + private readonly finish: (lease: RequestLease, waitMs: number, elapsedMs: number, error?: unknown) => void) { + const timeout = new AbortController(); + this.signal = AbortSignal.any([parent, timeout.signal]); + this.deadline = Date.now() + budgetMs; + this.timer = setTimeout(() => timeout.abort(new TimeoutError('request', budgetMs)), budgetMs); + this.queue = { wait: () => this.wait() }; + } + + get waiting(): boolean { return this.waits > 0; } + get operation(): OperationContext { return { signal: this.signal, deadline: this.deadline, queue: this.queue }; } + wait(): () => void { + if (this.released) return () => {}; + if (this.waits++ === 0) this.waitingAt = performance.now(); + let resumed = false; + return () => { + if (resumed || this.released) return; + resumed = true; + if (--this.waits === 0) this.waitedMs += performance.now() - this.waitingAt; + }; + } + release(error?: unknown): void { + if (this.released) return; + if (this.waits) this.waitedMs += performance.now() - this.waitingAt; + this.released = true; + clearTimeout(this.timer); + this.finish(this, this.waitedMs, performance.now() - this.startedAt, error); + } +} + +interface SharedWait { + settled: boolean; + failed: boolean; + error?: unknown; + waiters: Set<() => void>; +} + +/** Counts unfinished MCP calls once, including waits inside existing adapter mutexes. */ +export class RequestAdmission { + private readonly leases = new Set(); + private readonly bySignal = new WeakMap(); + private readonly totals = { business: counters(), status: counters() }; + private readonly shared = new WeakMap, SharedWait>(); + private sharedWaiters = 0; + + get pendingCount(): number { return this.leases.size; } + operation(signal?: AbortSignal): OperationContext | undefined { return signal ? this.bySignal.get(signal)?.operation : undefined; } + + acquire(lane: RequestLane, parent: AbortSignal, budgetMs: number): RequestLease { + if (parent.aborted) throw new AbortError('Tool call cancelled before admission.'); + if (!Number.isFinite(budgetMs) || budgetMs <= 0) throw new Error('Request timeout must be finite and positive.'); + const active = [...this.leases].filter(lease => lease.lane === lane).length; + if (active >= ADMISSION_LIMITS[lane]) { + this.totals[lane].rejected++; + throw new ServerBusyError(lane, this.snapshot()); + } + const lease = new RequestLease(lane, parent, budgetMs, (finished, waitMs, elapsedMs, error) => { + this.leases.delete(finished); this.bySignal.delete(finished.signal); + const total = this.totals[lane]; + total.completed++; total.waitMs += waitMs; total.executionMs += Math.max(0, elapsedMs - waitMs); + total.maxWaitMs = Math.max(total.maxWaitMs, waitMs); + // A deadline check or shorter adapter budget may fail before the lease timer runs. + if (error instanceof TimeoutError || finished.signal.reason instanceof TimeoutError) total.timedOut++; + else if (finished.signal.aborted) total.cancelled++; + }); + this.leases.add(lease); this.bySignal.set(lease.signal, lease); + this.totals[lane].accepted++; this.totals[lane].peakActive = Math.max(this.totals[lane].peakActive, active + 1); + return lease; + } + + snapshot() { + const lane = (name: RequestLane) => { + const active = [...this.leases].filter(lease => lease.lane === name); + const waiting = active.filter(lease => lease.waiting).length; + return { limit: ADMISSION_LIMITS[name], active: active.length, executing: active.length - waiting, + waiting, ...this.totals[name] }; + }; + return { maxArgumentBytes: ADMISSION_LIMITS.argumentBytes, business: lane('business'), status: lane('status'), sharedWaiters: this.sharedWaiters }; + } + + /** One reaction per shared startup promise; cancelled calls remove their actual waiter. */ + async waitFor(promise: Promise, lease: RequestLease): Promise { + let state = this.shared.get(promise); + if (!state) { + state = { settled: false, failed: false, waiters: new Set() }; + this.shared.set(promise, state); + const observed = state; + const settle = (failed: boolean, error?: unknown) => { + observed.settled = true; observed.failed = failed; observed.error = error; + for (const complete of [...observed.waiters]) complete(); + }; + void promise.then(() => settle(false), error => settle(true, error)); + } + if (lease.signal.aborted) throw new AbortError('Tool call cancelled while waiting for startup.'); + if (state.settled) { if (state.failed) throw state.error; return; } + const observed = state; + const resume = lease.wait(); + await new Promise((resolve, reject) => { + const complete = () => { + if (!observed.waiters.delete(complete)) return; + this.sharedWaiters--; lease.signal.removeEventListener('abort', complete); resume(); + if (lease.signal.aborted) reject(new AbortError('Tool call cancelled while waiting for startup.')); + else if (observed.failed) reject(observed.error); + else resolve(); + }; + observed.waiters.add(complete); this.sharedWaiters++; + lease.signal.addEventListener('abort', complete, { once: true }); + }); + } +} diff --git a/src/Core/ResourceManager.ts b/src/Core/ResourceManager.ts index d23faff..b77f577 100644 --- a/src/Core/ResourceManager.ts +++ b/src/Core/ResourceManager.ts @@ -110,13 +110,15 @@ export class GatewayRestartRequiredError extends AggregateError { * Cancels queue waiting. Once fn starts, it owns cooperative cancellation and cleanup; * releasing this lock on abort before fn settles would permit concurrent owners. */ +export interface QueueObserver { wait(): () => void } + export class Mutex { private running = false; private readonly waiting = new Set<() => void>(); get pendingCount(): number { return this.waiting.size; } - runExclusive(fn: () => Promise, signal?: AbortSignal): Promise { + runExclusive(fn: () => Promise, signal?: AbortSignal, observer?: QueueObserver): Promise { if (signal?.aborted) { return Promise.reject( new AbortError(signal.reason ? String(signal.reason) : 'The operation was aborted') @@ -124,14 +126,17 @@ export class Mutex { } return new Promise((resolve, reject) => { + const resume = this.running ? observer?.wait() : undefined; const cancelled = () => { // Set insertion order is FIFO; removal releases the closure immediately. if (!this.waiting.delete(execute)) return; + resume?.(); signal?.removeEventListener('abort', cancelled); reject(new AbortError(signal?.reason ? String(signal.reason) : 'The operation was aborted')); }; const execute = () => { this.waiting.delete(execute); + resume?.(); signal?.removeEventListener('abort', cancelled); this.running = true; // Keep asynchronous entry and recheck cancellation before invoking work. @@ -163,9 +168,20 @@ function processExists(pid: number): boolean { catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; throw error; } } -async function waitForProcessExit(pid: number): Promise { +type OwnedProcess = ChildProcess | { + pid?: number | null; + kill?: (sig?: NodeJS.Signals) => boolean; + exitCode?: number | null; + signalCode?: NodeJS.Signals | null; +}; + +function hasExited(proc: OwnedProcess): boolean { + return proc.exitCode != null || proc.signalCode != null; +} + +async function waitForProcessExit(proc: OwnedProcess, pid: number): Promise { const deadline = Date.now() + 2000; - while (processExists(pid)) { + while (!hasExited(proc) && processExists(pid)) { if (Date.now() >= deadline) throw new Error(`Owned process ${pid} did not exit after termination.`); await new Promise(resolve => setTimeout(resolve, 20)); } @@ -178,8 +194,10 @@ async function waitForProcessExit(pid: number): Promise { * (Codex #34614, MCP typescript-sdk #2023, python-sdk #850). */ export async function killProcessTree( - proc: ChildProcess | { pid?: number | null; kill?: (sig?: NodeJS.Signals) => boolean } + proc: OwnedProcess ): Promise { + // A ChildProcess retains its old PID after exit. That number may now belong to another process. + if (hasExited(proc)) return; const pid = proc.pid; if (!pid) { try { @@ -194,12 +212,13 @@ export async function killProcessTree( if (process.platform === 'win32') { await taskkillTree(pid); + if (hasExited(proc)) return; try { proc.kill?.('SIGKILL'); } catch { // already reaped by taskkill } - await waitForProcessExit(pid); + await waitForProcessExit(proc, pid); return; } @@ -216,6 +235,7 @@ export async function killProcessTree( const timer = setTimeout(resolve, 40); timer.unref?.(); }); + if (hasExited(proc)) return; try { process.kill(-pid, 'SIGKILL'); } catch { @@ -225,7 +245,7 @@ export async function killProcessTree( // ignore } } - await waitForProcessExit(pid); + await waitForProcessExit(proc, pid); } export interface ResourceCloseResult extends ManagedResourceInfo { @@ -304,9 +324,16 @@ export class ResourceManager { registerProcess(owner: string, proc: ChildProcess): string { const id = this.register('process', owner, () => killProcessTree(proc)); - const drop = () => this.unregister(id); - proc.once('exit', drop); - proc.once('close', drop); + const drop = () => { + this.unregister(id); + proc.removeListener('exit', drop); + proc.removeListener('close', drop); + }; + if (hasExited(proc)) drop(); + else { + proc.once('exit', drop); + proc.once('close', drop); + } return id; } @@ -337,10 +364,17 @@ export class ResourceManager { const failures: Error[] = []; for (const [index, item] of items.entries()) { try { - const pending = Promise.resolve().then(() => item.dispose()); + let invoked = false; + const pending = Promise.resolve().then(() => { + // Recheck at invocation, including unregisters during the preceding await/microtask. + if (this.resources.get(item.id) !== item) return; + invoked = true; + return item.dispose(); + }); if (Number.isFinite(this.closeDeadline)) await withTimeout(pending, Math.max(1, (this.closeDeadline - Date.now()) / (items.length - index)), `close-${item.owner}`); else await pending; + if (!invoked) continue; this.resources.delete(item.id); this.recordClose(item); } catch (error) { diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index 369e0cc..99a4e41 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { WinCodeConfig, WINCODE_VERSION } from './Config.js'; -import { CacheManager, CacheStats } from './Cache.js'; +import { CacheManager, KnownCacheStats } from './Cache.js'; +import { RequestAdmission } from './RequestAdmission.js'; import { WorkspaceManager, WorkspaceOpenOptions, WorkspaceDirectoryOptions } from './Workspace.js'; import { ContextManager, PreparedContextOptions } from './Context.js'; import { RepomixAdapter } from '../Adapters/RepomixAdapter.js'; @@ -54,6 +55,7 @@ export interface RuntimeHealth { uptimeMs: number; startedAt: string; activeWorkspace: string | null; + workspaceBinding: WorkspaceManager['binding']; workspaceWatch: ReturnType; session: WorkspaceSession | null; text: { available: boolean; semanticConfigured: false; details: string }; @@ -70,7 +72,8 @@ export interface RuntimeHealth { details?: string; lastError?: AdapterLastError; }; - cache: CacheStats; + cache: KnownCacheStats; + admission: ReturnType; managedChildProcesses: number; nodeMemory: NodeJS.MemoryUsage; inFlightRequests: number; @@ -79,6 +82,7 @@ export interface RuntimeHealth { } export class ToolRouter { + readonly admission = new RequestAdmission(); readonly config: WinCodeConfig; readonly cache: CacheManager; readonly workspace: WorkspaceManager; @@ -114,10 +118,13 @@ export class ToolRouter { private async runCode(signal: AbortSignal | undefined, work: (operation: OperationContext) => Promise): Promise { const controller = new AbortController(); - const cancel = () => controller.abort(); + const cancel = () => controller.abort(signal?.reason); const budget = (this.roslyn?.operationBudgetMs ?? 0) + this.config.timeouts.fileScanMs; - const operation = { signal: controller.signal, deadline: Date.now() + budget }; - const timer = setTimeout(() => controller.abort(new TimeoutError('operation', budget)), budget); + const admitted = this.admission.operation(signal); + const operation = { signal: controller.signal, deadline: Math.min(Date.now() + budget, admitted?.deadline ?? Infinity), queue: admitted?.queue }; + // The lease already owns a shared deadline; a second timer can win with an unclassified inner abort. + const timer = admitted && operation.deadline === admitted.deadline ? undefined : + setTimeout(() => controller.abort(new TimeoutError('operation', budget)), Math.max(1, operation.deadline - Date.now())); this.codeOperations.add(controller); signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted || this.shuttingDown) cancel(); @@ -129,6 +136,8 @@ export class ToolRouter { phase: 'roslyn-cleanup', message: error.message.slice(0, 1024), recoveryAction: 'restart_gateway' }; throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); } + // Preserve a shorter operation deadline even when an adapter mutex wraps its abort reason. + checkOperation(operation); throw error; } finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); this.codeOperations.delete(controller); } @@ -189,9 +198,9 @@ export class ToolRouter { 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', + this.releasing ? 'releasing' : this.admission.pendingCount || 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, + activeRequests: Math.max(this.inFlight, this.admission.pendingCount), managedChildProcesses: this.resources.childProcessCount(), nodeRssBytes: process.memoryUsage().rss, lastError: this.workspaceRecovery?.message ?? roslyn?.health?.lastError?.message ?? null }; } @@ -201,12 +210,12 @@ export class ToolRouter { ({ 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) + if (this.releasing || this.admission.pendingCount || 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; + const canRelease = () => !this.shuttingDown && !this.workspaceRecovery && !this.admission.pendingCount && !this.inFlight && !this.codeOperations.size && !this.pendingWorkspaceChanges; if (!canRelease()) return reply('busy', '已有新任务或工作区切换,本次未释放。'); try { const status = await adapter.releaseWarmState(canRelease); @@ -248,11 +257,16 @@ export class ToolRouter { return this.runCode(signal, operation => this.impact.analyzeImpact(target, operation, location)); } - async diagnoseProject() { - const diagnostics = await this.diagnostics.runDiagnostics(); - await this.repomix.checkHealth(this.config.timeouts.repomixHealthMs); - await this.flaui.checkHealth(this.config.timeouts.healthProbeMs); - const runtime = await this.getRuntimeHealth(); + async diagnoseProject(signal?: AbortSignal) { + const operation = this.admission.operation(signal) ?? { signal }; + checkOperation(operation); + const diagnostics = await this.diagnostics.runDiagnostics(operation); + checkOperation(operation); + await this.repomix.checkHealth(this.config.timeouts.repomixHealthMs, operation); + checkOperation(operation); + await this.flaui.checkHealth(this.config.timeouts.healthProbeMs, operation); + checkOperation(operation); + const runtime = await this.getRuntimeHealth(true); return { ...diagnostics, runtime }; } @@ -269,29 +283,16 @@ export class ToolRouter { return this.runCode(signal, operation => this.workspace.listDirectory(options, operation)); } + assertWorkspace(targetPath: string): string { + return this.workspace.assertWorkspace(targetPath); + } + 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 || this.releasing) { - const barrier = this.switchingPromise ?? this.releasing!; - if (!signal) { - await barrier; - } else { - await new Promise((resolve, reject) => { - const onAbort = () => reject(new AbortError('The tool call was cancelled.')); - signal.addEventListener('abort', onAbort, { once: true }); - barrier.then( - () => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, - () => { - signal.removeEventListener('abort', onAbort); - resolve(); - } - ); - }); - } + // Reuse the cancellable FIFO instead of retaining one Promise reaction per cancelled call. + await this.workspaceLock.runExclusive(async () => {}, signal, this.admission.operation(signal)?.queue); if (signal?.aborted) throw new AbortError('The tool call was cancelled.'); } if (this.shuttingDown) throw new Error('WinCode is shutting down; tool call rejected.'); @@ -322,6 +323,8 @@ export class ToolRouter { } private async initializeOnce(): Promise { + await this.workspace.validateRoot(); + this.assertActive(); this.cache.setNamespace(this.config.workspaceRoot); this.session.open(this.config.workspaceRoot, this.cache.currentNamespace); for (const initialize of [() => this.cache.initialize(), () => this.repomix.initialize(), @@ -366,10 +369,12 @@ export class ToolRouter { } /** - * Switch the active workspace. Serialized so two MCP calls cannot interleave - * provider cleanup/reinitialization and cache namespace changes. + * Confirm or recover the fixed workspace. Serialize recovery so provider cleanup, + * cache/session renewal and watcher rebinding cannot interleave. */ async openWorkspace(targetPath: string, options: WorkspaceOpenOptions = {}, signal?: AbortSignal) { + const resolved = this.workspace.assertWorkspace(targetPath); + const queue = this.admission.operation(signal)?.queue; signal = signal ? AbortSignal.any([signal, this.shutdownSignal]) : this.shutdownSignal; this.pendingWorkspaceChanges++; return this.workspaceLock.runExclusive(async () => { @@ -379,9 +384,11 @@ export class ToolRouter { if (this.workspaceRecovery?.recoveryAction === 'restart_gateway') throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); + await this.workspace.validateRoot(); + checkOperation({ signal }); + const previousRoot = this.config.workspaceRoot; // A healthy same-root confirmation is read-only. Do not put business/status requests behind a drain barrier. - const resolved = path.relative(previousRoot, targetPath) === '' ? path.resolve(previousRoot) : path.resolve(targetPath); const knownRoslyn = this.roslyn?.getKnownHealth(); const sameWorkspace = !this.workspaceRecovery && this.watch.getStatus().active && Boolean(previousRoot) && path.resolve(previousRoot) === resolved && Boolean(this.session.current); @@ -405,12 +412,12 @@ export class ToolRouter { let rootPrepared = false; let phase = 'drain'; try { - // Wait for existing in-flight queries on the old workspace to settle before re-binding + // Recovery waits for existing requests before rebinding resources in this same workspace. const drainTimeout = this.config.timeouts?.shutdownMs ?? 8_000; const drained = await this.waitForIdle(drainTimeout, signal); if (!drained) { throw new Error( - `Workspace switch rejected: in-flight queries failed to drain within ${drainTimeout}ms (in-flight: ${this.inFlight}).` + `Workspace recovery rejected: in-flight queries failed to drain within ${drainTimeout}ms (in-flight: ${this.inFlight}).` ); } @@ -441,9 +448,7 @@ export class ToolRouter { return result; } - // Keep the process cache directory; isolate by namespace so we do not - // write `.cache/wincode` into every opened repo, and so project A - // symbols cannot be read as project B. + // Recover participants against the original root; no cross-project rebinding is permitted. phase = 'cache'; this.cache.invalidateFingerprint(previousRoot); this.cache.setNamespace(this.config.workspaceRoot); @@ -477,7 +482,7 @@ export class ToolRouter { this.workspaceRecovery = null; return result; } catch (error) { - if (rootPrepared || this.config.workspaceRoot !== previousRoot || this.workspaceRecovery) { + if (rootPrepared || !sameWorkspace || this.workspaceRecovery) { this.workspaceRecovery = { activeWorkspace: this.config.workspaceRoot, attemptedWorkspace: path.resolve(targetPath), phase, message: (error instanceof Error ? error.message : String(error)).slice(0, 1024), @@ -493,7 +498,7 @@ export class ToolRouter { this.resolveSwitching = null; resolve?.(); } - }, signal).finally(() => { this.pendingWorkspaceChanges--; }); + }, signal, queue).finally(() => { this.pendingWorkspaceChanges--; }); } private bindCompositeTools(): void { @@ -504,9 +509,9 @@ export class ToolRouter { this.diagnostics = new ProjectDiagnostics(this.workspace, this.config, this.code); } - async waitForIdle(timeoutMs: number, signal?: AbortSignal): Promise { + async waitForIdle(timeoutMs: number, signal?: AbortSignal, includeAdmission = false): Promise { const start = Date.now(); - while (this.inFlight > 0) { + while (this.inFlight > 0 || (includeAdmission && this.admission.pendingCount > 0)) { checkOperation({ signal }); if (Date.now() - start >= timeoutMs) { return false; @@ -516,14 +521,22 @@ export class ToolRouter { return true; } - async getRuntimeHealth(): Promise { + requestBudget(kind?: 'ui' | 'diagnostics' | 'workspace', args: Record = {}): number { + const timeouts = this.config.timeouts; + if (kind === 'ui') return timeouts.fileScanMs + (typeof args.timeoutMs === 'number' ? args.timeoutMs : this.config.adapters.flaui.timeoutMs ?? timeouts.flauiInspectMs); + if (kind === 'diagnostics') return timeouts.fileScanMs + timeouts.dotnetMs * 4 + timeouts.gitMs + timeouts.commandProbeMs * 4 + timeouts.repomixHealthMs + timeouts.healthProbeMs; + return timeouts.fileScanMs + (this.roslyn?.operationBudgetMs ?? 0) + (kind === 'workspace' ? timeouts.shutdownMs : 0); + } + + async getRuntimeHealth(refreshCacheStats = false): Promise { const snapshots = { text: this.text.getKnownHealth(), repomix: this.repomix.getKnownHealth(), flaui: this.flaui.getKnownHealth() }; 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), lastError: this.flaui.lastError ?? snapshots.flaui.health?.lastError }; - const cache = await this.cache.getStats(); + if (refreshCacheStats) await this.cache.getStats(); + const cache = this.cache.getKnownStats(); const lastAdapterError = this.pickLastError( { error: repomixHealth.lastError, provider: 'repomix' }, { error: flauiHealth.lastError, provider: 'flaui' }, @@ -539,6 +552,7 @@ export class ToolRouter { uptimeMs: Date.now() - this.startedAt, startedAt: new Date(this.startedAt).toISOString(), activeWorkspace: this.config.workspaceRoot, + workspaceBinding: this.workspace.binding, workspaceWatch: this.watch.getStatus(), session: this.session.current, text: { available: true, semanticConfigured: false, details: textHealth.details! }, @@ -556,6 +570,7 @@ export class ToolRouter { lastError: flauiHealth.lastError, }, cache, + admission: this.admission.snapshot(), managedChildProcesses: this.resources.childProcessCount(), nodeMemory: process.memoryUsage(), inFlightRequests: this.inFlight, @@ -581,11 +596,11 @@ export class ToolRouter { } async inspectUi(request: UiInspectRequest, signal?: AbortSignal): Promise { - return this.flaui.inspect(request, signal); + return this.flaui.inspect(request, signal, this.admission.operation(signal)); } async listUiWindows(request: import('./UiContracts.js').UiListWindowsRequest, signal?: AbortSignal): Promise { - return this.flaui.listWindows(request, signal); + return this.flaui.listWindows(request, signal, this.admission.operation(signal)); } async reviewUi(request: UiInspectRequest, candidateFiles: string[], signal?: AbortSignal, textQueries?: string[], candidateCodeFiles?: string[]): Promise { @@ -620,7 +635,7 @@ export class ToolRouter { 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()))); + const drained = await this.waitForIdle(Math.max(1, Math.min(3_000, softDeadline - Date.now())), undefined, true); if (!drained) throw new Error('Requests did not settle before shutdown.'); }, Math.min(3_000, (softDeadline - Date.now()) / 3)); const drained = failures.length === 0; diff --git a/src/Core/Workspace.ts b/src/Core/Workspace.ts index 8ecd601..dbe1b83 100644 --- a/src/Core/Workspace.ts +++ b/src/Core/Workspace.ts @@ -1,4 +1,4 @@ -import { boundedInteger, invalidTrashResult, type WorkspaceGitStatus, type WorkspaceOpenResult } from './WorkspaceContracts.js'; +import { boundedInteger, invalidTrashResult, WorkspaceMismatchError, type WorkspaceBinding, type WorkspaceGitStatus, type WorkspaceOpenResult } from './WorkspaceContracts.js'; import { identifyProject, discoverProject, getMetadata } from './ProjectDiscovery.js'; import { getDirectoryTree, listDirectory } from './WorkspaceBrowser.js'; import { isWorkspacePathInside, validateTrashPath, type TrashMoveResult, type ProjectIdentity, type WorkspaceMetadata, type WorkspaceTreeItem, type WorkspaceOpenOptions, type WorkspaceDirectoryOptions, type WorkspaceDirectoryResult } from './WorkspaceContracts.js'; @@ -13,16 +13,20 @@ import { randomUUID } from 'node:crypto'; export class WorkspaceManager { private config: WinCodeConfig; + readonly binding: WorkspaceBinding; constructor(config: WinCodeConfig) { this.config = config; + this.binding = Object.freeze({ mode: 'fixed', root: path.resolve(config.workspaceRoot), source: config.workspaceRootSource ?? 'configuration' }); + // Adapters share this configuration. Prevent internal callers from bypassing the fixed binding. + Object.defineProperty(config, 'workspaceRoot', { value: this.binding.root, enumerable: true, writable: false, configurable: false }); if (this.config.workspaceRoot && !this.config.trashDir) { this.config.trashDir = path.join(this.config.workspaceRoot, 'trash'); } } get root(): string { - return this.config.workspaceRoot; + return this.binding.root; } get trashDir(): string { @@ -64,23 +68,26 @@ export class WorkspaceManager { } } - /** - * Sets the workspace root path and synchronizes the trash directory - */ + /** Retained internal entry point: confirming the same root never changes configuration or trash. */ setRoot(newRoot: string): void { - const oldRoot = this.config.workspaceRoot ? path.resolve(this.config.workspaceRoot) : ''; - const resolvedRoot = path.resolve(newRoot); - this.config.workspaceRoot = resolvedRoot; + this.assertWorkspace(newRoot); + } - // Synchronize trashDir to the new workspace root - if (!oldRoot || !this.config.trashDir || this.isPathInside(oldRoot, this.config.trashDir) || path.resolve(this.config.trashDir) === path.join(oldRoot, 'trash')) { - const relTrash = (oldRoot && this.config.trashDir && this.isPathInside(oldRoot, this.config.trashDir)) - ? path.relative(oldRoot, this.config.trashDir) - : 'trash'; - this.config.trashDir = path.resolve(resolvedRoot, relTrash); - } else { - this.config.trashDir = path.resolve(resolvedRoot, 'trash'); - } + /** Compare normalized spellings before any filesystem access or request/resource state change. */ + assertWorkspace(targetPath: string): string { + if (typeof targetPath !== 'string' || !targetPath.trim()) throw new Error('Workspace path must be a non-empty string.'); + const requested = path.resolve(targetPath); + if (path.relative(this.root, requested) !== '') throw new WorkspaceMismatchError(this.root, requested); + return this.root; + } + + async validateRoot(): Promise { + await assertLinkFreePath(this.root); + const stat = await fs.stat(this.root).catch(error => { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; + throw error; + }); + if (!stat?.isDirectory()) throw new Error(`Invalid workspace path: "${this.root}". Directory does not exist.`); } /** @@ -145,101 +152,84 @@ export class WorkspaceManager { } /** - * Phase 2: Opens and analyzes any target workspace directory. + * Describes the bound workspace; opening another root is never a mutation path. */ async openWorkspace(targetPath: string, options: WorkspaceOpenOptions = {}): Promise { + this.assertWorkspace(targetPath); const maxOutputChars = boundedInteger(options.maxOutputChars, 8000, 2048, 32768, 'maxOutputChars'); if (options.includeTree !== undefined && typeof options.includeTree !== 'boolean') throw new Error('includeTree must be a boolean.'); - const resolvedPath = path.resolve(targetPath); - - const stat = await fs.stat(resolvedPath).catch(() => null); - if (!stat || !stat.isDirectory()) { - throw new Error(`Invalid workspace path: "${targetPath}". Directory does not exist.`); + await this.validateRoot(); + const { identity, complete, discovery, entryPoints } = await this.discoverProject(); + const git = await this.getGitStatus(); + const metadata: WorkspaceMetadata = { + totalFiles: null, totalDirectories: null, totalSizeBytes: null, + scanScope: 'project-discovery-only', maxScanDepth: discovery.maxDepth, + omittedDirectories: discovery.omissions.filter(item => ['default-ignore', 'local-dotnet-sdk', 'generated-or-work-directory'].includes(item.reason)), + omittedDirectoryCount: discovery.ignoredDirectoryCount, + targetFramework: identity.targetFramework, frameworks: identity.frameworks, + packageManagers: identity.packageManagers, solutions: identity.solutionFiles, projectList: identity.projectFiles, + projectDiscovery: discovery, + }; + const result: WorkspaceOpenResult = { + workspace: this.root, + type: identity.type, + solution: identity.primarySolution, + projects: identity.projectFiles.length, + language: identity.language, + git, + metadata, + entryPoints, projectScanComplete: complete, truncated: !complete, + limits: { maxOutputChars, includeTree: options.includeTree ?? false }, outputOmissions: [], + }; + if (discovery.omittedCount > discovery.omissions.length) { + result.outputOmissions.push('omission-details-limit'); result.truncated = true; } - - const previousRoot = this.config.workspaceRoot; - const previousTrash = this.config.trashDir; - // Metadata collection can fail after validation (e.g. the directory disappears). - // Restore both mutable paths on failure so callers never observe a rejected root. - // Same-root overview must not mutate trash/config while business requests are running. - if (path.relative(previousRoot, resolvedPath) !== '') this.setRoot(resolvedPath); - try { - const { identity, complete, discovery, entryPoints } = await this.discoverProject(); - const git = await this.getGitStatus(); - const metadata: WorkspaceMetadata = { - totalFiles: null, totalDirectories: null, totalSizeBytes: null, - scanScope: 'project-discovery-only', maxScanDepth: discovery.maxDepth, - omittedDirectories: discovery.omissions.filter(item => ['default-ignore', 'local-dotnet-sdk', 'generated-or-work-directory'].includes(item.reason)), - omittedDirectoryCount: discovery.ignoredDirectoryCount, - targetFramework: identity.targetFramework, frameworks: identity.frameworks, - packageManagers: identity.packageManagers, solutions: identity.solutionFiles, projectList: identity.projectFiles, - projectDiscovery: discovery, - }; - const result: WorkspaceOpenResult = { - workspace: this.root, - type: identity.type, - solution: identity.primarySolution, - projects: identity.projectFiles.length, - language: identity.language, - git, - metadata, - entryPoints, projectScanComplete: complete, truncated: !complete, - limits: { maxOutputChars, includeTree: options.includeTree ?? false }, outputOmissions: [], + if (options.includeTree) { + const listing = await this.listDirectory({ maxDepth: 2, maxEntries: 100, maxOutputChars }); + const fileTree: WorkspaceTreeItem = { + name: path.basename(this.root), path: this.root, relativePath: '.', type: 'directory', children: [], }; - if (discovery.omittedCount > discovery.omissions.length) { - result.outputOmissions.push('omission-details-limit'); result.truncated = true; - } - if (options.includeTree) { - const listing = await this.listDirectory({ maxDepth: 2, maxEntries: 100, maxOutputChars }); - const fileTree: WorkspaceTreeItem = { - name: path.basename(this.root), path: this.root, relativePath: '.', type: 'directory', children: [], + const nodes = new Map([['.', fileTree]]); + for (const entry of listing.entries) { + const node: WorkspaceTreeItem = { + name: path.posix.basename(entry.path), path: path.join(this.root, entry.path), relativePath: entry.path, + type: entry.type, ...(entry.type === 'directory' ? { children: [] } : {}), }; - const nodes = new Map([['.', fileTree]]); - for (const entry of listing.entries) { - const node: WorkspaceTreeItem = { - name: path.posix.basename(entry.path), path: path.join(this.root, entry.path), relativePath: entry.path, - type: entry.type, ...(entry.type === 'directory' ? { children: [] } : {}), - }; - nodes.get(path.posix.dirname(entry.path))?.children?.push(node); - nodes.set(entry.path, node); - } - fileTree.omittedDirectories = listing.omissions; - result.fileTree = fileTree; - if (listing.truncated) { result.truncated = true; result.outputOmissions.push('fileTree-bounded'); } + nodes.get(path.posix.dirname(entry.path))?.children?.push(node); + nodes.set(entry.path, node); } - const fits = () => JSON.stringify(result).length <= maxOutputChars; - if (!fits()) { - result.truncated = true; - result.outputOmissions.push('response-budget'); - const trim = (items: unknown[], field: string) => { - if (!fits() && items.length) { - result.outputOmissions.push(field); - while (!fits() && items.length) items.pop(); - } - }; - trim(result.fileTree?.omittedDirectories ?? [], 'fileTree.omittedDirectories'); - trim(result.fileTree?.children ?? [], 'fileTree.children'); - trim(discovery.omissions, 'metadata.projectDiscovery.omissions'); - trim(metadata.omittedDirectories, 'metadata.omittedDirectories'); - trim(metadata.projectList, 'metadata.projectList'); - trim(metadata.solutions, 'metadata.solutions'); - trim(metadata.frameworks, 'metadata.frameworks'); - trim(metadata.packageManagers, 'metadata.packageManagers'); - if (!fits() && metadata.targetFramework !== undefined) { - delete metadata.targetFramework; result.outputOmissions.push('metadata.targetFramework'); - } - for (const key of ['remoteUrl', 'branch', 'headCommit'] as const) { - if (!fits() && git[key] !== undefined) { delete git[key]; result.outputOmissions.push(`git.${key}`); } + fileTree.omittedDirectories = listing.omissions; + result.fileTree = fileTree; + if (listing.truncated) { result.truncated = true; result.outputOmissions.push('fileTree-bounded'); } + } + const fits = () => JSON.stringify(result).length <= maxOutputChars; + if (!fits()) { + result.truncated = true; + result.outputOmissions.push('response-budget'); + const trim = (items: unknown[], field: string) => { + if (!fits() && items.length) { + result.outputOmissions.push(field); + while (!fits() && items.length) items.pop(); } - trim(result.entryPoints, 'entryPoints'); + }; + trim(result.fileTree?.omittedDirectories ?? [], 'fileTree.omittedDirectories'); + trim(result.fileTree?.children ?? [], 'fileTree.children'); + trim(discovery.omissions, 'metadata.projectDiscovery.omissions'); + trim(metadata.omittedDirectories, 'metadata.omittedDirectories'); + trim(metadata.projectList, 'metadata.projectList'); + trim(metadata.solutions, 'metadata.solutions'); + trim(metadata.frameworks, 'metadata.frameworks'); + trim(metadata.packageManagers, 'metadata.packageManagers'); + if (!fits() && metadata.targetFramework !== undefined) { + delete metadata.targetFramework; result.outputOmissions.push('metadata.targetFramework'); } - if (!fits()) throw new Error('maxOutputChars cannot contain the workspace identity and required summary.'); - return result; - } catch (error) { - this.config.workspaceRoot = previousRoot; - this.config.trashDir = previousTrash; - throw error; + for (const key of ['remoteUrl', 'branch', 'headCommit'] as const) { + if (!fits() && git[key] !== undefined) { delete git[key]; result.outputOmissions.push(`git.${key}`); } + } + trim(result.entryPoints, 'entryPoints'); } + if (!fits()) throw new Error('maxOutputChars cannot contain the workspace identity and required summary.'); + return result; } /** diff --git a/src/Core/WorkspaceContracts.ts b/src/Core/WorkspaceContracts.ts index d395579..b2f7daf 100644 --- a/src/Core/WorkspaceContracts.ts +++ b/src/Core/WorkspaceContracts.ts @@ -1,5 +1,19 @@ import path from 'node:path'; +export interface WorkspaceBinding { + readonly mode: 'fixed'; + readonly root: string; + readonly source: 'argument' | 'cwd' | 'configuration'; +} + +export class WorkspaceMismatchError extends Error { + readonly errorCode = 'WORKSPACE_MISMATCH'; + constructor(readonly activeWorkspace: string, readonly requestedWorkspace: string) { + super(`This connection is fixed to "${activeWorkspace}". Select the connection configured for "${requestedWorkspace}"; workspace_open cannot switch projects.`); + this.name = 'WorkspaceMismatchError'; + } +} + /** 工作区公开数据与路径校验契约;不执行文件移动或改变当前根。 */ export interface TrashMoveResult { success: boolean; diff --git a/src/Gateway/CodeTools.ts b/src/Gateway/CodeTools.ts index 2b14fc5..f6e6789 100644 --- a/src/Gateway/CodeTools.ts +++ b/src/Gateway/CodeTools.ts @@ -7,7 +7,7 @@ import type { SymbolLocation } from '../Core/CodeQueries.js'; const symbolLocationSchema = { type: 'object', additionalProperties: true, required: ['snapshotId', 'project', 'file', 'position'], properties: { - snapshotId: { type: 'string', pattern: '^[a-f0-9]{32}$' }, + snapshotId: { type: 'string', minLength: 32, maxLength: 32, pattern: '^[a-f0-9]{32}$' }, project: { type: 'string', minLength: 1, maxLength: 4096 }, file: { type: 'string', minLength: 1, maxLength: 4096 }, position: { type: 'integer', minimum: 0 }, @@ -92,16 +92,16 @@ export const CODE_TOOLS = [ }), defineTool<{ query: string; kind?: string }>({ name: 'wincode_find_code_symbol', - description: 'Locates code declarations with signatures and positions using the configured provider. Direct Roslyn returns snapshot-bound location objects for exact reference selection; old locations expire after edits/reloads/switches. Inspect source, queryComplete, truncation and limitations.', + description: 'Locates code declarations with signatures and positions using the configured provider. Direct Roslyn returns snapshot-bound location objects for exact reference selection; old locations expire after edits/reloads or changing connections. Inspect source, queryComplete, truncation and limitations.', inputSchema: { type: 'object', additionalProperties: true, properties: { query: { - type: 'string', minLength: 1, pattern: '\\S', + type: 'string', minLength: 1, maxLength: 256, pattern: '\\S', description: 'Symbol name or search query.', }, kind: { - type: 'string', + type: 'string', maxLength: 128, description: 'Optional filter: class, interface, method, function, type, enum.', }, }, @@ -117,11 +117,11 @@ export const CODE_TOOLS = [ type: 'object', additionalProperties: true, properties: { symbolName: { - type: 'string', minLength: 1, pattern: '\\S', + type: 'string', minLength: 1, maxLength: 256, pattern: '\\S', description: 'Plain symbol name. With Roslyn, select a returned symbolLocation to identify an overload. Old Serena namePath identities are retired.', }, relativePath: { - type: 'string', + type: 'string', maxLength: 4096, description: 'Defining file relative to the workspace. Roslyn uses it to scope candidates; local text references remain a workspace-wide textual scan.', }, symbolLocation: symbolLocationSchema, @@ -141,7 +141,7 @@ export const CODE_TOOLS = [ properties: { symbolLocation: symbolLocationSchema, target: { - type: 'string', minLength: 1, pattern: '\\S', + type: 'string', minLength: 1, maxLength: 4096, pattern: '\\S', description: 'Name of the class, component, or file to evaluate (e.g. "MemoryService" or "MemoryService.cs").', }, }, @@ -164,11 +164,11 @@ export const CODE_TOOLS = [ properties: { symbolLocation: symbolLocationSchema, target: { - type: 'string', minLength: 1, pattern: '\\S', + type: 'string', minLength: 1, maxLength: 4096, pattern: '\\S', description: 'Component or symbol name to refactor.', }, goal: { - type: 'string', minLength: 1, pattern: '\\S', + type: 'string', minLength: 1, maxLength: 8192, pattern: '\\S', description: 'Goal or rationale for the refactoring.', }, }, diff --git a/src/Gateway/McpServer.ts b/src/Gateway/McpServer.ts index 38a5114..9de5e23 100644 --- a/src/Gateway/McpServer.ts +++ b/src/Gateway/McpServer.ts @@ -2,8 +2,11 @@ import { Server, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import { ToolRouter, WorkspaceRecoveryRequiredError } from '../Core/ToolRouter.js'; import { WINCODE_VERSION } from '../Core/Config.js'; -import { AbortError } from '../Core/ResourceManager.js'; +import { AbortError, TimeoutError } from '../Core/ResourceManager.js'; +import { ADMISSION_LIMITS, RequestLease, ServerBusyError } from '../Core/RequestAdmission.js'; +import { checkOperation } from '../Core/OperationContext.js'; import { CodeQueryError } from '../Core/CodeQueries.js'; +import { WorkspaceMismatchError } from '../Core/WorkspaceContracts.js'; import { ToolRegistry } from './ToolRegistry.js'; import { toolErrorResult, codeRecoveryAction, type ToolExecutionContext } from './ToolDefinition.js'; @@ -21,11 +24,21 @@ export class WinCodeMcpServer { } private registerHandlers(): void { - this.server.setRequestHandler('tools/list', async () => ({ tools: this.registry.list() })); + this.server.setRequestHandler('tools/list', async (_request, ctx) => { + let lease: RequestLease | undefined; + try { + const signal = ctx.mcpReq.signal ? AbortSignal.any([ctx.mcpReq.signal, this.router.shutdownSignal]) : this.router.shutdownSignal; + lease = this.router.admission.acquire('status', signal, this.router.config.timeouts.commandProbeMs); + return { tools: this.registry.list() }; + } catch (error) { + if (error instanceof ServerBusyError) throw new ProtocolError(ProtocolErrorCode.InternalError, error.message, + { errorCode: 'SERVER_BUSY', workStarted: false, retryable: true, admission: error.admission }); + throw error; + } finally { lease?.release(); } + }); this.server.setRequestHandler('tools/call', async (request, ctx) => { - await this.startPromise?.catch(() => {}); const { name, arguments: input = {} } = request.params; - const signal = ctx.mcpReq.signal ? AbortSignal.any([ctx.mcpReq.signal, this.router.shutdownSignal]) : this.router.shutdownSignal; + let signal = ctx.mcpReq.signal ? AbortSignal.any([ctx.mcpReq.signal, this.router.shutdownSignal]) : this.router.shutdownSignal; if (this.router.isShuttingDown || signal?.aborted) { return toolErrorResult(this.router.isShuttingDown ? 'SHUTDOWN' : 'CANCELLED', this.router.isShuttingDown ? 'WinCode is shutting down; tool call rejected.' : 'Tool call was cancelled.', @@ -36,22 +49,47 @@ export class WinCodeMcpServer { const context: ToolExecutionContext = { router: this.router, signal, tools: this.registry.list(), schemaHash: this.registry.schemaHash }; let args: Record; try { + const argumentBytes = Buffer.byteLength(JSON.stringify(input), 'utf8'); + if (argumentBytes > ADMISSION_LIMITS.argumentBytes) return toolErrorResult('INVALID_ARGUMENT', + 'Tool arguments exceed the UTF-8 serialization budget; reduce the input.', 'correct_arguments', + { argumentBytes, maxArgumentBytes: ADMISSION_LIMITS.argumentBytes }); args = this.registry.prepare(name, input, context); } catch (error) { const message = error instanceof Error ? error.message : String(error); return definition.invalidArguments?.(message) ?? toolErrorResult('INVALID_ARGUMENT', message, 'correct_arguments'); } let acquired = false; + let lease: RequestLease | undefined; + let failure: unknown; try { - if (!definition.switchesWorkspace) { + if (definition.workspaceControl) this.router.assertWorkspace(args.path as string); + const lane = definition.requestLane ?? 'business'; + lease = this.router.admission.acquire(lane, signal, lane === 'status' + ? this.router.config.timeouts.commandProbeMs : this.router.requestBudget(definition.requestBudget, args)); + signal = lease.signal; context.signal = signal; + if (lane !== 'status' && this.startPromise) await this.router.admission.waitFor(this.startPromise, lease); + if (lane !== 'status' && !definition.workspaceControl) { await this.router.acquireRequestSlot(signal, definition.allowDuringWorkspaceRecovery); acquired = true; } - return await definition.execute(args, context); + checkOperation(lease.operation); + lease.workStarted = true; + const result = await definition.execute(args, context); + checkOperation(lease.operation); + return result; } catch (error) { - // 根变化后的失败必须携带真实恢复状态;取消不能掩盖已发生的部分状态变更。 + failure = error; + if (error instanceof ServerBusyError) return toolErrorResult('SERVER_BUSY', error.message, 'retry_later', + { workStarted: false, retryable: true, lane: error.lane, admission: error.admission }); + if (error instanceof WorkspaceMismatchError) + return toolErrorResult(error.errorCode, error.message, 'select_workspace_connection', + { activeWorkspace: error.activeWorkspace, requestedWorkspace: error.requestedWorkspace }); + // 同根恢复失败必须携带真实恢复状态;取消不能掩盖已发生的部分状态变更。 const recovery = error instanceof WorkspaceRecoveryRequiredError ? error.recovery : this.router.workspaceRecoveryState; const details = recovery ? { workspaceRecovery: recovery } : {}; + if (signal.reason instanceof TimeoutError || error instanceof TimeoutError) + return toolErrorResult('REQUEST_TIMEOUT', 'Tool request deadline exceeded, including queue wait.', + recovery?.recoveryAction ?? 'inspect_error', { ...details, workStarted: lease?.workStarted ?? false, retryable: false }); if (error instanceof AbortError || (error instanceof Error && error.name === 'AbortError') || signal?.aborted) return toolErrorResult('CANCELLED', 'Tool call was cancelled.', recovery?.recoveryAction ?? 'none', details); if (error instanceof WorkspaceRecoveryRequiredError) @@ -63,6 +101,7 @@ export class WinCodeMcpServer { recovery?.recoveryAction ?? (this.router.isShuttingDown ? 'restart_gateway' : 'inspect_error'), details); } finally { if (acquired) this.router.endRequest(); + lease?.release(failure); } }); } diff --git a/src/Gateway/ToolDefinition.ts b/src/Gateway/ToolDefinition.ts index b85f06e..dd2e538 100644 --- a/src/Gateway/ToolDefinition.ts +++ b/src/Gateway/ToolDefinition.ts @@ -11,8 +11,10 @@ export interface ToolExecutionContext { export interface ToolDefinition { tool: Tool; aliases?: Array<{ name: string; listed: boolean; description?: string }>; - switchesWorkspace?: boolean; + workspaceControl?: boolean; allowDuringWorkspaceRecovery?: boolean; + requestLane?: 'status'; + requestBudget?: 'ui' | 'diagnostics' | 'workspace'; invalidArguments?: (message: string) => CallToolResult; validate?: (args: Record, context: ToolExecutionContext) => void; execute: (args: Record, context: ToolExecutionContext) => Promise; diff --git a/src/Gateway/UiTools.ts b/src/Gateway/UiTools.ts index 182cc2b..4dc9d98 100644 --- a/src/Gateway/UiTools.ts +++ b/src/Gateway/UiTools.ts @@ -29,7 +29,7 @@ const inspectDefinition = defineTool({ description: 'Process ID of the target Windows desktop application.', }, hwnd: { - type: 'string', + type: 'string', maxLength: 32, description: 'Window handle of the target window (hex e.g. "0x00120ABC" or decimal string).', }, capture: { @@ -77,6 +77,7 @@ const inspectDefinition = defineTool({ }, }, { invalidArguments, validate: validateInspect, + requestBudget: 'ui', execute: async (args, { router, signal }) => uiResponse(await router.inspectUi({ ...args, hwnd: args.hwnd?.trim() }, signal)), }); const uiInspectTool = inspectDefinition.tool; @@ -95,6 +96,7 @@ export const UI_TOOLS = [ }, { invalidArguments, validate: args => validateWindowQuery(args), + requestBudget: 'ui', execute: async (args, { router, signal }) => { const result = await router.listUiWindows(args, signal); const text = JSON.stringify(result); @@ -137,6 +139,7 @@ export const UI_TOOLS = [ validateCandidateCodeFiles(args.candidateCodeFiles); validateTextQueries(args.textQueries); }, + requestBudget: 'ui', execute: async (args, { router, signal }) => { const { candidateFiles, candidateCodeFiles, textQueries, ...input } = args; return uiResponse(await router.reviewUi({ ...input, hwnd: input.hwnd?.trim() }, candidateFiles, signal, textQueries, candidateCodeFiles)); diff --git a/src/Gateway/WorkspaceTools.ts b/src/Gateway/WorkspaceTools.ts index 8a36850..39e5617 100644 --- a/src/Gateway/WorkspaceTools.ts +++ b/src/Gateway/WorkspaceTools.ts @@ -7,7 +7,7 @@ import { contractHash } from './ContractHash.js'; export const WORKSPACE_TOOLS = [ defineTool({ name: 'workspace_open', - description: 'Opens a workspace and returns a compact project summary and at most 8 entry paths. Reopening the same healthy workspace preserves the Roslyn Host and snapshot; known HOST_RESTART_REQUIRED or workspace recovery still requires explicit recovery. Default output is bounded to 8000 UTF-16 characters; counts describe bounded discovery, not a complete inventory. Directory tree is opt-in and bounded; use wincode_list_directory for focused browsing.', + description: 'Confirms or recovers the workspace fixed at Gateway startup and returns a compact project summary with at most 8 entry paths. Another root is rejected with WORKSPACE_MISMATCH before any state change; select that project connection instead. Healthy same-root confirmation preserves the Roslyn Host and snapshot; known HOST_RESTART_REQUIRED or workspace recovery still requires explicit recovery. Default output is bounded to 8000 UTF-16 characters; counts describe bounded discovery, not a complete inventory. Directory tree is opt-in and bounded; use wincode_list_directory for focused browsing.', inputSchema: { type: 'object', additionalProperties: true, @@ -15,7 +15,7 @@ export const WORKSPACE_TOOLS = [ path: { type: 'string', minLength: 1, maxLength: 4096, - description: 'Path to the workspace project directory to open.', + description: 'Absolute path of this connection\'s startup workspace. Another project requires its own connection.', }, includeTree: { type: 'boolean', default: false, description: 'Include a bounded compatibility directory tree. Never an unbounded inventory.' }, maxOutputChars: { type: 'integer', minimum: 2048, maximum: 32768, default: 8000, description: 'Budget for the entire compact JSON text including escaping and metadata; not model tokens.' }, @@ -23,7 +23,7 @@ export const WORKSPACE_TOOLS = [ required: ['path'], }, }, { - aliases: [{ name: 'wincode_workspace_open', listed: false }], switchesWorkspace: true, + aliases: [{ name: 'wincode_workspace_open', listed: false }], workspaceControl: true, requestBudget: 'workspace', validate: args => { if (!args.path.trim()) throw new Error('path must not be blank.'); }, execute: async (args, { router, signal }) => jsonResult(await router.openWorkspace(args.path, { includeTree: args.includeTree, maxOutputChars: args.maxOutputChars, @@ -64,6 +64,7 @@ export const WORKSPACE_TOOLS = [ }, }, { allowDuringWorkspaceRecovery: true, + requestLane: 'status', validate: (args, context) => { if (args.toolName !== undefined && !context.tools.some(tool => tool.name === args.toolName)) throw new Error(`Tool is not registered in this instance: ${args.toolName}`); @@ -111,7 +112,8 @@ export const WORKSPACE_TOOLS = [ properties: {}, }, }, { - execute: async (_args, { router }) => jsonResult(await router.diagnoseProject(), true), + requestBudget: 'diagnostics', + execute: async (_args, { router, signal }) => jsonResult(await router.diagnoseProject(signal), true), }), defineTool<{ filePath: string; reason?: string }>({ name: 'wincode_safe_move_to_trash', @@ -120,11 +122,11 @@ export const WORKSPACE_TOOLS = [ type: 'object', additionalProperties: true, properties: { filePath: { - type: 'string', minLength: 1, pattern: '\\S', + type: 'string', minLength: 1, maxLength: 4096, pattern: '\\S', description: 'Non-empty relative path of the file within the current workspace to safely move to trash. Absolute paths and drive-relative paths (e.g., C:foo) are strictly rejected.', }, reason: { - type: 'string', + type: 'string', maxLength: 4096, description: 'Reason for removal.', }, }, diff --git a/src/index.ts b/src/index.ts index 3953d0e..f373870 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,17 +9,19 @@ import { resolveTrayEndpoint, TrayClient } from './Gateway/TrayClient.js'; async function main() { let workspaceRoot = process.cwd(); + let workspaceRootSource: 'argument' | 'cwd' = 'cwd'; const args = process.argv.slice(2); for (let i = 0; i < args.length; i++) { if (args[i] === '--workspace' || args[i] === '-w') { - if (args[i + 1]) { - workspaceRoot = args[i + 1]; - i++; - } + const target = args[++i]; + if (!target || !path.isAbsolute(target)) throw new Error('--workspace requires an absolute directory path.'); + workspaceRoot = target; + workspaceRootSource = 'argument'; } } const config = getDefaultConfig(workspaceRoot); + config.workspaceRootSource = workspaceRootSource; // 此文件是用户显式选择的启动配置,不从目标仓库自动发现或接受 MCP 参数指定执行程序。 const roslynIndex = args.indexOf('--roslyn-config'); if (roslynIndex >= 0) { diff --git a/tests/check-reporting.test.ts b/tests/check-reporting.test.ts new file mode 100644 index 0000000..813c02c --- /dev/null +++ b/tests/check-reporting.test.ts @@ -0,0 +1,80 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const { runCheckStage, testReporters, testTotals } = await import(pathToFileURL(path.resolve('scripts/lib/check-stage.mjs')).href); + +async function fixture(run: (directory: string, report: any, env: NodeJS.ProcessEnv) => Promise) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-check-report-')); + const env = { ...process.env }; + delete env.NODE_TEST_CONTEXT; + try { await run(directory, { stages: [] }, env); } + finally { + assert.equal(path.dirname(directory), path.resolve(os.tmpdir())); + assert.ok(path.basename(directory).startsWith('wincode-check-report-')); + await fs.rm(directory, { recursive: true, force: true }); + } +} + +it('failed checks preserve an early real assertion, full TAP totals, and native JUnit details', async () => fixture(async (directory, report, env) => { + const file = path.join(directory, 'failure.test.mjs'); + await fs.writeFile(file, `import {it} from 'node:test'; import assert from 'node:assert/strict'; +it('early assertion <&', () => assert.equal('actual-marker', 'expected-marker')); +for (let i=0;i<60;i++) it('later passing test '+i+' padding '.repeat(10), () => {}); +it.skip('deliberate skip', () => {});`); + await assert.rejects(runCheckStage({ directory, root: directory, report, name: 'regression', command: process.execPath, + args: ['--test', ...testReporters(directory, 'regression'), file], env }), /regression failed/); + assert.deepEqual(report.tests, { tests: 62, pass: 60, fail: 1, cancelled: 0, skipped: 1 }); + const stage = report.stages[0]; + assert.equal(stage.success, false); + assert.equal(stage.exitCode, 1); + assert.equal(stage.testSummaryComplete, true); + assert.equal(stage.outputCaptureComplete, true); + const log = await fs.readFile(path.join(directory, stage.logFile), 'utf8'); + assert.match(log, /not ok 1 - early assertion/); + assert.match(log, /actual-marker/); + assert.doesNotMatch(stage.error, /actual-marker/); // Failure was deliberately placed before the old tail-only report. + const xml = await fs.readFile(path.join(directory, stage.junitFile), 'utf8'); + assert.match(xml, / fixture(async (directory, report, env) => { + const file = path.join(directory, 'passing.test.mjs'); + await fs.writeFile(file, "import {it} from 'node:test'; it('passing', () => {});"); + await runCheckStage({ directory, root: directory, report, name: 'regression', command: process.execPath, + args: ['--test', ...testReporters(directory, 'regression'), file], env }); + assert.equal(report.stages[0].success, true); + assert.equal(report.tests.pass, 1); + assert.equal(report.stages[0].junitFile, 'regression.xml'); +})); + +it('a zero exit with missing TAP totals is not treated as a passing suite', async () => fixture(async (directory, report, env) => { + const file = path.join(directory, 'incomplete.mjs'); + await fs.writeFile(file, "console.log('TAP version 13');"); + await assert.rejects(runCheckStage({ directory, root: directory, report, name: 'regression', command: process.execPath, + args: [file, '--test'], env }), /regression failed/); + assert.equal(report.stages[0].exitCode, 0); + assert.match(report.stages[0].error, /complete TAP summary/); + assert.equal(report.tests, null); + assert.equal(report.stages[0].testSummaryComplete, false); + assert.equal(report.stages[0].junitFile, null); + assert.equal(testTotals('TAP version 13\n# tests 1\n'), null); +})); + +it('capture overflow and launch failures retain explicit process errors', async () => fixture(async (directory, report, env) => { + await assert.rejects(runCheckStage({ directory, root: directory, report, name: 'overflow', command: process.execPath, + args: ['-e', "require('node:fs').writeSync(1,'x'.repeat(256*1024))"], env, maxBuffer: 1024 }), /overflow failed/); + assert.equal(report.stages[0].outputCaptureComplete, false); + assert.equal(report.stages[0].processError.code, 'ENOBUFS'); + await assert.rejects(runCheckStage({ directory, root: directory, report, name: 'launch', + command: path.join(directory, 'missing-program'), args: [], env }), /launch failed/); + assert.equal(report.stages[1].processError.code, 'ENOENT'); + assert.equal(report.stages[1].exitCode, null); + assert.equal(report.stages[1].outputCaptureComplete, false); + assert.equal(await fs.readFile(path.join(directory, 'launch.log'), 'utf8'), ''); +})); diff --git a/tests/design-time-artifacts.test.ts b/tests/design-time-artifacts.test.ts new file mode 100644 index 0000000..c374c24 --- /dev/null +++ b/tests/design-time-artifacts.test.ts @@ -0,0 +1,62 @@ +import { it, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { cleanupDesignTimeArtifacts } from '../src/Adapters/DesignTimeArtifacts.js'; + +const parent = path.resolve(import.meta.dirname, '../test-tmp'); +const first = 'a'.repeat(32), second = 'b'.repeat(32); +async function fixture(t: TestContext) { + await fs.mkdir(parent, { recursive: true }); + const directory = await fs.mkdtemp(path.join(parent, 'output-owner-')); + t.after(async () => { assert.equal(path.dirname(directory), parent); await fs.rm(directory, { recursive: true, force: true }); }); + const root = path.join(directory, 'workspace'); await fs.mkdir(root); + return { directory, root }; +} +async function owner(root: string, id: string, paths = [`.cache/wincode-msbuild/${id}`]) { + const storage = path.join(root, '.cache/wincode-build', id); await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(path.join(storage, 'owner.json'), JSON.stringify({ version: 1, instance: id, paths })); + return storage; +} +async function output(root: string, id: string) { + const directory = path.join(root, '.cache/wincode-msbuild', id); await fs.mkdir(directory, { recursive: true }); + const file = path.join(directory, 'generated.cs'); await fs.writeFile(file, id); return file; +} +it('reclaims only the exited owner and preserves peer contents; repeat cleanup is harmless', async t => { + const { root } = await fixture(t); + await owner(root, first); await owner(root, second); + const a = await output(root, first), b = await output(root, second); + await cleanupDesignTimeArtifacts(root.replaceAll('\\', '/'), first); + await assert.rejects(fs.stat(a), { code: 'ENOENT' }); assert.equal(await fs.readFile(b, 'utf8'), second); + await cleanupDesignTimeArtifacts(root, first); +}); +it('validates every ownership path before removing anything', async t => { + const { root } = await fixture(t); + await owner(root, first, [`.cache/wincode-msbuild/${first}`, `.cache/wincode-msbuild/${second}`]); + const a = await output(root, first), b = await output(root, second); + await assert.rejects(cleanupDesignTimeArtifacts(root, first), /identity/); + assert.equal(await fs.readFile(a, 'utf8'), first); assert.equal(await fs.readFile(b, 'utf8'), second); +}); +it('rejects a root escape even when the final namespace matches the UUID', async t => { + const { root, directory } = await fixture(t); + const external = path.join(directory, 'external'); await fs.mkdir(external); + const sentinel = await output(external, first); + await owner(root, first, [`../external/.cache/wincode-msbuild/${first}`]); + await assert.rejects(cleanupDesignTimeArtifacts(root, first), /escaped/); + assert.equal(await fs.readFile(sentinel, 'utf8'), first); +}); +it('rejects junctions without following or deleting their target', async t => { + const { root, directory } = await fixture(t); + const external = path.join(directory, 'external'); await fs.mkdir(external); + const sentinel = path.join(external, 'sentinel'); await fs.writeFile(sentinel, 'keep'); + await owner(root, first); await fs.mkdir(path.join(root, '.cache/wincode-msbuild'), { recursive: true }); + await fs.symlink(external, path.join(root, '.cache/wincode-msbuild', first), 'junction'); + await assert.rejects(cleanupDesignTimeArtifacts(root, first), /linked/); + assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep'); +}); +it('a corrupt manifest retains the output and reports failure', async t => { + const { root } = await fixture(t); const storage = await owner(root, first); + const file = await output(root, first); await fs.writeFile(path.join(storage, 'owner.json'), '{'); + await assert.rejects(cleanupDesignTimeArtifacts(root, first)); + assert.equal(await fs.readFile(file, 'utf8'), first); +}); diff --git a/tests/failure-recovery.test.ts b/tests/failure-recovery.test.ts index 5f64125..20bef42 100644 --- a/tests/failure-recovery.test.ts +++ b/tests/failure-recovery.test.ts @@ -41,17 +41,34 @@ async function fixture(run: (router: ToolRouter, a: string, b: string, client: C const body = (result: any) => JSON.parse(result.content[0].text); -it('native watcher creation failure blocks queries and a later open recreates the watcher', async t => fixture(async (router, _a, b) => { +for (const stage of ['fingerprint', 'overview'] as const) { + it(`same-root recovery ${stage} failure keeps business calls blocked until repair`, async t => fixture(async (router, a) => { + await (router as any).watch.stop(); + const target = stage === 'fingerprint' ? router.cache : router.workspace; + const method = stage === 'fingerprint' ? 'computeWorkspaceFingerprint' : 'openWorkspace'; + const fault = t.mock.method(target as any, method, async () => { throw new Error(`fixture ${stage} failure`); }); + try { await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); } + finally { fault.mock.restore(); } + assert.equal(router.config.workspaceRoot, a); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + await router.openWorkspace(a); + await router.acquireRequestSlot(); router.endRequest(); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.root, a); + })); +} + +it('native watcher creation failure blocks queries and a later open recreates the watcher', async t => fixture(async (router, a) => { + await (router as any).watch.stop(); const failed = t.mock.method(nativeFs, 'watch', () => { throw new Error('fixture native watch creation failure'); }); - try { await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); } + try { await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); } finally { failed.mock.restore(); } assert.equal(router.workspaceRecoveryState?.recoveryAction, 'workspace_open'); assert.equal((await router.getRuntimeHealth()).workspaceWatch.active, false); await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); - await router.openWorkspace(b); + await router.openWorkspace(a); assert.equal(router.workspaceRecoveryState, null); assert.equal((await router.getRuntimeHealth()).workspaceWatch.active, true); - assert.equal((await router.getRuntimeHealth()).workspaceWatch.root, b); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.root, a); })); it('a retained native watcher close failure requires restart and is not advertised as reopenable', async t => { @@ -59,17 +76,20 @@ it('a retained native watcher close failure requires restart and is not advertis native.close = () => { throw new Error('fixture native close failure'); }; const mock = t.mock.method(nativeFs, 'watch', () => native); try { - await fixture(async (router, _a, b) => { - await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); + await fixture(async (router, a, b) => { + native.emit('error', new Error('fixture watcher error before failed close')); + await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); assert.equal(router.workspaceRecoveryState?.recoveryAction, 'restart_gateway'); const sessionId = router.session.current?.id; - await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); + await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); + await assert.rejects(router.openWorkspace(b), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); assert.equal(router.session.current?.id, sessionId); }, true); } finally { mock.mock.restore(); } }); -it('watcher failure during adapter initialization cannot commit a successful workspace switch', async t => fixture(async (router, _a, b) => { +it('watcher failure during adapter initialization cannot commit a successful same-root recovery', async t => fixture(async (router, a) => { + await (router as any).watch.stop(); const original = nativeFs.watch; let targetWatch: nativeFs.FSWatcher | undefined; t.mock.method(nativeFs, 'watch', (...args: Parameters) => { targetWatch = original(...args); return targetWatch; }); @@ -78,17 +98,17 @@ it('watcher failure during adapter initialization cannot commit a successful wor await initialize(); targetWatch!.emit('error', new Error('fixture asynchronous watch failure')); }); - try { await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); } + try { await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); } finally { fault.mock.restore(); } assert.equal(router.workspaceRecoveryState?.phase, 'watch-confirmation'); await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); - await router.openWorkspace(b); + await router.openWorkspace(a); assert.equal(router.workspaceRecoveryState, null); })); it('invalid target preserves the old workspace and still admits requests', async () => fixture(async (router, a, b) => { const before = router.session.current?.id; - await assert.rejects(router.openWorkspace(path.join(b, 'missing')), /Invalid workspace/); + await assert.rejects(router.openWorkspace(path.join(b, 'missing')), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); assert.equal(router.config.workspaceRoot, a); assert.equal(router.session.current?.id, before); assert.equal(router.workspaceRecoveryState, null); @@ -98,6 +118,7 @@ it('invalid target preserves the old workspace and still admits requests', async for (const stage of ['namespace', 'session', 'watch', 'dispose', 'initialize', 'text', 'composites']) { it(`failure at ${stage} blocks queries; same-root recovery performs a full rebind`, async t => fixture(async (router, a, b, client) => { await router.cache.set('isolation', 'A'); + await (router as any).watch.stop(); const targets: Record = { namespace: [router.cache, 'setNamespace'], session: [router.session, 'open'], watch: [router, 'bindWatch'], dispose: [router.repomix, 'dispose'], @@ -106,9 +127,9 @@ for (const stage of ['namespace', 'session', 'watch', 'dispose', 'initialize', ' }; const [target, method] = targets[stage]; const fault = t.mock.method(target, method, () => { throw new Error(`fixture:${stage}`); }); - try { await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); } + try { await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); } finally { fault.mock.restore(); } - assert.equal(router.config.workspaceRoot, b); + assert.equal(router.config.workspaceRoot, a); await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); assert.equal(router.inFlightRequests, 0); const query = await client.callTool({ name: 'wincode_list_directory', arguments: {} }); @@ -122,24 +143,25 @@ for (const stage of ['namespace', 'session', 'watch', 'dispose', 'initialize', ' await assert.rejects(router.openWorkspace(path.join(b, 'missing'))); await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); const resets = t.mock.method(router.text, 'initialize', router.text.initialize.bind(router.text)); - const opened = await client.callTool({ name: 'wincode_workspace_open', arguments: { path: b } }); + const opened = await client.callTool({ name: 'wincode_workspace_open', arguments: { path: a } }); assert.notEqual(opened.isError, true); // 同根恢复仍须重新绑定本地能力;外部连接已退出。 assert.equal(resets.mock.callCount(), 1); assert.equal(router.workspaceRecoveryState, null); const health = await router.getRuntimeHealth(); - assert.equal(health.session?.workspaceRoot, b); - assert.equal(health.workspaceWatch.root, b); + assert.equal(health.session?.workspaceRoot, a); + assert.equal(health.workspaceWatch.root, a); assert.equal(health.session?.cacheNamespace, router.cache.currentNamespace); - assert.equal(await router.cache.get('isolation'), null); + assert.equal(await router.cache.get('isolation'), 'A', 'same-root recovery retains correctly namespaced disk entries'); const listing = body(await client.callTool({ name: 'wincode_list_directory', arguments: {} })); - assert.ok(listing.entries.some((entry: any) => entry.path === 'OnlyB.cs')); - assert.ok(!listing.entries.some((entry: any) => entry.path === 'OnlyA.cs')); + assert.ok(listing.entries.some((entry: any) => entry.path === 'OnlyA.cs')); + assert.ok(!listing.entries.some((entry: any) => entry.path === 'OnlyB.cs')); await router.openWorkspace(a); })); } -it('cancellation after root preparation rejects queued queries until recovery', async t => fixture(async (router, a, b) => { +it('cancellation during same-root recovery rejects queued queries until recovery', async t => fixture(async (router, a) => { + await (router as any).watch.stop(); const controller = new AbortController(); const original = router.workspace.openWorkspace.bind(router.workspace); let entered!: () => void, release!: () => void; @@ -148,12 +170,12 @@ it('cancellation after root preparation rejects queued queries until recovery', const fault = t.mock.method(router.workspace, 'openWorkspace', async (...args: Parameters) => { const result = await original(...args); entered(); await proceed; return result; }); - const switching = assert.rejects(router.openWorkspace(b, {}, controller.signal), /cancel|abort/i); + const switching = assert.rejects(router.openWorkspace(a, {}, controller.signal), /cancel|abort/i); await started; const query = assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); controller.abort(); release(); try { await Promise.all([switching, query]); } finally { fault.mock.restore(); } - assert.equal(router.config.workspaceRoot, b); + assert.equal(router.config.workspaceRoot, a); assert.equal(router.workspaceRecoveryState?.phase, 'workspace'); await router.openWorkspace(a); await router.acquireRequestSlot(); router.endRequest(); diff --git a/tests/fixed-workspace.test.ts b/tests/fixed-workspace.test.ts new file mode 100644 index 0000000..2fa8d22 --- /dev/null +++ b/tests/fixed-workspace.test.ts @@ -0,0 +1,184 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { WorkspaceManager } from '../src/Core/Workspace.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; + +const body = (result: any) => JSON.parse(result.content[0].text); +const mismatch = (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'; + +async function fixture(run: (a: string, b: string, connect: (root: string) => Promise) => Promise) { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-fixed-')); + const a = path.join(parent, '项目 A'), b = path.join(parent, '项目 B'); + const connections: Array<{ client: Client; server: WinCodeMcpServer }> = []; + for (const [root, marker] of [[a, 'ONLY_A'], [b, 'ONLY_B']]) { + await fs.mkdir(root); + await fs.writeFile(path.join(root, 'Api.cs'), `public class Api { public string Marker = "${marker}"; }\n`); + } + const connect = async (root: string) => { + const config = getDefaultConfig(root); + config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; + const router = new ToolRouter(config), server = new WinCodeMcpServer(router); + const client = new Client({ name: 'fixed-workspace-fixture', version: '1' }); + connections.push({ client, server }); + await router.initialize(); + const [left, right] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(left), (server as any).server.connect(right)]); + const call = (name: string, args: Record = {}) => client.callTool({ name, arguments: args }); + return { router, call }; + }; + try { await run(a, b, connect); } + finally { + for (const { client, server } of connections) { await client.close(); await server.stop(); } + assert.equal(path.dirname(parent), path.resolve(os.tmpdir())); + assert.ok(path.basename(parent).startsWith('wincode-fixed-')); + await fs.rm(parent, { recursive: true, force: true }); + } +} + +it('internal workspace entry points and shared configuration cannot rebind the startup root', async () => fixture(async (a, b) => { + const config = getDefaultConfig(a), workspace = new WorkspaceManager(config); + const trash = config.trashDir; + assert.throws(() => workspace.setRoot(b), mismatch); + await assert.rejects(workspace.openWorkspace(b), mismatch); + assert.equal(Reflect.set(config, 'workspaceRoot', b), false); + assert.equal(workspace.root, a); + assert.equal(config.workspaceRoot, a); + assert.equal(config.trashDir, trash); +})); + +it('both MCP open names reject a different root before draining, fingerprinting or touching resources', async t => fixture(async (a, b, connect) => { + const { router, call } = await connect(a); + const before = await router.getRuntimeHealth(); + const resources = router.resources.list(); + const trash = router.config.trashDir; + const probes = [t.mock.method(router, 'waitForIdle', async () => { throw new Error('Unexpected drain'); }), + t.mock.method(router.cache, 'computeWorkspaceFingerprint', async () => { throw new Error('Unexpected fingerprint'); }), + t.mock.method(router.workspace, 'openWorkspace', async () => { throw new Error('Unexpected workspace overview'); })]; + await router.acquireRequestSlot(); + try { + for (const name of ['workspace_open', 'wincode_workspace_open']) { + const result = await call(name, { path: b }); + assert.equal(result.isError, true); + const error = body(result); + assert.equal(error.errorCode, 'WORKSPACE_MISMATCH'); + assert.equal(error.activeWorkspace, a); + assert.equal(error.requestedWorkspace, b); + assert.equal(error.recoveryAction, 'select_workspace_connection'); + assert.deepEqual(result.structuredContent, error); + } + const after = await router.getRuntimeHealth(); + assert.equal(after.inFlightRequests, 1, 'the pre-existing request remains owned'); + assert.deepEqual(after.session, before.session); + assert.deepEqual(after.workspaceWatch, before.workspaceWatch); + assert.equal(after.cache.namespace, before.cache.namespace); + assert.equal(after.workspaceRecovery, null); + assert.equal(router.config.trashDir, trash); + assert.deepEqual(router.resources.list(), resources); + for (const probe of probes) assert.equal(probe.mock.callCount(), 0); + } finally { router.endRequest(); for (const probe of probes) probe.mock.restore(); } +})); + +it('rejected opens preserve A text, relative context and composites while an independent B connection remains usable', async () => fixture(async (a, b, connect) => { + const left = await connect(a), right = await connect(b); + const before = body(await left.call('wincode_find_code_symbol', { query: 'Api' })); + assert.equal(before.totalFound, 1); + const rejected = await left.call('workspace_open', { path: b }); + assert.equal(body(rejected).errorCode, 'WORKSPACE_MISMATCH'); + for (const [connection, root, marker, other] of [[left, a, 'ONLY_A', 'ONLY_B'], [right, b, 'ONLY_B', 'ONLY_A']] as const) { + const hello = body(await connection.call('wincode_hello_world')); + assert.equal(hello.workspace, root); + assert.deepEqual(hello.health.workspaceBinding, { mode: 'fixed', root, source: 'configuration' }); + assert.equal(body(await connection.call('wincode_find_code_symbol', { query: 'Api' })).totalFound, 1); + const context = await connection.call('wincode_prepare_context', { task: 'Read Api', lineRanges: [{ file: 'Api.cs', startLine: 1, endLine: 1 }] }); + assert.notEqual(context.isError, true); + assert.ok(JSON.stringify(body(context)).includes(marker)); + assert.ok(!JSON.stringify(body(context)).includes(other)); + const architecture = await connection.call('wincode_analyze_workspace', { maxDepth: 1 }); + assert.notEqual(architecture.isError, true); + const listing = body(await connection.call('wincode_list_directory')); + assert.equal(listing.workspace, root); + assert.ok(listing.entries.some((entry: any) => entry.path === 'Api.cs')); + const impact = await connection.call('analyze_change_impact', { target: 'Api.cs' }); + assert.notEqual(impact.isError, true); + const plan = await connection.call('wincode_plan_refactoring', { target: 'Api.cs', goal: 'Review Api' }); + assert.notEqual(plan.isError, true); + } +})); + +it('normalized same-root spellings preserve binding while parent, child and junction targets are rejected', async () => fixture(async (a, b, connect) => { + const { router, call } = await connect(a); + const before = await router.getRuntimeHealth(); + const aliases = [a + path.sep, path.join(a, '..', path.basename(a))]; + if (process.platform === 'win32') aliases.push(a.toUpperCase(), a.replace(/\\/g, '/')); + for (const alias of aliases) assert.notEqual((await call('workspace_open', { path: alias })).isError, true); + const link = path.join(path.dirname(a), 'alias-to-A'); + await fs.symlink(a, link, process.platform === 'win32' ? 'junction' : 'dir'); + for (const target of [path.dirname(a), path.join(a, 'child'), b, link]) { + const result = await call('workspace_open', { path: target }); + assert.equal(body(result).errorCode, 'WORKSPACE_MISMATCH'); + } + const after = await router.getRuntimeHealth(); + assert.equal(after.session.id, before.session.id); + assert.equal(after.workspaceWatch.root, a); + assert.equal(after.cache.namespace, before.cache.namespace); + assert.equal(router.config.workspaceRoot, a); +})); + +it('a missing startup directory is rejected before cache creation', async () => fixture(async (a) => { + const missing = path.join(a, 'missing'); + const config = getDefaultConfig(missing); + config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; + const router = new ToolRouter(config); + try { + await assert.rejects(router.initialize(), /Invalid workspace/); + await assert.rejects(fs.stat(missing), { code: 'ENOENT' }); + } finally { await router.dispose(); } +})); + +it('a startup junction is rejected even when the cache is configured on a separate safe path', async () => fixture(async (a, b) => { + const alias = path.join(path.dirname(a), 'startup-alias'); + await fs.symlink(a, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const config = getDefaultConfig(alias); + config.cacheDir = path.join(b, 'uncreated-cache'); + config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + const router = new ToolRouter(config); + try { + await assert.rejects(router.initialize(), /link or junction/); + await assert.rejects(fs.stat(config.cacheDir), { code: 'ENOENT' }); + } finally { await router.dispose(); } +})); + +it('the production CLI reports cwd fallback and binds it without requiring workspace_open', async () => fixture(async (a, b) => { + const client = new Client({ name: 'fixed-cwd-acceptance', version: '1' }); + const transport = new StdioClientTransport({ command: process.execPath, + args: [path.resolve('dist/index.js')], cwd: a, stderr: 'pipe' }); + try { + await client.connect(transport); + const hello = body(await client.callTool({ name: 'wincode_hello_world', arguments: {} })); + assert.deepEqual(hello.health.workspaceBinding, { mode: 'fixed', root: a, source: 'cwd' }); + assert.equal(hello.workspace, a); + const rejected = await client.callTool({ name: 'workspace_open', arguments: { path: b } }); + assert.equal(body(rejected).errorCode, 'WORKSPACE_MISMATCH'); + } finally { await client.close(); } +})); + +it('an explicit workspace flag requires an absolute value and fails before creating a default cache', async () => fixture(async (a) => { + for (const args of [['--workspace'], ['--workspace', 'relative'], ['-w'], ['-w', '--development']]) { + const result = spawnSync(process.execPath, [path.resolve('dist/index.js'), ...args], + { cwd: a, input: '', encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 262144 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.match(result.stderr, /--workspace requires an absolute directory path/); + } + await assert.rejects(fs.stat(path.join(a, '.cache')), { code: 'ENOENT' }); +})); diff --git a/tests/fixtures/cache-gateway.mjs b/tests/fixtures/cache-gateway.mjs new file mode 100644 index 0000000..e20ff9c --- /dev/null +++ b/tests/fixtures/cache-gateway.mjs @@ -0,0 +1,33 @@ +/** Real production Gateway with small fixture budgets; no extra MCP tools or alternate cache implementation. */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { getDefaultConfig } from '../../dist/Core/Config.js'; +import { ToolRouter } from '../../dist/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../../dist/Gateway/McpServer.js'; + +const [workspace, sharedCache, receipt] = process.argv.slice(2); +if (![workspace, sharedCache, receipt].every(value => value && path.isAbsolute(value))) throw new Error('Absolute fixture paths required.'); +const config = getDefaultConfig(workspace); +config.cacheDir = sharedCache; +config.adapters.flaui.enabled = false; +config.adapters.repomix.useCli = false; +Object.assign(config.cacheLimits, { maxEntryBytes: 8192, maxDiskEntries: 4, maxDiskBytes: 256 * 1024 }); +const router = new ToolRouter(config), server = new WinCodeMcpServer(router); +let stopping; +const stop = () => stopping ??= (async () => { + try { + await server.stop(); + await fs.writeFile(receipt, JSON.stringify({ success: true, pid: process.pid, + inFlightRequests: router.inFlightRequests, resourcesDisposed: router.resources.isDisposed })); + process.exit(0); + } catch (error) { + await fs.writeFile(receipt, JSON.stringify({ success: false, error: String(error) })); + process.exit(1); + } +})(); +server.onDisconnect = stop; +for (const event of ['end', 'close', 'error']) process.stdin.once(event, stop); +process.stdout.once('error', stop); +process.stderr.on('error', () => {}); +process.once('SIGTERM', stop); process.once('SIGINT', stop); +await server.start(); diff --git a/tests/fixtures/design-time-comparison/BuildLayout.cs b/tests/fixtures/design-time-comparison/BuildLayout.cs new file mode 100644 index 0000000..50dbc47 --- /dev/null +++ b/tests/fixtures/design-time-comparison/BuildLayout.cs @@ -0,0 +1,76 @@ +using Microsoft.Build.Evaluation; +using Microsoft.Build.Construction; +using Microsoft.Build.Globbing; +using System.Text; +using System.Xml.Linq; + +// Candidate revision: evaluate the original project without running targets, then preserve its exclusions. +internal sealed class BuildLayout +{ + private readonly List<(string Path, bool CustomCompile, IMSBuildGlob[] Globs, HashSet Explicit)> outputs = []; + internal readonly List PrivateDirectories = []; + internal string Hook { get; private set; } = ""; + internal static BuildLayout? Current; + internal static bool IsCandidate(string file) + { + if (Current == null || !Path.GetExtension(file).Equals(".cs", StringComparison.OrdinalIgnoreCase)) return true; + var matched = false; + foreach (var entry in Current.outputs) + if (file.StartsWith(entry.Path, StringComparison.OrdinalIgnoreCase)) + { + matched = true; + if (entry.CustomCompile || entry.Explicit.Contains(file) || entry.Globs.Any(glob => glob.IsMatch(file))) return true; + } + return !matched; + } + + internal static BuildLayout Prepare(string root, string projectPath, string configuration, string framework, string identity, CancellationToken token) + { + var result = new BuildLayout(); + var xml = new XElement("Project"); + using var collection = new ProjectCollection(new Dictionary { + ["Configuration"] = configuration, ["TargetFramework"] = framework, + ["DesignTimeBuild"] = "true", ["BuildingInsideVisualStudio"] = "true" + }); + var pending = new Stack(); pending.Push(projectPath); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + while (pending.TryPop(out var path)) + { + token.ThrowIfCancellationRequested(); path = WorkspaceInputs.Inside(root, path); + if (!seen.Add(path)) continue; + if (seen.Count > 64) throw new HostFailure("INPUT_BUDGET_EXCEEDED", "More than 64 project layouts."); + var project = collection.LoadProject(path); + var directory = Path.GetDirectoryName(path)!; + var intermediate = WorkspaceInputs.Inside(root, Path.GetFullPath(project.GetPropertyValue("IntermediateOutputPath"), directory)); + if (string.Equals(intermediate, directory, StringComparison.OrdinalIgnoreCase)) + throw new HostFailure("INVALID_ARGUMENT", "Intermediate output cannot be the project directory."); + var customCompile = new[] { project.Xml }.Concat(project.Imports.Select(i => i.ImportedProject)).Any(document => + !document.FullPath.StartsWith(collection.Toolsets.First().ToolsPath, StringComparison.OrdinalIgnoreCase) && + document.AllChildren.OfType().Any(item => item.ItemType == "Compile" && item.Include.Length != 0)); + var globs = project.GetAllGlobs("Compile").Select(glob => glob.MsBuildGlob).ToArray(); + var explicitFiles = project.GetItems("Compile").Select(item => Path.GetFullPath(item.EvaluatedInclude, directory)).ToHashSet(StringComparer.OrdinalIgnoreCase); + result.outputs.Add((intermediate.TrimEnd('\\', '/') + Path.DirectorySeparatorChar, customCompile, globs, explicitFiles)); + // Other configurations under the same base are excluded by default SDK Compile globs too. + var baseIntermediate = WorkspaceInputs.Inside(root, Path.GetFullPath(project.GetPropertyValue("BaseIntermediateOutputPath"), directory)); + if (!string.Equals(baseIntermediate, directory, StringComparison.OrdinalIgnoreCase)) + result.outputs.Add((baseIntermediate.TrimEnd('\\', '/') + Path.DirectorySeparatorChar, customCompile, globs, explicitFiles)); + var condition = $"'$(MSBuildProjectFullPath)' == '{ProjectCollection.Escape(path)}'"; + var originalHook = project.Imports.FirstOrDefault(import => + import.ImportingElement.Project == "$(CustomBeforeMicrosoftCommonTargets)").ImportedProject?.FullPath; + if (originalHook != null) + xml.Add(new XElement("Import", new XAttribute("Project", originalHook), + new XAttribute("Condition", condition + $" And Exists('{ProjectCollection.Escape(originalHook)}')"))); + xml.Add(new XElement("PropertyGroup", new XAttribute("Condition", condition), + new XElement("DefaultItemExcludes", "$(DefaultItemExcludes);" + ProjectCollection.Escape(intermediate.Replace('\\', '/')) + "/**"))); + result.PrivateDirectories.Add(WorkspaceInputs.Inside(root, Path.Combine(directory, ".cache/wincode-msbuild", identity))); + foreach (var reference in project.GetItems("ProjectReference")) pending.Push(Path.GetFullPath(reference.EvaluatedInclude, directory)); + } + var storage = WorkspaceInputs.Inside(root, Path.Combine(root, ".cache/wincode-build", identity)); + OwnedBuildOutputs.Record(root, identity, result.PrivateDirectories); + Directory.CreateDirectory(storage); + result.Hook = Path.Combine(storage, "preserve.targets"); + File.WriteAllText(result.Hook, xml.ToString(), new UTF8Encoding(false)); + Current = result; + return result; + } +} diff --git a/tests/fixtures/design-time-comparison/OwnedBuildOutputs.cs b/tests/fixtures/design-time-comparison/OwnedBuildOutputs.cs new file mode 100644 index 0000000..36d9f00 --- /dev/null +++ b/tests/fixtures/design-time-comparison/OwnedBuildOutputs.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +// One Host owns one namespace. The Gateway retains the same manifest for forced-exit cleanup. +internal sealed class OwnedBuildOutputs : IDisposable +{ + internal static OwnedBuildOutputs? Current; + private readonly string root, identity, storage; + private readonly FileStream lease; + private readonly HashSet paths = new(StringComparer.OrdinalIgnoreCase); + private OwnedBuildOutputs(string root, string identity) + { + if (!Guid.TryParseExact(identity, "N", out _)) throw new ArgumentException("Invalid build output identity."); + this.root = root; this.identity = identity; + storage = WorkspaceInputs.Inside(root, Path.Combine(root, ".cache/wincode-build", identity)); + Directory.CreateDirectory(storage); + lease = new FileStream(Path.Combine(storage, "active.lock"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + } + internal static void Record(string root, string identity, IEnumerable directories) + { + Current ??= new(root, identity); + if (Current.root != root || Current.identity != identity) throw new InvalidOperationException("Build output owner changed."); + foreach (var directory in directories) + { + Current.Validate(directory); + Current.paths.Add(directory); + if (Current.paths.Count > 128) throw new HostFailure("INPUT_BUDGET_EXCEEDED", "Too many private output roots."); + } + var temporary = Path.Combine(Current.storage, "owner.json.tmp"); + File.WriteAllText(temporary, JsonSerializer.Serialize(new { version = 1, instance = identity, + paths = Current.paths.Select(path => Path.GetRelativePath(root, path)).ToArray() })); + File.Move(temporary, Path.Combine(Current.storage, "owner.json"), true); + } + private void Validate(string directory) + { + WorkspaceInputs.Inside(root, directory); + var suffix = Path.Combine(".cache", "wincode-msbuild", identity); + if (!directory.EndsWith(Path.DirectorySeparatorChar + suffix, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Output directory is not owned by this Host."); + } + private void Remove(string directory) + { + if (!Directory.Exists(directory)) return; + var pending = new Stack(); pending.Push(directory); var count = 0; + while (pending.TryPop(out var current)) + { + WorkspaceInputs.Inside(root, current); + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + if (++count > 16384) throw new IOException("Private cleanup entry budget exceeded."); + WorkspaceInputs.Inside(root, entry); + if (Directory.Exists(entry)) pending.Push(entry); + } + } + Directory.Delete(directory, true); + } + public void Dispose() + { + try + { + foreach (var directory in paths) { Validate(directory); Remove(directory); } + } + finally { lease.Dispose(); } + Remove(storage); + Current = null; + } +} diff --git a/tests/fixtures/design-time-comparison/PrototypeCoordination.cs b/tests/fixtures/design-time-comparison/PrototypeCoordination.cs new file mode 100644 index 0000000..08c0e6d --- /dev/null +++ b/tests/fixtures/design-time-comparison/PrototypeCoordination.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; +using System.Text.Json; + +// Experimental only: copied into an isolated Host by verify-design-time-concurrency.mjs. +internal sealed class PrototypeCoordination : IDisposable +{ + private readonly FileStream? handle; + internal static readonly string Mode = Environment.GetEnvironmentVariable("WINCODE_N4_MODE") ?? "baseline"; + internal static readonly string Instance = Environment.GetEnvironmentVariable("WINCODE_N4_INSTANCE") ?? Guid.NewGuid().ToString("N"); + internal static double LastWaitMs; + internal static string? LastIntermediate; + private PrototypeCoordination(FileStream? handle) { this.handle = handle; } + private static void Trace(string stage) => Console.Error.WriteLine("N4TRACE " + JsonSerializer.Serialize(new { + stage, mode = Mode, instance = Instance, pid = Environment.ProcessId, at = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + })); + + internal static async Task EnterAsync(string root, CancellationToken token) + { + if (Mode is not ("baseline" or "private" or "private2" or "lock")) throw new InvalidOperationException("Unknown prototype mode."); + if (Mode != "lock") return new(null); + var directory = WorkspaceInputs.Inside(root, ".cache/wincode-comparison"); + Directory.CreateDirectory(directory); + var file = WorkspaceInputs.Inside(root, Path.Combine(directory, "load.lock")); + var clock = Stopwatch.StartNew(); + var reportedWait = false; + while (true) + { + token.ThrowIfCancellationRequested(); + try + { + var stream = new FileStream(file, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + LastWaitMs = clock.Elapsed.TotalMilliseconds; + Trace("gate-acquired"); + return new(stream); + } + catch (IOException error) when ((error.HResult & 0xffff) is 32 or 33) + { + if (!reportedWait) { Trace("gate-waiting"); reportedWait = true; } + // Only sharing/lock contention is a wait; access and path failures remain errors. + await Task.Delay(25, token); + } + } + } + + internal static Dictionary Properties(string configuration, string framework) + { + Trace("msbuild-start"); + var properties = new Dictionary { + ["Configuration"] = configuration, ["TargetFramework"] = framework, + ["RunAnalyzers"] = "false", ["RunAnalyzersDuringBuild"] = "false" + }; + if (Mode is "private" or "private2") + { + if (!Guid.TryParseExact(Instance, "N", out _)) throw new InvalidOperationException("Invalid prototype identity."); + if (configuration.IndexOfAny(['/', '\\', ':']) >= 0 || framework.IndexOfAny(['/', '\\', ':']) >= 0) + throw new InvalidOperationException("Prototype configuration must be a path segment."); + // Relative to each evaluated project, including its ProjectReferences. + LastIntermediate = $".cache/wincode-msbuild/{Instance}/{configuration}/{framework}/"; + properties["IntermediateOutputPath"] = LastIntermediate; + if (Mode == "private2") properties["CustomBeforeMicrosoftCommonTargets"] = BuildLayout.Current!.Hook; + } + return properties; + } + + public void Dispose() { handle?.Dispose(); if (Mode == "lock") Trace("gate-released"); } +} diff --git a/tests/lifecycle-cancellation.test.ts b/tests/lifecycle-cancellation.test.ts index 6b2cb3a..c7856ac 100644 --- a/tests/lifecycle-cancellation.test.ts +++ b/tests/lifecycle-cancellation.test.ts @@ -212,11 +212,11 @@ it('MCP cancellation stops local scanning, releases the opened file and permits assert.equal(JSON.parse(next.content[0].text).symbols[0].name, 'Target'); })); -it('cancelled queued workspace switch preserves the current workspace', async () => fixture(async router => { +it('cancelled queued workspace recovery preserves the fixed workspace', async () => fixture(async router => { await router.acquireRequestSlot(); const controller = new AbortController(); const before = router.config.workspaceRoot; - const pending = router.openWorkspace(path.join(before, 'other'), {}, controller.signal); + const pending = router.openWorkspace(before, {}, controller.signal); controller.abort(); await assert.rejects(pending, /cancel|abort/i); router.endRequest(); diff --git a/tests/manual-release.test.ts b/tests/manual-release.test.ts index 2a092c8..d382d99 100644 --- a/tests/manual-release.test.ts +++ b/tests/manual-release.test.ts @@ -94,7 +94,7 @@ it('an arriving MCP request waits for an accepted manual release and then procee await router.dispose(); }); -it('a queued workspace switch wins over a settings release', async () => { +it('a queued workspace confirmation 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); diff --git a/tests/mcp-stdio.test.ts b/tests/mcp-stdio.test.ts index 8023f64..82a4f76 100644 --- a/tests/mcp-stdio.test.ts +++ b/tests/mcp-stdio.test.ts @@ -123,21 +123,21 @@ describe('mcp-stdio', () => { it('Tool 0: workspace_open works end-to-end via MCP', async () => { const res = await callMcp('tools/call', { name: 'workspace_open', - arguments: { path: FIXTURE_DOTNET }, + arguments: { path: root }, }); const data = JSON.parse(res.result?.content?.[0]?.text); - assert.strictEqual(data.type, 'dotnet'); - assert.strictEqual(data.solution, 'MiniDesk.sln'); - assert.strictEqual(data.projects, 3); - assert.strictEqual(data.language, 'C#'); + assert.strictEqual(data.workspace, root); + assert.equal(typeof data.type, 'string'); assert.equal(data.metadata.totalFiles, null); assert.equal(data.fileTree, undefined); assert.ok(data.entryPoints.length <= 8); - await callMcp('tools/call', { + const rejected = await callMcp('tools/call', { name: 'workspace_open', - arguments: { path: root }, + arguments: { path: FIXTURE_DOTNET }, }); + assert.strictEqual(rejected.result.isError, true); + assert.strictEqual(JSON.parse(rejected.result.content[0].text).errorCode, 'WORKSPACE_MISMATCH'); }); it('Tool 1: wincode_hello_world works', async () => { @@ -149,6 +149,7 @@ describe('mcp-stdio', () => { assert.strictEqual(data.status, 'online'); assert.strictEqual(data.message, 'TDD Test Greeting'); assert.strictEqual(data.gateway, 'WinCode Agent Gateway'); + assert.deepEqual(data.health.workspaceBinding, { mode: 'fixed', root, source: 'argument' }); assert.equal(data.codeProvider, 'local-text'); assert.equal(data.adapters.text.source, 'local-text'); assert.equal(data.health.text.semanticConfigured, false); diff --git a/tests/owner-process-guard.test.ts b/tests/owner-process-guard.test.ts index 02d021c..d2ea547 100644 --- a/tests/owner-process-guard.test.ts +++ b/tests/owner-process-guard.test.ts @@ -42,6 +42,9 @@ async function scenario(mode: string, run: (owner: ChildProcessWithoutNullStream 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)); + // Avoid a PowerShell startup for an already exited fixture. Live/unknown PIDs still require identity validation. + try { process.kill(record.pid, 0); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') continue; } // 捕获的创建时间必须匹配;先持有实际对象句柄,避免在核验与清理之间复用 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 }); @@ -61,6 +64,7 @@ for (const mode of ['normal', 'repeat']) it(`owner guard supports ${mode} dispos 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 record of records) assert.throws(() => process.kill(record.pid, 0), { code: 'ESRCH' }, 'Disposed Helper survived'); }); }); for (const mode of ['native-block', 'blocked-callback', 'cooperative', 'early-owner-death']) @@ -130,4 +134,3 @@ it('production UIA still treats stdin EOF as the request boundary', options, asy await withTimeout(closed, 5000, 'UIA cleanup'); } }); - diff --git a/tests/process-failures.test.ts b/tests/process-failures.test.ts index a5251a2..6cdd7f8 100644 --- a/tests/process-failures.test.ts +++ b/tests/process-failures.test.ts @@ -67,25 +67,30 @@ describe('process-failures', () => { }); it('git-less workspace is reported, not thrown', async () => { - // A directory inside this repository is still Git-controlled even without its own .git. + // TEMP may be inside this repository; stop discovery at its parent to model a non-Git workspace. const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-nongit-')); + const previousCeiling = process.env.GIT_CEILING_DIRECTORIES; try { + process.env.GIT_CEILING_DIRECTORIES = path.dirname(tmp); await fs.writeFile(path.join(tmp, 'readme.txt'), 'x'); const ws = new WorkspaceManager(getDefaultConfig(tmp)); const git = await ws.getGitStatus(); assert.strictEqual(git.isGit, false); } finally { + if (previousCeiling === undefined) delete process.env.GIT_CEILING_DIRECTORIES; + else process.env.GIT_CEILING_DIRECTORIES = previousCeiling; assert.strictEqual(path.dirname(await fs.realpath(tmp)), await fs.realpath(os.tmpdir())); await fs.rm(tmp, { recursive: true, force: true }); } }); it('malformed workspace path fails with a structured error', async () => { - const ws = new WorkspaceManager(getDefaultConfig(root)); const filePath = path.join(testCacheDir, 'not_a_dir.txt'); await fs.writeFile(filePath, 'nope'); + const ws = new WorkspaceManager(getDefaultConfig(filePath)); await assert.rejects(() => ws.openWorkspace(filePath), /Invalid workspace path/); - await assert.rejects(() => ws.openWorkspace(path.join(testCacheDir, 'missing_dir_zzz')), /Invalid workspace path/); + const missing = path.join(testCacheDir, 'missing_dir_zzz'); + await assert.rejects(() => new WorkspaceManager(getDefaultConfig(missing)).openWorkspace(missing), /Invalid workspace path/); }); it('withTimeout converts hangs into TimeoutError without rejecting later', async () => { diff --git a/tests/request-admission.test.ts b/tests/request-admission.test.ts new file mode 100644 index 0000000..906b82c --- /dev/null +++ b/tests/request-admission.test.ts @@ -0,0 +1,318 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; +import { Mutex, AbortError } from '../src/Core/ResourceManager.js'; + +const body = (response: any) => JSON.parse(response.content[0].text); +const deferred = () => { let resolve!: () => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; +async function until(predicate: () => boolean, message: string) { + const deadline = Date.now() + 2500; + while (!predicate()) { if (Date.now() > deadline) assert.fail(message); await new Promise(r => setTimeout(r, 5)); } +} +async function fixture(run: (f: { root: string; router: ToolRouter; server: WinCodeMcpServer; client: Client; call: (name: string, args?: any, signal?: AbortSignal) => Promise }) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-admission-')); + const config = getDefaultConfig(root); config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + const router = new ToolRouter(config), server = new WinCodeMcpServer(router); + const client = new Client({ name: 'bounded-admission-fixture', version: '1' }); + try { + await router.initialize(); + const [a, b] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(a), (server as any).server.connect(b)]); + await run({ root, router, server, client, call: (name, args = {}, signal) => client.callTool({ name, arguments: args }, { signal, timeout: 8000 }) }); + } finally { + await client.close(); await server.stop(); + assert.equal(path.dirname(root), path.resolve(os.tmpdir())); assert.ok(path.basename(root).startsWith('wincode-admission-')); + await fs.rm(root, { recursive: true, force: true }); + } +} + +it('raw UTF-8 argument budget applies before unknown fields are discarded', async () => fixture(async ({ call, router }) => { + let invoked = false; + router.findCodeSymbols = async () => { invoked = true; throw new Error('must not execute'); }; + const args = { query: 'Api', unknownPadding: '中'.repeat(22000) }; + const result = await call('wincode_find_code_symbol', args); + assert.equal(result.isError, true); assert.equal(body(result).errorCode, 'INVALID_ARGUMENT'); + assert.equal(body(result).maxArgumentBytes, 65536); + assert.equal(body(result).argumentBytes, Buffer.byteLength(JSON.stringify(args))); + assert.equal(invoked, false); + const legal = await call('wincode_hello_world', { ignored: 'a'.repeat(64000) }); + assert.notEqual(legal.isError, true); +})); + +for (const count of [4, 8, 16]) it(`${count} ordinary queued calls complete in FIFO order without overload`, async () => fixture(async ({ router, call }) => { + const hold = deferred(), mutex = new Mutex(), order: string[] = []; + (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { order.push(query); if (query === 'q0') await hold.promise; + return { symbols: [], source: 'local-text', queryComplete: true }; }, operation?.signal, operation?.queue); + const pending = Array.from({ length: count }, (_, i) => call('wincode_find_code_symbol', { query: `q${i}` })); + try { await until(() => mutex.pendingCount === count - 1, 'normal burst should queue'); } + finally { hold.resolve(); } + for (const result of await Promise.all(pending)) assert.notEqual(result.isError, true); + assert.deepEqual(order, Array.from({ length: count }, (_, i) => `q${i}`)); + assert.equal(router.admission.snapshot().business.rejected, 0); assert.equal(router.admission.pendingCount, 0); +})); + +it('repeated queued cancellation frees real nodes and permits replacement while the owner stays active', async () => fixture(async ({ call, router }) => { + const hold = deferred(), mutex = new Mutex(), order: string[] = []; + (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { order.push(query); await hold.promise; + return { symbols: [], source: 'local-text', queryComplete: true }; }, operation?.signal, operation?.queue); + const owner = call('wincode_find_code_symbol', { query: 'owner' }); + try { + await until(() => order.length === 1, 'owner should enter'); + for (let round = 0; round < 3; round++) { + const controllers = Array.from({ length: 31 }, () => new AbortController()); + const pending = controllers.map((c, i) => call('wincode_find_code_symbol', { query: `cancel-${round}-${i}` }, c.signal).catch(e => e)); + await until(() => mutex.pendingCount === 31, 'replacement calls should fit the released capacity'); + controllers.forEach(c => c.abort()); await Promise.all(pending); + await until(() => mutex.pendingCount === 0 && router.admission.pendingCount === 1, 'cancelled nodes and capacity must both disappear'); + assert.deepEqual(order, ['owner']); + } + assert.equal(router.admission.snapshot().business.cancelled, 93); + } finally { hold.resolve(); await owner; } + assert.equal(router.admission.pendingCount, 0); +})); + +it('a cancelled running call holds its capacity until asynchronous cleanup actually finishes', async () => fixture(async ({ call, router }) => { + const entered = deferred(), cleanup = deferred(), mutex = new Mutex(), controller = new AbortController(); + (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { + if (query === 'owner') { + entered.resolve(); + await new Promise(resolve => operation.signal.addEventListener('abort', () => resolve(), { once: true })); + await cleanup.promise; throw new AbortError('cancelled after cleanup'); + } + return { symbols: [], source: 'local-text', queryComplete: true }; + }, operation?.signal, operation?.queue); + const owner = call('wincode_find_code_symbol', { query: 'owner' }, controller.signal).catch(e => e); + await entered.promise; + const pending = Array.from({ length: 31 }, (_, i) => call('wincode_find_code_symbol', { query: `q${i}` })); + try { + await until(() => mutex.pendingCount === 31, 'queue should fill'); controller.abort(); await owner; + assert.equal(router.admission.pendingCount, 32, 'client cancellation is earlier than actual cleanup'); + assert.equal(body(await call('wincode_find_code_symbol', { query: 'overflow' })).errorCode, 'SERVER_BUSY'); + assert.equal((await router.releaseRoslynMemory()).status, 'busy'); + } finally { cleanup.resolve(); await Promise.all(pending); } + assert.equal(router.admission.pendingCount, 0); assert.equal(mutex.pendingCount, 0); +})); + +it('queue wait consumes the request deadline and expiry removes the unexecuted adapter node', async () => fixture(async ({ call, router }) => { + const hold = deferred(), mutex = new Mutex(), order: string[] = []; + (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { order.push(query); if (query === 'owner') await hold.promise; + return { symbols: [], source: 'local-text', queryComplete: true }; }, operation?.signal, operation?.queue); + const owner = call('wincode_find_code_symbol', { query: 'owner' }); + try { + await until(() => order.length === 1, 'owner should enter'); router.config.timeouts.fileScanMs = 80; + const expired = await call('wincode_find_code_symbol', { query: 'expired' }); + assert.equal(body(expired).errorCode, 'REQUEST_TIMEOUT'); assert.equal(body(expired).retryable, false); + assert.deepEqual(order, ['owner']); assert.equal(mutex.pendingCount, 0); + assert.equal(router.admission.snapshot().business.active, 1); assert.equal(router.admission.snapshot().business.timedOut, 1); + } finally { hold.resolve(); await owner; } +})); + +it('admitted queue expiry stays a timeout when the clock advances before adapter entry', async t => fixture(async ({ call, router }) => { + const hold = deferred(), mutex = new Mutex(), order: string[] = []; + (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { order.push(query); if (query === 'owner') await hold.promise; + return { symbols: [], source: 'local-text', queryComplete: true }; }, operation?.signal, operation?.queue); + const owner = call('wincode_find_code_symbol', { query: 'owner' }); + try { + await until(() => order.length === 1, 'owner should enter'); + router.config.timeouts.fileScanMs = 1200; + const now = Date.now.bind(Date), acquire = router.acquireRequestSlot.bind(router); + let advance = 0; + t.mock.method(Date, 'now', () => now() + advance); + t.mock.method(router, 'acquireRequestSlot', async (...args: Parameters) => { + await acquire(...args); + // Force any second, remaining-budget timer to fire before the admitted timer. + advance = 600; + }); + const expired = await call('wincode_find_code_symbol', { query: 'expired' }); + assert.equal(body(expired).errorCode, 'REQUEST_TIMEOUT'); + assert.equal(body(expired).retryable, false); + assert.deepEqual(order, ['owner']); assert.equal(mutex.pendingCount, 0); + assert.equal(router.admission.snapshot().business.active, 1); + assert.equal(router.admission.snapshot().business.timedOut, 1); + assert.equal(router.admission.snapshot().business.cancelled, 0); + } finally { hold.resolve(); await owner; } +})); + +it('deadline outcome: expiry before adapter entry is counted even before its timer runs', async t => fixture(async ({ call, router }) => { + const now = Date.now.bind(Date), acquire = router.acquireRequestSlot.bind(router); + let advance = 0, invoked = false; + router.config.timeouts.fileScanMs = 1000; + t.mock.method(Date, 'now', () => now() + advance); + t.mock.method(router, 'acquireRequestSlot', async (...args: Parameters) => { + await acquire(...args); advance = 2000; + }); + router.findCodeSymbols = async () => { invoked = true; throw new Error('Expired work must not start'); }; + const result = body(await call('wincode_find_code_symbol', { query: 'expired' })); + assert.equal(result.errorCode, 'REQUEST_TIMEOUT'); assert.equal(result.workStarted, false); + assert.equal(invoked, false); + const state = router.admission.snapshot().business; + assert.equal(state.active, 0); assert.equal(state.completed, 1); + assert.equal(state.timedOut, 1); assert.equal(state.cancelled, 0); +})); + +it('deadline outcome: status results completed after the deadline cannot be returned as success', async t => fixture(async ({ call, router }) => { + const now = Date.now.bind(Date), health = router.getRuntimeHealth.bind(router); + let advance = 0; + router.config.timeouts.commandProbeMs = 1000; + t.mock.method(Date, 'now', () => now() + advance); + t.mock.method(router, 'getRuntimeHealth', async (...args: Parameters) => { + const result = await health(...args); advance = 2000; return result; + }); + const result = await call('wincode_hello_world'); + assert.equal(result.isError, true); + assert.equal(body(result).errorCode, 'REQUEST_TIMEOUT'); + assert.equal(body(result).workStarted, true); assert.equal(body(result).retryable, false); + const state = router.admission.snapshot().status; + assert.equal(state.active, 0); assert.equal(state.completed, 1); + assert.equal(state.timedOut, 1); assert.equal(state.cancelled, 0); +})); + +it('deadline outcome: a shorter adapter queue deadline preserves timeout classification and counting', async () => fixture(async ({ call, router }) => { + const hold = deferred(), mutex = new Mutex(); + const owner = mutex.runExclusive(() => hold.promise); + let invoked = false; + router.config.timeouts.fileScanMs = 40; + router.requestBudget = () => 2000; + (router.text as any).findSymbolsDetailed = (_q: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { invoked = true; return { symbols: [], source: 'local-text', queryComplete: true }; }, + operation.signal, operation.queue); + try { + const result = body(await call('wincode_find_code_symbol', { query: 'expired' })); + assert.equal(result.errorCode, 'REQUEST_TIMEOUT'); assert.equal(result.retryable, false); + assert.equal(invoked, false); assert.equal(mutex.pendingCount, 0); + const state = router.admission.snapshot().business; + assert.equal(state.active, 0); assert.equal(state.completed, 1); + assert.equal(state.timedOut, 1); assert.equal(state.cancelled, 0); + } finally { hold.resolve(); await owner; } +})); + +it('cancelled startup waiters are removed and passive requests stay available during initialization', async () => fixture(async ({ call, router, server, client }) => { + const startup = deferred(); (server as any).startPromise = startup.promise; + try { + for (let round = 0; round < 3; round++) { + const controllers = Array.from({ length: 8 }, () => new AbortController()); + const pending = controllers.map(c => call('wincode_find_code_symbol', { query: 'Api' }, c.signal).catch(e => e)); + await until(() => router.admission.snapshot().sharedWaiters === 8, 'startup waiters must be observable'); + assert.notEqual((await call('wincode_hello_world')).isError, true); assert.equal((await client.listTools()).tools.length, 15); + controllers.forEach(c => c.abort()); await Promise.all(pending); + await until(() => router.admission.pendingCount === 0, 'cancelled startup calls must release capacity'); + assert.equal(router.admission.snapshot().sharedWaiters, 0); + } + } finally { startup.resolve(); } + assert.notEqual((await call('wincode_find_code_symbol', { query: 'Api' })).isError, true); +})); + +it('startup waiting does not restart the operation budget at adapter entry', async () => fixture(async ({ call, router, server }) => { + const startup = deferred(); (server as any).startPromise = startup.promise; + router.config.timeouts.fileScanMs = 300; + let remaining = Infinity; + (router.text as any).findSymbolsDetailed = async (_q: string, _k: unknown, _p: unknown, operation: any) => { + remaining = operation.deadline - Date.now(); + return { symbols: [], source: 'local-text', queryComplete: true }; + }; + const start = Date.now(); const pending = call('wincode_find_code_symbol', { query: 'Api' }); + try { + await until(() => router.admission.snapshot().sharedWaiters === 1, 'request must be waiting before timing the release'); + await until(() => Date.now() - start >= 220, 'consume most of the shared budget'); + } finally { startup.resolve(); } + assert.notEqual((await pending).isError, true); assert.ok(remaining > 0 && remaining < 110, `remaining=${remaining}`); +})); + +it('four lightweight status slots are bounded independently from business admission', async () => fixture(async ({ call, router, client }) => { + const hold = deferred(), original = router.getRuntimeHealth.bind(router); let entered = 0; + router.getRuntimeHealth = async () => { entered++; await hold.promise; return original(); }; + const pending = Array.from({ length: 4 }, () => call('wincode_hello_world')); + try { + await until(() => entered === 4, 'status slots should be occupied'); + assert.equal(body(await call('wincode_hello_world')).errorCode, 'SERVER_BUSY'); + await assert.rejects(client.listTools(), (error: any) => error.data?.errorCode === 'SERVER_BUSY'); + assert.notEqual((await call('wincode_find_code_symbol', { query: 'Api' })).isError, true); + assert.equal(router.admission.snapshot().status.active, 4); + } finally { hold.resolve(); await Promise.all(pending); } + assert.equal(router.admission.pendingCount, 0); +})); + +it('same-root recovery has bounded cancellable waiters without counting itself in the drain', async () => fixture(async ({ call, router, root }) => { + const hold = deferred(), entered = deferred(); + (router.text as any).findSymbolsDetailed = async () => { entered.resolve(); await hold.promise; + return { symbols: [], source: 'local-text', queryComplete: true }; }; + const owner = call('wincode_find_code_symbol', { query: 'owner' }); await entered.promise; + await (router as any).watch.stop(); + const recovery = call('workspace_open', { path: root }); + try { + await until(() => router.isSwitchingWorkspace, 'same-root recovery should wait for the owner'); + for (let round = 0; round < 3; round++) { + const controls = Array.from({ length: 8 }, () => new AbortController()); + const pending = controls.map(c => call('wincode_find_code_symbol', { query: 'queued' }, c.signal).catch(e => e)); + await until(() => (router as any).workspaceLock.pendingCount === 8, 'requests should queue on the recovery barrier'); + assert.notEqual((await call('wincode_hello_world')).isError, true); + controls.forEach(c => c.abort()); await Promise.all(pending); + await until(() => router.admission.pendingCount === 2, 'only the owner and recovery should retain admission'); + assert.equal((router as any).workspaceLock.pendingCount, 0); assert.equal(router.inFlightRequests, 1); + } + } finally { hold.resolve(); await owner; } + assert.notEqual((await recovery).isError, true); assert.equal(router.workspaceRecoveryState, null); + assert.equal(router.admission.pendingCount, 0); assert.equal(router.inFlightRequests, 0); +})); + +it('shutdown cancels admitted queue nodes and waits for the active owner to finish', async () => fixture(async ({ call, router, server }) => { + const entered = deferred(), mutex = new Mutex(); let started = 0; + (router.text as any).findSymbolsDetailed = (_q: string, _k: unknown, _p: unknown, operation: any) => mutex.runExclusive(async () => { + started++; entered.resolve(); + await new Promise(resolve => operation.signal.addEventListener('abort', () => resolve(), { once: true })); + throw new AbortError('shutdown'); + }, operation?.signal, operation?.queue); + const pending = Array.from({ length: 16 }, () => call('wincode_find_code_symbol', { query: 'Api' }).catch(e => e)); + await entered.promise; await until(() => mutex.pendingCount === 15, 'queue should exist before disconnect'); + await server.stop(); await Promise.all(pending); + assert.equal(started, 1); assert.equal(mutex.pendingCount, 0); assert.equal(router.admission.pendingCount, 0); + assert.equal(router.inFlightRequests, 0); assert.equal(router.resources.childProcessCount(), 0); +})); + +it('passive hello uses known cache observations without scanning disk', async () => fixture(async ({ call, router }) => { + router.cache.getStats = async () => { throw new Error('unexpected cache enumeration'); }; + const result = await call('wincode_hello_world'); + assert.notEqual(result.isError, true); + assert.equal(body(result).health.cache.diskObservation, 'not-observed'); + assert.equal(body(result).health.cache.diskEntries, null); +})); + +it('128-call burst admits 32, rejects overflow before execution, and preserves FIFO and status access', async () => fixture(async ({ call, router, root, client }) => { + const hold = deferred(), mutex = new Mutex(), order: string[] = [], replies: any[] = []; + (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => + mutex.runExclusive(async () => { order.push(query); if (query === 'q0') await hold.promise; + return { symbols: [], source: 'local-text', queryComplete: true }; }, operation?.signal, operation?.queue); + const pending = Array.from({ length: 128 }, (_, i) => call('wincode_find_code_symbol', { query: `q${i}` }).then(r => { replies.push(r); return r; })); + try { + await until(() => replies.length === 96, 'overflow must return while the accepted owner is still blocked'); + for (const response of replies) { + assert.equal(response.isError, true); assert.equal(body(response).errorCode, 'SERVER_BUSY'); + assert.equal(body(response).workStarted, false); assert.equal(body(response).retryable, true); + assert.deepEqual(response.structuredContent, body(response)); + } + assert.equal(mutex.pendingCount, 31); assert.deepEqual(order, ['q0']); + const health = body(await call('wincode_hello_world')).health; + assert.equal(health.admission.business.active, 32); assert.equal(health.admission.business.waiting, 31); + assert.equal(health.admission.business.executing, 1); + assert.equal((await client.listTools()).tools.length, 15); + assert.equal(body(await call('workspace_open', { path: path.join(root, 'other') })).errorCode, 'WORKSPACE_MISMATCH'); + assert.equal(body(await call('workspace_open', { path: root })).errorCode, 'SERVER_BUSY'); + assert.equal((await router.releaseRoslynMemory()).status, 'busy'); + } finally { hold.resolve(); await Promise.all(pending); } + assert.equal(replies.filter(r => !r.isError).length, 32); + assert.deepEqual(order, Array.from({ length: 32 }, (_, i) => `q${i}`)); assert.equal(mutex.pendingCount, 0); + const after = body(await call('wincode_hello_world')).health.admission; + assert.equal(after.business.active, 0); assert.equal(after.business.waiting, 0); + assert.equal(after.business.accepted, 32); assert.equal(after.business.rejected, 97); +})); diff --git a/tests/resource-cleanup.test.ts b/tests/resource-cleanup.test.ts index c00b3a9..3dab2e2 100644 --- a/tests/resource-cleanup.test.ts +++ b/tests/resource-cleanup.test.ts @@ -54,11 +54,13 @@ describe('resource-cleanup', () => { assert.strictEqual(hitB, null, 'Data must NOT have drifted into projectB'); }); - it('ToolRouter: slow in-flight queries drain before openWorkspace and new queries queue', async () => { + it('ToolRouter: slow in-flight queries drain before same-root resource recovery', async t => { const config = getDefaultConfig(root); config.cacheDir = path.join(testCacheDir, 'drain_switch'); const router = new ToolRouter(config); + t.after(() => router.dispose()); await router.initialize(); + await (router as any).watch.stop(); let queryFinished = false; await router.acquireRequestSlot(); @@ -68,54 +70,64 @@ describe('resource-cleanup', () => { router.endRequest(); })(); - const switchOp = router.openWorkspace(FIXTURE_DOTNET); + const switchOp = router.openWorkspace(root); await switchOp; assert.strictEqual(queryFinished, true, 'openWorkspace must wait for in-flight requests to drain'); - assert.strictEqual(router.session.current?.workspaceRoot, FIXTURE_DOTNET); + assert.strictEqual(router.session.current?.workspaceRoot, root); + await slowOp; await router.openWorkspace(root); - await router.dispose(); }); - it('ToolRouter & McpServer: openWorkspace rejects switch and preserves workspace if in-flight queries do not drain', async () => { + it('ToolRouter & McpServer: same-root recovery rejects when in-flight queries do not drain', async t => { const config = getDefaultConfig(root); config.cacheDir = path.join(testCacheDir, 'drain_timeout'); config.timeouts.shutdownMs = 60; // short drain timeout const router = new ToolRouter(config); + t.after(() => router.dispose()); await router.initialize(); + await (router as any).watch.stop(); // Hold an in-flight slot that will NOT end in time await router.acquireRequestSlot(); try { await assert.rejects( async () => { - await router.openWorkspace(FIXTURE_DOTNET); + await router.openWorkspace(root); }, - /Workspace switch rejected: in-flight queries failed to drain/ + (error: any) => { + assert.strictEqual(error.name, 'WorkspaceRecoveryRequiredError'); + assert.strictEqual(error.recovery.phase, 'drain'); + assert.match(error.recovery.message, /Workspace recovery rejected: in-flight queries failed to drain/); + assert.strictEqual(error.recovery.recoveryAction, 'workspace_open'); + return true; + } ); // Ensure workspace was NOT changed and remains root assert.strictEqual(router.config.workspaceRoot, root); + assert.strictEqual(router.inFlightRequests, 1, 'timeout must not release another request'); + await assert.rejects(router.acquireRequestSlot(), { name: 'WorkspaceRecoveryRequiredError' }); } finally { router.endRequest(); - await router.dispose(); } }); - it('McpServer: workspace_open does not increment in-flight and completes promptly without self-wait', async () => { + it('McpServer: workspace_open does not increment in-flight and completes promptly without self-wait', async t => { const config = getDefaultConfig(root); config.cacheDir = path.join(testCacheDir, 'server_ws_open'); const router = new ToolRouter(config); const server = new WinCodeMcpServer(router); + t.after(() => server.stop()); await router.initialize(); + await (router as any).watch.stop(); const t0 = Date.now(); - await router.openWorkspace(FIXTURE_DOTNET); + await router.openWorkspace(root); const elapsed = Date.now() - t0; - assert.strictEqual(router.inFlightRequests, 0, 'Switch op must not leave in-flight request dangling'); - assert.ok(elapsed < 4000, `Switch must not wait out drain timeout, took ${elapsed}ms`); + assert.strictEqual(router.inFlightRequests, 0, 'Recovery must not leave in-flight request dangling'); + assert.ok(elapsed < 4000, `Recovery must not wait out drain timeout, took ${elapsed}ms`); await router.openWorkspace(root); - await server.stop(); }); it('CacheManager: pruneDiskCache counts overflow size, enforces maxDiskBytes, respects grace period & memory protection', async () => { @@ -207,10 +219,11 @@ describe('resource-cleanup', () => { assert.ok(reportAmbiguous.limitations.some((l) => l.includes('未能唯一解析'))); }); - it('ImpactAnalyzer: unresolved explicitFileHint strictly returns uniqueResolution=false and UNKNOWN', async () => { + it('ImpactAnalyzer: unresolved explicitFileHint strictly returns uniqueResolution=false and UNKNOWN', async t => { const config = getDefaultConfig(FIXTURE_DOTNET); config.cacheDir = path.join(testCacheDir, 'impact_unique'); const router = new ToolRouter(config); + t.after(() => router.dispose()); await router.initialize(); const report = await router.impact.analyzeImpact('TotallyNonExistentHelper.cs'); @@ -219,7 +232,6 @@ describe('resource-cleanup', () => { assert.strictEqual(report.confidence, 'UNCERTAIN'); assert.strictEqual(report.analysisCompleteness, 'unindexed'); - await router.dispose(); }); it('CacheManager: computeWorkspaceFingerprint handles non-ASCII and detects in-place content modifications', async () => { diff --git a/tests/resource-identity.test.ts b/tests/resource-identity.test.ts new file mode 100644 index 0000000..5e0ff09 --- /dev/null +++ b/tests/resource-identity.test.ts @@ -0,0 +1,58 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import cp from 'node:child_process'; +import { ResourceManager, killProcessTree } from '../src/Core/ResourceManager.js'; + +it('cleanup does not invoke a resource unregistered while an earlier disposer is pending', async () => { + const resources = new ResourceManager(); + let calls = 0, entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const id = resources.register('disposable', 'already-released', () => { calls++; }); + resources.register('disposable', 'barrier', async () => { entered(); await gate; }); + const cleanup = resources.dispose(); + await started; + resources.unregister(id); + release(); + await cleanup; + assert.equal(calls, 0); + assert.deepEqual(resources.getCloseReport().results.map(item => item.owner), ['barrier']); + assert.equal(resources.list().length, 0); +}); + +it('unregister also wins before the queued disposer microtask starts', async () => { + const resources = new ResourceManager(); + let calls = 0; + const id = resources.register('disposable', 'released-before-entry', () => { calls++; }); + const cleanup = resources.dispose(); + resources.unregister(id); + await cleanup; + assert.equal(calls, 0); + assert.deepEqual(resources.getCloseReport().results, []); +}); + +it('an exited child identity never probes or terminates a potentially reused numeric PID', async t => { + const probe = t.mock.method(process, 'kill', () => { throw new Error('Must not inspect or signal an exited child PID'); }); + for (const state of [{ exitCode: 0, signalCode: null }, { exitCode: null, signalCode: 'SIGTERM' }]) { + const child = Object.assign(new cp.ChildProcess(), { pid: 42424, ...state }); + const kill = t.mock.method(child, 'kill', () => { throw new Error('Must not signal an exited child'); }); + await killProcessTree(child); + assert.equal(kill.mock.callCount(), 0); + } + assert.equal(probe.mock.callCount(), 0); +}); + +it('registration and natural exit remove both process listeners and stale ownership', async t => { + const probe = t.mock.method(process, 'kill', () => { throw new Error('Must not inspect an unregistered PID'); }); + for (const alreadyExited of [false, true]) { + const resources = new ResourceManager(); + const child = Object.assign(new cp.ChildProcess(), { pid: 42424, exitCode: alreadyExited ? 0 : null }); + resources.registerProcess('exited-child', child); + if (!alreadyExited) { child.exitCode = 0; child.emit('exit', 0, null); } + assert.equal(resources.childProcessCount(), 0); + assert.equal(child.listenerCount('exit'), 0); + assert.equal(child.listenerCount('close'), 0); + await resources.dispose(); + } + assert.equal(probe.mock.callCount(), 0); +}); diff --git a/tests/roslyn-contracts.test.ts b/tests/roslyn-contracts.test.ts index af11358..f8224b9 100644 --- a/tests/roslyn-contracts.test.ts +++ b/tests/roslyn-contracts.test.ts @@ -204,7 +204,7 @@ it('rejects a Host that fails to confirm the configured additional inputs and re const host = path.join(root, 'host.cjs'); await fs.writeFile(path.join(root, 'App.csproj'), ''); await fs.writeFile(path.join(root, 'schema.yaml'), 'mode: original'); - await fs.writeFile(host, `console.log(JSON.stringify({id:null,type:'ready',success:true,protocolVersion:2,snapshot:'${'a'.repeat(32)}',configuration:'Debug',framework:'net10.0',processTreeGuard:true,hostIdentity:{version:'${WINCODE_VERSION}',configuration:'Release',protocolVersion:2},inputPolicy:{version:1,additionalInputs:[]}})); process.stdin.resume(); setInterval(()=>{},1000);`); + await fs.writeFile(host, `console.log(JSON.stringify({id:null,type:'ready',success:true,protocolVersion:2,snapshot:'${'a'.repeat(32)}',configuration:'Debug',framework:'net10.0',processTreeGuard:true,hostIdentity:{version:'${WINCODE_VERSION}',configuration:'Release',protocolVersion:2},inputPolicy:{version:2,additionalInputs:[]}})); process.stdin.resume(); setInterval(()=>{},1000);`); config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', targetFramework: 'net10.0', dotnetPath: process.execPath, hostPath: host, additionalInputs: ['schema.yaml'] }; const adapter = new RoslynAdapter(config, resources, () => []); @@ -264,10 +264,12 @@ it('Roslyn cleanup failure enters sticky E1 recovery and never starts Local text try { await router.initialize(); (router.roslyn as any).client = { close: async () => { closes++; throw new Error('injected cleanup failure'); } }; - await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); + await (router as any).watch.stop(); + await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); assert.equal(router.workspaceRecoveryState?.recoveryAction, 'restart_gateway'); await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); - assert.equal(config.workspaceRoot, b); + await assert.rejects(router.openWorkspace(b), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + assert.equal(config.workspaceRoot, a); assert.equal(closes, 1); await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); const health = await router.getRuntimeHealth(); @@ -283,9 +285,14 @@ it('Roslyn cleanup failure enters sticky E1 recovery and never starts Local text } }); -for (const identity of [undefined, { version: '0.0.0', configuration: 'Release', protocolVersion: 2 }, - { version: WINCODE_VERSION, configuration: 'Debug', protocolVersion: 2 }]) { - it('rejects missing or mismatched Code Host build identity and reaps its process', async () => { +const currentHostIdentity = { version: WINCODE_VERSION, configuration: 'Release', protocolVersion: 2 }; +for (const rejected of [ + ...[undefined, { ...currentHostIdentity, version: '0.0.0' }, { ...currentHostIdentity, configuration: 'Debug' }] + .map(identity => ({ label: 'build identity', identity, policy: { version: 2, additionalInputs: [] }, code: 'HOST_VERSION_MISMATCH' })), + ...[undefined, { version: 1, additionalInputs: [] }] + .map(policy => ({ label: 'input policy', identity: currentHostIdentity, policy, code: 'HOST_PROTOCOL_ERROR' })), +]) { + it(`rejects missing or mismatched Code Host ${rejected.label} and reaps its process`, async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-host-version-')); const resources = new ResourceManager(); try { @@ -293,14 +300,14 @@ for (const identity of [undefined, { version: '0.0.0', configuration: 'Release', const host = path.join(root, 'host.cjs'); await fs.writeFile(path.join(root, 'App.csproj'), ''); const ready = { id: null, type: 'ready', success: true, protocolVersion: 2, snapshot: 'a'.repeat(32), - configuration: 'Debug', framework: 'net10.0', processTreeGuard: true, hostIdentity: identity, - inputPolicy: { version: 1, additionalInputs: [] } }; + configuration: 'Debug', framework: 'net10.0', processTreeGuard: true, hostIdentity: rejected.identity, + inputPolicy: rejected.policy }; await fs.writeFile(host, 'console.log(' + JSON.stringify(JSON.stringify(ready)) + '); process.stdin.resume(); setInterval(()=>{},1000);'); config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', targetFramework: 'net10.0', dotnetPath: process.execPath, hostPath: host }; const adapter = new RoslynAdapter(config, resources, () => []); await assert.rejects(adapter.findSymbolsDetailed('Service'), - (error: unknown) => error instanceof CodeQueryError && error.errorCode === 'HOST_VERSION_MISMATCH'); + (error: unknown) => error instanceof CodeQueryError && error.errorCode === rejected.code); assert.equal(adapter.getKnownHealth().snapshotId, null); assert.equal(resources.childProcessCount(), 0); } finally { @@ -311,6 +318,23 @@ for (const identity of [undefined, { version: '0.0.0', configuration: 'Release', }); } +it('rejects nonliteral output configuration segments before registering or starting a Host', async () => { + const resources = new ResourceManager(); + try { + for (const field of ['configuration', 'targetFramework'] as const) { + for (const value of ['.', '..', 'Debug.', 'Debug ', 'a/b', 'a\\b', 'x;y', '$(Configuration)', '%2e%2e', '@(Compile)', 'a\u0000b']) { + 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('unused-host.dll'), [field]: value }; + assert.throws(() => new RoslynAdapter(config, resources, () => []), + (error: unknown) => error instanceof CodeQueryError && error.errorCode === 'INVALID_ARGUMENT', `${field}: ${value}`); + assert.equal(resources.childProcessCount(), 0); + } + } + } finally { await resources.dispose(); } +}); + it('text mode rejects selected identities before any analysis or resource admission', async () => { const router = new ToolRouter(getDefaultConfig(process.cwd())); let calls = 0; diff --git a/tests/runtime-cache-regressions.test.ts b/tests/runtime-cache-regressions.test.ts index 8318067..f5b45b0 100644 --- a/tests/runtime-cache-regressions.test.ts +++ b/tests/runtime-cache-regressions.test.ts @@ -1,4 +1,4 @@ -import { it } from 'node:test'; +import { it, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -16,6 +16,183 @@ import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; const deferred = () => { let resolve!: () => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; const body = (result: any) => result.structuredContent ?? JSON.parse(result.content[0].text); +async function cacheStateFixture(run: (root: string) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-cache-state-')); + try { await run(root); } finally { + assert.equal(path.dirname(root), path.resolve(os.tmpdir())); + assert.ok(path.basename(root).startsWith('wincode-cache-state-')); + await fs.rm(root, { recursive: true, force: true }); + } +} + +// Pause after the real file has been read and closed, so Windows can replace it. +function pauseCacheFileRead(t: TestContext, file: string) { + const entered = deferred(), release = deferred(); + const open = fs.open.bind(fs); + let paused = false; + t.mock.method(fs, 'open', async (...args: Parameters) => { + const handle = await open(...args); + if (!paused && String(args[0]) === file && args[1] === 'r') { + paused = true; + const close = handle.close.bind(handle); + handle.close = async () => { await close(); entered.resolve(); await release.promise; }; + } + return handle; + }); + return { entered: entered.promise, release: release.resolve }; +} + +for (const change of ['replacement', 'eviction', 'clear', 'namespace-reset']) { + it(`cache state: an awaiting memory hit cannot undo ${change}`, async () => cacheStateFixture(async root => { + const cache = new CacheManager(root, 1); + await cache.set('key', 'old'); + const reading = cache.get('key'); + if (change === 'replacement') await cache.set('key', 'new'); + else if (change === 'eviction') await cache.set('other', 'B'); + else if (change === 'clear') await cache.clear(); + else cache.setNamespace(path.join(root, 'workspace')); + assert.equal(await reading, null, 'an invalidated read must become a miss'); + assert.equal(cache.memoryEntryCount, change === 'replacement' || change === 'eviction' ? 1 : 0); + assert.equal(cache.estimatedMemoryBytes, change === 'replacement' ? 6 : change === 'eviction' ? 2 : 0); + if (change === 'replacement') { + assert.equal(await cache.get('key'), 'new'); + assert.equal(await new CacheManager(root).get('key'), 'new'); + } else if (change === 'eviction') assert.equal(await cache.get('other'), 'B'); + else assert.equal(await cache.get('key'), null); + })); +} + +for (const change of ['replacement', 'clear', 'namespace-roundtrip', 'rebind']) { + it(`cache state: a suspended disk read cannot undo ${change}`, { timeout: 10000 }, async t => cacheStateFixture(async root => { + const writer = new CacheManager(root), reader = new CacheManager(root); + writer.setNamespace(root); reader.setNamespace(root); + await writer.set('key', 'old'); + const file = path.join(root, (await fs.readdir(root)).find(name => name.endsWith('.json'))!); + const pause = pauseCacheFileRead(t, file); + const reading = reader.get('key'); + try { + await pause.entered; + if (change === 'replacement') await reader.set('key', 'new'); + else if (change === 'clear') await reader.clear(); + else if (change === 'rebind') await reader.rebind(path.join(root, 'rebound')); + else { reader.setNamespace(path.join(root, 'other')); reader.setNamespace(root); } + } finally { pause.release(); } + assert.equal(await reading, null, 'previous directory/namespace/value must not be hydrated'); + assert.equal(reader.memoryEntryCount, change === 'replacement' ? 1 : 0); + assert.equal(reader.estimatedMemoryBytes, change === 'replacement' ? 6 : 0); + const expected = change === 'replacement' ? 'new' : change === 'namespace-roundtrip' ? 'old' : null; + assert.equal(await reader.get('key'), expected, 'later valid reads still work'); + })); +} + +it('cache state: failed backing validation cannot delete a replacement in memory', { timeout: 10000 }, async t => cacheStateFixture(async root => { + const cache = new CacheManager(root); + const overflowPath = await cache.writeOverflow('old'); + await cache.set('key', { overflowPath }); + await fs.writeFile(overflowPath, 'bad'); + const pause = pauseCacheFileRead(t, overflowPath); + const reading = cache.get('key'); + try { await pause.entered; await cache.set('key', 'new'); } + finally { pause.release(); } + assert.equal(await reading, null); + assert.equal(cache.memoryEntryCount, 1); + assert.equal(cache.estimatedMemoryBytes, 6); + assert.equal(await cache.get('key'), 'new'); +})); + +it('cache state: an earlier failed backing write cannot delete a later accepted value', { timeout: 10000 }, async t => cacheStateFixture(async root => { + const cache = new CacheManager(root); + const overflowPath = await cache.writeOverflow('old'); + await fs.unlink(overflowPath); + const entered = deferred(), release = deferred(), lstat = fs.lstat.bind(fs); + let paused = false; + t.mock.method(fs, 'lstat', async (...args: Parameters) => { + if (!paused && String(args[0]) === overflowPath) { paused = true; entered.resolve(); await release.promise; } + return lstat(...args); + }); + const first = cache.set('key', { overflowPath }); + let second: Promise | undefined; + try { await entered.promise; second = cache.set('key', 'new'); } + finally { release.resolve(); } + await Promise.all([first, second]); + assert.equal(cache.memoryEntryCount, 1); + assert.equal(cache.estimatedMemoryBytes, 6); + assert.equal(await cache.get('key'), 'new'); + assert.equal(await new CacheManager(root).get('key'), 'new'); +})); + +it('cache state: expiration of a suspended disk read cannot unlink a newer write', { timeout: 10000 }, async t => cacheStateFixture(async root => { + const writer = new CacheManager(root), reader = new CacheManager(root); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + await writer.set('key', 'old', { ttlMs: 10 }); + now += 20; + const file = path.join(root, (await fs.readdir(root)).find(name => name.endsWith('.json'))!); + const pause = pauseCacheFileRead(t, file), reading = reader.get('key'); + try { await pause.entered; await reader.set('key', 'new'); } + finally { pause.release(); } + assert.equal(await reading, null); + assert.equal(await reader.get('key'), 'new'); + assert.equal(await new CacheManager(root).get('key'), 'new', 'cleanup must preserve the replacement on disk'); +})); + +it('cache state: an obsolete oversized stat cannot unlink a newer write', { timeout: 10000 }, async t => cacheStateFixture(async root => { + const cache = new CacheManager(root, 5, 5, { maxEntryBytes: 1024 }); + await cache.set('key', 'old'); + const file = path.join(root, (await fs.readdir(root)).find(name => name.endsWith('.json'))!); + await fs.appendFile(file, ' '.repeat(2048)); + const reader = new CacheManager(root, 5, 5, { maxEntryBytes: 1024 }); + const entered = deferred(), release = deferred(), lstat = fs.lstat.bind(fs); + let paused = false; + t.mock.method(fs, 'lstat', async (...args: Parameters) => { + const stat = await lstat(...args); + if (!paused && String(args[0]) === file) { paused = true; entered.resolve(); await release.promise; } + return stat; + }); + const reading = reader.get('key'); + try { await entered.promise; await reader.set('key', 'new'); } + finally { release.resolve(); } + assert.equal(await reading, null); + assert.equal(await new CacheManager(root).get('key'), 'new', 'cleanup must preserve the replacement on disk'); +})); + +for (const change of ['clear', 'disk-only-write']) { + it(`cache state: disk reads drain an already accepted ${change}`, async t => cacheStateFixture(async root => { + const cache = new CacheManager(root, 5, 5, { maxMemoryBytes: 0 }); + await cache.set('key', 'old'); + const release = deferred(); + // Hold the existing writer queue, as in the accepted-write/clear lifecycle regression. + (cache as any).writeChain = release.promise; + const lstat = fs.lstat.bind(fs); + let filesystemReads = 0; + t.mock.method(fs, 'lstat', (...args: Parameters) => { filesystemReads++; return lstat(...args); }); + const writing = change === 'clear' ? cache.clear() : cache.set('key', 'new'); + const reading = cache.get('key'); + let prematureReads: number; + try { + await Promise.resolve(); await Promise.resolve(); + prematureReads = filesystemReads; + } finally { release.resolve(); } + const [, result] = await Promise.all([writing, reading]); + assert.equal(prematureReads, 0, 'disk reads must not bypass the accepted writer queue'); + assert.equal(result, change === 'clear' ? null : 'new'); + assert.equal(cache.memoryEntryCount, 0); + assert.equal(cache.estimatedMemoryBytes, 0); + })); +} + +it('cache state: parallel valid hits still return data and respect the shared LRU budget', async () => cacheStateFixture(async root => { + const writer = new CacheManager(root), reader = new CacheManager(root, 1, 20, { maxMemoryBytes: 4 }); + await writer.set('a', 'A'); await writer.set('b', 'BB'); + assert.deepEqual(await Promise.all([reader.get('a'), reader.get('b')]), ['A', 'BB']); + assert.equal(reader.memoryEntryCount, 1); + assert.ok(reader.estimatedMemoryBytes === 2 || reader.estimatedMemoryBytes === 4); + await reader.set('a', 'A'); + assert.deepEqual(await Promise.all([reader.get('a'), reader.get('a')]), ['A', 'A']); + assert.equal(reader.memoryEntryCount, 1); + assert.equal(reader.estimatedMemoryBytes, 2); +})); + it('process observation excludes stale parent PID edges without hiding real descendants', async () => { const { selectOwnedProcesses } = await import(pathToFileURL(path.resolve('scripts/lib/owned-processes.mjs')).href); const proc = (ProcessId: number, ParentProcessId: number, time: number) => ({ ProcessId, ParentProcessId, CreationDate: `/Date(${time})/` }); @@ -125,6 +302,119 @@ it('peer eviction causes recomputation for both memory and disk hits with missin } finally { await adapter.dispose(); await cache.flush(); await peer.flush(); } })); +for (const layer of ['memory', 'disk']) it(`corrupted overflow is rebuilt before a ${layer} cache hit even when size and mtime match`, async () => fixture(async (router, root) => { + await fs.writeFile(path.join(root, 'Large.cs'), 'class Large {}\n' + '// evidence\n'.repeat(2000)); + const cache = new CacheManager(router.config.cacheDir, 20, 20, { maxEntryBytes: 8192 }); + const adapter = new RepomixAdapter(router.config, cache); + const options = { candidateFiles: ['Large.cs'] }; + let reader = adapter; + try { + const first = await adapter.packWorkspace(options); + assert.ok(first.overflowPath); assert.equal((await adapter.packWorkspace(options)).fromCache, true); + const before = await fs.stat(first.overflowPath); + const content = await fs.readFile(first.overflowPath, 'utf8'); + await fs.writeFile(first.overflowPath, content.replace('class Large', 'class Wrong')); + await fs.utimes(first.overflowPath, before.atime, before.mtime); + assert.equal((await fs.stat(first.overflowPath)).size, before.size); + if (layer === 'disk') reader = new RepomixAdapter(router.config, + new CacheManager(router.config.cacheDir, 20, 20, { maxEntryBytes: 8192 })); + const repaired = await reader.packWorkspace(options); + assert.equal(repaired.fromCache, false, 'existing corrupted file cannot count as a valid cache hit'); + assert.ok(repaired.overflowPath); + assert.notEqual(repaired.overflowPath, first.overflowPath); + assert.equal(await fs.readFile(repaired.overflowPath, 'utf8'), content); + } finally { if (reader !== adapter) await reader.dispose(); await adapter.dispose(); await cache.flush(); } +})); + +for (const damage of ['changed-body', 'other-key']) it(`disk cache rejects valid JSON with ${damage} before exposing its data`, async () => fixture(async (router) => { + const cache = new CacheManager(router.config.cacheDir); + const reader = new CacheManager(router.config.cacheDir); + await cache.set('wanted', { source: 'A' }, { fingerprint: 'same' }); + await cache.set('other', { source: 'B' }, { fingerprint: 'same' }); + const files = (await fs.readdir(cache.directory)).filter(file => file.endsWith('.json')); + const records = await Promise.all(files.map(async file => ({ file: path.join(cache.directory, file), + entry: JSON.parse(await fs.readFile(path.join(cache.directory, file), 'utf8')) }))); + const wanted = records.find(record => record.entry.data?.source === 'A')!; + const other = records.find(record => record.entry.data?.source === 'B')!; + if (damage === 'changed-body') { wanted.entry.data.source = 'B'; await fs.writeFile(wanted.file, JSON.stringify(wanted.entry)); } + else await fs.copyFile(other.file, wanted.file); + assert.equal(await reader.get('wanted', 'same'), null, 'readers must return a miss instead of another payload'); + assert.deepEqual(await reader.get('other', 'same'), { source: 'B' }); +})); + +it('a cache file growing after the opened-handle size check stays within its original read budget', async t => fixture(async router => { + const cache = new CacheManager(router.config.cacheDir, 20, 20, { maxEntryBytes: 1024 }); + await cache.set('growing', { source: 'A' }); + const name = (await fs.readdir(cache.directory)).find(file => file.endsWith('.json'))!; + const file = path.join(cache.directory, name), size = (await fs.stat(file)).size; + const originalOpen = fs.open.bind(fs); + let totalRead = 0, changed = false; + t.mock.method(fs, 'open', async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === file) { + const stat = handle.stat.bind(handle), read = handle.read.bind(handle); + t.mock.method(handle, 'stat', async () => { + const result = await stat(); + if (!changed) { changed = true; await fs.appendFile(file, ' '.repeat(8192)); } + return result; + }); + t.mock.method(handle, 'read', async (...readArgs: any[]) => { + const result = await (read as any)(...readArgs); totalRead += result.bytesRead; return result; + }); + } + return handle; + }); + assert.equal(await new CacheManager(cache.directory, 20, 20, { maxEntryBytes: 1024 }).get('growing'), null); + assert.equal(changed, true); assert.ok(totalRead <= size + 1, `${totalRead} bytes read for original size ${size}`); +})); + +for (const kind of ['inline', 'overflow']) it(`legacy ${kind} cache without integrity metadata is rebuilt and then reusable`, async () => fixture(async (router, root) => { + await fs.writeFile(path.join(root, 'Legacy.cs'), 'class Legacy {}\n' + (kind === 'overflow' ? '// evidence\n'.repeat(2000) : '')); + const cache = new CacheManager(router.config.cacheDir, 20, 20, { maxEntryBytes: 8192 }); + const adapter = new RepomixAdapter(router.config, cache); + const reader = new RepomixAdapter(router.config, + new CacheManager(router.config.cacheDir, 20, 20, { maxEntryBytes: 8192 })); + const options = { candidateFiles: ['Legacy.cs'] }; + try { + const original = await adapter.packWorkspace(options); + assert.equal(Boolean(original.overflowPath), kind === 'overflow'); + const names = (await fs.readdir(cache.directory)).filter(name => name.endsWith('.json')); + assert.equal(names.length, 1); + const file = path.join(cache.directory, names[0]); + const entry = JSON.parse(await fs.readFile(file, 'utf8')); + delete entry.integrity; delete entry.backingFile; + await fs.writeFile(file, JSON.stringify(entry)); + const rebuilt = await reader.packWorkspace(options); + assert.equal(rebuilt.fromCache, false, 'legacy metadata must not establish a verified cache hit'); + if (kind === 'overflow') { + assert.ok(original.overflowPath && rebuilt.overflowPath); + assert.notEqual(rebuilt.overflowPath, original.overflowPath); + assert.equal(await fs.readFile(rebuilt.overflowPath, 'utf8'), await fs.readFile(original.overflowPath, 'utf8')); + } else assert.equal(rebuilt.content, original.content); + assert.equal((await reader.packWorkspace(options)).fromCache, true, 'rebuilt data must remain cacheable'); + } finally { await reader.dispose(); await adapter.dispose(); await cache.flush(); } +})); + +it('cache JSON growing after the path size check cannot exceed the entry budget and become a hit', async t => fixture(async router => { + const cache = new CacheManager(router.config.cacheDir, 20, 20, { maxEntryBytes: 1024 }); + await cache.set('path-growth', { source: 'A' }); + const name = (await fs.readdir(cache.directory)).find(file => file.endsWith('.json'))!; + const file = path.join(cache.directory, name), originalStat = fs.lstat.bind(fs); + let changed = false; + t.mock.method(fs, 'lstat', async (...args: Parameters) => { + const stat = await originalStat(...args); + if (args[0] === file && !changed) { + changed = true; + // Legal trailing JSON whitespace keeps payload integrity unchanged while violating the read budget. + await fs.appendFile(file, ' '.repeat(8192)); + } + return stat; + }); + assert.equal(await new CacheManager(cache.directory, 20, 20, { maxEntryBytes: 1024 }).get('path-growth'), null); + assert.equal(changed, true, 'the growth must occur between observation and read'); + assert.ok((await fs.stat(file)).size > 1024); +})); + it('cancelling same-root metadata confirmation leaves the healthy session usable', async () => fixture(async (router, root) => { const original = router.workspace.openWorkspace.bind(router.workspace); const entered = deferred(), finish = deferred(), controller = new AbortController(); diff --git a/tests/runtime-contract.test.ts b/tests/runtime-contract.test.ts index 56d526d..3be22be 100644 --- a/tests/runtime-contract.test.ts +++ b/tests/runtime-contract.test.ts @@ -10,7 +10,7 @@ import { ToolRouter } from '../src/Core/ToolRouter.js'; import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; import { WINCODE_TOOLS, contractHash, toolsContractHash } from '../src/Gateway/Protocol.js'; -it('hello and tools/list share an immutable registered contract and runtime survives workspace switching', async () => { +it('hello and tools/list share an immutable registered contract and runtime survives a rejected workspace switch', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-contract-')); const config = getDefaultConfig(root); @@ -42,7 +42,9 @@ it('hello and tools/list share an immutable registered contract and runtime surv assert.equal(selected.toolContract.tool.schemaHash, contractHash(schema)); await fs.mkdir(path.join(root, 'other')); const switched = await client.callTool({ name: 'workspace_open', arguments: { path: path.join(root, 'other') } }); - assert.notEqual(switched.isError, true); + assert.equal(switched.isError, true); + assert.equal(JSON.parse((switched.content[0] as any).text).errorCode, 'WORKSPACE_MISMATCH'); + assert.equal((await call()).workspace, root); assert.deepEqual((await call()).runtime, hello.runtime); for (const args of [{ toolName: 'missing' }, { greeting: 5 }]) { assert.equal((await client.callTool({ name: 'wincode_hello_world', arguments: args })).isError, true); diff --git a/tests/stability-lifecycle.test.ts b/tests/stability-lifecycle.test.ts index 78f513d..80aa8e9 100644 --- a/tests/stability-lifecycle.test.ts +++ b/tests/stability-lifecycle.test.ts @@ -60,7 +60,7 @@ describe('stability-lifecycle', () => { assert.strictEqual(router.resources.isDisposed, true); }); - it('workspace A → workspace B switches session namespace and does not leak symbols', async () => { + it('independent fixed workspaces retain separate namespaces without leaking symbols', async () => { const config = getDefaultConfig(root); config.cacheDir = path.join(testCacheDir, 'ws_switch'); const router = new ToolRouter(config); @@ -70,20 +70,27 @@ describe('stability-lifecycle', () => { await router.cache.set('leak_probe', { workspace: 'A' }); assert.ok(await router.cache.get('leak_probe')); - const opened = await router.openWorkspace(FIXTURE_DOTNET); - assert.strictEqual(opened.type, 'dotnet'); - const nsB = router.cache.currentNamespace; - assert.notStrictEqual(nsB, nsA); - assert.strictEqual(router.session.current?.workspaceRoot, FIXTURE_DOTNET); - assert.strictEqual(await router.cache.get('leak_probe'), null, 'memory/namespace must not leak project A keys'); + await assert.rejects(router.openWorkspace(FIXTURE_DOTNET), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + const peerConfig = getDefaultConfig(FIXTURE_DOTNET); + peerConfig.cacheDir = config.cacheDir; + const peer = new ToolRouter(peerConfig); + await peer.initialize(); + try { + const opened = await peer.openWorkspace(FIXTURE_DOTNET); + assert.strictEqual(opened.type, 'dotnet'); + const nsB = peer.cache.currentNamespace; + assert.notStrictEqual(nsB, nsA); + assert.strictEqual(peer.session.current?.workspaceRoot, FIXTURE_DOTNET); + assert.strictEqual(await peer.cache.get('leak_probe'), null, 'memory/namespace must not leak project A keys'); + assert.deepStrictEqual(await router.cache.get('leak_probe'), { workspace: 'A' }); - const symbols = await router.text.findSymbols('MemoryService', 'class'); - assert.ok(symbols.some((s) => s.name === 'MemoryService')); - assert.ok(symbols.every((s) => s.file.replace(/\\/g, '/').includes('MiniDesk') || s.file.endsWith('MemoryService.cs') || s.file.includes('Core'))); + const symbols = await peer.text.findSymbols('MemoryService', 'class'); + assert.ok(symbols.some((s) => s.name === 'MemoryService')); + assert.ok(symbols.every((s) => s.file.replace(/\\/g, '/').includes('MiniDesk') || s.file.endsWith('MemoryService.cs') || s.file.includes('Core'))); - await router.openWorkspace(root); - assert.strictEqual(path.resolve(router.config.workspaceRoot), path.resolve(root)); - await router.dispose(); + await router.openWorkspace(root); + assert.strictEqual(path.resolve(router.config.workspaceRoot), path.resolve(root)); + } finally { await peer.dispose(); await router.dispose(); } }); }); }); diff --git a/tests/stage1-cleanup.test.ts b/tests/stage1-cleanup.test.ts index 2a31e57..24a21f8 100644 --- a/tests/stage1-cleanup.test.ts +++ b/tests/stage1-cleanup.test.ts @@ -44,14 +44,12 @@ it('project summaries use WPF build declarations despite an unrelated directory assert.ok(report.recommendedAgentFocus.includes('not an architecture judgment')); })); -it('workspace metadata failure restores root and trash paths before rejecting', async () => isolated(async root => { +it('workspace metadata failure preserves the fixed root and trash paths', async () => isolated(async root => { const config = getDefaultConfig(root); const previousTrash = config.trashDir; const workspace = new WorkspaceManager(config); - const next = path.join(root, 'next'); - await fs.mkdir(next); (workspace as any).discoverProject = async () => { throw new Error('simulated read failure'); }; - await assert.rejects(workspace.openWorkspace(next), /simulated read failure/); + await assert.rejects(workspace.openWorkspace(root), /simulated read failure/); assert.equal(config.workspaceRoot, root); assert.equal(config.trashDir, previousTrash); })); diff --git a/tests/tool-contracts.test.ts b/tests/tool-contracts.test.ts index 4daa4f1..68efc18 100644 --- a/tests/tool-contracts.test.ts +++ b/tests/tool-contracts.test.ts @@ -65,7 +65,7 @@ const expectedCalls: Record = { wincode_find_references: { method: 'findCodeReferences', args: ['Target', 'Target.ts', ''] }, analyze_change_impact: { method: 'analyzeChangeImpact', args: ['Target', ''] }, wincode_analyze_change_impact: { method: 'analyzeChangeImpact', args: ['Target', ''] }, - wincode_diagnose_project: { method: 'diagnoseProject', args: [] }, + wincode_diagnose_project: { method: 'diagnoseProject', args: [''] }, wincode_plan_refactoring: { method: 'planRefactoring', args: ['Target', 'Improve reliability', ''] }, wincode_safe_move_to_trash: { method: 'moveToTrash', args: ['Target.ts', 'fixture'] }, wincode_ui_list_windows: { method: 'listUiWindows', args: [{ pid: 5 }, ''] }, @@ -94,7 +94,8 @@ it('calls all 15 published tools and the hidden alias; unknown fields do not rea assert.ok(!published.some(tool => tool.name === 'wincode_workspace_open')); assert.deepEqual(new Set([...published.map(tool => tool.name), 'wincode_workspace_open']), new Set(Object.keys(examples))); const responses = new Map(); - for (const [name, args] of Object.entries(examples)) { + for (const [name, input] of Object.entries(examples)) { + const args = name === 'workspace_open' || name === 'wincode_workspace_open' ? { ...input, path: router.config.workspaceRoot } : input; calls.length = 0; const baseline: any = await client.callTool({ name, arguments: args }); assert.notEqual(baseline.isError, true, `${name}: ${JSON.stringify(baseline)}`); @@ -103,7 +104,8 @@ it('calls all 15 published tools and the hidden alias; unknown fields do not rea // AbortSignal is transport-owned and differs for every call; normalize only that field. const comparable = (call: { method: string; args: unknown[] }) => ({ method: call.method, args: call.args.map(value => value instanceof AbortSignal ? '' : value) }); - const expected = expectedCalls[name]; + const expected = structuredClone(expectedCalls[name]); + if (name === 'workspace_open' || name === 'wincode_workspace_open') expected.args[0] = router.config.workspaceRoot; assert.deepEqual(comparable(calls[0]), expected, `${name} dispatch`); calls.length = 0; const extended: any = await client.callTool({ name, arguments: { ...args, futureOption: { enabled: true } } }); diff --git a/tests/ui-inspect-mcp.test.ts b/tests/ui-inspect-mcp.test.ts index c9f77b3..229ad92 100644 --- a/tests/ui-inspect-mcp.test.ts +++ b/tests/ui-inspect-mcp.test.ts @@ -376,32 +376,35 @@ describe('WinCode MCP UI Inspect Protocol & End-to-End Suite', () => { assert.strictEqual(nextData.success, true); }); - it('14. cross-workspace switch to external project still resolves helper and executes inspect successfully', async () => { + it('14. a rejected switch preserves UI access and an independent external workspace resolves the installed helper', async () => { const tempWs = path.resolve(root, 'test-tmp/external_wpf_target'); await fsPromises.mkdir(tempWs, { recursive: true }); - // Switch workspace to external directory which has NO tools/ directory + // The external directory has no tools/ folder and requires its own connection. const switchRes = await client.callTool({ name: 'workspace_open', arguments: { path: tempWs }, }); - assert.ok(!switchRes.isError); - - // Now inspect target application from this external workspace - const res = await client.callTool({ - name: 'wincode_ui_inspect', - arguments: { pid: wpfPid, capture: 'none', maxDepth: 2 }, - }); - assert.ok(!res.isError); - const data = JSON.parse(getContent(res)[0].text!); - assert.strictEqual(data.success, true); - assert.strictEqual(data.pid, wpfPid); - - // Switch back to root workspace - await client.callTool({ - name: 'workspace_open', - arguments: { path: root }, - }); + assert.strictEqual(switchRes.isError, true); + assert.strictEqual(JSON.parse(getContent(switchRes)[0].text!).errorCode, 'WORKSPACE_MISMATCH'); + assert.strictEqual(router.config.workspaceRoot, root); + const peerConfig = getDefaultConfig(tempWs); + peerConfig.adapters.repomix.useCli = false; + const peerRouter = new ToolRouter(peerConfig), peerServer = new WinCodeMcpServer(peerRouter); + const peer = new Client({ name: 'external-ui-workspace', version: '1' }); + try { + await peerRouter.initialize(); + const [left, right] = InMemoryTransport.createLinkedPair(); + await Promise.all([peer.connect(left), (peerServer as any).server.connect(right)]); + for (const connection of [client, peer]) { + const res = await connection.callTool({ name: 'wincode_ui_inspect', + arguments: { pid: wpfPid, capture: 'none', maxDepth: 2 } }); + assert.ok(!res.isError); + const data = JSON.parse(getContent(res)[0].text!); + assert.strictEqual(data.success, true); + assert.strictEqual(data.pid, wpfPid); + } + } finally { await peer.close(); await peerServer.stop(); } }); it('15. inspect returns image scale and dimension metadata preserving coordinate alignment', async () => { diff --git a/tests/workspace-files.test.ts b/tests/workspace-files.test.ts index c2449e4..345cf1f 100644 --- a/tests/workspace-files.test.ts +++ b/tests/workspace-files.test.ts @@ -156,7 +156,7 @@ describe('workspace-files', () => { }); it('Phase 2: openWorkspace parses the portable .NET fixture sln, projects, metadata and tree', async () => { - const result = await ws.openWorkspace(FIXTURE_DOTNET); + const result = await new WorkspaceManager(getDefaultConfig(FIXTURE_DOTNET)).openWorkspace(FIXTURE_DOTNET); assert.strictEqual(result.type, 'dotnet'); assert.strictEqual(result.solution, 'MiniDesk.sln'); assert.strictEqual(result.language, 'C#'); @@ -169,34 +169,38 @@ describe('workspace-files', () => { ws.setRoot(root); }); - it('Workspace switching: openWorkspace and setRoot must synchronize trashDir and isolate cross-project deletions', async () => { + it('fixed workspace managers reject rebinding and isolate cross-project trash operations', async () => { const initialTrash = path.resolve(ws.trashDir); assert.strictEqual(initialTrash, path.join(root, 'trash')); - await ws.openWorkspace(FIXTURE_DOTNET); - assert.strictEqual(path.resolve(ws.root), FIXTURE_DOTNET); - const switchedTrash = path.resolve(ws.trashDir); - assert.strictEqual(switchedTrash, path.join(FIXTURE_DOTNET, 'trash'), 'trashDir must update to fixture workspace'); - - const rejectCrossProject = await ws.moveToTrash('../../../package.json', 'Try deleting host file from fixture'); - assert.strictEqual(rejectCrossProject.success, false); - assert.ok(rejectCrossProject.message.includes('outside the workspace boundary')); - - const tempBFile = path.join(FIXTURE_DOTNET, 'temp_test_b_file.txt'); - await fs.writeFile(tempBFile, 'File in fixture project', 'utf-8'); - - const trashBResult = await ws.moveToTrash('temp_test_b_file.txt', 'Safe deletion in fixture'); - assert.strictEqual(trashBResult.success, true); - assert.ok(trashBResult.trashPath.startsWith(path.join(FIXTURE_DOTNET, 'trash')), 'Must move to fixture trash'); - assert.ok(!trashBResult.trashPath.startsWith(path.join(root, 'trash')), 'Must NOT move to host trash'); - - await fs.rm(trashBResult.trashPath, { force: true }).catch(() => { }); - await fs.rm(`${trashBResult.trashPath}.meta.json`, { force: true }).catch(() => { }); - await fs.rm(path.join(FIXTURE_DOTNET, 'trash'), { recursive: true, force: true }).catch(() => { }); - - ws.setRoot(root); - assert.strictEqual(path.resolve(ws.root), root); - assert.strictEqual(path.resolve(ws.trashDir), path.join(root, 'trash'), 'trashDir must restore to host workspace'); + const peerRoot = await fs.mkdtemp(path.join(testCacheDir, 'project-b-')); + const peer = new WorkspaceManager(getDefaultConfig(peerRoot)); + try { + await assert.rejects(ws.openWorkspace(peerRoot), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + assert.throws(() => ws.setRoot(peerRoot), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); + assert.strictEqual(ws.root, root); + await peer.openWorkspace(peerRoot); + assert.strictEqual(peer.trashDir, path.join(peerRoot, 'trash')); + + const rejectCrossProject = await peer.moveToTrash('../../../package.json', 'Try deleting host file from fixture'); + assert.strictEqual(rejectCrossProject.success, false); + assert.ok(rejectCrossProject.message.includes('outside the workspace boundary')); + + const tempBFile = path.join(peerRoot, 'temp_test_b_file.txt'); + await fs.writeFile(tempBFile, 'File in fixture project', 'utf-8'); + + const trashBResult = await peer.moveToTrash('temp_test_b_file.txt', 'Safe deletion in fixture'); + assert.strictEqual(trashBResult.success, true); + assert.ok(trashBResult.trashPath.startsWith(path.join(peerRoot, 'trash')), 'Must move to fixture trash'); + assert.ok(!trashBResult.trashPath.startsWith(path.join(root, 'trash')), 'Must NOT move to host trash'); + + ws.setRoot(root); + assert.strictEqual(path.resolve(ws.root), root); + assert.strictEqual(path.resolve(ws.trashDir), path.join(root, 'trash'), 'trashDir stays bound to the original workspace'); + } finally { + assert.strictEqual(path.dirname(peerRoot), testCacheDir); + await fs.rm(peerRoot, { recursive: true, force: true }); + } }); }); }); diff --git a/tests/workspace-lifecycle.test.ts b/tests/workspace-lifecycle.test.ts index ca2012b..07003d3 100644 --- a/tests/workspace-lifecycle.test.ts +++ b/tests/workspace-lifecycle.test.ts @@ -36,8 +36,9 @@ it('ten sequential and ten concurrent workspace lifecycles close native watchers const router = new ToolRouter(config); try { await router.initialize(); - await router.openWorkspace(other); + await assert.rejects(router.openWorkspace(other), (error: any) => error.errorCode === 'WORKSPACE_MISMATCH'); await fs.writeFile(path.join(other, 'Changed.cs'), 'class Changed {}'); + await (router as any).watch.stop(); await router.openWorkspace(workspace); } finally { await router.dispose(); diff --git a/tests/workspace-summary.test.ts b/tests/workspace-summary.test.ts index 17d94cb..35c9dcf 100644 --- a/tests/workspace-summary.test.ts +++ b/tests/workspace-summary.test.ts @@ -120,7 +120,7 @@ it('invalid workspace and directory options fail without switching workspace', a const next = path.join(root, 'next'); await fs.mkdir(next); for (const args of [{ maxOutputChars: 1 }, { maxOutputChars: 8000.5 }, { includeTree: 'yes' }]) { - assert.equal((await call('workspace_open', { path: next, ...args })).isError, true, JSON.stringify(args)); + assert.equal((await call('workspace_open', { path: root, ...args })).isError, true, JSON.stringify(args)); assert.equal(router.workspace.root, root); } for (const args of [{ path: '..' }, { path: 'src/../src' }, { path: 'C:relative' }, { path: 'a'.repeat(4097) }, { maxDepth: 0 }, { maxDepth: 6 }, { maxEntries: 501 }, { includeIgnored: 'yes' }, { maxOutputChars: 200 }]) { @@ -129,8 +129,11 @@ it('invalid workspace and directory options fail without switching workspace', a const baseline = payload(await call('wincode_list_directory', { path: '.', maxDepth: 1 })); const extended = payload(await call('wincode_list_directory', { path: '.', maxDepth: 1, unsupported: true })); assert.deepEqual(extended.entries, baseline.entries, 'unknown fields cannot alter the directory scope'); - assert.notEqual((await call('workspace_open', { path: next, unsupported: true })).isError, true); - assert.equal(router.workspace.root, next, 'the declared workspace path still applies'); + assert.notEqual((await call('workspace_open', { path: root, unsupported: true })).isError, true); + const mismatch = await call('workspace_open', { path: next, unsupported: true }); + assert.equal(mismatch.isError, true); + assert.equal(JSON.parse(mismatch.content[0].text).errorCode, 'WORKSPACE_MISMATCH'); + assert.equal(router.workspace.root, root, 'extra fields cannot enable switching'); })); it('the opt-in tree is bounded and does not change the default summary contract', async () => fixture(async (root, _router, call) => { diff --git a/tools/WinCode.Code.Host/DesignTimeBuild.cs b/tools/WinCode.Code.Host/DesignTimeBuild.cs new file mode 100644 index 0000000..fc42079 --- /dev/null +++ b/tools/WinCode.Code.Host/DesignTimeBuild.cs @@ -0,0 +1,96 @@ +using Microsoft.Build.Evaluation; +using Microsoft.Build.Construction; +using Microsoft.Build.Globbing; +using Microsoft.Build.Exceptions; +using System.Text; +using System.Xml.Linq; + +/// 原项目只求值、不执行 targets;保留编译排除规则,并仅过滤不会进入默认编译的中间产物。 +internal sealed class DesignTimeBuild +{ + private readonly List<(string Path, bool CustomCompile, IMSBuildGlob[] Globs, HashSet Explicit)> outputs = []; + internal readonly List PrivateDirectories = []; + internal string Hook { get; private set; } = ""; + private string intermediateOutputPath = ""; + internal static DesignTimeBuild? Current; + internal static Dictionary Properties(string configuration, string framework) => new() { + ["Configuration"] = configuration, ["TargetFramework"] = framework, + ["RunAnalyzers"] = "false", ["RunAnalyzersDuringBuild"] = "false", + ["IntermediateOutputPath"] = Current!.intermediateOutputPath, + ["CustomBeforeMicrosoftCommonTargets"] = Current!.Hook + }; + internal static bool IsCandidate(string file) + { + if (Current == null || !Path.GetExtension(file).Equals(".cs", StringComparison.OrdinalIgnoreCase)) return true; + var matched = false; + foreach (var entry in Current.outputs) + if (file.StartsWith(entry.Path, StringComparison.OrdinalIgnoreCase)) + { + matched = true; + if (entry.CustomCompile || entry.Explicit.Contains(file) || entry.Globs.Any(glob => glob.IsMatch(file))) return true; + } + return !matched; + } + + internal static DesignTimeBuild Prepare(string root, string projectPath, string configuration, string framework, string identity, CancellationToken token) + { + foreach (var segment in new[] { configuration, framework }) + if (string.IsNullOrWhiteSpace(segment) || segment.Length > 128 || segment.EndsWith('.') || char.IsWhiteSpace(segment[^1]) || + segment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || segment.IndexOfAny(['/', '\\', ':', ';', '$', '%', '@']) >= 0) + throw new ArgumentException("Configuration and TargetFramework must be literal directory names."); + if (!Guid.TryParseExact(identity, "N", out _)) throw new ArgumentException("Invalid build output identity."); + var result = new DesignTimeBuild { intermediateOutputPath = $".cache/wincode-msbuild/{identity}/{configuration}/{framework}/" }; + var xml = new XElement("Project"); + using var collection = new ProjectCollection(new Dictionary { + ["Configuration"] = configuration, ["TargetFramework"] = framework, + ["DesignTimeBuild"] = "true", ["BuildingInsideVisualStudio"] = "true" + }); + var pending = new Stack(); pending.Push(projectPath); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + while (pending.TryPop(out var path)) + { + token.ThrowIfCancellationRequested(); path = WorkspaceInputs.Inside(root, path); + if (!seen.Add(path)) continue; + if (seen.Count > 64) throw new HostFailure("INPUT_BUDGET_EXCEEDED", "More than 64 project layouts."); + Project project; + try { project = collection.LoadProject(path); } + catch (InvalidProjectFileException error) { throw new HostFailure("PROJECT_LOAD_FAILED", error.Message); } + var directory = Path.GetDirectoryName(path)!; + var privateDirectory = WorkspaceInputs.Inside(root, Path.Combine(directory, ".cache/wincode-msbuild", identity)); + var actualOutput = WorkspaceInputs.Inside(root, Path.GetFullPath(result.intermediateOutputPath, directory)); + var relativeOutput = Path.GetRelativePath(privateDirectory, actualOutput); + if (relativeOutput is "." or ".." || relativeOutput.StartsWith(".." + Path.DirectorySeparatorChar) || Path.IsPathRooted(relativeOutput)) + throw new HostFailure("INVALID_ARGUMENT", "Design-time output escaped its Host namespace."); + var intermediate = WorkspaceInputs.Inside(root, Path.GetFullPath(project.GetPropertyValue("IntermediateOutputPath"), directory)); + if (string.Equals(intermediate, directory, StringComparison.OrdinalIgnoreCase)) + throw new HostFailure("INVALID_ARGUMENT", "Intermediate output cannot be the project directory."); + var customCompile = new[] { project.Xml }.Concat(project.Imports.Select(i => i.ImportedProject)).Any(document => + !document.FullPath.StartsWith(collection.Toolsets.First().ToolsPath, StringComparison.OrdinalIgnoreCase) && + document.AllChildren.OfType().Any(item => item.ItemType == "Compile" && item.Include.Length != 0)); + var globs = project.GetAllGlobs("Compile").Select(glob => glob.MsBuildGlob).ToArray(); + var explicitFiles = project.GetItems("Compile").Select(item => Path.GetFullPath(item.EvaluatedInclude, directory)).ToHashSet(StringComparer.OrdinalIgnoreCase); + result.outputs.Add((intermediate.TrimEnd('\\', '/') + Path.DirectorySeparatorChar, customCompile, globs, explicitFiles)); + // Other configurations under the same base are excluded by default SDK Compile globs too. + var baseIntermediate = WorkspaceInputs.Inside(root, Path.GetFullPath(project.GetPropertyValue("BaseIntermediateOutputPath"), directory)); + if (!string.Equals(baseIntermediate, directory, StringComparison.OrdinalIgnoreCase)) + result.outputs.Add((baseIntermediate.TrimEnd('\\', '/') + Path.DirectorySeparatorChar, customCompile, globs, explicitFiles)); + var condition = $"'$(MSBuildProjectFullPath)' == '{ProjectCollection.Escape(path)}'"; + var originalHook = project.Imports.FirstOrDefault(import => + import.ImportingElement.Project == "$(CustomBeforeMicrosoftCommonTargets)").ImportedProject?.FullPath; + if (originalHook != null) + xml.Add(new XElement("Import", new XAttribute("Project", originalHook), + new XAttribute("Condition", condition + $" And Exists('{ProjectCollection.Escape(originalHook)}')"))); + xml.Add(new XElement("PropertyGroup", new XAttribute("Condition", condition), + new XElement("DefaultItemExcludes", "$(DefaultItemExcludes);" + ProjectCollection.Escape(intermediate.Replace('\\', '/')) + "/**"))); + result.PrivateDirectories.Add(privateDirectory); + foreach (var reference in project.GetItems("ProjectReference")) pending.Push(Path.GetFullPath(reference.EvaluatedInclude, directory)); + } + var storage = WorkspaceInputs.Inside(root, Path.Combine(root, ".cache/wincode-build", identity)); + OwnedBuildOutputs.Record(root, identity, result.PrivateDirectories); + Directory.CreateDirectory(storage); + result.Hook = Path.Combine(storage, "preserve.targets"); + File.WriteAllText(result.Hook, xml.ToString(), new UTF8Encoding(false)); + Current = result; + return result; + } +} diff --git a/tools/WinCode.Code.Host/OwnedBuildOutputs.cs b/tools/WinCode.Code.Host/OwnedBuildOutputs.cs new file mode 100644 index 0000000..f201d70 --- /dev/null +++ b/tools/WinCode.Code.Host/OwnedBuildOutputs.cs @@ -0,0 +1,67 @@ +using System.Text.Json; + +// One Host owns one namespace. The Gateway retains the same manifest for forced-exit cleanup. +internal sealed class OwnedBuildOutputs : IDisposable +{ + internal static readonly string Instance = Environment.GetEnvironmentVariable("WINCODE_BUILD_INSTANCE") ?? Guid.NewGuid().ToString("N"); + internal static OwnedBuildOutputs? Current; + private readonly string root, identity, storage; + private readonly FileStream lease; + private readonly HashSet paths = new(StringComparer.OrdinalIgnoreCase); + private OwnedBuildOutputs(string root, string identity) + { + if (!Guid.TryParseExact(identity, "N", out _)) throw new ArgumentException("Invalid build output identity."); + this.root = root; this.identity = identity; + storage = WorkspaceInputs.Inside(root, Path.Combine(root, ".cache/wincode-build", identity)); + Directory.CreateDirectory(storage); + lease = new FileStream(Path.Combine(storage, "active.lock"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + } + internal static void Record(string root, string identity, IEnumerable directories) + { + Current ??= new(root, identity); + if (Current.root != root || Current.identity != identity) throw new InvalidOperationException("Build output owner changed."); + foreach (var directory in directories) + { + Current.Validate(directory); + Current.paths.Add(directory); + if (Current.paths.Count > 128) throw new HostFailure("INPUT_BUDGET_EXCEEDED", "Too many private output roots."); + } + var temporary = Path.Combine(Current.storage, "owner.json.tmp"); + File.WriteAllText(temporary, JsonSerializer.Serialize(new { version = 1, instance = identity, + paths = Current.paths.Select(path => Path.GetRelativePath(root, path)).ToArray() })); + File.Move(temporary, Path.Combine(Current.storage, "owner.json"), true); + } + private void Validate(string directory) + { + WorkspaceInputs.Inside(root, directory); + var suffix = Path.Combine(".cache", "wincode-msbuild", identity); + if (!directory.EndsWith(Path.DirectorySeparatorChar + suffix, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Output directory is not owned by this Host."); + } + private void Remove(string directory) + { + if (!Directory.Exists(directory)) return; + var pending = new Stack(); pending.Push(directory); var count = 0; + while (pending.TryPop(out var current)) + { + WorkspaceInputs.Inside(root, current); + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + if (++count > 16384) throw new IOException("Private cleanup entry budget exceeded."); + WorkspaceInputs.Inside(root, entry); + if (Directory.Exists(entry)) pending.Push(entry); + } + } + Directory.Delete(directory, true); + } + public void Dispose() + { + try + { + foreach (var directory in paths) { Validate(directory); Remove(directory); } + } + finally { lease.Dispose(); } + Remove(storage); + Current = null; + } +} diff --git a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj index cca6c6f..39c0cb9 100644 --- a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj +++ b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj @@ -1,6 +1,6 @@ - 0.14.0 + 0.15.0 Exe net10.0 enable @@ -8,6 +8,8 @@ true + + $(MSBuildBinPath)/Microsoft.Build.dllfalse diff --git a/tools/WinCode.Code.Host/WorkspaceInputs.cs b/tools/WinCode.Code.Host/WorkspaceInputs.cs index 61e49b5..4f40a2e 100644 --- a/tools/WinCode.Code.Host/WorkspaceInputs.cs +++ b/tools/WinCode.Code.Host/WorkspaceInputs.cs @@ -98,7 +98,7 @@ public static async Task CaptureAsync(string root, IEnumerable< if ((attributes & FileAttributes.Directory) != 0 && IgnoredDirectories.Contains(Path.GetFileName(entry))) continue; if ((attributes & FileAttributes.ReparsePoint) != 0) throw new HostFailure("UNSUPPORTED_LINK", "Linked workspace input."); if ((attributes & FileAttributes.Directory) != 0) pending.Push(entry); - else if (IsAutomaticInput(entry)) paths.Add(entry); + else if (IsAutomaticInput(entry) && DesignTimeBuild.IsCandidate(entry)) paths.Add(entry); } } foreach (var extra in extraFiles) paths.Add(Path.GetFullPath(extra)); diff --git a/tools/WinCode.Code.Host/WorkspaceSession.cs b/tools/WinCode.Code.Host/WorkspaceSession.cs index d8389cd..427f83d 100644 --- a/tools/WinCode.Code.Host/WorkspaceSession.cs +++ b/tools/WinCode.Code.Host/WorkspaceSession.cs @@ -123,10 +123,8 @@ public async Task ReloadAsync(string? id, CancellationToken token) var configurationBefore = Interlocked.Read(ref configurationGeneration); var before = await CaptureAsync(token, checkEvents: false); sdkSelection ??= before.SdkSelection; - workspace = MSBuildWorkspace.Create(new Dictionary { - ["Configuration"] = configuration, ["TargetFramework"] = framework, - ["RunAnalyzers"] = "false", ["RunAnalyzersDuringBuild"] = "false" - }); + DesignTimeBuild.Prepare(root, projectPath, configuration, framework, OwnedBuildOutputs.Instance, token); + workspace = MSBuildWorkspace.Create(DesignTimeBuild.Properties(configuration, framework)); try { await workspace.OpenProjectAsync(projectPath, cancellationToken: token); @@ -192,7 +190,7 @@ public async Task ReloadAsync(string? id, CancellationToken token) return new { id, type = "ready", success = true, protocolVersion = 2, snapshot, hostIdentity = HostBuildIdentity.Current, projects = candidate.ProjectIds.Count, configuration, framework, loadMs = clock.ElapsedMilliseconds, - inputPolicy = new { version = 1, additionalInputs = additionalInputs.Select(file => Path.GetRelativePath(root, file)).ToArray() }, + inputPolicy = new { version = 2, additionalInputs = additionalInputs.Select(file => Path.GetRelativePath(root, file)).ToArray() }, loadDiagnostics, compilationErrors, excludedAnalyzers, scope = "loaded-solution-snapshot", processTreeGuard = OperatingSystem.IsWindows(), diskFreshnessVerified = false, freshness = Freshness(after) }; } @@ -357,7 +355,7 @@ public void Dispose() invalidated = true; try { watcher.Dispose(); } catch (Exception error) { cleanupFailure ??= error; } - finally { ReleaseWorkspace(); } + finally { ReleaseWorkspace(); OwnedBuildOutputs.Current?.Dispose(); } if (cleanupFailure != null) throw new HostFailure("HOST_RESTART_REQUIRED", cleanupFailure.Message); } } diff --git a/tools/WinCode.Tray/WinCode.Tray.csproj b/tools/WinCode.Tray/WinCode.Tray.csproj index fcbde73..7adf57a 100644 --- a/tools/WinCode.Tray/WinCode.Tray.csproj +++ b/tools/WinCode.Tray/WinCode.Tray.csproj @@ -1,6 +1,6 @@ - 0.14.0 + 0.15.0 WinExe net10.0-windows win-x64 diff --git a/tools/WinCode.UIA.Host/README.md b/tools/WinCode.UIA.Host/README.md index 0dd157f..fc9cf0a 100644 --- a/tools/WinCode.UIA.Host/README.md +++ b/tools/WinCode.UIA.Host/README.md @@ -1,6 +1,6 @@ # WinCode.UIA.Host -0.14.0 的 Windows UI Automation(FlaUI.UIA3)一次性取证进程。实现入口为 [Program.cs](Program.cs),面向 Agent 的规范参数见 [UI 手册](../../skills/wincode/references/ui.md),整体数据流见 [架构说明](../../WinCode-架构与数据流说明.md)。 +0.15.0 的 Windows UI Automation(FlaUI.UIA3)一次性取证进程。实现入口为 [Program.cs](Program.cs),面向 Agent 的规范参数见 [UI 手册](../../skills/wincode/references/ui.md),整体数据流见 [架构说明](../../WinCode-架构与数据流说明.md)。 ## 职责和边界 diff --git a/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj b/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj index 68fc167..6e84c5f 100644 --- a/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj +++ b/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj @@ -1,7 +1,7 @@ - 0.14.0 + 0.15.0 true Exe net10.0-windows