Bläddra i källkod

fix: reconcile stopped background jobs

Alvin Unreal 1 månad sedan
förälder
incheckning
b9fdc7b0a7

+ 21 - 1
docs/background-orchestration.md

@@ -300,10 +300,11 @@ The prompt/runtime treats background tasks as a small job board:
 | task ID | Native OpenCode background task/session ID |
 | task ID | Native OpenCode background task/session ID |
 | specialist | Agent type assigned |
 | specialist | Agent type assigned |
 | objective | What the task is responsible for |
 | objective | What the task is responsible for |
-| state | running, completed, error, cancelled, timed out |
+| state | running; stopped (runtime ended without terminal task output); completed, error, or cancelled (explicit terminal task output); reconciled (terminal result consumed) |
 | ownership | Files/folders/subsystems the task may edit |
 | ownership | Files/folders/subsystems the task may edit |
 | dependencies | Tasks that must complete first |
 | dependencies | Tasks that must complete first |
 | result | Final task output once terminal |
 | result | Final task output once terminal |
+| status certainty | `status uncertain` when the live status map is malformed or unavailable; it never implies completion |
 
 
 The current todo list can represent user-visible work, but task IDs and file
 The current todo list can represent user-visible work, but task IDs and file
 ownership need to be explicit in the orchestrator's working context.
 ownership need to be explicit in the orchestrator's working context.
@@ -424,6 +425,25 @@ 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
 can accumulate. The cache is lost on plugin restart, so snapshots are not
 restored beyond those present in the current OpenCode message history.
 restored beyond those present in the current OpenCode message history.
 
 
+### Runtime Liveness Reconciliation
+
+The job board is a local projection; OpenCode's live session-status map is the
+liveness authority. After a tracked task launches, the plugin periodically
+checks that single map for every board job still marked `running`, while normal
+session events remain the fast path.
+
+`busy` and `retry` confirm that a job is live. An explicit `idle` state or an
+absent session in an otherwise valid map records `stopped, unreconciled` rather
+than `completed`: it means execution ended before a native terminal task result
+was delivered, not that the task succeeded. Stopped sessions are never reusable
+and stay visible to the parent for recovery. A later live `busy` observation can
+revive them, and only explicit terminal task output proves completion, error, or
+cancellation.
+
+Malformed status entries and failed status requests are surfaced as `status
+uncertain`; they never prove that a job stopped or completed. Each observation
+is generation-aware, so a delayed response cannot modify a relaunched task.
+
 ### Opt-in Wall-clock Supervisor
 ### Opt-in Wall-clock Supervisor
 
 
 The plugin can apply a one-shot wall-clock deadline to native background task
 The plugin can apply a one-shot wall-clock deadline to native background task

+ 4 - 0
src/cache-safety-tripwire.test.ts

@@ -66,6 +66,10 @@ const ALLOWLIST = new Map<string, string>([
     'hooks/task-session-manager/event-router.ts',
     'hooks/task-session-manager/event-router.ts',
     'Date.now() captures idleObservedAt to detect post-idle busy recovery from foreground-fallback re-prompts; never serialized into prompt content.',
     'Date.now() captures idleObservedAt to detect post-idle busy recovery from foreground-fallback re-prompts; never serialized into prompt content.',
   ],
   ],
