Browse Source

fix(task): gate timeout recovery on live busy

Alvin Unreal 1 month ago
parent
commit
4fedcbd951

+ 4 - 2
src/hooks/task-session-manager/codemap.md

@@ -24,8 +24,10 @@ sessions by short aliases (`exp-1`, `ora-2`).
 ## Flow
 
 1. `tool.execute.before` receives `task` calls.
-2. `task.task_id` aliases resolve only to completed/reconciled jobs for the same
-   specialist; misses remove `task_id` to force fresh task creation.
+2. `task.task_id` aliases resolve to completed/reconciled jobs for the same
+   specialist, or to timed-out running jobs after a live busy signal confirms
+   they are safe to resume; misses remove `task_id` to force fresh task
+   creation.
 3. `tool.execute.after` registers launches and status transitions from native V2
    output; bare task IDs without state do not create reusable jobs.
 5. Read context from child sessions is attached to board records with line-count

+ 76 - 2
src/hooks/task-session-manager/index.test.ts

@@ -207,7 +207,7 @@ describe('task-session-manager hook', () => {
     );
   });
 
-  test('reuses timed-out running aliases for safe recovery', async () => {
+  test('reuses timed-out running aliases after live busy recovery', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
 
@@ -234,6 +234,20 @@ describe('task-session-manager hook', () => {
       },
     );
 
+    expect(
+      board.resolveRecoverable('parent-1', 'exp-1', 'explorer')?.taskID,
+    ).toBeUndefined();
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-1',
+          status: { type: 'busy' },
+        },
+      },
+    });
+
     expect(
       board.resolveRecoverable('parent-1', 'exp-1', 'explorer')?.taskID,
     ).toBe('child-1');
@@ -249,10 +263,69 @@ describe('task-session-manager hook', () => {
     expect(resume.args.task_id).toBe('child-1');
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
-      timedOut: true,
+      timedOut: false,
+      recoverableAfterLiveBusy: true,
     });
   });
 
