Skip to content
Merged
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
18 changes: 18 additions & 0 deletions assistant/src/daemon/handlers/computer-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Comment on lines +337 to +351
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Recording file untracked when reportToSessionId exists but conversation was deleted

When meta?.reportToSessionId is set but conversationStore.getConversation(reportSessionId) returns null (e.g., the user cleared sessions while the CU session was running), the recording file gets no file-backed attachment created.

Root Cause

The new orphan-tracking code at line 337 checks !(meta?.reportToSessionId), which is false when a reportToSessionId exists. But the normal attachment-creation path at line 256-287 only runs inside if (conversation) (line 243). When the conversation is missing, neither path creates an attachment:

  1. Line 243: if (conversation) is false → attachment creation at line 258 is skipped
  2. Line 327: else branch just logs a warning
  3. Line 337: !(meta?.reportToSessionId) is false → orphan tracking is skipped

Result: the recording file on disk is never registered in the attachment store, so the cleanup system cannot track or expire it.

Impact: Recording files accumulate on disk indefinitely for this edge case, causing a slow storage leak.

Suggested change
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');
}
}
// Create a file-backed attachment for recordings without a reporting session
// (or when the reporting conversation was deleted) so cleanup can track orphan files.
if (msg.recording && !(meta?.reportToSessionId && conversationStore.getConversation(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');
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Clean up all CU session state.
removeCuSessionReferences(ctx, msg.sessionId);
// Delete metadata last — after it has been consumed for summary injection
Expand Down
9 changes: 7 additions & 2 deletions assistant/src/daemon/handlers/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -84,15 +85,19 @@ 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);

ctx.send(socket, {
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
Expand Down
6 changes: 5 additions & 1 deletion assistant/src/daemon/handlers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -256,7 +257,10 @@ export function wireEscalationHandler(
task,
escalatedFrom: sourceSessionId,
reportToSessionId: sourceSessionId,
...(isQa ? { qaMode: true } : {}),
...(isQa ? {
qaMode: true,
retentionDays: config.qaRecording.defaultRetentionDays,
} : {}),
});

return true;
Expand Down
4 changes: 4 additions & 0 deletions assistant/src/daemon/ipc-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Safely parse routed retentionDays before Int conversion

routed.retentionDays.flatMap { Int($0) } performs a trapping conversion, so a very large (or non-finite) retentionDays value from the daemon will crash the macOS client when a QA session is created; this is reachable because daemon config currently only requires qaRecording.defaultRetentionDays to be a positive integer, not bounded to Int range. Please use a non-trapping conversion with bounds checks/fallback (and apply the same guard to the identical conversion in the other session-construction path).

Useful? React with 👍 / 👎.

)
// Don't bind relatedViewModel for escalated sessions — the active view model
// may be unrelated if the user switched threads. Tool calls for escalated
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions clients/macos/vellum-assistant/ComputerUse/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions clients/shared/IPC/Generated/IPCContractGenerated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions clients/shared/IPC/IPCMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading