Browse Source

fix: stabilize multiplexer pane lifecycle

m.seomun 1 month ago
parent
commit
e37419d7af

+ 3 - 0
src/index.ts

@@ -254,6 +254,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       multiplexerConfig,
       backgroundJobBoard,
     );
+    backgroundJobBoard.setTerminalStateListener((taskID) => {
+      void multiplexerSessionManager.retryDeferredIdleClose(taskID);
+    });
 
     // Initialize auto-update checker hook
     autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {

+ 4 - 5
src/multiplexer/codemap.md

@@ -54,11 +54,10 @@
     - known sessions (`knownSessions`),
     - in-flight spawns (`spawningSessions`).
   - `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.
+  - Polling fallback (`pollSessions`) handles explicit idle detection only.
+    Missing from `/session/status` is not a close signal.
+  - Deferred idle closes keep panes open while `BackgroundJobBoard` says the
+    task is running, then complete via the hook-driven terminal-state callback.
 
 - `index.ts`
   - Re-exports factory, manager, and implementations for external import.

+ 272 - 53
src/multiplexer/session-manager.test.ts

@@ -410,116 +410,278 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('does not close on transient status absence', async () => {
+    test('deferred idle closes retry on terminal status updates', async () => {
+      for (const state of ['completed', 'error', 'cancelled'] as const) {
+        resetMultiplexerSessionManagerState();
+        mockMultiplexer.spawnPane.mockClear();
+        mockMultiplexer.closePane.mockClear();
+        const ctx = createMockContext();
+        const board = new BackgroundJobBoard();
+        const sessionId = `deferred-${state}`;
+        board.registerLaunch({
+          taskID: sessionId,
+          parentSessionID: 'parent-1',
+          agent: 'explorer',
+        });
+        mockMultiplexer.spawnPane.mockResolvedValueOnce({
+          success: true,
+          paneId: `p-${state}`,
+        });
+        const manager = new MultiplexerSessionManager(
+          ctx,
+          defaultMultiplexerConfig,
+          board,
+        );
+        board.setTerminalStateListener((taskID) => {
+          void manager.retryDeferredIdleClose(taskID);
+        });
+
+        await manager.onSessionCreated({
+          type: 'session.created',
+          properties: { info: { id: sessionId, parentID: 'parent-1' } },
+        });
+        await manager.onSessionStatus({
+          type: 'session.status',
+          properties: { sessionID: sessionId, status: { type: 'idle' } },
+        });
+
+        expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+        board.updateStatus({ taskID: sessionId, state });
+        await Promise.resolve();
+
+        expect(mockMultiplexer.closePane).toHaveBeenCalledWith(`p-${state}`);
+      }
+    });
+
+    test('deferred idle close retries on markCancelled', async () => {
       const ctx = createMockContext();
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'deferred-cancel',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+      });
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-deferred-cancel',
+      });
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
+        board,
       );
+      board.setTerminalStateListener((taskID) => {
+        void manager.retryDeferredIdleClose(taskID);
+      });
 
       await manager.onSessionCreated({
         type: 'session.created',
-        properties: { info: { id: 'c1', parentID: 'p1' } },
+        properties: { info: { id: 'deferred-cancel', parentID: 'parent-1' } },
       });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'deferred-cancel',
+          status: { type: 'idle' },
+        },
+      });
+      board.markCancelled('deferred-cancel');
+      await Promise.resolve();
 
-      setMockSessionStatuses({});
-      await (manager as any).pollSessions();
-
-      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-deferred-cancel',
+      );
     });
 
-    test('keeps background child pane open while status is running until deleted', async () => {
+    test('terminal status without deferred idle close does not close pane', async () => {
       const ctx = createMockContext();
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'terminal-without-defer',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+      });
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-terminal-without-defer',
+      });
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
+        board,
       );
-
-      mockMultiplexer.spawnPane.mockResolvedValueOnce({
-        success: true,
-        paneId: 'p-background-child',
+      board.setTerminalStateListener((taskID) => {
+        void manager.retryDeferredIdleClose(taskID);
       });
 
       await manager.onSessionCreated({
         type: 'session.created',
         properties: {
-          info: {
-            id: 'background-child',
-            parentID: 'parent-1',
-            title: 'Background Worker',
-          },
+          info: { id: 'terminal-without-defer', parentID: 'parent-1' },
         },
       });
-
-      setMockSessionStatuses({ 'background-child': { type: 'running' } });
-      await (manager as any).pollSessions();
-      await (manager as any).pollSessions();
+      board.updateStatus({
+        taskID: 'terminal-without-defer',
+        state: 'completed',
+      });
+      await Promise.resolve();
 
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
 
+    test('deleted clears deferred idle close and later terminal update is no-op', async () => {
+      const ctx = createMockContext();
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'deleted-deferred',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+      });
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-deleted-deferred',
+      });
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+        board,
+      );
+      board.setTerminalStateListener((taskID) => {
+        void manager.retryDeferredIdleClose(taskID);
+      });
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'deleted-deferred', parentID: 'parent-1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: { sessionID: 'deleted-deferred', status: { type: 'idle' } },
+      });
       await manager.onSessionDeleted({
         type: 'session.deleted',
-        properties: { info: { id: 'background-child' } },
+        properties: { sessionID: 'deleted-deferred' },
       });
+      board.updateStatus({ taskID: 'deleted-deferred', state: 'completed' });
+      await Promise.resolve();
 
       expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
       expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
-        'p-background-child',
+        'p-deleted-deferred',
       );
     });
 
-    test('does not close long-running pane based on age alone', async () => {
+    test('retry while still running keeps deferred idle close', async () => {
       const ctx = createMockContext();
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'still-running-deferred',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+      });
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
-        paneId: 'p-long-running',
+        paneId: 'p-still-running-deferred',
       });
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
+        board,
       );
+      board.setTerminalStateListener((taskID) => {
+        void manager.retryDeferredIdleClose(taskID);
+      });
 
       await manager.onSessionCreated({
         type: 'session.created',
-        properties: { info: { id: 'long-running', parentID: 'p1' } },
+        properties: {
+          info: { id: 'still-running-deferred', parentID: 'parent-1' },
+        },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'still-running-deferred',
+          status: { type: 'idle' },
+        },
       });
 
-      const tracked = (manager as any).sessions.get('long-running');
-      tracked.createdAt = Date.now() - 11 * 60 * 1000;
-
-      setMockSessionStatuses({ 'long-running': { type: 'running' } });
-      await (manager as any).pollSessions();
-
+      await manager.retryDeferredIdleClose('still-running-deferred');
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      board.updateStatus({
+        taskID: 'still-running-deferred',
+        state: 'completed',
+      });
+      await Promise.resolve();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-still-running-deferred',
+      );
     });
 
-    test('keeps missing running background job pane open', async () => {
+    test('explicit non-idle poll clears stale deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
       board.registerLaunch({
-        taskID: 'running-background-job',
+        taskID: 'resumed-deferred',
         parentSessionID: 'parent-1',
         agent: 'explorer',
       });
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
-        paneId: 'p-running-background-job',
+        paneId: 'p-resumed-deferred',
       });
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
         board,
       );
+      board.setTerminalStateListener((taskID) => {
+        void manager.retryDeferredIdleClose(taskID);
+      });
 
       await manager.onSessionCreated({
         type: 'session.created',
+        properties: { info: { id: 'resumed-deferred', parentID: 'parent-1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
         properties: {
-          info: { id: 'running-background-job', parentID: 'parent-1' },
+          sessionID: 'resumed-deferred',
+          status: { type: 'idle' },
         },
       });
+      setMockSessionStatuses({ 'resumed-deferred': { type: 'busy' } });
+      await (manager as any).pollSessions();
 
-      const tracked = (manager as any).sessions.get('running-background-job');
-      tracked.missingSince = Date.now() - 60_000;
+      board.updateStatus({ taskID: 'resumed-deferred', state: 'completed' });
+      await Promise.resolve();
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'resumed-deferred',
+          status: { type: 'idle' },
+        },
+      });
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-resumed-deferred',
+      );
+    });
+
+    test('does not close on transient status absence', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      });
 
       setMockSessionStatuses({});
       await (manager as any).pollSessions();
@@ -527,7 +689,47 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('closes never-seen pane when no running background job exists', async () => {
+    test('keeps background child pane open while status is running until deleted', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      mockMultiplexer.spawnPane.mockResolvedValueOnce({
+        success: true,
+        paneId: 'p-background-child',
+      });
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'background-child',
+            parentID: 'parent-1',
+            title: 'Background Worker',
+          },
+        },
+      });
+
+      setMockSessionStatuses({ 'background-child': { type: 'running' } });
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      await manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { info: { id: 'background-child' } },
+      });
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-background-child',
+      );
+    });
+
+    test('missing status does not close never-seen pane', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
@@ -543,15 +745,10 @@ describe('MultiplexerSessionManager', () => {
         properties: { info: { id: 'never-seen-orphan', parentID: 'p1' } },
       });
 
-      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).toHaveBeenCalledWith(
-        'p-never-seen-orphan',
-      );
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
     test('ignores empty session status response without closing panes', async () => {
@@ -570,9 +767,6 @@ describe('MultiplexerSessionManager', () => {
         properties: { info: { id: 'empty-status', 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 }),
       );
@@ -582,7 +776,7 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
-    test('keeps missing cleanup for sessions previously seen in status', async () => {
+    test('previously seen then missing does not close pane', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
@@ -601,15 +795,40 @@ describe('MultiplexerSessionManager', () => {
       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();
 
-      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
-        'p-seen-before-missing',
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('missing then busy does not duplicate respawn', async () => {
+      const ctx = createMockContext();
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-missing-then-busy',
+      });
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
       );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'missing-then-busy', parentID: 'p1' } },
+      });
+
+      setMockSessionStatuses({});
+      await (manager as any).pollSessions();
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'missing-then-busy',
+          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 () => {

+ 27 - 51
src/multiplexer/session-manager.ts

@@ -16,10 +16,6 @@ interface TrackedSession {
   title: string;
   directory: string;
   ownerInstanceId: string;
-  createdAt: number;
-  lastSeenAt: number;
-  seenInStatus: boolean;
-  missingSince?: number;
 }
 
 interface KnownSession {
@@ -33,6 +29,7 @@ interface SharedSessionState {
   knownSessions: Map<string, KnownSession>;
   spawningSessions: Set<string>;
   closingSessions: Map<string, Promise<void>>;
+  deferredIdleCloses: Set<string>;
 }
 
 interface SessionEvent {
@@ -49,9 +46,8 @@ interface SessionEvent {
   };
 }
 
-type CloseReason = 'idle' | 'deleted' | 'missing';
+type CloseReason = 'idle' | 'deleted';
 
-const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 const SHARED_STATE_KEY = Symbol.for(
   'oh-my-opencode-slim.multiplexer-session-manager.state',
 );
@@ -66,6 +62,7 @@ function getSharedState(): SharedSessionState {
     knownSessions: new Map(),
     spawningSessions: new Set(),
     closingSessions: new Map(),
+    deferredIdleCloses: new Set(),
   };
 
   return globalWithState[SHARED_STATE_KEY];
@@ -77,6 +74,7 @@ export function resetMultiplexerSessionManagerState(): void {
   state.knownSessions.clear();
   state.spawningSessions.clear();
   state.closingSessions.clear();
+  state.deferredIdleCloses.clear();
 }
 
 /**
@@ -94,6 +92,7 @@ export class MultiplexerSessionManager {
   private knownSessions: SharedSessionState['knownSessions'];
   private spawningSessions: SharedSessionState['spawningSessions'];
   private closingSessions: SharedSessionState['closingSessions'];
+  private deferredIdleCloses: SharedSessionState['deferredIdleCloses'];
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
 
@@ -107,6 +106,7 @@ export class MultiplexerSessionManager {
     this.knownSessions = sharedState.knownSessions;
     this.spawningSessions = sharedState.spawningSessions;
     this.closingSessions = sharedState.closingSessions;
+    this.deferredIdleCloses = sharedState.deferredIdleCloses;
 
     this.directory = ctx.directory;
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
@@ -218,7 +218,6 @@ export class MultiplexerSessionManager {
         return;
       }
 
-      const now = Date.now();
       this.sessions.set(sessionId, {
         sessionId,
         paneId: paneResult.paneId,
@@ -226,9 +225,6 @@ export class MultiplexerSessionManager {
         title,
         directory,
         ownerInstanceId: this.instanceId,
-        createdAt: now,
-        lastSeenAt: now,
-        seenInStatus: false,
       });
 
       log('[multiplexer-session-manager] pane spawned', {
@@ -282,6 +278,7 @@ export class MultiplexerSessionManager {
     }
 
     if (event.properties?.status?.type === 'busy') {
+      this.deferredIdleCloses.delete(sessionId);
       log('[multiplexer-session-manager] session busy event received', {
         instanceId: this.instanceId,
         sessionId,
@@ -310,6 +307,7 @@ export class MultiplexerSessionManager {
       backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
     });
 
+    this.deferredIdleCloses.delete(sessionId);
     await this.closeSession(sessionId, 'deleted');
   }
 
@@ -344,9 +342,7 @@ export class MultiplexerSessionManager {
     try {
       const allStatuses = await this.fetchSessionStatuses();
 
-      const now = Date.now();
-      const sessionsToClose: Array<{ sessionId: string; reason: CloseReason }> =
-        [];
+      const sessionsToClose: string[] = [];
 
       for (const [sessionId, tracked] of this.sessions.entries()) {
         if (tracked.ownerInstanceId !== this.instanceId) {
@@ -360,44 +356,18 @@ 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;
 
-        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,
-            reason: isIdle ? 'idle' : 'missing',
-          });
+        if (status.type !== 'idle') {
+          this.deferredIdleCloses.delete(sessionId);
+          continue;
         }
+
+        sessionsToClose.push(sessionId);
       }
 
-      for (const { sessionId, reason } of sessionsToClose) {
-        await this.closeSession(sessionId, reason);
+      for (const sessionId of sessionsToClose) {
+        await this.closeSession(sessionId, 'idle');
       }
     } catch (err) {
       log('[multiplexer-session-manager] poll error', { error: String(err) });
@@ -434,6 +404,7 @@ export class MultiplexerSessionManager {
   ): Promise<void> {
     if (reason === 'deleted') {
       this.knownSessions.delete(sessionId);
+      this.deferredIdleCloses.delete(sessionId);
     }
 
     const existingClose = this.closingSessions.get(sessionId);
@@ -472,6 +443,7 @@ export class MultiplexerSessionManager {
     }
 
     if (reason === 'idle' && this.isRunningBackgroundJob(sessionId)) {
+      this.deferredIdleCloses.add(sessionId);
       log(
         '[multiplexer-session-manager] close skipped; background job running',
         {
@@ -485,6 +457,7 @@ export class MultiplexerSessionManager {
       return;
     }
 
+    this.deferredIdleCloses.delete(sessionId);
     this.sessions.delete(sessionId);
 
     log('[multiplexer-session-manager] closing session pane', {
@@ -590,7 +563,6 @@ export class MultiplexerSessionManager {
         return;
       }
 
-      const now = Date.now();
       this.sessions.set(sessionId, {
         sessionId,
         paneId: paneResult.paneId,
@@ -598,10 +570,8 @@ export class MultiplexerSessionManager {
         title: known.title,
         directory: known.directory,
         ownerInstanceId: this.instanceId,
-        createdAt: now,
-        lastSeenAt: now,
-        seenInStatus: false,
       });
+      this.deferredIdleCloses.delete(sessionId);
 
       log('[multiplexer-session-manager] pane respawned on busy', {
         instanceId: this.instanceId,
@@ -635,6 +605,11 @@ export class MultiplexerSessionManager {
     return this.backgroundJobBoard?.get(sessionId)?.state === 'running';
   }
 
+  async retryDeferredIdleClose(sessionId: string): Promise<void> {
+    if (!this.deferredIdleCloses.has(sessionId)) return;
+    await this.closeSession(sessionId, 'idle');
+  }
+
   async cleanup(): Promise<void> {
     this.stopPolling();
 
@@ -662,6 +637,7 @@ export class MultiplexerSessionManager {
     this.knownSessions.clear();
     this.spawningSessions.clear();
     this.closingSessions.clear();
+    this.deferredIdleCloses.clear();
 
     log('[multiplexer-session-manager] cleanup complete');
   }

+ 52 - 1
src/utils/background-job-board.test.ts

@@ -1,4 +1,4 @@
-import { describe, expect, test } from 'bun:test';
+import { describe, expect, mock, test } from 'bun:test';
 import { BackgroundJobBoard } from './background-job-board';
 
 describe('BackgroundJobBoard', () => {
@@ -368,6 +368,57 @@ describe('BackgroundJobBoard', () => {
     });
   });
 
+  test('notifies terminal listener on updateStatus terminal transition', () => {
+    const board = new BackgroundJobBoard();
+    const listener = mock(() => {});
+    board.setTerminalStateListener(listener);
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+
+    expect(listener).toHaveBeenCalledWith('ses_1');
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  test('notifies terminal listener on markCancelled mutation', () => {
+    const board = new BackgroundJobBoard();
+    const listener = mock(() => {});
+    board.setTerminalStateListener(listener);
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+
+    board.markCancelled('ses_1');
+
+    expect(listener).toHaveBeenCalledWith('ses_1');
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  test('does not notify terminal listener for running or stale updates', () => {
+    const board = new BackgroundJobBoard();
+    const listener = mock(() => {});
+    board.setTerminalStateListener(listener);
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+
+    board.updateStatus({ taskID: 'ses_1', state: 'running' });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+    listener.mockClear();
+    board.updateStatus({ taskID: 'ses_1', state: 'running' });
+    board.markCancelled('ses_1');
+
+    expect(listener).not.toHaveBeenCalled();
+  });
+
   test('cancelled jobs ignore late non-cancelled terminal statuses', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

+ 10 - 0
src/utils/background-job-board.ts

@@ -58,6 +58,8 @@ export interface BackgroundJobStatusInput {
   now?: number;
 }
 
+type TerminalStateListener = (taskID: string) => void;
+
 const TERMINAL_STATES = new Set<BackgroundJobState>([
   'completed',
   'error',
@@ -77,6 +79,7 @@ const AGENT_PREFIX: Record<string, string> = {
 export class BackgroundJobBoard {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
+  private terminalStateListener?: TerminalStateListener;
 
   private readonly maxReusablePerAgent: number;
   private readonly readContextMinLines: number;
@@ -88,6 +91,10 @@ export class BackgroundJobBoard {
     this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
   }
 
+  setTerminalStateListener(listener?: TerminalStateListener): void {
+    this.terminalStateListener = listener;
+  }
+
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
     const now = input.now ?? Date.now();
     const existing = this.jobs.get(input.taskID);
@@ -157,6 +164,7 @@ export class BackgroundJobBoard {
 
     const now = input.now ?? Date.now();
     const terminal = TERMINAL_STATES.has(input.state);
+    const notifyTerminal = terminal && !TERMINAL_STATES.has(existing.state);
     const updated: BackgroundJobRecord = {
       ...existing,
       state: input.state,
@@ -174,6 +182,7 @@ export class BackgroundJobBoard {
 
     this.jobs.set(input.taskID, updated);
     this.trimReusable(input.taskID);
+    if (notifyTerminal) this.terminalStateListener?.(input.taskID);
     return updated;
   }
 
@@ -274,6 +283,7 @@ export class BackgroundJobBoard {
     };
 
     this.jobs.set(taskID, updated);
+    this.terminalStateListener?.(taskID);
     return updated;
   }