diff --git a/modules/apps/cli/core/commands/core/McpCommand.cs b/modules/apps/cli/core/commands/core/McpCommand.cs index 8836943a2..d42adeea2 100644 --- a/modules/apps/cli/core/commands/core/McpCommand.cs +++ b/modules/apps/cli/core/commands/core/McpCommand.cs @@ -241,6 +241,11 @@ internal static async Task WithHostAsync(Func> // 历史注释"host.Dispose() 调 Environment.Exit(210)"已过时:退出码 210 在代码中不存在, // 且 ExecuteServeAsync 已用 await using var appHost 验证 DisposeAsync 安全。 await using var host = await BuildHostAsync(vendor, model, ct).ConfigureAwait(false); + // 单次命令等待插件后台链完成:八插件加载后台化后 registry 同步可见仅 15 个工具(gh_pr_* 全缺失), + // gh/mcp_call/mcp_list 查询前等 Completion 补齐工具表(长驻宿主不等待,保留启动优化收益) + if (host.Services.GetService() is { } bootstrap) { + await bootstrap.Completion.WaitAsync(ct).ConfigureAwait(false); + } return await action(host.Services).ConfigureAwait(false); } diff --git a/modules/apps/engine/JoinCode.Engine.csproj b/modules/apps/engine/JoinCode.Engine.csproj index adf8faf08..dad40df3c 100644 --- a/modules/apps/engine/JoinCode.Engine.csproj +++ b/modules/apps/engine/JoinCode.Engine.csproj @@ -34,6 +34,8 @@ + + diff --git a/modules/apps/engine/app/modules/McpPluginBootstrap.cs b/modules/apps/engine/app/modules/McpPluginBootstrap.cs index 6d3103244..49b61a753 100644 --- a/modules/apps/engine/app/modules/McpPluginBootstrap.cs +++ b/modules/apps/engine/app/modules/McpPluginBootstrap.cs @@ -87,8 +87,11 @@ public void Run(Func background) } /// 插件就绪完成信号(邮箱/通知语义)— Run 前为已完成 Task(无可等之物); - /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方(E2E/宿主)等此信号 - /// 确认工具注册就绪,替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。 + /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方等此信号确认工具注册就绪, + /// 替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。两类消费方: + /// E2E/宿主等就绪;CLI 单次命令(gh/mcp_call/mcp_list)在查询工具前等待 — + /// 后台化后单次进程不等插件加载完成即查 registry,工具表只剩同步注册的 15 个(gh_pr_* 全缺失); + /// 长驻宿主(GUI/TUI/交互会话)不等待 — 保留启动开销优化收益。 public Task Completion => _background; /// 收尾 — 取消(尽力加速断链)→ 无超时等待链完成(链恒成功语义由 Bootstrap 保证,兜底吞);幂等。 diff --git a/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs b/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs index d322661c8..1125558dd 100644 --- a/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs +++ b/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs @@ -18,14 +18,24 @@ public async Task FirstAttemptSucceeds_ReturnsImmediately() { /// /// 首次超时,重试后成功 — CallCount>=2。 + /// flaky 根治(AGENTS 规范:重试16次+延长窗口):本机全量并行高压下 Actor 邮箱处理延迟 + /// 可超过单次 100ms 超时 → 多轮超时后返回 success-at-3/4 撞死精确断言(8 轮全量 3 红实证)。 + /// 修复:单次超时放宽 200ms + 结果改前缀断言(第几次成功是实现细节,契约是"重试后成功") + /// + 外层 Retry16(Actor 彻底饿死才判红)。 /// [Fact] - public async Task FirstAttemptTimesOut_RetriesAndSucceeds() { - await using var actor = new RetryDemoActor(); - actor.SetSuccessAfter(1); - var result = await actor.AskWithRetryTestAsync(singleTimeoutMs: 100, maxRetries: 5); - result.Should().Be("success-at-2"); - actor.CallCount.Should().BeGreaterThanOrEqualTo(2); + [Trait("Category", "Flaky")] + public async Task FirstAttemptTimesOut_RetriesAndSucceeds_Retry16Times() { + for (var attempt = 0; attempt < 16; attempt++) { + await using var actor = new RetryDemoActor(); + actor.SetSuccessAfter(1); + var result = await actor.AskWithRetryTestAsync(singleTimeoutMs: 200, maxRetries: 5); + if (result.StartsWith("success-at-", StringComparison.Ordinal) + && actor.CallCount >= 2) { + return; + } + } + throw new Xunit.Sdk.XunitException("16次重试均失败: 首次超时后重试未能成功(每次都未拿到 success-at-* 或 CallCount<2)"); } /// diff --git a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs index c96809aef..55a66c9a3 100644 --- a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs +++ b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs @@ -122,12 +122,18 @@ public void Dispose() { LockRegistry.SetDiagnosticsEnabled(false); var messages = new ConcurrentQueue(); LockRegistry.SetDiagnosticSink(messages.Enqueue); - using var lk = new AsyncLock("disabled-test"); - using (await lk.TryLockAsync() ?? throw new System.TimeoutException($"锁 '{lk.Name}' 等待超时")) { - await Task.Delay(60); + try { + using var lk = new AsyncLock("disabled-test"); + using (await lk.TryLockAsync() ?? throw new System.TimeoutException($"锁 '{lk.Name}' 等待超时")) { + await Task.Delay(60); + } + // 断言本锁无诊断(而非队列全空):DiagnosticSink 是静态全局,并行测试类的锁诊断 + // 也会进本队列 —— "全空"断言在跨类并行下偶发红(flaky 同族,LockRegistry 静态互踩) + messages.Should().NotContain(m => m.Contains("disabled-test"), + "诊断关闭时本锁不应产生任何告警"); + } finally { + LockRegistry.SetDiagnosticsEnabled(true); } - messages.Should().BeEmpty("诊断关闭时不应产生任何告警"); - LockRegistry.SetDiagnosticsEnabled(true); } [Fact] @@ -142,14 +148,38 @@ public void Dispose() { [Fact] [Trait("Category", "Timing")] - public Task 死锁检测_两个线程互相等待时自动检测() { + [Trait("Category", "Flaky")] + public async Task 死锁检测_两个线程互相等待时自动检测_Retry16Times() { + // flaky 根治(AGENTS 规范:重试16次+延长窗口):后台 Timer 扫描在高负载(CI/本机全量并行)下回调 + // 延迟可远超单次窗口 —— 单轮 15s 检测窗口 × 16 轮重试,全部失败才判红(run 885 及本机全量偶发红实证)。 + // 每轮完整恢复 LockRegistry 全局(threshold/sink/扫描),防状态泄漏到下一轮。 var originalWaitThreshold = LockRegistry.WaitTimeoutThreshold; + string? lastReport = null; + try { + for (var attempt = 0; attempt < 16; attempt++) { + var detected = await RunDeadlockRoundAsync(attempt); + if (detected is { } report) { + return; + } + lastReport = LockRegistry.LastDeadlockReport; + } + } finally { + LockRegistry.StopBackgroundScan(); + LockRegistry.SetWaitTimeoutThreshold(originalWaitThreshold); + LockRegistry.SetDiagnosticSink(null); + } + throw new Xunit.Sdk.XunitException( + $"16次重试均失败: 后台扫描在 16×15s 窗口内未检测到互相等待死锁(最后一次报告: {lastReport ?? ""})"); + } + + /// 单轮死锁检测场景(两个线程经后台扫描路径互相等待)——返回检测报告字符串表示本轮命中;null=本轮未检测到 + private static async Task RunDeadlockRoundAsync(int attempt) { LockRegistry.SetWaitTimeoutThreshold(TimeSpan.FromMilliseconds(100)); LockRegistry.StartBackgroundScan(TimeSpan.FromMilliseconds(50)); var messages = new ConcurrentQueue(); LockRegistry.SetDiagnosticSink(messages.Enqueue); - using var lockA = new AsyncLock("deadlock-A", TimeSpan.FromSeconds(3)); - using var lockB = new AsyncLock("deadlock-B", TimeSpan.FromSeconds(3)); + using var lockA = new AsyncLock($"deadlock-A-{attempt}", TimeSpan.FromSeconds(3)); + using var lockB = new AsyncLock($"deadlock-B-{attempt}", TimeSpan.FromSeconds(3)); using var barrier = new Barrier(2); using var t1Done = new ManualResetEventSlim(); @@ -174,20 +204,24 @@ public void Dispose() { t1.Start(); t2.Start(); - var detected = System.Threading.SpinWait.SpinUntil(() => LockRegistry.DeadlockDetected, TimeSpan.FromSeconds(5)); - detected.Should().BeTrue("两个线程互相等待对方持有的锁应被自动检测为死锁(5s 轮询等待,容忍 CI 调度延迟)"); - LockRegistry.LastDeadlockReport.Should().Contain("DEADLOCK-DETECTED"); - LockRegistry.LastDeadlockReport.Should().Contain("deadlock-A"); - LockRegistry.LastDeadlockReport.Should().Contain("deadlock-B"); - messages.Should().Contain(m => m.Contains("DEADLOCK-DETECTED")); + // 窗口 15s:CI 高负载 Timer 回调延迟留足余量(原 5s 曾被压满) + var detected = System.Threading.SpinWait.SpinUntil(() => LockRegistry.DeadlockDetected, TimeSpan.FromSeconds(15)); + var report = detected ? LockRegistry.LastDeadlockReport : null; + if (detected) { + report.Should().Contain("DEADLOCK-DETECTED"); + report.Should().Contain(lockA.Name); + report.Should().Contain(lockB.Name); + messages.Should().Contain(m => m.Contains("DEADLOCK-DETECTED")); + } t1Done.Wait(8000); t2Done.Wait(8000); - - LockRegistry.StopBackgroundScan(); - LockRegistry.SetWaitTimeoutThreshold(originalWaitThreshold); - LockRegistry.SetDiagnosticSink(null); - return Task.CompletedTask; + // 本轮收尾:清检测标志/报告/flow 身份(internal ClearForTesting 先例),让下一轮从干净状态开始; + // 锁对象带 attempt 后缀避免注册表残留互相干扰。ClearForTesting 内部会 StopBackgroundScan, + // 下一轮开头的 StartBackgroundScan 重新拉起 + LockRegistry.ClearForTesting(); + await Task.CompletedTask; + return report; } /// diff --git a/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs b/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs index e06e8f924..835f459b7 100644 --- a/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs +++ b/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs @@ -29,6 +29,7 @@ public async Task AckAsync_DecreasesPendingCount() { } [Fact] + [Trait("Category", "Flaky")] public async Task RecoverAsync_ReplaysPendingMessages() { var store = new InMemoryPersistentStore(); await using var actor = new SimpleTestActor(); @@ -38,12 +39,16 @@ public async Task RecoverAsync_ReplaysPendingMessages() { await mailbox.PersistentSendAsync("msg-2"); await mailbox.PersistentSendAsync("msg-3"); - var processed = new List(); - actor.OnMessage = msg => processed.Add(msg); + var processed = new System.Collections.Concurrent.ConcurrentQueue(); + actor.OnMessage = msg => processed.Enqueue(msg); await mailbox.RecoverAsync(); - await Task.Delay(100); - + // 状态收敛等待(禁睡规范):回放经 Actor 邮箱异步处理,固定 Task.Delay(100) 在本机 + // 全量并行高压下不够 → 偶发丢断言(8 轮全量 1 红实证);10s 窗口轮询到三条齐即返回 + var converged = System.Threading.SpinWait.SpinUntil( + () => processed.Count >= 3, + TimeSpan.FromSeconds(10)); + converged.Should().BeTrue("10s 窗口内应回放完成(本机并行高压兜底)"); processed.Should().Contain(new[] { "msg-1", "msg-2", "msg-3" }); } diff --git a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs index 4be895b80..814928563 100644 --- a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs +++ b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs @@ -13,6 +13,20 @@ public class TransportE2ETest { try { return await source.FirstOrDefaultAsync(cts.Token); } catch (OperationCanceledException) { return null; } } + /// + /// 等待 host 的已连接进程表收敛到包含全部期望 PID(状态收敛轮询,非固定延迟祈祷)。 + /// BroadcastAsync 只对「此刻已入 _clientConnections」的 slave 发包(fire-and-forget 无回放), + /// 而 slave StartAsync 返回 ≠ 握手完成入表 — 固定 Task.Delay 在慢机(CI)上不够, + /// 曾致 BusTransport_Broadcast_MultipleSlavesReceive slave2 收不到广播(599/600 flaky)。 + /// + private static async Task WaitForConnectedAsync( + BusTransport host, IReadOnlyList expectedPids, TimeSpan timeout) { + using var cts = new CancellationTokenSource(timeout); + while (!expectedPids.All(host.GetConnectedProcesses().Contains)) { + await Task.Delay(50, cts.Token); + } + } + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] [Fact] public async Task BusTransport_HostSlave_MessageRoundTrip() { @@ -161,22 +175,26 @@ await TestWaitHelper.WaitUntilAsync(async () => { [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] [Fact] + [Trait("Category", "Flaky")] public async Task BusTransport_Broadcast_MultipleSlavesReceive() { var pipeName = $"e2e-bus-multi-{Guid.NewGuid():N}"; var hostPid = "600001"; var slave1Pid = "600002"; var slave2Pid = "600003"; + // Linux CI 确定性失败取证(本地 Windows 恒绿无法复现):开 TransportDiagnostics, + // 失败时 xUnit 会把 stderr 全轨迹(PIPE-ACCEPT/BUS 收发)带进失败输出 — 一次定位根因 + TransportDiagnostics.Enabled = true; + try { await using var host = new BusTransport(pipeName: pipeName, processId: hostPid); await using var slave1 = new BusTransport(pipeName: pipeName, processId: slave1Pid); await using var slave2 = new BusTransport(pipeName: pipeName, processId: slave2Pid); await host.StartAsync(); - await Task.Delay(300); await slave1.StartAsync(); - await Task.Delay(300); await slave2.StartAsync(); - await Task.Delay(300); + // 确定性等待两个 slave 握手入表(BroadcastAsync 只发已连接者,无回放 — 参见 WaitForConnectedAsync 注释) + await WaitForConnectedAsync(host, [slave1Pid, slave2Pid], TimeSpan.FromSeconds(10)); var data = new ReadOnlyMemory(Encoding.UTF8.GetBytes("broadcast-all")); await host.BroadcastAsync(data); @@ -184,10 +202,21 @@ public async Task BusTransport_Broadcast_MultipleSlavesReceive() { var recv1 = await ReceiveWithTimeoutAsync(slave1.ReceiveAsync(), TimeSpan.FromSeconds(5)); var recv2 = await ReceiveWithTimeoutAsync(slave2.ReceiveAsync(), TimeSpan.FromSeconds(5)); - recv1.Should().NotBeNull("slave1 应收到广播"); + // 现场快照:广播后 host 连接表 + 各 slave 实收帧(失败时直接看丢在哪一环) + var scene = $"广播后host表=[{string.Join(",", host.GetConnectedProcesses())}] " + + $"slave1实收={DescribeFrame(recv1)} slave2实收={DescribeFrame(recv2)}"; + + recv1.Should().NotBeNull($"slave1 应收到广播;现场: {scene}"); Encoding.UTF8.GetString(recv1!.Data.Span).Should().Be("broadcast-all"); - recv2.Should().NotBeNull("slave2 应收到广播"); + recv2.Should().NotBeNull($"slave2 应收到广播;现场: {scene}"); Encoding.UTF8.GetString(recv2!.Data.Span).Should().Be("broadcast-all"); + } finally { + TransportDiagnostics.Enabled = false; + } } + + /// 帧摘要(null 显式标注)— 断言失败现场快照用 + private static string DescribeFrame(TransportFrame? frame) => + frame is null ? "" : $"{frame.SourceProcessId}:{Encoding.UTF8.GetString(frame.Data.Span)}"; } \ No newline at end of file diff --git a/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs b/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs index 84c25aae6..90e3e290a 100644 --- a/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs +++ b/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs @@ -358,7 +358,7 @@ public static async ValueTask ReadAsync(Stream stream, Cancellat return new DecodedMessage(type, sourcePid, targetPid, payload); } - /// 持续从流异步读取消息序列,直到流结束或取消。 + /// 持续从流异步读取消息序列,直到流结束、取消或 IO 错误(对端关闭/重置以 IOException 呈现,帧错位以 InvalidDataException 呈现 — 均按流终止处理而非炸调用方)。 /// 数据流。 /// 取消令牌。 public static async IAsyncEnumerable ReadStreamAsync( @@ -369,6 +369,7 @@ public static async IAsyncEnumerable ReadStreamAsync( try { msg = await ReadAsync(stream, ct).ConfigureAwait(false); } catch (OperationCanceledException) { yield break; } catch (EndOfStreamException) { yield break; } + catch (IOException) { yield break; } catch (InvalidDataException) { yield break; } yield return msg; } } diff --git a/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs b/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs index 1ac442533..a6c3abc1a 100644 --- a/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs +++ b/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs @@ -188,19 +188,27 @@ public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() { Sha256 = sha256, SizeBytes = content.Length }; - var progressReports = new List(); - var progress = new Progress(p => progressReports.Add(p)); + // 内联同步 IProgress(禁用 Progress):Progress 回调经同步上下文异步投递, + // DownloadUpdateAsync 返回时末条进度可能尚未执行 → 断言停在块边界偶发红(CI run 39629867210 实证: + // 期望 246760 实收 163840)。内联 Report 在下载线程直接调用,返回后队列必然完整 + var progressReports = new System.Collections.Concurrent.ConcurrentQueue(); + IProgress progress = new InlineProgress(progressReports.Enqueue); var result = await service.DownloadUpdateAsync(entry, progress); result.Success.Should().BeTrue(); progressReports.Should().NotBeEmpty(); - progressReports[^1].BytesDownloaded.Should().Be(content.Length); + progressReports.Last().BytesDownloaded.Should().Be(content.Length); if (result.DownloadedPath is not null && _fs.FileExists(result.DownloadedPath)) _fs.DeleteFile(result.DownloadedPath); } + /// 同步内联 IProgress — Report 在调用线程直接执行回调(无异步投递竞态,测试确定性等待用) + private sealed class InlineProgress(Action report) : IProgress { + public void Report(T value) => report(value); + } + [Fact] public async Task ApplyUpdateAsync_NonExistentFile_ReturnsFailed() { await using var service = new UpgradeService(_httpClient, _fs); diff --git a/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs new file mode 100644 index 000000000..4b43fb86d --- /dev/null +++ b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs @@ -0,0 +1,77 @@ +namespace Mcp.Tests; + +/// +/// gh 工具 refresh 强制刷新测试 — 缺陷修复守卫: +/// pr checks / pr view 此前无 refresh 参数(CLI --refresh 报未知选项),且 watch 轮询与普通查询 +/// 均命中 API 缓存(detail 10min)导致状态冻结(PR 已合并仍 open / run 已 cancelled 仍 pending)。 +/// 修复 = refresh=true 时 evict API 缓存 key 后重拉。 +/// +public sealed partial class GitHubToolHandlersTests { + /// 构造带内存 KV 缓存的 handler(真实走 GhCacheService 路径,非 NoOp) + private static (GitHubToolHandlers Handler, FakeGitHubApiClient Api) CreateCachedHandler() { + var api = new FakeGitHubApiClient(); + var handler = new GitHubToolHandlers(new FakeDownloader(), new InMemoryFileSystem(), api, + git: null, logger: NullLogger.Instance, kvStore: new InMemoryKvStore()); + return (handler, api); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrChecks_WithoutRefresh_HitsApiCache() { + var (handler, api) = CreateCachedHandler(); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"success"}]}""" }); + var first = await handler.GhPrChecksAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + first.GetFirstText()!.Should().Contain("1 通过"); + + // 换新响应但缓存 TTL 内 — 不带 refresh 应命中缓存不消费队列 + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"failure"}]}""" }); + var second = await handler.GhPrChecksAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + second.GetFirstText()!.Should().Contain("1 通过", "缓存命中返回旧状态(TTL 内)"); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrChecks_WithRefresh_EvictsCacheAndRefetches() { + var (handler, api) = CreateCachedHandler(); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"success"}]}""" }); + await handler.GhPrChecksAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"failure"}]}""" }); + var refreshed = await handler.GhPrChecksAsync("42", refresh: true, common: new GitHubCommonOptions { Repo = "owner/repo" }); + refreshed.GetFirstText()!.Should().Contain("1 失败", "refresh=true 应 evict 缓存并重拉最新 check-runs"); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrView_WithRefresh_EvictsCacheAndRefetches() { + var (handler, api) = CreateCachedHandler(); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"number":42,"state":"open","title":"t"}""" }); + var first = await handler.GhPrViewAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + first.GetFirstText()!.Should().Contain("open"); + + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"number":42,"state":"closed","title":"t"}""" }); + var refreshed = await handler.GhPrViewAsync("42", refresh: true, common: new GitHubCommonOptions { Repo = "owner/repo" }); + refreshed.GetFirstText()!.Should().Contain("closed", "refresh=true 应 evict 缓存并重拉最新 PR 状态"); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrChecks_WatchMode_EachRoundBypassesCache() { + // watch 轮询语义要求看最新状态 — 内层每轮强制 refresh,不得命中缓存冻结状态 + var (handler, api) = CreateCachedHandler(); + // 第 1 轮:pending;第 2 轮:success(interval 最小 1s,两轮各 enq 两个响应) + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","status":"in_progress"}]}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"success"}]}""" }); + + var result = await handler.GhPrChecksAsync("42", watch: true, interval: 1, common: new GitHubCommonOptions { Repo = "owner/repo" }); + + var text = result.GetFirstText()!; + text.Should().Contain("1 通过", "watch 第二轮应看到最新 success 状态(轮询绕缓存+完成判定精确子串)"); + } +} diff --git a/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs index 7b2365405..fcfea7f07 100644 --- a/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs +++ b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs @@ -1906,6 +1906,8 @@ public GitHubApiResponse NextResponse { private readonly GitHubApiResponse _default = new() { Success = true, StatusCode = 200, Body = "[]" }; public string? LastPath { get; private set; } public HttpMethod? LastMethod { get; private set; } + /// 调用序列诊断(refresh evict 测试用):每次 SendAsync 追加 "method path body前40" + public List CallLog { get; } = []; public string? LastBody { get; private set; } public IReadOnlyDictionary LastQuery { get; private set; } = new Dictionary(); public void EnqueueResponse(GitHubApiResponse response) => _responses.Enqueue(response); @@ -1914,6 +1916,8 @@ public GitHubApiResponse NextResponse { public Task SendAsync(HttpMethod method, string path, string? body = null, IReadOnlyDictionary? query = null, bool paginate = false, CancellationToken ct = default) { LastMethod = method; LastPath = path; + CallLog.Add(string.Create(System.Globalization.CultureInfo.InvariantCulture, + $"{method} {path} <- {(_responses.Count > 0 ? _responses.Peek().Body[..Math.Min(40, _responses.Peek().Body.Length)] : "")}")); LastBody = body; LastQuery = query ?? new Dictionary(); if (_responses.Count > 0) diff --git a/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs b/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs index 1cd7bd7ef..1c6512741 100644 --- a/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs +++ b/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs @@ -132,6 +132,8 @@ internal interface IGhCacheService { Task TryGetApiAsync(string path, IReadOnlyDictionary? query, CancellationToken ct); /// 写入 API 缓存 — 按端点自动选择 TTL(search 2min/detail 10min/default 5min) Task WriteApiAsync(string path, IReadOnlyDictionary? query, string body, CancellationToken ct); + /// 删除指定 API 缓存 — refresh 强刷通道(key 与 TryGet/Write 同构;PR state/check-runs 等 detail 类状态查询用) + Task EvictApiAsync(string path, IReadOnlyDictionary? query, CancellationToken ct); /// 尝试读取 GraphQL 查询缓存 — 命中返回 body,未命中返回 null Task TryGetGraphqlAsync(string body, CancellationToken ct); /// 写入 GraphQL 查询缓存 — TTL 5min @@ -238,6 +240,17 @@ public async Task WriteApiAsync(string path, IReadOnlyDictionary } } + /// + public async Task EvictApiAsync(string path, IReadOnlyDictionary? query, CancellationToken ct) { + if (_kvStore is null) return; + var keyBytes = BuildApiCacheKey(path, query); + try { + await _kvStore.DeleteAsync(keyBytes, ct).ConfigureAwait(false); + } catch (Exception ex) { + _logger?.LogWarning(ex, "gh API 缓存清除失败 path={Path}", path); + } + } + /// public async Task TryGetGraphqlAsync(string body, CancellationToken ct) { if (_kvStore is null) return null; diff --git a/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs b/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs index e37b2194a..1cda67fd8 100644 --- a/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs +++ b/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs @@ -16,10 +16,14 @@ public async Task GhPrViewAsync( [McpToolOptions] GitHubCommonOptions common, [McpToolParameter("comments=true 附带评论列表", Required = false)] bool? comments = null, [McpToolParameter("web=true 只返回 PR 浏览器 URL", Required = false)] bool? web = null, + [McpToolParameter("强制刷新缓存(默认 false,绕过 detail 10min TTL 直拉最新 state — 已知误导案例: PR 已合并仍显示 open)", Required = false)] bool? refresh = null, CancellationToken cancellationToken = default) => await ExecuteGhAsync(common.Repo, common.WorkingDir, cancellationToken, async (client, owner, repoName) => { var number = ParseNumberFromRef(pr_number); var apiPath = $"repos/{owner}/{repoName}/pulls/{number}"; + if (refresh == true && _cacheService is { } cache) { + await cache.EvictApiAsync(apiPath, null, cancellationToken).ConfigureAwait(false); + } if (web == true) { var prResult = await client.SendAsync(HttpMethod.Get, apiPath, ct: cancellationToken).ConfigureAwait(false); if (!prResult.Success) return Fail(prResult.Error); @@ -249,11 +253,12 @@ public async Task GhPrChecksAsync( [McpToolParameter("轮询间隔秒数(可选,默认 10)", Required = false)] int? interval = null, [McpToolParameter("有失败立即标记(可选)", Required = false)] bool? fail_fast = null, [McpToolParameter("最大显示 check 数量(可选,默认全部,pass 最多 5)", Required = false)] int? limit = null, + [McpToolParameter("强制刷新缓存(默认 false,绕过 detail TTL 直拉最新 check-runs — 状态冻结/已 rerun 时用)", Required = false)] bool? refresh = null, [McpToolOptions] GitHubCommonOptions? common = null, CancellationToken cancellationToken = default) => await ExecuteGhAsync(common?.Repo, common?.WorkingDir, cancellationToken, async (client, owner, repoName) => { if (watch == true) return await GhPrChecksWatchAsync(client, owner, repoName, pr_number, interval, fail_fast, required, limit, cancellationToken).ConfigureAwait(false); - return await GhPrChecksCoreAsync(client, owner, repoName, pr_number, fail_fast, required, common?.JsonFields, limit, cancellationToken).ConfigureAwait(false); + return await GhPrChecksCoreAsync(client, owner, repoName, pr_number, fail_fast, required, common?.JsonFields, limit, refresh == true, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); /// @@ -266,11 +271,16 @@ private async Task GhPrChecksWatchAsync(IGitHubApiClient client, str if (tracker is not null) tracker.CacheDisabled = true; try { for (var i = 0; i < 120; i++) { - var checkResult = await GhPrChecksCoreAsync(client, owner, repoName, prNumber, failFast, required, null, limit, ct).ConfigureAwait(false); + // 每轮强制 evict(双保险:tracker.CacheDisabled 依赖 AsyncLocal 存在,直调/无 tracker 场景由此兜底) + var checkResult = await GhPrChecksCoreAsync(client, owner, repoName, prNumber, failFast, required, null, limit, forceRefresh: true, ct).ConfigureAwait(false); if (checkResult.IsError) return checkResult; var text = checkResult.GetFirstText() ?? ""; - if (!text.Contains("进行中")) return checkResult; - if (failFast == true && text.Contains("失败")) return checkResult; + // 汇总行由 Core 唯一构造("汇总: N 通过, N 失败, N 警告, N 进行中, N 跳过")—— + // 存量 bug:Contains("进行中")/"失败" 恒真("0 进行中" 也含该词)导致 watch 永不提前返回、fail-fast 恒触发; + // 修正为精确子串 ", 0 进行中," / ", 0 失败,"(完成=无进行中,有失败=失败数非 0) + if (text.Contains(", 0 进行中,", StringComparison.Ordinal)) return checkResult; + var hasFailure = !text.Contains(", 0 失败,", StringComparison.Ordinal); + if (failFast == true && hasFailure) return checkResult; await Task.Delay(delay, ct).ConfigureAwait(false); } return Ok("轮询超时,仍有 checks 进行中"); @@ -357,8 +367,11 @@ private static string MapCheckRollupState(string? status, string? conclusion) { /// GhPrChecks 核心逻辑 — 调 REST API 获取 check-runs,正确处理 skipping 语义(非失败),支持 required 过滤和 fail-fast 标记 /// [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] - private async Task GhPrChecksCoreAsync(IGitHubApiClient client, string owner, string repoName, string prNumber, bool? failFast, bool? required, string? json_fields, int? limit, CancellationToken ct) { + private async Task GhPrChecksCoreAsync(IGitHubApiClient client, string owner, string repoName, string prNumber, bool? failFast, bool? required, string? json_fields, int? limit, bool forceRefresh = false, CancellationToken ct = default) { var number = ParseNumberFromRef(prNumber); + if (forceRefresh && _cacheService is { } cache) { + await cache.EvictApiAsync($"repos/{owner}/{repoName}/pulls/{number}", null, ct).ConfigureAwait(false); + } var prResult = await client.SendAsync(HttpMethod.Get, $"repos/{owner}/{repoName}/pulls/{number}", ct: ct).ConfigureAwait(false); if (!prResult.Success) return Fail(prResult.Error); string? headSha; string? headRef; @@ -368,6 +381,10 @@ private async Task GhPrChecksCoreAsync(IGitHubApiClient client, stri headRef = prDetail?.Head?.Ref; } catch (Exception ex) { return Fail($"解析 PR head sha 失败: {ex.Message}"); } if (string.IsNullOrEmpty(headSha)) return Fail("无法从 PR 响应中解析 head.sha"); + if (forceRefresh && _cacheService is { } checksCache) { + await checksCache.EvictApiAsync($"repos/{owner}/{repoName}/commits/{headSha}/check-runs", + new Dictionary { ["per_page"] = "100" }, ct).ConfigureAwait(false); + } var checksResult = await client.SendAsync( HttpMethod.Get, $"repos/{owner}/{repoName}/commits/{headSha}/check-runs",