Przeglądaj źródła

feat(tasks): add opt-in wall-clock supervision

zhaohaofan 1 miesiąc temu
rodzic
commit
01e4d8b71e

+ 22 - 0
oh-my-opencode-slim.schema.json

@@ -1058,6 +1058,28 @@
           "default": false,
           "description": "Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.",
           "type": "boolean"
+        },
+        "wallClockTimeoutMs": {
+          "default": 0,
+          "description": "Explicit opt-in wall-clock deadline for native task(..., background: true) child sessions. 0 disables supervision; finite values are 60,000–2,147,483,647ms.",
+          "anyOf": [
+            {
+              "type": "number",
+              "const": 0
+            },
+            {
+              "type": "integer",
+              "minimum": 60000,
+              "maximum": 2147483647
+            }
+          ]
+        },
+        "abortGraceMs": {
+          "default": 10000,
+          "description": "Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).",
+          "type": "integer",
+          "minimum": 1000,
+          "maximum": 60000
         }
       }
     },

+ 55 - 0
src/config/schema.test.ts

@@ -118,4 +118,59 @@ describe('PluginConfigSchema backgroundJobs', () => {
       }).success,
     ).toBe(false);
   });
+
+  it('defaults the wall-clock supervisor to disabled with a 10 second grace', () => {
+    const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.wallClockTimeoutMs).toBe(0);
+      expect(result.data.backgroundJobs?.abortGraceMs).toBe(10_000);
+    }
+  });
+
+  it('accepts the documented wall-clock supervisor bounds', () => {
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: {
+          wallClockTimeoutMs: 0,
+          abortGraceMs: 1_000,
+        },
+      }).success,
+    ).toBe(true);
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: {
+          wallClockTimeoutMs: 60_000,
+          abortGraceMs: 60_000,
+        },
+      }).success,
+    ).toBe(true);
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: {
+          wallClockTimeoutMs: 2_147_483_647,
+        },
+      }).success,
+    ).toBe(true);
+  });
+
+  it('rejects wall-clock supervisor values outside the safe integer bounds', () => {
+    const invalid = [
+      { wallClockTimeoutMs: -1 },
+      { wallClockTimeoutMs: 1 },
+      { wallClockTimeoutMs: 59_999 },
+      { wallClockTimeoutMs: 2_147_483_648 },
+      { wallClockTimeoutMs: 60_000.5 },
+      { abortGraceMs: 999 },
+      { abortGraceMs: 60_001 },
+      { abortGraceMs: 1_000.5 },
+    ];
+
+    for (const backgroundJobs of invalid) {
+      expect(PluginConfigSchema.safeParse({ backgroundJobs }).success).toBe(
+        false,
+      );
+    }
+  });
 });

+ 15 - 0
src/config/schema.ts

@@ -222,6 +222,21 @@ export const BackgroundJobsConfigSchema = z.object({
     .describe(
       'Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.',
     ),
+  wallClockTimeoutMs: z
+    .union([z.literal(0), z.number().int().min(60_000).max(2_147_483_647)])
+    .default(0)
+    .describe(
+      'Explicit opt-in wall-clock deadline for native task(..., background: true) child sessions. 0 disables supervision; finite values are 60,000–2,147,483,647ms.',
+    ),
+  abortGraceMs: z
+    .number()
+    .int()
+    .min(1_000)
+    .max(60_000)
+    .default(10_000)
+    .describe(
+      'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
+    ),
 });
 
 export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;

+ 14 - 1
src/hooks/task-session-manager/event-router.ts

@@ -6,6 +6,7 @@
  * the appropriate subsystems.
  */
 import type { BackgroundJobStore } from '../../utils/background-job-store';
+import type { BackgroundJobSupervisor } from '../../utils/background-job-supervisor';
 import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
 import type { RetainedBoardSnapshotState } from './board-injection';
