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);