diff --git a/assistant/src/daemon/handlers/computer-use.ts b/assistant/src/daemon/handlers/computer-use.ts index 1be40acd6f9..43e0694e50d 100644 --- a/assistant/src/daemon/handlers/computer-use.ts +++ b/assistant/src/daemon/handlers/computer-use.ts @@ -332,6 +332,24 @@ export function handleCuSessionFinalized( } } + // Create a file-backed attachment for recordings without a reporting session + // so cleanup can track orphan files. + if (msg.recording && !(meta?.reportToSessionId)) { + try { + createFileBackedAttachment({ + filename: `qa-recording-${msg.sessionId}.mp4`, + mimeType: msg.recording.mimeType || 'video/mp4', + sizeBytes: msg.recording.sizeBytes, + filePath: msg.recording.localPath, + sha256: undefined, + expiresAt: msg.recording.expiresAt, + }); + log.info({ sessionId: msg.sessionId }, 'Created orphan file-backed attachment for cleanup tracking (no reportToSessionId)'); + } catch (err) { + log.error({ err, sessionId: msg.sessionId }, 'Failed to create file-backed attachment for orphan recording'); + } + } + // Clean up all CU session state. removeCuSessionReferences(ctx, msg.sessionId); // Delete metadata last — after it has been consumed for summary injection diff --git a/assistant/src/daemon/handlers/misc.ts b/assistant/src/daemon/handlers/misc.ts index 7dedba45b4c..f987bb2f984 100644 --- a/assistant/src/daemon/handlers/misc.ts +++ b/assistant/src/daemon/handlers/misc.ts @@ -76,6 +76,7 @@ export async function handleTaskSubmit( // Create CU session (reuse handleCuSessionCreate logic) const sessionId = uuid(); const isQa = detectQaIntent(msg.task); + const config = getConfig(); const cuMsg: CuSessionCreate = { type: 'cu_session_create', sessionId, @@ -84,7 +85,7 @@ export async function handleTaskSubmit( screenHeight: msg.screenHeight, attachments: msg.attachments, interactionType: 'computer_use', - ...(isQa ? { qaMode: true } : {}), + ...(isQa ? { qaMode: true, reportToSessionId: msg.conversationId } : {}), }; handleCuSessionCreate(cuMsg, socket, ctx); @@ -92,7 +93,11 @@ export async function handleTaskSubmit( type: 'task_routed', sessionId, interactionType: 'computer_use', - ...(isQa ? { qaMode: true } : {}), + ...(isQa ? { + qaMode: true, + reportToSessionId: msg.conversationId, + retentionDays: config.qaRecording.defaultRetentionDays, + } : {}), }); } else { // Create text QA session and immediately start processing diff --git a/assistant/src/daemon/handlers/shared.ts b/assistant/src/daemon/handlers/shared.ts index 48808692f1c..044664f741d 100644 --- a/assistant/src/daemon/handlers/shared.ts +++ b/assistant/src/daemon/handlers/shared.ts @@ -237,6 +237,7 @@ export function wireEscalationHandler( const cuSessionId = uuid(); const isQa = detectQaIntent(task); + const config = getConfig(); const cuMsg: CuSessionCreate = { type: 'cu_session_create', sessionId: cuSessionId, @@ -256,7 +257,10 @@ export function wireEscalationHandler( task, escalatedFrom: sourceSessionId, reportToSessionId: sourceSessionId, - ...(isQa ? { qaMode: true } : {}), + ...(isQa ? { + qaMode: true, + retentionDays: config.qaRecording.defaultRetentionDays, + } : {}), }); return true; diff --git a/assistant/src/daemon/ipc-contract.ts b/assistant/src/daemon/ipc-contract.ts index 7cb87a23ab1..73fc3372764 100644 --- a/assistant/src/daemon/ipc-contract.ts +++ b/assistant/src/daemon/ipc-contract.ts @@ -232,6 +232,8 @@ export interface TaskSubmit { screenHeight: number; attachments?: UserMessageAttachment[]; source?: 'voice' | 'text'; + /** The originating conversation/thread ID, if submitting from a chat context. */ + conversationId?: string; } export interface RideShotgunStart { @@ -1549,6 +1551,8 @@ export interface TaskRouted { qaMode?: boolean; /** The originating chat session ID for result injection. */ reportToSessionId?: string; + /** Recording retention in days (from daemon config). */ + retentionDays?: number; } export interface RideShotgunResult { diff --git a/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift b/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift index 7d8fdfdaa4d..22b3e99f268 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift @@ -56,7 +56,8 @@ extension AppDelegate { notificationService: self.services.activityNotificationService, screenRecorder: (routed.qaMode == true) ? ScreenRecorder() : nil, reportToSessionId: routed.reportToSessionId, - qaMode: routed.qaMode ?? false + qaMode: routed.qaMode ?? false, + retentionDays: routed.retentionDays.flatMap { Int($0) } ?? 7 ) // Don't bind relatedViewModel for escalated sessions — the active view model // may be unrelated if the user switched threads. Tool calls for escalated @@ -139,13 +140,17 @@ extension AppDelegate { extractedText: $0.extractedText ) } + // Pass the active thread's conversation ID so the daemon can set reportToSessionId for QA sessions + let activeConversationId = self.mainWindow?.threadManager.activeViewModel?.sessionId + do { try self.daemonClient.send(TaskSubmitMessage( task: effectiveTask, screenWidth: Int(screenBounds.width), screenHeight: Int(screenBounds.height), attachments: ipcAttachments, - source: submission.source + source: submission.source, + conversationId: activeConversationId )) } catch { log.error("Failed to send task submit message: \(error)") @@ -199,7 +204,8 @@ extension AppDelegate { notificationService: self.services.activityNotificationService, screenRecorder: (routed.qaMode == true) ? ScreenRecorder() : nil, reportToSessionId: routed.reportToSessionId, - qaMode: routed.qaMode ?? false + qaMode: routed.qaMode ?? false, + retentionDays: routed.retentionDays.flatMap { Int($0) } ?? 7 ) // Don't bind relatedViewModel — sessions started via startSession() don't // originate from a chat thread, so there's no ChatViewModel to extract diff --git a/clients/macos/vellum-assistant/ComputerUse/Session.swift b/clients/macos/vellum-assistant/ComputerUse/Session.swift index b626805806b..22e2fda09d7 100644 --- a/clients/macos/vellum-assistant/ComputerUse/Session.swift +++ b/clients/macos/vellum-assistant/ComputerUse/Session.swift @@ -40,6 +40,8 @@ final class ComputerUseSession: ObservableObject { let reportToSessionId: String? /// Whether this session is running in QA/test mode. let qaMode: Bool + /// Recording retention in days (from daemon config, default 7). + let retentionDays: Int /// Weak reference to the chat view model for extracting tool calls for notifications. weak var relatedViewModel: ChatViewModel? @@ -85,7 +87,8 @@ final class ComputerUseSession: ObservableObject { notificationService: ActivityNotificationServiceProtocol? = nil, screenRecorder: ScreenRecording? = nil, reportToSessionId: String? = nil, - qaMode: Bool = false + qaMode: Bool = false, + retentionDays: Int = 7 ) { self.id = sessionId ?? UUID().uuidString self.task = task @@ -103,6 +106,7 @@ final class ComputerUseSession: ObservableObject { self.screenRecorder = screenRecorder self.reportToSessionId = reportToSessionId self.qaMode = qaMode + self.retentionDays = retentionDays self.verifier = ActionVerifier(maxSteps: maxSteps) self.logger = SessionLogger(task: task, attachments: attachments) } @@ -1005,7 +1009,7 @@ final class ComputerUseSession: ObservableObject { if let recorder = screenRecorder, recorder.isRecording { do { let result = try await recorder.stopRecording() - let expiresAtEpoch = Int(Date().addingTimeInterval(7 * 24 * 3600).timeIntervalSince1970 * 1000) + let expiresAtEpoch = Int(Date().addingTimeInterval(Double(retentionDays) * 24 * 3600).timeIntervalSince1970 * 1000) recordingData = IPCCuSessionFinalizedRecording( localPath: result.fileURL.path, mimeType: result.mimeType, diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index d8e85b52cfd..adbe84f8604 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -1744,6 +1744,8 @@ public struct IPCTaskRouted: Codable, Sendable { public let qaMode: Bool? /// The originating chat session ID for result injection. public let reportToSessionId: String? + /// Recording retention in days (from daemon config). + public let retentionDays: Double? } /// Server push — broadcast when a task run creates a conversation, so the client can show it as a chat thread. @@ -1766,6 +1768,8 @@ public struct IPCTaskSubmit: Codable, Sendable { public let screenHeight: Int public let attachments: [IPCUserMessageAttachment]? public let source: String? + /// The originating conversation/thread ID, if submitting from a chat context. + public let conversationId: String? } public struct IPCTelegramConfigRequest: Codable, Sendable { diff --git a/clients/shared/IPC/IPCMessages.swift b/clients/shared/IPC/IPCMessages.swift index 34d65d49622..8f9955524f5 100644 --- a/clients/shared/IPC/IPCMessages.swift +++ b/clients/shared/IPC/IPCMessages.swift @@ -324,8 +324,8 @@ extension IPCUserMessage { public typealias TaskSubmitMessage = IPCTaskSubmit extension IPCTaskSubmit { - public init(task: String, screenWidth: Int, screenHeight: Int, attachments: [IPCAttachment]?, source: String?) { - self.init(type: "task_submit", task: task, screenWidth: screenWidth, screenHeight: screenHeight, attachments: attachments, source: source) + public init(task: String, screenWidth: Int, screenHeight: Int, attachments: [IPCAttachment]?, source: String?, conversationId: String? = nil) { + self.init(type: "task_submit", task: task, screenWidth: screenWidth, screenHeight: screenHeight, attachments: attachments, source: source, conversationId: conversationId) } }