Repository navigation
Fix Claude workflow resume transcript resolution #5242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,39 +1142,39 @@ 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, | ||
| processArgumentsProvider: processArgumentsProvider | ||
| ) | ||
| 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<String> = [] | ||
|
|
||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This full Prompt for AI agents |
||
| appendIfWorkflowContainer( | ||
| projectRoot: (projectsRoot as NSString).appendingPathComponent(projectDir) | ||
| ) | ||
| } | ||
|
Comment on lines
+1322
to
+1326
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For every Claude record that lacks an explicit Rule Used: Flag production code that adds nested full-collect... (source) |
||
| } | ||
| return projectDirs | ||
|
Comment on lines
+1266
to
+1328
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache or bound the project-directory fallback scan. Lines 1266-1328 run inside the per-record load loop, but they still iterate every Claude project directory and probe 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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) | ||
|
Comment on lines
+1331
to
+1359
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't auto-resolve to the newest sibling transcript. Lines 1331-1359 always pick the most recently modified sibling 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private static func claudeTranscriptExists( | ||
| for record: RestorableAgentHookSessionRecord, | ||
| fileManager: FileManager, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After
resolvedClaudeWorkflowRecordremaps the container to a sibling transcript,effectiveRecord.pidstill holds the workflow container process's PID. For the common case — the user wants to resume a completed workflow — that process is dead, soliveProcessIDisnil. The guardeffectiveRecord.pid == nil || liveProcessID != nilthen skips theresolved[key] = entryassignment, removing the session from the restorable index even though a valid sibling transcript was found.The resolved entry still ends up in
hookCandidatesByPanelandhookCandidatesBySession(written before this guard), but those are only consulted by thedetectedSnapshotsloop for process-detected entries, not for hook-only sessions. In practice this means workflow sessions that had an associated process — which is the normal state — will never appear as restorable after the process exits, defeating the purpose of this fix.The fix is to clear
pidinresolvedClaudeWorkflowRecordwhen the session ID changes: the container's process is irrelevant to the sibling session. The test doesn't exercise this path becausehookRecord(...)defaultspidtonil; adding a non-nil PID to the test fixture would reproduce the failure.