+  [
+    'hooks/task-session-manager/runtime-status-reconciliation.ts',
+    'Date.now() establishes in-memory request/observation ordering for generation-safe status reconciliation; board timestamps are never formatted into prompt content.',
+  ],
   [
   [
     'hooks/image-hook.ts',
     'hooks/image-hook.ts',
     'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
     'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',

+ 42 - 0
src/hooks/orchestrator-wake/index.test.ts

@@ -5,6 +5,7 @@ import { resetUserWaitGateForTests } from '../task-session-manager/user-wait-gat
 import {
 import {
   buildOrchestratorWakeFingerprint,
   buildOrchestratorWakeFingerprint,
   createOrchestratorWakeScheduler,
   createOrchestratorWakeScheduler,
+  ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT,
   ORCHESTRATOR_WAKE_TEXT,
   ORCHESTRATOR_WAKE_TEXT,
   ORCHESTRATOR_WAKE_UNCHANGED_CAP,
   ORCHESTRATOR_WAKE_UNCHANGED_CAP,
 } from './index';
 } from './index';
@@ -176,6 +177,47 @@ describe('buildOrchestratorWakeFingerprint', () => {
 });
 });
 
 
 describe('orchestrator wake scheduler', () => {
 describe('orchestrator wake scheduler', () => {
+  test('immediately wakes an idle parent after a stopped child', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ todos: [], promptAsync }),
+    });
+
+    scheduler.triggerStoppedJobRecovery('p1');
+    await clock.advance(0);
+
+    expect(promptAsync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        body: expect.objectContaining({
+          parts: [
+            createInternalAgentTextPart(ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT),
+          ],
+        }),
+      }),
+    );
+  });
+
+  test('does not recover-wake when disabled, waiting for input, busy, or disposed', async () => {
+    const cases = [
+      createScheduler({ enabled: false }),
+      createScheduler({ hasInputWait: () => true }),
+      createScheduler({
+        sessionClient: makeClient({ statusData: { p1: { type: 'busy' } } }),
+      }),
+      createScheduler(),
+    ];
+    const disposed = cases[3];
+    await disposed?.scheduler.event({
+      event: { type: 'server.instance.disposed' },
+    });
+
+    for (const item of cases) item?.scheduler.triggerStoppedJobRecovery('p1');
+    await clock.advance(0);
+
+    for (const item of cases) {
+      expect(item?.session?.promptAsync).not.toHaveBeenCalled();
+    }
+  });
   test('does nothing when disabled', async () => {
   test('does nothing when disabled', async () => {
     const promptAsync = mock(async () => ({}));
     const promptAsync = mock(async () => ({}));
     const { scheduler } = createScheduler({
     const { scheduler } = createScheduler({

+ 49 - 3
src/hooks/orchestrator-wake/index.ts

@@ -40,6 +40,9 @@ import {
 export const ORCHESTRATOR_WAKE_TEXT =
 export const ORCHESTRATOR_WAKE_TEXT =
   '<system-reminder>\nFinish any incomplete TODOs. Await running agents; if one appears stuck, assess it and cancel/respawn only when justified. Do not respond to this reminder.\n</system-reminder>';
   '<system-reminder>\nFinish any incomplete TODOs. Await running agents; if one appears stuck, assess it and cancel/respawn only when justified. Do not respond to this reminder.\n</system-reminder>';
 
 
+export const ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT =
+  '<system-reminder>\nA background job stopped without a terminal result. Consult the Background Job Board, recover or reroute the work as needed, and do not wait for that job as if it were still running. Do not respond to this reminder.\n</system-reminder>';
+
 /** After this many successful wakes with an unchanged fingerprint, stop. */
 /** After this many successful wakes with an unchanged fingerprint, stop. */
 export const ORCHESTRATOR_WAKE_UNCHANGED_CAP = 2;
 export const ORCHESTRATOR_WAKE_UNCHANGED_CAP = 2;
 
 
@@ -217,6 +220,8 @@ export function createOrchestratorWakeScheduler(
   const localSessions = new Map<string, LocalSessionState>();
   const localSessions = new Map<string, LocalSessionState>();
   /** Reservations this hook owns and must release when it is disposed. */
   /** Reservations this hook owns and must release when it is disposed. */
   const localWakeOwners = new Map<string, symbol>();
   const localWakeOwners = new Map<string, symbol>();
+  const pendingStoppedRecoveries = new Set<string>();
+  let disposed = false;
 
 
   function touchLocal(sessionID: string): LocalSessionState {
   function touchLocal(sessionID: string): LocalSessionState {
     const existing = localSessions.get(sessionID);
     const existing = localSessions.get(sessionID);
@@ -260,6 +265,7 @@ export function createOrchestratorWakeScheduler(
     releaseLocalWakeOwner(sessionID);
     releaseLocalWakeOwner(sessionID);
     clearLocalSession(sessionID);
     clearLocalSession(sessionID);
     clearWakeSession(sessionID);
     clearWakeSession(sessionID);
+    pendingStoppedRecoveries.delete(sessionID);
   }
   }
 
 
   /**
   /**
@@ -408,6 +414,7 @@ export function createOrchestratorWakeScheduler(
   async function evaluate(
   async function evaluate(
     sessionID: string,
     sessionID: string,
     generation: symbol,
     generation: symbol,
+    recoveryWake = false,
   ): Promise<void> {
   ): Promise<void> {
     const state = localSessions.get(sessionID);
     const state = localSessions.get(sessionID);
     if (!state || state.generation !== generation) return;
     if (!state || state.generation !== generation) return;
@@ -446,7 +453,7 @@ export function createOrchestratorWakeScheduler(
         endIdleSpell(sessionID, true);
         endIdleSpell(sessionID, true);
         return;
         return;
       }
       }
-      if (!hasIncompleteTodos(snapshot.todos)) {
+      if (!recoveryWake && !hasIncompleteTodos(snapshot.todos)) {
         // No incomplete work: end the spell; do not keep polling.
         // No incomplete work: end the spell; do not keep polling.
         endIdleSpell(sessionID, false);
         endIdleSpell(sessionID, false);
         return;
         return;
@@ -482,7 +489,7 @@ export function createOrchestratorWakeScheduler(
         endIdleSpell(sessionID, true);
         endIdleSpell(sessionID, true);
         return;
         return;
       }
       }
-      if (!hasIncompleteTodos(latest.todos)) {
+      if (!recoveryWake && !hasIncompleteTodos(latest.todos)) {
         endIdleSpell(sessionID, false);
         endIdleSpell(sessionID, false);
         return;
         return;
       }
       }
@@ -521,10 +528,17 @@ export function createOrchestratorWakeScheduler(
         body: {
         body: {
           agent: 'orchestrator',
           agent: 'orchestrator',
           ...(modelSelection ? { model: modelSelection.model } : {}),
           ...(modelSelection ? { model: modelSelection.model } : {}),
-          parts: [createInternalAgentTextPart(ORCHESTRATOR_WAKE_TEXT)],
+          parts: [
+            createInternalAgentTextPart(
+              recoveryWake
+                ? ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT
+                : ORCHESTRATOR_WAKE_TEXT,
+            ),
+          ],
         },
         },
         throwOnError: true,
         throwOnError: true,
       });
       });
+      if (recoveryWake) pendingStoppedRecoveries.delete(sessionID);
     } catch (error) {
     } catch (error) {
       // Failed promptAsync already reserved; clear expecting-busy so a later
       // Failed promptAsync already reserved; clear expecting-busy so a later
       // unrelated busy can rearm normally.
       // unrelated busy can rearm normally.
@@ -609,6 +623,31 @@ export function createOrchestratorWakeScheduler(
     rearmWakeProgress(sessionID);
     rearmWakeProgress(sessionID);
   }
   }
 
 
+  /**
+   * Immediately evaluate an idle orchestrator after a child stops without a
+   * native terminal result. This is deliberately separate from the periodic
+   * TODO wake: stopped work needs recovery even when its parent has no todo.
+   */
+  function triggerStoppedJobRecovery(sessionID: string): void {
+    if (
+      disposed ||
+      !enabled ||
+      !hasRequiredSessionApis(sessionSdk) ||
+      !options.shouldManageSession(sessionID)
+    ) {
+      return;
+    }
+    pendingStoppedRecoveries.add(sessionID);
+    rearmWakeProgress(sessionID);
+    if (!canSchedule(sessionID)) return;
+    const state = touchLocal(sessionID);
+    clearTimer(state);
+    bumpGeneration(state);
+    state.continuousIdle = true;
+    rearmWakeProgress(sessionID);
+    void evaluate(sessionID, state.generation, true);
+  }
+
   async function event(input: {
   async function event(input: {
     event: {
     event: {
       type: string;
       type: string;
@@ -622,6 +661,8 @@ export function createOrchestratorWakeScheduler(
     const { type, properties } = input.event;
     const { type, properties } = input.event;
 
 
     if (type === 'server.instance.disposed') {
     if (type === 'server.instance.disposed') {
+      disposed = true;
+      pendingStoppedRecoveries.clear();
       for (const sessionID of [...localWakeOwners.keys()]) {
       for (const sessionID of [...localWakeOwners.keys()]) {
         releaseLocalWakeOwner(sessionID);
         releaseLocalWakeOwner(sessionID);
       }
       }
@@ -649,6 +690,10 @@ export function createOrchestratorWakeScheduler(
     if (isIdleEvent(type, properties)) {
     if (isIdleEvent(type, properties)) {
       if (options.shouldManageSession(sessionID)) {
       if (options.shouldManageSession(sessionID)) {
         clearExpectingWakeBusy(sessionID);
         clearExpectingWakeBusy(sessionID);
+        if (pendingStoppedRecoveries.has(sessionID)) {
+          triggerStoppedJobRecovery(sessionID);
+          return;
+        }
         beginContinuousIdle(sessionID);
         beginContinuousIdle(sessionID);
       }
       }
       return;
       return;
@@ -688,6 +733,7 @@ export function createOrchestratorWakeScheduler(
   return {
   return {
     event,
     event,
     observeChatMessage,
     observeChatMessage,
+    triggerStoppedJobRecovery,
     /** Clear timers when wait_for_user or fallback begins. */
     /** Clear timers when wait_for_user or fallback begins. */
     suppress,
     suppress,
     /** Test seam */
     /** Test seam */

+ 2 - 0
src/hooks/task-session-manager/event-router.ts

@@ -65,6 +65,7 @@ export async function handleEvent(
       scheduleChildIdleReconciliation(
       scheduleChildIdleReconciliation(
         sessionID: string,
         sessionID: string,
         idleObservedAt: number,
         idleObservedAt: number,
+        observedGeneration: number,
       ): void;
       ): void;
       scheduleErrorTerminalize(sessionID: string, idleObservedAt: number): void;
       scheduleErrorTerminalize(sessionID: string, idleObservedAt: number): void;
       clearIdleTimers(sessionID: string): void;
       clearIdleTimers(sessionID: string): void;
@@ -213,6 +214,7 @@ export async function handleEvent(
         deps.idleReconciler.scheduleChildIdleReconciliation(
         deps.idleReconciler.scheduleChildIdleReconciliation(
           sessionId,
           sessionId,
           Date.now(),
           Date.now(),
+          job.generation,
         );
         );
       }
       }
     }
     }

+ 13 - 8
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -52,6 +52,7 @@ export function createIdleReconciler(options: {
   function scheduleChildIdleReconciliation(
   function scheduleChildIdleReconciliation(
     sessionID: string,
     sessionID: string,
     idleObservedAt: number,
     idleObservedAt: number,
+    observedGeneration: number,
   ): void {
   ): void {
     if (childIdleReconcileTimers.has(sessionID)) return;
     if (childIdleReconcileTimers.has(sessionID)) return;
     if (options.isFallbackInProgress?.(sessionID)) return;
     if (options.isFallbackInProgress?.(sessionID)) return;
@@ -61,7 +62,9 @@ export function createIdleReconciler(options: {
       if (options.isFallbackInProgress?.(sessionID)) return;
       if (options.isFallbackInProgress?.(sessionID)) return;
 
 
       const job = options.backgroundJobBoard.get(sessionID);
       const job = options.backgroundJobBoard.get(sessionID);
-      if (job?.state !== 'running') return;
+      if (job?.state !== 'running' || job.generation !== observedGeneration) {
+        return;
+      }
 
 
       // Busy after the idle means the session recovered (e.g. FG re-prompt).
       // Busy after the idle means the session recovered (e.g. FG re-prompt).
       if (
       if (
@@ -71,17 +74,19 @@ export function createIdleReconciler(options: {
         return;
         return;
       }
       }
 
 
-      log('[task-session-manager] reconciled running job from idle', {
+      log('[task-session-manager] observed runtime-stopped job from idle', {
         sessionID,
         sessionID,
         alias: job.alias,
         alias: job.alias,
         parentSessionID: job.parentSessionID,
         parentSessionID: job.parentSessionID,
       });
       });
-      options.backgroundJobBoard.updateStatus({
-        taskID: sessionID,
-        state: 'completed',
-        resultSummary: 'Background task completed (reconciled from idle event)',
-      });
-      options.backgroundJobBoard.markReconciled(sessionID);
+      options.backgroundJobBoard.markStopped(
+        sessionID,
+        'Background session stopped before a terminal task result was received.',
+        // The idle event itself happened after the last busy event. Preserve
+        // that ordering when timestamps share millisecond precision.
+        idleObservedAt + 1,
+        observedGeneration,
+      );
       options.taskContextTracker.pendingManagedTaskIds.delete(sessionID);
       options.taskContextTracker.pendingManagedTaskIds.delete(sessionID);
       options.backgroundJobBoard.addContext(
       options.backgroundJobBoard.addContext(
         sessionID,
         sessionID,

+ 146 - 78
src/hooks/task-session-manager/index.test.ts

@@ -88,6 +88,7 @@ type HookOptions = {
   sessionStatus?: unknown;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
   sessionClient?: Record<string, unknown>;
   idleReconcileDelayMs?: number;
   idleReconcileDelayMs?: number;
+  runtimeStatusReconcileDelayMs?: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   isFallbackInProgress?: (sessionID: string) => boolean;
   willAttemptFallback?: (sessionID: string) => boolean;
   willAttemptFallback?: (sessionID: string) => boolean;
   coordinator?: SessionLifecycle;
   coordinator?: SessionLifecycle;
@@ -121,6 +122,7 @@ function createHook(options?: HookOptions) {
       willAttemptFallback: options?.willAttemptFallback,
       willAttemptFallback: options?.willAttemptFallback,
       coordinator: options?.coordinator,
       coordinator: options?.coordinator,
       idleReconcileDelayMs: options?.idleReconcileDelayMs,
       idleReconcileDelayMs: options?.idleReconcileDelayMs,
+      runtimeStatusReconcileDelayMs: options?.runtimeStatusReconcileDelayMs,
     },
     },
   );
   );
 
 
@@ -4288,7 +4290,7 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toBe('do something');
     expect(messages.messages[0].parts[0].text).toBe('do something');
   });
   });
 
 
-  test('reconciles running child session job from session.idle event', async () => {
+  test('marks a running child as stopped when idle has no terminal task result', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({
       taskID: 'child-1',
       taskID: 'child-1',
@@ -4310,8 +4312,71 @@ describe('task-session-manager hook', () => {
     await flushChildIdleReconcile();
     await flushChildIdleReconcile();
 
 
     expect(board.get('child-1')).toMatchObject({
     expect(board.get('child-1')).toMatchObject({
-      state: 'reconciled',
-      terminalState: 'completed',
+      state: 'stopped',
+      terminalUnreconciled: true,
+      resultSummary:
+        'Background session stopped before a terminal task result was received.',
+    });
+  });
+
+  test('ignores an idle observation after the child has relaunched', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'first run',
+      now: 0,
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 20,
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'second run',
+      now: 1,
+    });
+    await new Promise((resolve) => setTimeout(resolve, 30));
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      generation: 2,
+      description: 'second run',
+    });
+  });
+
+  test('starts runtime reconciliation after task launch', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 0,
+    });
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'fixer',
+          background: true,
+          description: 'runtime-check',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: taskLaunchOutput('child-1') },
+    );
+    await flushChildIdleReconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: true,
     });
     });
   });
   });
 
 
@@ -4414,7 +4479,7 @@ describe('task-session-manager hook', () => {
     expect(board.get('child-2')).toBeUndefined();
     expect(board.get('child-2')).toBeUndefined();
   });
   });
 
 
-  test('reconciles from idle when fallback guard passes', async () => {
+  test('marks stopped from idle when fallback guard passes', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({
       taskID: 'child-1',
       taskID: 'child-1',
@@ -4438,8 +4503,8 @@ describe('task-session-manager hook', () => {
     await flushChildIdleReconcile();
     await flushChildIdleReconcile();
 
 
     expect(board.get('child-1')).toMatchObject({
     expect(board.get('child-1')).toMatchObject({
-      state: 'reconciled',
-      terminalState: 'completed',
+      state: 'stopped',
+      terminalUnreconciled: true,
     });
     });
   });
   });
 
 
@@ -4479,7 +4544,7 @@ describe('task-session-manager hook', () => {
     });
     });
     expect(board.get('child-1')).toMatchObject({ state: 'running' });
     expect(board.get('child-1')).toMatchObject({ state: 'running' });
 
 
-    // Second idle (real completion) — fallback no longer in progress
+    // Second idle stops the child without an explicit task result.
     const hook2 = createHook({
     const hook2 = createHook({
       backgroundJobBoard: board,
       backgroundJobBoard: board,
       shouldManageSession: () => false,
       shouldManageSession: () => false,
@@ -4491,8 +4556,8 @@ describe('task-session-manager hook', () => {
     });
     });
     await flushChildIdleReconcile();
     await flushChildIdleReconcile();
     expect(board.get('child-1')).toMatchObject({
     expect(board.get('child-1')).toMatchObject({
-      state: 'reconciled',
-      terminalState: 'completed',
+      state: 'stopped',
+      terminalUnreconciled: true,
     });
     });
   });
   });
 
 
@@ -4610,15 +4675,15 @@ describe('task-session-manager hook', () => {
     });
     });
 
 
     // Simulate parent tool never firing tool.execute.after (cancelled).
     // Simulate parent tool never firing tool.execute.after (cancelled).
-    // Child goes idle after finishing — board must still reconcile.
+    // Child goes idle without task output — board must stop waiting.
     await hook.event({
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
     });
     await flushChildIdleReconcile();
     await flushChildIdleReconcile();
 
 
     expect(board.get('child-1')).toMatchObject({
     expect(board.get('child-1')).toMatchObject({
-      state: 'reconciled',
-      terminalState: 'completed',
+      state: 'stopped',
+      terminalUnreconciled: true,
     });
     });
   });
   });
 
 
@@ -4691,77 +4756,80 @@ describe('task-session-manager hook', () => {
   test.each([
   test.each([
     ['foreground-created-first', ['foreground-child', 'background-child']],
     ['foreground-created-first', ['foreground-child', 'background-child']],
     ['background-created-first', ['background-child', 'foreground-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,
-    });
+  ])(
+    '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: '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',
+      );
+      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' } },
-        },
-      });
-    }
+      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();
+      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') },
-    );
+      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(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');
-  });
+      expect(abort).toHaveBeenCalledTimes(1);
+      expect(abort).toHaveBeenCalledWith('background-child');
+    },
+  );
 
 
   test('missing after-hook callID fails closed while an exact background call remains', async () => {
   test('missing after-hook callID fails closed while an exact background call remains', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
@@ -4967,7 +5035,7 @@ describe('task-session-manager hook', () => {
     expect(job?.state).toBe('cancelled');
     expect(job?.state).toBe('cancelled');
   });
   });
 
 
-  test('idle via session.status idle path triggers reconciliation', async () => {
+  test('idle via session.status path marks the job stopped', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({
       taskID: 'child-1',
       taskID: 'child-1',
@@ -4992,8 +5060,8 @@ describe('task-session-manager hook', () => {
     await flushChildIdleReconcile();
     await flushChildIdleReconcile();
 
 
     expect(board.get('child-1')).toMatchObject({
     expect(board.get('child-1')).toMatchObject({
-      state: 'reconciled',
-      terminalState: 'completed',
+      state: 'stopped',
+      terminalUnreconciled: true,
     });
     });
   });
   });
 
 

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

@@ -24,6 +24,7 @@ import { createIdleReconciler } from './idle-reconciliation';
 import { createIdleSessionTokens } from './idle-session-tokens';
 import { createIdleSessionTokens } from './idle-session-tokens';
 import { createInputWaitTracker } from './input-wait-tracker';
 import { createInputWaitTracker } from './input-wait-tracker';
 import { createPendingCallTracker } from './pending-call-tracker';
 import { createPendingCallTracker } from './pending-call-tracker';
+import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
 import { createTaskContextTracker } from './task-context-tracker';
 import { createTaskContextTracker } from './task-context-tracker';
 import {
 import {
   handleToolExecuteAfter,
   handleToolExecuteAfter,
@@ -69,6 +70,8 @@ export function createTaskSessionManagerHook(
     coordinator?: SessionLifecycle;
     coordinator?: SessionLifecycle;
     /** Test seam only; production always uses the reconciliation delay. */
     /** Test seam only; production always uses the reconciliation delay. */
     idleReconcileDelayMs?: number;
     idleReconcileDelayMs?: number;
+    /** Test seam only; production uses the runtime reconciliation delay. */
+    runtimeStatusReconcileDelayMs?: number;
   },
   },
 ) {
 ) {
   const backgroundJobBoard =
   const backgroundJobBoard =
@@ -124,6 +127,12 @@ export function createTaskSessionManagerHook(
     isCurrentIdleSessionToken: (s, t) => isCurrentIdleSessionToken(s, t),
     isCurrentIdleSessionToken: (s, t) => isCurrentIdleSessionToken(s, t),
     taskContextTracker,
     taskContextTracker,
   });
   });
+  const runtimeStatusReconciler = createRuntimeStatusReconciler({
+    input: _ctx,
+    backgroundJobBoard,
+    delayMs: options.runtimeStatusReconcileDelayMs,
+    taskContextTracker,
+  });
 
 
   const idleSessionTokens = createIdleSessionTokens({
   const idleSessionTokens = createIdleSessionTokens({
     onInvalidate: idleReconciler.onInvalidateIdle,
     onInvalidate: idleReconciler.onInvalidateIdle,
@@ -261,17 +270,19 @@ export function createTaskSessionManagerHook(
         taskContextTracker,
         taskContextTracker,
       }),
       }),
 
 
-    'tool.execute.after': (
+    'tool.execute.after': async (
       input: { tool: string; sessionID?: string; callID?: string },
       input: { tool: string; sessionID?: string; callID?: string },
       output: { output: unknown; metadata?: unknown },
       output: { output: unknown; metadata?: unknown },
-    ): Promise<void> =>
-      handleToolExecuteAfter(input, output, {
+    ): Promise<void> => {
+      await handleToolExecuteAfter(input, output, {
         directory: _ctx.directory,
         directory: _ctx.directory,
         backgroundJobBoard,
         backgroundJobBoard,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         pendingCallTracker,
         taskContextTracker,
         taskContextTracker,
-      }),
+      });
+      runtimeStatusReconciler.schedule();
+    },
 
 
     'experimental.chat.messages.transform': async (
     'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
       _input: Record<string, never>,
@@ -339,6 +350,9 @@ export function createTaskSessionManagerHook(
         }
         }
       }
       }
 
 
+      if (input.event.type === 'server.instance.disposed') {
+        runtimeStatusReconciler.dispose();
+      }
       return handleEvent(input, {
       return handleEvent(input, {
         inputWaits,
         inputWaits,
         idleSessionTokens,
         idleSessionTokens,
@@ -352,7 +366,7 @@ export function createTaskSessionManagerHook(
         pendingInjectedTerminalJobsByParent,
         pendingInjectedTerminalJobsByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
-      });
+      }).then(() => runtimeStatusReconciler.schedule());
     },
     },
   };
   };
 }
 }

+ 219 - 0
src/hooks/task-session-manager/runtime-status-reconciliation.test.ts

@@ -0,0 +1,219 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils';
+import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
+
+function createReconciler(
+  status: () => Promise<unknown>,
+  statusTimeoutMs?: number,
+) {
+  const board = new BackgroundJobBoard();
+  const contextFilesForPrompt = mock(() => []);
+  const prune = mock(() => {});
+  const reconciler = createRuntimeStatusReconciler({
+    input: {
+      directory: '/test/project',
+      client: { session: { status } },
+    } as never,
+    backgroundJobBoard: board,
+    statusTimeoutMs,
+    taskContextTracker: {
+      pendingManagedTaskIds: new Set(['child-1']),
+      contextFilesForPrompt,
+      prune,
+    },
+  });
+  board.registerLaunch({
+    taskID: 'child-1',
+    parentSessionID: 'parent-1',
+    agent: 'fixer',
+    description: 'fix reconciliation',
+    now: 0,
+  });
+  return { board, reconciler, contextFilesForPrompt, prune };
+}
+
+function deferred<T>() {
+  let resolve: ((value: T) => void) | undefined;
+  const promise = new Promise<T>((next) => {
+    resolve = next;
+  });
+  return {
+    promise,
+    resolve(value: T) {
+      if (!resolve) throw new Error('Deferred promise resolver is unavailable');
+      resolve(value);
+    },
+  };
+}
+
+describe('runtime status reconciliation', () => {
+  test('keeps a runtime-busy job running', async () => {
+    const { board, reconciler } = createReconciler(async () => ({
+      data: { 'child-1': { type: 'busy' } },
+    }));
+
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: false,
+    });
+  });
+
+  test('marks an absent runtime session stopped instead of completed', async () => {
+    const { board, reconciler, contextFilesForPrompt, prune } =
+      createReconciler(async () => ({ data: {} }));
+
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: true,
+      resultSummary:
+        'Background session stopped before a terminal task result was received.',
+    });
+    expect(board.resolveReusable('parent-1', 'fix-1', 'fixer')).toBeUndefined();
+    expect(contextFilesForPrompt).toHaveBeenCalledWith('child-1');
+    expect(prune).toHaveBeenCalledWith(board);
+  });
+
+  test('keeps the board running but explicitly uncertain when lookup fails', async () => {
+    const { board, reconciler } = createReconciler(async () => {
+      throw new Error('server restarting');
+    });
+
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+      lastStatusError: 'Runtime status lookup failed: server restarting',
+    });
+  });
+
+  test('marks malformed runtime status entries uncertain rather than stopped', async () => {
+    const { board, reconciler } = createReconciler(async () => ({
+      data: { 'child-1': { type: 'suspended' } },
+    }));
+
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+      lastStatusError:
+        'Runtime status response did not contain a recognized session state.',
+    });
+  });
+
+  test.each([
+    { type: 'idle' },
+    { type: 'suspended' },
+    { status: { type: 'busy' } },
+  ])('marks unsupported status wrapper %j uncertain', async (data) => {
+    const { board, reconciler } = createReconciler(async () => ({ data }));
+
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+    });
+  });
+
+  test('turns a hung status lookup into uncertainty instead of stalling', async () => {
+    const { board, reconciler } = createReconciler(
+      () => new Promise(() => {}),
+      1,
+    );
+
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+      lastStatusError:
+        'Runtime status lookup failed: Session status lookup timed out',
+    });
+  });
+
+  test('does not stop a job that received busy while status lookup was in flight', async () => {
+    const response = deferred<unknown>();
+    const { board, reconciler } = createReconciler(() => response.promise);
+
+    const reconciliation = reconciler.reconcile();
+    await Promise.resolve();
+    board.markRunningFromLiveSession('child-1');
+    response.resolve({ data: {} });
+    await reconciliation;
+
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+
+  test('does not apply an old status response to a relaunched generation', async () => {
+    const response = deferred<unknown>();
+    const { board, reconciler } = createReconciler(() => response.promise);
+
+    const reconciliation = reconciler.reconcile();
+    await Promise.resolve();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'relaunched fix',
+      now: 1,
+    });
+    response.resolve({ data: {} });
+    await reconciliation;
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      description: 'relaunched fix',
+      generation: 2,
+    });
+  });
+
+  test('allows runtime busy to revive an acknowledged stopped job', () => {
+    const { board } = createReconciler(async () => ({ data: {} }));
+    const generation = board.get('child-1')?.generation;
+    board.markStopped('child-1', 'no result', 1, generation);
+    board.markReconciled('child-1');
+
+    board.markRunningFromLiveSession('child-1', 2, generation);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+      resultSummary: undefined,
+    });
+  });
+
+  test('keeps a timed-out job recoverable through repeated busy observations', () => {
+    const { board } = createReconciler(async () => ({ data: {} }));
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'running',
+      timedOut: true,
+    });
+
+    board.markRunningFromLiveSession('child-1', 1);
+    board.markRunningFromLiveSession('child-1', 2);
+
+    expect(
+      board.resolveRecoverable('parent-1', 'fix-1', 'fixer'),
+    ).toBeDefined();
+  });
+
+  test('does not mutate after disposal while a lookup is in flight', async () => {
+    const response = deferred<unknown>();
+    const { board, reconciler } = createReconciler(() => response.promise);
+
+    const reconciliation = reconciler.reconcile();
+    await Promise.resolve();
+    reconciler.dispose();
+    response.resolve({ data: {} });
+    await reconciliation;
+
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+});

