Browse Source

Guard for no stops

Alvin Unreal 2 months ago
parent
commit
ca85d6ffb7

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

@@ -824,6 +824,33 @@ describe('task-session-manager hook', () => {
     );
   });
 
+  test('reopens stale cancelled child job when child session becomes busy', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'read internals',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'cancelled' });
+    board.markReconciled('child-1');
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'child-1', status: { type: 'busy' } },
+      },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+    expect(board.get('child-1')?.terminalState).toBeUndefined();
+  });
+
   test('does not reconcile terminal jobs before they are injected into a prompt', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -940,7 +967,7 @@ describe('task-session-manager hook', () => {
     expect(resume.args.task_id).toBe('child-1');
   });
 
-  test('unreconciled or failed jobs do not resolve as reusable task sessions', async () => {
+  test('non-running jobs resolve as reusable task sessions even before reconciliation', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
 
@@ -967,19 +994,22 @@ describe('task-session-manager hook', () => {
       { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
       unreconciled,
     );
-    expect(unreconciled.args.task_id).toBeUndefined();
+    expect(unreconciled.args.task_id).toBe('done-1');
 
     const failed = { args: { subagent_type: 'oracle', task_id: 'ora-2' } };
     await hook['tool.execute.before'](
       { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
       failed,
     );
-    expect(failed.args.task_id).toBeUndefined();
+    expect(failed.args.task_id).toBe('err-1');
 
     const messages = createMessages('parent-1', 'continue');
     await hook['experimental.chat.messages.transform']({}, messages);
-    expect(messages.messages[0].parts[0].text).not.toContain(
-      'err-1 / oracle / completed, reconciled',
+    expect(messages.messages[0].parts[0].text).toContain(
+      'ora-1 / done-1 / oracle / completed, unreconciled',
+    );
+    expect(messages.messages[0].parts[0].text).toContain(
+      'ora-2 / err-1 / oracle / error, reconciled',
     );
   });
 

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

@@ -11,6 +11,7 @@ import {
   parseTaskStatusOutput,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
+import { log } from '../../utils/logger';
 
 interface TaskArgs {
   description?: unknown;
@@ -252,13 +253,35 @@ export function createTaskSessionManagerHook(
     const status = parseTaskStatusOutput(output);
     if (!status) return undefined;
 
+    log('[task-session-manager] parsed task status output', {
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      hasResult: Boolean(status.result),
+    });
+
     const updated = backgroundJobBoard.updateStatus({
       taskID: status.taskID,
       state: status.state,
       timedOut: status.timedOut,
       resultSummary: status.result,
     });
-    if (!updated) return undefined;
+    if (!updated) {
+      log('[task-session-manager] ignored status for unknown background job', {
+        taskID: status.taskID,
+        state: status.state,
+      });
+      return undefined;
+    }
+
+    log('[task-session-manager] background job status updated', {
+      taskID: updated.taskID,
+      alias: updated.alias,
+      parentSessionID: updated.parentSessionID,
+      state: updated.state,
+      terminalUnreconciled: updated.terminalUnreconciled,
+      timedOut: updated.timedOut,
+    });
 
     if (updated.terminalUnreconciled) {
       pendingManagedTaskIds.delete(updated.taskID);
@@ -306,6 +329,14 @@ export function createTaskSessionManagerHook(
     const updated = updateBackgroundJobFromOutput(part.text);
     if (!updated) return undefined;
 
+    log('[task-session-manager] processed injected background completion', {
+      taskID: updated.taskID,
+      alias: updated.alias,
+      parentSessionID: updated.parentSessionID,
+      state: updated.state,
+      occurrenceId,
+    });
+
     rememberProcessedInjectedCompletion(occurrenceId);
     return updated;
   }
@@ -395,6 +426,11 @@ export function createTaskSessionManagerHook(
       .map((job) => job.taskID);
     if (taskIDs.length === 0) return;
 
+    log('[task-session-manager] terminal jobs injected for reconciliation', {
+      parentSessionID,
+      taskIDs,
+    });
+
     const existing =
       terminalJobsInjectedByParent.get(parentSessionID) ?? new Set<string>();
     for (const taskID of taskIDs) {
@@ -407,6 +443,11 @@ export function createTaskSessionManagerHook(
     const taskIDs = terminalJobsInjectedByParent.get(parentSessionID);
     if (!taskIDs) return;
 
+    log('[task-session-manager] reconciling injected terminal jobs', {
+      parentSessionID,
+      taskIDs: [...taskIDs],
+    });
+
     for (const taskID of taskIDs) {
       backgroundJobBoard.markReconciled(taskID);
     }
@@ -516,13 +557,21 @@ export function createTaskSessionManagerHook(
       if (!pending || typeof output.output !== 'string') return;
       const launch = parseTaskLaunchOutput(output.output);
       if (launch) {
-        backgroundJobBoard.registerLaunch({
+        const record = backgroundJobBoard.registerLaunch({
           taskID: launch.taskID,
           parentSessionID: pending.parentSessionId,
           agent: pending.agentType,
           description: pending.label,
           objective: pending.label,
         });
+        log('[task-session-manager] background task launch registered', {
+          taskID: record.taskID,
+          alias: record.alias,
+          parentSessionID: record.parentSessionID,
+          agent: record.agent,
+          description: record.description,
+          state: record.state,
+        });
         backgroundJobBoard.addContext(
           launch.taskID,
           contextFilesForPrompt(contextByTask.get(launch.taskID)),
@@ -617,6 +666,13 @@ export function createTaskSessionManagerHook(
     }): Promise<void> => {
       if (input.event.type === 'session.created') {
         const info = input.event.properties?.info;
+        log('[task-session-manager] session.created observed', {
+          sessionID: info?.id,
+          parentSessionID: info?.parentID,
+          managesParent: info?.parentID
+            ? options.shouldManageSession(info.parentID)
+            : false,
+        });
         if (
           info?.id &&
           info.parentID &&
@@ -635,6 +691,15 @@ export function createTaskSessionManagerHook(
       ) {
         const sessionId =
           input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        log('[task-session-manager] idle/status idle observed', {
+          sessionID: sessionId,
+          managesSession: sessionId
+            ? options.shouldManageSession(sessionId)
+            : false,
+          terminalJobsPending: sessionId
+            ? (terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+            : 0,
+        });
         if (sessionId && options.shouldManageSession(sessionId)) {
           reconcileInjectedTerminalJobs(sessionId);
         }
@@ -647,6 +712,32 @@ export function createTaskSessionManagerHook(
         if (sessionId && options.shouldManageSession(sessionId)) {
           terminalJobsInjectedByParent.delete(sessionId);
         }
+
+        return;
+      }
+
+      if (
+        input.event.type === 'session.status' &&
+        (input.event.properties as { status?: { type?: string } } | undefined)
+          ?.status?.type === 'busy'
+      ) {
+        const sessionId =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        const before = sessionId
+          ? backgroundJobBoard.get(sessionId)
+          : undefined;
+        const updated = sessionId
+          ? backgroundJobBoard.markRunningFromLiveSession(sessionId)
+          : undefined;
+        log('[task-session-manager] busy/status busy observed', {
+          sessionID: sessionId,
+          managesSession: sessionId
+            ? options.shouldManageSession(sessionId)
+            : false,
+          previousState: before?.state,
+          previousTerminalState: before?.terminalState,
+          updatedState: updated?.state,
+        });
         return;
       }
 
@@ -655,6 +746,23 @@ export function createTaskSessionManagerHook(
         input.event.properties?.info?.id ?? input.event.properties?.sessionID;
       if (!sessionId) return;
 
+      log(
+        '[task-session-manager] session.deleted observed; clearing job state',
+        {
+          sessionID: sessionId,
+          deletedJob: backgroundJobBoard.get(sessionId)
+            ? {
+                state: backgroundJobBoard.get(sessionId)?.state,
+                parentSessionID:
+                  backgroundJobBoard.get(sessionId)?.parentSessionID,
+                alias: backgroundJobBoard.get(sessionId)?.alias,
+              }
+            : undefined,
+          childJobCount: backgroundJobBoard.list(sessionId).length,
+          managesSession: options.shouldManageSession(sessionId),
+        },
+      );
+
       backgroundJobBoard.drop(sessionId);
       backgroundJobBoard.clearParent(sessionId);
       terminalJobsInjectedByParent.delete(sessionId);

+ 15 - 0
src/multiplexer/session-manager.ts

@@ -253,6 +253,7 @@ export class MultiplexerSessionManager {
         sessionId,
         tracked: this.sessions.has(sessionId),
         known: this.knownSessions.has(sessionId),
+        backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
       });
 
       await this.closeSession(sessionId, 'idle');
@@ -265,6 +266,13 @@ export class MultiplexerSessionManager {
     if (!sessionId) return;
 
     if (event.properties?.status?.type === 'idle') {
+      log('[multiplexer-session-manager] session status idle received', {
+        instanceId: this.instanceId,
+        sessionId,
+        tracked: this.sessions.has(sessionId),
+        known: this.knownSessions.has(sessionId),
+        backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
+      });
       await this.closeSession(sessionId, 'idle');
       return;
     }
@@ -275,6 +283,7 @@ export class MultiplexerSessionManager {
         sessionId,
         tracked: this.sessions.has(sessionId),
         known: this.knownSessions.has(sessionId),
+        backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
       });
       await this.respawnIfKnown(sessionId);
     }
@@ -290,6 +299,9 @@ export class MultiplexerSessionManager {
     log('[multiplexer-session-manager] session deleted, closing pane', {
       instanceId: this.instanceId,
       sessionId,
+      tracked: this.sessions.has(sessionId),
+      known: this.knownSessions.has(sessionId),
+      backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
     });
 
     await this.closeSession(sessionId, 'deleted');
@@ -430,6 +442,9 @@ export class MultiplexerSessionManager {
       sessionId,
       paneId: tracked.paneId,
       reason,
+      backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
+      parentId: tracked.parentId,
+      title: tracked.title,
     });
 
     const closePromise: Promise<void> = this.multiplexer

+ 65 - 0
src/utils/background-job-board.test.ts

@@ -340,6 +340,71 @@ describe('BackgroundJobBoard', () => {
     });
   });
 
+  test('live busy session reopens stale cancelled jobs', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({
+      taskID: 'ses_1',
+      state: 'cancelled',
+      resultSummary: 'upstream cancelled during compaction',
+      now: 100,
+    });
+
+    const updated = board.markRunningFromLiveSession('ses_1', 200);
+
+    expect(updated).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+      timedOut: false,
+      updatedAt: 200,
+    });
+    expect(updated?.completedAt).toBeUndefined();
+    expect(updated?.terminalState).toBeUndefined();
+    expect(updated?.resultSummary).toBeUndefined();
+  });
+
+  test('live busy session reopens reconciled stale cancellations', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'cancelled', now: 100 });
+    board.markReconciled('ses_1', 150);
+
+    const updated = board.markRunningFromLiveSession('ses_1', 200);
+
+    expect(updated).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+      updatedAt: 200,
+    });
+    expect(updated?.terminalState).toBeUndefined();
+  });
+
+  test('live busy session does not reopen non-cancelled terminal jobs', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed', now: 100 });
+
+    const updated = board.markRunningFromLiveSession('ses_1', 200);
+
+    expect(updated).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      completedAt: 100,
+    });
+  });
+
   test('stale status updates cannot reopen already reconciled jobs', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

+ 40 - 3
src/utils/background-job-board.ts

@@ -174,6 +174,39 @@ export class BackgroundJobBoard {
     });
   }
 
+  markRunningFromLiveSession(
+    taskID: string,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(taskID);
+    if (!existing) return undefined;
+
+    // Temporary mitigation for https://github.com/anomalyco/opencode/issues/28995:
+    // OpenCode can report task_status=cancelled after compaction while the child
+    // session keeps running. Trust live session.status=busy over that stale
+    // BackgroundJob terminal state, including after the stale cancellation was
+    // already injected/reconciled into the parent prompt.
+    const isStaleCancellation =
+      existing.state === 'cancelled' ||
+      (existing.state === 'reconciled' &&
+        existing.terminalState === 'cancelled');
+    if (!isStaleCancellation) return existing;
+
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      state: 'running',
+      timedOut: false,
+      terminalUnreconciled: false,
+      updatedAt: now,
+      completedAt: undefined,
+      terminalState: undefined,
+      resultSummary: undefined,
+    };
+
+    this.jobs.set(taskID, updated);
+    return updated;
+  }
+
   markReconciled(
     taskID: string,
     now = Date.now(),
@@ -321,7 +354,7 @@ export class BackgroundJobBoard {
     return [
       '### Background Job Board',
       'SENTINEL: background-job-board-v2',
-      'Use task_status for running jobs. Reconcile terminal jobs before final response. Reuse only completed/reconciled sessions for the same specialist/context.',
+      'Use task_status for running jobs. Reconcile terminal jobs before final response. Reuse any non-running session for the same specialist/context.',
       '',
       '#### Active / Unreconciled',
       ...(active.length > 0
@@ -359,8 +392,12 @@ export class BackgroundJobBoard {
   }
 
   private formatReusableJob(job: BackgroundJobRecord): string {
+    const terminal = job.terminalState ?? terminalStateOf(job.state);
+    const reconciliation = job.terminalUnreconciled
+      ? 'unreconciled'
+      : 'reconciled';
     const lines = [
-      `- ${job.alias} / ${job.taskID} / ${job.agent} / completed, reconciled`,
+      `- ${job.alias} / ${job.taskID} / ${job.agent} / ${terminal ?? job.state}, ${reconciliation}`,
       `  Objective: ${job.objective || job.description}`,
     ];
     const context = formatContextFiles(
@@ -398,7 +435,7 @@ export function deriveTaskSessionLabel(input: {
 }
 
 function isReusable(job: BackgroundJobRecord): boolean {
-  return job.state === 'reconciled' && job.terminalState === 'completed';
+  return job.state !== 'running';
 }
 
 function terminalStateOf(