+  test('does not bypass live busy recovery gate for known raw session ids', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'map timed out session',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: [
+          'task_id: ses_timeout',
+          'state: running',
+          '',
+          '<task_result>',
+          'Timed out after 120000ms while waiting for task completion.',
+          '</task_result>',
+        ].join('\n'),
+      },
+    );
+
+    const resumeBeforeLiveBusy = {
+      args: { subagent_type: 'explorer', task_id: 'ses_timeout' },
+    };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+      resumeBeforeLiveBusy,
+    );
+
+    expect(resumeBeforeLiveBusy.args.task_id).toBeUndefined();
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: {
+          sessionID: 'ses_timeout',
+          status: { type: 'busy' },
+        },
+      },
+    });
+
+    const resumeAfterLiveBusy = {
+      args: { subagent_type: 'explorer', task_id: 'ses_timeout' },
+    };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume-2' },
+      resumeAfterLiveBusy,
+    );
+
+    expect(resumeAfterLiveBusy.args.task_id).toBe('ses_timeout');
+  });
+
   test('busy timeout recovery clears timeout overlay from prompt', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -299,6 +372,7 @@ describe('task-session-manager hook', () => {
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
       timedOut: false,
+      recoverableAfterLiveBusy: true,
       statusUncertain: false,
     });
 

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

@@ -524,6 +524,15 @@ export function createTaskSessionManagerHook(
         );
 
       if (!remembered) {
+        const knownManagedTask = backgroundJobBoard.resolve(
+          input.sessionID,
+          requested,
+        );
+        if (knownManagedTask) {
+          delete args.task_id;
+          return;
+        }
+
         if (RAW_SESSION_ID_PATTERN.test(requested)) {
           pendingCall.resumedTaskId = requested;
           rememberPendingCall(pendingCall);

+ 8 - 5
src/multiplexer/session-manager.test.ts

@@ -454,13 +454,16 @@ describe('MultiplexerSessionManager', () => {
 
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
 
-      board.markRunningFromLiveSession('timedout-child', 200);
-      expect(board.get('timedout-child')).toMatchObject({
-        state: 'running',
-        timedOut: false,
-        lastLiveBusyAt: 200,
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'timedout-child',
+          status: { type: 'busy' },
+        },
       });
 
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
       board.updateStatus({
         taskID: 'timedout-child',
         state: 'completed',

+ 4 - 3
src/multiplexer/session-manager.ts

@@ -280,9 +280,10 @@ export class MultiplexerSessionManager {
     }
 
     if (statusType) {
-      this.deferredIdleCloses.delete(sessionId);
-
-      if (statusType !== 'busy') return;
+      if (statusType !== 'busy') {
+        this.deferredIdleCloses.delete(sessionId);
+        return;
+      }
 
       log('[multiplexer-session-manager] session busy event received', {
         instanceId: this.instanceId,

+ 11 - 2
src/utils/background-job-board.test.ts

@@ -605,6 +605,7 @@ describe('BackgroundJobBoard', () => {
     expect(updated).toMatchObject({
       state: 'running',
       timedOut: false,
+      recoverableAfterLiveBusy: true,
       statusUncertain: false,
       lastLiveBusyAt: 200,
       updatedAt: 200,
@@ -612,7 +613,7 @@ describe('BackgroundJobBoard', () => {
     });
   });
 
-  test('resolves timed-out running jobs for safe recovery only', () => {
+  test('resolves timed-out running jobs only after live busy recovery', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
       taskID: 'ses_1',
@@ -628,12 +629,20 @@ describe('BackgroundJobBoard', () => {
     expect(
       board.resolveReusable('parent-1', 'exp-1', 'explorer'),
     ).toBeUndefined();
+    expect(
+      board.resolveRecoverable('parent-1', 'exp-1', 'explorer'),
+    ).toBeUndefined();
+
+    board.markRunningFromLiveSession('ses_1', 200);
+
     expect(
       board.resolveRecoverable('parent-1', 'exp-1', 'explorer'),
     ).toMatchObject({
       taskID: 'ses_1',
       state: 'running',
-      timedOut: true,
+      timedOut: false,
+      recoverableAfterLiveBusy: true,
+      lastLiveBusyAt: 200,
     });
     expect(
       board.resolveRecoverable('parent-1', 'exp-1', 'oracle'),

+ 15 - 1
src/utils/background-job-board.ts

@@ -17,6 +17,7 @@ export interface BackgroundJobRecord {
   objective?: string;
   state: BackgroundJobState;
   timedOut: boolean;
+  recoverableAfterLiveBusy: boolean;
   statusUncertain: boolean;
   cancellationRequested: boolean;
   terminalUnreconciled: boolean;
@@ -107,6 +108,7 @@ export class BackgroundJobBoard {
         objective: input.objective ?? existing.objective,
         state: 'running',
         timedOut: false,
+        recoverableAfterLiveBusy: false,
         statusUncertain: false,
         cancellationRequested: false,
         terminalUnreconciled: false,
@@ -131,6 +133,7 @@ export class BackgroundJobBoard {
       objective: input.objective,
       state: 'running',
       timedOut: false,
+      recoverableAfterLiveBusy: false,
       statusUncertain: false,
       cancellationRequested: false,
       terminalUnreconciled: false,
@@ -169,6 +172,12 @@ export class BackgroundJobBoard {
       ...existing,
       state: input.state,
       timedOut: input.timedOut ?? false,
+      recoverableAfterLiveBusy:
+        input.state !== 'running'
+          ? false
+          : input.timedOut === true
+            ? false
+            : existing.recoverableAfterLiveBusy,
       statusUncertain: input.statusUncertain ?? false,
       terminalUnreconciled: terminal ? true : existing.terminalUnreconciled,
       updatedAt: now,
@@ -221,6 +230,8 @@ export class BackgroundJobBoard {
       updatedAt: now,
       lastLiveBusyAt: now,
       timedOut: false,
+      recoverableAfterLiveBusy:
+        existing.recoverableAfterLiveBusy || existing.timedOut,
       statusUncertain: false,
     };
 
@@ -276,6 +287,7 @@ export class BackgroundJobBoard {
       ...existing,
       state: 'cancelled',
       timedOut: false,
+      recoverableAfterLiveBusy: false,
       statusUncertain: false,
       cancellationRequested: true,
       terminalUnreconciled: true,
@@ -324,7 +336,9 @@ export class BackgroundJobBoard {
     const job = this.resolve(parentSessionID, taskIDOrAlias);
     if (!job) return undefined;
     if (agent && job.agent !== agent) return undefined;
-    if (job.state !== 'running' || !job.timedOut) return undefined;
+    if (job.state !== 'running' || !job.recoverableAfterLiveBusy) {
+      return undefined;
+    }
     return job;
   }