+ 130 - 0
src/hooks/task-session-manager/runtime-status-reconciliation.ts

@@ -0,0 +1,130 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { BackgroundJobStore, ContextFile } from '../../utils';
+import {
+  getRuntimeSessionStatusSnapshot,
+  runtimeSessionStatus,
+} from '../../utils';
+import { log } from '../../utils/logger';
+
+export const RUNTIME_STATUS_RECONCILE_DELAY_MS = 5_000;
+
+export function createRuntimeStatusReconciler(options: {
+  input: PluginInput;
+  backgroundJobBoard: BackgroundJobStore;
+  delayMs?: number;
+  statusTimeoutMs?: number;
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  };
+}) {
+  const delayMs = options.delayMs ?? RUNTIME_STATUS_RECONCILE_DELAY_MS;
+  let timer: ReturnType<typeof setTimeout> | undefined;
+  let disposed = false;
+  let reconciling = false;
+
+  function schedule(): void {
+    if (disposed || timer || reconciling) return;
+    if (
+      !options.backgroundJobBoard.list().some((job) => job.state === 'running')
+    ) {
+      return;
+    }
+    timer = setTimeout(() => {
+      timer = undefined;
+      void reconcile();
+    }, delayMs);
+    timer.unref?.();
+  }
+
+  async function reconcile(): Promise<void> {
+    if (disposed || reconciling) return;
+    const running = options.backgroundJobBoard
+      .list()
+      .filter((job) => job.state === 'running');
+    if (running.length === 0) return;
+
+    reconciling = true;
+    try {
+      const requestStartedAt = Date.now();
+      const snapshot = await getRuntimeSessionStatusSnapshot(options.input, {
+        timeoutMs: options.statusTimeoutMs,
+      });
+      if (disposed) return;
+      const observedAt = Date.now();
+      if (snapshot.error) {
+        for (const job of running) {
+          options.backgroundJobBoard.markStatusUncertain(
+            job.taskID,
+            `Runtime status lookup failed: ${snapshot.error}`,
+            job.generation,
+          );
+        }
+        log('[task-session-manager] runtime status reconciliation uncertain', {
+          activeJobs: running.length,
+          error: snapshot.error,
+        });
+        return;
+      }
+
+      for (const job of running) {
+        if (disposed) return;
+        const current = options.backgroundJobBoard.get(job.taskID);
+        if (
+          current?.state !== 'running' ||
+          current.generation !== job.generation
+        ) {
+          continue;
+        }
+        const status = runtimeSessionStatus(snapshot, job.taskID);
+        if (status === undefined) {
+          options.backgroundJobBoard.markStatusUncertain(
+            job.taskID,
+            'Runtime status response did not contain a recognized session state.',
+            job.generation,
+          );
+          continue;
+        }
+        if (status === 'busy' || status === 'retry') {
+          options.backgroundJobBoard.markRunningFromLiveSession(
+            job.taskID,
+            observedAt,
+            job.generation,
+          );
+          continue;
+        }
+
+        const stopped = options.backgroundJobBoard.markStopped(
+          job.taskID,
+          'Background session stopped before a terminal task result was received.',
+          requestStartedAt,
+          job.generation,
+        );
+        if (stopped?.state !== 'stopped') continue;
+        options.taskContextTracker.pendingManagedTaskIds.delete(job.taskID);
+        options.backgroundJobBoard.addContext(
+          job.taskID,
+          options.taskContextTracker.contextFilesForPrompt(job.taskID),
+        );
+        options.taskContextTracker.prune(options.backgroundJobBoard);
+        log('[task-session-manager] reconciled runtime-stopped job', {
+          taskID: stopped.taskID,
+          alias: stopped.alias,
+          parentSessionID: stopped.parentSessionID,
+        });
+      }
+    } finally {
+      reconciling = false;
+      schedule();
+    }
+  }
+
+  function dispose(): void {
+    disposed = true;
+    if (timer) clearTimeout(timer);
+    timer = undefined;
+  }
+
+  return { schedule, reconcile, dispose };
+}

