Browse Source

fix(task-session): cancel pending child idle-reconcile on session.deleted

Greptile P1: onSessionDeleted cancelled idleReconcileTimers via
clearContinuation but not childIdleReconcileTimers. During an FG
teardown race (idle → FG aborts → session.deleted → FG finishes
before timer fires) the pending timer could fire after
isFallbackInProgress flips false and falsely reconcile the board
entry to completed while the re-prompted session keeps working.
Cancel the child timer on session.deleted, matching the
server.instance.disposed path.
Jiajun0413 2 weeks ago
parent
commit
4a6f3cdf2d

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

@@ -2560,6 +2560,48 @@ describe('task-session-manager hook', () => {
     expect(board.get('child-b')).toMatchObject({ state: 'running' });
   });
 
+  test('session.deleted cancels pending child idle-reconcile (FG teardown race)', async () => {
+    // FG aborts the child session mid-idle-delay; onSessionDeleted must
+    // cancel the pending timer so it cannot fire after FG finishes and
+    // re-check isFallbackInProgress=false, falsely reconciling the board
+    // entry while the re-prompted session keeps working.
+    const coordinator = new SessionLifecycle(() => {});
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-b',
+      parentSessionID: 'parent-1',
+      agent: 'councillor-reviewer-b',
+      description: 'audit distributed',
+    });
+
+    let fgInProgress = false;
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      coordinator,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => fgInProgress,
+      idleReconcileDelayMs: 30,
+    });
+
+    // idle fires before FG sets isFallbackInProgress — schedules timer T.
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-b' } },
+    });
+    // FG claims the session and aborts it; OpenCode emits session.deleted
+    // while the timer is still pending. onSessionDeleted must cancel T.
+    fgInProgress = true;
+    coordinator.dispatchSessionDeleted('child-b');
+    // FG finishes; isFallbackInProgress goes false before T would fire.
+    fgInProgress = false;
+
+    await new Promise((r) => setTimeout(r, 60));
+    // Board entry survives (isFallbackInProgress was true at delete time)
+    // but is NOT reconciled — the timer was cancelled on session.deleted.
+    const job = board.get('child-b');
+    expect(job).toBeDefined();
+    expect(job?.state).toBe('running');
+  });
+
   test('session.created early-registers board job so after-hook cancellation cannot orphan the child', async () => {
     // Reproduces #765: parent tool may be cancelled before tool.execute.after,
     // so the job never lands on the board. Early registration from

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

@@ -501,6 +501,11 @@ export function createTaskSessionManagerHook(
     options.coordinator.onSessionDeleted((sessionId) => {
       clearContinuation(sessionId);
       clearInputWaits(sessionId);
+      const pendingChildIdle = childIdleReconcileTimers.get(sessionId);
+      if (pendingChildIdle) {
+        clearTimeout(pendingChildIdle);
+        childIdleReconcileTimers.delete(sessionId);
+      }
       // During a foreground fallback abort/re-prompt cycle, the session
       // is being torn down and immediately recreated with a fallback model.
       // Dropping the job from the board here would make the orchestrator