Bläddra i källkod

Revert "Fix multiplexer pane flicker from missing session status"

Alvin 1 månad sedan
förälder
incheckning
74631cd678
3 ändrade filer med 85 tillägg och 51 borttagningar
  1. 3 2
      src/multiplexer/codemap.md
  2. 43 32
      src/multiplexer/session-manager.test.ts
  3. 39 17
      src/multiplexer/session-manager.ts

+ 3 - 2
src/multiplexer/codemap.md

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

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

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

+ 39 - 17
src/multiplexer/session-manager.ts

@@ -17,6 +17,9 @@ interface TrackedSession {
   directory: string;
   ownerInstanceId: string;
   createdAt: number;
+  lastSeenAt: number;
+  seenInStatus: boolean;
+  missingSince?: number;
 }
 
 interface KnownSession {
@@ -46,7 +49,9 @@ interface SessionEvent {
   };
 }
 
-type CloseReason = 'idle' | 'deleted';
+type CloseReason = 'idle' | 'deleted' | 'missing';
+
+const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 const SHARED_STATE_KEY = Symbol.for(
   'oh-my-opencode-slim.multiplexer-session-manager.state',
 );
@@ -222,6 +227,8 @@ export class MultiplexerSessionManager {
         directory,
         ownerInstanceId: this.instanceId,
         createdAt: now,
+        lastSeenAt: now,
+        seenInStatus: false,
       });
 
       log('[multiplexer-session-manager] pane spawned', {
@@ -353,27 +360,40 @@ export class MultiplexerSessionManager {
         }
 
         const status = allStatuses[sessionId];
-        if (!status) {
-          continue;
-        }
-
-        if (status.type !== 'idle') {
-          continue;
+        const isIdle = status?.type === 'idle';
+
+        if (status) {
+          tracked.lastSeenAt = now;
+          tracked.seenInStatus = true;
+          tracked.missingSince = undefined;
+        } else if (!tracked.missingSince) {
+          tracked.missingSince = now;
         }
 
-        if (this.isRunningBackgroundJob(sessionId)) {
-          log('[multiplexer-session-manager] keeping running background pane', {
-            instanceId: this.instanceId,
+        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({
             sessionId,
-            paneId: tracked.paneId,
+            reason: isIdle ? 'idle' : 'missing',
           });
-          continue;
         }
-
-        sessionsToClose.push({
-          sessionId,
-          reason: 'idle',
-        });
       }
 
       for (const { sessionId, reason } of sessionsToClose) {
@@ -579,6 +599,8 @@ export class MultiplexerSessionManager {
         directory: known.directory,
         ownerInstanceId: this.instanceId,
         createdAt: now,
+        lastSeenAt: now,
+        seenInStatus: false,
       });
 
       log('[multiplexer-session-manager] pane respawned on busy', {