diff --git a/Sources/AgentDeliveryTargetResolution.swift b/Sources/AgentDeliveryTargetResolution.swift index 3fc4bc9e3353..0e341385de8b 100644 --- a/Sources/AgentDeliveryTargetResolution.swift +++ b/Sources/AgentDeliveryTargetResolution.swift @@ -68,8 +68,10 @@ nonisolated func agentLiveProcessIdentity(pid: pid_t) -> (ttyDevice: Int64?, sco let expectedSize = MemoryLayout.stride let size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize)) guard size == expectedSize else { return nil } - let device = Int64(info.e_tdev) - return (device > 0 ? device : nil, CmuxTopProcessSnapshot.scopeCacheKey(from: info)) + return ( + CmuxTopProcessSnapshot.controllingTTYDevice(info.e_tdev), + CmuxTopProcessSnapshot.scopeCacheKey(from: info) + ) } @MainActor extension Workspace { diff --git a/Sources/App/AgentHibernationController+ProcessExitWaiting.swift b/Sources/App/AgentHibernationController+ProcessExitWaiting.swift index c04ebcffaa94..317fb13826cf 100644 --- a/Sources/App/AgentHibernationController+ProcessExitWaiting.swift +++ b/Sources/App/AgentHibernationController+ProcessExitWaiting.swift @@ -111,19 +111,15 @@ extension AgentHibernationController { } if let processScopeKey { guard refreshedTerminations.allSatisfy({ termination in - guard let ttyDevice = termination.ttyDevice else { - return false - } - return processTTYDeviceProvider( - pid_t(termination.processID) - ) == ttyDevice + termination.terminalStillMatches( + processTTYDeviceProvider(pid_t(termination.processID)) + ) }), refreshedTerminations.allSatisfy({ termination in - processArgumentsProvider(termination.processID)? - .matchesCMUXScope( - workspaceId: processScopeKey.workspaceId, - surfaceId: processScopeKey.panelId - ) == true + termination.argumentsStillMatch( + processArgumentsProvider(termination.processID), + processScopeKey: processScopeKey + ) }), // Close same-generation exec and process-group races // introduced by the uncached scope/TTY probes. @@ -231,10 +227,13 @@ extension AgentHibernationController { ) } + /// The one terminal every termination except a cmux helper shares. nonisolated static func commonTTYDevice( in terminations: [ScopedProcessTermination] ) -> Int64? { - let ttyDevices = Set(terminations.compactMap(\.ttyDevice)) + let ttyDevices = Set( + terminations.filter { !$0.isCmuxHelper }.compactMap(\.ttyDevice) + ) guard ttyDevices.count == 1 else { return nil } return ttyDevices.first } diff --git a/Sources/App/AgentHibernationController+ProcessSignaling.swift b/Sources/App/AgentHibernationController+ProcessSignaling.swift index df24f4727bb9..2e4efd7418ab 100644 --- a/Sources/App/AgentHibernationController+ProcessSignaling.swift +++ b/Sources/App/AgentHibernationController+ProcessSignaling.swift @@ -119,18 +119,15 @@ extension AgentHibernationController { } guard terminations.allSatisfy({ termination in - guard let ttyDevice = termination.ttyDevice else { - return false - } - return processTTYDeviceProvider( - pid_t(termination.processID) - ) == ttyDevice + termination.terminalStillMatches( + processTTYDeviceProvider(pid_t(termination.processID)) + ) }), terminations.allSatisfy({ termination in - processArgumentsProvider(termination.processID)?.matchesCMUXScope( - workspaceId: processScopeKey.workspaceId, - surfaceId: processScopeKey.panelId - ) == true + termination.argumentsStillMatch( + processArgumentsProvider(termination.processID), + processScopeKey: processScopeKey + ) }), terminations.allSatisfy({ termination in let processID = pid_t(termination.processID) @@ -149,13 +146,10 @@ extension AgentHibernationController { return await MainActor.run { guard terminations.allSatisfy({ termination in let processID = pid_t(termination.processID) - guard let ttyDevice = termination.ttyDevice else { - return false - } return processIdentityProvider(processID) == termination.processIdentity && processGroupProvider(processID) == termination.processGroupID && - processTTYDeviceProvider(processID) == ttyDevice + termination.terminalStillMatches(processTTYDeviceProvider(processID)) }), signalableProcessGroupIDs.allSatisfy({ processGroupID in guard let expectedLeader = diff --git a/Sources/App/AgentHibernationController+ProcessTermination.swift b/Sources/App/AgentHibernationController+ProcessTermination.swift index 59f55a9fa77a..7b6132165b35 100644 --- a/Sources/App/AgentHibernationController+ProcessTermination.swift +++ b/Sources/App/AgentHibernationController+ProcessTermination.swift @@ -7,7 +7,8 @@ extension AgentHibernationRecord { AgentHibernationController.ProcessTerminationScope( key: key, processIDs: processIDs, - processIdentities: processIdentities + processIdentities: processIdentities, + cmuxHelperProcessIDs: cmuxHelperProcessIDs ) } } @@ -22,6 +23,19 @@ extension AgentHibernationController { let key: AgentHibernationPanelKey let processIDs: Set let processIdentities: [Int: AgentPIDProcessIdentity] + let cmuxHelperProcessIDs: Set + + init( + key: AgentHibernationPanelKey, + processIDs: Set, + processIdentities: [Int: AgentPIDProcessIdentity], + cmuxHelperProcessIDs: Set = [] + ) { + self.key = key + self.processIDs = processIDs + self.processIdentities = processIdentities + self.cmuxHelperProcessIDs = cmuxHelperProcessIDs + } } struct ScopedProcessTermination: Equatable, Sendable { @@ -29,17 +43,52 @@ extension AgentHibernationController { let processIdentity: AgentPIDProcessIdentity let processGroupID: pid_t let ttyDevice: Int64? + /// A registered cmux helper (``CmuxAgentHelperProcess``): it has no + /// terminal, and must still run that helper when it is signaled. + let isCmuxHelper: Bool init( processID: Int, processIdentity: AgentPIDProcessIdentity, processGroupID: pid_t, - ttyDevice: Int64? = nil + ttyDevice: Int64? = nil, + isCmuxHelper: Bool = false ) { self.processID = processID self.processIdentity = processIdentity self.processGroupID = processGroupID self.ttyDevice = ttyDevice + self.isCmuxHelper = isCmuxHelper + } + + /// Whether `liveTTYDevice` is still the terminal this process was + /// scoped with: the agent's, or none at all for a cmux helper. + func terminalStillMatches(_ liveTTYDevice: Int64?) -> Bool { + if isCmuxHelper { + return ttyDevice == nil && liveTTYDevice == nil + } + guard let ttyDevice else { return false } + return liveTTYDevice == ttyDevice + } + + /// Whether `arguments` still place this process in the panel, and + /// still run the registered helper when it was scoped as one. + func argumentsStillMatch( + _ arguments: CmuxTopProcessArguments?, + processScopeKey: AgentHibernationPanelKey + ) -> Bool { + guard let arguments, + arguments.matchesCMUXScope( + workspaceId: processScopeKey.workspaceId, + surfaceId: processScopeKey.panelId + ) else { + return false + } + return !isCmuxHelper || CmuxAgentHelperProcess.isRegistered( + arguments, + workspaceId: processScopeKey.workspaceId, + surfaceId: processScopeKey.panelId + ) } } @@ -124,7 +173,8 @@ extension AgentHibernationController { processID: processID, processIdentity: expectedIdentity, processGroupID: processGroupProvider(processID), - ttyDevice: processTTYDeviceProvider(processID) + ttyDevice: processTTYDeviceProvider(processID), + isCmuxHelper: scope.cmuxHelperProcessIDs.contains(processID) ) ) } @@ -439,7 +489,18 @@ extension AgentHibernationController { onRecoveryFailure: handleRecoveryFailure, onRecoveryRetry: beginRecoveryRetry ) - return false + // Routine reclaim moves on and lets the observation finish. A + // manual request answers for this one pane, so it waits for the + // exit to be observed and reports the pane's settled phase; the + // commit alone is neither a hibernation nor a refusal. + guard request.trigger == .manual, + let observation = committedTerminationObservationsByPanelID[panelID], + observation.requestID == committedTerminationRequestID else { + return false + } + await observation.task?.value + return record.terminalPanel.isAgentHibernated && + !record.terminalPanel.isAgentHibernationCommitPending } return record.terminalPanel.isAgentHibernated && diff --git a/Sources/App/AgentHibernationController+Records.swift b/Sources/App/AgentHibernationController+Records.swift index 01503e4c9eff..3338025a51b5 100644 --- a/Sources/App/AgentHibernationController+Records.swift +++ b/Sources/App/AgentHibernationController+Records.swift @@ -9,7 +9,8 @@ extension AgentHibernationRecord { !containsUnrelatedProcess && !processIDs.isEmpty && processIDs.count <= AgentHibernationController.maximumScopedProcessTerminationCount && - Set(processIdentities.keys) == processIDs + Set(processIdentities.keys) == processIDs && + cmuxHelperProcessIDs.isSubset(of: processIDs) } /// Background work the transcript records before this belongs to an earlier @@ -36,7 +37,8 @@ extension AgentHibernationRecord { !hasLiveProcess && panelProcessIDs.isEmpty && processIDs.isEmpty && - processIdentities.isEmpty + processIdentities.isEmpty && + cmuxHelperProcessIDs.isEmpty case .running: return hasPressureSafeProcessEvidence case .unknown: @@ -54,13 +56,15 @@ extension RestorableAgentSessionIndex.Entry { processIDs.isEmpty && hibernationPanelProcessIDs.isEmpty && terminationProcessIDs.isEmpty && - terminationProcessIdentities.isEmpty + terminationProcessIdentities.isEmpty && + cmuxHelperProcessIDs.isEmpty case .running: return !processIDs.isEmpty && !containsUnrelatedProcess && !terminationProcessIDs.isEmpty && terminationProcessIDs.count <= AgentHibernationController.maximumScopedProcessTerminationCount && - Set(terminationProcessIdentities.keys) == terminationProcessIDs + Set(terminationProcessIdentities.keys) == terminationProcessIDs && + cmuxHelperProcessIDs.isSubset(of: terminationProcessIDs) case .unknown: return false } @@ -136,6 +140,7 @@ extension AppDelegate { panelProcessIDs: processEntry?.hibernationPanelProcessIDs ?? [], processIDs: processEntry?.terminationProcessIDs ?? [], processIdentities: processEntry?.terminationProcessIdentities ?? [:], + cmuxHelperProcessIDs: processEntry?.cmuxHelperProcessIDs ?? [], processLiveness: processEntry?.processLiveness ?? .unknown ) ) diff --git a/Sources/App/AgentHibernationController+TeardownValidation.swift b/Sources/App/AgentHibernationController+TeardownValidation.swift index ae58e67134a8..23b4d9dc184c 100644 --- a/Sources/App/AgentHibernationController+TeardownValidation.swift +++ b/Sources/App/AgentHibernationController+TeardownValidation.swift @@ -46,6 +46,7 @@ extension AgentHibernationController { let currentTerminationProcessIDs = currentProcessEntry?.terminationProcessIDs ?? [] let currentTerminationProcessIdentities = currentProcessEntry?.terminationProcessIdentities ?? [:] + let currentCmuxHelperProcessIDs = currentProcessEntry?.cmuxHelperProcessIDs ?? [] // Routine reclaim may terminate a live agent only when the fresh index // still proves the same exclusive, identity-complete process scope. return (shouldProceed?() ?? true) && @@ -62,6 +63,7 @@ extension AgentHibernationController { currentHibernationPanelProcessIDs == record.panelProcessIDs && currentTerminationProcessIDs == record.processIDs && currentTerminationProcessIdentities == record.processIdentities && + currentCmuxHelperProcessIDs == record.cmuxHelperProcessIDs && TabManager.restorableAgentSnapshotFingerprint(currentAgent) == TabManager.restorableAgentSnapshotFingerprint(record.agent) && !record.terminalPanel.isAgentHibernated && diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift index 0d4fadba597f..c25282ff63c2 100644 --- a/Sources/App/AgentHibernationController.swift +++ b/Sources/App/AgentHibernationController.swift @@ -22,6 +22,8 @@ struct AgentHibernationRecord { let panelProcessIDs: Set let processIDs: Set let processIdentities: [Int: AgentPIDProcessIdentity] + /// Registered cmux helpers among `processIDs`, terminated without a terminal. + let cmuxHelperProcessIDs: Set private(set) var processLiveness: RestorableAgentProcessLiveness = .unknown init( key: AgentHibernationPanelKey, @@ -37,6 +39,7 @@ struct AgentHibernationRecord { panelProcessIDs: Set, processIDs: Set, processIdentities: [Int: AgentPIDProcessIdentity], + cmuxHelperProcessIDs: Set = [], processLiveness: RestorableAgentProcessLiveness = .unknown ) { self.key = key @@ -52,6 +55,7 @@ struct AgentHibernationRecord { self.panelProcessIDs = panelProcessIDs self.processIDs = processIDs self.processIdentities = processIdentities + self.cmuxHelperProcessIDs = cmuxHelperProcessIDs self.processLiveness = processLiveness } } diff --git a/Sources/App/AgentHibernationProcessSnapshotCoordinator.swift b/Sources/App/AgentHibernationProcessSnapshotCoordinator.swift index 301db2388af8..0c6013faf355 100644 --- a/Sources/App/AgentHibernationProcessSnapshotCoordinator.swift +++ b/Sources/App/AgentHibernationProcessSnapshotCoordinator.swift @@ -185,6 +185,7 @@ actor AgentHibernationProcessSnapshotCoordinator { return nil } var validatedIdentities: [Int: AgentPIDProcessIdentity] = [:] + var cmuxHelperProcessIDs: Set = [] if let processScopeKey { guard observedProcessIDs.allSatisfy({ processID in guard let process = snapshot.process(pid: processID), @@ -200,6 +201,17 @@ actor AgentHibernationProcessSnapshotCoordinator { return false } validatedIdentities[processID] = currentIdentity + // A detached helper leads its own authorized group; it has + // no terminal to match. + if process.ttyDevice == nil, + process.processGroupID == processID, + CmuxAgentHelperProcess.isRegistered( + arguments, + workspaceId: processScopeKey.workspaceId, + surfaceId: processScopeKey.panelId + ) { + cmuxHelperProcessIDs.insert(processID) + } return true }) else { return nil @@ -233,7 +245,8 @@ actor AgentHibernationProcessSnapshotCoordinator { processID: processID, processIdentity: identity, processGroupID: processGroupID, - ttyDevice: process.ttyDevice + ttyDevice: process.ttyDevice, + isCmuxHelper: cmuxHelperProcessIDs.contains(processID) ) if let leaderIdentity = authorizedLeaders[processGroupID] { liveAuthorizedLeaders[processGroupID] = leaderIdentity diff --git a/Sources/CmuxTopProcessSampler.swift b/Sources/CmuxTopProcessSampler.swift index b6bd6b38e931..c77262fe6f3f 100644 --- a/Sources/CmuxTopProcessSampler.swift +++ b/Sources/CmuxTopProcessSampler.swift @@ -94,8 +94,7 @@ struct CmuxTopProcessSampler: Sendable { let resourceUsage = includeResources ? reader.resourceUsage(for: pid) : nil let cacheKey = CmuxTopProcessSnapshot.scopeCacheKey(from: bsdInfo) let fallbackName = CmuxTopProcessSnapshot.fixedString(bsdInfo.pbi_comm) - let rawTTY = Int64(bsdInfo.e_tdev) - let ttyDevice = rawTTY > 0 ? rawTTY : nil + let ttyDevice = CmuxTopProcessSnapshot.controllingTTYDevice(bsdInfo.e_tdev) let rawProcessGroupID = Int(bsdInfo.pbi_pgid) let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil let rawTerminalProcessGroupID = Int(bsdInfo.e_tpgid) diff --git a/Sources/CmuxTopSnapshot+AgentBackgroundWork.swift b/Sources/CmuxTopSnapshot+AgentBackgroundWork.swift index 7121ab16b8c6..94ab72fb606c 100644 --- a/Sources/CmuxTopSnapshot+AgentBackgroundWork.swift +++ b/Sources/CmuxTopSnapshot+AgentBackgroundWork.swift @@ -45,3 +45,32 @@ extension CmuxTopProcessSnapshot { return shellProcessNames.contains(base) } } + +/// Helpers cmux's own agent integration runs beside an agent. A helper does +/// none of the agent's work and is useless without it, so hibernation stops it +/// with the agent instead of refusing the pane as running other work. +enum CmuxAgentHelperProcess { + /// Each helper's argv after the cmux executable. + /// + /// `hooks claude inbox-wait` is the `asyncRewake` hook the Claude wrapper + /// registers on SessionStart, Stop and StopFailure. Claude Code starts it + /// detached, with no controlling terminal; it polls the app for this + /// surface's agent messages and exits once its agent is gone. + static let registeredArguments: [[String]] = [ + ["hooks", "claude", "inbox-wait"], + ] + + /// Whether `arguments` run a registered helper for the given panel. + static func isRegistered( + _ arguments: CmuxTopProcessArguments, + workspaceId: UUID, + surfaceId: UUID + ) -> Bool { + guard let executable = arguments.arguments.first, + (executable as NSString).lastPathComponent == "cmux", + registeredArguments.contains(Array(arguments.arguments.dropFirst())) else { + return false + } + return arguments.matchesCMUXScope(workspaceId: workspaceId, surfaceId: surfaceId) + } +} diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index a1904a2e804d..2bede46ea3e1 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -159,6 +159,14 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { includesCMUXScope } + /// The controlling terminal in `proc_bsdinfo.e_tdev`, or nil when there is + /// none. A process without one, such as a detached hook, reports `NODEV` + /// (all bits set), which is not a device every such process shares. + static func controllingTTYDevice(_ rawDevice: UInt32) -> Int64? { + guard rawDevice != 0, rawDevice != UInt32.max else { return nil } + return Int64(rawDevice) + } + private static func sortedMemorySources( in sources: [CmuxTopProcessMemorySource] ) -> [CmuxTopProcessMemorySource] { @@ -264,11 +272,16 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return result } + /// - Parameter isRegisteredCmuxHelper: whether a process runs one of + /// cmux's registered agent helpers (``CmuxAgentHelperProcess``) for this + /// panel. Such a helper is terminated with the agent although it has no + /// terminal. func agentHibernationProcessScope( panelProcessIDs: Set, - agentProcessIDs: Set + agentProcessIDs: Set, + isRegisteredCmuxHelper: (CmuxTopProcessInfo) -> Bool = { _ in false } ) -> RestorableAgentSessionIndex.HibernationProcessScope { - guard enumerationIsComplete else { return ([], [], true) } + guard enumerationIsComplete else { return ([], [], true, []) } let maximumProcessCount = AgentHibernationController.maximumScopedProcessTerminationCount func appendBounded( _ processIDs: S, @@ -326,7 +339,8 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return ( boundedPanelProcessIDs, boundedTerminationProcessIDs, - true + true, + [] ) } @@ -359,7 +373,8 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return ( boundedObservedPanelProcessIDs, boundedTerminationProcessIDs, - true + true, + [] ) } @@ -371,7 +386,8 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return ( observedPanelProcessIDs, descendantCollector.values, - true + true, + [] ) } let descendantProcessIDs = descendantCollector.values @@ -412,7 +428,8 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return ( observedPanelProcessIDs, boundedTerminationProcessIDs, - true + true, + [] ) } @@ -442,17 +459,26 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return ( observedPanelProcessIDs, boundedTerminationProcessIDs, - true + true, + [] ) } let processGroupMemberIDs = boundedProcessGroupMemberIDs let terminationProcessIDs = boundedTerminationProcessIDs - let terminationTTYDevices = terminationProcessIDs.compactMap { + // A registered cmux helper is detached by design. Everything else the + // termination reaches must share the agent's one terminal. + let cmuxHelperProcessIDs = cmuxAgentHelperProcessIDs( + agentRootPIDs: boundedAgentRoots, + descendantProcessIDs: descendantProcessIDs, + isRegisteredCmuxHelper: isRegisteredCmuxHelper + ) + let terminalProcessIDs = terminationProcessIDs.subtracting(cmuxHelperProcessIDs) + let terminationTTYDevices = terminalProcessIDs.compactMap { processesByPID[$0]?.ttyDevice } - let hasCompleteTerminationTTYEvidence = terminationProcessIDs.isEmpty || + let hasCompleteTerminationTTYEvidence = terminalProcessIDs.isEmpty || ( - terminationTTYDevices.count == terminationProcessIDs.count && + terminationTTYDevices.count == terminalProcessIDs.count && Set(terminationTTYDevices).count == 1 ) @@ -479,10 +505,35 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { processGroupIDs.isEmpty || !hasCompleteProcessGroups || !processGroupMemberIDs.isSubset(of: boundedAllowedProcessIDs) || - !observedPanelProcessIDs.isSubset(of: boundedAllowedProcessIDs) + !observedPanelProcessIDs.isSubset(of: boundedAllowedProcessIDs), + cmuxHelperProcessIDs ) } + /// Direct children of `agentRootPIDs` that run a registered cmux helper and + /// are detached the way Claude Code starts an async hook: no controlling + /// terminal, alone in a process group they lead, and with no children. + /// Termination signals each one's own group, so nothing else is reached. + func cmuxAgentHelperProcessIDs( + agentRootPIDs: Set, + descendantProcessIDs: Set, + isRegisteredCmuxHelper: (CmuxTopProcessInfo) -> Bool + ) -> Set { + descendantProcessIDs.filter { processID in + guard !agentRootPIDs.contains(processID), + let process = processesByPID[processID], + agentRootPIDs.contains(process.parentPID), + process.ttyDevice == nil, + process.processGroupID == processID, + pidsByProcessGroupID[processID] == [processID], + childrenByParentPID[processID]?.isEmpty ?? true else { + return false + } + return isRegisteredCmuxHelper(process) + } + } + + func descendantPIDs(rootPID: Int, includeRoot: Bool = false) -> Set { guard rootPID > 0 else { return [] } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 2099b95b44b5..750e0b82b382 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1073,6 +1073,9 @@ struct RestorableAgentSessionIndex: Sendable { let terminationProcessIDs: Set let terminationProcessIdentities: [Int: AgentPIDProcessIdentity] let containsUnrelatedProcess: Bool + /// Registered cmux helpers among `terminationProcessIDs`; see + /// ``CmuxAgentHelperProcess``. They carry no terminal. + let cmuxHelperProcessIDs: Set /// Keeps older in-process fixtures source-compatible while callers that /// have persisted PID evidence can opt in explicitly. @@ -1089,7 +1092,8 @@ struct RestorableAgentSessionIndex: Sendable { hibernationPanelProcessIDs: Set, terminationProcessIDs: Set, terminationProcessIdentities: [Int: AgentPIDProcessIdentity], - containsUnrelatedProcess: Bool + containsUnrelatedProcess: Bool, + cmuxHelperProcessIDs: Set = [] ) { self.snapshot = snapshot self.lifecycle = lifecycle @@ -1104,6 +1108,7 @@ struct RestorableAgentSessionIndex: Sendable { self.terminationProcessIDs = terminationProcessIDs self.terminationProcessIdentities = terminationProcessIdentities self.containsUnrelatedProcess = containsUnrelatedProcess + self.cmuxHelperProcessIDs = cmuxHelperProcessIDs } } @@ -1125,7 +1130,8 @@ struct RestorableAgentSessionIndex: Sendable { typealias HibernationProcessScope = ( panelProcessIDs: Set, terminationProcessIDs: Set, - containsUnrelatedProcess: Bool + containsUnrelatedProcess: Bool, + cmuxHelperProcessIDs: Set ) /// The panel's cmux-scoped processes and the fresh process-tree scope /// around a live hook-recorded agent PID. @@ -1713,7 +1719,8 @@ struct RestorableAgentSessionIndex: Sendable { currentPanelProcessIDs.contains($0.key) }, containsUnrelatedProcess: (processLiveness == .running && entry.containsUnrelatedProcess) || - presentMismatchedProcess + presentMismatchedProcess, + cmuxHelperProcessIDs: entry.cmuxHelperProcessIDs.intersection(currentPanelProcessIDs) ) } @@ -2264,7 +2271,8 @@ struct RestorableAgentSessionIndex: Sendable { for: hookScope.scope.terminationProcessIDs, processIdentityProvider: processIdentityForRecord ), - containsUnrelatedProcess: hookScope.scope.containsUnrelatedProcess + containsUnrelatedProcess: hookScope.scope.containsUnrelatedProcess, + cmuxHelperProcessIDs: hookScope.scope.cmuxHelperProcessIDs ) } else { entry = Entry( @@ -2339,7 +2347,7 @@ struct RestorableAgentSessionIndex: Sendable { processIdentityProvider: processIdentityProvider ) // Detection establishes liveness, not exclusive ownership of a complete process tree. - let hibernationScope = hibernationProcessScopes[key] ?? ([], [], true) + let hibernationScope = hibernationProcessScopes[key] ?? ([], [], true, []) let terminationProcessIdentities = Self.processIdentities( for: hibernationScope.terminationProcessIDs, processIdentityProvider: processIdentityProvider @@ -2357,7 +2365,8 @@ struct RestorableAgentSessionIndex: Sendable { hibernationPanelProcessIDs: hibernationScope.panelProcessIDs, terminationProcessIDs: hibernationScope.terminationProcessIDs, terminationProcessIdentities: terminationProcessIdentities, - containsUnrelatedProcess: hibernationScope.containsUnrelatedProcess + containsUnrelatedProcess: hibernationScope.containsUnrelatedProcess, + cmuxHelperProcessIDs: hibernationScope.cmuxHelperProcessIDs ) } diff --git a/Sources/RestorableAgentSessionIndex+ProcessCensus.swift b/Sources/RestorableAgentSessionIndex+ProcessCensus.swift index f1af9f76193a..c1e22803763d 100644 --- a/Sources/RestorableAgentSessionIndex+ProcessCensus.swift +++ b/Sources/RestorableAgentSessionIndex+ProcessCensus.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import Foundation /// Process-backed index loading shares census ownership and preserves cancellation. @@ -17,10 +18,20 @@ extension RestorableAgentSessionIndex { fileManager: fileManager ) } + /// Manual hibernation, memory-pressure reclaim and the teardown's final + /// revalidation read this index, so it scopes hook-backed agents exactly + /// as ``SharedLiveAgentIndexLoader`` does. static func loadIncludingProcessDetectedSnapshotsSynchronously( processSnapshot: CmuxTopProcessSnapshot, homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default + fileManager: FileManager = .default, + processArgumentsProvider: @escaping (Int) -> CmuxTopProcessArguments? = { + CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) + }, + processIdentityProvider: @escaping (Int) -> AgentPIDProcessIdentity? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + return AgentPIDProcessIdentity(pid: pid_t($0)) + } ) -> RestorableAgentSessionIndex { guard processSnapshot.captureIsAvailable, processSnapshot.enumerationIsComplete, !Task.isCancelled else { return .unavailable } let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) @@ -28,7 +39,8 @@ extension RestorableAgentSessionIndex { registry: registry, fileManager: fileManager, processSnapshot: processSnapshot, - capturedAt: processSnapshot.sampledAt.timeIntervalSince1970 + capturedAt: processSnapshot.sampledAt.timeIntervalSince1970, + processArgumentsProvider: processArgumentsProvider ) let hibernationProcessScopes = detectedSnapshots.mapValues { detected in processSnapshot.agentHibernationProcessScope( @@ -41,7 +53,13 @@ extension RestorableAgentSessionIndex { fileManager: fileManager, registry: registry, detectedSnapshots: detectedSnapshots, - hibernationProcessScopes: hibernationProcessScopes + hibernationProcessScopes: hibernationProcessScopes, + hookProcessScopeProvider: SharedLiveAgentIndexLoader.hookProcessScopeProvider( + processSnapshot: processSnapshot, + processArgumentsProvider: processArgumentsProvider + ), + processArgumentsProvider: processArgumentsProvider, + processIdentityProvider: processIdentityProvider ) } } diff --git a/Sources/SharedLiveAgentIndexLoader.swift b/Sources/SharedLiveAgentIndexLoader.swift index 3ca29a8157b0..f71ccd9a29a7 100644 --- a/Sources/SharedLiveAgentIndexLoader.swift +++ b/Sources/SharedLiveAgentIndexLoader.swift @@ -87,28 +87,16 @@ struct SharedLiveAgentIndexLoader { agentProcessIDs: detected.agentProcessIDs ) } - // Hook-backed agents such as Claude Code are never process-detected, - // so scope their recorded PID against the same process snapshot. - let scopedProcessIDsByPanelKey = processSnapshot.cmuxScopedProcessIDsByPanelKey() let index = RestorableAgentSessionIndex.load( homeDirectory: homeDirectory, fileManager: fileManager, registry: resolvedRegistry, detectedSnapshots: detectedSnapshots, hibernationProcessScopes: hibernationProcessScopes, - hookProcessScopeProvider: { key, agentProcessID in - guard let panelProcessIDs = scopedProcessIDsByPanelKey[key], - panelProcessIDs.contains(agentProcessID) else { - return nil - } - return ( - processIDs: panelProcessIDs, - scope: processSnapshot.agentHibernationProcessScope( - panelProcessIDs: panelProcessIDs, - agentProcessIDs: [agentProcessID] - ) - ) - }, + hookProcessScopeProvider: Self.hookProcessScopeProvider( + processSnapshot: processSnapshot, + processArgumentsProvider: processArgumentsProvider + ), processArgumentsProvider: processArgumentsProvider, processIdentityProvider: processIdentityProvider ) @@ -129,6 +117,41 @@ struct SharedLiveAgentIndexLoader { ) } + /// Hook-backed agents such as Claude Code are never process-detected, so + /// scope a live recorded PID against the same process snapshot: the + /// panel's cmux-scoped processes must include it. Every index that gates + /// hibernation needs this provider; without it a live hook record stays + /// unsafe to reclaim. + static func hookProcessScopeProvider( + processSnapshot: CmuxTopProcessSnapshot, + processArgumentsProvider: @escaping (Int) -> CmuxTopProcessArguments? + ) -> (RestorableAgentSessionIndex.PanelKey, Int) -> RestorableAgentSessionIndex.HookProcessScope? { + let scopedProcessIDsByPanelKey = processSnapshot.cmuxScopedProcessIDsByPanelKey() + return { key, agentProcessID in + guard let panelProcessIDs = scopedProcessIDsByPanelKey[key], + panelProcessIDs.contains(agentProcessID) else { + return nil + } + return ( + processIDs: panelProcessIDs, + scope: processSnapshot.agentHibernationProcessScope( + panelProcessIDs: panelProcessIDs, + agentProcessIDs: [agentProcessID], + isRegisteredCmuxHelper: { process in + guard let arguments = processArgumentsProvider(process.pid) else { + return false + } + return CmuxAgentHelperProcess.isRegistered( + arguments, + workspaceId: key.workspaceId, + surfaceId: key.panelId + ) + } + ) + ) + } + } + static func processScopeFingerprint(from snapshot: CmuxTopProcessSnapshot) -> Set { Set(snapshot.cmuxScopedProcesses().map { process in [ @@ -158,6 +181,7 @@ struct SharedLiveAgentIndexLoader { key.panelId.uuidString, boundedProcessIDFingerprint(scope.panelProcessIDs), boundedProcessIDFingerprint(scope.terminationProcessIDs), + boundedProcessIDFingerprint(scope.cmuxHelperProcessIDs), scope.containsUnrelatedProcess ? "unrelated" : "exclusive" ].joined(separator: "|") }) @@ -200,6 +224,7 @@ struct SharedLiveAgentIndexLoader { key.workspaceId.uuidString, key.panelId.uuidString, boundedProcessIDFingerprint(entry.terminationProcessIDs), + boundedProcessIDFingerprint(entry.cmuxHelperProcessIDs), identities ].joined(separator: "|") }) diff --git a/cmuxTests/AgentHibernationProcessSignalBoundaryTests.swift b/cmuxTests/AgentHibernationProcessSignalBoundaryTests.swift index a430cd4f1b5a..69dbd702af11 100644 --- a/cmuxTests/AgentHibernationProcessSignalBoundaryTests.swift +++ b/cmuxTests/AgentHibernationProcessSignalBoundaryTests.swift @@ -105,6 +105,128 @@ struct AgentHibernationProcessSignalBoundaryTests { #expect(signaledTargets.withLock { $0 }.isEmpty) } + private nonisolated static let agentIdentity = AgentPIDProcessIdentity( + pid: 101, + startSeconds: 10, + startMicroseconds: 1 + ) + private nonisolated static let helperIdentity = AgentPIDProcessIdentity( + pid: 202, + startSeconds: 20, + startMicroseconds: 2 + ) + /// `cmux hooks claude inbox-wait`, detached by Claude Code into its own + /// process group with no terminal. + private nonisolated static let helperArguments = CmuxTopProcessArguments( + arguments: ["/Applications/cmux.app/Contents/Resources/bin/cmux", "hooks", "claude", "inbox-wait"], + environment: signalScopeArguments.environment + ) + private nonisolated static let agentAndHelperTerminations: [AgentHibernationController.ScopedProcessTermination] = [ + .init(processID: 101, processIdentity: agentIdentity, processGroupID: 101, ttyDevice: 123), + .init(processID: 202, processIdentity: helperIdentity, processGroupID: 202, ttyDevice: nil, isCmuxHelper: true), + ] + + /// Signals the agent's own group and the helper's, and nothing else. + private func terminateAgentAndHelper( + _ terminations: [AgentHibernationController.ScopedProcessTermination] = + AgentHibernationProcessSignalBoundaryTests.agentAndHelperTerminations, + helperArguments: CmuxTopProcessArguments = + AgentHibernationProcessSignalBoundaryTests.helperArguments, + helperTTYDevice: Int64? = nil, + signaledTargets: OSAllocatedUnfairLock<[pid_t]> + ) async -> AgentHibernationController.ScopedProcessTerminationResult { + await AgentHibernationController.terminateScopedProcessesForHibernation( + terminations, + processScopeKey: Self.signalScopeKey, + currentProcessID: 999, + currentProcessGroupID: 999, + processIdentityProvider: { $0 == 101 ? Self.agentIdentity : Self.helperIdentity }, + processGroupProvider: { $0 }, + processArgumentsProvider: { $0 == 101 ? Self.signalScopeArguments : helperArguments }, + processTTYDeviceProvider: { $0 == 101 ? 123 : helperTTYDevice }, + signalErrorProvider: { target, _ in + signaledTargets.withLock { $0.append(target) } + return nil + } + ) + } + + @MainActor + @Test + func signalsARegisteredHelperBesideItsAgent() async { + let signaledTargets = OSAllocatedUnfairLock(initialState: [pid_t]()) + + let result = await terminateAgentAndHelper(signaledTargets: signaledTargets) + + #expect(result == .committedAwaitingExit) + #expect(signaledTargets.withLock { $0 } == [-101, -202]) + #expect( + AgentHibernationController.commonTTYDevice(in: Self.agentAndHelperTerminations) == 123 + ) + } + + @MainActor + @Test + func rejectsAHelperWhoseOwnershipEvidenceChanged() async { + let signaledTargets = OSAllocatedUnfairLock(initialState: [pid_t]()) + let otherCommand = CmuxTopProcessArguments( + arguments: ["/usr/bin/python3", "-m", "http.server"], + environment: Self.signalScopeArguments.environment + ) + let otherSurface = CmuxTopProcessArguments( + arguments: Self.helperArguments.arguments, + environment: [ + "CMUX_WORKSPACE_ID": Self.signalScopeKey.workspaceId.uuidString, + "CMUX_SURFACE_ID": UUID().uuidString, + ] + ) + // Detached work that was never scoped as a helper. + let unregistered: [AgentHibernationController.ScopedProcessTermination] = [ + Self.agentAndHelperTerminations[0], + .init(processID: 202, processIdentity: Self.helperIdentity, processGroupID: 202, ttyDevice: nil), + ] + + let changedCommand = await terminateAgentAndHelper( + helperArguments: otherCommand, + signaledTargets: signaledTargets + ) + let changedSurface = await terminateAgentAndHelper( + helperArguments: otherSurface, + signaledTargets: signaledTargets + ) + let gainedTerminal = await terminateAgentAndHelper( + helperTTYDevice: 123, + signaledTargets: signaledTargets + ) + let neverScoped = await terminateAgentAndHelper( + unregistered, + signaledTargets: signaledTargets + ) + + #expect(changedCommand == .rejected) + #expect(changedSurface == .rejected) + #expect(gainedTerminal == .rejected) + #expect(neverScoped == .rejected) + #expect(signaledTargets.withLock { $0 }.isEmpty) + } + + @Test + func helperScopeCarriesIntoValidatedTerminations() { + let terminations = AgentHibernationController.validatedScopedProcessTerminations( + for: .init( + key: Self.signalScopeKey, + processIDs: [101, 202], + processIdentities: [101: Self.agentIdentity, 202: Self.helperIdentity], + cmuxHelperProcessIDs: [202] + ), + processIdentityProvider: { $0 == 101 ? Self.agentIdentity : Self.helperIdentity }, + processGroupProvider: { pid_t($0) }, + processTTYDeviceProvider: { $0 == 101 ? 123 : nil } + ) + + #expect(terminations == Self.agentAndHelperTerminations.sorted { $0.processID > $1.processID }) + } + @MainActor @Test func rejectsTTYChangeBeforeFinalCommit() async { diff --git a/cmuxTests/AgentHibernationProcessSnapshotCoordinatorTests.swift b/cmuxTests/AgentHibernationProcessSnapshotCoordinatorTests.swift index 477a2b1aa561..f63073a75104 100644 --- a/cmuxTests/AgentHibernationProcessSnapshotCoordinatorTests.swift +++ b/cmuxTests/AgentHibernationProcessSnapshotCoordinatorTests.swift @@ -193,4 +193,55 @@ struct AgentHibernationProcessSnapshotCoordinatorTests { #expect(epoch == nil) } + /// A Claude inbox helper still alive after the agent exited keeps its + /// helper scope in the refreshed epoch: it is detached, so it has no + /// terminal to match on the escalation or retry path. + @Test(arguments: [ + ["/Applications/cmux.app/Contents/Resources/bin/cmux", "hooks", "claude", "inbox-wait"], + ["/usr/bin/python3", "-m", "http.server"], + ]) + func refreshedEpochKeepsOnlyARegisteredHelperDetached(arguments: [String]) async throws { + let scopeKey = AgentHibernationPanelKey(workspaceId: UUID(), panelId: UUID()) + let helper = AgentPIDProcessIdentity(pid: 202, startSeconds: 20, startMicroseconds: 2) + let snapshot = CmuxTopProcessSnapshot( + processes: [ + CmuxTopProcessInfo( + pid: 202, processIdentity: helper, parentPID: 1, + name: "cmux", path: nil, ttyDevice: nil, cmuxWorkspaceID: nil, + cmuxSurfaceID: nil, cmuxAttributionReason: nil, processGroupID: 202, + terminalProcessGroupID: nil, cpuPercent: 0, residentBytes: 0, + virtualBytes: 0, threadCount: 0 + ), + ], + sampledAt: .now, includesProcessDetails: false, includesCMUXScope: false + ) + let coordinator = AgentHibernationProcessSnapshotCoordinator( + captureSnapshot: { snapshot }, + processArgumentsProvider: { _ in + CmuxTopProcessArguments( + arguments: arguments, + environment: [ + "CMUX_WORKSPACE_ID": scopeKey.workspaceId.uuidString, + "CMUX_SURFACE_ID": scopeKey.panelId.uuidString, + ] + ) + }, + processIdentityProvider: { _ in helper }, + processGroupProvider: { _ in 202 } + ) + + let epoch = try #require(await coordinator.refreshedExitEpoch( + processGroupLeaders: [202: helper], processScopeKey: scopeKey, + ttyDevice: 42, excluding: [] + )) + + let isRegistered = arguments.dropFirst() == ["hooks", "claude", "inbox-wait"] + #expect(epoch.terminations == [ + .init( + processID: 202, processIdentity: helper, processGroupID: 202, + ttyDevice: nil, isCmuxHelper: isRegistered + ), + ]) + #expect(epoch.signalableProcessIdentities == [helper]) + } } diff --git a/cmuxTests/AgentHibernationTerminationFailureTests.swift b/cmuxTests/AgentHibernationTerminationFailureTests.swift index a38d4415c4a7..1525018ea608 100644 --- a/cmuxTests/AgentHibernationTerminationFailureTests.swift +++ b/cmuxTests/AgentHibernationTerminationFailureTests.swift @@ -483,7 +483,8 @@ struct AgentHibernationTerminationFailureTests { sourceKey: ( panelProcessIDs: [processID], terminationProcessIDs: [processID], - containsUnrelatedProcess: false + containsUnrelatedProcess: false, + cmuxHelperProcessIDs: [] ), ], processIdentityProvider: { requestedProcessID in diff --git a/cmuxTests/ClaudeHookSessionLivenessTests.swift b/cmuxTests/ClaudeHookSessionLivenessTests.swift index 0726bf758344..dddd7ee2e265 100644 --- a/cmuxTests/ClaudeHookSessionLivenessTests.swift +++ b/cmuxTests/ClaudeHookSessionLivenessTests.swift @@ -318,32 +318,275 @@ struct ClaudeHookSessionLivenessTests { #expect(!entry.processSafetyAllowsScheduledHibernation) } + /// The two indexes that gate hibernation: the shared live index, and the + /// process census that manual hibernation, memory-pressure reclaim and + /// the teardown's final revalidation load. + enum HibernationIndexLoader: String, CaseIterable, Sendable { + case shared + case processCensus + } + + /// cmux's Claude wrapper starts `cmux hooks claude inbox-wait` on + /// SessionStart and Stop. Claude Code runs it detached: a child of the + /// agent that leads its own session and process group, with no terminal. + enum DetachedWork: String, CaseIterable, Sendable { + /// The registered helper, but scoped to another surface. + case helperForAnotherSurface + /// A cmux hook the wrapper runs but that does work, so it is not registered. + case unregisteredCmuxHook + /// A user's background job, detached the same way. + case userJob + /// The registered helper argv, but in the agent's process group. + case helperInAgentProcessGroup + /// The registered helper argv, but with a child of its own. + case helperWithChild + } + + /// Every live Claude used to refuse manual hibernation with + /// `process_scope_unsafe`: the census index left the hook PID unscoped. + @Test( + "A live Claude alone in its pane is safe to hibernate from every index", + arguments: HibernationIndexLoader.allCases + ) + func claudeAloneInItsPaneIsSafeFromEveryIndex(loader: HibernationIndexLoader) throws { + let fixture = try makeFixture(prefix: "cmux-claude-hook-scope-\(loader.rawValue)") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let shellPID = 8_000 + let agentPID = 8_001 + let identity = AgentPIDProcessIdentity( + pid: pid_t(agentPID), + startSeconds: 1_790_627_504, + startMicroseconds: 891_303 + ) + try writeHookRecord(fixture: fixture, identity: identity) + let index = loadIndex( + fixture: fixture, + loader: loader, + processes: [ + processInfo(fixture: fixture, pid: shellPID, parentPID: 1, name: "zsh", path: "/bin/zsh"), + processInfo( + fixture: fixture, + pid: agentPID, + parentPID: shellPID, + name: "claude", + path: fixture.executablePath, + processGroupID: agentPID + ), + ], + agentPID: agentPID, + identities: [agentPID: identity] + ) + + let entry = try #require(index.entry(workspaceId: fixture.workspaceId, panelId: fixture.panelId)) + #expect(entry.processLiveness == .running) + #expect(entry.terminationProcessIDs == [agentPID]) + #expect(entry.cmuxHelperProcessIDs.isEmpty) + #expect(entry.containsUnrelatedProcess == false) + #expect(entry.processSafetyAllowsScheduledHibernation) + } + + @Test( + "A wrapped Claude's detached inbox helper is terminated with it", + arguments: HibernationIndexLoader.allCases + ) + func wrappedClaudeInboxHelperIsTerminatedWithIt(loader: HibernationIndexLoader) throws { + let fixture = try makeFixture(prefix: "cmux-claude-hook-helper-\(loader.rawValue)") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let shellPID = 8_100 + let agentPID = 8_101 + let helperPID = 8_102 + let identity = AgentPIDProcessIdentity( + pid: pid_t(agentPID), + startSeconds: 1_790_627_504, + startMicroseconds: 891_303 + ) + let helperIdentity = AgentPIDProcessIdentity( + pid: pid_t(helperPID), + startSeconds: 1_790_627_560, + startMicroseconds: 0 + ) + try writeHookRecord(fixture: fixture, identity: identity) + let index = loadIndex( + fixture: fixture, + loader: loader, + processes: [ + processInfo(fixture: fixture, pid: shellPID, parentPID: 1, name: "zsh", path: "/bin/zsh"), + processInfo( + fixture: fixture, + pid: agentPID, + parentPID: shellPID, + name: "claude", + path: fixture.executablePath, + processGroupID: agentPID + ), + processInfo( + fixture: fixture, + pid: helperPID, + parentPID: agentPID, + name: "cmux", + path: Self.bundledCLIPath, + processGroupID: helperPID, + ttyDevice: nil + ), + ], + agentPID: agentPID, + identities: [agentPID: identity, helperPID: helperIdentity], + otherArguments: [ + helperPID: CmuxTopProcessArguments( + arguments: Self.inboxWaitArguments, + environment: liveEnvironment(fixture: fixture) + ), + ] + ) + + let entry = try #require(index.entry(workspaceId: fixture.workspaceId, panelId: fixture.panelId)) + #expect(entry.processLiveness == .running) + #expect(entry.terminationProcessIDs == [agentPID, helperPID]) + #expect(Set(entry.terminationProcessIdentities.keys) == [agentPID, helperPID]) + #expect(entry.cmuxHelperProcessIDs == [helperPID]) + #expect(entry.containsUnrelatedProcess == false) + #expect(entry.processSafetyAllowsScheduledHibernation) + } + + @Test( + "Detached work beside a Claude that is not its registered helper keeps it unsafe", + arguments: DetachedWork.allCases, HibernationIndexLoader.allCases + ) + func detachedWorkBesideClaudeKeepsItUnsafe( + work: DetachedWork, + loader: HibernationIndexLoader + ) throws { + let fixture = try makeFixture(prefix: "cmux-claude-hook-detached-\(work.rawValue)") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let shellPID = 8_200 + let agentPID = 8_201 + let detachedPID = 8_202 + let grandchildPID = 8_203 + let identity = AgentPIDProcessIdentity( + pid: pid_t(agentPID), + startSeconds: 1_790_627_504, + startMicroseconds: 891_303 + ) + try writeHookRecord(fixture: fixture, identity: identity) + var environment = liveEnvironment(fixture: fixture) + var arguments = Self.inboxWaitArguments + var processGroupID = detachedPID + switch work { + case .helperForAnotherSurface: + environment["CMUX_SURFACE_ID"] = UUID().uuidString + environment["CMUX_PANEL_ID"] = environment["CMUX_SURFACE_ID"] + case .unregisteredCmuxHook: + arguments = [Self.bundledCLIPath, "hooks", "claude", "auto-name"] + case .userJob: + arguments = ["/usr/bin/python3", "-m", "http.server"] + case .helperInAgentProcessGroup: + processGroupID = agentPID + case .helperWithChild: + break + } + var processes = [ + processInfo(fixture: fixture, pid: shellPID, parentPID: 1, name: "zsh", path: "/bin/zsh"), + processInfo( + fixture: fixture, + pid: agentPID, + parentPID: shellPID, + name: "claude", + path: fixture.executablePath, + processGroupID: agentPID + ), + processInfo( + fixture: fixture, + pid: detachedPID, + parentPID: agentPID, + name: (arguments[0] as NSString).lastPathComponent, + path: arguments[0], + processGroupID: processGroupID, + ttyDevice: nil + ), + ] + if work == .helperWithChild { + processes.append(processInfo( + fixture: fixture, + pid: grandchildPID, + parentPID: detachedPID, + name: "sleep", + path: "/bin/sleep", + processGroupID: detachedPID, + ttyDevice: nil + )) + } + let index = loadIndex( + fixture: fixture, + loader: loader, + processes: processes, + agentPID: agentPID, + identities: [ + agentPID: identity, + detachedPID: AgentPIDProcessIdentity( + pid: pid_t(detachedPID), startSeconds: 1_790_627_560, startMicroseconds: 0 + ), + grandchildPID: AgentPIDProcessIdentity( + pid: pid_t(grandchildPID), startSeconds: 1_790_627_561, startMicroseconds: 0 + ), + ], + otherArguments: [ + detachedPID: CmuxTopProcessArguments(arguments: arguments, environment: environment), + ] + ) + + let entry = try #require(index.entry(workspaceId: fixture.workspaceId, panelId: fixture.panelId)) + #expect(entry.processLiveness == .running) + #expect(entry.cmuxHelperProcessIDs.isEmpty) + #expect(entry.containsUnrelatedProcess) + #expect(!entry.processSafetyAllowsScheduledHibernation) + } + + private static let bundledCLIPath = "/Applications/cmux.app/Contents/Resources/bin/cmux" + private static let inboxWaitArguments = [bundledCLIPath, "hooks", "claude", "inbox-wait"] + private func loadIndex( fixture: Fixture, + loader: HibernationIndexLoader = .shared, processes: [CmuxTopProcessInfo], agentPID: Int, - identities: [Int: AgentPIDProcessIdentity] + identities: [Int: AgentPIDProcessIdentity], + otherArguments: [Int: CmuxTopProcessArguments] = [:] ) -> RestorableAgentSessionIndex { let processSnapshot = CmuxTopProcessSnapshot( processes: processes, sampledAt: Date(timeIntervalSince1970: fixture.capturedAt), includesProcessDetails: true ) - return SharedLiveAgentIndexLoader( - homeDirectory: fixture.root.path, - fileManager: .default, - registry: CmuxVaultAgentRegistry(registrations: []), - processSnapshotProvider: { processSnapshot }, - capturedAtProvider: { fixture.capturedAt }, - processArgumentsProvider: { pid in - guard pid == agentPID else { return nil } - return CmuxTopProcessArguments( - arguments: liveArguments(fixture: fixture), - environment: liveEnvironment(fixture: fixture) - ) - }, - processIdentityProvider: { pid in identities[pid] } - ).loadSynchronously() + let agentArguments = CmuxTopProcessArguments( + arguments: liveArguments(fixture: fixture), + environment: liveEnvironment(fixture: fixture) + ) + let processArgumentsProvider: (Int) -> CmuxTopProcessArguments? = { pid in + pid == agentPID ? agentArguments : otherArguments[pid] + } + switch loader { + case .shared: + return SharedLiveAgentIndexLoader( + homeDirectory: fixture.root.path, + fileManager: .default, + registry: CmuxVaultAgentRegistry(registrations: []), + processSnapshotProvider: { processSnapshot }, + capturedAtProvider: { fixture.capturedAt }, + processArgumentsProvider: processArgumentsProvider, + processIdentityProvider: { pid in identities[pid] } + ).loadSynchronously() + case .processCensus: + return RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshotsSynchronously( + processSnapshot: processSnapshot, + homeDirectory: fixture.root.path, + fileManager: .default, + processArgumentsProvider: processArgumentsProvider, + processIdentityProvider: { pid in identities[pid] } + ) + } } private func makeFixture(prefix: String) throws -> Fixture { @@ -420,7 +663,8 @@ struct ClaudeHookSessionLivenessTests { parentPID: Int, name: String, path: String, - processGroupID: Int? = nil + processGroupID: Int? = nil, + ttyDevice: Int64? = 0x123 ) -> CmuxTopProcessInfo { let resolvedProcessGroupID = processGroupID ?? (parentPID == 1 ? pid : parentPID) return CmuxTopProcessInfo( @@ -428,7 +672,7 @@ struct ClaudeHookSessionLivenessTests { parentPID: parentPID, name: name, path: path, - ttyDevice: 0x123, + ttyDevice: ttyDevice, cmuxWorkspaceID: fixture.workspaceId, cmuxSurfaceID: fixture.panelId, cmuxAttributionReason: "cmux-test", diff --git a/cmuxTests/CmuxTopProcessTreeTests.swift b/cmuxTests/CmuxTopProcessTreeTests.swift index 5477a94cf8fb..6200f1b4724e 100644 --- a/cmuxTests/CmuxTopProcessTreeTests.swift +++ b/cmuxTests/CmuxTopProcessTreeTests.swift @@ -8,6 +8,14 @@ import Testing #endif struct CmuxTopProcessTreeTests { + /// `e_tdev` is `NODEV` for a process with no controlling terminal; read + /// as a device, every detached process on the Mac would share one TTY. + @Test func noControllingTerminalIsNoTTYDevice() { + #expect(CmuxTopProcessSnapshot.controllingTTYDevice(UInt32.max) == nil) + #expect(CmuxTopProcessSnapshot.controllingTTYDevice(0) == nil) + #expect(CmuxTopProcessSnapshot.controllingTTYDevice(268_435_473) == 268_435_473) + } + @Test func deepProcessChainIsSplitIntoStackSafeSegments() throws { let processCount = 96 let firstPID = 920_000