Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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: 5 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31289,6 +31289,7 @@ struct CMUXCLI {
sessionId: acceptedSessionId,
cwd: hookCwd,
launchCommand: launchCommand,
transcriptPath: hookTranscriptPath,
observedPermissionMode: observedHookPermissionMode
)
emitAgentJournalEvent(
Expand Down Expand Up @@ -31506,6 +31507,7 @@ struct CMUXCLI {
sessionId: sessionId,
cwd: hookCwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand,
transcriptPath: hookTranscriptPath,
observedPermissionMode: observedHookPermissionMode
?? mappedSession?.lastPermissionMode
)
Expand Down Expand Up @@ -31718,6 +31720,7 @@ struct CMUXCLI {
sessionId: sessionId,
cwd: hookCwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand,
transcriptPath: hookTranscriptPath,
observedPermissionMode: observedHookPermissionMode
?? mappedSession?.lastPermissionMode
)
Expand Down Expand Up @@ -35780,7 +35783,8 @@ struct CMUXCLI {
let resumeWorkingDirectory = AgentResumeWorkingDirectory().resolve(
kind: kind,
runtimeCwd: cwd,
launchWorkingDirectory: launchCommand?.workingDirectory
launchWorkingDirectory: launchCommand?.workingDirectory,
transcriptPath: transcriptPath
)
guard let command = agentSurfaceResumeCommand(
kind: kind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,51 @@ public struct AgentResumeWorkingDirectory: Sendable, Equatable {
/// - kind: the agent's raw kind identifier.
/// - runtimeCwd: the agent's last-reported runtime cwd (may have drifted).
/// - launchWorkingDirectory: the directory the agent was launched in.
/// - transcriptPath: The hook-reported transcript path, when available.
/// - Returns: the directory to `cd` into, or `nil` when neither input is usable.
public func resolve(
kind: String,
runtimeCwd: String?,
launchWorkingDirectory: String?
launchWorkingDirectory: String?,
transcriptPath: String? = nil
) -> String? {
let runtime = normalized(runtimeCwd)
let launch = normalized(launchWorkingDirectory)
switch cwdNamespacing(forKind: kind) {
case .cwdInFile:
return runtime ?? launch
case .byDirectory:
if kind == "claude",
let transcriptProject = transcriptProjectDirectoryName(transcriptPath),
let transcriptCwd = [launch, runtime].first(where: {
encodedProjectDirectoryName(forPath: $0) == transcriptProject
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}) {
return transcriptCwd
}
return launch ?? runtime
}
}

/// Returns the Claude project-directory component from a transcript path.
/// Claude stores transcripts below `<config>/projects/<encoded-cwd>/`; the
/// session file may be either directly in that directory or nested below it.
private func transcriptProjectDirectoryName(_ transcriptPath: String?) -> String? {
guard let rawPath = normalized(transcriptPath) else { return nil }
let components = (rawPath as NSString).expandingTildeInPath
.split(separator: "/", omittingEmptySubsequences: true)
.map(String.init)
guard let projectsIndex = components.firstIndex(of: "projects"),
projectsIndex + 1 < components.count else {
return nil
}
return components[projectsIndex + 1]
Comment thread
austinywang marked this conversation as resolved.
Resolved
}

private func encodedProjectDirectoryName(forPath path: String) -> String {
path.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ".", with: "-")
}
Comment thread
austinywang marked this conversation as resolved.
Resolved

private func normalized(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import Testing

@Suite("AgentResumeWorkingDirectory")
struct AgentResumeWorkingDirectoryTests {
@Test("Claude resumes from the worktree that owns its transcript", arguments: [false, true])
func claudeTranscriptOverridesStaleLaunchDirectory(nested: Bool) {
let sessionID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
let transcript = "/custom/claude/projects/-Users-x-repo--claude-worktrees-feature/"
+ (nested ? "\(sessionID)/messages/" : "") + "\(sessionID).jsonl"
#expect(AgentResumeWorkingDirectory().resolve(
kind: "claude",
runtimeCwd: "/Users/x/repo/.claude/worktrees/feature",
launchWorkingDirectory: "/Users/x/repo",
transcriptPath: transcript
) == "/Users/x/repo/.claude/worktrees/feature")
}

@Test("Directory-namespaced agents pin the launch cwd over a drifted runtime cwd")
func directoryNamespacedPrefersLaunch() {
#expect(
Expand Down
44 changes: 30 additions & 14 deletions Sources/RestorableAgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2219,9 +2219,13 @@ struct RestorableAgentSessionIndex: Sendable {
let liveProcessID = processObservation.processID
let liveProcessIdentities: [Int: AgentPIDProcessIdentity]
if let liveProcessID,
let recordedProcessIdentity,
Int(recordedProcessIdentity.pid) == liveProcessID {
liveProcessIdentities = [liveProcessID: recordedProcessIdentity]
let currentProcessIdentity,
Int(currentProcessIdentity.pid) == liveProcessID,
(recordedProcessIdentity == nil || recordedProcessIdentity == currentProcessIdentity) {
// Legacy hook records did not persist a process generation. Keep the
// generation observed during this scan so a later PID reuse can be
// rejected without treating an unrelated process as an uncertain owner.
liveProcessIdentities = [liveProcessID: currentProcessIdentity]
} else {
liveProcessIdentities = [:]
}
Expand Down Expand Up @@ -3627,17 +3631,22 @@ struct RestorableAgentSessionIndex: Sendable {
validator: CachedAgentProcessIdentityValidator,
hermesSessionValidation: CachedAgentProcessIdentityValidator.HermesSessionValidation = .cachedSnapshot
) -> RestorableAgentProcessMatch {
guard let recordedProcessIdentity,
Int(recordedProcessIdentity.pid) == processID else {
return processPresenceProvider(processID) == .absent ? .mismatches : .unknown
}
guard let currentProcessIdentity,
Int(currentProcessIdentity.pid) == processID else {
if let recordedProcessIdentity {
guard Int(recordedProcessIdentity.pid) == processID else {
return processPresenceProvider(processID) == .absent ? .mismatches : .unknown
}
guard let currentProcessIdentity,
Int(currentProcessIdentity.pid) == processID else {
return processPresenceProvider(processID) == .absent ? .mismatches : .unknown
}
guard currentProcessIdentity == recordedProcessIdentity else {
return .mismatches
}
} else if currentProcessIdentity == nil {
// A legacy record has no generation to compare. Without a current
// generation, only an absent PID is definitive.
Comment thread
austinywang marked this conversation as resolved.
Resolved
return processPresenceProvider(processID) == .absent ? .mismatches : .unknown
}
guard currentProcessIdentity == recordedProcessIdentity else {
return .mismatches
}
guard let process = processArgumentsProvider(processID) else {
// A present process may be temporarily uninspectable. Only ESRCH-grade
// absence proves that the recorded generation exited.
Expand All @@ -3646,11 +3655,18 @@ struct RestorableAgentSessionIndex: Sendable {
guard process.matchesCMUXScope(workspaceId: workspaceId, surfaceId: panelId) else {
return .mismatches
}
return validator.currentProcess(
let matchesExpectedProcess = validator.currentProcess(
process,
matches: snapshot,
hermesSessionValidation: hermesSessionValidation
) ? .matches : .mismatches
)
// A legacy record can reject a reused PID when its executable or
// session identity contradicts the snapshot, but a matching process
// still lacks a historical generation and cannot authenticate itself.
if recordedProcessIdentity == nil {
return matchesExpectedProcess ? .unknown : .mismatches
}
return matchesExpectedProcess ? .matches : .mismatches
}

private static func normalizedNonEmptyValue(_ value: String?) -> String? {
Expand Down
33 changes: 33 additions & 0 deletions cmuxTests/RestorableAgentProcessGenerationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,39 @@ struct RestorableAgentProcessGenerationTests {
)?.processLiveness == .running)
}

@Test("A legacy PID reused by another executable does not block restore")
func legacyPIDReusedByAnotherExecutableDoesNotBlockRestore() throws {
let fixture = try makeFixture(prefix: "cmux-legacy-pid-reuse")
defer { cleanup(fixture) }
let reusedIdentity = AgentPIDProcessIdentity(
pid: pid_t(fixture.processID),
startSeconds: Int64(fixture.updatedAt + 1),
startMicroseconds: 0
)
let reusedProcess = CmuxTopProcessArguments(
arguments: ["/usr/local/bin/claude"],
environment: [
"CMUX_WORKSPACE_ID": fixture.workspaceID.uuidString,
"CMUX_SURFACE_ID": fixture.panelID.uuidString,
]
)
let index = loadRunningFixture(
fixture,
processArguments: reusedProcess,
processIdentity: reusedIdentity
)

#expect(index.entry(
workspaceId: fixture.workspaceID,
panelId: fixture.panelID
)?.processLiveness == .exited)
#expect(!index.hasUncertainStablePanelEntry(panelId: fixture.panelID))
#expect(index.snapshot(
workspaceId: fixture.workspaceID,
panelId: fixture.panelID
)?.sessionId == fixture.sessionID)
}

@Test("A present mismatched hook PID is not pressure-safe")
func presentMismatchedHookPIDIsNotPressureSafe() throws {
let fixture = try makeFixture(prefix: "cmux-mismatched-present-pid")
Expand Down
Loading