+ 6 - 0
src/index.ts

@@ -356,6 +356,12 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         foregroundFallback.isFallbackInProgress(sessionID),
         foregroundFallback.isFallbackInProgress(sessionID),
       coordinator: sessionLifecycle,
       coordinator: sessionLifecycle,
     });
     });
+    backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
+      if (record.state !== 'stopped' || !record.terminalUnreconciled) return;
+      orchestratorWakeScheduler.triggerStoppedJobRecovery(
+        record.parentSessionID,
+      );
+    });
 
 
     // Initialize hooks and wrapPostToolHook helper for error isolation
     // Initialize hooks and wrapPostToolHook helper for error isolation
 
 

+ 35 - 44
src/tools/cancel-task.ts

@@ -4,7 +4,6 @@ import {
   tool,
   tool,
 } from '@opencode-ai/plugin';
 } from '@opencode-ai/plugin';
 import type { BackgroundJobStore } from '../utils/background-job-store';
 import type { BackgroundJobStore } from '../utils/background-job-store';
-import { isRecord as isObjectRecord } from '../utils/guards';
 import { log } from '../utils/logger';
 import { log } from '../utils/logger';
 import { getClient } from '../utils/opencode-client';
 import { getClient } from '../utils/opencode-client';
 import { delay } from '../utils/polling';
 import { delay } from '../utils/polling';
@@ -13,6 +12,10 @@ import {
   SESSION_ID_PATTERN,
   SESSION_ID_PATTERN,
   withTimeout,
   withTimeout,
 } from '../utils/session';
 } from '../utils/session';
+import {
+  getRuntimeSessionStatusSnapshot,
+  runtimeSessionStatus,
+} from '../utils/session-runtime-status';
 
 
 const z = tool.schema;
 const z = tool.schema;
 
 
@@ -129,6 +132,7 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
         );
         );
       }
       }
 
 
+      const generation = job.generation;
       try {
       try {
         await abortAndVerifySession(options, job.taskID);
         await abortAndVerifySession(options, job.taskID);
       } catch (error) {
       } catch (error) {
@@ -140,19 +144,18 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
           boardRunning,
           boardRunning,
           error: error instanceof Error ? error.message : String(error),
           error: error instanceof Error ? error.message : String(error),
         });
         });
-        options.backgroundJobBoard.updateStatus({
-          taskID: job.taskID,
-          state: 'running',
-          statusUncertain: true,
-          lastStatusError:
-            error instanceof Error ? error.message : String(error),
-        });
+        const message = error instanceof Error ? error.message : String(error);
+        const updated = options.backgroundJobBoard.markStatusUncertain(
+          job.taskID,
+          message,
+          generation,
+        );
         return [
         return [
           `task_id: ${job.taskID}`,
           `task_id: ${job.taskID}`,
-          'state: running',
+          `state: ${updated?.state ?? 'unknown'}`,
           '',
           '',
           '<task_error>',
           '<task_error>',
-          error instanceof Error ? error.message : String(error),
+          message,
           '</task_error>',
           '</task_error>',
         ].join('\n');
         ].join('\n');
       }
       }
@@ -275,7 +278,11 @@ async function deleteAndVerifySession(
       reason,
       reason,
       error: error instanceof Error ? error.message : String(error),
       error: error instanceof Error ? error.message : String(error),
     });
     });
