From 17ea1e9bbccfc3df04048e95b2eb7b9c550a7357 Mon Sep 17 00:00:00 2001 From: heznpc Date: Thu, 3 Sep 2026 12:19:09 +0900 Subject: [PATCH] Surface sustained CPU incidents --- .../Modore/Models/IncidentAssessment.swift | 59 +++++++++++++++++++ .../Modore/Views/StorageOverviewView.swift | 6 ++ .../ModoreTests/IncidentAssessmentTests.swift | 58 ++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/macos/Modore/Sources/Modore/Models/IncidentAssessment.swift b/macos/Modore/Sources/Modore/Models/IncidentAssessment.swift index ed5b8a2..d028289 100644 --- a/macos/Modore/Sources/Modore/Models/IncidentAssessment.swift +++ b/macos/Modore/Sources/Modore/Models/IncidentAssessment.swift @@ -4,6 +4,7 @@ struct IncidentAssessment: Equatable { enum Kind: Equatable { case noResult case securityDanger + case sustainedCPU case storageCritical case collectionIncomplete case browserAutomation @@ -16,6 +17,7 @@ struct IncidentAssessment: Equatable { switch self { case .noResult: return "no_result" case .securityDanger: return "security_danger" + case .sustainedCPU: return "sustained_cpu" case .storageCritical: return "storage_critical" case .collectionIncomplete: return "collection_incomplete" case .browserAutomation: return "browser_automation" @@ -62,6 +64,31 @@ struct IncidentAssessment: Equatable { ) } + // Trust and resource health are separate judgments. A known runtime can + // be perfectly legitimate and still be stuck consuming a full core. + // The old ladder only considered the row's security risk, so a trusted + // Node binary running inside Taxi.app was collected at 102.7% but buried + // below storage/browser findings. Correlate the observation with the + // point-in-time process row to recover the owning app and executable. + if let cpu = sustainedCPU(in: content) { + let window = cpu.row.windowSeconds > 0 ? "\(cpu.row.windowSeconds)초 관찰에서 " : "관찰 구간에 " + let path = cpu.path.isEmpty ? "" : " 실행 경로: \(cpu.path)" + return IncidentAssessment( + kind: .sustainedCPU, + title: "\(cpu.displayName)가 CPU 한 코어 이상을 계속 사용합니다", + detail: String( + format: "%@%@ 프로세스가 평균 %.1f%%를 사용했습니다.%@", + window, + cpu.row.name, + cpu.row.cpuPercent, + path + ), + impact: "발열, 배터리 소모와 다른 앱의 응답 지연이 계속될 수 있습니다.", + value: String(format: "%.1f%%", cpu.row.cpuPercent), + symbol: "gauge.with.needle.fill" + ) + } + if let storage = content.storage, storage.risk == "danger" { return IncidentAssessment( kind: .storageCritical, @@ -154,4 +181,36 @@ struct IncidentAssessment: Equatable { symbol: "checkmark.circle" ) } + + private struct SustainedCPU { + let row: BackgroundCpuRow + let displayName: String + let path: String + } + + private static func sustainedCPU(in content: ScanContent) -> SustainedCPU? { + content.backgroundCpuRows + .filter { $0.cpuPercent >= 90 } + .compactMap { row -> SustainedCPU? in + let process = content.cpuRows.first { $0.pid == row.pid } + let path = process?.path ?? "" + guard row.name.caseInsensitiveCompare("Modore") != .orderedSame, + !path.localizedCaseInsensitiveContains("/Modore.app/Contents/") else { + return nil + } + let appName = appBundleName(in: path) + let responsible = row.responsibleName.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = appName + ?? (responsible.isEmpty ? row.name : responsible) + return SustainedCPU(row: row, displayName: displayName, path: path) + } + .max { $0.row.cpuPercent < $1.row.cpuPercent } + } + + private static func appBundleName(in path: String) -> String? { + guard let component = path.split(separator: "/").last(where: { + $0.lowercased().hasSuffix(".app") + }) else { return nil } + return String(component.dropLast(4)) + } } diff --git a/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift b/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift index eaf2db6..4085642 100644 --- a/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift +++ b/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift @@ -589,6 +589,7 @@ private struct StatusRecoverySection: View { private var actionSymbol: String { switch assessment.kind { case .securityDanger, .securityAttention: return "lock.shield" + case .sustainedCPU: return "gauge.with.needle" case .storageCritical: return "internaldrive" case .collectionIncomplete, .noResult: return "arrow.clockwise" case .browserAutomation, .runtimeAttention: return "hammer" @@ -599,6 +600,7 @@ private struct StatusRecoverySection: View { private var actionTitle: String { switch assessment.kind { case .securityDanger, .securityAttention: return "증거를 먼저 확인하세요" + case .sustainedCPU: return "지속 점유 프로세스를 확인하세요" case .storageCritical: return "승인 가능한 정리 후보를 검토하세요" case .collectionIncomplete, .noResult: return "검사 범위를 다시 수집하세요" case .browserAutomation: return "자동화 소유자와 격리 설정을 확인하세요" @@ -618,6 +620,8 @@ private struct StatusRecoverySection: View { return "세션 기록과 개발 필수 자산은 자동 정리 대상에서 제외합니다." case .securityDanger, .securityAttention: return "경로, 실행 맥락과 수집 범위를 확인한 뒤 별도 제거 여부를 판단합니다." + case .sustainedCPU: + return "자동 종료하지 않습니다. 프로세스와 실행 경로를 확인한 뒤 의도한 작업인지 판단하세요." case .runtimeAttention: return "Codex·Claude 세션 데이터는 보존하고 실행 중인 작업만 구분합니다." case .storageDrop: @@ -640,6 +644,7 @@ private struct StatusRecoverySection: View { switch assessment.kind { case .collectionIncomplete, .noResult: return "정밀 검사" case .securityDanger, .securityAttention: return "보안 보기" + case .sustainedCPU: return "활동 보기" case .storageCritical: return "확보 계획" case .browserAutomation, .runtimeAttention: return "개발 보기" case .storageDrop, .clear: return "기록 보기" @@ -650,6 +655,7 @@ private struct StatusRecoverySection: View { switch assessment.kind { case .collectionIncomplete, .noResult: return runScan case .securityDanger, .securityAttention: return onOpenSecurity + case .sustainedCPU: return onOpenActivity case .storageCritical: return { onOpenStorage(.goal) } case .browserAutomation, .runtimeAttention: return { onOpenStorage(.development) } case .storageDrop, .clear: return onOpenActivity diff --git a/macos/Modore/Tests/ModoreTests/IncidentAssessmentTests.swift b/macos/Modore/Tests/ModoreTests/IncidentAssessmentTests.swift index d955c7f..38dd9a1 100644 --- a/macos/Modore/Tests/ModoreTests/IncidentAssessmentTests.swift +++ b/macos/Modore/Tests/ModoreTests/IncidentAssessmentTests.swift @@ -268,6 +268,64 @@ final class IncidentAssessmentTests: XCTestCase { XCTAssertEqual(assessment.kind, .storageCritical) } + func testSustainedCPUOutranksStorageAndNamesOwningApp() throws { + var value = root(collection: completeCollection()) + var sections = try XCTUnwrap(value["sections"] as? [String: Any]) + var storage = try XCTUnwrap(sections["storage"] as? [String: Any]) + var volume = try XCTUnwrap(storage["volume"] as? [String: Any]) + volume["risk"] = "danger" + storage["volume"] = volume + storage["browserAutomation"] = [ + "verdict": "orphaned", "rootCount": 1, + "note": "소유 작업을 찾지 못했습니다.", + ] + sections["storage"] = storage + sections["backgroundCpu"] = [[ + "windowSeconds": 3, "name": "node", "pid_": 3559, + "cpuPercent": 102.7, "responsiblePid": 3559, + "responsibleName": "node", "selfResponsible": true, + "startedFromShell": false, "risk": "warning", + ]] + sections["cpu"] = [[ + "name": "node", "pid_": 3559, "cpu": 92.5, "memoryMB": 174, + "path": "/Users/test/Taxi.app/Contents/Resources/runtime/bin/node", + "risk": "safe", "note": "Node.js 런타임", + ]] + value["sections"] = sections + + let assessment = IncidentAssessment.make( + content: ScanContent(root: value), storageChange: nil + ) + + XCTAssertEqual(assessment.kind, .sustainedCPU) + XCTAssertTrue(assessment.title.contains("Taxi")) + XCTAssertTrue(assessment.detail.contains("102.7%")) + XCTAssertTrue(assessment.detail.contains("Taxi.app")) + } + + func testModoreOwnScanCPUDoesNotBecomeIncident() { + var value = root(collection: completeCollection()) + var sections = value["sections"] as! [String: Any] + sections["backgroundCpu"] = [[ + "windowSeconds": 3, "name": "Modore", "pid_": 100, + "cpuPercent": 120.0, "responsiblePid": 100, + "responsibleName": "Modore", "selfResponsible": true, + "startedFromShell": false, "risk": "warning", + ]] + sections["cpu"] = [[ + "name": "Modore", "pid_": 100, "cpu": 99.0, "memoryMB": 150, + "path": "/Applications/Modore.app/Contents/MacOS/Modore", + "risk": "safe", "note": "Modore", + ]] + value["sections"] = sections + + let assessment = IncidentAssessment.make( + content: ScanContent(root: value), storageChange: nil + ) + + XCTAssertEqual(assessment.kind, .clear) + } + func testSecurityAttentionWhenWarningOnly() { var value = root(collection: completeCollection()) value["findings"] = [