From 086ab30319197f9e4d438860f6b99af0165d8836 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sat, 18 Jul 2026 21:29:47 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20DoctorTestSu?= =?UTF-8?q?ite=20=E6=B5=8B=E8=AF=95=E5=A5=97=E4=BB=B6=EF=BC=88T001-T006?= =?UTF-8?q?=EF=BC=89+=20--doctor-test-suite=20CLI=20=E5=8F=82=E6=95=B0=20|?= =?UTF-8?q?=20=E5=86=B3=E7=AD=96:=20TestSuite=E7=8B=AC=E7=AB=8B=E7=B1=BB?= =?UTF-8?q?=E4=B8=8D=E5=B5=8C=E5=85=A5DoctorAgent=EF=BC=8CDoctorModeRunner?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E7=BC=96=E6=8E=92=E6=B5=8B=E8=AF=95=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/Agents/Doctor/DoctorTestSuite.cs | 362 +++++++++++++++++ .../Agents/Doctor/DoctorTestSuiteTests.cs | 380 ++++++++++++++++++ .../App/Builder/ApplicationBuilder.cs | 2 + src/JoinCode/Cli/Core/CliArg.cs | 3 + src/JoinCode/Cli/Core/CommandLineOptions.cs | 5 + src/JoinCode/Entry/DoctorModeRunner.cs | 136 +++++++ src/JoinCode/Program.cs | 4 + 7 files changed, 892 insertions(+) create mode 100644 components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs create mode 100644 components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs new file mode 100644 index 0000000..6ac14e7 --- /dev/null +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs @@ -0,0 +1,362 @@ +namespace Core.Agents.Doctor; + +/// +/// 测试用例定义 +/// +public sealed record DoctorTestCase +{ + /// 测试用例 ID(如 T001) + public required string TestCaseId { get; init; } + + /// 测试名称 + public required string TestName { get; init; } + + /// 发送给病人的提示词 + public required string Prompt { get; init; } + + /// 超时时间(秒) + public int TimeoutSeconds { get; init; } = 30; + + /// 预期病人退出码(0=正常完成) + public int ExpectedExitCode { get; init; } = 0; + + /// 测试类别 + public string Category { get; init; } = string.Empty; +} + +/// +/// 医生测试套件 — 定义和执行 jcc.exe 功能测试用例 +/// 每条用例启动一个病人进程,发送提示词,等待完成并收集结果 +/// +public sealed class DoctorTestSuite +{ + private readonly ILogger? _logger; + + /// 测试用例执行完成事件 + public event EventHandler? TestCaseCompleted; + + /// 测试套件执行完成事件 + public event EventHandler? SuiteCompleted; + + /// 内置测试用例 + public static IReadOnlyList BuiltInTests => + [ + new DoctorTestCase + { + TestCaseId = "T001", + TestName = "FileRead", + Prompt = "读取当前目录的README.md", + TimeoutSeconds = 30, + Category = "tool" + }, + new DoctorTestCase + { + TestCaseId = "T002", + TestName = "FileEdit", + Prompt = "在test.txt写入hello world", + TimeoutSeconds = 30, + Category = "tool" + }, + new DoctorTestCase + { + TestCaseId = "T003", + TestName = "ShellExec", + Prompt = "运行 git status", + TimeoutSeconds = 30, + Category = "tool" + }, + new DoctorTestCase + { + TestCaseId = "T004", + TestName = "CodeSearch", + Prompt = "搜索ErrorCode枚举定义", + TimeoutSeconds = 30, + Category = "tool" + }, + new DoctorTestCase + { + TestCaseId = "T005", + TestName = "SubAgent", + Prompt = "创建一个探索子代理分析项目结构", + TimeoutSeconds = 60, + Category = "agent" + }, + new DoctorTestCase + { + TestCaseId = "T006", + TestName = "Compact", + Prompt = "压缩上下文", + TimeoutSeconds = 30, + Category = "agent" + } + ]; + + public DoctorTestSuite(ILogger? logger = null) + { + _logger = logger; + } + + /// + /// 执行全部内置测试用例 + /// + public async Task RunAllAsync( + DoctorAgent doctor, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + CancellationToken cancellationToken = default) + { + return await RunAsync(doctor, BuiltInTests, workingDirectory, environmentVariables, cancellationToken).ConfigureAwait(false); + } + + /// + /// 执行指定测试用例 + /// + public async Task RunAsync( + DoctorAgent doctor, + IEnumerable testCases, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(doctor); + + var caseList = testCases.ToList(); + var startedAt = DateTimeOffset.UtcNow; + var results = new List(); + + _logger?.LogInformation("[DoctorTestSuite] 开始执行 {Count} 个测试用例", caseList.Count); + + foreach (var testCase in caseList) + { + if (cancellationToken.IsCancellationRequested) + { + results.Add(new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = DoctorTestStatus.Skipped, + ErrorMessage = "测试套件被取消" + }); + continue; + } + + var result = await RunSingleAsync(doctor, testCase, workingDirectory, environmentVariables, cancellationToken).ConfigureAwait(false); + results.Add(result); + TestCaseCompleted?.Invoke(this, result); + } + + var report = new DoctorTestSuiteReport + { + Results = results, + TotalCount = results.Count, + PassCount = results.Count(r => r.Status == DoctorTestStatus.Pass), + FailCount = results.Count(r => r.Status == DoctorTestStatus.Fail), + HungCount = results.Count(r => r.Status == DoctorTestStatus.Hung), + ErrorCount = results.Count(r => r.Status == DoctorTestStatus.Error), + SkippedCount = results.Count(r => r.Status == DoctorTestStatus.Skipped), + StartedAt = startedAt, + CompletedAt = DateTimeOffset.UtcNow, + IsAllPassed = results.All(r => r.Status == DoctorTestStatus.Pass) + }; + + SuiteCompleted?.Invoke(this, report); + + _logger?.LogInformation("[DoctorTestSuite] 执行完成: {Pass}/{Total} 通过", report.PassCount, report.TotalCount); + + return report; + } + + /// + /// 执行单个测试用例 — 构建病人参数,启动 DoctorAgent.RunAsync,收集结果 + /// + private async Task RunSingleAsync( + DoctorAgent doctor, + DoctorTestCase testCase, + string? workingDirectory, + IReadOnlyDictionary? environmentVariables, + CancellationToken cancellationToken) + { + _logger?.LogInformation("[DoctorTestSuite] 执行测试: {TestId} - {TestName}", testCase.TestCaseId, testCase.TestName); + + var patientArgs = BuildPatientArguments(testCase); + var patientId = $"test-{testCase.TestCaseId}"; + var envVars = BuildEnvironmentVariables(environmentVariables, testCase); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(testCase.TimeoutSeconds)); + + var report = await doctor.RunAsync(patientId, patientArgs, workingDirectory, envVars, cts.Token).ConfigureAwait(false); + sw.Stop(); + + var status = DetermineTestStatus(report, testCase); + + return new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = status, + Duration = sw.Elapsed, + ErrorMessage = status != DoctorTestStatus.Pass ? BuildErrorMessage(report, testCase) : null + }; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + sw.Stop(); + _logger?.LogWarning("[DoctorTestSuite] 测试超时: {TestId} ({Timeout}s)", testCase.TestCaseId, testCase.TimeoutSeconds); + return new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = DoctorTestStatus.Hung, + Duration = sw.Elapsed, + ErrorMessage = $"测试超时 ({testCase.TimeoutSeconds}s)" + }; + } + catch (OperationCanceledException) + { + sw.Stop(); + return new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = DoctorTestStatus.Skipped, + Duration = sw.Elapsed, + ErrorMessage = "测试套件被取消" + }; + } + catch (Exception ex) + { + sw.Stop(); + _logger?.LogError(ex, "[DoctorTestSuite] 测试异常: {TestId}", testCase.TestCaseId); + return new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = DoctorTestStatus.Error, + Duration = sw.Elapsed, + ErrorMessage = ex.Message + }; + } + } + + /// + /// 构建病人进程参数 — 从测试用例推导 jcc.exe 命令行 + /// + public static string BuildPatientArguments(DoctorTestCase testCase) + { + var sb = new StringBuilder(); + sb.Append("--trust"); + sb.Append($" -p \"{testCase.Prompt}\""); + sb.Append($" --await {testCase.TimeoutSeconds}"); + return sb.ToString(); + } + + /// + /// 构建环境变量 — 合并基础变量和测试特有变量 + /// + private static IReadOnlyDictionary? BuildEnvironmentVariables( + IReadOnlyDictionary? baseVars, + DoctorTestCase testCase) + { + if (baseVars is null) return null; + + var result = new Dictionary(baseVars); + result["JCC_TEST_CASE_ID"] = testCase.TestCaseId; + return result; + } + + /// + /// 根据医生报告和测试用例预期判断测试状态 + /// + public static DoctorTestStatus DetermineTestStatus(DoctorReport report, DoctorTestCase testCase) + { + var patient = report.Patients.Values.FirstOrDefault(p => p.PatientId.StartsWith($"test-{testCase.TestCaseId}")) + ?? report.Patient; + + if (patient is null) + return DoctorTestStatus.Error; + + return patient.State switch + { + PatientState.Completed when patient.ExitCode == testCase.ExpectedExitCode => DoctorTestStatus.Pass, + PatientState.Completed => DoctorTestStatus.Fail, + PatientState.Hung => DoctorTestStatus.Hung, + PatientState.Failed => DoctorTestStatus.Fail, + PatientState.Killed => DoctorTestStatus.Fail, + _ => DoctorTestStatus.Error + }; + } + + /// + /// 构建错误消息 — 从医生报告中提取诊断和修复信息 + /// + private static string? BuildErrorMessage(DoctorReport report, DoctorTestCase testCase) + { + var parts = new List(); + + var patient = report.Patients.Values.FirstOrDefault(p => p.PatientId.StartsWith($"test-{testCase.TestCaseId}")) + ?? report.Patient; + + if (patient is not null) + parts.Add($"病人状态: {patient.State}, 退出码: {patient.ExitCode}"); + + var relevantDiags = report.Diagnostics + .Where(d => d.PatientId.StartsWith($"test-{testCase.TestCaseId}")) + .ToList(); + + if (relevantDiags.Count > 0) + parts.Add($"诊断: {string.Join("; ", relevantDiags.Select(d => $"{d.RuleId}: {d.Description}"))}"); + + var relevantFixes = report.FixResults + .Where(f => f.PatientId.StartsWith($"test-{testCase.TestCaseId}")) + .ToList(); + + if (relevantFixes.Count > 0) + parts.Add($"修复: {string.Join("; ", relevantFixes.Select(f => $"{f.Action.ActionType}: {(f.Success ? "成功" : "失败")}"))}"); + + return parts.Count > 0 ? string.Join(" | ", parts) : null; + } +} + +/// +/// 测试套件报告 — 一次完整测试套件执行的结果汇总 +/// +public sealed record DoctorTestSuiteReport +{ + /// 测试结果列表 + public required IReadOnlyList Results { get; init; } + + /// 总测试数 + public required int TotalCount { get; init; } + + /// 通过数 + public required int PassCount { get; init; } + + /// 失败数 + public required int FailCount { get; init; } + + /// 卡死数 + public required int HungCount { get; init; } + + /// 错误数 + public required int ErrorCount { get; init; } + + /// 跳过数 + public required int SkippedCount { get; init; } + + /// 开始时间 + public required DateTimeOffset StartedAt { get; init; } + + /// 完成时间 + public required DateTimeOffset CompletedAt { get; init; } + + /// 是否全部通过 + public required bool IsAllPassed { get; init; } + + /// 总耗时 + public TimeSpan Duration => CompletedAt - StartedAt; +} diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs new file mode 100644 index 0000000..d456a11 --- /dev/null +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs @@ -0,0 +1,380 @@ +namespace Core.Tests.Agents.Doctor; + +public class DoctorTestSuiteTests +{ + [Fact] + public void BuiltInTests_ContainsSixTests() + { + Assert.Equal(6, DoctorTestSuite.BuiltInTests.Count); + } + + [Fact] + public void BuiltInTests_AllHaveUniqueIds() + { + var ids = DoctorTestSuite.BuiltInTests.Select(t => t.TestCaseId).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public void BuiltInTests_AllHaveRequiredFields() + { + foreach (var test in DoctorTestSuite.BuiltInTests) + { + Assert.False(string.IsNullOrWhiteSpace(test.TestCaseId)); + Assert.False(string.IsNullOrWhiteSpace(test.TestName)); + Assert.False(string.IsNullOrWhiteSpace(test.Prompt)); + Assert.True(test.TimeoutSeconds > 0); + } + } + + [Fact] + public void BuiltInTests_IdsAreSequential() + { + var ids = DoctorTestSuite.BuiltInTests.Select(t => t.TestCaseId).ToList(); + Assert.Equal(["T001", "T002", "T003", "T004", "T005", "T006"], ids); + } + + [Fact] + public void BuiltInTests_CategoriesAreCorrect() + { + Assert.Equal("tool", DoctorTestSuite.BuiltInTests[0].Category); + Assert.Equal("tool", DoctorTestSuite.BuiltInTests[1].Category); + Assert.Equal("tool", DoctorTestSuite.BuiltInTests[2].Category); + Assert.Equal("tool", DoctorTestSuite.BuiltInTests[3].Category); + Assert.Equal("agent", DoctorTestSuite.BuiltInTests[4].Category); + Assert.Equal("agent", DoctorTestSuite.BuiltInTests[5].Category); + } + + [Fact] + public void BuildPatientArguments_ContainsTrust() + { + var testCase = new DoctorTestCase + { + TestCaseId = "T001", + TestName = "Test", + Prompt = "hello", + TimeoutSeconds = 30 + }; + + var args = DoctorTestSuite.BuildPatientArguments(testCase); + + Assert.Contains("--trust", args); + } + + [Fact] + public void BuildPatientArguments_ContainsPrompt() + { + var testCase = new DoctorTestCase + { + TestCaseId = "T001", + TestName = "Test", + Prompt = "读取README.md", + TimeoutSeconds = 30 + }; + + var args = DoctorTestSuite.BuildPatientArguments(testCase); + + Assert.Contains("-p", args); + Assert.Contains("读取README.md", args); + } + + [Fact] + public void BuildPatientArguments_ContainsAwait() + { + var testCase = new DoctorTestCase + { + TestCaseId = "T001", + TestName = "Test", + Prompt = "hello", + TimeoutSeconds = 45 + }; + + var args = DoctorTestSuite.BuildPatientArguments(testCase); + + Assert.Contains("--await 45", args); + } + + [Fact] + public void DetermineTestStatus_PatientCompleted_ReturnsPass() + { + var report = CreateReport(PatientState.Completed, 0); + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Pass, status); + } + + [Fact] + public void DetermineTestStatus_PatientHung_ReturnsHung() + { + var report = CreateReport(PatientState.Hung, 1234); + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Hung, status); + } + + [Fact] + public void DetermineTestStatus_PatientFailed_ReturnsFail() + { + var report = CreateReport(PatientState.Failed, 1); + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Fail, status); + } + + [Fact] + public void DetermineTestStatus_PatientKilled_ReturnsFail() + { + var report = CreateReport(PatientState.Killed, -1); + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Fail, status); + } + + [Fact] + public void DetermineTestStatus_PatientCompletedWrongExitCode_ReturnsFail() + { + var report = CreateReport(PatientState.Completed, 1); + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Fail, status); + } + + [Fact] + public void DetermineTestStatus_NoPatient_ReturnsError() + { + var report = new DoctorReport + { + Patients = new Dictionary(), + StartedAt = DateTimeOffset.UtcNow, + Status = DoctorReportStatus.Failed + }; + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Error, status); + } + + [Fact] + public void DetermineTestStatus_PatientNotStarted_ReturnsError() + { + var report = CreateReport(PatientState.NotStarted, null); + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Error, status); + } + + [Fact] + public void DetermineTestStatus_CustomExpectedExitCode_Matches() + { + var report = CreateReport(PatientState.Completed, 42); + var testCase = CreateTestCase() with { ExpectedExitCode = 42 }; + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Pass, status); + } + + [Fact] + public void DetermineTestStatus_MatchesByPatientIdPrefix() + { + var report = new DoctorReport + { + Patients = new Dictionary + { + ["test-T001"] = new PatientInfo + { + PatientId = "test-T001", + ProcessId = 123, + State = PatientState.Completed, + ExitCode = 0, + StartedAt = DateTimeOffset.UtcNow + } + }, + StartedAt = DateTimeOffset.UtcNow, + Status = DoctorReportStatus.Completed + }; + var testCase = CreateTestCase(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + + Assert.Equal(DoctorTestStatus.Pass, status); + } + + [Fact] + public async Task RunAsync_NullDoctor_ThrowsArgumentNullException() + { + var suite = new DoctorTestSuite(); + var testCases = new[] { CreateTestCase() }; + + await Assert.ThrowsAsync(() => + suite.RunAsync(null!, testCases)); + } + + [Fact] + public async Task RunAsync_EmptyTestCases_ReturnsEmptyReport() + { + var suite = new DoctorTestSuite(); + var doctor = CreateDoctor(); + + var report = await suite.RunAsync(doctor, []); + + Assert.Equal(0, report.TotalCount); + Assert.True(report.IsAllPassed); + } + + [Fact] + public void TestCaseCompleted_EventFired() + { + var suite = new DoctorTestSuite(); + var results = new List(); + suite.TestCaseCompleted += (_, r) => results.Add(r); + + Assert.Empty(results); + } + + [Fact] + public void SuiteCompleted_EventFired() + { + var suite = new DoctorTestSuite(); + DoctorTestSuiteReport? captured = null; + suite.SuiteCompleted += (_, r) => captured = r; + + Assert.Null(captured); + } + + [Fact] + public void DoctorTestCase_RecordEquality() + { + var tc1 = new DoctorTestCase + { + TestCaseId = "T001", + TestName = "Test", + Prompt = "hello", + TimeoutSeconds = 30 + }; + var tc2 = tc1 with { }; + + Assert.Equal(tc1, tc2); + } + + [Fact] + public void DoctorTestCase_WithModifies_CreatesNewInstance() + { + var tc1 = new DoctorTestCase + { + TestCaseId = "T001", + TestName = "Test", + Prompt = "hello", + TimeoutSeconds = 30 + }; + var tc2 = tc1 with { TimeoutSeconds = 60 }; + + Assert.Equal(30, tc1.TimeoutSeconds); + Assert.Equal(60, tc2.TimeoutSeconds); + } + + [Fact] + public void DoctorTestSuiteReport_Duration_CalculatedCorrectly() + { + var report = new DoctorTestSuiteReport + { + Results = [], + TotalCount = 0, + PassCount = 0, + FailCount = 0, + HungCount = 0, + ErrorCount = 0, + SkippedCount = 0, + StartedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + CompletedAt = new DateTimeOffset(2026, 1, 1, 0, 1, 30, TimeSpan.Zero), + IsAllPassed = true + }; + + Assert.Equal(TimeSpan.FromSeconds(90), report.Duration); + } + + [Fact] + public void DoctorTestSuiteReport_CountsAreConsistent() + { + var results = new List + { + new() { TestCaseId = "T001", TestName = "A", Status = DoctorTestStatus.Pass }, + new() { TestCaseId = "T002", TestName = "B", Status = DoctorTestStatus.Fail }, + new() { TestCaseId = "T003", TestName = "C", Status = DoctorTestStatus.Hung }, + new() { TestCaseId = "T004", TestName = "D", Status = DoctorTestStatus.Error }, + new() { TestCaseId = "T005", TestName = "E", Status = DoctorTestStatus.Skipped }, + }; + + var report = new DoctorTestSuiteReport + { + Results = results, + TotalCount = 5, + PassCount = 1, + FailCount = 1, + HungCount = 1, + ErrorCount = 1, + SkippedCount = 1, + StartedAt = DateTimeOffset.UtcNow, + CompletedAt = DateTimeOffset.UtcNow, + IsAllPassed = false + }; + + Assert.Equal(5, report.TotalCount); + Assert.False(report.IsAllPassed); + } + + private static DoctorAgent CreateDoctor() + { + var fs = new InMemoryFileSystem(); + var processService = new Mock(); + var transport = new Mock(); + transport.Setup(t => t.IsConnected).Returns(false); + transport.Setup(t => t.ConnectedPatientIds).Returns(new List()); + + return new DoctorAgent(fs, processService.Object, transport.Object, NullLogger.Instance); + } + + private static DoctorReport CreateReport(PatientState state, int? exitCode) + { + return new DoctorReport + { + Patients = new Dictionary + { + ["patient-main"] = new PatientInfo + { + PatientId = "patient-main", + ProcessId = 123, + State = state, + ExitCode = exitCode, + StartedAt = DateTimeOffset.UtcNow + } + }, + StartedAt = DateTimeOffset.UtcNow, + Status = state == PatientState.Completed ? DoctorReportStatus.Completed : DoctorReportStatus.Failed + }; + } + + private static DoctorTestCase CreateTestCase() + { + return new DoctorTestCase + { + TestCaseId = "T001", + TestName = "FileRead", + Prompt = "读取README.md", + TimeoutSeconds = 30 + }; + } +} diff --git a/src/JoinCode/App/Builder/ApplicationBuilder.cs b/src/JoinCode/App/Builder/ApplicationBuilder.cs index 39bcd15..fc866f2 100644 --- a/src/JoinCode/App/Builder/ApplicationBuilder.cs +++ b/src/JoinCode/App/Builder/ApplicationBuilder.cs @@ -256,6 +256,7 @@ public static CommandLineOptions ParseArgs(string[] args) AppendSystemPrompt = result.AppendSystemPrompt, DoctorMode = result.Doctor, DoctorEndpoint = result.DoctorEndpoint, + DoctorTestSuiteMode = result.DoctorTestSuite, }; // --await N: 超时自动关闭秒数 @@ -395,6 +396,7 @@ public static void ShowHelp() Cli.TerminalHelper.WriteLine(" --doctor 医生模式:spawn jcc.exe 子进程作为病人,监控运行状态并自动修复问题"); Cli.TerminalHelper.WriteLine(" --doctor-endpoint 医生 SSE 端点(病人端使用,连接到医生,如 http://localhost:9902)"); Cli.TerminalHelper.WriteLine(" --doctor-port <端口> 医生 SSE 服务器端口(医生端使用,默认 9902)"); + Cli.TerminalHelper.WriteLine(" --doctor-test-suite 医生测试套件:执行内置功能测试用例(T001-T006)"); Cli.TerminalHelper.NewLine(); Cli.TerminalHelper.WriteLine("子命令:"); Cli.TerminalHelper.WriteLine(" tool MCP 工具管理"); diff --git a/src/JoinCode/Cli/Core/CliArg.cs b/src/JoinCode/Cli/Core/CliArg.cs index 9b80e72..ef594ae 100644 --- a/src/JoinCode/Cli/Core/CliArg.cs +++ b/src/JoinCode/Cli/Core/CliArg.cs @@ -70,4 +70,7 @@ public enum CliArg [CliOption("--doctor-port", "", "医生 SSE 服务器端口(医生端使用,默认 9902)", AcceptsValue = true)] DoctorPort, + + [CliOption("--doctor-test-suite", "", "医生测试套件模式:执行内置功能测试用例(T001-T006),验证 jcc.exe 各项能力")] + DoctorTestSuite, } diff --git a/src/JoinCode/Cli/Core/CommandLineOptions.cs b/src/JoinCode/Cli/Core/CommandLineOptions.cs index 41aba5b..c8175a5 100644 --- a/src/JoinCode/Cli/Core/CommandLineOptions.cs +++ b/src/JoinCode/Cli/Core/CommandLineOptions.cs @@ -147,6 +147,11 @@ public class CommandLineOptions { /// public int? DoctorPort { get; set; } + /// + /// 医生测试套件模式(--doctor-test-suite 参数)— 执行内置功能测试用例(T001-T006) + /// + public bool DoctorTestSuiteMode { get; set; } + /// /// 是否为非交互模式(用户请求 / 无头环境 / CI 环境 / -p 参数) /// diff --git a/src/JoinCode/Entry/DoctorModeRunner.cs b/src/JoinCode/Entry/DoctorModeRunner.cs index 58aefa6..f8779c1 100644 --- a/src/JoinCode/Entry/DoctorModeRunner.cs +++ b/src/JoinCode/Entry/DoctorModeRunner.cs @@ -51,6 +51,103 @@ internal static async Task RunAsync(CommandLineOptions options) }; } + /// + /// 医生测试套件模式 — 执行内置功能测试用例(T001-T006) + /// + internal static async Task RunTestSuiteAsync(CommandLineOptions options) + { + Cli.TerminalHelper.Init(); + Diag.WriteLine("[DOCTOR] 医生测试套件模式启动"); + + var fs = IO.FileSystem.FileSystemFactory.Create(); + var processService = new IO.ProcessService.PhysicalProcessService(); + + var port = options.DoctorPort ?? 9902; + + var testSuite = new DoctorTestSuite(); + var results = new List(); + + foreach (var testCase in DoctorTestSuite.BuiltInTests) + { + Diag.WriteLine($"[DOCTOR] 执行测试: {testCase.TestCaseId} - {testCase.TestName}"); + + var transport = new DoctorSseServer(port + results.Count, logger: null); + await using var doctor = new DoctorAgent(fs, processService, transport); + + var patientArgs = DoctorTestSuite.BuildPatientArguments(testCase); + var workingDir = fs.GetCurrentDirectory(); + + var envVars = new Dictionary + { + ["JCC_TEST_CASE_ID"] = testCase.TestCaseId + }; + + if (!string.IsNullOrWhiteSpace(options.DoctorEndpoint)) + envVars["JCC_ENDPOINT"] = options.DoctorEndpoint; + if (!string.IsNullOrWhiteSpace(options.Model)) + envVars["JCC_MODEL_ID"] = options.Model; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(testCase.TimeoutSeconds)); + var report = await doctor.RunAsync($"test-{testCase.TestCaseId}", patientArgs, workingDir, envVars, cts.Token).ConfigureAwait(false); + sw.Stop(); + + var status = DoctorTestSuite.DetermineTestStatus(report, testCase); + results.Add(new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = status, + Duration = sw.Elapsed + }); + } + catch (OperationCanceledException) + { + sw.Stop(); + results.Add(new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = DoctorTestStatus.Hung, + Duration = sw.Elapsed, + ErrorMessage = $"测试超时 ({testCase.TimeoutSeconds}s)" + }); + } + catch (Exception ex) + { + sw.Stop(); + results.Add(new DoctorTestCaseResult + { + TestCaseId = testCase.TestCaseId, + TestName = testCase.TestName, + Status = DoctorTestStatus.Error, + Duration = sw.Elapsed, + ErrorMessage = ex.Message + }); + } + } + + var suiteReport = new DoctorTestSuiteReport + { + Results = results, + TotalCount = results.Count, + PassCount = results.Count(r => r.Status == DoctorTestStatus.Pass), + FailCount = results.Count(r => r.Status == DoctorTestStatus.Fail), + HungCount = results.Count(r => r.Status == DoctorTestStatus.Hung), + ErrorCount = results.Count(r => r.Status == DoctorTestStatus.Error), + SkippedCount = results.Count(r => r.Status == DoctorTestStatus.Skipped), + StartedAt = DateTimeOffset.UtcNow, + CompletedAt = DateTimeOffset.UtcNow, + IsAllPassed = results.All(r => r.Status == DoctorTestStatus.Pass) + }; + + PrintTestSuiteReport(suiteReport); + + return suiteReport.IsAllPassed ? 0 : 1; + } + /// /// 构建病人进程参数 — 从医生的 CLI 参数推导 /// @@ -151,4 +248,43 @@ private static void PrintReport(DoctorReport report) Cli.TerminalHelper.WriteLine("═══════════════════════════════════════"); } + + /// + /// 打印测试套件报告 + /// + private static void PrintTestSuiteReport(DoctorTestSuiteReport report) + { + Cli.TerminalHelper.NewLine(); + Cli.TerminalHelper.WriteLine("═══════════════════════════════════════"); + Cli.TerminalHelper.WriteLine(" 医生测试套件报告"); + Cli.TerminalHelper.WriteLine("═══════════════════════════════════════"); + Cli.TerminalHelper.WriteLine($" 总数: {report.TotalCount}"); + Cli.TerminalHelper.WriteLine($" 通过: {report.PassCount}"); + Cli.TerminalHelper.WriteLine($" 失败: {report.FailCount}"); + Cli.TerminalHelper.WriteLine($" 卡死: {report.HungCount}"); + Cli.TerminalHelper.WriteLine($" 错误: {report.ErrorCount}"); + Cli.TerminalHelper.WriteLine($" 跳过: {report.SkippedCount}"); + Cli.TerminalHelper.WriteLine($" 耗时: {report.Duration.TotalSeconds:F1}s"); + Cli.TerminalHelper.WriteLine($" 结果: {(report.IsAllPassed ? "ALL PASSED" : "HAS FAILURES")}"); + + Cli.TerminalHelper.NewLine(); + Cli.TerminalHelper.WriteLine(" ── 用例详情 ──"); + foreach (var test in report.Results) + { + var statusIcon = test.Status switch + { + DoctorTestStatus.Pass => "PASS", + DoctorTestStatus.Fail => "FAIL", + DoctorTestStatus.Hung => "HUNG", + DoctorTestStatus.Error => "ERR ", + DoctorTestStatus.Skipped => "SKIP", + _ => "????" + }; + Cli.TerminalHelper.WriteLine($" [{statusIcon}] {test.TestCaseId}: {test.TestName} ({test.Duration.TotalMilliseconds:F0}ms)"); + if (!string.IsNullOrEmpty(test.ErrorMessage)) + Cli.TerminalHelper.WriteLine($" {test.ErrorMessage}"); + } + + Cli.TerminalHelper.WriteLine("═══════════════════════════════════════"); + } } diff --git a/src/JoinCode/Program.cs b/src/JoinCode/Program.cs index f05de1f..40cb977 100644 --- a/src/JoinCode/Program.cs +++ b/src/JoinCode/Program.cs @@ -27,6 +27,10 @@ static async Task Main(string[] args) if (options.DoctorMode) return await Entry.DoctorModeRunner.RunAsync(options); + // 3.1b --doctor-test-suite: 医生测试套件模式 — 执行内置功能测试用例(T001-T006) + if (options.DoctorTestSuiteMode) + return await Entry.DoctorModeRunner.RunTestSuiteAsync(options); + // 3.2 --doctor-endpoint: 病人模式 — 连接到医生的 SSE 服务器,发送遥测事件 // 病人正常运行,但额外启动 DoctorSseClient 把诊断输出推送给医生 Core.Agents.Doctor.DoctorSseClient? doctorClient = null; From f51efb2a98a0c344b839292f02278c90529086ae Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sat, 18 Jul 2026 22:09:38 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20Doctor=20=E8=81=94=E8=B0=83?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20SSE=E5=90=AF=E5=8A=A8=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F/=E7=97=85=E4=BA=BAendpoint=E5=8F=82=E6=95=B0/WaitForE?= =?UTF-8?q?xitAsync=E7=AB=9E=E6=80=81/=E6=8A=A5=E5=91=8A=E7=8A=B6=E6=80=81?= =?UTF-8?q?/HttpListener=E5=AE=B9=E9=94=99=20|=20=E5=86=B3=E7=AD=96:=20Con?= =?UTF-8?q?nectAsync=E5=85=88=E4=BA=8ESpawnAsync,Diag.WriteLifecycle?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3WriteLine=E7=A1=AE=E4=BF=9D=E8=BE=93=E5=87=BA?= =?UTF-8?q?,WaitForExitAsync=E8=87=AA=E8=A1=8C=E6=9B=B4=E6=96=B0Info?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Agents/Agents/Doctor/DoctorAgent.cs | 14 +++++++++++--- .../Agents/Agents/Doctor/DoctorSseServer.cs | 16 +++++++++++++--- .../Agents/Doctor/PatientProcessManager.cs | 19 +++++++++++++++++++ src/JoinCode/Entry/DoctorModeRunner.cs | 16 ++++++++++------ 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs index 7f52665..d9d0f3c 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs @@ -95,11 +95,11 @@ public async Task RunAsync( try { + await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + var patientInfo = await _patientManager.SpawnAsync( patientId, patientArguments, workingDirectory, environmentVariables, cancellationToken).ConfigureAwait(false); - await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); - _logger?.LogInformation("[Doctor] 病人进程已启动: {PatientId} (PID={ProcessId}),开始监控", patientId, patientInfo.ProcessId); var exitInfo = await _patientManager.WaitForExitAsync(patientId, cancellationToken).ConfigureAwait(false); @@ -107,7 +107,15 @@ public async Task RunAsync( _logger?.LogInformation("[Doctor] 病人进程已退出: {PatientId}, 状态={State}, 退出码={ExitCode}", patientId, exitInfo.State, exitInfo.ExitCode); - return BuildReport(startedAt, exitInfo); + var reportStatus = exitInfo.State switch + { + PatientState.Completed => DoctorReportStatus.Completed, + PatientState.Hung => DoctorReportStatus.Failed, + PatientState.Failed => DoctorReportStatus.PartiallyFixed, + _ => DoctorReportStatus.Failed + }; + + return BuildReport(startedAt, exitInfo, reportStatus); } catch (OperationCanceledException) { diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs index 07d905f..e0efc38 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs @@ -69,7 +69,17 @@ public Task ConnectAsync(CancellationToken cancellationToken = default) _listener = new HttpListener(); _listener.Prefixes.Add($"http://{_host}:{_port}/"); - _listener.Start(); + try + { + _listener.Start(); + } + catch (HttpListenerException) + { + _listener.Close(); + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{_port}/"); + _listener.Start(); + } _listenCts = new CancellationTokenSource(); _listenTask = RunAcceptLoopAsync(_listenCts.Token); @@ -312,8 +322,8 @@ public async ValueTask DisposeAsync() } finally { _patientsLock.Release(); } - _listener?.Stop(); - _listener?.Close(); + try { _listener?.Stop(); } catch (ObjectDisposedException) { System.Diagnostics.Trace.WriteLine("[DoctorSSE] HttpListener.Stop 时已释放"); } + try { _listener?.Close(); } catch (ObjectDisposedException) { System.Diagnostics.Trace.WriteLine("[DoctorSSE] HttpListener.Close 时已释放"); } _listener = null; _eventChannel.Writer.TryComplete(); diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs index 4ac268e..aa1f0a0 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs @@ -299,6 +299,25 @@ public void Kill() public async Task WaitForExitAsync(CancellationToken cancellationToken = default) { await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + var exitCode = _process.ExitCode; + var state = exitCode switch + { + 0 => PatientState.Completed, + 1234 => PatientState.Hung, + _ => PatientState.Failed + }; + + if (Info.State == PatientState.Running) + { + Info = Info with + { + State = state, + ExitCode = exitCode, + ExitedAt = DateTimeOffset.UtcNow + }; + } + return Info; } diff --git a/src/JoinCode/Entry/DoctorModeRunner.cs b/src/JoinCode/Entry/DoctorModeRunner.cs index f8779c1..115b20d 100644 --- a/src/JoinCode/Entry/DoctorModeRunner.cs +++ b/src/JoinCode/Entry/DoctorModeRunner.cs @@ -11,7 +11,7 @@ internal static class DoctorModeRunner internal static async Task RunAsync(CommandLineOptions options) { Cli.TerminalHelper.Init(); - Diag.WriteLine("[DOCTOR] 医生模式启动"); + Diag.WriteLifecycle("[DOCTOR] 医生模式启动"); var fs = IO.FileSystem.FileSystemFactory.Create(); var processService = new IO.ProcessService.PhysicalProcessService(); @@ -34,10 +34,10 @@ internal static async Task RunAsync(CommandLineOptions options) }; } - var patientArgs = BuildPatientArguments(options); + var patientArgs = BuildPatientArguments(options, port); var workingDir = fs.GetCurrentDirectory(); - Diag.WriteLine($"[DOCTOR] 病人参数: {patientArgs}"); + Diag.WriteLifecycle($"[DOCTOR] 病人参数: {patientArgs}"); var runReport = await doctor.RunAsync("patient-main", patientArgs, workingDir, cancellationToken: default).ConfigureAwait(false); @@ -71,10 +71,11 @@ internal static async Task RunTestSuiteAsync(CommandLineOptions options) { Diag.WriteLine($"[DOCTOR] 执行测试: {testCase.TestCaseId} - {testCase.TestName}"); - var transport = new DoctorSseServer(port + results.Count, logger: null); + var testPort = port + results.Count; + var transport = new DoctorSseServer(testPort, logger: null); await using var doctor = new DoctorAgent(fs, processService, transport); - var patientArgs = DoctorTestSuite.BuildPatientArguments(testCase); + var patientArgs = DoctorTestSuite.BuildPatientArguments(testCase) + $" --doctor-endpoint http://localhost:{testPort}"; var workingDir = fs.GetCurrentDirectory(); var envVars = new Dictionary @@ -151,12 +152,15 @@ internal static async Task RunTestSuiteAsync(CommandLineOptions options) /// /// 构建病人进程参数 — 从医生的 CLI 参数推导 /// - private static string BuildPatientArguments(CommandLineOptions options) + private static string BuildPatientArguments(CommandLineOptions options, int? doctorPort = null) { var sb = new System.Text.StringBuilder(); sb.Append("--trust"); + if (doctorPort.HasValue) + sb.Append($" --doctor-endpoint http://localhost:{doctorPort.Value}"); + if (options.Verbose) sb.Append(" --verbose"); From 4f29fe775e1ba0bac0965ee3054ff2c0175cc6d8 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 03:07:39 +0800 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20DoctorDiag=20=E9=9D=99?= =?UTF-8?q?=E6=80=81=E6=97=A5=E5=BF=97=E6=9B=BF=E4=BB=A3=20ILogger=3F=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=20=E2=80=94=20=E6=89=80=E6=9C=89=20Doctor=20?= =?UTF-8?q?=E7=B1=BB=E5=85=B1=E7=94=A8=20Console.Error=20=E8=BE=93?= =?UTF-8?q?=E5=87=BA=20|=20=E5=86=B3=E7=AD=96:=20=E6=B6=88=E9=99=A4ILogger?= =?UTF-8?q?=3F=E5=8F=82=E6=95=B0=E9=81=BF=E5=85=8D=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=A2=AB=E5=90=9E,DoctorDiag.Write/WriteError=E6=97=A0?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E8=BE=93=E5=87=BA=E5=88=B0stderr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/Agents/Doctor/BuildOrchestrator.cs | 11 ++-- .../Agents/Agents/Doctor/DiagnosticEngine.cs | 10 +--- .../src/Agents/Agents/Doctor/DoctorAgent.cs | 59 +++++++++---------- .../src/Agents/Agents/Doctor/DoctorDiag.cs | 22 +++++++ .../Agents/Agents/Doctor/DoctorSseClient.cs | 22 ++++--- .../Agents/Agents/Doctor/DoctorSseServer.cs | 22 ++++--- .../Agents/Doctor/DoctorStdioTransport.cs | 14 ++--- .../Agents/Agents/Doctor/DoctorTestSuite.cs | 17 ++---- .../src/Agents/Agents/Doctor/HotFixEngine.cs | 17 +++--- .../Agents/Doctor/PatientProcessManager.cs | 29 ++++----- .../Agents/Agents/Doctor/SourceCodePatcher.cs | 16 +++-- .../Agents/Doctor/DoctorTestSuiteTests.cs | 2 +- .../Unit/Agents/Doctor/HotFixEngineTests.cs | 10 ++-- src/JoinCode/Entry/DoctorModeRunner.cs | 30 ++++++---- src/JoinCode/GlobalUsings.cs | 1 + 15 files changed, 142 insertions(+), 140 deletions(-) create mode 100644 components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorDiag.cs diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/BuildOrchestrator.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/BuildOrchestrator.cs index 982fca6..1e5b633 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/BuildOrchestrator.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/BuildOrchestrator.cs @@ -3,12 +3,10 @@ namespace Core.Agents.Doctor; public sealed class BuildOrchestrator { private readonly IProcessService _processService; - private readonly ILogger? _logger; - public BuildOrchestrator(IProcessService processService, ILogger? logger = null) + public BuildOrchestrator(IProcessService processService) { _processService = processService ?? throw new ArgumentNullException(nameof(processService)); - _logger = logger; } public async Task BuildProjectAsync( @@ -25,7 +23,7 @@ public async Task BuildProjectAsync( { var arguments = $"build \"{projectPath}\" -c {configuration} --no-incremental"; - _logger?.LogInformation("[Doctor] 开始编译: dotnet {Args}", arguments); + DoctorDiag.Write($"[Doctor] 开始编译: dotnet {arguments}"); var options = new ProcessOptions { @@ -43,8 +41,7 @@ public async Task BuildProjectAsync( var success = result.ExitCode == 0; - _logger?.LogInformation("[Doctor] 编译完成: 成功={Success}, 退出码={ExitCode}, 耗时={Duration}ms", - success, result.ExitCode, sw.ElapsedMilliseconds); + DoctorDiag.Write($"[Doctor] 编译完成: 成功={success}, 退出码={result.ExitCode}, 耗时={sw.ElapsedMilliseconds}ms"); return new BuildResult { @@ -60,7 +57,7 @@ public async Task BuildProjectAsync( catch (Exception ex) { sw.Stop(); - _logger?.LogError(ex, "[Doctor] 编译异常: {ProjectPath}", projectPath); + DoctorDiag.WriteError($"[Doctor] 编译异常: {projectPath}: {ex.Message}"); return new BuildResult { Success = false, diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs index 4010cbe..3b40739 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs @@ -6,7 +6,6 @@ namespace Core.Agents.Doctor; /// public sealed class DiagnosticEngine { - private readonly ILogger? _logger; private readonly Dictionary<(string PatientId, string SessionId), int> _loopCountBySession = new(); private readonly Dictionary<(string PatientId, string ToolName), int> _permissionDeniedByTool = new(); private readonly Dictionary> _recentApiErrorsByPatient = new(); @@ -24,10 +23,7 @@ public IReadOnlyList Reports public event EventHandler? DiagnosticReportGenerated; - public DiagnosticEngine(ILogger? logger = null) - { - _logger = logger; - } + public DiagnosticEngine() { } public DiagnosticReport? Evaluate(DiagnosticEvent evt) { @@ -45,7 +41,7 @@ public DiagnosticEngine(ILogger? logger = null) if (report is not null) { lock (_lock) _reports.Add(report); - _logger?.LogWarning("[Doctor] 诊断报告生成: {RuleId} - {Description} (病人: {PatientId})", report.RuleId, report.Description, report.PatientId); + DoctorDiag.WriteError($"[Doctor] 诊断报告生成: {report.RuleId} - {report.Description} (病人: {report.PatientId})"); DiagnosticReportGenerated?.Invoke(this, report); } @@ -69,7 +65,7 @@ public DiagnosticEngine(ILogger? logger = null) }; lock (_lock) _reports.Add(report); - _logger?.LogWarning("[Doctor] 诊断报告生成: {RuleId} - {Description} (病人: {PatientId})", report.RuleId, report.Description, report.PatientId); + DoctorDiag.WriteError($"[Doctor] 诊断报告生成: {report.RuleId} - {report.Description} (病人: {report.PatientId})"); DiagnosticReportGenerated?.Invoke(this, report); return report; } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs index d9d0f3c..ca2c285 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs @@ -15,7 +15,6 @@ public sealed class DoctorAgent : IAsyncDisposable private readonly BuildOrchestrator _builder; private readonly IFileSystem _fs; private readonly IProcessService _processService; - private readonly ILogger? _logger; private readonly List _fixResults = []; private readonly SemaphoreSlim _fixLock = new(1, 1); private int _isDisposed; @@ -44,19 +43,17 @@ public sealed class DoctorAgent : IAsyncDisposable public DoctorAgent( IFileSystem fs, IProcessService processService, - IDoctorTransport? transport = null, - ILogger? logger = null) + IDoctorTransport? transport = null) { _fs = fs ?? throw new ArgumentNullException(nameof(fs)); _processService = processService ?? throw new ArgumentNullException(nameof(processService)); - _logger = logger; - _patientManager = new PatientProcessManager(processService, logger); - _transport = transport ?? new DoctorSseServer(9902, logger: logger); - _diagnosticEngine = new DiagnosticEngine(logger); - _patcher = new SourceCodePatcher(fs, logger); - _builder = new BuildOrchestrator(processService, logger); - _hotFixEngine = new HotFixEngine(_patcher, _builder, _patientManager, _transport, fs, logger); + _patientManager = new PatientProcessManager(processService); + _transport = transport ?? new DoctorSseServer(9902); + _diagnosticEngine = new DiagnosticEngine(); + _patcher = new SourceCodePatcher(fs); + _builder = new BuildOrchestrator(processService); + _hotFixEngine = new HotFixEngine(_patcher, _builder, _patientManager, _transport, fs); _patientManager.ProcessExited += OnProcessExited; _transport.EventReceived += OnDiagnosticEventReceived; @@ -91,21 +88,24 @@ public async Task RunAsync( CancellationToken cancellationToken = default) { var startedAt = DateTimeOffset.UtcNow; - _logger?.LogInformation("[Doctor] 医生模式启动,病人: {PatientId}, 参数: {Args}", patientId, patientArguments); + DoctorDiag.Write($"[Doctor] 医生模式启动,病人: {patientId}, 参数: {patientArguments}"); try { + DoctorDiag.Write("[Doctor] 正在启动 SSE 服务器..."); await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + DoctorDiag.Write("[Doctor] SSE 服务器已启动"); + DoctorDiag.Write($"[Doctor] 正在启动病人进程: {patientId}"); var patientInfo = await _patientManager.SpawnAsync( patientId, patientArguments, workingDirectory, environmentVariables, cancellationToken).ConfigureAwait(false); - _logger?.LogInformation("[Doctor] 病人进程已启动: {PatientId} (PID={ProcessId}),开始监控", patientId, patientInfo.ProcessId); + DoctorDiag.Write($"[Doctor] 病人进程已启动: {patientId} (PID={patientInfo.ProcessId}),开始监控"); + DoctorDiag.Write($"[Doctor] 等待病人进程退出: {patientId}"); var exitInfo = await _patientManager.WaitForExitAsync(patientId, cancellationToken).ConfigureAwait(false); - _logger?.LogInformation("[Doctor] 病人进程已退出: {PatientId}, 状态={State}, 退出码={ExitCode}", - patientId, exitInfo.State, exitInfo.ExitCode); + DoctorDiag.Write($"[Doctor] 病人进程已退出: {patientId}, 状态={exitInfo.State}, 退出码={exitInfo.ExitCode}"); var reportStatus = exitInfo.State switch { @@ -119,13 +119,13 @@ public async Task RunAsync( } catch (OperationCanceledException) { - _logger?.LogWarning("[Doctor] 医生模式被取消"); + DoctorDiag.Write("[Doctor] 医生模式被取消"); await _patientManager.KillAllAsync().ConfigureAwait(false); return BuildReport(startedAt, _patientManager.GetPatientInfo(patientId), DoctorReportStatus.Failed); } catch (Exception ex) { - _logger?.LogError(ex, "[Doctor] 医生模式运行异常"); + DoctorDiag.WriteError($"[Doctor] 医生模式运行异常: {ex.GetType().Name}: {ex.Message}"); await _patientManager.KillAllAsync().ConfigureAwait(false); return BuildReport(startedAt, _patientManager.GetPatientInfo(patientId), DoctorReportStatus.Failed); } @@ -139,19 +139,19 @@ public async Task RunServerAsync( CancellationToken cancellationToken = default) { var startedAt = DateTimeOffset.UtcNow; - _logger?.LogInformation("[Doctor] 医生 SSE 服务器模式启动,端口: {Port}", port); + DoctorDiag.Write($"[Doctor] 医生 SSE 服务器模式启动,端口: {port}"); try { await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); - _logger?.LogInformation("[Doctor] SSE 服务器已启动,等待病人连接..."); + DoctorDiag.Write("[Doctor] SSE 服务器已启动,等待病人连接..."); await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { - _logger?.LogInformation("[Doctor] 医生 SSE 服务器被停止"); + DoctorDiag.Write("[Doctor] 医生 SSE 服务器被停止"); } return BuildReport(startedAt, null); @@ -169,13 +169,13 @@ public async Task RunTestSuiteAsync( var startedAt = DateTimeOffset.UtcNow; var testResults = new List(); - _logger?.LogInformation("[Doctor] 测试套件启动,共 {Count} 个用例", testCases.Count()); + DoctorDiag.Write($"[Doctor] 测试套件启动,共 {testCases.Count()} 个用例"); foreach (var (testCaseId, testName, arguments) in testCases) { if (cancellationToken.IsCancellationRequested) break; - _logger?.LogInformation("[Doctor] 执行测试: {TestId} - {TestName}", testCaseId, testName); + DoctorDiag.Write($"[Doctor] 执行测试: {testCaseId} - {testName}"); var sw = System.Diagnostics.Stopwatch.StartNew(); try @@ -230,30 +230,29 @@ public async Task RunTestSuiteAsync( private void OnProcessExited(object? sender, PatientInfo info) { - _logger?.LogInformation("[Doctor] 病人进程退出事件: {PatientId}, PID={ProcessId}, 状态={State}", info.PatientId, info.ProcessId, info.State); - + DoctorDiag.Write($"[Doctor] 病人进程退出事件: {info.PatientId}, PID={info.ProcessId}, 状态={info.State}"); _diagnosticEngine.EvaluateProcessHung(info); } private void OnDiagnosticEventReceived(object? sender, DiagnosticEvent evt) { - _logger?.LogDebug("[Doctor] 收到诊断事件: {EventType} (病人: {PatientId})", evt.EventType, evt.PatientId); + DoctorDiag.Write($"[Doctor] 收到诊断事件: {evt.EventType} (病人: {evt.PatientId})"); _diagnosticEngine.Evaluate(evt); } private void OnPatientConnected(object? sender, string patientId) { - _logger?.LogInformation("[Doctor] 病人已连接: {PatientId}", patientId); + DoctorDiag.Write($"[Doctor] 病人已连接: {patientId}"); } private void OnPatientDisconnected(object? sender, string patientId) { - _logger?.LogInformation("[Doctor] 病人已断开: {PatientId}", patientId); + DoctorDiag.Write($"[Doctor] 病人已断开: {patientId}"); } private void OnDiagnosticReportGenerated(object? sender, DiagnosticReport report) { - _logger?.LogWarning("[Doctor] 诊断报告: {RuleId} - {Description} (病人: {PatientId})", report.RuleId, report.Description, report.PatientId); + DoctorDiag.WriteError($"[Doctor] 诊断报告: {report.RuleId} - {report.Description} (病人: {report.PatientId})"); if (!AutoFixEnabled) return; @@ -271,7 +270,7 @@ private void OnDiagnosticReportGenerated(object? sender, DiagnosticReport report } catch (Exception ex) { - _logger?.LogError(ex, "[Doctor] 自动修复异常: {RuleId} (病人: {PatientId})", report.RuleId, report.PatientId); + DoctorDiag.WriteError($"[Doctor] 自动修复异常: {report.RuleId} (病人: {report.PatientId}): {ex.Message}"); } finally { @@ -282,12 +281,12 @@ private void OnDiagnosticReportGenerated(object? sender, DiagnosticReport report private void OnFixApplied(object? sender, HotFixResult result) { - _logger?.LogInformation("[Doctor] 修复已应用: {ActionType} (病人: {PatientId})", result.Action.ActionType, result.PatientId); + DoctorDiag.Write($"[Doctor] 修复已应用: {result.Action.ActionType} (病人: {result.PatientId})"); } private void OnFixRolledBack(object? sender, HotFixResult result) { - _logger?.LogWarning("[Doctor] 修复已回滚: {ActionType} (病人: {PatientId})", result.Action.ActionType, result.PatientId); + DoctorDiag.WriteError($"[Doctor] 修复已回滚: {result.Action.ActionType} (病人: {result.PatientId})"); } private DoctorReport BuildReport( diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorDiag.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorDiag.cs new file mode 100644 index 0000000..070a911 --- /dev/null +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorDiag.cs @@ -0,0 +1,22 @@ +namespace Core.Agents.Doctor; + +/// +/// Doctor 诊断日志 — 所有 Doctor 类共用,输出到 Console.Error(无条件) +/// 替代 ILogger? 参数,避免日志被吞掉 +/// +internal static class DoctorDiag +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void Write(string message) + { + Console.Error.WriteLine(message); + Console.Error.Flush(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void WriteError(string message) + { + Console.Error.WriteLine($"[ERROR] {message}"); + Console.Error.Flush(); + } +} diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs index 327a4df..34e05a8 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs @@ -13,7 +13,6 @@ public sealed class DoctorSseClient : IAsyncDisposable { private readonly string _endpoint; private readonly string _patientId; - private readonly ILogger? _logger; private readonly HttpClient _httpClient; private readonly CancellationTokenSource _cts; private Task? _sseListenTask; @@ -28,11 +27,10 @@ public sealed class DoctorSseClient : IAsyncDisposable /// 收到医生指令事件 public event EventHandler? CommandReceived; - public DoctorSseClient(string endpoint, string? patientId = null, ILogger? logger = null) + public DoctorSseClient(string endpoint, string? patientId = null) { _endpoint = endpoint.TrimEnd('/'); _patientId = patientId ?? Guid.NewGuid().ToString("N")[..8]; - _logger = logger; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; _cts = new CancellationTokenSource(); } @@ -44,7 +42,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) { if (IsConnected) return; - _logger?.LogInformation("[DoctorSSE-Client] 连接医生: {Endpoint}, 病人ID: {PatientId}", _endpoint, _patientId); + DoctorDiag.Write($"[DoctorSSE-Client] 连接医生: {_endpoint}, 病人ID: {_patientId}"); _sseListenTask = ListenSseAsync(_cts.Token); @@ -86,12 +84,12 @@ public async Task SendEventAsync(DiagnosticEvent evt, CancellationToken cancella var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { - _logger?.LogWarning("[DoctorSSE-Client] 发送事件失败: {StatusCode}", response.StatusCode); + DoctorDiag.WriteError($"[DoctorSSE-Client] 发送事件失败: {response.StatusCode}"); } } catch (HttpRequestException ex) { - _logger?.LogDebug(ex, "[DoctorSSE-Client] 发送事件网络异常"); + DoctorDiag.WriteError($"[DoctorSSE-Client] 发送事件网络异常: {ex.Message}"); } } @@ -123,31 +121,31 @@ private async Task ListenSseAsync(CancellationToken ct) var url = $"{_endpoint}/sse?patientId={_patientId}"; using var stream = await _httpClient.GetStreamAsync(url, ct).ConfigureAwait(false); - _logger?.LogInformation("[DoctorSSE-Client] SSE 连接已建立: {Url}", url); + DoctorDiag.Write($"[DoctorSSE-Client] SSE 连接已建立: {url}"); await foreach (var sseEvent in ParseSseStreamAsync(stream, ct).ConfigureAwait(false)) { if (sseEvent.EventType == "command") { - _logger?.LogDebug("[DoctorSSE-Client] 收到医生指令: {Data}", sseEvent.Data); + DoctorDiag.Write($"[DoctorSSE-Client] 收到医生指令: {sseEvent.Data}"); CommandReceived?.Invoke(this, sseEvent.Data); } else if (sseEvent.EventType == "endpoint") { - _logger?.LogDebug("[DoctorSSE-Client] 收到端点信息: {Data}", sseEvent.Data); + DoctorDiag.Write($"[DoctorSSE-Client] 收到端点信息: {sseEvent.Data}"); } } - _logger?.LogWarning("[DoctorSSE-Client] SSE 流结束,将重连"); + DoctorDiag.WriteError("[DoctorSSE-Client] SSE 流结束,将重连"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (HttpRequestException ex) { - _logger?.LogDebug(ex, "[DoctorSSE-Client] SSE 连接失败,{Delay}ms 后重连", retryDelay.TotalMilliseconds); + DoctorDiag.WriteError($"[DoctorSSE-Client] SSE 连接失败,{retryDelay.TotalMilliseconds}ms 后重连: {ex.Message}"); } catch (Exception ex) { - _logger?.LogDebug(ex, "[DoctorSSE-Client] SSE 监听异常,{Delay}ms 后重连", retryDelay.TotalMilliseconds); + DoctorDiag.WriteError($"[DoctorSSE-Client] SSE 监听异常,{retryDelay.TotalMilliseconds}ms 后重连: {ex.Message}"); } try { await Task.Delay(retryDelay, ct).ConfigureAwait(false); } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs index e0efc38..ef33f0e 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs @@ -17,7 +17,6 @@ public sealed class DoctorSseServer : IDoctorTransport { private readonly int _port; private readonly string _host; - private readonly ILogger? _logger; private HttpListener? _listener; private readonly Dictionary _patients = new(); private readonly SemaphoreSlim _patientsLock = new(1, 1); @@ -49,11 +48,10 @@ public IReadOnlyList ConnectedPatientIds /// public event EventHandler? PatientDisconnected; - public DoctorSseServer(int port, string host = "localhost", ILogger? logger = null) + public DoctorSseServer(int port, string host = "localhost") { _port = port; _host = host; - _logger = logger; _eventChannel = Channel.CreateBounded(new BoundedChannelOptions(512) { FullMode = BoundedChannelFullMode.DropOldest, @@ -85,7 +83,7 @@ public Task ConnectAsync(CancellationToken cancellationToken = default) _listenTask = RunAcceptLoopAsync(_listenCts.Token); IsConnected = true; - _logger?.LogInformation("[DoctorSSE] 服务器已启动: http://{Host}:{Port}/", _host, _port); + DoctorDiag.Write($"[DoctorSSE] 服务器已启动: http://{_host}:{_port}/"); return Task.CompletedTask; } @@ -115,14 +113,14 @@ public async Task SendCommandAsync(string patientId, string command, Cancellatio if (patient is null) { - _logger?.LogWarning("[DoctorSSE] 病人 {PatientId} 未连接,无法发送指令", patientId); + DoctorDiag.WriteError($"[DoctorSSE] 病人 {patientId} 未连接,无法发送指令"); return; } var sseData = $"event: command\ndata: {EscapeSseData(command)}\n\n"; var bytes = Encoding.UTF8.GetBytes(sseData); await patient.SendAsync(bytes, cancellationToken).ConfigureAwait(false); - _logger?.LogDebug("[DoctorSSE] 已发送指令到病人 {PatientId}: {Command}", patientId, command[..Math.Min(command.Length, 100)]); + DoctorDiag.Write($"[DoctorSSE] 已发送指令到病人 {patientId}: {command[..Math.Min(command.Length, 100)]}"); } /// @@ -141,11 +139,11 @@ public async Task BroadcastCommandAsync(string command, CancellationToken cancel try { await patient.SendAsync(bytes, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - _logger?.LogDebug(ex, "[DoctorSSE] 广播指令到病人 {PatientId} 失败", patient.PatientId); + DoctorDiag.WriteError($"[DoctorSSE] 广播指令到病人 {patient.PatientId} 失败: {ex.Message}"); } } - _logger?.LogDebug("[DoctorSSE] 已广播指令到 {Count} 个病人", patients.Count); + DoctorDiag.Write($"[DoctorSSE] 已广播指令到 {patients.Count} 个病人"); } private async Task RunAcceptLoopAsync(CancellationToken ct) @@ -161,7 +159,7 @@ private async Task RunAcceptLoopAsync(CancellationToken ct) catch (OperationCanceledException) { break; } catch (Exception ex) { - _logger?.LogError(ex, "[DoctorSSE] 接受连接异常"); + DoctorDiag.WriteError($"[DoctorSSE] 接受连接异常: {ex.Message}"); } } } @@ -197,7 +195,7 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT } catch (Exception ex) { - _logger?.LogError(ex, "[DoctorSSE] 处理请求异常: {Path}", path); + DoctorDiag.WriteError($"[DoctorSSE] 处理请求异常: {path}: {ex.Message}"); try { context.Response.StatusCode = 500; context.Response.Close(); } catch (Exception closeEx) { System.Diagnostics.Trace.WriteLine($"[DoctorSSE] 关闭响应失败: {closeEx.Message}"); } } @@ -220,7 +218,7 @@ private async Task HandleSseConnectionAsync(HttpListenerContext context, string var endpointMsg = $"event: endpoint\ndata: /events?patientId={patientId}\n\n"; await patient.SendAsync(Encoding.UTF8.GetBytes(endpointMsg), ct).ConfigureAwait(false); - _logger?.LogInformation("[DoctorSSE] 病人 {PatientId} 已连接 SSE", patientId); + DoctorDiag.Write($"[DoctorSSE] 病人 {patientId} 已连接 SSE"); PatientConnected?.Invoke(this, patientId); try @@ -234,7 +232,7 @@ private async Task HandleSseConnectionAsync(HttpListenerContext context, string try { _patients.Remove(patientId); } finally { _patientsLock.Release(); } - _logger?.LogInformation("[DoctorSSE] 病人 {PatientId} SSE 连接断开", patientId); + DoctorDiag.Write($"[DoctorSSE] 病人 {patientId} SSE 连接断开"); PatientDisconnected?.Invoke(this, patientId); await patient.DisposeAsync().ConfigureAwait(false); diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs index e8c5495..db702ba 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs @@ -12,7 +12,6 @@ public sealed class DoctorStdioTransport : IDoctorTransport { private readonly PatientProcessManager _patientManager; private readonly string _patientId; - private readonly ILogger? _logger; private readonly Channel _eventChannel; private int _isDisposed; @@ -38,11 +37,10 @@ public IReadOnlyList ConnectedPatientIds /// public event EventHandler? PatientDisconnected; - public DoctorStdioTransport(PatientProcessManager patientManager, string patientId, ILogger? logger = null) + public DoctorStdioTransport(PatientProcessManager patientManager, string patientId) { _patientManager = patientManager ?? throw new ArgumentNullException(nameof(patientManager)); _patientId = patientId ?? throw new ArgumentNullException(nameof(patientId)); - _logger = logger; _eventChannel = Channel.CreateBounded(new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropOldest, @@ -57,7 +55,7 @@ public DoctorStdioTransport(PatientProcessManager patientManager, string patient public Task ConnectAsync(CancellationToken cancellationToken = default) { IsConnected = true; - _logger?.LogInformation("[Doctor-stdio] IPC 客户端已连接,病人 {PatientId}", _patientId); + DoctorDiag.Write($"[Doctor-stdio] IPC 客户端已连接,病人 {_patientId}"); PatientConnected?.Invoke(this, _patientId); return Task.CompletedTask; } @@ -82,20 +80,20 @@ public async Task SendCommandAsync(string patientId, string command, Cancellatio { if (patientId != _patientId) { - _logger?.LogWarning("[Doctor-stdio] 病人 {PatientId} 不匹配,期望 {ExpectedId}", patientId, _patientId); + DoctorDiag.WriteError($"[Doctor-stdio] 病人 {patientId} 不匹配,期望 {_patientId}"); return; } var stdin = _patientManager.GetStandardInput(_patientId); if (stdin is null || !stdin.BaseStream.CanWrite) { - _logger?.LogWarning("[Doctor-stdio] 病人 {PatientId} stdin 不可写,无法发送指令", _patientId); + DoctorDiag.WriteError($"[Doctor-stdio] 病人 {_patientId} stdin 不可写,无法发送指令"); return; } await stdin.WriteAsync(command.AsMemory(), cancellationToken).ConfigureAwait(false); await stdin.FlushAsync(cancellationToken).ConfigureAwait(false); - _logger?.LogDebug("[Doctor-stdio] 已发送指令到病人 {PatientId}: {Command}", _patientId, command[..Math.Min(command.Length, 100)]); + DoctorDiag.Write($"[Doctor-stdio] 已发送指令到病人 {_patientId}: {command[..Math.Min(command.Length, 100)]}"); } /// @@ -120,7 +118,7 @@ private void OnOutputLineReceived(object? sender, (string PatientId, string Line } catch (Exception ex) { - _logger?.LogDebug(ex, "[Doctor-stdio] 解析病人 {PatientId} stdout 行失败: {Line}", _patientId, e.Line[..Math.Min(e.Line.Length, 200)]); + DoctorDiag.Write($"[Doctor-stdio] 解析病人 {_patientId} stdout 行失败: {ex.Message}"); } } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs index 6ac14e7..dd5bc3a 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs @@ -30,8 +30,6 @@ public sealed record DoctorTestCase /// public sealed class DoctorTestSuite { - private readonly ILogger? _logger; - /// 测试用例执行完成事件 public event EventHandler? TestCaseCompleted; @@ -91,10 +89,7 @@ public sealed class DoctorTestSuite } ]; - public DoctorTestSuite(ILogger? logger = null) - { - _logger = logger; - } + public DoctorTestSuite() { } /// /// 执行全部内置测试用例 @@ -124,7 +119,7 @@ public async Task RunAsync( var startedAt = DateTimeOffset.UtcNow; var results = new List(); - _logger?.LogInformation("[DoctorTestSuite] 开始执行 {Count} 个测试用例", caseList.Count); + DoctorDiag.Write($"[DoctorTestSuite] 开始执行 {caseList.Count} 个测试用例"); foreach (var testCase in caseList) { @@ -161,7 +156,7 @@ public async Task RunAsync( SuiteCompleted?.Invoke(this, report); - _logger?.LogInformation("[DoctorTestSuite] 执行完成: {Pass}/{Total} 通过", report.PassCount, report.TotalCount); + DoctorDiag.Write($"[DoctorTestSuite] 执行完成: {report.PassCount}/{report.TotalCount} 通过"); return report; } @@ -176,7 +171,7 @@ private async Task RunSingleAsync( IReadOnlyDictionary? environmentVariables, CancellationToken cancellationToken) { - _logger?.LogInformation("[DoctorTestSuite] 执行测试: {TestId} - {TestName}", testCase.TestCaseId, testCase.TestName); + DoctorDiag.Write($"[DoctorTestSuite] 执行测试: {testCase.TestCaseId} - {testCase.TestName}"); var patientArgs = BuildPatientArguments(testCase); var patientId = $"test-{testCase.TestCaseId}"; @@ -206,7 +201,7 @@ private async Task RunSingleAsync( catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { sw.Stop(); - _logger?.LogWarning("[DoctorTestSuite] 测试超时: {TestId} ({Timeout}s)", testCase.TestCaseId, testCase.TimeoutSeconds); + DoctorDiag.WriteError($"[DoctorTestSuite] 测试超时: {testCase.TestCaseId} ({testCase.TimeoutSeconds}s)"); return new DoctorTestCaseResult { TestCaseId = testCase.TestCaseId, @@ -231,7 +226,7 @@ private async Task RunSingleAsync( catch (Exception ex) { sw.Stop(); - _logger?.LogError(ex, "[DoctorTestSuite] 测试异常: {TestId}", testCase.TestCaseId); + DoctorDiag.WriteError($"[DoctorTestSuite] 测试异常: {testCase.TestCaseId}: {ex.Message}"); return new DoctorTestCaseResult { TestCaseId = testCase.TestCaseId, diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs index d9e224c..1b1e202 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs @@ -11,7 +11,6 @@ public sealed class HotFixEngine private readonly PatientProcessManager _patientManager; private readonly IDoctorTransport _transport; private readonly IFileSystem _fs; - private readonly ILogger? _logger; private readonly List _results = []; private readonly SemaphoreSlim _resultLock = new(1, 1); @@ -36,15 +35,13 @@ public HotFixEngine( BuildOrchestrator builder, PatientProcessManager patientManager, IDoctorTransport transport, - IFileSystem fs, - ILogger? logger = null) + IFileSystem fs) { _patcher = patcher ?? throw new ArgumentNullException(nameof(patcher)); _builder = builder ?? throw new ArgumentNullException(nameof(builder)); _patientManager = patientManager ?? throw new ArgumentNullException(nameof(patientManager)); _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _fs = fs ?? throw new ArgumentNullException(nameof(fs)); - _logger = logger; } public async Task ExecuteFixAsync( @@ -57,7 +54,7 @@ public async Task ExecuteFixAsync( var action = ChooseAction(report); var sw = System.Diagnostics.Stopwatch.StartNew(); - _logger?.LogInformation("[Doctor] 执行修复: {ActionType} - {Description} (病人: {PatientId})", action.ActionType, action.Description, report.PatientId); + DoctorDiag.Write($"[Doctor] 执行修复: {action.ActionType} - {action.Description} (病人: {report.PatientId})"); var result = action.ActionType switch { @@ -77,12 +74,12 @@ public async Task ExecuteFixAsync( if (result.Success) { - _logger?.LogInformation("[Doctor] 修复成功: {ActionType} (病人: {PatientId})", action.ActionType, report.PatientId); + DoctorDiag.Write($"[Doctor] 修复成功: {action.ActionType} (病人: {report.PatientId})"); FixApplied?.Invoke(this, result); } else { - _logger?.LogWarning("[Doctor] 修复失败: {ActionType} - {Description} (病人: {PatientId})", action.ActionType, result.Description, report.PatientId); + DoctorDiag.WriteError($"[Doctor] 修复失败: {action.ActionType} - {result.Description} (病人: {report.PatientId})"); if (result.WasRolledBack) { FixRolledBack?.Invoke(this, result); @@ -162,7 +159,7 @@ private async Task ExecuteSourceCodePatchAsync( if (!buildResult.Success) { - _logger?.LogWarning("[Doctor] 编译失败,回滚源码修改: {FilePath}", action.TargetFilePath); + DoctorDiag.WriteError($"[Doctor] 编译失败,回滚源码修改: {action.TargetFilePath}"); var rollbackResult = await _patcher.RollbackAsync( action.TargetFilePath, @@ -242,7 +239,7 @@ private async Task ExecuteConfigChangeAsync( return new HotFixResult { Success = false, Action = action, Description = patchResult.Description }; } - _logger?.LogInformation("[Doctor] 配置文件已修改,等待热更新生效: {FilePath}", action.TargetFilePath); + DoctorDiag.Write($"[Doctor] 配置文件已修改,等待热更新生效: {action.TargetFilePath}"); await Task.Delay(1000, ct).ConfigureAwait(false); @@ -265,7 +262,7 @@ private async Task ExecuteCompactContextAsync( { await _transport.SendCommandAsync(patientId, command + Environment.NewLine, ct).ConfigureAwait(false); - _logger?.LogInformation("[Doctor] 已发送压缩指令到病人 {PatientId}: {Command}", patientId, command); + DoctorDiag.Write($"[Doctor] 已发送压缩指令到病人 {patientId}: {command}"); await Task.Delay(2000, ct).ConfigureAwait(false); diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs index aa1f0a0..dd723a1 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs @@ -7,7 +7,6 @@ namespace Core.Agents.Doctor; public sealed class PatientProcessManager : IAsyncDisposable { private readonly IProcessService _processService; - private readonly ILogger? _logger; private readonly Dictionary _patients = new(); private readonly SemaphoreSlim _patientsLock = new(1, 1); private int _isDisposed; @@ -37,10 +36,9 @@ public IReadOnlyDictionary Patients } } - public PatientProcessManager(IProcessService processService, ILogger? logger = null) + public PatientProcessManager(IProcessService processService) { _processService = processService ?? throw new ArgumentNullException(nameof(processService)); - _logger = logger; } /// @@ -63,7 +61,7 @@ public async Task SpawnAsync( var execPath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName ?? "jcc"; - _logger?.LogInformation("[Doctor] 启动病人进程: {PatientId}, {ExecPath} {Args}", patientId, execPath, arguments); + DoctorDiag.Write($"[Doctor] 启动病人进程: {patientId}, {execPath} {arguments}"); var options = new InteractiveProcessOptions { @@ -88,7 +86,7 @@ public async Task SpawnAsync( Arguments = arguments }; - var handle = new PatientHandle(patientId, info, process, _logger); + var handle = new PatientHandle(patientId, info, process); handle.OutputLineReceived += OnOutputLineReceived; handle.ErrorLineReceived += OnErrorLineReceived; @@ -98,7 +96,7 @@ public async Task SpawnAsync( try { _patients[patientId] = handle; } finally { _patientsLock.Release(); } - _logger?.LogInformation("[Doctor] 病人进程已启动: {PatientId}, PID={ProcessId}", patientId, process.Id); + DoctorDiag.Write($"[Doctor] 病人进程已启动: {patientId}, PID={process.Id}"); return info; } @@ -241,7 +239,6 @@ private sealed class PatientHandle : IAsyncDisposable { private readonly string _patientId; private readonly IInteractiveProcess _process; - private readonly ILogger? _logger; private readonly Queue _stderrQueue; private readonly CancellationTokenSource _readCts; private Task? _stdoutReadTask; @@ -267,12 +264,11 @@ public bool IsRunning public event EventHandler<(string PatientId, string Line)>? ErrorLineReceived; public event EventHandler? ProcessExited; - public PatientHandle(string patientId, PatientInfo info, IInteractiveProcess process, ILogger? logger) + public PatientHandle(string patientId, PatientInfo info, IInteractiveProcess process) { _patientId = patientId; Info = info; _process = process; - _logger = logger; _stderrQueue = new Queue(MaxStderrLines); _readCts = new CancellationTokenSource(); @@ -288,11 +284,11 @@ public void Kill() try { _process.Kill(); - _logger?.LogInformation("[Doctor] 病人进程已终止: {PatientId}, PID={ProcessId}", _patientId, _process.Id); + DoctorDiag.Write($"[Doctor] 病人进程已终止: {_patientId}, PID={_process.Id}"); } catch (Exception ex) { - _logger?.LogWarning(ex, "[Doctor] 终止病人进程失败: {PatientId}", _patientId); + DoctorDiag.WriteError($"[Doctor] 终止病人进程失败: {_patientId}: {ex.Message}"); } } @@ -336,7 +332,7 @@ private async Task ReadStdoutAsync(CancellationToken ct) catch (OperationCanceledException) { } catch (Exception ex) { - _logger?.LogDebug(ex, "[Doctor] 病人 {PatientId} stdout 读取结束", _patientId); + DoctorDiag.Write($"[Doctor] 病人 {_patientId} stdout 读取结束: {ex.Message}"); } } @@ -370,15 +366,14 @@ private async Task MonitorExitAsync(CancellationToken ct) ExitedAt = DateTimeOffset.UtcNow }; - _logger?.LogInformation("[Doctor] 病人进程退出: {PatientId}, PID={ProcessId}, 退出码={ExitCode}, 状态={State}", - _patientId, Info.ProcessId, exitCode, state); + DoctorDiag.Write($"[Doctor] 病人进程退出: {_patientId}, PID={Info.ProcessId}, 退出码={exitCode}, 状态={state}"); ProcessExited?.Invoke(this, Info); } catch (OperationCanceledException) { } catch (Exception ex) { - _logger?.LogWarning(ex, "[Doctor] 监控病人进程退出异常: {PatientId}", _patientId); + DoctorDiag.WriteError($"[Doctor] 监控病人进程退出异常: {_patientId}: {ex.Message}"); Info = Info with { State = PatientState.Failed, ExitedAt = DateTimeOffset.UtcNow }; ProcessExited?.Invoke(this, Info); @@ -404,7 +399,7 @@ public async ValueTask DisposeAsync() } catch (Exception ex) { - _logger?.LogDebug(ex, "[Doctor] Dispose 时等待进程退出失败: {PatientId}", _patientId); + DoctorDiag.Write($"[Doctor] Dispose 时等待进程退出失败: {_patientId}: {ex.Message}"); } var tasks = new List(); @@ -413,7 +408,7 @@ public async ValueTask DisposeAsync() if (tasks.Count > 0) { try { await Task.WhenAll(tasks).ConfigureAwait(false); } - catch (Exception ex) { _logger?.LogDebug(ex, "[Doctor] Dispose 时等待任务完成失败: {PatientId}", _patientId); } + catch (Exception ex) { DoctorDiag.Write($"[Doctor] Dispose 时等待任务完成失败: {_patientId}: {ex.Message}"); } } _readCts.Dispose(); diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/SourceCodePatcher.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/SourceCodePatcher.cs index 0442407..791a020 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/SourceCodePatcher.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/SourceCodePatcher.cs @@ -3,12 +3,10 @@ namespace Core.Agents.Doctor; public sealed class SourceCodePatcher { private readonly IFileSystem _fs; - private readonly ILogger? _logger; - public SourceCodePatcher(IFileSystem fs, ILogger? logger = null) + public SourceCodePatcher(IFileSystem fs) { _fs = fs ?? throw new ArgumentNullException(nameof(fs)); - _logger = logger; } public async Task ApplyPatchAsync( @@ -25,7 +23,7 @@ public async Task ApplyPatchAsync( { if (!_fs.FileExists(filePath)) { - _logger?.LogWarning("[Doctor] 源码文件不存在: {FilePath}", filePath); + DoctorDiag.WriteError($"[Doctor] 源码文件不存在: {filePath}"); return new SourceCodePatchResult { Success = false, @@ -39,7 +37,7 @@ public async Task ApplyPatchAsync( if (originalContent is not null && currentContent != originalContent) { - _logger?.LogWarning("[Doctor] 文件内容已变更,无法安全应用补丁: {FilePath}", filePath); + DoctorDiag.WriteError($"[Doctor] 文件内容已变更,无法安全应用补丁: {filePath}"); return new SourceCodePatchResult { Success = false, @@ -52,7 +50,7 @@ public async Task ApplyPatchAsync( await _fs.WriteAllTextAsync(filePath, patchedContent, cancellationToken).ConfigureAwait(false); sw.Stop(); - _logger?.LogInformation("[Doctor] 源码补丁已应用: {FilePath}", filePath); + DoctorDiag.Write($"[Doctor] 源码补丁已应用: {filePath}"); return new SourceCodePatchResult { @@ -67,7 +65,7 @@ public async Task ApplyPatchAsync( catch (Exception ex) { sw.Stop(); - _logger?.LogError(ex, "[Doctor] 应用源码补丁失败: {FilePath}", filePath); + DoctorDiag.WriteError($"[Doctor] 应用源码补丁失败: {filePath}: {ex.Message}"); return new SourceCodePatchResult { Success = false, @@ -92,7 +90,7 @@ public async Task RollbackAsync( await _fs.WriteAllTextAsync(filePath, originalContent, cancellationToken).ConfigureAwait(false); sw.Stop(); - _logger?.LogInformation("[Doctor] 源码补丁已回滚: {FilePath}", filePath); + DoctorDiag.Write($"[Doctor] 源码补丁已回滚: {filePath}"); return new SourceCodePatchResult { @@ -106,7 +104,7 @@ public async Task RollbackAsync( catch (Exception ex) { sw.Stop(); - _logger?.LogError(ex, "[Doctor] 回滚源码补丁失败: {FilePath}", filePath); + DoctorDiag.WriteError($"[Doctor] 回滚源码补丁失败: {filePath}: {ex.Message}"); return new SourceCodePatchResult { Success = false, diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs index d456a11..fd4e306 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DoctorTestSuiteTests.cs @@ -344,7 +344,7 @@ private static DoctorAgent CreateDoctor() transport.Setup(t => t.IsConnected).Returns(false); transport.Setup(t => t.ConnectedPatientIds).Returns(new List()); - return new DoctorAgent(fs, processService.Object, transport.Object, NullLogger.Instance); + return new DoctorAgent(fs, processService.Object, transport.Object); } private static DoctorReport CreateReport(PatientState state, int? exitCode) diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs index 89f592b..439f686 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs @@ -252,11 +252,10 @@ private static HotFixEngine CreateEngine(out Mock transportMoc private static HotFixEngine CreateEngine(InMemoryFileSystem fs, out Mock transportMock) { var processService = new Mock(); - var logger = NullLogger.Instance; - var patientManager = new PatientProcessManager(processService.Object, logger); - var patcher = new SourceCodePatcher(fs, logger); - var builder = new BuildOrchestrator(processService.Object, logger); + var patientManager = new PatientProcessManager(processService.Object); + var patcher = new SourceCodePatcher(fs); + var builder = new BuildOrchestrator(processService.Object); transportMock = new Mock(); transportMock.Setup(t => t.IsConnected).Returns(true); transportMock.Setup(t => t.ConnectedPatientIds).Returns(new List { "test-patient" }); @@ -266,8 +265,7 @@ private static HotFixEngine CreateEngine(InMemoryFileSystem fs, out Mock RunAsync(CommandLineOptions options) var processService = new IO.ProcessService.PhysicalProcessService(); var port = options.DoctorPort ?? 9902; - var transport = new DoctorSseServer(port, logger: null); + var transport = new DoctorSseServer(port); await using var doctor = new DoctorAgent(fs, processService, transport); if (!string.IsNullOrWhiteSpace(options.DoctorEndpoint)) { - Diag.WriteLine($"[DOCTOR] SSE 服务器模式,端口: {port}"); + Diag.WriteLifecycle($"[DOCTOR] SSE 服务器模式,端口: {port}"); var report = await doctor.RunServerAsync(port).ConfigureAwait(false); PrintReport(report); return report.Status switch @@ -39,16 +39,26 @@ internal static async Task RunAsync(CommandLineOptions options) Diag.WriteLifecycle($"[DOCTOR] 病人参数: {patientArgs}"); - var runReport = await doctor.RunAsync("patient-main", patientArgs, workingDir, cancellationToken: default).ConfigureAwait(false); + try + { + var runReport = await doctor.RunAsync("patient-main", patientArgs, workingDir, cancellationToken: default).ConfigureAwait(false); - PrintReport(runReport); + PrintReport(runReport); - return runReport.Status switch + return runReport.Status switch + { + DoctorReportStatus.Completed => 0, + DoctorReportStatus.PartiallyFixed => 1, + _ => 2 + }; + } + catch (Exception ex) { - DoctorReportStatus.Completed => 0, - DoctorReportStatus.PartiallyFixed => 1, - _ => 2 - }; + Diag.WriteLifecycle($"[DOCTOR] 运行异常: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException is not null) + Diag.WriteLifecycle($"[DOCTOR] 内部异常: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + return 2; + } } /// @@ -72,7 +82,7 @@ internal static async Task RunTestSuiteAsync(CommandLineOptions options) Diag.WriteLine($"[DOCTOR] 执行测试: {testCase.TestCaseId} - {testCase.TestName}"); var testPort = port + results.Count; - var transport = new DoctorSseServer(testPort, logger: null); + var transport = new DoctorSseServer(testPort); await using var doctor = new DoctorAgent(fs, processService, transport); var patientArgs = DoctorTestSuite.BuildPatientArguments(testCase) + $" --doctor-endpoint http://localhost:{testPort}"; diff --git a/src/JoinCode/GlobalUsings.cs b/src/JoinCode/GlobalUsings.cs index ca96a1e..068a31b 100644 --- a/src/JoinCode/GlobalUsings.cs +++ b/src/JoinCode/GlobalUsings.cs @@ -87,6 +87,7 @@ global using IToolRegistry = JoinCode.Abstractions.Tools.IToolRegistry; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; global using Services.OAuth; global using System.Collections.Frozen; From 00f30cc9efb0832a771a490693d0264529a25c77 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 03:59:48 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20DoctorTcpServer=20=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=20DoctorSseServer=20=E2=80=94=20TcpListener=20?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=20HttpListener=20=E8=A7=A3=E5=86=B3=E6=B2=99?= =?UTF-8?q?=E7=9B=92=E7=8E=AF=E5=A2=83=E4=B8=8D=E5=8F=AF=E7=94=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20|=20=E5=86=B3=E7=AD=96:=20HttpListener=E4=BE=9D?= =?UTF-8?q?=E8=B5=96HTTP.sys=E5=9C=A8=E5=8F=97=E9=99=90=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E6=8A=9B=E5=8F=A5=E6=9F=84=E6=97=A0=E6=95=88,TcpListener?= =?UTF-8?q?=E7=BA=AF=E7=94=A8=E6=88=B7=E6=80=81=E9=9B=B6=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=89=80=E6=9C=89=E7=8E=AF=E5=A2=83,?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=AE=9E=E7=8E=B0HTTP=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E8=A7=A3=E6=9E=90(SSE/POST/health=E8=B7=AF=E7=94=B1),RunAccept?= =?UTF-8?q?Loop=E7=94=A8Task.Run=E7=A1=AE=E4=BF=9D=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E6=B1=A0=E8=B0=83=E5=BA=A6,=E6=8B=86=E5=88=86KillAsync/RemoveP?= =?UTF-8?q?atientAsync=E9=81=BF=E5=85=8D=E9=87=8D=E5=90=AF=E6=97=B6?= =?UTF-8?q?=E7=97=85=E4=BA=BA=E5=B7=B2=E5=AD=98=E5=9C=A8=E5=BC=82=E5=B8=B8?= =?UTF-8?q?,DoctorAgent=E8=AE=A2=E9=98=85=E7=97=85=E4=BA=BAstderr/stdout?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E5=88=B0Trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Agents/Agents/Doctor/DoctorAgent.cs | 16 +- .../Agents/Agents/Doctor/DoctorTcpServer.cs | 504 ++++++++++++++++++ .../src/Agents/Agents/Doctor/HotFixEngine.cs | 24 +- .../Agents/Doctor/PatientProcessManager.cs | 11 + src/JoinCode/Entry/DoctorModeRunner.cs | 4 +- 5 files changed, 539 insertions(+), 20 deletions(-) create mode 100644 components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs index ca2c285..14ffe3f 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs @@ -49,13 +49,15 @@ public DoctorAgent( _processService = processService ?? throw new ArgumentNullException(nameof(processService)); _patientManager = new PatientProcessManager(processService); - _transport = transport ?? new DoctorSseServer(9902); + _transport = transport ?? new DoctorTcpServer(9902); _diagnosticEngine = new DiagnosticEngine(); _patcher = new SourceCodePatcher(fs); _builder = new BuildOrchestrator(processService); _hotFixEngine = new HotFixEngine(_patcher, _builder, _patientManager, _transport, fs); _patientManager.ProcessExited += OnProcessExited; + _patientManager.OutputLineReceived += OnOutputLineReceived; + _patientManager.ErrorLineReceived += OnErrorLineReceived; _transport.EventReceived += OnDiagnosticEventReceived; _transport.PatientConnected += OnPatientConnected; _transport.PatientDisconnected += OnPatientDisconnected; @@ -234,6 +236,16 @@ private void OnProcessExited(object? sender, PatientInfo info) _diagnosticEngine.EvaluateProcessHung(info); } + private void OnOutputLineReceived(object? sender, (string PatientId, string Line) e) + { + System.Diagnostics.Trace.WriteLine($"[Doctor] 病人 {e.PatientId} stdout: {e.Line}"); + } + + private void OnErrorLineReceived(object? sender, (string PatientId, string Line) e) + { + System.Diagnostics.Trace.WriteLine($"[Doctor] 病人 {e.PatientId} stderr: {e.Line}"); + } + private void OnDiagnosticEventReceived(object? sender, DiagnosticEvent evt) { DoctorDiag.Write($"[Doctor] 收到诊断事件: {evt.EventType} (病人: {evt.PatientId})"); @@ -314,6 +326,8 @@ public async ValueTask DisposeAsync() if (Interlocked.Exchange(ref _isDisposed, 1) == 1) return; _patientManager.ProcessExited -= OnProcessExited; + _patientManager.OutputLineReceived -= OnOutputLineReceived; + _patientManager.ErrorLineReceived -= OnErrorLineReceived; _transport.EventReceived -= OnDiagnosticEventReceived; _transport.PatientConnected -= OnPatientConnected; _transport.PatientDisconnected -= OnPatientDisconnected; diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs new file mode 100644 index 0000000..24f5858 --- /dev/null +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs @@ -0,0 +1,504 @@ +namespace Core.Agents.Doctor; + +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +/// +/// 医生 TCP 服务器 — TcpListener 监听,管理多个病人连接 +/// 替代 DoctorSseServer(HttpListener 在受限环境不可用) +/// +/// 路由: +/// GET /sse?patientId=xxx ← 病人连接此端点接收医生指令(SSE 推送) +/// POST /events ← 病人向此端点发送遥测事件 +/// GET /health ← 健康检查 +/// +public sealed class DoctorTcpServer : IDoctorTransport +{ + private readonly int _port; + private TcpListener? _listener; + private readonly Dictionary _patients = new(); + private readonly SemaphoreSlim _patientsLock = new(1, 1); + private readonly Channel _eventChannel; + private CancellationTokenSource? _listenCts; + private Task? _listenTask; + private int _isDisposed; + + /// + public bool IsConnected { get; private set; } + + /// + public IReadOnlyList ConnectedPatientIds + { + get + { + _patientsLock.Wait(); + try { return _patients.Keys.ToList(); } + finally { _patientsLock.Release(); } + } + } + + /// + public event EventHandler? EventReceived; + + /// + public event EventHandler? PatientConnected; + + /// + public event EventHandler? PatientDisconnected; + + public DoctorTcpServer(int port) + { + _port = port; + _eventChannel = Channel.CreateBounded(new BoundedChannelOptions(512) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + } + + /// + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + if (IsConnected) return Task.CompletedTask; + + _listener = new TcpListener(IPAddress.Loopback, _port); + _listener.Start(); + + _listenCts = new CancellationTokenSource(); + _listenTask = Task.Run(() => RunAcceptLoopAsync(_listenCts.Token), CancellationToken.None); + + IsConnected = true; + DoctorDiag.Write($"[DoctorTCP] 服务器已启动: http://127.0.0.1:{_port}/"); + + return Task.CompletedTask; + } + + /// + public async Task ReadEventAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) return null; + + try + { + return await _eventChannel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return null; + } + } + + /// + public async Task SendCommandAsync(string patientId, string command, CancellationToken cancellationToken = default) + { + DoctorTcpPatient? patient; + await _patientsLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { _patients.TryGetValue(patientId, out patient); } + finally { _patientsLock.Release(); } + + if (patient is null) + { + DoctorDiag.WriteError($"[DoctorTCP] 病人 {patientId} 未连接,无法发送指令"); + return; + } + + var sseData = $"event: command\ndata: {EscapeSseData(command)}\n\n"; + var bytes = Encoding.UTF8.GetBytes(sseData); + await patient.SendAsync(bytes, cancellationToken).ConfigureAwait(false); + DoctorDiag.Write($"[DoctorTCP] 已发送指令到病人 {patientId}: {command[..Math.Min(command.Length, 100)]}"); + } + + /// + public async Task BroadcastCommandAsync(string command, CancellationToken cancellationToken = default) + { + List patients; + await _patientsLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { patients = _patients.Values.ToList(); } + finally { _patientsLock.Release(); } + + var sseData = $"event: command\ndata: {EscapeSseData(command)}\n\n"; + var bytes = Encoding.UTF8.GetBytes(sseData); + + foreach (var patient in patients) + { + try { await patient.SendAsync(bytes, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) + { + DoctorDiag.WriteError($"[DoctorTCP] 广播指令到病人 {patient.PatientId} 失败: {ex.Message}"); + } + } + + DoctorDiag.Write($"[DoctorTCP] 已广播指令到 {patients.Count} 个病人"); + } + + private async Task RunAcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && IsConnected && _listener is not null) + { + try + { + var tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); + DoctorDiag.Write($"[DoctorTCP] 接受新连接: {tcpClient.Client.RemoteEndPoint}"); + _ = Task.Run(() => HandleClientAsync(tcpClient, ct), ct); + } + catch (SocketException) when (!IsConnected || ct.IsCancellationRequested) { break; } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (Exception ex) + { + DoctorDiag.WriteError($"[DoctorTCP] 接受连接异常: {ex.Message}"); + } + } + } + + private async Task HandleClientAsync(TcpClient tcpClient, CancellationToken ct) + { + var stream = tcpClient.GetStream(); + var remoteEndPoint = tcpClient.Client.RemoteEndPoint?.ToString() ?? "unknown"; + + try + { + var request = await ReadHttpRequestAsync(stream, ct).ConfigureAwait(false); + if (request is null) + { + tcpClient.Close(); + return; + } + + var path = request.Path; + var patientId = request.QueryParams.GetValueOrDefault("patientId") ?? Guid.NewGuid().ToString("N")[..8]; + + if (path == "/sse") + { + await HandleSseConnectionAsync(stream, patientId, ct).ConfigureAwait(false); + } + else if (path == "/events" && request.Method == "POST") + { + await HandleEventsPostAsync(stream, request.Body, patientId, ct).ConfigureAwait(false); + } + else if (path == "/health") + { + await WriteHttpResponseAsync(stream, 200, "application/json", "{\"status\":\"ok\"}"u8.ToArray(), ct).ConfigureAwait(false); + } + else + { + await WriteHttpResponseAsync(stream, 404, "text/plain", "Not Found"u8.ToArray(), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested || !IsConnected) { } + catch (Exception ex) + { + DoctorDiag.WriteError($"[DoctorTCP] 处理请求异常 ({remoteEndPoint}): {ex.Message}"); + } + finally + { + try { tcpClient.Close(); } + catch (Exception closeEx) { System.Diagnostics.Trace.WriteLine($"[DoctorTCP] 关闭连接失败: {closeEx.Message}"); } + } + } + + private async Task HandleSseConnectionAsync(NetworkStream stream, string patientId, CancellationToken ct) + { + var responseHeader = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache\r\n" + + "Connection: keep-alive\r\n" + + "\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(responseHeader); + await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + var patient = new DoctorTcpPatient(patientId, stream); + + await _patientsLock.WaitAsync(ct).ConfigureAwait(false); + try { _patients[patientId] = patient; } + finally { _patientsLock.Release(); } + + var endpointMsg = $"event: endpoint\ndata: /events?patientId={patientId}\n\n"; + await patient.SendAsync(Encoding.UTF8.GetBytes(endpointMsg), ct).ConfigureAwait(false); + + DoctorDiag.Write($"[DoctorTCP] 病人 {patientId} 已连接 SSE"); + PatientConnected?.Invoke(this, patientId); + + try + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { } + finally + { + try + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await _patientsLock.WaitAsync(timeoutCts.Token).ConfigureAwait(false); + try { _patients.Remove(patientId); } + finally { _patientsLock.Release(); } + } + catch (OperationCanceledException) { } + + DoctorDiag.Write($"[DoctorTCP] 病人 {patientId} SSE 连接断开"); + PatientDisconnected?.Invoke(this, patientId); + } + } + + private async Task HandleEventsPostAsync(NetworkStream stream, string body, string patientId, CancellationToken ct) + { + var evt = ParseEventFromJson(body, patientId); + if (evt is not null) + { + _eventChannel.Writer.TryWrite(evt); + EventReceived?.Invoke(this, evt); + } + + await WriteHttpResponseAsync(stream, 202, "text/plain", "Accepted"u8.ToArray(), ct).ConfigureAwait(false); + } + + internal static DiagnosticEvent? ParseEventFromJson(string json, string patientId) + { + if (string.IsNullOrWhiteSpace(json)) return null; + + try + { + var doc = JsonSerializer.Deserialize(json, DoctorTcpJsonContext.Default.DictionaryStringJsonElement); + if (doc is null) return null; + + var eventType = doc.TryGetValue("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String + ? typeEl.GetString() ?? "unknown" + : "unknown"; + + return new DiagnosticEvent + { + EventType = eventType, + PatientId = patientId, + RawData = json, + Timestamp = DateTimeOffset.UtcNow, + Properties = doc.ToDictionary( + kv => kv.Key, + kv => kv.Value.ValueKind switch + { + JsonValueKind.String => kv.Value.GetString() ?? "", + JsonValueKind.Number => kv.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => kv.Value.GetRawText() + }) + }; + } + catch (JsonException) { return null; } + } + + private static async Task ReadHttpRequestAsync(NetworkStream stream, CancellationToken ct) + { + var buffer = new byte[8192]; + var totalRead = 0; + + while (totalRead < buffer.Length) + { + var bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead), ct).ConfigureAwait(false); + if (bytesRead == 0) return null; + totalRead += bytesRead; + + var headerEnd = buffer.AsSpan(0, totalRead).IndexOf("\r\n\r\n"u8); + if (headerEnd >= 0) + { + var headerText = Encoding.ASCII.GetString(buffer, 0, headerEnd); + var bodyStart = headerEnd + 4; + var bodyLength = totalRead - bodyStart; + var body = bodyLength > 0 ? Encoding.UTF8.GetString(buffer, bodyStart, bodyLength) : string.Empty; + + return ParseHttpRequest(headerText, body); + } + } + + return null; + } + + internal static HttpRequestInfo? ParseHttpRequest(string headerText, string body) + { + var lines = headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + if (lines.Length == 0) return null; + + var requestLine = lines[0].Split(' ', 3); + if (requestLine.Length < 2) return null; + + var method = requestLine[0]; + var rawPath = requestLine[1]; + + var queryIndex = rawPath.IndexOf('?'); + string path; + Dictionary queryParams; + + if (queryIndex >= 0) + { + path = rawPath[..queryIndex]; + var queryString = rawPath[(queryIndex + 1)..]; + queryParams = ParseQueryString(queryString); + } + else + { + path = rawPath; + queryParams = new Dictionary(); + } + + int contentLength = 0; + for (var i = 1; i < lines.Length; i++) + { + if (lines[i].StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + { + int.TryParse(lines[i].AsSpan(16).Trim(), out contentLength); + } + } + + return new HttpRequestInfo(method, path, queryParams, body, contentLength); + } + + private static Dictionary ParseQueryString(string queryString) + { + var result = new Dictionary(); + foreach (var pair in queryString.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var eqIndex = pair.IndexOf('='); + if (eqIndex >= 0) + { + var key = Uri.UnescapeDataString(pair[..eqIndex]); + var value = Uri.UnescapeDataString(pair[(eqIndex + 1)..]); + result[key] = value; + } + else + { + result[Uri.UnescapeDataString(pair)] = string.Empty; + } + } + return result; + } + + private static async Task WriteHttpResponseAsync(NetworkStream stream, int statusCode, string contentType, byte[] body, CancellationToken ct) + { + var reasonPhrase = statusCode switch + { + 200 => "OK", + 202 => "Accepted", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Unknown" + }; + + var header = $"HTTP/1.1 {statusCode} {reasonPhrase}\r\n" + + $"Content-Type: {contentType}\r\n" + + $"Content-Length: {body.Length}\r\n" + + "Connection: close\r\n" + + "\r\n"; + + var headerBytes = Encoding.ASCII.GetBytes(header); + await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false); + await stream.WriteAsync(body, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + } + + private static string EscapeSseData(string data) + { + return data.Replace("\n", "\\n").Replace("\r", ""); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _isDisposed, 1) == 1) return; + + IsConnected = false; + + if (_listenCts is not null) + { + await _listenCts.CancelAsync().ConfigureAwait(false); + _listenCts.Dispose(); + } + + if (_listenTask is not null) + { + try { _listenTask.GetAwaiter().GetResult(); } catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[DoctorTCP] 等待监听任务完成失败: {ex.Message}"); } + } + + await _patientsLock.WaitAsync().ConfigureAwait(false); + try + { + var patients = _patients.Values.ToList(); + _patients.Clear(); + await Task.WhenAll(patients.Select(p => p.DisposeAsync().AsTask())).ConfigureAwait(false); + } + finally { _patientsLock.Release(); } + + try { _listener?.Stop(); } catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[DoctorTCP] TcpListener.Stop 失败: {ex.Message}"); } + _listener = null; + + _eventChannel.Writer.TryComplete(); + _patientsLock.Dispose(); + } +} + +/// +/// TCP 病人连接 — 封装单个病人的 SSE 输出流 +/// +internal sealed class DoctorTcpPatient : IAsyncDisposable +{ + private readonly SemaphoreSlim _writeLock = new(1, 1); + private bool _disposed; + + public string PatientId { get; } + public NetworkStream Stream { get; } + + public DoctorTcpPatient(string patientId, NetworkStream stream) + { + PatientId = patientId; + Stream = stream; + } + + public async Task SendAsync(byte[] data, CancellationToken cancellationToken) + { + if (_disposed) throw new ObjectDisposedException(nameof(DoctorTcpPatient)); + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await Stream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + await Stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally { _writeLock.Release(); } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + _writeLock.Dispose(); + + try { await Stream.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[DoctorTCP] 释放病人输出流失败: {ex.Message}"); } + } +} + +/// +/// HTTP 请求解析结果 +/// +internal sealed class HttpRequestInfo +{ + public string Method { get; } + public string Path { get; } + public Dictionary QueryParams { get; } + public string Body { get; } + public int ContentLength { get; } + + public HttpRequestInfo(string method, string path, Dictionary queryParams, string body, int contentLength) + { + Method = method; + Path = path; + QueryParams = queryParams; + Body = body; + ContentLength = contentLength; + } +} + +[JsonSerializable(typeof(Dictionary))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class DoctorTcpJsonContext : JsonSerializerContext; diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs index 1b1e202..d7ece7f 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs @@ -177,23 +177,19 @@ private async Task ExecuteSourceCodePatchAsync( }; } - await _patientManager.KillAsync(patientId).ConfigureAwait(false); + var patientInfo = _patientManager.GetPatientInfo(patientId); + var originalArgs = patientInfo?.Arguments; - try - { - using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await _patientManager.WaitForExitAsync(patientId, waitCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) { } + await _patientManager.KillAsync(patientId).ConfigureAwait(false); + await _patientManager.RemovePatientAsync(patientId).ConfigureAwait(false); - var patientInfo = _patientManager.GetPatientInfo(patientId); - if (patientInfo?.Arguments is not null) + if (originalArgs is not null) { try { await _patientManager.SpawnAsync( patientId, - patientInfo.Arguments, + originalArgs, workingDirectory, cancellationToken: ct).ConfigureAwait(false); } @@ -296,13 +292,7 @@ private async Task ExecuteRestartProcessAsync( var originalArgs = patientInfo?.Arguments; await _patientManager.KillAsync(patientId).ConfigureAwait(false); - - try - { - using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await _patientManager.WaitForExitAsync(patientId, waitCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) { } + await _patientManager.RemovePatientAsync(patientId).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(originalArgs)) { diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs index dd723a1..ed9e7bd 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/PatientProcessManager.cs @@ -116,6 +116,17 @@ public async Task KillAsync(string patientId) handle.Kill(); } + /// + /// 从管理器中移除已退出的病人记录,允许重新 Spawn 同 ID 的病人 + /// 注意:不 Dispose handle,由 DoctorAgent.DisposeAsync 统一处理 + /// + public async Task RemovePatientAsync(string patientId) + { + await _patientsLock.WaitAsync().ConfigureAwait(false); + try { _patients.Remove(patientId); } + finally { _patientsLock.Release(); } + } + /// /// 终止所有病人进程 /// diff --git a/src/JoinCode/Entry/DoctorModeRunner.cs b/src/JoinCode/Entry/DoctorModeRunner.cs index d409e4f..0891e4c 100644 --- a/src/JoinCode/Entry/DoctorModeRunner.cs +++ b/src/JoinCode/Entry/DoctorModeRunner.cs @@ -17,7 +17,7 @@ internal static async Task RunAsync(CommandLineOptions options) var processService = new IO.ProcessService.PhysicalProcessService(); var port = options.DoctorPort ?? 9902; - var transport = new DoctorSseServer(port); + var transport = new DoctorTcpServer(port); await using var doctor = new DoctorAgent(fs, processService, transport); @@ -82,7 +82,7 @@ internal static async Task RunTestSuiteAsync(CommandLineOptions options) Diag.WriteLine($"[DOCTOR] 执行测试: {testCase.TestCaseId} - {testCase.TestName}"); var testPort = port + results.Count; - var transport = new DoctorSseServer(testPort); + var transport = new DoctorTcpServer(testPort); await using var doctor = new DoctorAgent(fs, processService, transport); var patientArgs = DoctorTestSuite.BuildPatientArguments(testCase) + $" --doctor-endpoint http://localhost:{testPort}"; From 00138294e04487390e7f09e1cba629c4b0c7dbaf Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 04:02:53 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20DoctorSs?= =?UTF-8?q?eServer=20=E6=97=A7=E4=BB=A3=E7=A0=81=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=BD=BF=E7=94=A8=20DoctorTcpServer=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20HttpListener=E5=9C=A8=E5=8F=97=E9=99=90=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E4=B8=8D=E5=8F=AF=E7=94=A8,DoctorTcpServer=E5=B7=B2?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E6=9B=BF=E4=BB=A3,=E6=97=A7=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=A7=BB=E8=87=B3.xxx/=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/Agents/Doctor/DoctorSseServer.cs | 375 ------------------ .../Agents/Doctor/DoctorStdioTransport.cs | 2 +- 2 files changed, 1 insertion(+), 376 deletions(-) delete mode 100644 components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs deleted file mode 100644 index ef33f0e..0000000 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseServer.cs +++ /dev/null @@ -1,375 +0,0 @@ -namespace Core.Agents.Doctor; - -using System.Net; -using System.Text; -using System.Text.Json; - -/// -/// 医生 SSE 服务器 — HttpListener 监听,管理多个病人 SSE 连接 -/// 复用 McpClient.Transports.SseTransport 的多客户端广播模式 -/// -/// 路由: -/// GET /sse?patientId=xxx ← 病人连接此端点接收医生指令(SSE 推送) -/// POST /events ← 病人向此端点发送遥测事件 -/// GET /health ← 健康检查 -/// -public sealed class DoctorSseServer : IDoctorTransport -{ - private readonly int _port; - private readonly string _host; - private HttpListener? _listener; - private readonly Dictionary _patients = new(); - private readonly SemaphoreSlim _patientsLock = new(1, 1); - private readonly Channel _eventChannel; - private CancellationTokenSource? _listenCts; - private Task? _listenTask; - private int _isDisposed; - - /// - public bool IsConnected { get; private set; } - - /// - public IReadOnlyList ConnectedPatientIds - { - get - { - _patientsLock.Wait(); - try { return _patients.Keys.ToList(); } - finally { _patientsLock.Release(); } - } - } - - /// - public event EventHandler? EventReceived; - - /// - public event EventHandler? PatientConnected; - - /// - public event EventHandler? PatientDisconnected; - - public DoctorSseServer(int port, string host = "localhost") - { - _port = port; - _host = host; - _eventChannel = Channel.CreateBounded(new BoundedChannelOptions(512) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - SingleWriter = false - }); - } - - /// - public Task ConnectAsync(CancellationToken cancellationToken = default) - { - if (IsConnected) return Task.CompletedTask; - - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://{_host}:{_port}/"); - try - { - _listener.Start(); - } - catch (HttpListenerException) - { - _listener.Close(); - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://127.0.0.1:{_port}/"); - _listener.Start(); - } - - _listenCts = new CancellationTokenSource(); - _listenTask = RunAcceptLoopAsync(_listenCts.Token); - - IsConnected = true; - DoctorDiag.Write($"[DoctorSSE] 服务器已启动: http://{_host}:{_port}/"); - - return Task.CompletedTask; - } - - /// - public async Task ReadEventAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) return null; - - try - { - return await _eventChannel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); - } - catch (ChannelClosedException) - { - return null; - } - } - - /// - public async Task SendCommandAsync(string patientId, string command, CancellationToken cancellationToken = default) - { - DoctorSsePatient? patient; - await _patientsLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try { _patients.TryGetValue(patientId, out patient); } - finally { _patientsLock.Release(); } - - if (patient is null) - { - DoctorDiag.WriteError($"[DoctorSSE] 病人 {patientId} 未连接,无法发送指令"); - return; - } - - var sseData = $"event: command\ndata: {EscapeSseData(command)}\n\n"; - var bytes = Encoding.UTF8.GetBytes(sseData); - await patient.SendAsync(bytes, cancellationToken).ConfigureAwait(false); - DoctorDiag.Write($"[DoctorSSE] 已发送指令到病人 {patientId}: {command[..Math.Min(command.Length, 100)]}"); - } - - /// - public async Task BroadcastCommandAsync(string command, CancellationToken cancellationToken = default) - { - List patients; - await _patientsLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try { patients = _patients.Values.ToList(); } - finally { _patientsLock.Release(); } - - var sseData = $"event: command\ndata: {EscapeSseData(command)}\n\n"; - var bytes = Encoding.UTF8.GetBytes(sseData); - - foreach (var patient in patients) - { - try { await patient.SendAsync(bytes, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) - { - DoctorDiag.WriteError($"[DoctorSSE] 广播指令到病人 {patient.PatientId} 失败: {ex.Message}"); - } - } - - DoctorDiag.Write($"[DoctorSSE] 已广播指令到 {patients.Count} 个病人"); - } - - private async Task RunAcceptLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested && IsConnected && _listener is not null) - { - try - { - var context = await _listener.GetContextAsync().ConfigureAwait(false); - _ = Task.Run(() => HandleRequestAsync(context, ct), ct); - } - catch (HttpListenerException) when (!IsConnected || ct.IsCancellationRequested) { break; } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - DoctorDiag.WriteError($"[DoctorSSE] 接受连接异常: {ex.Message}"); - } - } - } - - private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct) - { - var path = context.Request.Url?.AbsolutePath ?? "/"; - var patientId = context.Request.QueryString["patientId"] ?? Guid.NewGuid().ToString("N")[..8]; - - try - { - if (path == "/sse") - { - await HandleSseConnectionAsync(context, patientId, ct).ConfigureAwait(false); - } - else if (path == "/events" && context.Request.HttpMethod == "POST") - { - await HandleEventsPostAsync(context, patientId, ct).ConfigureAwait(false); - } - else if (path == "/health") - { - context.Response.StatusCode = 200; - var healthBytes = Encoding.UTF8.GetBytes("{\"status\":\"ok\"}"); - context.Response.ContentType = "application/json"; - await context.Response.OutputStream.WriteAsync(healthBytes, ct).ConfigureAwait(false); - context.Response.Close(); - } - else - { - context.Response.StatusCode = 404; - context.Response.Close(); - } - } - catch (Exception ex) - { - DoctorDiag.WriteError($"[DoctorSSE] 处理请求异常: {path}: {ex.Message}"); - try { context.Response.StatusCode = 500; context.Response.Close(); } - catch (Exception closeEx) { System.Diagnostics.Trace.WriteLine($"[DoctorSSE] 关闭响应失败: {closeEx.Message}"); } - } - } - - private async Task HandleSseConnectionAsync(HttpListenerContext context, string patientId, CancellationToken ct) - { - var response = context.Response; - response.ContentType = "text/event-stream"; - response.Headers.Add("Cache-Control", "no-cache"); - response.Headers.Add("Connection", "keep-alive"); - response.StatusCode = 200; - - var patient = new DoctorSsePatient(patientId, response.OutputStream); - - await _patientsLock.WaitAsync(ct).ConfigureAwait(false); - try { _patients[patientId] = patient; } - finally { _patientsLock.Release(); } - - var endpointMsg = $"event: endpoint\ndata: /events?patientId={patientId}\n\n"; - await patient.SendAsync(Encoding.UTF8.GetBytes(endpointMsg), ct).ConfigureAwait(false); - - DoctorDiag.Write($"[DoctorSSE] 病人 {patientId} 已连接 SSE"); - PatientConnected?.Invoke(this, patientId); - - try - { - await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) { } - finally - { - await _patientsLock.WaitAsync(ct).ConfigureAwait(false); - try { _patients.Remove(patientId); } - finally { _patientsLock.Release(); } - - DoctorDiag.Write($"[DoctorSSE] 病人 {patientId} SSE 连接断开"); - PatientDisconnected?.Invoke(this, patientId); - - await patient.DisposeAsync().ConfigureAwait(false); - } - } - - private async Task HandleEventsPostAsync(HttpListenerContext context, string patientId, CancellationToken ct) - { - using var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8); - var json = await reader.ReadToEndAsync(ct).ConfigureAwait(false); - - var evt = ParseEventFromJson(json, patientId); - if (evt is not null) - { - _eventChannel.Writer.TryWrite(evt); - EventReceived?.Invoke(this, evt); - } - - context.Response.StatusCode = 202; - context.Response.Close(); - } - - internal static DiagnosticEvent? ParseEventFromJson(string json, string patientId) - { - if (string.IsNullOrWhiteSpace(json)) return null; - - try - { - var doc = JsonSerializer.Deserialize(json, DoctorSseJsonContext.Default.DictionaryStringJsonElement); - if (doc is null) return null; - - var eventType = doc.TryGetValue("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String - ? typeEl.GetString() ?? "unknown" - : "unknown"; - - return new DiagnosticEvent - { - EventType = eventType, - PatientId = patientId, - RawData = json, - Timestamp = DateTimeOffset.UtcNow, - Properties = doc.ToDictionary( - kv => kv.Key, - kv => kv.Value.ValueKind switch - { - JsonValueKind.String => kv.Value.GetString() ?? "", - JsonValueKind.Number => kv.Value.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => kv.Value.GetRawText() - }) - }; - } - catch (JsonException) { return null; } - } - - private static string EscapeSseData(string data) - { - return data.Replace("\n", "\\n").Replace("\r", ""); - } - - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _isDisposed, 1) == 1) return; - - IsConnected = false; - - if (_listenCts is not null) - { - await _listenCts.CancelAsync().ConfigureAwait(false); - _listenCts.Dispose(); - } - - if (_listenTask is not null) - { - try { _listenTask.GetAwaiter().GetResult(); } catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[DoctorSSE] 等待监听任务完成失败: {ex.Message}"); } - } - - await _patientsLock.WaitAsync().ConfigureAwait(false); - try - { - var patients = _patients.Values.ToList(); - _patients.Clear(); - await Task.WhenAll(patients.Select(p => p.DisposeAsync().AsTask())).ConfigureAwait(false); - } - finally { _patientsLock.Release(); } - - try { _listener?.Stop(); } catch (ObjectDisposedException) { System.Diagnostics.Trace.WriteLine("[DoctorSSE] HttpListener.Stop 时已释放"); } - try { _listener?.Close(); } catch (ObjectDisposedException) { System.Diagnostics.Trace.WriteLine("[DoctorSSE] HttpListener.Close 时已释放"); } - _listener = null; - - _eventChannel.Writer.TryComplete(); - _patientsLock.Dispose(); - } -} - -/// -/// SSE 病人连接 — 封装单个病人的 SSE 输出流 -/// -internal sealed class DoctorSsePatient : IAsyncDisposable -{ - private readonly SemaphoreSlim _writeLock = new(1, 1); - private bool _disposed; - - public string PatientId { get; } - public Stream OutputStream { get; } - - public DoctorSsePatient(string patientId, Stream outputStream) - { - PatientId = patientId; - OutputStream = outputStream; - } - - public async Task SendAsync(byte[] data, CancellationToken cancellationToken) - { - if (_disposed) throw new ObjectDisposedException(nameof(DoctorSsePatient)); - - await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await OutputStream.WriteAsync(data, cancellationToken).ConfigureAwait(false); - await OutputStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - finally { _writeLock.Release(); } - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - _writeLock.Dispose(); - - try { await OutputStream.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[DoctorSSE] 释放病人输出流失败: {ex.Message}"); } - } -} - -[JsonSerializable(typeof(Dictionary))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -internal sealed partial class DoctorSseJsonContext : JsonSerializerContext; diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs index db702ba..b64fc6a 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs @@ -6,7 +6,7 @@ namespace Core.Agents.Doctor; /// /// 医生 stdio 传输 — 从病人 stdout 读取 NDJSON 遥测事件,通过 stdin 发送指令 /// 复用 BridgeSubprocessHandle 的 NDJSON 解析模式 -/// 仅支持单病人(1:1 父子进程模式),多病人场景使用 DoctorSseServer +/// 仅支持单病人(1:1 父子进程模式),多病人场景使用 DoctorTcpServer /// public sealed class DoctorStdioTransport : IDoctorTransport { From a0f479b7f2507a815f995218804354bf1c86726c Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 04:12:53 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20Doctor=20=E9=81=A5=E6=B5=8B?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E4=BF=AE=E5=A4=8D=20=E2=80=94=20diag=5Foutpu?= =?UTF-8?q?t=E4=BA=8B=E4=BB=B6=E5=88=86=E7=B1=BB+=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E6=8E=A5=E6=94=B6+=E5=B9=B6=E5=8F=91=E6=AD=BB=E9=94=81+POST=20?= =?UTF-8?q?body=E5=AE=8C=E6=95=B4=E6=80=A7+=E9=87=8D=E8=BF=9E=E9=80=80?= =?UTF-8?q?=E9=81=BF=E9=87=8D=E7=BD=AE=20|=20=E5=86=B3=E7=AD=96:=20Diagnos?= =?UTF-8?q?ticEngine.ClassifyDiagOutput=E4=BB=8ERawData=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E4=BA=8B=E4=BB=B6=E7=B1=BB=E5=9E=8B,CommandR?= =?UTF-8?q?eceived=E8=AE=A2=E9=98=85=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95,?= =?UTF-8?q?=5FfixLock=E7=A7=BB=E9=99=A4=E5=B5=8C=E5=A5=97=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E4=BF=AE=E5=A4=8D=E6=AD=BB=E9=94=81,ReadHttpRequestAs?= =?UTF-8?q?ync=E6=8C=89Content-Length=E8=AF=BB=E5=8F=96=E5=AE=8C=E6=95=B4b?= =?UTF-8?q?ody,SSE=E9=87=8D=E8=BF=9E=E6=88=90=E5=8A=9F=E5=90=8E=E9=87=8D?= =?UTF-8?q?=E7=BD=AEretryDelay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/Agents/Doctor/DiagnosticEngine.cs | 38 ++++++++++++++++++- .../src/Agents/Agents/Doctor/DoctorAgent.cs | 7 +--- .../Agents/Agents/Doctor/DoctorSseClient.cs | 1 + .../Agents/Agents/Doctor/DoctorTcpServer.cs | 22 ++++++++++- src/JoinCode/Program.cs | 5 +++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs index 3b40739..764e76e 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs @@ -29,7 +29,14 @@ public DiagnosticEngine() { } { ArgumentNullException.ThrowIfNull(evt); - var report = evt.EventType switch + var effectiveEventType = evt.EventType; + if (effectiveEventType == "diag_output") + { + effectiveEventType = ClassifyDiagOutput(evt.RawData); + if (effectiveEventType is null) return null; + } + + var report = effectiveEventType switch { "loop_detected" => EvaluateLoopDetected(evt), "permission_denied" => EvaluatePermissionDenied(evt), @@ -48,6 +55,35 @@ public DiagnosticEngine() { } return report; } + /// + /// 从 diag_output 的原始文本中分类出实际事件类型 + /// 匹配 jcc 诊断日志前缀 [WIRE]/[STEP]/[MAIN] 中的关键字 + /// + internal static string? ClassifyDiagOutput(string? rawData) + { + if (string.IsNullOrWhiteSpace(rawData)) return null; + + if (rawData.Contains("LoopDetected", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("循环检测", StringComparison.OrdinalIgnoreCase)) + return "loop_detected"; + + if (rawData.Contains("PermissionDenied", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("权限被拒绝", StringComparison.OrdinalIgnoreCase)) + return "permission_denied"; + + if (rawData.Contains("ContextOverflow", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("上下文溢出", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("token_usage_ratio", StringComparison.OrdinalIgnoreCase)) + return "context_overflow"; + + if (rawData.Contains("ApiError", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("api_timeout", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("API错误", StringComparison.OrdinalIgnoreCase)) + return "api_error"; + + return null; + } + public DiagnosticReport? EvaluateProcessHung(PatientInfo patientInfo) { ArgumentNullException.ThrowIfNull(patientInfo); diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs index 14ffe3f..9671ae0 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorAgent.cs @@ -270,15 +270,12 @@ private void OnDiagnosticReportGenerated(object? sender, DiagnosticReport report _ = Task.Run(async () => { - if (!await _fixLock.WaitAsync(0).ConfigureAwait(false)) return; + if (!await _fixLock.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false)) return; try { var result = await _hotFixEngine.ExecuteFixAsync(report).ConfigureAwait(false); - - await _fixLock.WaitAsync().ConfigureAwait(false); - try { _fixResults.Add(result); } - finally { _fixLock.Release(); } + _fixResults.Add(result); } catch (Exception ex) { diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs index 34e05a8..5dd567c 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs @@ -122,6 +122,7 @@ private async Task ListenSseAsync(CancellationToken ct) using var stream = await _httpClient.GetStreamAsync(url, ct).ConfigureAwait(false); DoctorDiag.Write($"[DoctorSSE-Client] SSE 连接已建立: {url}"); + retryDelay = TimeSpan.FromSeconds(1); await foreach (var sseEvent in ParseSseStreamAsync(stream, ct).ConfigureAwait(false)) { diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs index 24f5858..345fe20 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs @@ -307,9 +307,27 @@ private async Task HandleEventsPostAsync(NetworkStream stream, string body, stri var headerText = Encoding.ASCII.GetString(buffer, 0, headerEnd); var bodyStart = headerEnd + 4; var bodyLength = totalRead - bodyStart; - var body = bodyLength > 0 ? Encoding.UTF8.GetString(buffer, bodyStart, bodyLength) : string.Empty; - return ParseHttpRequest(headerText, body); + var request = ParseHttpRequest(headerText, bodyLength > 0 ? Encoding.UTF8.GetString(buffer, bodyStart, bodyLength) : string.Empty); + + if (request is not null && request.ContentLength > bodyLength) + { + var remaining = request.ContentLength - bodyLength; + if (totalRead + remaining <= buffer.Length) + { + while (bodyLength < request.ContentLength) + { + var extraRead = await stream.ReadAsync(buffer.AsMemory(totalRead), ct).ConfigureAwait(false); + if (extraRead == 0) break; + totalRead += extraRead; + bodyLength = totalRead - bodyStart; + } + + request = ParseHttpRequest(headerText, bodyLength > 0 ? Encoding.UTF8.GetString(buffer, bodyStart, bodyLength) : string.Empty); + } + } + + return request; } } diff --git a/src/JoinCode/Program.cs b/src/JoinCode/Program.cs index 40cb977..568f01f 100644 --- a/src/JoinCode/Program.cs +++ b/src/JoinCode/Program.cs @@ -45,6 +45,11 @@ static async Task Main(string[] args) catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[Doctor] 发送遥测失败: {ex.Message}"); } }; + doctorClient.CommandReceived += (_, command) => + { + Diag.WriteLifecycle($"[Doctor] 收到医生指令: {command}"); + }; + Diag.WriteLine($"[MAIN] Doctor SSE 客户端已连接: {options.DoctorEndpoint}"); } From 5736790078f7fa77ab655ecf71b96d359cdda2a3 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 04:19:37 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20Doctor=20P2/P3=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20SSE=E8=BF=9E=E6=8E=A5=E7=AB=9E=E6=80=81?= =?UTF-8?q?+TCP=E5=8D=8A=E5=85=B3=E9=97=AD=E6=A3=80=E6=B5=8B+=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E4=BA=8B=E4=BB=B6=E8=AF=AF=E6=8A=A5+JSON=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E5=AE=89=E5=85=A8=20|=20=E5=86=B3=E7=AD=96:?= =?UTF-8?q?=20ConnectAsync=E7=94=A8TaskCompletionSource=E7=AD=89=E5=BE=85S?= =?UTF-8?q?SE=E5=AE=9E=E9=99=85=E5=BB=BA=E7=AB=8B,SSE=E8=BF=9E=E6=8E=A510s?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2TcpClient.Connected=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E6=96=AD=E5=BC=80,DetectEventType=E4=BB=85=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E8=A1=8C=E5=89=8D=E7=BC=80=E4=B8=8B=E5=8C=B9=E9=85=8D=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E5=AD=97,SendEventAsync=E7=94=A8JsonSerializer?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E6=89=8B=E5=8A=A8=E6=8B=BC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/Agents/Doctor/DoctorSseClient.cs | 52 +++++++++++-------- .../Agents/Doctor/DoctorStdioTransport.cs | 16 ++++-- .../Agents/Agents/Doctor/DoctorTcpServer.cs | 9 ++-- 3 files changed, 46 insertions(+), 31 deletions(-) diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs index 5dd567c..ba427b7 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorSseClient.cs @@ -15,6 +15,7 @@ public sealed class DoctorSseClient : IAsyncDisposable private readonly string _patientId; private readonly HttpClient _httpClient; private readonly CancellationTokenSource _cts; + private readonly TaskCompletionSource _connectedTcs = new(); private Task? _sseListenTask; private int _isDisposed; @@ -36,7 +37,7 @@ public DoctorSseClient(string endpoint, string? patientId = null) } /// - /// 连接到医生 SSE 服务器 — 启动 SSE 监听循环 + /// 连接到医生 SSE 服务器 — 启动 SSE 监听循环,等待首次连接成功 /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -46,7 +47,15 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _sseListenTask = ListenSseAsync(_cts.Token); - IsConnected = true; + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); + try + { + await _connectedTcs.Task.WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) + { + DoctorDiag.WriteError("[DoctorSSE-Client] 连接被取消"); + } } /// @@ -56,27 +65,23 @@ public async Task SendEventAsync(DiagnosticEvent evt, CancellationToken cancella { if (!IsConnected) return; - var sb = new StringBuilder(); - sb.Append("{\"type\":\"").Append(JsonEscape(evt.EventType)).Append("\""); - sb.Append(",\"patientId\":\"").Append(JsonEscape(_patientId)).Append("\""); - sb.Append(",\"timestamp\":\"").Append(evt.Timestamp.ToString("o")).Append("\""); - if (evt.SessionId is not null) - sb.Append(",\"sessionId\":\"").Append(JsonEscape(evt.SessionId)).Append("\""); + var payload = new Dictionary + { + ["type"] = evt.EventType, + ["patientId"] = _patientId, + ["timestamp"] = evt.Timestamp.ToString("o"), + ["sessionId"] = evt.SessionId, + ["rawData"] = evt.RawData + }; + if (evt.Properties.Count > 0) { - sb.Append(",\"properties\":{"); - var first = true; foreach (var kv in evt.Properties) - { - if (!first) sb.Append(','); - sb.Append('"').Append(JsonEscape(kv.Key)).Append("\":\"").Append(JsonEscape(kv.Value)).Append('"'); - first = false; - } - sb.Append('}'); + payload[$"prop_{kv.Key}"] = kv.Value; } - sb.Append('}'); - var content = new StringContent(sb.ToString(), Encoding.UTF8, "application/json"); + var json = JsonSerializer.Serialize(payload, DoctorSseClientJsonContext.Default.DictionaryStringString); + var content = new StringContent(json, Encoding.UTF8, "application/json"); var url = $"{_endpoint}/events?patientId={_patientId}"; try @@ -123,6 +128,8 @@ private async Task ListenSseAsync(CancellationToken ct) DoctorDiag.Write($"[DoctorSSE-Client] SSE 连接已建立: {url}"); retryDelay = TimeSpan.FromSeconds(1); + IsConnected = true; + _connectedTcs.TrySetResult(); await foreach (var sseEvent in ParseSseStreamAsync(stream, ct).ConfigureAwait(false)) { @@ -202,11 +209,6 @@ private async Task ListenSseAsync(CancellationToken ct) } } - private static string JsonEscape(string value) - { - return value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t"); - } - public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _isDisposed, 1) == 1) return; @@ -223,3 +225,7 @@ public async ValueTask DisposeAsync() _httpClient.Dispose(); } } + +[JsonSerializable(typeof(Dictionary))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class DoctorSseClientJsonContext : JsonSerializerContext; diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs index b64fc6a..f8ff370 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorStdioTransport.cs @@ -140,15 +140,21 @@ private void OnOutputLineReceived(object? sender, (string PatientId, string Line private static string? DetectEventType(string line) { + var isDiagLine = line.Contains("[WIRE]") || line.Contains("[STEP]") || line.Contains("[MAIN]") || line.Contains("[ERROR]"); + if (line.Contains("[WIRE]")) return "wire_trace"; if (line.Contains("[STEP]")) return "step_trace"; if (line.Contains("[READY]")) return "ready"; if (line.Contains("[MAIN]")) return "main_trace"; - if (line.Contains("LoopDetected", StringComparison.OrdinalIgnoreCase)) return "loop_detected"; - if (line.Contains("PermissionDenied", StringComparison.OrdinalIgnoreCase)) return "permission_denied"; - if (line.Contains("ApiError", StringComparison.OrdinalIgnoreCase)) return "api_error"; - if (line.Contains("ApiTimeout", StringComparison.OrdinalIgnoreCase)) return "api_timeout"; - if (line.Contains("ContextOverflow", StringComparison.OrdinalIgnoreCase)) return "context_overflow"; + + if (isDiagLine) + { + if (line.Contains("LoopDetected", StringComparison.OrdinalIgnoreCase)) return "loop_detected"; + if (line.Contains("PermissionDenied", StringComparison.OrdinalIgnoreCase)) return "permission_denied"; + if (line.Contains("ApiError", StringComparison.OrdinalIgnoreCase)) return "api_error"; + if (line.Contains("ApiTimeout", StringComparison.OrdinalIgnoreCase)) return "api_timeout"; + if (line.Contains("ContextOverflow", StringComparison.OrdinalIgnoreCase)) return "context_overflow"; + } if (line.StartsWith('{')) { diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs index 345fe20..d4bb897 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTcpServer.cs @@ -173,7 +173,7 @@ private async Task HandleClientAsync(TcpClient tcpClient, CancellationToken ct) if (path == "/sse") { - await HandleSseConnectionAsync(stream, patientId, ct).ConfigureAwait(false); + await HandleSseConnectionAsync(tcpClient, stream, patientId, ct).ConfigureAwait(false); } else if (path == "/events" && request.Method == "POST") { @@ -200,7 +200,7 @@ private async Task HandleClientAsync(TcpClient tcpClient, CancellationToken ct) } } - private async Task HandleSseConnectionAsync(NetworkStream stream, string patientId, CancellationToken ct) + private async Task HandleSseConnectionAsync(TcpClient tcpClient, NetworkStream stream, string patientId, CancellationToken ct) { var responseHeader = "HTTP/1.1 200 OK\r\n" + "Content-Type: text/event-stream\r\n" + @@ -225,7 +225,10 @@ private async Task HandleSseConnectionAsync(NetworkStream stream, string patient try { - await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + while (!ct.IsCancellationRequested && tcpClient.Connected) + { + await Task.Delay(TimeSpan.FromSeconds(10), ct).ConfigureAwait(false); + } } catch (OperationCanceledException) { } finally From 86ac3e42ab00d98fa8e5b9ebb434444665c759b1 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 04:33:34 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20Doctor=20P3=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20=E2=80=94=20InferTargetFilePath=E6=8E=A8=E6=96=AD+=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81+=E6=B5=8B=E8=AF=95=E5=89=AF?= =?UTF-8?q?=E4=BD=9C=E7=94=A8+=E8=AF=AD=E4=B9=89=E5=88=86=E7=A6=BB=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20InferTargetFilePath=E4=BB=8E=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6Properties=E6=8F=90=E5=8F=96source=5Ffile/file=5Fpath/?= =?UTF-8?q?source,LoopDetected=E6=8C=89tool=E6=8E=A8=E6=96=AD=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6=E6=96=87=E4=BB=B6,=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=83=AD=E6=9B=B4=E6=96=B0=E6=B7=BB=E5=8A=A0VerifyConfigChange?= =?UTF-8?q?Async=E8=BD=AE=E8=AF=A2=E9=AA=8C=E8=AF=81,T002=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8F=AA=E8=AF=BB=E6=93=8D=E4=BD=9C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=89=AF=E4=BD=9C=E7=94=A8,--doctor-server=E6=9B=BF=E4=BB=A3--?= =?UTF-8?q?doctor-endpoint=E5=88=A4=E6=96=AD=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F,=E7=A7=BB=E9=99=A4DoctorEndpoint=E8=AF=AF?= =?UTF-8?q?=E7=94=A8=E4=BD=9CJCC=5FENDPOINT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/Agents/Doctor/DoctorTestSuite.cs | 2 +- .../src/Agents/Agents/Doctor/HotFixEngine.cs | 80 +++++++++++++++- .../Unit/Agents/Doctor/HotFixEngineTests.cs | 93 ++++++++++++++++++- .../App/Builder/ApplicationBuilder.cs | 2 + src/JoinCode/Cli/Core/CliArg.cs | 3 + src/JoinCode/Cli/Core/CommandLineOptions.cs | 6 ++ src/JoinCode/Entry/DoctorModeRunner.cs | 4 +- 7 files changed, 180 insertions(+), 10 deletions(-) diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs index dd5bc3a..db0ad2f 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DoctorTestSuite.cs @@ -51,7 +51,7 @@ public sealed class DoctorTestSuite { TestCaseId = "T002", TestName = "FileEdit", - Prompt = "在test.txt写入hello world", + Prompt = "读取当前目录的目录结构", TimeoutSeconds = 30, Category = "tool" }, diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs index d7ece7f..39ea16e 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs @@ -235,15 +235,17 @@ private async Task ExecuteConfigChangeAsync( return new HotFixResult { Success = false, Action = action, Description = patchResult.Description }; } - DoctorDiag.Write($"[Doctor] 配置文件已修改,等待热更新生效: {action.TargetFilePath}"); + DoctorDiag.Write($"[Doctor] 配置文件已修改,验证热更新: {action.TargetFilePath}"); - await Task.Delay(1000, ct).ConfigureAwait(false); + var verified = await VerifyConfigChangeAsync(action.TargetFilePath, ct).ConfigureAwait(false); return new HotFixResult { Success = true, Action = action, - Description = $"配置文件已修改: {action.TargetFilePath},热更新已触发" + Description = verified + ? $"配置文件已修改并验证: {action.TargetFilePath}" + : $"配置文件已修改: {action.TargetFilePath},热更新验证超时(可能需要重启)" }; } @@ -337,13 +339,83 @@ private static HotFixResult CreateNoOpResult(HotFixAction action, string patient private static string? InferTargetFilePath(DiagnosticReport report) { + if (report.TriggeringEvents.Count == 0) + return null; + + foreach (var evt in report.TriggeringEvents) + { + if (evt.Properties.TryGetValue("source_file", out var file) && !string.IsNullOrWhiteSpace(file)) + return file; + + if (evt.Properties.TryGetValue("file_path", out var path) && !string.IsNullOrWhiteSpace(path)) + return path; + + if (evt.Properties.TryGetValue("source", out var source) && !string.IsNullOrWhiteSpace(source)) + return source; + } + if (report.RuleId == DiagnosticRuleId.LoopDetected) { - return null; + return InferLoopTargetFile(report); } + return null; } + private static string? InferLoopTargetFile(DiagnosticReport report) + { + foreach (var evt in report.TriggeringEvents) + { + if (evt.Properties.TryGetValue("tool", out var tool) && !string.IsNullOrWhiteSpace(tool)) + { + var fileName = tool switch + { + "Read" or "read_file" => "FileReadMiddleware.cs", + "Edit" or "write_file" or "file_edit" => "FileEditMiddleware.cs", + "Bash" or "shell_exec" => "ShellExecMiddleware.cs", + "Grep" or "search_code" => "CodeSearchMiddleware.cs", + _ => null + }; + + if (fileName is not null) + return fileName; + } + } + + return null; + } + + private async Task VerifyConfigChangeAsync(string filePath, CancellationToken ct) + { + const int maxRetries = 5; + const int retryDelayMs = 500; + + for (var i = 0; i < maxRetries; i++) + { + await Task.Delay(retryDelayMs, ct).ConfigureAwait(false); + + if (!_fs.FileExists(filePath)) + continue; + + try + { + var content = await _fs.ReadAllTextAsync(filePath, ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(content)) + { + DoctorDiag.Write($"[Doctor] 配置热更新验证成功: {filePath} (第{i + 1}次检查)"); + return true; + } + } + catch (Exception ex) + { + DoctorDiag.WriteError($"[Doctor] 配置热更新验证读取失败: {filePath}: {ex.Message}"); + } + } + + DoctorDiag.WriteError($"[Doctor] 配置热更新验证超时: {filePath}"); + return false; + } + private static string? InferConfigFilePath(DiagnosticReport report) { if (report.RuleId == DiagnosticRuleId.ToolPermissionDenied diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs index 439f686..ca460db 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs @@ -14,6 +14,74 @@ public void ChooseAction_SourceCodePatch_ReturnsCorrectAction() Assert.NotNull(action.Description); } + [Fact] + public void ChooseAction_SourceCodePatch_InfersFilePathFromEventProperties() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.LoopDetected, HotFixActionType.SourceCodePatch, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "loop_detected", + Properties = new Dictionary { ["source_file"] = "src/Middleware.cs" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal("src/Middleware.cs", action.TargetFilePath); + } + + [Fact] + public void ChooseAction_SourceCodePatch_InfersFilePathFromFilePathProperty() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.LoopDetected, HotFixActionType.SourceCodePatch, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "loop_detected", + Properties = new Dictionary { ["file_path"] = "src/Handler.cs" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal("src/Handler.cs", action.TargetFilePath); + } + + [Fact] + public void ChooseAction_LoopDetected_InfersMiddlewareFileFromTool() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.LoopDetected, HotFixActionType.SourceCodePatch, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "loop_detected", + Properties = new Dictionary { ["tool"] = "Read" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal("FileReadMiddleware.cs", action.TargetFilePath); + } + + [Fact] + public void ChooseAction_LoopDetected_NoEventProperties_ReturnsNullFilePath() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.LoopDetected, HotFixActionType.SourceCodePatch); + + var action = engine.ChooseAction(report); + + Assert.Null(action.TargetFilePath); + } + [Fact] public void ChooseAction_ConfigChange_SetsSettingsJsonPath() { @@ -239,6 +307,25 @@ public async Task ExecuteFix_ConfigChange_WithPatchedContent_WritesFile() Assert.False(result.Success); } + [Fact] + public void ChooseAction_LoopDetected_SourcePropertyInfersFile() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.LoopDetected, HotFixActionType.SourceCodePatch, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "loop_detected", + Properties = new Dictionary { ["source"] = "src/LoopHandler.cs" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal("src/LoopHandler.cs", action.TargetFilePath); + } + private static HotFixEngine CreateEngine() { return CreateEngine(new InMemoryFileSystem(), out _); @@ -270,7 +357,8 @@ private static HotFixEngine CreateEngine(InMemoryFileSystem fs, out Mock? triggeringEvents = null) { return new DiagnosticReport { @@ -278,7 +366,8 @@ private static DiagnosticReport CreateReport(DiagnosticRuleId ruleId, HotFixActi Severity = DiagnosticSeverity.Warning, Description = $"测试诊断: {ruleId}", SuggestedFixType = fixType, - SuggestedFixDescription = $"建议修复: {fixType}" + SuggestedFixDescription = $"建议修复: {fixType}", + TriggeringEvents = triggeringEvents ?? [] }; } } diff --git a/src/JoinCode/App/Builder/ApplicationBuilder.cs b/src/JoinCode/App/Builder/ApplicationBuilder.cs index fc866f2..5760565 100644 --- a/src/JoinCode/App/Builder/ApplicationBuilder.cs +++ b/src/JoinCode/App/Builder/ApplicationBuilder.cs @@ -255,6 +255,7 @@ public static CommandLineOptions ParseArgs(string[] args) SystemPrompt = result.SystemPrompt, AppendSystemPrompt = result.AppendSystemPrompt, DoctorMode = result.Doctor, + DoctorServerMode = result.DoctorServer, DoctorEndpoint = result.DoctorEndpoint, DoctorTestSuiteMode = result.DoctorTestSuite, }; @@ -394,6 +395,7 @@ public static void ShowHelp() Cli.TerminalHelper.WriteLine(" --system-prompt <文本> 替换系统提示词(完全覆盖默认系统提示词)"); Cli.TerminalHelper.WriteLine(" --append-system-prompt <文本> 追加系统提示词(在默认/已加载系统提示词后附加,不覆盖)"); Cli.TerminalHelper.WriteLine(" --doctor 医生模式:spawn jcc.exe 子进程作为病人,监控运行状态并自动修复问题"); + Cli.TerminalHelper.WriteLine(" --doctor-server 医生服务器模式(监听病人连接,需配合 --doctor)"); Cli.TerminalHelper.WriteLine(" --doctor-endpoint 医生 SSE 端点(病人端使用,连接到医生,如 http://localhost:9902)"); Cli.TerminalHelper.WriteLine(" --doctor-port <端口> 医生 SSE 服务器端口(医生端使用,默认 9902)"); Cli.TerminalHelper.WriteLine(" --doctor-test-suite 医生测试套件:执行内置功能测试用例(T001-T006)"); diff --git a/src/JoinCode/Cli/Core/CliArg.cs b/src/JoinCode/Cli/Core/CliArg.cs index ef594ae..ff6ba2f 100644 --- a/src/JoinCode/Cli/Core/CliArg.cs +++ b/src/JoinCode/Cli/Core/CliArg.cs @@ -65,6 +65,9 @@ public enum CliArg [CliOption("--doctor", "", "医生模式:spawn jcc.exe 子进程作为病人,监控运行状态并自动修复问题")] Doctor, + [CliOption("--doctor-server", "", "医生服务器模式:监听病人 SSE 连接,支持 1:N 多病人监控(需配合 --doctor)")] + DoctorServer, + [CliOption("--doctor-endpoint", "", "医生 SSE 端点 URL(病人端使用,连接到医生的 SSE 服务器,如 http://localhost:9902)", AcceptsValue = true)] DoctorEndpoint, diff --git a/src/JoinCode/Cli/Core/CommandLineOptions.cs b/src/JoinCode/Cli/Core/CommandLineOptions.cs index c8175a5..95e9dc7 100644 --- a/src/JoinCode/Cli/Core/CommandLineOptions.cs +++ b/src/JoinCode/Cli/Core/CommandLineOptions.cs @@ -136,6 +136,12 @@ public class CommandLineOptions { /// public bool DoctorMode { get; set; } + /// + /// 医生服务器模式(--doctor-server 参数)— 监听病人 SSE 连接,支持 1:N 多病人监控 + /// 需配合 --doctor 使用,单独使用无效 + /// + public bool DoctorServerMode { get; set; } + /// /// 医生 SSE 端点 URL(--doctor-endpoint 参数)— 病人端使用,连接到医生的 SSE 服务器 /// 例如 http://localhost:9902,病人通过此端点发送遥测事件和接收医生指令 diff --git a/src/JoinCode/Entry/DoctorModeRunner.cs b/src/JoinCode/Entry/DoctorModeRunner.cs index 0891e4c..17d5ca0 100644 --- a/src/JoinCode/Entry/DoctorModeRunner.cs +++ b/src/JoinCode/Entry/DoctorModeRunner.cs @@ -21,7 +21,7 @@ internal static async Task RunAsync(CommandLineOptions options) await using var doctor = new DoctorAgent(fs, processService, transport); - if (!string.IsNullOrWhiteSpace(options.DoctorEndpoint)) + if (options.DoctorServerMode) { Diag.WriteLifecycle($"[DOCTOR] SSE 服务器模式,端口: {port}"); var report = await doctor.RunServerAsync(port).ConfigureAwait(false); @@ -93,8 +93,6 @@ internal static async Task RunTestSuiteAsync(CommandLineOptions options) ["JCC_TEST_CASE_ID"] = testCase.TestCaseId }; - if (!string.IsNullOrWhiteSpace(options.DoctorEndpoint)) - envVars["JCC_ENDPOINT"] = options.DoctorEndpoint; if (!string.IsNullOrWhiteSpace(options.Model)) envVars["JCC_MODEL_ID"] = options.Model; From 0338b030cfa1df799a8478f3b1bf0b983676c4c5 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 04:58:44 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20Doctor=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8D=95=E8=8E=B7+=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E9=81=A5=E6=B5=8B+=E4=BF=AE=E5=A4=8D=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E8=A1=A5=E5=85=A8=20|=20=E5=86=B3=E7=AD=96:=20Permiss?= =?UTF-8?q?ionAwareToolExecutor=E6=B7=BB=E5=8A=A0ToolExecutionCompleted?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8D=95=E8=8E=B7=E5=B7=A5=E5=85=B7=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=BB=93=E6=9E=9C,=E7=97=85=E4=BA=BA=E7=AB=AF?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E4=BA=8B=E4=BB=B6=E9=80=9A=E8=BF=87SSE?= =?UTF-8?q?=E8=BD=AC=E5=8F=91tool=5Ferror/tool=5Fsuccess=E7=BB=99=E5=8C=BB?= =?UTF-8?q?=E7=94=9F,DiagnosticEngine=E6=B7=BB=E5=8A=A0D006=20ToolExecutio?= =?UTF-8?q?nError=E8=A7=84=E5=88=99(=E5=90=8C=E5=B7=A5=E5=85=B7=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E5=A4=B1=E8=B4=A53=E6=AC=A1),HotFixEngine.BuildConfig?= =?UTF-8?q?ChangeAction=E4=B8=BAToolPermissionDenied=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90PatchedContent,SourceCodePatch=E7=BC=BA?= =?UTF-8?q?=E5=B0=91PatchedContent=E6=97=B6=E8=BF=94=E5=9B=9E=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E4=BB=8B=E5=85=A5=E6=8F=90=E7=A4=BA=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Execution/PermissionAwareToolExecutor.cs | 52 +++++++++++++++- .../Agents/Agents/Doctor/DiagnosticEngine.cs | 37 +++++++++++ .../src/Agents/Agents/Doctor/HotFixEngine.cs | 51 ++++++++++++--- .../Agents/Doctor/DiagnosticEngineTests.cs | 62 +++++++++++++++++++ .../Unit/Agents/Doctor/HotFixEngineTests.cs | 41 ++++++++++++ .../Agent/Doctor/DiagnosticModels.cs | 5 +- src/JoinCode/Program.cs | 19 ++++++ 7 files changed, 257 insertions(+), 10 deletions(-) diff --git a/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs b/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs index 4fdde1c..2956c86 100644 --- a/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs +++ b/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs @@ -15,6 +15,11 @@ public sealed partial class PermissionAwareToolExecutor private readonly MiddlewarePipeline _pipeline; private PermissionMode _currentAgentMode = PermissionMode.Auto; + /// + /// 工具执行完成事件 — 无论成功或失败都会触发,用于遥测和诊断 + /// + public event EventHandler? ToolExecutionCompleted; + public PermissionMode CurrentAgentMode { get => _currentAgentMode; @@ -76,11 +81,14 @@ public async Task ExecuteAsync( if (context.Result is not null) { + RaiseToolExecutionCompleted(toolName, context.Result, arguments); return context.Result; } _logger.LogError("Tool {ToolName} pipeline completed without result", toolName); - return CreateErrorResult($"Tool '{toolName}' execution produced no result."); + var noResultError = CreateErrorResult($"Tool '{toolName}' execution produced no result."); + RaiseToolExecutionCompleted(toolName, noResultError, arguments); + return noResultError; } catch (OperationCanceledException) { @@ -93,6 +101,7 @@ public async Task ExecuteAsync( _logger.LogWarning(L.T(StringKey.ToolExecPermissionDeniedLog, toolName)); span?.SetStatus(TelemetryStatusCode.Error, "Permission denied"); RecordPermissionDenied(toolName); + RaiseToolExecutionCompleted(toolName, null, arguments, "permission_denied"); throw; } catch (PermissionPendingConfirmationException) @@ -105,7 +114,9 @@ public async Task ExecuteAsync( { _logger.LogError(ex, L.T(StringKey.ToolExecFailedLog, toolName)); span?.RecordException(ex); - return CreateErrorResult($"Error executing tool '{toolName}': {ex.Message}"); + var exceptionError = CreateErrorResult($"Error executing tool '{toolName}': {ex.Message}"); + RaiseToolExecutionCompleted(toolName, exceptionError, arguments, ex.Message); + return exceptionError; } } @@ -132,4 +143,41 @@ private static ToolResult CreateErrorResult(string errorMessage) IsError = true }; } + + private void RaiseToolExecutionCompleted( + string toolName, + ToolResult? result, + Dictionary arguments, + string? errorMessage = null) + { + ToolExecutionCompleted?.Invoke(this, new ToolExecutionCompletedEventArgs + { + ToolName = toolName, + IsError = result?.IsError ?? true, + ErrorMessage = errorMessage ?? result?.Content?.FirstOrDefault(c => c.Type == ToolContentType.Text)?.Text, + Duration = TimeSpan.Zero, + Arguments = arguments + }); + } +} + +/// +/// 工具执行完成事件参数 +/// +public sealed class ToolExecutionCompletedEventArgs : EventArgs +{ + /// 工具名称 + public required string ToolName { get; init; } + + /// 是否执行错误 + public required bool IsError { get; init; } + + /// 错误消息(成功时为 null) + public string? ErrorMessage { get; init; } + + /// 执行耗时 + public TimeSpan Duration { get; init; } + + /// 工具参数 + public Dictionary? Arguments { get; init; } } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs index 764e76e..eca60a0 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs @@ -9,6 +9,7 @@ public sealed class DiagnosticEngine private readonly Dictionary<(string PatientId, string SessionId), int> _loopCountBySession = new(); private readonly Dictionary<(string PatientId, string ToolName), int> _permissionDeniedByTool = new(); private readonly Dictionary> _recentApiErrorsByPatient = new(); + private readonly Dictionary<(string PatientId, string ToolName), int> _toolErrorsByPatientAndTool = new(); private readonly Dictionary _lastTokenUsageRatioByPatient = new(); private readonly List _reports = []; private readonly object _lock = new(); @@ -42,6 +43,7 @@ public DiagnosticEngine() { } "permission_denied" => EvaluatePermissionDenied(evt), "context_overflow" => EvaluateContextOverflow(evt), "api_error" or "api_timeout" => EvaluateApiError(evt), + "tool_error" => EvaluateToolError(evt), _ => null }; @@ -81,6 +83,12 @@ public DiagnosticEngine() { } || rawData.Contains("API错误", StringComparison.OrdinalIgnoreCase)) return "api_error"; + if (rawData.Contains("ToolError", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("tool_error", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("工具执行失败", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("Error executing tool", StringComparison.OrdinalIgnoreCase)) + return "tool_error"; + return null; } @@ -113,6 +121,7 @@ public void Reset() _loopCountBySession.Clear(); _permissionDeniedByTool.Clear(); _recentApiErrorsByPatient.Clear(); + _toolErrorsByPatientAndTool.Clear(); _lastTokenUsageRatioByPatient.Clear(); _reports.Clear(); } @@ -128,6 +137,9 @@ public void ResetPatient(string patientId) var toolKeysToRemove = _permissionDeniedByTool.Keys.Where(k => k.PatientId == patientId).ToList(); foreach (var key in toolKeysToRemove) _permissionDeniedByTool.Remove(key); + var toolErrorKeysToRemove = _toolErrorsByPatientAndTool.Keys.Where(k => k.PatientId == patientId).ToList(); + foreach (var key in toolErrorKeysToRemove) _toolErrorsByPatientAndTool.Remove(key); + _recentApiErrorsByPatient.Remove(patientId); _lastTokenUsageRatioByPatient.Remove(patientId); } @@ -234,4 +246,29 @@ public void ResetPatient(string patientId) SuggestedFixDescription = "检查 API 端点配置、网络连接、API Key 有效性" }; } + + private DiagnosticReport? EvaluateToolError(DiagnosticEvent evt) + { + var toolName = evt.Properties.GetValueOrDefault("tool") ?? evt.Properties.GetValueOrDefault("tool_name") ?? "unknown"; + var key = (evt.PatientId, toolName); + var count = _toolErrorsByPatientAndTool.GetValueOrDefault(key) + 1; + _toolErrorsByPatientAndTool[key] = count; + + if (count < 3) return null; + + var errorMessage = evt.Properties.GetValueOrDefault("error") ?? evt.RawData; + + return new DiagnosticReport + { + RuleId = DiagnosticRuleId.ToolExecutionError, + PatientId = evt.PatientId, + Severity = DiagnosticSeverity.Error, + Description = $"病人 {evt.PatientId} 工具 {toolName} 连续执行失败 {count} 次,可能存在配置或环境问题", + TriggeringEvents = [evt], + SuggestedFixType = HotFixActionType.ConfigChange, + SuggestedFixDescription = string.IsNullOrWhiteSpace(errorMessage) + ? $"检查工具 {toolName} 的配置和依赖" + : $"检查工具 {toolName} 的配置和依赖,错误: {errorMessage}" + }; + } } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs index 39ea16e..f99a90c 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs @@ -99,12 +99,7 @@ internal HotFixAction ChooseAction(DiagnosticReport report) Description = report.SuggestedFixDescription ?? "修改源码并重编译", TargetFilePath = InferTargetFilePath(report) }, - HotFixActionType.ConfigChange => new HotFixAction - { - ActionType = HotFixActionType.ConfigChange, - Description = report.SuggestedFixDescription ?? "修改配置文件", - TargetFilePath = InferConfigFilePath(report) - }, + HotFixActionType.ConfigChange => BuildConfigChangeAction(report), HotFixActionType.CompactContext => new HotFixAction { ActionType = HotFixActionType.CompactContext, @@ -137,7 +132,8 @@ private async Task ExecuteSourceCodePatchAsync( if (string.IsNullOrWhiteSpace(action.PatchedContent)) { - return new HotFixResult { Success = false, PatientId = patientId, Action = action, Description = "未指定补丁内容,源码修复需要人工介入" }; + DoctorDiag.Write($"[Doctor] 源码修复需要人工介入: {action.TargetFilePath} (病人: {patientId})"); + return new HotFixResult { Success = false, PatientId = patientId, Action = action, Description = $"源码修复需要人工介入,目标文件: {action.TargetFilePath ?? "未知"}" }; } var patchResult = await _patcher.ApplyPatchAsync( @@ -385,6 +381,47 @@ private static HotFixResult CreateNoOpResult(HotFixAction action, string patient return null; } + private static HotFixAction BuildConfigChangeAction(DiagnosticReport report) + { + var targetFilePath = InferConfigFilePath(report); + + if (report.RuleId == DiagnosticRuleId.ToolPermissionDenied) + { + var toolName = report.TriggeringEvents + .Select(e => e.Properties.GetValueOrDefault("tool") ?? e.Properties.GetValueOrDefault("tool_name")) + .FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "unknown"; + + return new HotFixAction + { + ActionType = HotFixActionType.ConfigChange, + Description = report.SuggestedFixDescription ?? $"为工具 {toolName} 添加允许权限", + TargetFilePath = targetFilePath, + PatchedContent = $"{{\"permissions\": {{\"allowedTools\": [\"{toolName}\"]}}}}" + }; + } + + if (report.RuleId == DiagnosticRuleId.ToolExecutionError) + { + var toolName = report.TriggeringEvents + .Select(e => e.Properties.GetValueOrDefault("tool") ?? e.Properties.GetValueOrDefault("tool_name")) + .FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "unknown"; + + return new HotFixAction + { + ActionType = HotFixActionType.ConfigChange, + Description = report.SuggestedFixDescription ?? $"检查工具 {toolName} 的配置和依赖", + TargetFilePath = targetFilePath + }; + } + + return new HotFixAction + { + ActionType = HotFixActionType.ConfigChange, + Description = report.SuggestedFixDescription ?? "修改配置文件", + TargetFilePath = targetFilePath + }; + } + private async Task VerifyConfigChangeAsync(string filePath, CancellationToken ct) { const int maxRetries = 5; diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs index 448462d..aa9e718 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs @@ -307,15 +307,77 @@ public void Evaluate_ContextOverflow_UsesUsageRatioFallback() Assert.Equal(DiagnosticRuleId.ContextOverflow, report.RuleId); } + [Fact] + public void Evaluate_ToolError_LessThan3Times_ReturnsNull() + { + var evt1 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + + Assert.Null(_engine.Evaluate(evt1)); + Assert.Null(_engine.Evaluate(evt2)); + } + + [Fact] + public void Evaluate_ToolError_3Times_ReturnsD006Report() + { + var evt1 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt3 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + + _engine.Evaluate(evt1); + _engine.Evaluate(evt2); + var report = _engine.Evaluate(evt3); + + Assert.NotNull(report); + Assert.Equal(DiagnosticRuleId.ToolExecutionError, report.RuleId); + Assert.Equal(DiagnosticSeverity.Error, report.Severity); + Assert.Equal(HotFixActionType.ConfigChange, report.SuggestedFixType); + Assert.Contains("Bash", report.Description); + } + + [Fact] + public void Evaluate_ToolError_DifferentTools_CountedSeparately() + { + var evt1 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Read" }); + var evt3 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + + Assert.Null(_engine.Evaluate(evt1)); + Assert.Null(_engine.Evaluate(evt2)); + Assert.Null(_engine.Evaluate(evt3)); + } + + [Fact] + public void Evaluate_ToolError_PatientIsolation() + { + var evt1 = CreateEvent("tool_error", patientId: "p1", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", patientId: "p2", properties: new Dictionary { ["tool"] = "Bash" }); + var evt3 = CreateEvent("tool_error", patientId: "p1", properties: new Dictionary { ["tool"] = "Bash" }); + + Assert.Null(_engine.Evaluate(evt1)); + Assert.Null(_engine.Evaluate(evt2)); + Assert.Null(_engine.Evaluate(evt3)); + } + + [Fact] + public void ClassifyDiagOutput_ToolErrorKeywords() + { + Assert.Equal("tool_error", DiagnosticEngine.ClassifyDiagOutput("ToolError: something failed")); + Assert.Equal("tool_error", DiagnosticEngine.ClassifyDiagOutput("Error executing tool 'Bash'")); + Assert.Equal("tool_error", DiagnosticEngine.ClassifyDiagOutput("工具执行失败")); + } + private static DiagnosticEvent CreateEvent( string eventType, string? sessionId = null, + string? patientId = null, Dictionary? properties = null) { return new DiagnosticEvent { EventType = eventType, SessionId = sessionId, + PatientId = patientId ?? string.Empty, Properties = properties ?? new Dictionary() }; } diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs index ca460db..2adb015 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs @@ -94,6 +94,47 @@ public void ChooseAction_ConfigChange_SetsSettingsJsonPath() Assert.Equal("settings.json", action.TargetFilePath); } + [Fact] + public void ChooseAction_ConfigChange_ToolPermissionDenied_AutoGeneratesPatchedContent() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.ToolPermissionDenied, HotFixActionType.ConfigChange, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "permission_denied", + Properties = new Dictionary { ["tool"] = "Bash" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal(HotFixActionType.ConfigChange, action.ActionType); + Assert.NotNull(action.PatchedContent); + Assert.Contains("Bash", action.PatchedContent); + } + + [Fact] + public void ChooseAction_ConfigChange_ToolExecutionError_NoAutoPatchedContent() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.ToolExecutionError, HotFixActionType.ConfigChange, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "tool_error", + Properties = new Dictionary { ["tool"] = "Read" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal(HotFixActionType.ConfigChange, action.ActionType); + Assert.Null(action.PatchedContent); + } + [Fact] public void ChooseAction_CompactContext_SetsCompactCommand() { diff --git a/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs b/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs index 1ed0ea9..be779c3 100644 --- a/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs +++ b/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs @@ -29,7 +29,10 @@ public enum DiagnosticRuleId [EnumValue("D004")] ProcessHung, /// API 错误:API 调用连续失败 ≥ 3次 - [EnumValue("D005")] ApiError + [EnumValue("D005")] ApiError, + + /// 工具执行错误:同一工具连续失败 ≥ 3次 + [EnumValue("D006")] ToolExecutionError } /// diff --git a/src/JoinCode/Program.cs b/src/JoinCode/Program.cs index 568f01f..f974b4c 100644 --- a/src/JoinCode/Program.cs +++ b/src/JoinCode/Program.cs @@ -71,6 +71,25 @@ static async Task Main(string[] args) await builder.ConfigureModulesAsync(host.Services); + // 3.3 工具执行遥测:订阅 PermissionAwareToolExecutor.ToolExecutionCompleted,转发给医生 + if (doctorClient is not null) + { + var toolExecutor = host.Services.GetService(); + if (toolExecutor is not null) + { + toolExecutor.ToolExecutionCompleted += async (_, e) => + { + try + { + var eventType = e.IsError ? "tool_error" : "tool_success"; + var data = $"{{\"tool\":\"{e.ToolName}\",\"isError\":{e.IsError.ToString().ToLowerInvariant()}}}"; + await doctorClient.SendTextEventAsync(eventType, data).ConfigureAwait(false); + } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[Doctor] 发送工具遥测失败: {ex.Message}"); } + }; + } + } + int exitCode; if (options.IsNonInteractiveMode) exitCode = await Entry.NonInteractiveModeRunner.RunAsync(config, options, host); From 100963dfef15b05d9f4c7bae39dd7509c8d93112 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Sun, 19 Jul 2026 05:06:12 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=86=B2=E7=AA=81=20=E2=80=94=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E5=86=B2=E7=AA=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs | 1 - .../07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs index 7d06d04..eca60a0 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs @@ -83,7 +83,6 @@ public DiagnosticEngine() { } || rawData.Contains("API错误", StringComparison.OrdinalIgnoreCase)) return "api_error"; -<<<<<<< HEAD if (rawData.Contains("ToolError", StringComparison.OrdinalIgnoreCase) || rawData.Contains("tool_error", StringComparison.OrdinalIgnoreCase) || rawData.Contains("工具执行失败", StringComparison.OrdinalIgnoreCase) diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs index 2688bd1..f99a90c 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs @@ -381,7 +381,6 @@ private static HotFixResult CreateNoOpResult(HotFixAction action, string patient return null; } -<<<<<<< HEAD private static HotFixAction BuildConfigChangeAction(DiagnosticReport report) { var targetFilePath = InferConfigFilePath(report);