@@ -76,6 +77,7 @@ export async function handleEvent(
     };
     terminalJobsInjectedByParent: Map<string, Set<string>>;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
 ): Promise<void> {
   deps.inputWaits.trackInputWait(input.event);
@@ -122,9 +124,13 @@ export async function handleEvent(
           agent: pending.agentType,
           description: pending.label,
           objective: pending.label,
+          // session.created has no reliable call identity. Keep this
+          // registration tentative so an unrelated foreground call cannot
+          // accidentally arm wall-clock supervision.
+          background: false,
         });
         log(
-          '[task-session-manager] early board registration from session.created',
+          '[task-session-manager] tentative early board registration from session.created',
           {
             taskID: record.taskID,
             alias: record.alias,
@@ -138,6 +144,7 @@ export async function handleEvent(
   }
 
   if (input.event.type === 'server.instance.disposed') {
+    deps.backgroundJobSupervisor?.dispose();
     deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
     // Local-only: release this instance's uncommitted reservations and drop
@@ -307,6 +314,12 @@ export async function handleEvent(
   }
   deps.inputWaits.clearInputWaits(sessionId);
   deps.retainedBoardSnapshots.delete(sessionId);
+  const fallbackInProgress =
+    deps.options.isFallbackInProgress?.(sessionId) === true;
+  const job = deps.backgroundJobBoard.get(sessionId);
+  if (!fallbackInProgress || job?.deadlineExceededAt !== undefined) {
+    deps.backgroundJobSupervisor?.onSessionDeleted(sessionId);
+  }
 
   log('[task-session-manager] session.deleted observed', {
     sessionID: sessionId,

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

@@ -3,6 +3,7 @@ import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
 import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import {
   BackgroundJobBoard,
+  BackgroundJobSupervisor,
   createInternalAgentTextPart,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
@@ -35,6 +36,44 @@ async function flushChildIdleReconcile(): Promise<void> {
   await new Promise((resolve) => setTimeout(resolve, 5));
 }
 
+function createSupervisorClock() {
+  let now = 0;
+  let nextID = 0;
+  const timers = new Map<number, { at: number; callback: () => void }>();
+
+  const setTimeout = (callback: () => void, delay: number) => {
+    const id = ++nextID;
+    timers.set(id, { at: now + delay, callback });
+    return id;
+  };
+  const clearTimeout = (id: number) => timers.delete(id);
+  const advanceTo = async (target: number) => {
+    now = target;
+    while (true) {
+      const due = [...timers.entries()]
+        .filter(([, timer]) => timer.at <= now)
+        .sort(([, left], [, right]) => left.at - right.at)[0];
+      if (!due) break;
+      timers.delete(due[0]);
+      due[1].callback();
+      await Promise.resolve();
+    }
+  };
+
+  return { now: () => now, setTimeout, clearTimeout, advanceTo };
+}
+
+function taskLaunchOutput(taskID: string): string {
+  return [
+    `task_id: ${taskID}`,
+    'state: running',
+    '',
+    '<task_result>',
+    'Background task started.',
+    '</task_result>',
+  ].join('\n');
+}
+
 type HookOptions = {
   shouldManageSession?: (sessionID: string) => boolean;
   registerSessionAsOrchestrator?: (sessionID: string) => void;
@@ -49,6 +88,7 @@ type HookOptions = {
   idleReconcileDelayMs?: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   coordinator?: SessionLifecycle;
+  backgroundJobSupervisor?: BackgroundJobSupervisor;
 };
 
 function createHook(options?: HookOptions) {
@@ -72,6 +112,7 @@ function createHook(options?: HookOptions) {
       readContextMaxFiles: options?.readContextMaxFiles,
       continueOnIdle: options?.continueOnIdle ?? false,
       backgroundJobBoard: options?.backgroundJobBoard,
+      backgroundJobSupervisor: options?.backgroundJobSupervisor,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
       isFallbackInProgress: options?.isFallbackInProgress,
@@ -222,6 +263,7 @@ describe('task-session-manager hook', () => {
       {
         args: {
           subagent_type: 'explorer',
+          background: true,
           description: 'map scheduler hooks',
           prompt: 'inspect scheduler hooks',
         },
@@ -274,6 +316,43 @@ describe('task-session-manager hook', () => {
     expect(boardPart.text).toContain('Objective: map scheduler hooks');
   });
 
+  test('records background=true explicitly and leaves foreground launches unsupervised', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    for (const [callID, taskID, background] of [
+      ['background-call', 'background-child', true],
+      ['foreground-call', 'foreground-child', false],
+    ] as const) {
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background,
+            description: taskID,
+          },
+        },
+      );
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: 'parent-1', callID },
+        {
+          output: [
+            `task_id: ${taskID}`,
+            'state: running',
+            '',
+            '<task_result>',
+            'started',
+            '</task_result>',
+          ].join('\n'),
+        },
+      );
+    }
+
+    expect(board.get('background-child')?.background).toBe(true);
+    expect(board.get('foreground-child')?.background).toBe(false);
+  });
+
   test('does not let user-visible sentinel text suppress board injection', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -3262,6 +3341,261 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test.each([
+    ['foreground-created-first', ['foreground-child', 'background-child']],
+    ['background-created-first', ['background-child', 'foreground-child']],
+  ])(
+    'ambiguous early created events never supervise the foreground child (%s)',
+    async (_, createdOrder) => {
+      const board = new BackgroundJobBoard();
+      const clock = createSupervisorClock();
+      const abort = mock(async () => undefined);
+      const supervisor = new BackgroundJobSupervisor({
+        backgroundJobStore: board,
+        wallClockTimeoutMs: 100,
+        abortGraceMs: 10,
+        abort,
+        now: clock.now,
+        setTimeout: clock.setTimeout,
+        clearTimeout: clock.clearTimeout,
+      });
+      const { hook } = createHook({
+        backgroundJobBoard: board,
+        backgroundJobSupervisor: supervisor,
+      });
+
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background: true,
+            description: 'background child',
+          },
+        },
+      );
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background: false,
+            description: 'foreground child',
+          },
+        },
+      );
+
+      for (const taskID of createdOrder) {
+        await hook.event({
+          event: {
+            type: 'session.created',
+            properties: { info: { id: taskID, parentID: 'parent-1' } },
+          },
+        });
+      }
+
+      expect(board.get('background-child')?.background).toBe(false);
+      expect(board.get('foreground-child')?.background).toBe(false);
+      expect(abort).not.toHaveBeenCalled();
+
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+        { output: taskLaunchOutput('foreground-child') },
+      );
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+        { output: taskLaunchOutput('background-child') },
+      );
+
+      expect(board.get('foreground-child')?.background).toBe(false);
+      expect(board.get('background-child')?.background).toBe(true);
+      const backgroundJob = board.get('background-child');
+      expect(backgroundJob).toBeDefined();
+      const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
+      await clock.advanceTo(deadline);
+
+      expect(abort).toHaveBeenCalledTimes(1);
+      expect(abort).toHaveBeenCalledWith('background-child');
+    },
+  );
+
+  test('missing after-hook callID fails closed while an exact background call remains', async () => {
+    const board = new BackgroundJobBoard();
+    const clock = createSupervisorClock();
+    const abort = mock(async () => undefined);
+    const supervisor = new BackgroundJobSupervisor({
+      backgroundJobStore: board,
+      wallClockTimeoutMs: 100,
+      abortGraceMs: 10,
+      abort,
+      now: clock.now,
+      setTimeout: clock.setTimeout,
+      clearTimeout: clock.clearTimeout,
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      backgroundJobSupervisor: supervisor,
+    });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          background: false,
+          description: 'foreground child',
+        },
+      },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          background: true,
+          description: 'background child',
+        },
+      },
+    );
+    for (const taskID of ['background-child', 'foreground-child']) {
+      await hook.event({
+        event: {
+          type: 'session.created',
+          properties: { info: { id: taskID, parentID: 'parent-1' } },
+        },
+      });
+    }
+
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1' },
+      { output: taskLaunchOutput('foreground-child') },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+      { output: taskLaunchOutput('background-child') },
+    );
+
+    expect(board.get('foreground-child')?.background).toBe(false);
+    expect(board.get('background-child')?.background).toBe(true);
+    const deadline = (board.get('background-child')?.runStartedAt ?? 0) + 100;
+    await clock.advanceTo(deadline);
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('background-child');
+  });
+
+  test('fallback delete/recreate/busy preserves an unclaimed absolute deadline', async () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new SessionLifecycle(() => {});
+    const clock = createSupervisorClock();
+    const abort = mock(async () => undefined);
+    const supervisor = new BackgroundJobSupervisor({
+      backgroundJobStore: board,
+      wallClockTimeoutMs: 100,
+      abortGraceMs: 10,
+      abort,
+      now: clock.now,
+      setTimeout: clock.setTimeout,
+      clearTimeout: clock.clearTimeout,
+    });
+    const job = board.registerLaunch({
+      taskID: 'fallback-child',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      background: true,
+      now: 0,
+    });
+    supervisor.onLaunch(job);
+    let fallback = true;
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      backgroundJobSupervisor: supervisor,
+      coordinator,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => fallback,
+    });
+
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { sessionID: 'fallback-child' },
+      },
+    });
+    coordinator.dispatchSessionDeleted('fallback-child');
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: {
+          info: { id: 'fallback-child', parentID: 'parent-1' },
+        },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: {
+          sessionID: 'fallback-child',
+          status: { type: 'busy' },
+        },
+      },
+    });
+    fallback = false;
+    await clock.advanceTo(100);
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('fallback-child');
+    expect(board.get('fallback-child')?.deadlineExceededAt).toBe(100);
+  });
+
+  test('fallback deletion during grace confirms rather than clears a hard timeout', async () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new SessionLifecycle(() => {});
+    const clock = createSupervisorClock();
+    const abort = mock(async () => undefined);
+    const supervisor = new BackgroundJobSupervisor({
+      backgroundJobStore: board,
+      wallClockTimeoutMs: 100,
+      abortGraceMs: 20,
+      abort,
+      now: clock.now,
+      setTimeout: clock.setTimeout,
+      clearTimeout: clock.clearTimeout,
+    });
+    const job = board.registerLaunch({
+      taskID: 'fallback-grace-child',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      background: true,
+      now: 0,
+    });
+    supervisor.onLaunch(job);
+    let fallback = false;
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      backgroundJobSupervisor: supervisor,
+      coordinator,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => fallback,
+    });
+
+    await clock.advanceTo(100);
+    fallback = true;
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { sessionID: 'fallback-grace-child' },
+      },
+    });
+    coordinator.dispatchSessionDeleted('fallback-grace-child');
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(board.get('fallback-grace-child')).toMatchObject({
+      state: 'error',
+      timedOut: true,
+      deadlineExceededAt: 100,
+    });
+  });
+
   test('cancelled job is not reconciled from idle', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

+ 12 - 1
src/hooks/task-session-manager/index.ts

@@ -2,6 +2,7 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
   type BackgroundJobStore,
+  type BackgroundJobSupervisor,
   isInternalInitiatorPart,
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
@@ -54,6 +55,7 @@ export function createTaskSessionManagerHook(
      */
     continueOnIdle?: boolean;
     backgroundJobBoard?: BackgroundJobStore;
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
     shouldManageSession: (sessionID: string) => boolean;
     /** Register a session as orchestrator when the transform hook detects
      *  an orchestrator message but the session isn't in the agent map yet. */
@@ -167,8 +169,14 @@ export function createTaskSessionManagerHook(
       // lose track of the task and report it as cancelled even though the
       // oracle actually completed.
       if (!options.isFallbackInProgress?.(sessionId)) {
-        backgroundJobBoard.drop(sessionId);
+        options.backgroundJobSupervisor?.onSessionDeleted(sessionId);
+        const hardTimedOut =
+          backgroundJobBoard.field(sessionId, 'deadlineExceededAt') !==
+          undefined;
+        if (!hardTimedOut) backgroundJobBoard.drop(sessionId);
+        options.backgroundJobSupervisor?.clearParent(sessionId);
         backgroundJobBoard.clearParent(sessionId);
+        if (!hardTimedOut) options.backgroundJobSupervisor?.drop(sessionId);
       }
       terminalJobsInjectedByParent.delete(sessionId);
       injectionState.retainedBoardSnapshots.delete(sessionId);
@@ -252,6 +260,7 @@ export function createTaskSessionManagerHook(
         shouldManageSession: options.shouldManageSession,
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -263,6 +272,7 @@ export function createTaskSessionManagerHook(
       handleToolExecuteAfter(input, output, {
         directory: _ctx.directory,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -335,6 +345,7 @@ export function createTaskSessionManagerHook(
         taskContextTracker,
         terminalJobsInjectedByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
       }),
   };
 }

+ 1 - 0
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -3,6 +3,7 @@ export interface PendingTaskCall {
   parentSessionId: string;
   agentType: string;
   label: string;
+  background: boolean;
   resumedTaskId?: string;
 }
 

+ 29 - 2
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -5,7 +5,11 @@
  * reusable/recoverable task_id resolution) and `tool.execute.after`
  * (read context tracking, task launch registration/update from output).
  */
-import type { BackgroundJobStore, ContextFile } from '../../utils';
+import type {
+  BackgroundJobStore,
+  BackgroundJobSupervisor,
+  ContextFile,
+} from '../../utils';
 import {
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
@@ -26,6 +30,7 @@ interface TaskArgs {
   prompt?: unknown;
   subagent_type?: unknown;
   task_id?: unknown;
+  background?: unknown;
 }
 
 export async function handleToolExecuteBefore(
@@ -40,6 +45,7 @@ export async function handleToolExecuteBefore(
       pendingCallId(sessionID?: string, callID?: string): string;
     };
     taskContextTracker: { pendingManagedTaskIds: Set<string> };
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
 ): Promise<void> {
   const toolName = input.tool.toLowerCase();
@@ -70,6 +76,7 @@ export async function handleToolExecuteBefore(
   }
 
   const agentType = args.subagent_type.trim();
+  const background = args.background === true;
 
   const label = deriveTaskSessionLabel({
     description:
@@ -86,6 +93,7 @@ export async function handleToolExecuteBefore(
     parentSessionId: input.sessionID,
     agentType,
     label,
+    background,
   };
   if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
     const requested = args.task_id.trim();
@@ -156,6 +164,7 @@ export async function handleToolExecuteAfter(
       contextFilesForPrompt(taskId: string): ContextFile[];
       prune(board: { taskIDs(): Set<string> }): void;
     };
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
 ): Promise<void> {
   if (input.tool.toLowerCase() === 'read') {
@@ -175,7 +184,16 @@ export async function handleToolExecuteAfter(
 
   if (input.tool.toLowerCase() !== 'task') return;
 
-  const pending = deps.pendingCallTracker.take(input.callID, input.sessionID);
+  const exactCallID =
+    typeof input.callID === 'string' && input.callID.trim() !== ''
+      ? input.callID
+      : undefined;
+  const pending = deps.pendingCallTracker.take(
+    exactCallID,
+    exactCallID ? undefined : input.sessionID,
+  );
+  const exactCallConfirmed =
+    exactCallID !== undefined && pending?.callId === exactCallID;
   log('[task-session-manager] tool.execute.after task', {
     callID: input.callID,
     sessionID: input.sessionID,
@@ -196,7 +214,10 @@ export async function handleToolExecuteAfter(
       agent: pending.agentType,
       description: pending.label,
       objective: pending.label,
+      background: exactCallConfirmed && pending.background,
+      preserveRun: pending.resumedTaskId === undefined,
     });
+    if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
     log('[task-session-manager] background task launch registered', {
       taskID: record.taskID,
       alias: record.alias,
@@ -225,7 +246,10 @@ export async function handleToolExecuteAfter(
         agent: pending.agentType,
         description: pending.label,
         objective: pending.label,
+        background: exactCallConfirmed && pending.background,
+        preserveRun: pending.resumedTaskId === undefined,
       });
+    if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
     const updated = deps.backgroundJobBoard.updateStatus({
       taskID: status.taskID,
       state: status.state,
@@ -241,6 +265,7 @@ export async function handleToolExecuteAfter(
     });
     if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
       deps.backgroundJobBoard.drop(pending.resumedTaskId);
+      deps.backgroundJobSupervisor?.drop(pending.resumedTaskId);
     }
     deps.taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
     deps.backgroundJobBoard.addContext(
@@ -258,12 +283,14 @@ export async function handleToolExecuteAfter(
       isMissingRememberedSessionError(output.output)
     ) {
       deps.backgroundJobBoard.drop(pending.resumedTaskId);
+      deps.backgroundJobSupervisor?.drop(pending.resumedTaskId);
     }
     return;
   }
 
   if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
     deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    deps.backgroundJobSupervisor?.drop(pending.resumedTaskId);
   }
 
   deps.taskContextTracker.pendingManagedTaskIds.delete(taskId);

+ 21 - 0
src/index.ts

@@ -68,6 +68,7 @@ import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   BackgroundJobBoard,
   BackgroundJobCoordinator,
+  BackgroundJobSupervisor,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
@@ -195,6 +196,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let jsonErrorRecoveryAfter: (i: unknown, o: unknown) => Promise<void>;
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let backgroundJobBoard: BackgroundJobBoard;
+  let backgroundJobSupervisor: BackgroundJobSupervisor;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
@@ -299,6 +301,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     const backgroundJobCoordinator = new BackgroundJobCoordinator(
       backgroundJobBoard,
     );
+    backgroundJobSupervisor = new BackgroundJobSupervisor({
+      backgroundJobStore: backgroundJobCoordinator,
+      wallClockTimeoutMs: config.backgroundJobs?.wallClockTimeoutMs ?? 0,
+      abortGraceMs: config.backgroundJobs?.abortGraceMs ?? 10_000,
+      abort: (taskID) =>
+        ctx.client.session.abort({
+          path: { id: taskID },
+        }),
+    });
+    backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
+      backgroundJobSupervisor.onTerminal(record);
+    });
 
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
@@ -310,6 +324,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     backgroundJobCoordinator.addTerminalStateListener((taskID) => {
       void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
     });
+    backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
+      if (record.deadlineExceededAt === undefined) return;
+      void multiplexerSessionManager.closeSessionPermanentlyFromCoordinator(
+        record.taskID,
+      );
+    });
 
     sessionLifecycle = new SessionLifecycle(log);
 
@@ -354,6 +374,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         DEFAULT_READ_CONTEXT_MAX_FILES,
       continueOnIdle: config.backgroundJobs?.continueOnIdle === true,
       backgroundJobBoard: backgroundJobCoordinator,
+      backgroundJobSupervisor,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
       registerSessionAsOrchestrator: (sessionID) => {

+ 42 - 4
src/multiplexer/cmux/session-lifecycle.ts

@@ -39,6 +39,7 @@ export interface CmuxSessionLifecycleOptions {
   shutdownTimeoutMs?: number;
   isServerRunning?: (url: string) => Promise<boolean>;
   fetchStatuses?: () => Promise<Record<string, { type: string }>>;
+  permanentlyClosedSessions?: Set<string>;
 }
 
 const ACTIVITY_EVENTS = new Set([
@@ -78,6 +79,7 @@ export class CmuxSessionLifecycle {
   private cleanupPromise?: Promise<void>;
   private disposed = false;
   private spawnGeneration = 0;
+  private readonly permanentlyClosedSessions?: Set<string>;
 
   constructor(
     private readonly owner: string,
@@ -88,6 +90,7 @@ export class CmuxSessionLifecycle {
     options: CmuxSessionLifecycleOptions = {},
   ) {
     this.now = options.now ?? Date.now;
+    this.permanentlyClosedSessions = options.permanentlyClosedSessions;
     this.injectedDelay = Boolean(options.delay);
     this.delay =
       options.delay ??
@@ -122,6 +125,7 @@ export class CmuxSessionLifecycle {
     if (event.type !== 'session.created') return;
     const info = event.properties?.info;
     if (!info?.id || !info.parentID) return;
+    if (this.permanentlyClosedSessions?.has(info.id)) return;
     const now = this.now();
     const record: CmuxSessionRecord = {
       session: info.id,
@@ -165,7 +169,12 @@ export class CmuxSessionLifecycle {
       this.activity(session);
       this.backgroundJobs?.clearDeferredClose(session);
       const record = this.store.get(session);
-      if (status === 'busy' && record && !record.paneId)
+      if (
+        status === 'busy' &&
+        record &&
+        record.lifecycle === 'active' &&
+        !record.paneId
+      )
         await this.spawn(record);
     }
     if (owned.paneId) this.startPolling();
@@ -194,6 +203,21 @@ export class CmuxSessionLifecycle {
     if (record?.paneId && record.owner === this.owner) this.startPolling();
   }
 
+  async closeSessionPermanentlyFromCoordinator(session: string): Promise<void> {
+    if (this.disposed) return;
+    this.permanentlyClosedSessions?.add(session);
+    const record = this.store.get(session);
+    if (!record || record.owner !== this.owner) return;
+    record.lifecycle = 'deleted';
+    this.cancelDeferred(record);
+    this.backgroundJobs?.clearDeferredClose(session);
+    if (!record.paneId) {
+      if (!record.spawnPromise) this.store.removeWithoutPane(session);
+      return;
+    }
+    await this.requestClose(record, 'deleted');
+  }
+
   cleanup(): Promise<void> {
     this.cleanupPromise ??= this.runCleanup();
     return this.cleanupPromise;
@@ -208,7 +232,13 @@ export class CmuxSessionLifecycle {
     record: CmuxSessionRecord,
     deferred = false,
   ): Promise<void> {
-    if (this.disposed || record.owner !== this.owner) return;
+    if (
+      this.disposed ||
+      record.owner !== this.owner ||
+      record.lifecycle !== 'active' ||
+      this.permanentlyClosedSessions?.has(record.session)
+    )
+      return;
     if (record.spawnState === 'spawning' || record.paneId) return;
     const generation = this.spawnGeneration;
     const token = record.deferredSpawn?.generation;
@@ -218,7 +248,11 @@ export class CmuxSessionLifecycle {
     const result = await operation;
     if (record.spawnPromise === operation) record.spawnPromise = undefined;
     const current = this.store.get(record.session);
-    if (this.disposed || generation !== this.spawnGeneration) {
+    if (
+      this.disposed ||
+      generation !== this.spawnGeneration ||
+      this.permanentlyClosedSessions?.has(record.session)
+    ) {
       const latePane = result.paneId ?? result.orphanPaneId;
       if (latePane) await this.closeLatePane(record, latePane);
       else if (current && !current.paneId)
@@ -274,6 +308,9 @@ export class CmuxSessionLifecycle {
     if (!(await this.serverCheck(serverUrl))) {
       return { success: false, error: 'unavailable' as const };
     }
+    if (this.permanentlyClosedSessions?.has(record.session)) {
+      return { success: false, error: 'unavailable' as const };
+    }
     try {
       return await this.multiplexer.spawnPane(
         record.session,
@@ -306,7 +343,8 @@ export class CmuxSessionLifecycle {
         this.disposed ||
         this.store.get(record.session) !== record ||
         record.lifecycle !== 'active' ||
-        record.owner !== this.owner
+        record.owner !== this.owner ||
+        this.permanentlyClosedSessions?.has(record.session)
       )
         return;
       await this.spawn(record, true);

+ 305 - 0
src/multiplexer/session-manager.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { BackgroundJobBoard } from '../utils/background-job-board';
 import { BackgroundJobCoordinator } from '../utils/background-job-coordinator';
+import { CmuxSessionStore } from './cmux/session-state';
 import {
   MultiplexerSessionManager,
   resetMultiplexerSessionManagerState,
@@ -611,6 +612,215 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
+    test('wall-clock timeout closes a live pane permanently and blocks late busy respawn', async () => {
+      const ctx = createMockContext();
+      const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
+      board.registerLaunch({
+        taskID: 'wall-clock-pane',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        background: true,
+      });
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-wall-clock-pane',
+      });
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+        coordinator,
+      );
+      coordinator.addTerminalOutcomeListener((record) => {
+        if (record.deadlineExceededAt !== undefined) {
+          void manager.closeSessionPermanentlyFromCoordinator(record.taskID);
+        }
+      });
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'wall-clock-pane', parentID: 'parent-1' },
+        },
+      });
+      board.claimWallClockDeadline({
+        taskID: 'wall-clock-pane',
+        generation: 1,
+        now: 100,
+      });
+      board.finalizeWallClockTimeout({
+        taskID: 'wall-clock-pane',
+        generation: 1,
+        now: 120,
+        statusUncertain: true,
+        resultSummary: 'abort was not confirmed',
+      });
+      await flushPromises();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-wall-clock-pane',
+      );
+      const spawns = mockMultiplexer.spawnPane.mock.calls.length;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'wall-clock-pane',
+          status: { type: 'busy' },
+        },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'wall-clock-pane', parentID: 'parent-1' },
+        },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+    });
+
+    test('generic tombstone wins a duplicate created event awaiting an existing close', async () => {
+      const close = createDeferred<boolean>();
+      mockMultiplexer.closePane.mockImplementationOnce(() => close.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      const created = {
+        type: 'session.created' as const,
+        properties: {
+          info: { id: 'created-race', parentID: 'parent-1' },
+        },
+      };
+
+      await manager.onSessionCreated(created);
+      const deleting = manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { sessionID: 'created-race' },
+      });
+      await flushPromises();
+      const duplicate = manager.onSessionCreated(created);
+      await flushPromises();
+
+      const permanent =
+        manager.closeSessionPermanentlyFromCoordinator('created-race');
+      close.resolve(true);
+      await Promise.all([deleting, duplicate, permanent]);
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      const state = manager as unknown as {
+        knownSessions: Map<string, unknown>;
+        sessions: Map<string, unknown>;
+      };
+      expect(state.knownSessions.has('created-race')).toBe(false);
+      expect(state.sessions.has('created-race')).toBe(false);
+    });
+
+    test('generic tombstone is rechecked after server health await', async () => {
+      const health = createDeferred<boolean>();
+      mockIsServerRunning.mockImplementationOnce(() => health.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+
+      const creating = manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'health-race', parentID: 'parent-1' },
+        },
+      });
+      await flushPromises();
+      await manager.closeSessionPermanentlyFromCoordinator('health-race');
+      health.resolve(true);
+      await creating;
+
+      expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
+      const state = manager as unknown as {
+        knownSessions: Map<string, unknown>;
+      };
+      expect(state.knownSessions.has('health-race')).toBe(false);
+    });
+
+    test('generic tombstone is rechecked across busy respawn health await', async () => {
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'respawn-race', parentID: 'parent-1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'respawn-race',
+          status: { type: 'idle' },
+        },
+      });
+      const health = createDeferred<boolean>();
+      mockIsServerRunning.mockImplementationOnce(() => health.promise);
+
+      const respawning = manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'respawn-race',
+          status: { type: 'busy' },
+        },
+      });
+      await flushPromises();
+      await manager.closeSessionPermanentlyFromCoordinator('respawn-race');
+      health.resolve(true);
+      await respawning;
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      const state = manager as unknown as {
+        knownSessions: Map<string, unknown>;
+      };
+      expect(state.knownSessions.has('respawn-race')).toBe(false);
+    });
+
+    test('disposing another generic manager does not clear a process-shared tombstone', async () => {
+      const managerA = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      const managerB = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      const created = {
+        type: 'session.created' as const,
+        properties: {
+          info: { id: 'shared-tombstone', parentID: 'parent-1' },
+        },
+      };
+
+      await managerA.onSessionCreated(created);
+      await managerA.closeSessionPermanentlyFromCoordinator('shared-tombstone');
+      await managerB.cleanupOnInstanceDisposed();
+      await managerA.onSessionCreated(created);
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+    });
+
+    test('backfills permanentlyClosedSessions for an older shared state shape', () => {
+      const key = Symbol.for(
+        'oh-my-opencode-slim.multiplexer-session-manager.state',
+      );
+      (globalThis as Record<PropertyKey, unknown>)[key] = {
+        sessions: new Map(),
+        knownSessions: new Map(),
+        spawningSessions: new Set(),
+        closingSessions: new Map(),
+      };
+
+      expect(() => resetMultiplexerSessionManagerState()).not.toThrow();
+      const state = (globalThis as Record<PropertyKey, unknown>)[key] as {
+        permanentlyClosedSessions?: unknown;
+      };
+      expect(state.permanentlyClosedSessions).toBeInstanceOf(Set);
+    });
+
     test('deleted clears deferred idle close and later terminal update is no-op', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
@@ -2144,6 +2354,101 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
     });
 
+    test('cmux wall-clock close is permanent and late busy does not respawn', async () => {
+      mockMultiplexerType = 'cmux';
+      const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
+      board.registerLaunch({
+        taskID: 'cmux-wall-clock',
+        parentSessionID: 'parent',
+        agent: 'explorer',
+        background: true,
+      });
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        coordinator,
+      );
+      coordinator.addTerminalOutcomeListener((record) => {
+        if (record.deadlineExceededAt !== undefined) {
+          void manager.closeSessionPermanentlyFromCoordinator(record.taskID);
+        }
+      });
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-wall-clock', parentID: 'parent' } },
+      });
+      board.claimWallClockDeadline({
+        taskID: 'cmux-wall-clock',
+        generation: 1,
+        now: 100,
+      });
+      board.finalizeWallClockTimeout({
+        taskID: 'cmux-wall-clock',
+        generation: 1,
+        now: 120,
+        statusUncertain: true,
+        resultSummary: 'abort was not confirmed',
+      });
+      await flushPromises();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('%mock-pane');
+      const spawns = mockMultiplexer.spawnPane.mock.calls.length;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'cmux-wall-clock',
+          status: { type: 'busy' },
+        },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-wall-clock', parentID: 'parent' } },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+    });
+
+    test('cmux permanent tombstone blocks duplicate created and busy across managers', async () => {
+      mockMultiplexerType = 'cmux';
+      const created = {
+        type: 'session.created' as const,
+        properties: {
+          info: { id: 'cmux-shared-tombstone', parentID: 'parent' },
+        },
+      };
+      const managerA = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+      );
+      await managerA.onSessionCreated(created);
+      await managerA.closeSessionPermanentlyFromCoordinator(
+        'cmux-shared-tombstone',
+      );
+
+      expect(
+        new CmuxSessionStore().get('cmux-shared-tombstone'),
+      ).toBeUndefined();
+
+      const managerB = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+      );
+      await managerB.onSessionCreated(created);
+      await managerB.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'cmux-shared-tombstone',
+          status: { type: 'busy' },
+        },
+      });
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      expect(
+        new CmuxSessionStore().get('cmux-shared-tombstone'),
+      ).toBeUndefined();
+    });
+
     test('session.deleted closes immediately', async () => {
       mockMultiplexerType = 'cmux';
       const manager = new MultiplexerSessionManager(

+ 67 - 14
src/multiplexer/session-manager.ts

@@ -40,6 +40,7 @@ interface SharedSessionState {
   knownSessions: Map<string, KnownSession>;
   spawningSessions: Set<string>;
   closingSessions: Map<string, Promise<void>>;
+  permanentlyClosedSessions: Set<string>;
 }
 
 interface SessionEvent {
@@ -69,14 +70,20 @@ function getSharedState(): SharedSessionState {
     [SHARED_STATE_KEY]?: SharedSessionState;
   };
 
-  globalWithState[SHARED_STATE_KEY] ??= {
-    sessions: new Map(),
-    knownSessions: new Map(),
-    spawningSessions: new Set(),
-    closingSessions: new Map(),
-  };
-
-  return globalWithState[SHARED_STATE_KEY];
+  let state = globalWithState[SHARED_STATE_KEY];
+  if (!state) {
+    state = {
+      sessions: new Map(),
+      knownSessions: new Map(),
+      spawningSessions: new Set(),
+      closingSessions: new Map(),
+      permanentlyClosedSessions: new Set(),
+    };
+    globalWithState[SHARED_STATE_KEY] = state;
+  }
+  // Migrate state created by older plugin instances in this process.
+  state.permanentlyClosedSessions ??= new Set();
+  return state;
 }
 
 export function resetMultiplexerSessionManagerState(): void {
@@ -85,6 +92,7 @@ export function resetMultiplexerSessionManagerState(): void {
   state.knownSessions.clear();
   state.spawningSessions.clear();
   state.closingSessions.clear();
+  state.permanentlyClosedSessions.clear();
   new CmuxSessionStore().resetForTests();
 }
 
@@ -149,6 +157,7 @@ export class MultiplexerSessionManager {
   private knownSessions: SharedSessionState['knownSessions'];
   private spawningSessions: SharedSessionState['spawningSessions'];
   private closingSessions: SharedSessionState['closingSessions'];
+  private permanentlyClosedSessions: SharedSessionState['permanentlyClosedSessions'];
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
   private cmuxLifecycle?: CmuxSessionLifecycle;
@@ -164,6 +173,7 @@ export class MultiplexerSessionManager {
     this.knownSessions = sharedState.knownSessions;
     this.spawningSessions = sharedState.spawningSessions;
     this.closingSessions = sharedState.closingSessions;
+    this.permanentlyClosedSessions = sharedState.permanentlyClosedSessions;
 
     this.directory = ctx.directory;
     this.resolveServerUrl = createServerUrlResolver(ctx);
@@ -180,7 +190,10 @@ export class MultiplexerSessionManager {
         this.resolveServerUrl,
         this.directory,
         this.backgroundJobBoard,
-        options,
+        {
+          ...options,
+          permanentlyClosedSessions: this.permanentlyClosedSessions,
+        },
       );
     }
 
@@ -209,6 +222,14 @@ export class MultiplexerSessionManager {
     const title = info.title ?? 'Subagent';
     const directory = info.directory ?? this.directory;
 
+    if (this.permanentlyClosedSessions.has(sessionId)) {
+      log('[multiplexer-session-manager] ignoring permanently closed session', {
+        instanceId: this.instanceId,
+        sessionId,
+      });
+      return;
+    }
+
     if (this.isTrackedOrSpawning(sessionId)) {
       log('[multiplexer-session-manager] session already tracked or spawning', {
         instanceId: this.instanceId,
@@ -220,6 +241,7 @@ export class MultiplexerSessionManager {
     const closing = this.closingSessions.get(sessionId);
     if (closing) await closing;
 
+    if (this.permanentlyClosedSessions.has(sessionId)) return;
     if (this.isTrackedOrSpawning(sessionId)) return;
 
     this.knownSessions.set(sessionId, {
@@ -251,7 +273,11 @@ export class MultiplexerSessionManager {
         return;
       }
 
-      if (this.closingSessions.has(sessionId) || this.sessions.has(sessionId)) {
+      if (
+        this.permanentlyClosedSessions.has(sessionId) ||
+        this.closingSessions.has(sessionId) ||
+        this.sessions.has(sessionId)
+      ) {
         return;
       }
 
@@ -279,7 +305,8 @@ export class MultiplexerSessionManager {
 
       if (
         !this.knownSessions.has(sessionId) ||
-        this.closingSessions.has(sessionId)
+        this.closingSessions.has(sessionId) ||
+        this.permanentlyClosedSessions.has(sessionId)
       ) {
         await this.multiplexer.closePane(paneResult.paneId).catch((err) =>
           log(
@@ -588,9 +615,11 @@ export class MultiplexerSessionManager {
 
   private async respawnIfKnown(sessionId: string): Promise<void> {
     if (!this.enabled || !this.multiplexer) return;
+    if (this.permanentlyClosedSessions.has(sessionId)) return;
     const closing = this.closingSessions.get(sessionId);
     if (closing) await closing;
 
+    if (this.permanentlyClosedSessions.has(sessionId)) return;
     if (this.isTrackedOrSpawning(sessionId)) {
       return;
     }
@@ -625,7 +654,11 @@ export class MultiplexerSessionManager {
         return;
       }
 
-      if (this.sessions.has(sessionId) || this.closingSessions.has(sessionId)) {
+      if (
+        this.permanentlyClosedSessions.has(sessionId) ||
+        this.sessions.has(sessionId) ||
+        this.closingSessions.has(sessionId)
+      ) {
         return;
       }
 
@@ -653,7 +686,8 @@ export class MultiplexerSessionManager {
 
       if (
         !this.knownSessions.has(sessionId) ||
-        this.closingSessions.has(sessionId)
+        this.closingSessions.has(sessionId) ||
+        this.permanentlyClosedSessions.has(sessionId)
       ) {
         await this.multiplexer.closePane(paneResult.paneId).catch((err) =>
           log(
@@ -727,8 +761,26 @@ export class MultiplexerSessionManager {
     await this.closeSession(sessionId, 'idle', true);
   }
 
+  /** Permanently close a wall-clock timed-out pane and block late busy respawn. */
+  async closeSessionPermanentlyFromCoordinator(
+    sessionId: string,
+  ): Promise<void> {
+    if (this.cmuxLifecycle) {
+      return this.cmuxLifecycle.closeSessionPermanentlyFromCoordinator(
+        sessionId,
+      );
+    }
+    if (!this.enabled) return;
+    this.permanentlyClosedSessions.add(sessionId);
+    await this.closeSession(sessionId, 'deleted', true);
+  }
+
   async cleanup(): Promise<void> {
-    if (this.cmuxLifecycle) return this.cmuxLifecycle.cleanup();
+    if (this.cmuxLifecycle) {
+      await this.cmuxLifecycle.cleanup();
+      this.permanentlyClosedSessions.clear();
+      return;
+    }
     this.stopPolling();
 
     if (this.closingSessions.size > 0) {
@@ -755,6 +807,7 @@ export class MultiplexerSessionManager {
     this.knownSessions.clear();
     this.spawningSessions.clear();
     this.closingSessions.clear();
+    this.permanentlyClosedSessions.clear();
     // ponytail: deferred state lives in coordinator, not here
     // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
 

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

@@ -23,6 +23,8 @@ export interface BackgroundJobRecord {
   description: string;
   objective?: string;
   state: BackgroundJobState;
+  /** True only when the native task call explicitly supplied background:true. */
+  background: boolean;
   timedOut: boolean;
   recoverableAfterLiveBusy: boolean;
   statusUncertain: boolean;
@@ -30,6 +32,12 @@ export interface BackgroundJobRecord {
   terminalUnreconciled: boolean;
   launchedAt: number;
   lastLaunchedAt: number;
+  /** Monotonic run identity. Explicit relaunch/reuse increments it. */
+  generation: number;
+  /** First launch observation for the current generation. */
+  runStartedAt: number;
+  /** Persistent hard wall-clock marker; distinct from external task wait timeout. */
+  deadlineExceededAt?: number;
   updatedAt: number;
   lastLiveBusyAt?: number;
   completedAt?: number;
@@ -56,6 +64,9 @@ export interface BackgroundJobLaunchInput {
   agent: string;
   description?: string;
   objective?: string;
+  background?: boolean;
+  /** Preserve the current run when this is a duplicate lifecycle observation. */
+  preserveRun?: boolean;
   now?: number;
 }
 
@@ -69,6 +80,21 @@ export interface BackgroundJobStatusInput {
   now?: number;
 }
 
+export interface WallClockTimeoutClaimInput {
+  taskID: string;
+  generation: number;
+  now?: number;
+  resultSummary?: string;
+}
+
+export interface WallClockTimeoutFinalizeInput {
+  taskID: string;
+  generation: number;
+  now?: number;
+  statusUncertain: boolean;
+  resultSummary: string;
+}
+
 type TerminalStateListener = (taskID: string) => void;
 
 const TERMINAL_STATES = new Set<BackgroundJobState>([
@@ -130,12 +156,26 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const existing = this.jobs.get(input.taskID);
 
     if (existing) {
+      if (input.preserveRun) {
+        if (existing.state !== 'running') return existing;
+        const observed = {
+          ...existing,
+          agent: input.agent || existing.agent,
+          description: input.description || existing.description,
+          objective: input.objective ?? existing.objective,
+          background: existing.background || input.background === true,
+        } satisfies BackgroundJobRecord;
+        this.jobs.set(input.taskID, observed);
+        return observed;
+      }
+
       const updated = {
         ...existing,
         agent: input.agent || existing.agent,
         description: input.description || existing.description,
         objective: input.objective ?? existing.objective,
         state: 'running',
+        background: input.background ?? existing.background,
         timedOut: false,
         recoverableAfterLiveBusy: false,
         statusUncertain: false,
@@ -146,6 +186,9 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         lastStatusError: undefined,
         terminalState: undefined,
         lastLaunchedAt: now,
+        generation: existing.generation + 1,
+        runStartedAt: now,
+        deadlineExceededAt: undefined,
         lastLiveBusyAt: now,
         lastUsedAt: now,
         updatedAt: now,
@@ -163,6 +206,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       description: input.description || `background ${input.agent} task`,
       objective: input.objective,
       state: 'running',
+      background: input.background === true,
       timedOut: false,
       recoverableAfterLiveBusy: false,
       statusUncertain: false,
@@ -170,6 +214,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       terminalUnreconciled: false,
       launchedAt: now,
       lastLaunchedAt: now,
+      generation: 1,
+      runStartedAt: now,
       lastLiveBusyAt: now,
       lastUsedAt: now,
       updatedAt: now,
@@ -189,6 +235,22 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const existing = this.jobs.get(input.taskID);
     if (!existing) return undefined;
 
+    // A wall-clock deadline is a hard, non-recoverable claim. Completion after
+    // that claim is late evidence and cannot replace the canonical timeout.
+    if (existing.deadlineExceededAt !== undefined) {
+      if (existing.state !== 'running') return existing;
+      if (input.state === 'completed' || input.state === 'running') {
+        return existing;
+      }
+      return this.finalizeWallClockTimeout({
+        taskID: input.taskID,
+        generation: existing.generation,
+        now: input.now,
+        statusUncertain: false,
+        resultSummary: existing.resultSummary ?? timeoutSummary(input.state),
+      });
+    }
+
     // Guard: stale status updates cannot reopen already terminal jobs.
     if (
       existing.state === 'reconciled' ||
@@ -258,6 +320,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const existing = this.jobs.get(taskID);
     if (!existing) return undefined;
 
+    if (existing.deadlineExceededAt !== undefined) return existing;
+
     const isStaleTerminal =
       TERMINAL_STATES.has(existing.state) || existing.state === 'reconciled';
     if (isStaleTerminal) {
@@ -300,7 +364,10 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       ...existing,
       state: 'reconciled',
       terminalUnreconciled: false,
-      statusUncertain: false,
+      statusUncertain:
+        existing.deadlineExceededAt !== undefined
+          ? existing.statusUncertain
+          : false,
       updatedAt: now,
       lastUsedAt: now,
       terminalState: existing.terminalState ?? terminalStateOf(existing.state),
@@ -319,6 +386,16 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   ): BackgroundJobRecord | undefined {
     const existing = this.jobs.get(taskID);
     if (!existing) return undefined;
+    if (existing.deadlineExceededAt !== undefined) {
+      if (existing.state !== 'running') return existing;
+      return this.finalizeWallClockTimeout({
+        taskID,
+        generation: existing.generation,
+        now,
+        statusUncertain: false,
+        resultSummary: existing.resultSummary ?? normalizeCancelReason(reason),
+      });
+    }
     if (!options.force) {
       if (existing.state === 'reconciled') return existing;
       if (TERMINAL_STATES.has(existing.state)) return existing;
@@ -376,6 +453,72 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return this.field(taskID, 'lastLiveBusyAt');
   }
 
+  claimWallClockDeadline(
+    input: WallClockTimeoutClaimInput,
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(input.taskID);
+    if (
+      existing?.state !== 'running' ||
+      existing?.generation !== input.generation ||
+      existing?.deadlineExceededAt !== undefined
+    ) {
+      return undefined;
+    }
+
+    const now = input.now ?? Date.now();
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      timedOut: true,
+      deadlineExceededAt: now,
+      cancellationRequested: true,
+      statusUncertain: false,
+      updatedAt: now,
+      resultSummary:
+        input.resultSummary ??
+        'Background task exceeded its wall-clock deadline; abort requested.',
+    };
+    this.jobs.set(input.taskID, updated);
+    return updated;
+  }
+
+  finalizeWallClockTimeout(
+    input: WallClockTimeoutFinalizeInput,
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(input.taskID);
+    if (!existing) return undefined;
+    if (existing.state !== 'running') return existing;
+    if (
+      existing.generation !== input.generation ||
+      existing.deadlineExceededAt === undefined
+    ) {
+      return undefined;
+    }
+
+    const now = input.now ?? Date.now();
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      state: 'error',
+      timedOut: true,
+      recoverableAfterLiveBusy: false,
+      statusUncertain: input.statusUncertain,
+      cancellationRequested: true,
+      terminalUnreconciled: true,
+      updatedAt: now,
+      completedAt: existing.completedAt ?? now,
+      terminalState: 'error',
+      resultSummary: input.resultSummary,
+      lastStatusError: input.statusUncertain
+        ? input.resultSummary
+        : existing.lastStatusError,
+      timeoutCount: (existing.timeoutCount ?? 0) + 1,
+      lastErrorAt: now,
+      totalErrors: (existing.totalErrors ?? 0) + 1,
+    };
+    this.jobs.set(input.taskID, updated);
+    this.notifyTerminalStateListeners(input.taskID);
+    return updated;
+  }
+
   getParentSessionID(taskID: string): string | undefined {
     return this.field(taskID, 'parentSessionID');
   }
@@ -413,7 +556,11 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const job = this.resolve(parentSessionID, taskIDOrAlias);
     if (!job) return undefined;
     if (agent && job.agent !== agent) return undefined;
-    if (job.state !== 'running' || !job.recoverableAfterLiveBusy) {
+    if (
+      job.state !== 'running' ||
+      !job.recoverableAfterLiveBusy ||
+      job.deadlineExceededAt !== undefined
+    ) {
       return undefined;
     }
     return job;
@@ -617,13 +764,19 @@ function normalizeWhitespace(value: string): string {
   return value.replace(/\s+/g, ' ').trim();
 }
 
+function timeoutSummary(state: TaskOutputState): string {
+  return `Background task exceeded its wall-clock deadline; abort was observed with child state ${state}.`;
+}
+
 function formatJob(job: BackgroundJobRecord): string {
   const isResume = job.lastLaunchedAt !== job.launchedAt;
   // Exclude wall-clock age labels so prompts remain stable between job-state transitions for cache reuse.
   const displayState =
     job.state === 'running' && isResume ? 'running [resumed]' : job.state;
   const status = job.terminalUnreconciled
-    ? `${job.state}, unreconciled`
+    ? `${job.state}, unreconciled${
+        job.deadlineExceededAt !== undefined ? ', timed out' : ''
+      }`
     : job.statusUncertain
       ? `${job.state}, status uncertain`
       : job.timedOut

+ 34 - 0
src/utils/background-job-coordinator.ts

@@ -4,11 +4,14 @@ import type {
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
+  WallClockTimeoutClaimInput,
+  WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 import type { BackgroundJobStore } from './background-job-store';
 import type { TaskOutputState } from './task';
 
 type TerminalStateListener = (taskID: string) => void;
+type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
 
 /**
  * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
@@ -23,6 +26,7 @@ type TerminalStateListener = (taskID: string) => void;
  */
 export class BackgroundJobCoordinator implements BackgroundJobStore {
   private terminalStateListeners: TerminalStateListener[] = [];
+  private terminalOutcomeListeners: TerminalOutcomeListener[] = [];
   // Stores session IDs (which equal task IDs) awaiting close after background job completes
   private readonly deferredIdleCloses = new Set<string>();
 
@@ -61,6 +65,24 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
         listener(taskID);
       }
     }
+
+    const record = this.board.get?.(taskID);
+    if (record) {
+      for (const listener of this.terminalOutcomeListeners) {
+        listener(record);
+      }
+    }
+  }
+
+  /** Observe every canonical terminal publication, including non-idle jobs. */
+  addTerminalOutcomeListener(listener: TerminalOutcomeListener): void {
+    this.terminalOutcomeListeners.push(listener);
+  }
+
+  removeTerminalOutcomeListener(listener: TerminalOutcomeListener): void {
+    this.terminalOutcomeListeners = this.terminalOutcomeListeners.filter(
+      (entry) => entry !== listener,
+    );
   }
 
   // ── Lifecycle policy ─────────────────────────────────────────────
@@ -110,6 +132,18 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.updateFromStatusOutput(output);
   }
 
+  claimWallClockDeadline(
+    input: WallClockTimeoutClaimInput,
+  ): BackgroundJobRecord | undefined {
+    return this.board.claimWallClockDeadline(input);
+  }
+
+  finalizeWallClockTimeout(
+    input: WallClockTimeoutFinalizeInput,
+  ): BackgroundJobRecord | undefined {
+    return this.board.finalizeWallClockTimeout(input);
+  }
+
   markRunningFromLiveSession(
     taskID: string,
     now = Date.now(),

+ 8 - 0
src/utils/background-job-store.ts

@@ -3,6 +3,8 @@ import type {
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
+  WallClockTimeoutClaimInput,
+  WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 import type { TaskOutputState } from './task';
 
@@ -19,6 +21,12 @@ export interface BackgroundJobStore {
     input: BackgroundJobStatusInput,
   ): BackgroundJobRecord | undefined;
   updateFromStatusOutput(output: string): BackgroundJobRecord | undefined;
+  claimWallClockDeadline(
+    input: WallClockTimeoutClaimInput,
+  ): BackgroundJobRecord | undefined;
+  finalizeWallClockTimeout(
+    input: WallClockTimeoutFinalizeInput,
+  ): BackgroundJobRecord | undefined;
   markRunningFromLiveSession(
     taskID: string,
     now?: number,

+ 317 - 0
src/utils/background-job-supervisor.test.ts

@@ -0,0 +1,317 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from './background-job-board';
+import { BackgroundJobCoordinator } from './background-job-coordinator';
+import { BackgroundJobSupervisor } from './background-job-supervisor';
+
+type TimerCallback = () => void;
+
+function createTimerHarness() {
+  let now = 0;
+  let nextID = 0;
+  const timers = new Map<number, { at: number; callback: TimerCallback }>();
+
+  const setTimeout = (callback: TimerCallback, delay: number) => {
+    const id = ++nextID;
+    timers.set(id, { at: now + delay, callback });
+    return id;
+  };
+  const clearTimeout = (id: number) => {
+    timers.delete(id);
+  };
+  const advanceTo = async (target: number) => {
+    now = target;
+    while (true) {
+      const due = [...timers.entries()]
+        .filter(([, timer]) => timer.at <= now)
+        .sort(([, a], [, b]) => a.at - b.at)[0];
+      if (!due) break;
+      timers.delete(due[0]);
+      due[1].callback();
+      await Promise.resolve();
+    }
+  };
+
+  return {
+    now: () => now,
+    setTimeout,
+    clearTimeout,
+    advanceTo,
+    pending: () => timers.size,
+  };
+}
+
+function createSupervisor(
+  overrides: {
+    timeoutMs?: number;
+    graceMs?: number;
+    abort?: (taskID: string) => Promise<unknown>;
+  } = {},
+) {
+  const board = new BackgroundJobBoard();
+  const coordinator = new BackgroundJobCoordinator(board);
+  const timers = createTimerHarness();
+  const abort = mock(overrides.abort ?? (async () => undefined)) as unknown as (
+    taskID: string,
+  ) => Promise<unknown>;
+  const supervisor = new BackgroundJobSupervisor({
+    backgroundJobStore: coordinator,
+    wallClockTimeoutMs: overrides.timeoutMs ?? 100,
+    abortGraceMs: overrides.graceMs ?? 20,
+    abort,
+    now: timers.now,
+    setTimeout: timers.setTimeout,
+    clearTimeout: timers.clearTimeout,
+  });
+  coordinator.addTerminalOutcomeListener((record) =>
+    supervisor.onTerminal(record),
+  );
+
+  return { board, coordinator, supervisor, timers, abort };
+}
+
+function launch(
+  board: BackgroundJobBoard,
+  background: boolean,
+  now = 0,
+  taskID = 'ses_1',
+) {
+  return board.registerLaunch({
+    taskID,
+    parentSessionID: 'parent',
+    agent: 'explorer',
+    description: 'test job',
+    background,
+    now,
+  });
+}
+
+describe('BackgroundJobSupervisor', () => {
+  test('supervises only explicit background launches', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const foreground = launch(board, false);
+    const background = launch(board, true, 0, 'ses_2');
+
+    supervisor.onLaunch(foreground);
+    supervisor.onLaunch(background);
+    await timers.advanceTo(100);
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('ses_2');
+    expect(board.get('ses_1')?.deadlineExceededAt).toBeUndefined();
+  });
+
+  test('duplicate launch observations do not renew one run deadline', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const first = launch(board, true);
+    supervisor.onLaunch(first);
+    supervisor.onLaunch({ ...first, updatedAt: 80, lastLiveBusyAt: 80 });
+
+    await timers.advanceTo(100);
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('ses_1');
+  });
+
+  test('terminal state wins before the deadline and clears timers', async () => {
+    const { board, coordinator, supervisor, timers, abort } =
+      createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    const completed = coordinator.updateStatus({
+      taskID: job.taskID,
+      state: 'completed',
+      now: 99,
+    });
+    if (completed) supervisor.onTerminal(completed);
+    await timers.advanceTo(100);
+
+    expect(abort).not.toHaveBeenCalled();
+    expect(board.get(job.taskID)?.state).toBe('completed');
+    expect(timers.pending()).toBe(0);
+  });
+
+  test.each([
+    ['resolve', async () => undefined],
+    ['reject', async () => Promise.reject(new Error('abort failed'))],
+    ['hang', () => new Promise<never>(() => {})],
+  ])(
+    'abort %s is requested once and grace remains independent',
+    async (_, abortCall) => {
+      const { board, supervisor, timers, abort } = createSupervisor({
+        abort: abortCall,
+      });
+      const job = launch(board, true);
+      supervisor.onLaunch(job);
+      await timers.advanceTo(100);
+      await timers.advanceTo(119);
+
+      expect(abort).toHaveBeenCalledTimes(1);
+      expect(board.get(job.taskID)?.state).toBe('running');
+      await timers.advanceTo(120);
+
+      expect(board.get(job.taskID)).toMatchObject({
+        state: 'error',
+        timedOut: true,
+        statusUncertain: true,
+        cancellationRequested: true,
+      });
+      expect(board.getResultSummary(job.taskID)).toContain(
+        'abort was not confirmed',
+      );
+    },
+  );
+
+  test('completion after the deadline claim cannot replace the timeout', async () => {
+    const { board, coordinator, supervisor, timers, abort } =
+      createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    await timers.advanceTo(100);
+
+    const late = coordinator.updateStatus({
+      taskID: job.taskID,
+      state: 'completed',
+      resultSummary: 'late success',
+      now: 101,
+    });
+    expect(late?.state).toBe('running');
+    expect(late?.resultSummary).not.toBe('late success');
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
+
+  test('busy activity after the deadline neither recovers nor renews the run', async () => {
+    const { board, coordinator, supervisor, timers, abort } =
+      createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    await timers.advanceTo(100);
+    const beforeBusy = board.get(job.taskID);
+    coordinator.markRunningFromLiveSession(job.taskID, 101);
+
+    expect(board.get(job.taskID)).toMatchObject({
+      state: 'running',
+      lastLiveBusyAt: beforeBusy?.lastLiveBusyAt,
+      deadlineExceededAt: 100,
+    });
+    expect(
+      coordinator.resolveRecoverable('parent', job.taskID),
+    ).toBeUndefined();
+    await timers.advanceTo(120);
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(board.get(job.taskID)?.state).toBe('error');
+  });
+
+  test('error and cancelled during grace settle the same timed-out terminal', async () => {
+    for (const state of ['error', 'cancelled'] as const) {
+      const { board, coordinator, supervisor, timers } = createSupervisor();
+      const job = launch(board, true);
+      supervisor.onLaunch(job);
+      await timers.advanceTo(100);
+
+      const settled = coordinator.updateStatus({
+        taskID: job.taskID,
+        state,
+        resultSummary: 'child terminal',
+        now: 101,
+      });
+
+      expect(settled).toMatchObject({
+        state: 'error',
+        timedOut: true,
+        statusUncertain: false,
+        deadlineExceededAt: 100,
+      });
+      expect(timers.pending()).toBe(0);
+    }
+  });
+
+  test('child deletion during grace publishes a visible timed-out terminal', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    await timers.advanceTo(100);
+
+    expect(supervisor.onSessionDeleted(job.taskID)).toBe(true);
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(board.get(job.taskID)).toMatchObject({
+      state: 'error',
+      timedOut: true,
+      terminalUnreconciled: true,
+      statusUncertain: false,
+    });
+    expect(board.formatForPrompt('parent')).toContain(
+      'error, unreconciled, timed out',
+    );
+    board.markReconciled(job.taskID, 130);
+    expect(board.get(job.taskID)).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'error',
+      statusUncertain: false,
+      deadlineExceededAt: 100,
+    });
+    expect(timers.pending()).toBe(0);
+  });
+
+  test('wall-clock timeout is not recoverable while external timeout remains recoverable', async () => {
+    const { board, coordinator, supervisor, timers } = createSupervisor();
+    const external = launch(board, false, 0, 'external');
+    coordinator.updateStatus({
+      taskID: external.taskID,
+      state: 'running',
+      timedOut: true,
+    });
+    coordinator.markRunningFromLiveSession(external.taskID, 1);
+    expect(
+      coordinator.resolveRecoverable('parent', external.taskID),
+    ).toBeDefined();
+
+    const wall = launch(board, true, 0, 'wall');
+    supervisor.onLaunch(wall);
+    await timers.advanceTo(100);
+    coordinator.markRunningFromLiveSession(wall.taskID, 101);
+    expect(
+      coordinator.resolveRecoverable('parent', wall.taskID),
+    ).toBeUndefined();
+  });
+
+  test('drop, parent cleanup, dispose, and relaunch clear or replace timers', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    supervisor.drop(job.taskID);
+    board.drop(job.taskID);
+    await timers.advanceTo(100);
+    expect(abort).not.toHaveBeenCalled();
+
+    const parentJob = launch(board, true, 100, 'parent-job');
+    supervisor.onLaunch(parentJob);
+    supervisor.clearParent('parent');
+    board.clearParent('parent');
+    await timers.advanceTo(200);
+    expect(abort).not.toHaveBeenCalled();
+
+    const relaunched = launch(board, true, 200, 'relaunch');
+    supervisor.onLaunch(relaunched);
+    const secondRun = board.registerLaunch({
+      taskID: relaunched.taskID,
+      parentSessionID: 'parent',
+      agent: 'explorer',
+      background: true,
+      now: 300,
+    });
+    supervisor.onLaunch(secondRun);
+    await timers.advanceTo(399);
+    expect(abort).not.toHaveBeenCalled();
+    await timers.advanceTo(400);
+    expect(abort).toHaveBeenCalledTimes(1);
+
+    const disposedJob = launch(board, true, 400, 'disposed');
+    supervisor.onLaunch(disposedJob);
+    supervisor.dispose();
+    supervisor.dispose();
+    expect(supervisor.onSessionDeleted(disposedJob.taskID)).toBe(false);
+    expect(board.get(disposedJob.taskID)?.state).toBe('running');
+    await timers.advanceTo(500);
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
+});

+ 187 - 0
src/utils/background-job-supervisor.ts

@@ -0,0 +1,187 @@
+import type { BackgroundJobRecord } from './background-job-board';
+import type { BackgroundJobStore } from './background-job-store';
+
+type TimerHandle = ReturnType<typeof setTimeout>;
+
+export interface BackgroundJobSupervisorOptions {
+  backgroundJobStore: BackgroundJobStore;
+  wallClockTimeoutMs: number;
+  abortGraceMs: number;
+  abort: (taskID: string) => Promise<unknown>;
+  now?: () => number;
+  setTimeout?: (callback: () => void, delay: number) => TimerHandle;
+  clearTimeout?: (timer: TimerHandle) => void;
+}
+
+interface RunTimers {
+  generation: number;
+  parentSessionID: string;
+  deadlineTimer?: TimerHandle;
+  graceTimer?: TimerHandle;
+}
+
+/**
+ * One-shot wall-clock supervision for native background task sessions.
+ *
+ * This class owns only timer/generation/abort mechanics. The board/coordinator
+ * remains the atomic state and terminal-publication boundary.
+ */
+export class BackgroundJobSupervisor {
+  private readonly now: () => number;
+  private readonly setTimer: (
+    callback: () => void,
+    delay: number,
+  ) => TimerHandle;
+  private readonly clearTimer: (timer: TimerHandle) => void;
+  private readonly runs = new Map<string, RunTimers>();
+  private disposed = false;
+
+  constructor(private readonly options: BackgroundJobSupervisorOptions) {
+    this.now = options.now ?? Date.now;
+    this.setTimer =
+      options.setTimeout ?? ((callback, delay) => setTimeout(callback, delay));
+    this.clearTimer = options.clearTimeout ?? ((timer) => clearTimeout(timer));
+  }
+
+  /** Register the first observation of a launch or an explicit new run. */
+  onLaunch(record: BackgroundJobRecord): void {
+    if (this.disposed || record.background !== true) {
+      this.clear(record.taskID);
+      return;
+    }
+    if (this.options.wallClockTimeoutMs <= 0 || record.state !== 'running') {
+      this.clear(record.taskID);
+      return;
+    }
+
+    const current = this.runs.get(record.taskID);
+    if (current?.generation === record.generation) return;
+    this.clear(record.taskID);
+
+    const run: RunTimers = {
+      generation: record.generation,
+      parentSessionID: record.parentSessionID,
+    };
+    run.deadlineTimer = this.setTimer(
+      () => this.onDeadline(record.taskID, record.generation),
+      Math.max(
+        0,
+        record.runStartedAt + this.options.wallClockTimeoutMs - this.now(),
+      ),
+    );
+    this.runs.set(record.taskID, run);
+  }
+
+  /** Clear one-shot timers after any canonical terminal publication. */
+  onTerminal(record: BackgroundJobRecord): void {
+    if (
+      record.state === 'completed' ||
+      record.state === 'error' ||
+      record.state === 'cancelled' ||
+      record.state === 'reconciled'
+    ) {
+      const run = this.runs.get(record.taskID);
+      if (run?.generation === record.generation) this.clear(record.taskID);
+    }
+  }
+
+  /**
+   * Handle a child deletion before the normal board drop callback. A deletion
+   * during grace confirms the timed-out terminal; an ordinary deletion simply
+   * invalidates the run without inventing a terminal result.
+   */
+  onSessionDeleted(taskID: string): boolean {
+    if (this.disposed) {
+      this.clear(taskID);
+      return false;
+    }
+    const record = this.options.backgroundJobStore.get(taskID);
+    if (!record) {
+      this.clear(taskID);
+      return false;
+    }
+    if (record.deadlineExceededAt !== undefined && record.state === 'running') {
+      this.options.backgroundJobStore.finalizeWallClockTimeout({
+        taskID,
+        generation: record.generation,
+        now: this.now(),
+        statusUncertain: false,
+        resultSummary:
+          'Background task exceeded its wall-clock deadline; session deletion confirmed the abort.',
+      });
+      this.clear(taskID);
+      return true;
+    }
+    this.clear(taskID);
+    return false;
+  }
+
+  drop(taskID: string): void {
+    this.clear(taskID);
+  }
+
+  clearParent(parentSessionID: string): void {
+    for (const [taskID] of this.runs) {
+      if (this.runs.get(taskID)?.parentSessionID === parentSessionID) {
+        this.clear(taskID);
+      }
+    }
+  }
+
+  /** Idempotent local cleanup. It never aborts or writes terminal state. */
+  dispose(): void {
+    if (this.disposed) return;
+    this.disposed = true;
+    for (const taskID of this.runs.keys()) this.clear(taskID);
+    this.runs.clear();
+  }
+
+  private onDeadline(taskID: string, generation: number): void {
+    const run = this.runs.get(taskID);
+    if (this.disposed || !run || run.generation !== generation) return;
+    run.deadlineTimer = undefined;
+
+    const claimed = this.options.backgroundJobStore.claimWallClockDeadline({
+      taskID,
+      generation,
+      now: this.now(),
+    });
+    if (!claimed) {
+      this.clear(taskID);
+      return;
+    }
+
+    // The grace timer is armed before abort is invoked. A rejected or hanging
+    // SDK promise must never prevent the bounded terminal transition.
+    run.graceTimer = this.setTimer(
+      () => this.onGraceExpired(taskID, generation),
+      this.options.abortGraceMs,
+    );
+    Promise.resolve()
+      .then(() => this.options.abort(taskID))
+      .catch(() => undefined);
+  }
+
+  private onGraceExpired(taskID: string, generation: number): void {
+    const run = this.runs.get(taskID);
+    if (this.disposed || !run || run.generation !== generation) return;
+    run.graceTimer = undefined;
+    this.options.backgroundJobStore.finalizeWallClockTimeout({
+      taskID,
+      generation,
+      now: this.now(),
+      statusUncertain: true,
+      resultSummary:
+        'Background task exceeded its wall-clock deadline; abort was not confirmed before the grace period expired.',
+    });
+    this.clear(taskID);
+  }
+
+  private clear(taskID: string): void {
+    const run = this.runs.get(taskID);
+    if (!run) return;
+    if (run.deadlineTimer !== undefined) this.clearTimer(run.deadlineTimer);
+    if (run.graceTimer !== undefined) this.clearTimer(run.graceTimer);
+    this.runs.delete(taskID);
+  }
+}

+ 1 - 0
src/utils/index.ts

@@ -2,6 +2,7 @@ export * from './agent-variant';
 export * from './background-job-board';
 export * from './background-job-coordinator';
 export * from './background-job-store';
+export * from './background-job-supervisor';
 export * from './internal-initiator';
 export { initLogger, log } from './logger';
 export * from './polling';