Browse Source

fix(scheduler): cancel idle reconciliation timer on busy/error/deleted

Closes the busy-after-idle race where a session goes idle -> busy within
the 2s reconciliation window. The timer would still fire and mark injected
jobs as reconciled, causing late real completions to be silently dropped.

- Track pending timers in a Map keyed by session ID
- Cancel timer on session.error (fatal), session.status busy, session.deleted
- Timer callback cleans up its own map entry

@greptile-apps
Michael Henke 3 weeks ago
parent
commit
75c463b737
2 changed files with 37 additions and 10 deletions
  1. 9 6
      src/hooks/foreground-fallback/index.ts
  2. 28 4
      src/hooks/task-session-manager/index.ts

+ 9 - 6
src/hooks/foreground-fallback/index.ts

@@ -350,12 +350,15 @@ export class ForegroundFallbackManager {
         currentModel &&
         !chain.includes(currentModel)
       ) {
-        log('[foreground-fallback] current model not in chain, skipping fallback (runtimeOverride=false)', {
-          sessionID,
-          agentName,
-          currentModel,
-          chain,
-        });
+        log(
+          '[foreground-fallback] current model not in chain, skipping fallback (runtimeOverride=false)',
+          {
+            sessionID,
+            agentName,
+            currentModel,
+            chain,
+          },
+        );
         // Abort the session so the rate-limit error surfaces to the user
         // instead of leaving the session in a silent retry loop.
         await abortSessionWithTimeout(this.client, sessionID);

+ 28 - 4
src/hooks/task-session-manager/index.ts

@@ -47,6 +47,9 @@ const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
  */
 const IDLE_RECONCILE_DELAY_MS = 2_000;
 
+/** Track idle reconciliation timers to cancel on busy/error/deleted. */
+const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
+
 function djb2Hash(str: string): string {
   let hash = 5381;
   for (let i = 0; i < str.length; i++) {
@@ -615,10 +618,11 @@ export function createTaskSessionManagerHook(
           runningJobForSession: job?.state === 'running' || false,
         });
         if (sessionId && options.shouldManageSession(sessionId)) {
-          setTimeout(
-            () => reconcileInjectedTerminalJobs(sessionId),
-            IDLE_RECONCILE_DELAY_MS,
-          ).unref?.();
+          const timer = setTimeout(() => {
+            idleReconcileTimers.delete(sessionId);
+            reconcileInjectedTerminalJobs(sessionId);
+          }, IDLE_RECONCILE_DELAY_MS).unref?.();
+          idleReconcileTimers.set(sessionId, timer);
         }
 
         // Fallback: for background child sessions that go idle without
@@ -658,6 +662,13 @@ export function createTaskSessionManagerHook(
       if (input.event.type === 'session.error') {
         const sessionId =
           input.event.properties?.info?.id || input.event.properties?.sessionID;
+        if (sessionId) {
+          const timer = idleReconcileTimers.get(sessionId);
+          if (timer) {
+            clearTimeout(timer);
+            idleReconcileTimers.delete(sessionId);
+          }
+        }
         if (sessionId && options.shouldManageSession(sessionId)) {
           // Only clear injected terminal jobs for fatal errors.
           // Rate-limit errors are recovered by ForegroundFallbackManager
@@ -682,6 +693,13 @@ export function createTaskSessionManagerHook(
       ) {
         const sessionId =
           input.event.properties?.info?.id || input.event.properties?.sessionID;
+        if (sessionId) {
+          const timer = idleReconcileTimers.get(sessionId);
+          if (timer) {
+            clearTimeout(timer);
+            idleReconcileTimers.delete(sessionId);
+          }
+        }
         const before = sessionId
           ? backgroundJobBoard.get(sessionId)
           : undefined;
@@ -718,6 +736,12 @@ export function createTaskSessionManagerHook(
         input.event.properties?.info?.id || input.event.properties?.sessionID;
       if (!sessionId) return;
 
+      const timer = idleReconcileTimers.get(sessionId);
+      if (timer) {
+        clearTimeout(timer);
+        idleReconcileTimers.delete(sessionId);
+      }
+
       log('[task-session-manager] session.deleted observed', {
         sessionID: sessionId,
       });