diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 7cf0cc924fe8..bd71d5374ce5 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1118,12 +1118,19 @@ struct RestorableAgentSessionIndex: Sendable { } for record in state.sessions.values { - let normalizedSessionId = record.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveRecord = kind == .claude + ? resolvedClaudeWorkflowRecord( + record, + fileManager: fileManager, + lookup: claudeTranscriptLookup + ) + : record + let normalizedSessionId = effectiveRecord.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionId.isEmpty, - let workspaceId = UUID(uuidString: record.workspaceId), - let panelId = UUID(uuidString: record.surfaceId), + let workspaceId = UUID(uuidString: effectiveRecord.workspaceId), + let panelId = UUID(uuidString: effectiveRecord.surfaceId), hookRecordIsRestorable( - record, + effectiveRecord, kind: kind, fileManager: fileManager, claudeTranscriptLookup: claudeTranscriptLookup @@ -1135,18 +1142,18 @@ struct RestorableAgentSessionIndex: Sendable { kind: kind, sessionId: normalizedSessionId, workingDirectory: restorableWorkingDirectory( - for: record, + for: effectiveRecord, kind: kind, fileManager: fileManager, lookup: claudeTranscriptLookup ), - launchCommand: record.launchCommand, + launchCommand: effectiveRecord.launchCommand, registration: registration ) let key = PanelKey(workspaceId: workspaceId, panelId: panelId) let sessionKey = SessionKey(kind: kind, sessionId: normalizedSessionId) let liveProcessID = liveScopedProcessID( - for: record, + for: effectiveRecord, kind: kind, workspaceId: workspaceId, panelId: panelId, @@ -1154,20 +1161,20 @@ struct RestorableAgentSessionIndex: Sendable { ) let entry = Entry( snapshot: snapshot, - lifecycle: record.agentLifecycle, - updatedAt: record.updatedAt, + lifecycle: effectiveRecord.agentLifecycle, + updatedAt: effectiveRecord.updatedAt, processIDs: liveProcessID.map { [$0] } ?? [] ) - if hookCandidatesByPanel[key]?.updatedAt ?? -Double.infinity <= record.updatedAt { + if hookCandidatesByPanel[key]?.updatedAt ?? -Double.infinity <= effectiveRecord.updatedAt { hookCandidatesByPanel[key] = entry } - if hookCandidatesBySession[sessionKey]?.updatedAt ?? -Double.infinity <= record.updatedAt { + if hookCandidatesBySession[sessionKey]?.updatedAt ?? -Double.infinity <= effectiveRecord.updatedAt { hookCandidatesBySession[sessionKey] = entry } - guard record.pid == nil || liveProcessID != nil else { + guard effectiveRecord.pid == nil || liveProcessID != nil else { continue } - if let existing = resolved[key], existing.updatedAt > record.updatedAt { + if let existing = resolved[key], existing.updatedAt > effectiveRecord.updatedAt { continue } resolved[key] = entry @@ -1239,6 +1246,119 @@ struct RestorableAgentSessionIndex: Sendable { return claudeTranscriptExists(for: record, fileManager: fileManager, lookup: claudeTranscriptLookup) } + private static func resolvedClaudeWorkflowRecord( + _ record: RestorableAgentHookSessionRecord, + fileManager: FileManager, + lookup: ClaudeTranscriptLookupCache + ) -> RestorableAgentHookSessionRecord { + guard let sessionId = normalizedNonEmptyValue(record.sessionId), + claudeSessionIdIsSafeFilename(sessionId) else { + return record + } + if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath), + regularNonEmptyFileExists( + atPath: (transcriptPath as NSString).expandingTildeInPath, + fileManager: fileManager + ) { + return record + } + + let roots = lookup.configRoots(for: record) + guard !roots.isEmpty else { return record } + let candidateProjectDirs = claudeWorkflowProjectDirs( + for: record, + sessionId: sessionId, + roots: roots, + fileManager: fileManager, + lookup: lookup + ) + guard let resolved = newestClaudeSiblingTranscript( + in: candidateProjectDirs, + excludingSessionId: sessionId, + fileManager: fileManager + ) else { + return record + } + + var resolvedRecord = record + resolvedRecord.sessionId = resolved.sessionId + resolvedRecord.transcriptPath = resolved.path + return resolvedRecord + } + + private static func claudeWorkflowProjectDirs( + for record: RestorableAgentHookSessionRecord, + sessionId: String, + roots: [String], + fileManager: FileManager, + lookup: ClaudeTranscriptLookupCache + ) -> [String] { + var projectDirs: [String] = [] + var seen: Set = [] + + func appendIfWorkflowContainer(projectRoot: String) { + let workflowContainer = (projectRoot as NSString).appendingPathComponent(sessionId) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: workflowContainer, isDirectory: &isDirectory), + isDirectory.boolValue else { + return + } + let standardized = (projectRoot as NSString).standardizingPath + guard seen.insert(standardized).inserted else { return } + projectDirs.append(standardized) + } + + let cwdCandidates = [ + normalizedWorkingDirectory(record.launchCommand?.workingDirectory), + normalizedWorkingDirectory(record.cwd), + ].compactMap { $0 } + for root in roots { + let projectsRoot = (root as NSString).appendingPathComponent("projects") + for cwd in cwdCandidates { + appendIfWorkflowContainer( + projectRoot: (projectsRoot as NSString).appendingPathComponent(encodeClaudeProjectDir(cwd)) + ) + } + for projectDir in lookup.projectDirs(configRoot: root) { + appendIfWorkflowContainer( + projectRoot: (projectsRoot as NSString).appendingPathComponent(projectDir) + ) + } + } + return projectDirs + } + + private static func newestClaudeSiblingTranscript( + in projectDirs: [String], + excludingSessionId excludedSessionId: String, + fileManager: FileManager + ) -> (sessionId: String, path: String)? { + var best: (sessionId: String, path: String, modifiedAt: TimeInterval)? + for projectDir in projectDirs { + guard let children = try? fileManager.contentsOfDirectory(atPath: projectDir) else { + continue + } + for child in children where child.hasSuffix(".jsonl") { + let sessionId = String(child.dropLast(".jsonl".count)) + guard sessionId != excludedSessionId, + claudeSessionIdIsSafeFilename(sessionId) else { + continue + } + let path = (projectDir as NSString).appendingPathComponent(child) + guard regularNonEmptyFileExists(atPath: path, fileManager: fileManager) else { + continue + } + let modifiedAt = ((try? fileManager.attributesOfItem(atPath: path)[.modificationDate]) as? Date)? + .timeIntervalSince1970 ?? 0 + if best == nil || modifiedAt > best!.modifiedAt { + best = (sessionId, path, modifiedAt) + } + } + } + guard let best else { return nil } + return (best.sessionId, best.path) + } + private static func claudeTranscriptExists( for record: RestorableAgentHookSessionRecord, fileManager: FileManager, diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index 52518db5e910..994ae56873f9 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -310,6 +310,64 @@ final class RestorableAgentSessionIndexTests: XCTestCase { XCTAssertEqual(snapshot.workingDirectory, launchCwd.path) } + func testClaudeWorkflowDirectorySessionUsesSiblingJsonlSessionForResume() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-workflow-directory-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let configDir = root.appendingPathComponent("claude-config", isDirectory: true) + let projectsDir = configDir.appendingPathComponent("projects", isDirectory: true) + let cwd = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: cwd, withIntermediateDirectories: true) + let projectDir = projectsDir.appendingPathComponent( + expectedClaudeProjectDirName(cwd.path), + isDirectory: true + ) + let workflowContainerSessionId = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa" + let resumableSessionId = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb" + let workflowContainerURL = projectDir + .appendingPathComponent(workflowContainerSessionId, isDirectory: true) + try fm.createDirectory( + at: workflowContainerURL + .appendingPathComponent("subagents", isDirectory: true), + withIntermediateDirectories: true + ) + let siblingTranscriptURL = projectDir.appendingPathComponent("\(resumableSessionId).jsonl", isDirectory: false) + try writeClaudeTranscript(sessionId: resumableSessionId, transcriptURL: siblingTranscriptURL, cwd: cwd) + + let workspaceId = UUID() + let panelId = UUID() + try writeClaudeHookStore( + root: root, + sessions: [ + workflowContainerSessionId: hookRecord( + sessionId: workflowContainerSessionId, + workspaceId: workspaceId, + panelId: panelId, + cwd: cwd.path, + configDir: configDir.path, + transcriptPath: workflowContainerURL.path, + isRestorable: true, + updatedAt: 10 + ), + ] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + let snapshot = try XCTUnwrap(index.snapshot(workspaceId: workspaceId, panelId: panelId)) + + XCTAssertEqual(snapshot.sessionId, resumableSessionId) + XCTAssertEqual(snapshot.workingDirectory, cwd.path) + XCTAssertTrue( + try XCTUnwrap(snapshot.resumeCommand).contains("'--resume' '\(resumableSessionId)'") + ) + XCTAssertFalse( + try XCTUnwrap(snapshot.resumeCommand).contains(workflowContainerSessionId), + "The Workflow container id is not accepted by claude --resume." + ) + } + /// Mirrors Claude's external project-directory naming rule ("/" and "." both become "-") /// independently of the production `encodeClaudeProjectDir`, so these regression tests fail if /// that helper regresses instead of masking it by sharing the same code path.