Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Sources/AgentDeliveryTargetResolution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ nonisolated func agentLiveProcessIdentity(pid: pid_t) -> (ttyDevice: Int64?, sco
let expectedSize = MemoryLayout<proc_bsdinfo>.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 {
Expand Down
23 changes: 11 additions & 12 deletions Sources/App/AgentHibernationController+ProcessExitWaiting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
22 changes: 8 additions & 14 deletions Sources/App/AgentHibernationController+ProcessSignaling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 =
Expand Down
69 changes: 65 additions & 4 deletions Sources/App/AgentHibernationController+ProcessTermination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ extension AgentHibernationRecord {
AgentHibernationController.ProcessTerminationScope(
key: key,
processIDs: processIDs,
processIdentities: processIdentities
processIdentities: processIdentities,
cmuxHelperProcessIDs: cmuxHelperProcessIDs
)
}
}
Expand All @@ -22,24 +23,72 @@ extension AgentHibernationController {
let key: AgentHibernationPanelKey
let processIDs: Set<Int>
let processIdentities: [Int: AgentPIDProcessIdentity]
let cmuxHelperProcessIDs: Set<Int>

init(
key: AgentHibernationPanelKey,
processIDs: Set<Int>,
processIdentities: [Int: AgentPIDProcessIdentity],
cmuxHelperProcessIDs: Set<Int> = []
) {
self.key = key
self.processIDs = processIDs
self.processIdentities = processIdentities
self.cmuxHelperProcessIDs = cmuxHelperProcessIDs
}
}

struct ScopedProcessTermination: Equatable, Sendable {
let processID: Int
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
)
}
}

Expand Down Expand Up @@ -124,7 +173,8 @@ extension AgentHibernationController {
processID: processID,
processIdentity: expectedIdentity,
processGroupID: processGroupProvider(processID),
ttyDevice: processTTYDeviceProvider(processID)
ttyDevice: processTTYDeviceProvider(processID),
isCmuxHelper: scope.cmuxHelperProcessIDs.contains(processID)
)
)
}
Expand Down Expand Up @@ -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 &&
Expand Down
13 changes: 9 additions & 4 deletions Sources/App/AgentHibernationController+Records.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,7 +37,8 @@ extension AgentHibernationRecord {
!hasLiveProcess &&
panelProcessIDs.isEmpty &&
processIDs.isEmpty &&
processIdentities.isEmpty
processIdentities.isEmpty &&
cmuxHelperProcessIDs.isEmpty
case .running:
return hasPressureSafeProcessEvidence
case .unknown:
Expand All @@ -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
}
Expand Down Expand Up @@ -136,6 +140,7 @@ extension AppDelegate {
panelProcessIDs: processEntry?.hibernationPanelProcessIDs ?? [],
processIDs: processEntry?.terminationProcessIDs ?? [],
processIdentities: processEntry?.terminationProcessIdentities ?? [:],
cmuxHelperProcessIDs: processEntry?.cmuxHelperProcessIDs ?? [],
processLiveness: processEntry?.processLiveness ?? .unknown
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand All @@ -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 &&
Expand Down
4 changes: 4 additions & 0 deletions Sources/App/AgentHibernationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ struct AgentHibernationRecord {
let panelProcessIDs: Set<Int>
let processIDs: Set<Int>
let processIdentities: [Int: AgentPIDProcessIdentity]
/// Registered cmux helpers among `processIDs`, terminated without a terminal.
let cmuxHelperProcessIDs: Set<Int>
private(set) var processLiveness: RestorableAgentProcessLiveness = .unknown
init(
key: AgentHibernationPanelKey,
Expand All @@ -37,6 +39,7 @@ struct AgentHibernationRecord {
panelProcessIDs: Set<Int>,
processIDs: Set<Int>,
processIdentities: [Int: AgentPIDProcessIdentity],
cmuxHelperProcessIDs: Set<Int> = [],
processLiveness: RestorableAgentProcessLiveness = .unknown
) {
self.key = key
Expand All @@ -52,6 +55,7 @@ struct AgentHibernationRecord {
self.panelProcessIDs = panelProcessIDs
self.processIDs = processIDs
self.processIdentities = processIdentities
self.cmuxHelperProcessIDs = cmuxHelperProcessIDs
self.processLiveness = processLiveness
}
}
Expand Down
15 changes: 14 additions & 1 deletion Sources/App/AgentHibernationProcessSnapshotCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ actor AgentHibernationProcessSnapshotCoordinator {
return nil
}
var validatedIdentities: [Int: AgentPIDProcessIdentity] = [:]
var cmuxHelperProcessIDs: Set<Int> = []
if let processScopeKey {
guard observedProcessIDs.allSatisfy({ processID in
guard let process = snapshot.process(pid: processID),
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions Sources/CmuxTopProcessSampler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions Sources/CmuxTopSnapshot+AgentBackgroundWork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading