Browse Source

Merge pull request #886 from lih54767-coder/fix/background-job-wall-clock-timeout

feat(tasks): add opt-in wall-clock supervision
Alvin 1 week ago
parent
commit
e498eef367

+ 43 - 0
docs/background-orchestration.md

@@ -412,6 +412,49 @@ miss at the epoch boundary, after which a fresh run of up to the configured limi
 can accumulate. The cache is lost on plugin restart, so snapshots are not
 restored beyond those present in the current OpenCode message history.
 
+### Opt-in Wall-clock Supervisor
+
+The plugin can apply a one-shot wall-clock deadline to native background task
+child sessions. It is disabled by default:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "wallClockTimeoutMs": 900000,
+    "abortGraceMs": 10000
+  }
+}
+```
+
+This supervisor recognizes only an explicit `task(..., background: true)` call.
+Foreground tasks and calls where `background` is omitted or `false` are not
+supervised. The deadline begins at the first launch observation for the current
+run. Duplicate `session.created`/tool-hook observations, busy activity, tool
+activity, and liveness timestamps do not renew it. An explicit relaunch or reuse
+starts a new run generation.
+
+When the deadline wins a race with a real terminal transition, the board records
+a persistent hard-deadline marker, marks cancellation as requested, starts the
+bounded abort grace period, and issues exactly one native session abort. The
+grace timer is independent of whether the SDK abort resolves, rejects, or hangs.
+An error, cancellation, or child deletion during grace publishes one stable
+timed-out terminal outcome. If no terminal confirmation arrives before grace
+expires, the outcome is `error`, `timedOut: true`, and `statusUncertain: true`,
+with a summary stating that abort was not confirmed.
+
+Late completion, busy, retry, or error events cannot replace a published hard
+timeout, and a hard wall-clock timeout is not recoverable through the existing
+external task-wait timeout path. The timeout outcome remains visible to the
+parent through the normal terminal-unreconciled Background Job Board flow; no
+prompt or raw task-result rewrite is used. Timeout terminals also issue a
+permanent logical pane-close intent so generic and cmux multiplexer paths do not
+respawn a pane on late busy events.
+
+`wallClockTimeoutMs` accepts `0` or integers from `60000` through `2147483647`;
+`abortGraceMs` accepts integers from `1000` through `60000`. This feature is
+wall-clock-only: no no-progress/plateau policy, foreground fallback, model swap,
+session deletion retry, or worker-death guarantee is implied.
+
 ---
 
 ## Startup Behavior

+ 14 - 3
docs/configuration.md

@@ -152,6 +152,8 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss See [Background Job Management](#background-job-management). |
 | `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart See [Background Job Management](#background-job-management). |
 | `backgroundJobs.continueOnIdle` | boolean | `false` | **Beta opt-in.** Set `true` to let idle orchestrator sessions with incomplete todos receive one automatic hidden continuation prompt. When omitted or `false`, idle reconciliation and background-job orchestration remain active without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.wallClockTimeoutMs` | integer | `0` | **Opt-in wall-clock supervisor.** `0` disables it. Otherwise, only native `task(..., background: true)` child sessions are supervised; accepted values are `60000`–`2147483647` milliseconds See [Background Job Management](#background-job-management). |
+| `backgroundJobs.abortGraceMs` | integer | `10000` | Grace period after a wall-clock deadline for a terminal confirmation. Accepted values are `1000`–`60000` milliseconds; a hanging or failed abort does not extend this grace See [Background Job Management](#background-job-management). |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -293,6 +295,8 @@ incomplete-todo continuation prompts on idle. For glossary definitions of
 background-job terms (board snapshot, checkpoint cache epoch, injection
 strategy, etc.), see [CONTEXT.md — Background
 Jobs](../CONTEXT.md#background-jobs).
+The wall-clock supervisor is separately opt-in and remains disabled unless
+`wallClockTimeoutMs` is set:
 
 ```jsonc
 {
@@ -300,15 +304,22 @@ Jobs](../CONTEXT.md#background-jobs).
     "maxSessionsPerAgent": 3,
     "strategy": "checkpoint-compatible",
     "maxRetainedSnapshots": 10,
-    "continueOnIdle": true
+    "continueOnIdle": true,
+    "wallClockTimeoutMs": 900000,
+    "abortGraceMs": 10000
   }
 }
 ```
 
-Without that opt-in, idle reconciliation and background-job orchestration remain
-enabled but no hidden continuation prompts are sent. See the
+Without `continueOnIdle`, idle reconciliation and background-job orchestration
+remain enabled but no hidden continuation prompts are sent. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,
 defaults, and examples.
+`wallClockTimeoutMs` is a hard deadline that only supervises explicitly
+background native task calls; foreground calls or calls with `background`
+omitted are not supervised. It is independent from OpenCode's external
+task-wait timeout, and a wall-clock timeout cannot be recovered by reusing the
+running session.
 
 ### Agent Display Names
 

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

@@ -1059,6 +1059,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

@@ -142,4 +142,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

@@ -218,6 +218,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

@@ -7,6 +7,7 @@
  */
 import type { BackgroundJobExecution } from '../../utils/background-job-board';
 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 {
@@ -84,6 +85,7 @@ export async function handleEvent(
       Map<string, BackgroundJobExecution>
     >;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
 ): Promise<void> {
   deps.inputWaits.trackInputWait(input.event);
@@ -130,9 +132,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,
@@ -146,6 +152,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
@@ -318,6 +325,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,
@@ -276,6 +317,7 @@ describe('task-session-manager hook', () => {
       {
         args: {
           subagent_type: 'explorer',
+          background: true,
           description: 'map scheduler hooks',
           prompt: 'inspect scheduler hooks',
         },
@@ -331,6 +373,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({
@@ -4534,6 +4613,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

@@ -3,6 +3,7 @@ import {
   BackgroundJobBoard,
   type BackgroundJobExecution,
   type BackgroundJobStore,
+  type BackgroundJobSupervisor,
   isInternalInitiatorPart,
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
@@ -60,6 +61,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. */
@@ -185,8 +187,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);
       pendingInjectedTerminalJobsByParent.delete(sessionId);
@@ -289,6 +297,7 @@ export function createTaskSessionManagerHook(
         shouldManageSession: options.shouldManageSession,
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -300,6 +309,7 @@ export function createTaskSessionManagerHook(
       handleToolExecuteAfter(input, output, {
         directory: _ctx.directory,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -381,6 +391,7 @@ export function createTaskSessionManagerHook(
         terminalJobsInjectedByParent,
         pendingInjectedTerminalJobsByParent,
         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);

+ 187 - 0
src/index.test.ts

@@ -1,6 +1,59 @@
 import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { mkdtemp, rm } from 'node:fs/promises';
 import plugin from './index';
 
+function createPluginClient(
+  noop: () => Promise<unknown>,
+  abort?: (input: { path: { id: string } }) => Promise<unknown>,
+) {
+  const session = new Proxy(abort ? { abort } : {}, {
+    get(target, property) {
+      if (property in target) {
+        return target[property as keyof typeof target];
+      }
+      return noop;
+    },
+  }) as Record<string, unknown>;
+  return new Proxy(
+    { app: { log: noop }, session },
+    {
+      get(target, property) {
+        if (property in target) {
+          return target[property as keyof typeof target];
+        }
+        return new Proxy({}, { get: () => noop });
+      },
+    },
+  );
+}
+
+function createHostTimerHarness() {
+  let now = 0;
+  let nextID = 0;
+  const timers = new Map<number, { at: number; callback: () => void }>();
+
+  const setTimeout = (callback: () => void, delay = 0) => {
+    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 };
+}
+
 describe('plugin env disable', () => {
   let originalEnv: typeof process.env;
 
@@ -88,4 +141,138 @@ describe('plugin tool registration', () => {
       ),
     ).resolves.toContain('state: waiting_for_user');
   });
+
+  test('exposes an idempotent top-level dispose finalizer', async () => {
+    const noop = async () => ({});
+    const session = new Proxy({}, { get: () => noop }) as Record<
+      string,
+      unknown
+    >;
+    const client = new Proxy(
+      { app: { log: noop }, session },
+      {
+        get(target, property) {
+          if (property in target) {
+            return target[property as keyof typeof target];
+          }
+          return new Proxy({}, { get: () => noop });
+        },
+      },
+    );
+
+    const hooks = await plugin({
+      client,
+      directory: '/private/tmp/oh-my-opencode-slim-dispose-project',
+      worktree: '/private/tmp/oh-my-opencode-slim-dispose-project',
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+
+    expect(hooks.dispose).toBeFunction();
+    await hooks.dispose?.();
+    await hooks.dispose?.();
+  });
+
+  test('disposes generation one timers and fresh generation two supervises launches', async () => {
+    const originalEnv = { ...process.env };
+    const originalSetTimeout = globalThis.setTimeout;
+    const originalClearTimeout = globalThis.clearTimeout;
+    const originalNow = Date.now;
+    const clock = createHostTimerHarness();
+    const abortCalls: string[] = [];
+    const noop = async () => ({});
+    const client = createPluginClient(noop, async ({ path }) => {
+      abortCalls.push(path.id);
+      return {};
+    });
+    const configDir = await mkdtemp('/tmp/oh-my-opencode-slim-phase-2r-');
+    await Bun.write(
+      `${configDir}/oh-my-opencode-slim.json`,
+      JSON.stringify({
+        backgroundJobs: {
+          wallClockTimeoutMs: 60_000,
+          abortGraceMs: 1_000,
+        },
+      }),
+    );
+    process.env = {
+      ...originalEnv,
+      OPENCODE_CONFIG_DIR: configDir,
+    };
+    delete process.env.OH_MY_OPENCODE_SLIM_DISABLE;
+    globalThis.setTimeout = clock.setTimeout as typeof globalThis.setTimeout;
+    globalThis.clearTimeout =
+      clock.clearTimeout as typeof globalThis.clearTimeout;
+    Date.now = clock.now;
+
+    const launch = async (
+      hooks: Awaited<ReturnType<typeof plugin>>,
+      callID: string,
+      taskID: string,
+    ) => {
+      await hooks['tool.execute.before']?.(
+        { tool: 'task', sessionID: 'parent-1', callID },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background: true,
+            description: taskID,
+          },
+        },
+      );
+      await hooks['tool.execute.after']?.(
+        { tool: 'task', sessionID: 'parent-1', callID },
+        {
+          output: [
+            `task_id: ${taskID}`,
+            'state: running',
+            '',
+            '<task_result>',
+            'started',
+            '</task_result>',
+          ].join('\n'),
+        },
+      );
+    };
+
+    let generationOne: Awaited<ReturnType<typeof plugin>> | undefined;
+    let generationTwo: Awaited<ReturnType<typeof plugin>> | undefined;
+    try {
+      generationOne = await plugin({
+        client,
+        directory: configDir,
+        worktree: configDir,
+        serverUrl: new URL('http://127.0.0.1:4096'),
+      } as never);
+      expect(generationOne.dispose).toBeFunction();
+      await launch(generationOne, 'call-1', 'child-generation-1');
+
+      await clock.advanceTo(59_999);
+      expect(abortCalls).toEqual([]);
+      await generationOne.dispose?.();
+      await generationOne.dispose?.();
+      await clock.advanceTo(60_000);
+      expect(abortCalls).toEqual([]);
+
+      generationTwo = await plugin({
+        client,
+        directory: configDir,
+        worktree: configDir,
+        serverUrl: new URL('http://127.0.0.1:4096'),
+      } as never);
+      expect(generationTwo.dispose).toBeFunction();
+      await launch(generationTwo, 'call-2', 'child-generation-2');
+      await clock.advanceTo(119_999);
+      expect(abortCalls).toEqual([]);
+      await clock.advanceTo(120_000);
+      expect(abortCalls).toEqual(['child-generation-2']);
+    } finally {
+      await generationTwo?.dispose?.();
+      await generationOne?.dispose?.();
+      process.env = originalEnv;
+      globalThis.setTimeout = originalSetTimeout;
+      globalThis.clearTimeout = originalClearTimeout;
+      Date.now = originalNow;
+      await rm(configDir, { recursive: true, force: true });
+    }
+  });
 });

+ 36 - 10
src/index.ts

@@ -71,6 +71,7 @@ import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   BackgroundJobBoard,
   BackgroundJobCoordinator,
+  BackgroundJobSupervisor,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
@@ -177,6 +178,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>;
@@ -194,11 +196,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   try {
     config = loadPluginConfig(ctx.directory);
 
-    // Safety net: if a runtime preset was set via /preset command and
-    // OpenCode ever fully re-runs the plugin function (not just the
-    // config() hook), override config.preset so agents are created with
-    // the correct models. Currently only the config() hook re-runs after
-    // Instance.dispose(), so this is a defensive guard.
+    // Safety net: instance disposal reruns the plugin factory and rebuilds
+    // factory-local state, while module-level runtime preset state may persist.
+    // Reapply that persisted preset so each fresh generation creates agents
+    // with the correct models.
     const runtimePreset = getActiveRuntimePreset();
     if (runtimePreset && config.presets?.[runtimePreset]) {
       config.preset = runtimePreset;
@@ -306,6 +307,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
@@ -317,6 +330,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);
 
@@ -358,6 +377,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         DEFAULT_READ_CONTEXT_MAX_FILES,
       continueOnIdle: config.backgroundJobs?.continueOnIdle === true,
       backgroundJobBoard: backgroundJobCoordinator,
+      backgroundJobSupervisor,
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
       registerSessionAsOrchestrator: (sessionID) => {
@@ -693,11 +713,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
-      // Runtime preset override: if /preset switched to a runtime preset,
-      // override the model/variant/temperature from the preset's agent
-      // config. This runs after the normal model resolution because the
-      // config() hook re-runs with stale modelArrayMap after dispose(),
-      // but the runtime preset data is in the captured `config` closure.
+      // Runtime preset override: instance disposal recreates the plugin
+      // factory and its factory-local state, while module-level runtime
+      // preset data may persist. Apply that persisted selection after normal
+      // model resolution for the current generation.
       const runtimePresetName = getActiveRuntimePreset();
       if (runtimePresetName && config.presets?.[runtimePresetName]) {
         const runtimePreset = config.presets[runtimePresetName];
@@ -1083,6 +1102,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
     },
 
+    dispose: async () => {
+      await taskSessionManagerHook.event({
+        event: { type: 'server.instance.disposed' },
+      });
+      await multiplexerSessionManager.cleanupOnInstanceDisposed();
+    },
+
     'tool.execute.before': async (input, output) => {
       await applyPatch['tool.execute.before'](input as never, output as never);
       await taskSessionManagerHook['tool.execute.before'](

+ 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
 

+ 154 - 4
src/utils/background-job-board.ts

@@ -30,12 +30,13 @@ export type BackgroundJobState = TaskOutputState | 'reconciled';
 
 export interface BackgroundJobRecord {
   taskID: string;
-  generation: number;
   parentSessionID: string;
   agent: string;
   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;
@@ -43,6 +44,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;
@@ -70,6 +77,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;
 }
 
@@ -83,6 +93,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>([
@@ -155,6 +180,19 @@ 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,
         generation,
@@ -162,6 +200,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         description: input.description || existing.description,
         objective: input.objective ?? existing.objective,
         state: 'running',
+        background: input.background ?? existing.background,
         timedOut: false,
         recoverableAfterLiveBusy: false,
         statusUncertain: false,
@@ -172,6 +211,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         lastStatusError: undefined,
         terminalState: undefined,
         lastLaunchedAt: now,
+        runStartedAt: now,
+        deadlineExceededAt: undefined,
         lastLiveBusyAt: now,
         lastUsedAt: now,
         updatedAt: now,
@@ -190,6 +231,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,
@@ -197,6 +239,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       terminalUnreconciled: false,
       launchedAt: now,
       lastLaunchedAt: now,
+      runStartedAt: now,
       lastLiveBusyAt: now,
       lastUsedAt: now,
       updatedAt: now,
@@ -216,6 +259,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' ||
@@ -285,6 +344,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) {
@@ -327,7 +388,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),
@@ -346,6 +410,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;
@@ -403,6 +477,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');
   }
@@ -440,7 +580,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;
@@ -687,13 +831,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

@@ -5,12 +5,15 @@ import type {
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
+  WallClockTimeoutClaimInput,
+  WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 import type { BackgroundJobStore } from './background-job-store';
 import { log } from './logger';
 import type { TaskOutputState } from './task';
 
 type TerminalStateListener = (taskID: string) => void;
+type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
 
 /**
  * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
@@ -25,6 +28,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>();
 
@@ -70,6 +74,24 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
         }
       }
     }
+
+    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 ─────────────────────────────────────────────
@@ -119,6 +141,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

@@ -4,6 +4,8 @@ import type {
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
+  WallClockTimeoutClaimInput,
+  WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 import type { TaskOutputState } from './task';
 
@@ -20,6 +22,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';