diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index e1aa8b7b14e8..b46a751fabd6 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -5,7 +5,7 @@ 17606 Sources/AppDelegate.swift 16021 Sources/ContentView.swift 14100 Sources/TerminalController.swift -12695 Sources/Workspace.swift +12803 Sources/Workspace.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11841 Sources/GhosttyTerminalView.swift 11411 Sources/Panels/BrowserPanel.swift @@ -188,6 +188,7 @@ 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift 555 Sources/Panels/BrowserAutomation.swift 551 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +548 cmuxTests/WorkspaceSidebarObservationTests.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 539 CLI/CMUXCLI+Themes.swift diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/WorkspaceModel/WorkspaceSidebarMetadataModel.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/WorkspaceModel/WorkspaceSidebarMetadataModel.swift index f79261705a03..aa6adc6f5cf9 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/WorkspaceModel/WorkspaceSidebarMetadataModel.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/WorkspaceModel/WorkspaceSidebarMetadataModel.swift @@ -13,18 +13,17 @@ public import Observation /// through a computed `get`/`set` pair, so every call site (`statusEntries[key] /// = …`, `logEntries.append(…)`, `workspace.progress`) stays byte-identical. /// -/// Byte-identical observer parity: the legacy properties were `@Published`, and -/// the sidebar observation publishers (`Workspace.sidebarObservationPublisher`) -/// fused their `$projection`s through `CombineLatest` + `removeDuplicates()`. -/// To preserve that exactly, each property here mirrors its value into a -/// `CurrentValueSubject` in `didSet`; the matching `…Publisher` accessor -/// replaces the former `$property`. `CombineLatest` over current-value subjects -/// seeded with the initial values, then deduplicated, produces the identical -/// sequence of distinct fused states the `@Published` projections did, so the -/// debounced sidebar refresh fires at the same moments. +/// Observer parity: the legacy properties were `@Published`, and the sidebar +/// observation publishers (`Workspace.sidebarObservationPublisher`) fused their +/// `$projection`s through `CombineLatest` + `removeDuplicates()`. Each property +/// here mirrors its retained value into a `CurrentValueSubject` in `didSet`; the +/// matching `…Publisher` accessor replaces the former `$property`. @MainActor @Observable public final class WorkspaceSidebarMetadataModel { + /// Upper bound on retained sidebar markdown metadata blocks per workspace. + public static let maxMetadataBlocks = 200 + /// Sidebar status entries keyed by status key (legacy /// `Workspace.statusEntries`). public var statusEntries: [String: SidebarStatusEntry] = [:] { @@ -34,7 +33,12 @@ public final class WorkspaceSidebarMetadataModel { /// Sidebar markdown metadata blocks keyed by block key (legacy /// `Workspace.metadataBlocks`). public var metadataBlocks: [String: SidebarMetadataBlock] = [:] { - didSet { metadataBlocksSubject.send(metadataBlocks) } + didSet { + if metadataBlocks.count > Self.maxMetadataBlocks { + metadataBlocks = cappedMetadataBlocksForDisplay(metadataBlocks) + } + metadataBlocksSubject.send(metadataBlocks) + } } /// Recent sidebar log entries, oldest first, capped to the configured @@ -210,4 +214,20 @@ public final class WorkspaceSidebarMetadataModel { return lhs.key < rhs.key } } + + private func cappedMetadataBlocksForDisplay( + _ blocks: [String: SidebarMetadataBlock] + ) -> [String: SidebarMetadataBlock] { + let keptKeys = Set( + blocks + .sorted { lhs, rhs in + if lhs.value.priority != rhs.value.priority { return lhs.value.priority > rhs.value.priority } + if lhs.value.timestamp != rhs.value.timestamp { return lhs.value.timestamp > rhs.value.timestamp } + return lhs.key < rhs.key + } + .prefix(Self.maxMetadataBlocks) + .map(\.key) + ) + return blocks.filter { keptKeys.contains($0.key) } + } } diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/WorkspaceSidebarMetadataModelTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/WorkspaceSidebarMetadataModelTests.swift index bfd48eda4d49..ddefa6e32e26 100644 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/WorkspaceSidebarMetadataModelTests.swift +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/WorkspaceSidebarMetadataModelTests.swift @@ -77,6 +77,47 @@ private struct FixedLogLimitProvider: SidebarLogEntryLimitProviding { #expect(ordered.map(\.key) == ["c", "a", "b"]) } + @Test func metadataBlocksStayBoundedUnderUnboundedDistinctKeys() { + let model = makeModel() + let cap = WorkspaceSidebarMetadataModel.maxMetadataBlocks + + for index in 0..<(cap * 3) { + model.metadataBlocks["key_\(index)"] = SidebarMetadataBlock( + key: "key_\(index)", + markdown: "block_\(index)", + priority: 0, + timestamp: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + + #expect(model.metadataBlocks.count <= cap) + #expect(model.metadataBlocks["key_\(cap * 3 - 1)"] != nil) + #expect(model.metadataBlocks["key_0"] == nil) + } + + @Test func metadataCapRetainsHighPriorityOverNewerLowPriorityFlood() { + let model = makeModel() + let cap = WorkspaceSidebarMetadataModel.maxMetadataBlocks + + model.metadataBlocks["important"] = SidebarMetadataBlock( + key: "important", + markdown: "m", + priority: 100, + timestamp: Date(timeIntervalSince1970: 0) + ) + for index in 0..<(cap * 2) { + model.metadataBlocks["low_\(index)"] = SidebarMetadataBlock( + key: "low_\(index)", + markdown: "m", + priority: 0, + timestamp: Date(timeIntervalSince1970: TimeInterval(index + 100)) + ) + } + + #expect(model.metadataBlocks.count <= cap) + #expect(model.metadataBlocks["important"] != nil) + } + @Test func progressGitAndPullRequestUpdaters() { let model = makeModel() model.updateProgress(SidebarProgressState(value: 0.5, label: "half")) diff --git a/Sources/TerminalController+ControlSidebarContext.swift b/Sources/TerminalController+ControlSidebarContext.swift index 55d1420f0381..8e4feee1cf22 100644 --- a/Sources/TerminalController+ControlSidebarContext.swift +++ b/Sources/TerminalController+ControlSidebarContext.swift @@ -45,11 +45,11 @@ extension TerminalController: ControlSidebarContext { ) else { // Still update PID tracking even if the status display hasn't changed. if let pid { - tab.recordAgentPID(key: key, pid: pid, panelId: panelID) + tab.recordAgentPIDForSurvivingStatusKey(key, pid: pid, panelId: panelID) } return } - tab.statusEntries[key] = SidebarStatusEntry( + tab.setSidebarStatusEntry(SidebarStatusEntry( key: key, value: value, icon: icon, @@ -58,9 +58,9 @@ extension TerminalController: ControlSidebarContext { priority: priority, format: appFormat, timestamp: Date() - ) + ), allowingPIDHandoffGrace: pid != nil) if let pid { - tab.recordAgentPID(key: key, pid: pid, panelId: panelID) + tab.recordAgentPIDForSurvivingStatusKey(key, pid: pid, panelId: panelID) } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4c55fa19b74e..00b95ee4034b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -12828,11 +12828,11 @@ class TerminalController { ) else { // Still update PID tracking even if the status display hasn't changed. if let pidValue { - tab.recordAgentPID(key: key, pid: pidValue, panelId: panelResolution.panelId) + tab.recordAgentPIDForSurvivingStatusKey(key, pid: pidValue, panelId: panelResolution.panelId) } return } - tab.statusEntries[key] = SidebarStatusEntry( + tab.setSidebarStatusEntry(SidebarStatusEntry( key: key, value: value, icon: icon, @@ -12841,9 +12841,9 @@ class TerminalController { priority: priority, format: format, timestamp: Date() - ) + ), allowingPIDHandoffGrace: pidValue != nil) if let pidValue { - tab.recordAgentPID(key: key, pid: pidValue, panelId: panelResolution.panelId) + tab.recordAgentPIDForSurvivingStatusKey(key, pid: pidValue, panelId: panelResolution.panelId) } } return "OK" diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 9e3742fd0575..f490d6dc5421 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -113,6 +113,13 @@ extension Workspace { return didClearOtherStructuredAgentRuntime } + /// Records a PID only when its status entry survived synchronous cap trimming. + @discardableResult + func recordAgentPIDForSurvivingStatusKey(_ key: String, pid: pid_t, panelId: UUID?) -> Bool { + guard statusEntries[key] != nil else { return false } + return recordAgentPID(key: key, pid: pid, panelId: panelId) + } + func suppressesRawTerminalNotification(panelId: UUID?) -> Bool { guard let panelId else { return false @@ -250,6 +257,41 @@ extension Workspace { recomputeListeningPorts() } + /// Status keys backed by active agent runtime or lifecycle state. + func statusKeysWithCoupledAgentRuntime() -> Set { + var keys = Set() + for pidKey in agentPIDs.keys { + keys.insert(agentStatusKey(forAgentPIDKey: pidKey)) + } + for pidKey in agentPIDPanelIdsByKey.keys { + keys.insert(agentStatusKey(forAgentPIDKey: pidKey)) + } + for lifecycleStates in agentLifecycleStatesByPanelId.values { + keys.formUnion(lifecycleStates.keys) + } + return keys + } + + /// Clears agent runtime state coupled to status keys evicted by the cap. + func purgeAgentRuntimeState(forEvictedStatusKeys evictedStatusKeys: Set) { + guard !evictedStatusKeys.isEmpty else { return } + var didChange = false + let pidKeysToClear = Set(agentPIDs.keys) + .union(agentPIDPanelIdsByKey.keys) + .filter { evictedStatusKeys.contains(agentStatusKey(forAgentPIDKey: $0)) } + for pidKey in pidKeysToClear { + if clearAgentPID(key: pidKey, panelId: nil, clearStatus: false, refreshPorts: false) { + didChange = true + } + } + for statusKey in evictedStatusKeys where clearAgentLifecycle(key: statusKey) { + didChange = true + } + if didChange { + refreshTrackedAgentPorts() + } + } + @discardableResult private func discardAgentRuntimeState(_ runtimeState: DetachedAgentRuntimeState?) -> Bool { guard let runtimeState else { return false } @@ -267,15 +309,36 @@ extension Workspace { func adoptDetachedAgentRuntimeState(_ runtimeState: DetachedAgentRuntimeState?) { guard let runtimeState else { return } - for (statusKey, statusEntry) in runtimeState.statusEntries { - statusEntries[statusKey] = statusEntry + func transferredStatusKey(forAgentPIDKey key: String) -> String? { + if runtimeState.statusEntries[key] != nil { + return key + } + guard let dotIndex = key.firstIndex(of: ".") else { + return nil + } + let statusKey = String(key[.. Bool { + guard let statusKey = transferredStatusKey(forAgentPIDKey: key) else { return true } + return statusEntries[statusKey] != nil } var didAdoptAgentPID = false - for (key, pid) in runtimeState.agentPIDs { + for (key, pid) in runtimeState.agentPIDs where adoptedStatusSurvived(forAgentPIDKey: key) { recordAgentPID(key: key, pid: pid, panelId: runtimeState.panelId, refreshPorts: false) didAdoptAgentPID = true } - for key in runtimeState.agentPIDKeys where runtimeState.agentPIDs[key] == nil { + for key in runtimeState.agentPIDKeys + where runtimeState.agentPIDs[key] == nil && adoptedStatusSurvived(forAgentPIDKey: key) { recordAgentPIDOwnership(key: key, panelId: runtimeState.panelId) } if didAdoptAgentPID { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 537c2e91b889..49797f3271ed 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2385,8 +2385,18 @@ final class Workspace: Identifiable, ObservableObject { ) var statusEntries: [String: SidebarStatusEntry] { get { sidebarMetadata.statusEntries } - set { sidebarMetadata.statusEntries = newValue } + set { + let previousKeys = Set(sidebarMetadata.statusEntries.keys) + let pidHandoffGraceKeys = sidebarStatusPIDHandoffGraceKeys + sidebarStatusPIDHandoffGraceKeys.removeAll(keepingCapacity: true) + sidebarMetadata.statusEntries = newValue + trimSidebarStatusEntriesIfNeeded( + previousKeys: previousKeys, + pidHandoffGraceKeys: pidHandoffGraceKeys + ) + } } + private var sidebarStatusPIDHandoffGraceKeys: Set = [] var metadataBlocks: [String: SidebarMetadataBlock] { get { sidebarMetadata.metadataBlocks } set { sidebarMetadata.metadataBlocks = newValue } @@ -2466,6 +2476,12 @@ final class Workspace: Identifiable, ObservableObject { private static let remoteErrorStatusKey = "remote.error" private static let remotePortConflictStatusKey = "remote.port_conflicts" + /// cmux-owned status keys that carry application state and must never be + /// evicted by the status cap. + private static let reservedSidebarStatusKeys: Set = [ + remoteErrorStatusKey, + remotePortConflictStatusKey, + ] private static let remoteNotificationCooldown: TimeInterval = 5 * 60 private static let sshControlMasterCleanupQueue = DispatchQueue( label: "com.cmux.remote-ssh.control-master-cleanup", @@ -6544,6 +6560,98 @@ final class Workspace: Identifiable, ObservableObject { sidebarMetadata.appendLogEntry(message: message, level: level, source: source) } + func setSidebarStatusEntry( + _ entry: SidebarStatusEntry, + allowingPIDHandoffGrace: Bool = false + ) { + if allowingPIDHandoffGrace { + sidebarStatusPIDHandoffGraceKeys.insert(entry.key) + } + statusEntries[entry.key] = entry + } + + func replaceSidebarStatusEntries( + _ entries: [String: SidebarStatusEntry], + pidHandoffGraceKeys: Set = [] + ) { + sidebarStatusPIDHandoffGraceKeys.formUnion(pidHandoffGraceKeys) + statusEntries = entries + } + + /// Upper bound on retained sidebar status pills / metadata blocks per + /// workspace. The sidebar `status`/`metadata` socket API lets agents and CI + /// scripts insert entries under arbitrary caller-chosen keys. Without a cap, + /// a long-running or verbose integration (e.g. ~30 live agent sessions over + /// hours, https://github.com/manaflow-ai/cmux/issues/5845) grows these + /// forwarded dictionaries without bound, which both leaks memory and makes + /// the sidebar observation `removeDuplicates` equality check and the + /// `sidebarStatusEntriesInDisplayOrder()` / `sidebarMetadataBlocksInDisplayOrder()` + /// sorts that feed the sidebar view graph progressively more expensive on the + /// main thread. The bound is generous enough that no realistic integration + /// (the collapsed sidebar shows at most a handful of pills) is affected; + /// it only clamps pathological growth. Mirrors the `logEntries` cap above. + static let maxSidebarStatusEntries = 200 + static let maxSidebarMetadataBlocks = WorkspaceSidebarMetadataModel.maxMetadataBlocks + + /// Evicts status entries once the cap is exceeded. Retention is tiered: + /// 1. cmux-owned reserved keys (application state). + /// 2. statuses backed by a live agent (coupled PID or lifecycle state) — so + /// an actively updated agent status whose display timestamp went stale + /// can't be aged out by a flood of newer distinct keys. + /// 3. newly inserted keys explicitly marked for a PID-handoff write. + /// Multi-step updates such as `set_status --pid` insert the status first + /// and record the coupling marker afterward; this grace tier keeps the + /// just-inserted status alive across its own synchronous trim so + /// `recordAgentPIDForSurvivingStatusKey` can mark it live before the next + /// trim (#5845). It is a *tier*, not an absolute pin — a bulk insert + /// above the cap still ranks within this tier by priority/timestamp, so + /// the cap is always enforced. Plain status telemetry does not get this + /// tier and retains strictly by priority/timestamp after reserved/live + /// keys. + /// 4. everything else, by the same priority/timestamp order as + /// `sidebarStatusEntriesInDisplayOrder()`. + /// Ranking and the keep-set are both keyed by the dictionary's storage key + /// (not `entry.key`) so the two can never diverge. + private func trimSidebarStatusEntriesIfNeeded( + previousKeys: Set, + pidHandoffGraceKeys: Set + ) { + guard statusEntries.count > Self.maxSidebarStatusEntries else { return } + let liveAgentStatusKeys = statusKeysWithCoupledAgentRuntime() + let justInsertedPIDHandoffKeys = Set(statusEntries.keys) + .subtracting(previousKeys) + .intersection(pidHandoffGraceKeys) + let kept = Set( + statusEntries + .sorted { lhs, rhs in + let lhsReserved = Self.reservedSidebarStatusKeys.contains(lhs.key) + let rhsReserved = Self.reservedSidebarStatusKeys.contains(rhs.key) + if lhsReserved != rhsReserved { return lhsReserved } + let lhsLive = liveAgentStatusKeys.contains(lhs.key) + let rhsLive = liveAgentStatusKeys.contains(rhs.key) + if lhsLive != rhsLive { return lhsLive } + let lhsFresh = justInsertedPIDHandoffKeys.contains(lhs.key) + let rhsFresh = justInsertedPIDHandoffKeys.contains(rhs.key) + if lhsFresh != rhsFresh { return lhsFresh } + if lhs.value.priority != rhs.value.priority { return lhs.value.priority > rhs.value.priority } + if lhs.value.timestamp != rhs.value.timestamp { return lhs.value.timestamp > rhs.value.timestamp } + return lhs.key < rhs.key + } + .prefix(Self.maxSidebarStatusEntries) + .map(\.key) + ) + let evictedKeys = Set(statusEntries.keys).subtracting(kept) + guard !evictedKeys.isEmpty else { return } + // Tear down the agent PID/ownership/lifecycle state coupled to the evicted + // status keys (`set_status --pid`) before they leave `statusEntries`, so + // those runtime maps and the port-scan tags keyed off them stay bounded + // too (#5845). Must precede the removal so dotted status keys resolve. + purgeAgentRuntimeState(forEvictedStatusKeys: evictedKeys) + // Assign the backing model directly so the computed setter does not + // recalculate the triggering write's previous-key set during trim. + sidebarMetadata.statusEntries = statusEntries.filter { kept.contains($0.key) } + } + // MARK: - Panel Operations private func seedTerminalInheritanceFontPoints( diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 5ab5eef384a3..b56c8e95bcff 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing import CmuxSidebar @@ -10,8 +10,8 @@ import CmuxSidebar #endif @MainActor -final class WorkspaceSidebarObservationTests: XCTestCase { - func testSidebarObservationPublisherEmitsForLateStatusSubscriber() { +@Suite struct WorkspaceSidebarObservationTests { + @Test func testSidebarObservationPublisherEmitsForLateStatusSubscriber() { let workspace = Workspace() workspace.statusEntries["test_probe"] = SidebarStatusEntry( key: "test_probe", @@ -27,14 +27,13 @@ final class WorkspaceSidebarObservationTests: XCTestCase { } defer { cancellable.cancel() } - XCTAssertGreaterThan( - publishCount, - 0, + #expect( + publishCount > 0, "A sidebar row that subscribes after status metadata already exists must still refresh from the current workspace state." ) } - func testSidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { + @Test func testSidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { let workspace = Workspace() workspace.title = "Restored Workspace" @@ -44,14 +43,13 @@ final class WorkspaceSidebarObservationTests: XCTestCase { } defer { cancellable.cancel() } - XCTAssertGreaterThan( - publishCount, - 0, + #expect( + publishCount > 0, "A sidebar row that subscribes after immediate workspace fields already exist must still refresh from the current workspace state." ) } - func testSidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { + @Test func testSidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { let workspace = Workspace() var publishCount = 0 @@ -64,10 +62,487 @@ final class WorkspaceSidebarObservationTests: XCTestCase { workspace.remoteHeartbeatCount = 1 workspace.remoteLastHeartbeatAt = Date() - XCTAssertEqual( - publishCount, - 0, + #expect( + publishCount == 0, "Expected non-visible remote heartbeat updates to avoid invalidating sidebar rows" ) } + + // The sidebar `status`/`metadata` socket API lets agents and CI scripts + // insert entries under arbitrary caller-chosen keys. With ~30 long-running + // agent sessions over hours, an integration that uses ever-distinct keys + // grows these forwarded dictionaries without bound, which both leaks memory + // (footprint climbed to 6–8 GB in https://github.com/manaflow-ai/cmux/issues/5845) + // and makes the sidebar observation `removeDuplicates` equality check and + // `sidebarStatusEntriesInDisplayOrder()` sort that feed the sidebar view + // graph progressively more expensive on the main thread. They must stay + // bounded like `logEntries` already is. + @Test func testStatusEntriesStayBoundedUnderUnboundedDistinctKeys() { + let workspace = Workspace() + let cap = Workspace.maxSidebarStatusEntries + + for index in 0..<(cap * 3) { + workspace.statusEntries["key_\(index)"] = SidebarStatusEntry( + key: "key_\(index)", + value: "value_\(index)", + priority: 0, + timestamp: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + + #expect( + workspace.statusEntries.count <= cap, + "statusEntries must stay bounded so unbounded agent telemetry cannot grow the sidebar view-graph inputs without limit" + ) + // Eviction keeps the most recent entries (highest timestamp) and drops + // the oldest, so the newest key survives and the oldest is gone. + #expect( + workspace.statusEntries["key_\(cap * 3 - 1)"] != nil, + "The most recent status entry must be retained after trimming" + ) + #expect( + workspace.statusEntries["key_0"] == nil, + "The oldest status entry must be evicted once the cap is exceeded" + ) + } + + @Test func testMetadataBlocksStayBoundedUnderUnboundedDistinctKeys() { + let workspace = Workspace() + let cap = Workspace.maxSidebarMetadataBlocks + + for index in 0..<(cap * 3) { + workspace.metadataBlocks["key_\(index)"] = SidebarMetadataBlock( + key: "key_\(index)", + markdown: "block_\(index)", + priority: 0, + timestamp: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + + #expect( + workspace.metadataBlocks.count <= cap, + "metadataBlocks must stay bounded so unbounded agent telemetry cannot grow the sidebar view-graph inputs without limit" + ) + #expect( + workspace.metadataBlocks["key_\(cap * 3 - 1)"] != nil, + "The most recent metadata block must be retained after trimming" + ) + #expect( + workspace.metadataBlocks["key_0"] == nil, + "The oldest metadata block must be evicted once the cap is exceeded" + ) + } + + // Metadata blocks have no PID/lifecycle coupling, so priority is the primary + // retention signal: a newer low-priority flood must not displace an existing + // high-priority block at the cap (#5845 follow-up — the original change gave + // metadata an unwarranted just-inserted grace tier). + @Test func testMetadataCapRetainsHighPriorityOverNewerLowPriorityFlood() { + let workspace = Workspace() + let cap = Workspace.maxSidebarMetadataBlocks + + workspace.metadataBlocks["important"] = SidebarMetadataBlock( + key: "important", + markdown: "m", + priority: 100, + timestamp: Date(timeIntervalSince1970: 0) + ) + for index in 0..<(cap * 2) { + workspace.metadataBlocks["low_\(index)"] = SidebarMetadataBlock( + key: "low_\(index)", + markdown: "m", + priority: 0, + timestamp: Date(timeIntervalSince1970: TimeInterval(index + 100)) + ) + } + + #expect(workspace.metadataBlocks.count <= cap) + #expect( + workspace.metadataBlocks["important"] != nil, + "A high-priority metadata block must survive a newer low-priority flood" + ) + } + + // `set_status --pid` couples a status key to agent PID runtime state + // (agentPIDs / ownership maps / port-scan tags). When the cap evicts the + // status key, that coupled state must be torn down too, otherwise the same + // ever-distinct-key workload keeps those maps growing without bound (#5845). + @Test func testStatusCapEvictionClearsCoupledAgentPIDState() { + let workspace = Workspace() + let cap = Workspace.maxSidebarStatusEntries + + // Every key is backed by a live agent PID, so the cap must still evict + // the oldest and purge its coupled agent PID state. PIDs are recorded + // first so all keys count as live when the cap trims. + for index in 0...cap { + _ = workspace.recordAgentPID( + key: "key_\(index)", + pid: pid_t(4000 + index), + panelId: nil, + refreshPorts: false + ) + } + for index in 0...cap { + workspace.statusEntries["key_\(index)"] = SidebarStatusEntry( + key: "key_\(index)", + value: "value_\(index)", + timestamp: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + + #expect(workspace.statusEntries.count <= cap) + #expect( + workspace.statusEntries["key_0"] == nil, + "The oldest status entry must be evicted once the cap is exceeded" + ) + #expect( + workspace.agentPIDs["key_0"] == nil, + "Evicting a status key must also clear its coupled agent PID runtime state" + ) + } + + // A brand-new agent status with a pending PID handoff (set_status --pid + // inserts the status first, then records the PID) must survive its own + // synchronous trim even when the workspace is already at cap with + // higher-priority plain telemetry, so the follow-up PID can still be tracked + // instead of lost to a self-eviction (#5845). + @Test func testNewPIDHandoffStatusSurvivesOwnTrimOverPlainTelemetry() { + let workspace = Workspace() + let cap = Workspace.maxSidebarStatusEntries + + // Fill the cap with high-priority plain telemetry. + for index in 0..