Selaa lähdekoodia

fix: stop closing panes on missing session status

m.seomun 1 kuukausi sitten
vanhempi
sitoutus
dd14d465aa

+ 2 - 3
src/multiplexer/codemap.md

@@ -56,9 +56,8 @@
   - `respawnIfKnown` handles busy sessions that reappear after being closed.
   - Polling fallback (`pollSessions`) is enabled when event coverage is incomplete.
     It handles:
-    - idle detection,
-    - missing status grace period,
-    - max session lifetime timeout.
+    - idle detection.
+    - A session missing from `/session/status` is not treated as a close signal.
 
 - `index.ts`
   - Re-exports factory, manager, and implementations for external import.

+ 32 - 43
src/multiplexer/session-manager.test.ts

@@ -493,45 +493,33 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('keeps missing running background job pane open', async () => {
+    test('does not close never-seen pane when missing from status', async () => {
       const ctx = createMockContext();
-      const board = new BackgroundJobBoard();
-      board.registerLaunch({
-        taskID: 'running-background-job',
-        parentSessionID: 'parent-1',
-        agent: 'explorer',
-      });
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
-        paneId: 'p-running-background-job',
+        paneId: 'p-never-seen-orphan',
       });
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
       );
 
       await manager.onSessionCreated({
         type: 'session.created',
-        properties: {
-          info: { id: 'running-background-job', parentID: 'parent-1' },
-        },
+        properties: { info: { id: 'never-seen-orphan', parentID: 'p1' } },
       });
 
-      const tracked = (manager as any).sessions.get('running-background-job');
-      tracked.missingSince = Date.now() - 60_000;
-
       setMockSessionStatuses({});
       await (manager as any).pollSessions();
 
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('closes never-seen pane when no running background job exists', async () => {
+    test('ignores empty session status response without closing panes', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
-        paneId: 'p-never-seen-orphan',
+        paneId: 'p-empty-status',
       });
       const manager = new MultiplexerSessionManager(
         ctx,
@@ -540,25 +528,23 @@ describe('MultiplexerSessionManager', () => {
 
       await manager.onSessionCreated({
         type: 'session.created',
-        properties: { info: { id: 'never-seen-orphan', parentID: 'p1' } },
+        properties: { info: { id: 'empty-status', parentID: 'p1' } },
       });
 
-      const tracked = (manager as any).sessions.get('never-seen-orphan');
-      tracked.missingSince = Date.now() - 60_000;
+      mockFetch.mockImplementationOnce(
+        async () => new Response('', { status: 200 }),
+      );
 
-      setMockSessionStatuses({});
       await (manager as any).pollSessions();
 
-      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
-        'p-never-seen-orphan',
-      );
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('ignores empty session status response without closing panes', async () => {
+    test('does not close previously seen session when later missing from status', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
-        paneId: 'p-empty-status',
+        paneId: 'p-seen-before-missing',
       });
       const manager = new MultiplexerSessionManager(
         ctx,
@@ -567,26 +553,23 @@ describe('MultiplexerSessionManager', () => {
 
       await manager.onSessionCreated({
         type: 'session.created',
-        properties: { info: { id: 'empty-status', parentID: 'p1' } },
+        properties: { info: { id: 'seen-before-missing', parentID: 'p1' } },
       });
 
-      const tracked = (manager as any).sessions.get('empty-status');
-      tracked.seenInStatus = true;
-      tracked.missingSince = Date.now() - 60_000;
-      mockFetch.mockImplementationOnce(
-        async () => new Response('', { status: 200 }),
-      );
+      setMockSessionStatuses({ 'seen-before-missing': { type: 'busy' } });
+      await (manager as any).pollSessions();
 
+      setMockSessionStatuses({});
       await (manager as any).pollSessions();
 
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('keeps missing cleanup for sessions previously seen in status', async () => {
+    test('does not respawn duplicate pane when missing session becomes busy again', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
-        paneId: 'p-seen-before-missing',
+        paneId: 'p-seen-before-busy-again',
       });
       const manager = new MultiplexerSessionManager(
         ctx,
@@ -595,21 +578,27 @@ describe('MultiplexerSessionManager', () => {
 
       await manager.onSessionCreated({
         type: 'session.created',
-        properties: { info: { id: 'seen-before-missing', parentID: 'p1' } },
+        properties: {
+          info: { id: 'seen-before-busy-again', parentID: 'p1' },
+        },
       });
 
-      setMockSessionStatuses({ 'seen-before-missing': { type: 'busy' } });
+      setMockSessionStatuses({ 'seen-before-busy-again': { type: 'busy' } });
       await (manager as any).pollSessions();
 
-      const tracked = (manager as any).sessions.get('seen-before-missing');
-      tracked.missingSince = Date.now() - 60_000;
-
       setMockSessionStatuses({});
       await (manager as any).pollSessions();
 
-      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
-        'p-seen-before-missing',
-      );
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'seen-before-busy-again',
+          status: { type: 'busy' },
+        },
+      });
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
     });
 
     test('polls the actual serverUrl instead of the plugin SDK default URL', async () => {

+ 19 - 36
src/multiplexer/session-manager.ts

@@ -18,8 +18,6 @@ interface TrackedSession {
   ownerInstanceId: string;
   createdAt: number;
   lastSeenAt: number;
-  seenInStatus: boolean;
-  missingSince?: number;
 }
 
 interface KnownSession {
@@ -49,9 +47,7 @@ interface SessionEvent {
   };
 }
 
-type CloseReason = 'idle' | 'deleted' | 'missing';
-
-const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
+type CloseReason = 'idle' | 'deleted';
 const SHARED_STATE_KEY = Symbol.for(
   'oh-my-opencode-slim.multiplexer-session-manager.state',
 );
@@ -228,7 +224,6 @@ export class MultiplexerSessionManager {
         ownerInstanceId: this.instanceId,
         createdAt: now,
         lastSeenAt: now,
-        seenInStatus: false,
       });
 
       log('[multiplexer-session-manager] pane spawned', {
@@ -360,40 +355,29 @@ export class MultiplexerSessionManager {
         }
 
         const status = allStatuses[sessionId];
-        const isIdle = status?.type === 'idle';
-
-        if (status) {
-          tracked.lastSeenAt = now;
-          tracked.seenInStatus = true;
-          tracked.missingSince = undefined;
-        } else if (!tracked.missingSince) {
-          tracked.missingSince = now;
+        if (!status) {
+          continue;
+        }
+
+        tracked.lastSeenAt = now;
+
+        if (status.type !== 'idle') {
+          continue;
         }
 
-        const missingTooLong =
-          !!tracked.missingSince &&
-          now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
-        const shouldKeepRunningBackgroundJob =
-          (isIdle || missingTooLong) && this.isRunningBackgroundJob(sessionId);
-        if (isIdle || missingTooLong) {
-          if (shouldKeepRunningBackgroundJob) {
-            log(
-              '[multiplexer-session-manager] keeping running background pane',
-              {
-                instanceId: this.instanceId,
-                sessionId,
-                paneId: tracked.paneId,
-                seenInStatus: tracked.seenInStatus,
-              },
-            );
-            continue;
-          }
-
-          sessionsToClose.push({
+        if (this.isRunningBackgroundJob(sessionId)) {
+          log('[multiplexer-session-manager] keeping running background pane', {
+            instanceId: this.instanceId,
             sessionId,
-            reason: isIdle ? 'idle' : 'missing',
+            paneId: tracked.paneId,
           });
+          continue;
         }
+
+        sessionsToClose.push({
+          sessionId,
+          reason: 'idle',
+        });
       }
 
       for (const { sessionId, reason } of sessionsToClose) {
@@ -600,7 +584,6 @@ export class MultiplexerSessionManager {
         ownerInstanceId: this.instanceId,
         createdAt: now,
         lastSeenAt: now,
-        seenInStatus: false,
       });
 
       log('[multiplexer-session-manager] pane respawned on busy', {