From 3518f29cbc5f036ba8f86ada30f3244622b57239 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 16:26:19 -0700 Subject: [PATCH] fix: set ended_at on session completion + filter completed sessions from threadKey lookup update_session_phase(status: 'completed') was not setting ended_at, leaving completed sessions visible to findByThreadKey queries. This caused strategy watchdog triggers to route to dead sessions instead of creating fresh ones. Two fixes: - Set endedAt in update_session_phase when status or lifecycle is 'completed' - Filter completed/cancelled/failed lifecycle states in findByThreadKey Co-Authored-By: Wren --- .../api/src/mcp/tools/memory-handlers.test.ts | 64 +++++++++++++++++++ packages/api/src/mcp/tools/memory-handlers.ts | 4 ++ .../services/sessions/session-repository.ts | 2 +- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/api/src/mcp/tools/memory-handlers.test.ts b/packages/api/src/mcp/tools/memory-handlers.test.ts index aec07e98..6df78b8f 100644 --- a/packages/api/src/mcp/tools/memory-handlers.test.ts +++ b/packages/api/src/mcp/tools/memory-handlers.test.ts @@ -1119,6 +1119,70 @@ describe('handleUpdateSessionPhase', () => { expect(parsed.message).toContain('workingDir updated'); }); + it('should set endedAt when status is completed', async () => { + mockDataComposer.repositories.memory.getActiveSession.mockResolvedValue(mockSession); + mockDataComposer.repositories.memory.updateSession.mockResolvedValue({ + ...mockSession, + status: 'completed', + lifecycle: 'completed', + endedAt: new Date(), + }); + + await handleUpdateSessionPhase( + { email: 'test@test.com', phase: 'complete', status: 'completed' }, + mockDataComposer as never + ); + + expect(mockDataComposer.repositories.memory.updateSession).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ + endedAt: expect.any(Date), + status: 'completed', + lifecycle: 'completed', + }) + ); + }); + + it('should set endedAt when lifecycle is explicitly completed', async () => { + mockDataComposer.repositories.memory.getActiveSession.mockResolvedValue(mockSession); + mockDataComposer.repositories.memory.updateSession.mockResolvedValue({ + ...mockSession, + lifecycle: 'completed', + endedAt: new Date(), + }); + + await handleUpdateSessionPhase( + { email: 'test@test.com', phase: 'complete', lifecycle: 'completed' }, + mockDataComposer as never + ); + + expect(mockDataComposer.repositories.memory.updateSession).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ + endedAt: expect.any(Date), + lifecycle: 'completed', + }) + ); + }); + + it('should NOT set endedAt for non-completed status', async () => { + mockDataComposer.repositories.memory.getActiveSession.mockResolvedValue(mockSession); + mockDataComposer.repositories.memory.updateSession.mockResolvedValue({ + ...mockSession, + status: 'active', + }); + + await handleUpdateSessionPhase( + { email: 'test@test.com', phase: 'implementing', status: 'active' }, + mockDataComposer as never + ); + + expect(mockDataComposer.repositories.memory.updateSession).toHaveBeenCalledWith( + 'session-123', + expect.not.objectContaining({ endedAt: expect.anything() }) + ); + }); + it('should include session info in response', async () => { const sessionWithWorkspace = { ...mockSession, diff --git a/packages/api/src/mcp/tools/memory-handlers.ts b/packages/api/src/mcp/tools/memory-handlers.ts index 3e0d4d7e..c90e8822 100644 --- a/packages/api/src/mcp/tools/memory-handlers.ts +++ b/packages/api/src/mcp/tools/memory-handlers.ts @@ -1411,6 +1411,7 @@ export async function handleUpdateSessionPhase(args: unknown, dataComposer: Data context?: string; workingDir?: string; cliAttached?: boolean; + endedAt?: Date | null; } = {}; // Map runtime: prefix phases to lifecycle (backward compat for old callers) @@ -1442,6 +1443,9 @@ export async function handleUpdateSessionPhase(args: unknown, dataComposer: Data if (params.status !== undefined) { updates.status = params.status; } + if (params.status === 'completed' || updates.lifecycle === 'completed') { + updates.endedAt = new Date(); + } if (params.backendSessionId !== undefined) { updates.backendSessionId = params.backendSessionId; } diff --git a/packages/api/src/services/sessions/session-repository.ts b/packages/api/src/services/sessions/session-repository.ts index 9a6eaf78..a2484d47 100644 --- a/packages/api/src/services/sessions/session-repository.ts +++ b/packages/api/src/services/sessions/session-repository.ts @@ -202,7 +202,7 @@ export class SessionRepository implements ISessionRepository { .eq('agent_id', agentId) .eq('thread_key', threadKey) .is('ended_at', null) - .neq('lifecycle', 'failed') + .not('lifecycle', 'in', '(completed,cancelled,failed)') .order('started_at', { ascending: false }) .limit(1);