Browse Source

fix(scheduler): reconcile running jobs when child session goes idle

Child background sessions going idle were ignored by the session.idle handler (filtered by shouldManageSession). If the injected completion message was lost, the job stayed running on the board forever.

Fix: when any session tracked by the board as running goes idle, mark it completed and reconcile. The idle event is itself the completion signal.

Closes issue #658.
Michael Henke 1 month ago
parent
commit
19ed2780f8

+ 48 - 0
src/hooks/task-session-manager/index.test.ts

@@ -1824,6 +1824,54 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toBe('do something');
   });
 
+  test('reconciles running child session job from session.idle event', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: (id) => id === 'parent-1',
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
+  test('ignores session.idle for already reconciled job', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+    board.markReconciled('child-1');
+
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
   test('parent deletion clears jobs and pending calls', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

+ 25 - 0
src/hooks/task-session-manager/index.ts

@@ -582,9 +582,34 @@ export function createTaskSessionManagerHook(
           terminalJobsPending: sessionId
             ? (terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
             : 0,
+          runningJobForSession: sessionId
+            ? backgroundJobBoard.get(sessionId)?.state === 'running' || false
+            : false,
         });
         if (sessionId && options.shouldManageSession(sessionId)) {
           reconcileInjectedTerminalJobs(sessionId);
+          return;
+        }
+
+        // Fallback: for background child sessions that go idle without
+        // an injected completion, reconcile the board entry since the
+        // session being idle is itself the completion signal.
+        if (sessionId) {
+          const job = backgroundJobBoard.get(sessionId);
+          if (job && job.state === 'running') {
+            log('[task-session-manager] reconciled running job from idle', {
+              sessionID: sessionId,
+              alias: job.alias,
+              parentSessionID: job.parentSessionID,
+            });
+            backgroundJobBoard.updateStatus({
+              taskID: sessionId,
+              state: 'completed',
+              resultSummary:
+                'Background task completed (reconciled from idle event)',
+            });
+            backgroundJobBoard.markReconciled(sessionId);
+          }
         }
         return;
       }