From 3518f29cbc5f036ba8f86ada30f3244622b57239 Mon Sep 17 00:00:00 2001
From: conoremclaughlin <conoremclaughlin@gmail.com>
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 <noreply@anthropic.com>
---
 .../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);
 