-    const status = await getSessionStatus(options.input, taskID);
+    const status = await getSessionStatus(
+      options.input,
+      taskID,
+      options.deleteVerifyMs ?? 1_500,
+    );
     log('[cancel-task] delete failure verification status', {
     log('[cancel-task] delete failure verification status', {
       taskID,
       taskID,
       reason,
       reason,
@@ -299,7 +306,11 @@ async function deleteAndVerifySession(
   let lastStatus: string | undefined;
   let lastStatus: string | undefined;
   while (Date.now() <= deadline) {
   while (Date.now() <= deadline) {
     attempts += 1;
     attempts += 1;
-    const status = await getSessionStatus(options.input, taskID);
+    const status = await getSessionStatus(
+      options.input,
+      taskID,
+      Math.max(1, deadline - Date.now()),
+    );
     lastStatus = status.status;
     lastStatus = status.status;
     log('[cancel-task] delete verification status', {
     log('[cancel-task] delete verification status', {
       taskID,
       taskID,
@@ -310,7 +321,7 @@ async function deleteAndVerifySession(
       statusKeys: status.keys,
       statusKeys: status.keys,
       stableStoppedSince,
       stableStoppedSince,
     });
     });
-    if (status.status === 'busy' || status.status === 'retry') {
+    if (status.status !== 'idle') {
       stableStoppedSince = undefined;
       stableStoppedSince = undefined;
       await delay(retryIntervalMs);
       await delay(retryIntervalMs);
       continue;
       continue;
@@ -328,42 +339,22 @@ async function deleteAndVerifySession(
 async function getSessionStatus(
 async function getSessionStatus(
   input: PluginInput,
   input: PluginInput,
   taskID: string,
   taskID: string,
+  timeoutMs?: number,
 ): Promise<{
 ): Promise<{
   status: string | undefined;
   status: string | undefined;
   source: string;
   source: string;
   keys: string[];
   keys: string[];
 }> {
 }> {
-  try {
-    const result = await getClient(input).session.status({
-      query: { directory: input.directory },
-    });
-    const data = result.data;
-    if (!isObjectRecord(data)) {
-      return { status: undefined, source: 'invalid-data', keys: [] };
-    }
-    const keys = Object.keys(data).slice(0, 20);
-    const item = data[taskID];
-    if (item === undefined) {
-      return { status: 'idle', source: 'missing-from-map', keys };
-    }
-    if (isObjectRecord(item) && typeof item.type === 'string') {
-      return { status: item.type, source: 'task-map-entry', keys };
-    }
-    if (typeof data.type === 'string') {
-      return { status: data.type, source: 'legacy-data-type', keys };
-    }
-    const nested = data.status;
-    if (isObjectRecord(nested) && typeof nested.type === 'string') {
-      return { status: nested.type, source: 'legacy-data-status', keys };
-    }
-    return { status: undefined, source: 'unknown-shape', keys };
-  } catch (error) {
-    log('[cancel-task] session status lookup failed', {
-      taskID,
-      error: error instanceof Error ? error.message : String(error),
-    });
-    return { status: undefined, source: 'lookup-error', keys: [] };
-  }
+  const snapshot = await getRuntimeSessionStatusSnapshot(input, { timeoutMs });
+  return {
+    status: runtimeSessionStatus(snapshot, taskID),
+    source: snapshot.error
+      ? 'lookup-error'
+      : snapshot.statuses.has(taskID)
+        ? 'task-map-entry'
+        : 'missing-from-map',
+    keys: [...snapshot.statuses.keys()].slice(0, 20),
+  };
 }
 }
 
 
 function normalizeCancelReason(reason?: string): string {
 function normalizeCancelReason(reason?: string): string {

+ 1 - 3
src/tools/smartfetch/network.test.ts

@@ -33,9 +33,7 @@ describe('smartfetch/network', () => {
   });
   });
 
 
   test('normalizeUrl keeps origin and query string while dropping the fragment', () => {
   test('normalizeUrl keeps origin and query string while dropping the fragment', () => {
-    const normalized = normalizeUrl(
-      'https://example.com/docs?page=2#anchor',
-    );
+    const normalized = normalizeUrl('https://example.com/docs?page=2#anchor');
 
 
     expect(normalized.url).toBe('https://example.com/docs?page=2');
     expect(normalized.url).toBe('https://example.com/docs?page=2');
     expect(new URL(normalized.url).origin).toBe('https://example.com');
     expect(new URL(normalized.url).origin).toBe('https://example.com');

+ 118 - 10
src/utils/background-job-board.ts

@@ -26,7 +26,7 @@ export interface BackgroundJobPromptMetadata {
   terminalUnreconciledTaskIDs: BackgroundJobExecution[];
   terminalUnreconciledTaskIDs: BackgroundJobExecution[];
 }
 }
 
 
-export type BackgroundJobState = TaskOutputState | 'reconciled';
+export type BackgroundJobState = TaskOutputState | 'stopped' | 'reconciled';
 
 
 export interface BackgroundJobRecord {
 export interface BackgroundJobRecord {
   taskID: string;
   taskID: string;
@@ -110,7 +110,7 @@ export interface WallClockTimeoutFinalizeInput {
 
 
 type TerminalStateListener = (taskID: string) => void;
 type TerminalStateListener = (taskID: string) => void;
 
 
-const TERMINAL_STATES = new Set<BackgroundJobState>([
+const CANONICAL_TERMINAL_STATES = new Set<TaskOutputState>([
   'completed',
   'completed',
   'error',
   'error',
   'cancelled',
   'cancelled',
@@ -278,15 +278,17 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     // Guard: stale status updates cannot reopen already terminal jobs.
     // Guard: stale status updates cannot reopen already terminal jobs.
     if (
     if (
       existing.state === 'reconciled' ||
       existing.state === 'reconciled' ||
+      (existing.state === 'stopped' && input.state === 'running') ||
       (existing.state === 'cancelled' && input.state !== 'cancelled') ||
       (existing.state === 'cancelled' && input.state !== 'cancelled') ||
-      (TERMINAL_STATES.has(existing.state) && input.state === 'running')
+      (isCanonicalTerminalState(existing.state) && input.state === 'running')
     ) {
     ) {
       return existing;
       return existing;
     }
     }
 
 
     const now = input.now ?? Date.now();
     const now = input.now ?? Date.now();
-    const terminal = TERMINAL_STATES.has(input.state);
-    const notifyTerminal = terminal && !TERMINAL_STATES.has(existing.state);
+    const terminal = input.state !== 'running';
+    const notifyTerminal =
+      terminal && !isCanonicalTerminalState(existing.state);
     const updated: BackgroundJobRecord = {
     const updated: BackgroundJobRecord = {
       ...existing,
       ...existing,
       state: input.state,
       state: input.state,
@@ -340,14 +342,22 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   markRunningFromLiveSession(
   markRunningFromLiveSession(
     taskID: string,
     taskID: string,
     now = Date.now(),
     now = Date.now(),
+    expectedGeneration?: number,
   ): BackgroundJobRecord | undefined {
   ): BackgroundJobRecord | undefined {
     const existing = this.jobs.get(taskID);
     const existing = this.jobs.get(taskID);
     if (!existing) return undefined;
     if (!existing) return undefined;
+    if (
+      expectedGeneration !== undefined &&
+      existing.generation !== expectedGeneration
+    ) {
+      return existing;
+    }
 
 
     if (existing.deadlineExceededAt !== undefined) return existing;
     if (existing.deadlineExceededAt !== undefined) return existing;
 
 
     const isStaleTerminal =
     const isStaleTerminal =
-      TERMINAL_STATES.has(existing.state) || existing.state === 'reconciled';
+      isCanonicalTerminalState(existing.state) ||
+      existing.state === 'reconciled';
     if (isStaleTerminal) {
     if (isStaleTerminal) {
       const updated: BackgroundJobRecord = {
       const updated: BackgroundJobRecord = {
         ...existing,
         ...existing,
@@ -359,18 +369,96 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
 
     const updated: BackgroundJobRecord = {
     const updated: BackgroundJobRecord = {
       ...existing,
       ...existing,
+      state: 'running',
       updatedAt: now,
       updatedAt: now,
       lastLiveBusyAt: now,
       lastLiveBusyAt: now,
       timedOut: false,
       timedOut: false,
       recoverableAfterLiveBusy:
       recoverableAfterLiveBusy:
         existing.recoverableAfterLiveBusy || existing.timedOut,
         existing.recoverableAfterLiveBusy || existing.timedOut,
       statusUncertain: false,
       statusUncertain: false,
+      terminalUnreconciled: false,
+      completedAt:
+        existing.state === 'stopped' ? undefined : existing.completedAt,
+      resultSummary:
+        existing.state === 'stopped' ? undefined : existing.resultSummary,
+      lastStatusError: undefined,
+      terminalState:
+        existing.state === 'stopped' ? undefined : existing.terminalState,
     };
     };
 
 
     this.jobs.set(taskID, updated);
     this.jobs.set(taskID, updated);
     return updated;
     return updated;
   }
   }
 
 
+  /**
+   * The host reports that this child no longer executes, but no native task
+   * result established success, cancellation, or failure. Keep that ambiguity
+   * visible to the parent and never permit session reuse.
+   */
+  markStopped(
+    taskID: string,
+    resultSummary: string,
+    observedAt = Date.now(),
+    expectedGeneration?: number,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(taskID);
+    if (existing?.state !== 'running') return existing;
+    if (existing.deadlineExceededAt !== undefined) return existing;
+    if (
+      expectedGeneration !== undefined &&
+      existing.generation !== expectedGeneration
+    ) {
+      return existing;
+    }
+    if (
+      existing.lastLiveBusyAt !== undefined &&
+      existing.lastLiveBusyAt >= observedAt
+    ) {
+      return existing;
+    }
+
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      state: 'stopped',
+      timedOut: false,
+      recoverableAfterLiveBusy: false,
+      statusUncertain: false,
+      terminalUnreconciled: true,
+      updatedAt: now,
+      completedAt: existing.completedAt ?? now,
+      resultSummary,
+      lastStatusError: undefined,
+    };
+    this.jobs.set(taskID, updated);
+    this.notifyTerminalStateListeners(taskID);
+    return updated;
+  }
+
+  markStatusUncertain(
+    taskID: string,
+    lastStatusError: string,
+    expectedGeneration?: number,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(taskID);
+    if (existing?.state !== 'running') return existing;
+    if (
+      expectedGeneration !== undefined &&
+      existing.generation !== expectedGeneration
+    ) {
+      return existing;
+    }
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      statusUncertain: true,
+      lastStatusError,
+      updatedAt: now,
+    };
+    this.jobs.set(taskID, updated);
+    return updated;
+  }
+
   markReconciled(
   markReconciled(
     taskID: string,
     taskID: string,
     now = Date.now(),
     now = Date.now(),
@@ -379,11 +467,23 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     if (!existing) return undefined;
     if (!existing) return undefined;
     if (
     if (
       !existing.terminalUnreconciled &&
       !existing.terminalUnreconciled &&
-      !TERMINAL_STATES.has(existing.state)
+      !isCanonicalTerminalState(existing.state)
     ) {
     ) {
       return undefined;
       return undefined;
     }
     }
 
 
+    if (existing.state === 'stopped') {
+      const updated: BackgroundJobRecord = {
+        ...existing,
+        terminalUnreconciled: false,
+        statusUncertain: false,
+        updatedAt: now,
+        lastUsedAt: now,
+      };
+      this.jobs.set(taskID, updated);
+      return updated;
+    }
+
     const updated: BackgroundJobRecord = {
     const updated: BackgroundJobRecord = {
       ...existing,
       ...existing,
       state: 'reconciled',
       state: 'reconciled',
@@ -422,11 +522,12 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     }
     }
     if (!options.force) {
     if (!options.force) {
       if (existing.state === 'reconciled') return existing;
       if (existing.state === 'reconciled') return existing;
-      if (TERMINAL_STATES.has(existing.state)) return existing;
+      if (isCanonicalTerminalState(existing.state)) return existing;
     }
     }
 
 
     const notifyTerminal =
     const notifyTerminal =
-      !TERMINAL_STATES.has(existing.state) && existing.state !== 'reconciled';
+      !isCanonicalTerminalState(existing.state) &&
+      existing.state !== 'reconciled';
     const summary = normalizeCancelReason(reason);
     const summary = normalizeCancelReason(reason);
     const updated: BackgroundJobRecord = {
     const updated: BackgroundJobRecord = {
       ...existing,
       ...existing,
@@ -730,7 +831,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     for (const entry of this.list(job.parentSessionID)) {
     for (const entry of this.list(job.parentSessionID)) {
       if (
       if (
         entry.agent === job.agent &&
         entry.agent === job.agent &&
-        TERMINAL_STATES.has(entry.state) &&
+        !entry.terminalUnreconciled &&
+        (entry.terminalState ?? terminalStateOf(entry.state)) !== undefined &&
         sumContextLines(entry) > this.maxContextLines
         sumContextLines(entry) > this.maxContextLines
       ) {
       ) {
         this.jobs.delete(entry.taskID);
         this.jobs.delete(entry.taskID);
@@ -817,6 +919,12 @@ function terminalStateOf(
     : undefined;
     : undefined;
 }
 }
 
 
+function isCanonicalTerminalState(
+  state: BackgroundJobState,
+): state is TaskOutputState {
+  return CANONICAL_TERMINAL_STATES.has(state as TaskOutputState);
+}
+
 function formatContextFiles(files: ContextFile[], maxFiles: number): string {
 function formatContextFiles(files: ContextFile[], maxFiles: number): string {
   if (maxFiles === 0) return '';
   if (maxFiles === 0) return '';
   const shown = files.slice(0, maxFiles);
   const shown = files.slice(0, maxFiles);

+ 21 - 0
src/utils/background-job-coordinator.test.ts

@@ -116,6 +116,27 @@ describe('BackgroundJobCoordinator', () => {
     expect(order).toEqual(['second']);
     expect(order).toEqual(['second']);
   });
   });
 
 
+  test('throws in one outcome listener without blocking later outcomes', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const delivered: string[] = [];
+    coordinator.addTerminalOutcomeListener(() => {
+      throw new Error('first outcome listener failed');
+    });
+    coordinator.addTerminalOutcomeListener((record) => {
+      delivered.push(record.taskID);
+    });
+    board.registerLaunch({
+      taskID: 'ses_123',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+
+    board.updateStatus({ taskID: 'ses_123', state: 'completed' });
+
+    expect(delivered).toEqual(['ses_123']);
+  });
+
   test('full chain: board terminal → coordinator → listener for deferred job', () => {
   test('full chain: board terminal → coordinator → listener for deferred job', () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     const coordinator = new BackgroundJobCoordinator(board);
     const coordinator = new BackgroundJobCoordinator(board);

+ 45 - 4
src/utils/background-job-coordinator.ts

@@ -10,7 +10,6 @@ import type {
 } from './background-job-board';
 } from './background-job-board';
 import type { BackgroundJobStore } from './background-job-store';
 import type { BackgroundJobStore } from './background-job-store';
 import { log } from './logger';
 import { log } from './logger';
-import type { TaskOutputState } from './task';
 
 
 type TerminalStateListener = (taskID: string) => void;
 type TerminalStateListener = (taskID: string) => void;
 type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
 type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
@@ -78,7 +77,14 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     const record = this.board.get?.(taskID);
     const record = this.board.get?.(taskID);
     if (record) {
     if (record) {
       for (const listener of this.terminalOutcomeListeners) {
       for (const listener of this.terminalOutcomeListeners) {
-        listener(record);
+        try {
+          listener(record);
+        } catch (error) {
+          log('Coordinator terminal outcome listener threw', {
+            taskID,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
       }
       }
     }
     }
   }
   }
@@ -156,8 +162,43 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
   markRunningFromLiveSession(
   markRunningFromLiveSession(
     taskID: string,
     taskID: string,
     now = Date.now(),
     now = Date.now(),
+    expectedGeneration?: number,
   ): BackgroundJobRecord | undefined {
   ): BackgroundJobRecord | undefined {
-    return this.board.markRunningFromLiveSession(taskID, now);
+    return this.board.markRunningFromLiveSession(
+      taskID,
+      now,
+      expectedGeneration,
+    );
+  }
+
+  markStopped(
+    taskID: string,
+    resultSummary: string,
+    observedAt = Date.now(),
+    expectedGeneration?: number,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    return this.board.markStopped(
+      taskID,
+      resultSummary,
+      observedAt,
+      expectedGeneration,
+      now,
+    );
+  }
+
+  markStatusUncertain(
+    taskID: string,
+    lastStatusError: string,
+    expectedGeneration?: number,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    return this.board.markStatusUncertain(
+      taskID,
+      lastStatusError,
+      expectedGeneration,
+      now,
+    );
   }
   }
 
 
   markReconciled(
   markReconciled(
@@ -209,7 +250,7 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.getParentSessionID(taskID);
     return this.board.getParentSessionID(taskID);
   }
   }
 
 
-  getState(taskID: string): TaskOutputState | 'reconciled' | undefined {
+  getState(taskID: string): BackgroundJobRecord['state'] | undefined {
     return this.board.getState(taskID);
     return this.board.getState(taskID);
   }
   }
 
 

+ 15 - 2
src/utils/background-job-store.ts

@@ -7,7 +7,6 @@ import type {
   WallClockTimeoutClaimInput,
   WallClockTimeoutClaimInput,
   WallClockTimeoutFinalizeInput,
   WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 } from './background-job-board';
-import type { TaskOutputState } from './task';
 
 
 /**
 /**
  * Unified interface for background job operations.
  * Unified interface for background job operations.
@@ -31,6 +30,20 @@ export interface BackgroundJobStore {
   markRunningFromLiveSession(
   markRunningFromLiveSession(
     taskID: string,
     taskID: string,
     now?: number,
     now?: number,
+    expectedGeneration?: number,
+  ): BackgroundJobRecord | undefined;
+  markStopped(
+    taskID: string,
+    resultSummary: string,
+    observedAt?: number,
+    expectedGeneration?: number,
+    now?: number,
+  ): BackgroundJobRecord | undefined;
+  markStatusUncertain(
+    taskID: string,
+    lastStatusError: string,
+    expectedGeneration?: number,
+    now?: number,
   ): BackgroundJobRecord | undefined;
   ): BackgroundJobRecord | undefined;
   markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
   markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
   markCancelled(
   markCancelled(
@@ -55,7 +68,7 @@ export interface BackgroundJobStore {
   getResultSummary(taskID: string): string | undefined;
   getResultSummary(taskID: string): string | undefined;
   getLastLiveBusyAt(taskID: string): number | undefined;
   getLastLiveBusyAt(taskID: string): number | undefined;
   getParentSessionID(taskID: string): string | undefined;
   getParentSessionID(taskID: string): string | undefined;
-  getState(taskID: string): TaskOutputState | 'reconciled' | undefined;
+  getState(taskID: string): BackgroundJobRecord['state'] | undefined;
   resolve(
   resolve(
     parentSessionID: string,
     parentSessionID: string,
     taskIDOrAlias: string,
     taskIDOrAlias: string,

+ 4 - 2
src/utils/codemap.md

@@ -15,7 +15,9 @@ Centralized utilities and shared abstractions used across the oh-my-opencode-sli
 
 
 ### Core Abstractions
 ### Core Abstractions
 
 
-- **BackgroundJobBoard** (`background-job-board.ts`): Singleton registry and lifecycle manager for background tasks spawned by sub-agents. Implements a reusable session pool pattern with automatic cleanup and reconciliation hooks. Tracks task state (running, completed, error, cancelled), maintains context files, and provides prompt-ready summaries for agent coordination.
+- **BackgroundJobBoard** (`background-job-board.ts`): Singleton registry and lifecycle manager for background tasks spawned by sub-agents. Implements a reusable session pool pattern with automatic cleanup and reconciliation hooks. Tracks task state (running, stopped, completed, error, cancelled), maintains context files, and provides prompt-ready summaries for agent coordination. `stopped` records an ended runtime session without fabricated task success and is never reusable.
+
+- **Runtime Session Status** (`session-runtime-status.ts`): Reads and validates the in-process OpenCode session-status map once per observation. It distinguishes a valid absent session (`idle`) from malformed data or lookup failure (`unknown`) so lifecycle policy never treats schema drift as completion.
 
 
 - **Logger** (`logger.ts`): File-based logging with 7-day retention, automatic directory creation, and write queuing. Logs are written to `~/.local/share/opencode/log/oh-my-opencode-slim.<sessionId>.log` and cleaned up on initialization.
 - **Logger** (`logger.ts`): File-based logging with 7-day retention, automatic directory creation, and write queuing. Logs are written to `~/.local/share/opencode/log/oh-my-opencode-slim.<sessionId>.log` and cleaned up on initialization.
 
 
@@ -109,4 +111,4 @@ This allows consumers to import from `src/utils` rather than individual files.
 | `logger.ts` | File-based logging with rotation |
 | `logger.ts` | File-based logging with rotation |
 | `session.ts` | Session timeout, abort, and extraction utilities |
 | `session.ts` | Session timeout, abort, and extraction utilities |
 | `system-collapse.ts` | System message collapsing utility |
 | `system-collapse.ts` | System message collapsing utility |
-| `task.ts` | Task output parsing utilities |
+| `task.ts` | Task output parsing utilities |

+ 1 - 0
src/utils/index.ts

@@ -7,5 +7,6 @@ export * from './internal-initiator';
 export { initLogger, log } from './logger';
 export { initLogger, log } from './logger';
 export * from './polling';
 export * from './polling';
 export * from './session';
 export * from './session';
+export * from './session-runtime-status';
 export * from './task';
 export * from './task';
 export { extractZip } from './zip-extractor';
 export { extractZip } from './zip-extractor';

+ 103 - 0
src/utils/session-runtime-status.ts

@@ -0,0 +1,103 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import { isRecord } from './guards';
+import { getClient } from './opencode-client';
+
+export type RuntimeSessionStatus = 'busy' | 'retry' | 'idle';
+export const DEFAULT_RUNTIME_SESSION_STATUS_TIMEOUT_MS = 5_000;
+
+export interface RuntimeSessionStatusSnapshot {
+  statuses: ReadonlyMap<string, RuntimeSessionStatus>;
+  malformedSessionIDs: ReadonlySet<string>;
+  error?: string;
+}
+
+/**
+ * Reads OpenCode's single live session-status map. An absent session in a
+ * valid response is idle; an invalid response or failed request is unknown.
+ */
+export async function getRuntimeSessionStatusSnapshot(
+  input: PluginInput,
+  options: { timeoutMs?: number } = {},
+): Promise<RuntimeSessionStatusSnapshot> {
+  try {
+    const response = await withTimeout(
+      getClient(input).session.status({
+        query: { directory: input.directory },
+      }),
+      options.timeoutMs ?? DEFAULT_RUNTIME_SESSION_STATUS_TIMEOUT_MS,
+    );
+    if (!isRecord(response.data)) {
+      return {
+        statuses: new Map(),
+        malformedSessionIDs: new Set(),
+        error: 'invalid session-status response',
+      };
+    }
+    if (
+      Object.hasOwn(response.data, 'type') ||
+      Object.hasOwn(response.data, 'status')
+    ) {
+      return {
+        statuses: new Map(),
+        malformedSessionIDs: new Set(),
+        error: 'invalid session-status map response',
+      };
+    }
+
+    const statuses = new Map<string, RuntimeSessionStatus>();
+    const malformedSessionIDs = new Set<string>();
+    for (const [sessionID, value] of Object.entries(response.data)) {
+      if (
+        isRecord(value) &&
+        (value.type === 'busy' ||
+          value.type === 'retry' ||
+          value.type === 'idle')
+      ) {
+        statuses.set(sessionID, value.type);
+      } else {
+        malformedSessionIDs.add(sessionID);
+      }
+    }
+    return { statuses, malformedSessionIDs };
+  } catch (error) {
+    return {
+      statuses: new Map(),
+      malformedSessionIDs: new Set(),
+      error: error instanceof Error ? error.message : String(error),
+    };
+  }
+}
+
+export function runtimeSessionStatus(
+  snapshot: RuntimeSessionStatusSnapshot,
+  sessionID: string,
+): RuntimeSessionStatus | undefined {
+  if (snapshot.error) return undefined;
+  if (snapshot.malformedSessionIDs.has(sessionID)) return undefined;
+  const status = snapshot.statuses.get(sessionID);
+  if (status === undefined) return 'idle';
+  return status;
+}
+
+async function withTimeout<T>(
+  promise: Promise<T>,
+  timeoutMs: number,
+): Promise<T> {
+  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
+    throw new Error('Session status lookup timed out');
+  }
+  let timer: ReturnType<typeof setTimeout> | undefined;
+  try {
+    return await Promise.race([
+      promise,
+      new Promise<T>((_, reject) => {
+        timer = setTimeout(() => {
+          reject(new Error('Session status lookup timed out'));
+        }, timeoutMs);
+        timer.unref?.();
+      }),
+    ]);
+  } finally {
+    if (timer) clearTimeout(timer);
+  }
+}

+ 1 - 1
src/v2/interview-bridge.ts

@@ -154,7 +154,7 @@ export function createV2InterviewBridge(
     transcripts.set(event.sessionID, messages);
     transcripts.set(event.sessionID, messages);
 
 
     const trailing = event.messages.at(-1);
     const trailing = event.messages.at(-1);
-    if (!trailing || trailing.role !== 'user') return;
+    if (trailing?.role !== 'user') return;
     const text = textFromContent(trailing.content);
     const text = textFromContent(trailing.content);
     const match = text.match(MARKER_PATTERN);
     const match = text.match(MARKER_PATTERN);
     if (!match) return;
     if (!match) return;