Просмотр исходного кода

fix(tasks): confirm stopped jobs before recovery wake

Alvin Unreal 1 месяц назад
Родитель
Сommit
60c679d993

+ 18 - 12
docs/background-orchestration.md

@@ -180,9 +180,10 @@ on the local job board.
 
 After a full OpenCode or plugin restart, persisted running background-task
 history is rehydrated into the local job board and immediately reconciled against
-live host session status. A missing or idle child is therefore surfaced as
-`stopped, unreconciled`, while a busy child remains running; status lookup
-failures remain uncertain rather than being treated as completion.
+live host session status. A missing or idle child is a stop candidate: after a
+5s confirmation grace it is surfaced as `stopped, unreconciled`, while a busy
+child remains running; status lookup failures remain uncertain rather than being
+treated as completion.
 
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 against each other and the original user goal.
@@ -453,17 +454,22 @@ 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.
+`busy` and `retry` confirm that a job is live and reset any pending stop
+confirmation. An explicit `idle` state or an absent session in an otherwise
+valid map is not immediately terminal: the first observation starts a 5s
+confirmation grace and keeps the job `running, status uncertain`. Repeat
+non-busy evidence after that grace 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 an unreconciled stopped job. After the parent has been woken and the stop
+acknowledged, stale busy cannot flip the job back to running. 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.
+uncertain`; they never prove that a job stopped or completed and do not confirm
+a pending stop. Each observation is generation-aware, so a delayed response
+cannot modify a relaunched task.
 
 ### Opt-in Wall-clock Supervisor
 

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

@@ -9,6 +9,7 @@ Manages V2 background job-board state for task execution and injected completion
 The directory follows a **Facade + Strategy** pattern where `index.ts` acts as the facade that composes and orchestrates behavior across specialized strategy modules:
 
 - **index.ts**: Main facade that wires hooks into OpenCode's lifecycle and coordinates between the job board, pending calls, task context tracking, and explicit user waits. Implements the plugin hook interface (`tool.execute.before`, `tool.execute.after`, `experimental.chat.messages.transform`, `event`) and exposes `beginUserWait()` to the `wait_for_user` tool.
+- **stop-confirmation.ts**: Shared 5s grace for idle/absent runtime observations. Transient non-busy evidence stays provisional; confirmed durable stop evidence calls `markStopped` and can wake the parent. Busy/retry/live-busy reset the clock.
 - **input-wait-tracker.ts**: Provides the single `hasInputWait()` seam used by idle reconciliation and continuation evaluation. It combines local question/permission waits with the process-global explicit user-wait latch.
 - **continuation-attempt-gate.ts**: Owns process-global continuation epochs, reservations, and explicit user waits across hook recreation. The wait is encoded as an `attempts` sentinel so pre-upgrade #856 hooks sharing the store also fail closed. Distinct external user-message identity rearms both states.
 - **continuation-model-selection.ts**: Normalizes current-session and chat-hook model shapes before forwarding runtime model and variant choices to idle continuation prompts.
@@ -56,8 +57,8 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
 
 5. **Lifecycle Events (`event`)**
     - `session.created`: Adds new task IDs to pending managed set
-    - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path), then can run the opt-in continuation evaluator in the same idle cycle under its existing guards
-    - `session.status` (busy): Marks sessions as running from live session state
+    - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path), then can run the opt-in continuation evaluator in the same idle cycle under its existing guards. Child idle is a stop candidate: the first observation stays provisional, and only a confirmed idle/absent after the 5s grace marks `stopped`
+    - `session.status` (busy): Marks sessions as running from live session state and resets pending stop confirmation
     - `session.deleted`: Clears job state, child jobs, and pending call records for the session
 
 6. **Human-in-the-loop Waits**

+ 114 - 0
src/hooks/task-session-manager/idle-reconciliation.test.ts

@@ -0,0 +1,114 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils';
+import { createIdleReconciler } from './idle-reconciliation';
+
+async function flushChildIdleReconcile(): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, 5));
+}
+
+function createHarness(options?: { stopConfirmationGraceMs?: number }) {
+  const board = new BackgroundJobBoard();
+  const terminalListener = mock(() => {});
+  board.addTerminalStateListener(terminalListener);
+  const contextFilesForPrompt = mock(() => []);
+  const prune = mock(() => {});
+  const reconciler = createIdleReconciler({
+    backgroundJobBoard: board,
+    reconcileInjectedTerminalJobs: mock(() => {}),
+    idleReconcileDelayMs: 0,
+    stopConfirmationGraceMs: options?.stopConfirmationGraceMs ?? 0,
+    hasInputWait: () => false,
+    getIdleSessionToken: () => Symbol('idle'),
+    isCurrentIdleSessionToken: () => true,
+    taskContextTracker: {
+      pendingManagedTaskIds: new Set(['child-1']),
+      contextFilesForPrompt,
+      prune,
+    },
+  });
+  board.registerLaunch({
+    taskID: 'child-1',
+    parentSessionID: 'parent-1',
+    agent: 'fixer',
+    description: 'fix idle race',
+    now: 0,
+  });
+  return { board, reconciler, terminalListener, contextFilesForPrompt, prune };
+}
+
+async function observeIdle(
+  reconciler: ReturnType<typeof createIdleReconciler>,
+  idleObservedAt: number,
+  generation: number,
+): Promise<void> {
+  reconciler.scheduleChildIdleReconciliation(
+    'child-1',
+    idleObservedAt,
+    generation,
+  );
+  await flushChildIdleReconcile();
+}
+
+describe('idle reconciliation stop confirmation', () => {
+  test('idle then busy inside grace remains running with no terminal listener', async () => {
+    const { board, reconciler, terminalListener } = createHarness({
+      stopConfirmationGraceMs: 60_000,
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(terminalListener).not.toHaveBeenCalled();
+
+    board.markRunningFromLiveSession('child-1', 15);
+    await observeIdle(reconciler, 16, generation);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      stopConfirmationStartedAt: 17,
+    });
+    expect(terminalListener).not.toHaveBeenCalled();
+  });
+
+  test('repeated idle beyond confirmation grace becomes stopped exactly once', async () => {
+    const { board, reconciler, terminalListener, contextFilesForPrompt, prune } =
+      createHarness();
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(terminalListener).not.toHaveBeenCalled();
+
+    await observeIdle(reconciler, 20, generation);
+    expect(board.get('child-1')).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: true,
+    });
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+    expect(contextFilesForPrompt).toHaveBeenCalledTimes(1);
+    expect(prune).toHaveBeenCalledTimes(1);
+
+    await observeIdle(reconciler, 30, generation);
+    expect(board.get('child-1')).toMatchObject({ state: 'stopped' });
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+  });
+
+  test('a busy observation resets pending stop confirmation', async () => {
+    const { board, reconciler, terminalListener } = createHarness();
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    expect(board.get('child-1')?.stopConfirmationStartedAt).toBe(11);
+
+    board.markRunningFromLiveSession('child-1', 15);
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      stopConfirmationStartedAt: undefined,
+    });
+
+    await observeIdle(reconciler, 20, generation);
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(board.get('child-1')?.stopConfirmationStartedAt).toBe(21);
+    expect(terminalListener).not.toHaveBeenCalled();
+  });
+});

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

@@ -1,6 +1,10 @@
 import type { BackgroundJobStore, ContextFile } from '../../utils';
 import { log } from '../../utils/logger';
 import type { RevivedRunTracker } from './revived-run-tracker';
+import {
+  observeNonBusyRuntime,
+  STOP_CONFIRMATION_GRACE_MS,
+} from './stop-confirmation';
 
 export function createIdleReconciler(options: {
   backgroundJobBoard: BackgroundJobStore;
@@ -8,6 +12,7 @@ export function createIdleReconciler(options: {
   /** Called when a deferred inline error is terminalized at idle. */
   onErrorTerminalize?: (sessionID: string) => void;
   idleReconcileDelayMs: number;
+  stopConfirmationGraceMs?: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   hasInputWait: (sessionID: string) => boolean;
   getIdleSessionToken: (sessionID: string) => symbol;
@@ -84,19 +89,29 @@ export function createIdleReconciler(options: {
         if (terminalPublished) return;
       }
 
-      // Idle is a quiescent runner observation, not proof that the background
-      // task ended. Keep the job live so a late terminal task result can win.
+      const updated = observeNonBusyRuntime({
+        backgroundJobBoard: options.backgroundJobBoard,
+        taskID: sessionID,
+        observedAt: idleObservedAt,
+        generation: observedGeneration,
+        graceMs: options.stopConfirmationGraceMs ?? STOP_CONFIRMATION_GRACE_MS,
+        lastStatusError:
+          'Runtime session is idle; task termination is unconfirmed.',
+        taskContextTracker: options.taskContextTracker,
+      });
+      if (updated?.state === 'stopped') {
+        log('[task-session-manager] confirmed runtime-stopped job from idle', {
+          sessionID,
+          alias: updated.alias,
+          parentSessionID: updated.parentSessionID,
+        });
+        return;
+      }
       log('[task-session-manager] observed quiescent job from idle', {
         sessionID,
         alias: job.alias,
         parentSessionID: job.parentSessionID,
       });
-      options.backgroundJobBoard.markStatusUncertain(
-        sessionID,
-        'Runtime session is idle; task termination is unconfirmed.',
-        observedGeneration,
-        idleObservedAt,
-      );
     }, options.idleReconcileDelayMs).unref?.();
     childIdleReconcileTimers.set(sessionID, timer);
   }

+ 132 - 4
src/hooks/task-session-manager/runtime-status-reconciliation.test.ts

@@ -5,6 +5,7 @@ import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
 function createReconciler(
   status: () => Promise<unknown>,
   statusTimeoutMs?: number,
+  stopConfirmationGraceMs?: number,
 ) {
   const board = new BackgroundJobBoard();
   const contextFilesForPrompt = mock(() => []);
@@ -16,6 +17,7 @@ function createReconciler(
     } as never,
     backgroundJobBoard: board,
     statusTimeoutMs,
+    stopConfirmationGraceMs,
     taskContextTracker: {
       pendingManagedTaskIds: new Set(['child-1']),
       contextFilesForPrompt,
@@ -256,13 +258,139 @@ describe('runtime status reconciliation', () => {
     });
   });
 
-  test('allows runtime busy to revive an acknowledged stopped job', () => {
+  test('idle then busy inside grace remains running with no terminal listener', async () => {
+    let liveStatus: unknown = { data: { 'child-1': { type: 'idle' } } };
+    const { board, reconciler } = createReconciler(
+      async () => liveStatus,
+      undefined,
+      60_000,
+    );
+    const listener = mock(() => {});
+    board.addTerminalStateListener(listener);
+
+    await reconciler.reconcile();
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+    });
+    expect(listener).not.toHaveBeenCalled();
+
+    liveStatus = { data: { 'child-1': { type: 'busy' } } };
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: false,
+      stopConfirmationStartedAt: undefined,
+    });
+    expect(listener).not.toHaveBeenCalled();
+  });
+
+  test('repeated idle beyond confirmation grace becomes stopped exactly once', async () => {
+    const { board, reconciler, contextFilesForPrompt, prune } = createReconciler(
+      async () => ({ data: { 'child-1': { type: 'idle' } } }),
+      undefined,
+      0,
+    );
+    const listener = mock(() => {});
+    board.addTerminalStateListener(listener);
+
+    await reconciler.reconcile();
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(listener).not.toHaveBeenCalled();
+
+    await reconciler.reconcile();
+    expect(board.get('child-1')).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: true,
+    });
+    expect(listener).toHaveBeenCalledTimes(1);
+    expect(contextFilesForPrompt).toHaveBeenCalledTimes(1);
+    expect(prune).toHaveBeenCalledTimes(1);
+
+    await reconciler.reconcile();
+    expect(board.get('child-1')).toMatchObject({ state: 'stopped' });
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  test('a busy observation resets pending stop confirmation', async () => {
+    let liveStatus: unknown = { data: { 'child-1': { type: 'idle' } } };
+    const { board, reconciler } = createReconciler(
+      async () => liveStatus,
+      undefined,
+      0,
+    );
+    const listener = mock(() => {});
+    board.addTerminalStateListener(listener);
+
+    await reconciler.reconcile();
+    expect(board.get('child-1')?.stopConfirmationStartedAt).toBeDefined();
+
+    liveStatus = { data: { 'child-1': { type: 'busy' } } };
+    await reconciler.reconcile();
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      stopConfirmationStartedAt: undefined,
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 2));
+    liveStatus = { data: { 'child-1': { type: 'idle' } } };
+    await reconciler.reconcile();
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(board.get('child-1')?.stopConfirmationStartedAt).toBeDefined();
+    expect(listener).not.toHaveBeenCalled();
+  });
+
+  test('status lookup failure does not confirm a stop or wake the parent', async () => {
+    let liveStatus: () => Promise<unknown> = async () => ({
+      data: { 'child-1': { type: 'idle' } },
+    });
+    const { board, reconciler } = createReconciler(
+      () => liveStatus(),
+      undefined,
+      0,
+    );
+    const listener = mock(() => {});
+    board.addTerminalStateListener(listener);
+
+    await reconciler.reconcile();
+    expect(board.get('child-1')?.stopConfirmationStartedAt).toBeDefined();
+
+    liveStatus = 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',
+    });
+    expect(board.get('child-1')?.stopConfirmationStartedAt).toBeDefined();
+    expect(listener).not.toHaveBeenCalled();
+  });
+
+  test('does not let stale busy revive a confirmed stopped job after terminal wake', () => {
+    const { board } = createReconciler(async () => ({ data: {} }));
+    const generation = board.get('child-1')?.generation;
+    board.markStopped('child-1', 'no result', 150, generation, 150);
+    board.markReconciled('child-1', 160);
+
+    board.markRunningFromLiveSession('child-1', 200, generation);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: false,
+      lastLiveBusyAt: 200,
+    });
+  });
+
+  test('later live busy can still revive an unreconciled 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.markStopped('child-1', 'no result', 150, generation, 150);
 
-    board.markRunningFromLiveSession('child-1', 2, generation);
+    board.markRunningFromLiveSession('child-1', 200, generation);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',

+ 35 - 16
src/hooks/task-session-manager/runtime-status-reconciliation.ts

@@ -5,6 +5,10 @@ import {
   runtimeSessionStatus,
 } from '../../utils';
 import { log } from '../../utils/logger';
+import {
+  observeNonBusyRuntime,
+  STOP_CONFIRMATION_GRACE_MS,
+} from './stop-confirmation';
 
 export const RUNTIME_STATUS_RECONCILE_DELAY_MS = 5_000;
 
@@ -13,6 +17,7 @@ export function createRuntimeStatusReconciler(options: {
   backgroundJobBoard: BackgroundJobStore;
   delayMs?: number;
   statusTimeoutMs?: number;
+  stopConfirmationGraceMs?: number;
   taskContextTracker: {
     pendingManagedTaskIds: Set<string>;
     contextFilesForPrompt(taskId: string): ContextFile[];
@@ -51,11 +56,14 @@ export function createRuntimeStatusReconciler(options: {
       .filter((job) => job.state === 'running');
     if (running.length === 0) return;
 
+    const requestStartedAt = Date.now();
     const snapshot = await getRuntimeSessionStatusSnapshot(options.input, {
       timeoutMs: options.statusTimeoutMs,
     });
     if (disposed) return;
     const observedAt = Date.now();
+    const graceMs =
+      options.stopConfirmationGraceMs ?? STOP_CONFIRMATION_GRACE_MS;
     if (snapshot.error) {
       for (const job of running) {
         options.backgroundJobBoard.markStatusUncertain(
@@ -81,33 +89,44 @@ export function createRuntimeStatusReconciler(options: {
         continue;
       }
       const status = runtimeSessionStatus(snapshot, job.taskID);
-      if (status === undefined) {
-        options.backgroundJobBoard.markStatusUncertain(
+      if (status === 'busy' || status === 'retry') {
+        options.backgroundJobBoard.markRunningFromLiveSession(
           job.taskID,
-          snapshot.malformedSessionIDs.has(job.taskID)
-            ? 'Runtime status response did not contain a recognized session state.'
-            : 'Runtime status response did not contain a live session state; task termination is unconfirmed.',
+          observedAt,
           job.generation,
         );
         continue;
       }
-      if (status === 'busy' || status === 'retry') {
-        options.backgroundJobBoard.markRunningFromLiveSession(
+      if (status === undefined && snapshot.malformedSessionIDs.has(job.taskID)) {
+        options.backgroundJobBoard.markStatusUncertain(
           job.taskID,
-          observedAt,
+          'Runtime status response did not contain a recognized session state.',
           job.generation,
         );
         continue;
       }
 
-      // Idle only says that the runner is currently quiescent. It is not
-      // terminal evidence for a background task: a task result can arrive
-      // after this observation.
-      options.backgroundJobBoard.markStatusUncertain(
-        job.taskID,
-        'Runtime session is idle; task termination is unconfirmed.',
-        job.generation,
-      );
+      const lastStatusError =
+        status === undefined
+          ? 'Runtime status response did not contain a live session state; task termination is unconfirmed.'
+          : 'Runtime session is idle; task termination is unconfirmed.';
+      const updated = observeNonBusyRuntime({
+        backgroundJobBoard: options.backgroundJobBoard,
+        taskID: job.taskID,
+        observedAt: requestStartedAt,
+        generation: job.generation,
+        graceMs,
+        lastStatusError,
+        taskContextTracker: options.taskContextTracker,
+      });
+      if (updated?.state === 'stopped') {
+        log('[task-session-manager] confirmed runtime-stopped job', {
+          taskID: updated.taskID,
+          alias: updated.alias,
+          parentSessionID: updated.parentSessionID,
+        });
+        continue;
+      }
       log(
         '[task-session-manager] runtime session quiescent; terminal result pending',
         {

+ 94 - 0
src/hooks/task-session-manager/stop-confirmation.ts

@@ -0,0 +1,94 @@
+import type {
+  BackgroundJobRecord,
+  BackgroundJobStore,
+  ContextFile,
+} from '../../utils';
+
+export const STOP_CONFIRMATION_GRACE_MS = 5_000;
+
+export const STOPPED_WITHOUT_TERMINAL_RESULT =
+  'Background session stopped before a terminal task result was received.';
+
+export type StopConfirmationTracker = {
+  pendingManagedTaskIds: Set<string>;
+  contextFilesForPrompt(taskId: string): ContextFile[];
+  prune(board: { taskIDs(): Set<string> }): void;
+};
+
+export function applyConfirmedStop(options: {
+  backgroundJobBoard: BackgroundJobStore;
+  taskID: string;
+  observedAt: number;
+  generation: number;
+  taskContextTracker: StopConfirmationTracker;
+}): BackgroundJobRecord | undefined {
+  const stopped = options.backgroundJobBoard.markStopped(
+    options.taskID,
+    STOPPED_WITHOUT_TERMINAL_RESULT,
+    options.observedAt,
+    options.generation,
+  );
+  if (stopped?.state !== 'stopped') return stopped;
+  options.taskContextTracker.pendingManagedTaskIds.delete(options.taskID);
+  options.backgroundJobBoard.addContext(
+    options.taskID,
+    options.taskContextTracker.contextFilesForPrompt(options.taskID),
+  );
+  options.taskContextTracker.prune(options.backgroundJobBoard);
+  return stopped;
+}
+
+/**
+ * Idle/absent/non-busy is only a stop candidate. The first observation
+ * starts a grace clock; a later observation after the grace confirms
+ * the stop. Live busy after the observation wins and leaves the job running.
+ */
+export function observeNonBusyRuntime(options: {
+  backgroundJobBoard: BackgroundJobStore;
+  taskID: string;
+  observedAt: number;
+  generation: number;
+  graceMs: number;
+  lastStatusError: string;
+  taskContextTracker: StopConfirmationTracker;
+}): BackgroundJobRecord | undefined {
+  const job = options.backgroundJobBoard.get(options.taskID);
+  if (job?.state !== 'running' || job.generation !== options.generation) {
+    return job;
+  }
+  if (
+    job.lastLiveBusyAt !== undefined &&
+    job.lastLiveBusyAt > options.observedAt
+  ) {
+    return job;
+  }
+
+  const observationTime = options.observedAt + 1;
+  const startedAt = job.stopConfirmationStartedAt;
+  if (
+    startedAt === undefined ||
+    observationTime - startedAt < options.graceMs
+  ) {
+    if (startedAt === undefined) {
+      options.backgroundJobBoard.noteStopConfirmation(
+        options.taskID,
+        observationTime,
+        options.generation,
+      );
+    }
+    return options.backgroundJobBoard.markStatusUncertain(
+      options.taskID,
+      options.lastStatusError,
+      options.generation,
+      options.observedAt,
+    );
+  }
+
+  return applyConfirmedStop({
+    backgroundJobBoard: options.backgroundJobBoard,
+    taskID: options.taskID,
+    observedAt: observationTime,
+    generation: options.generation,
+    taskContextTracker: options.taskContextTracker,
+  });
+}

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

@@ -1005,6 +1005,88 @@ describe('BackgroundJobBoard', () => {
     });
   });
 
+  test('live busy clears pending stop confirmation', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      now: 100,
+    });
+    board.noteStopConfirmation('ses_1', 111, board.get('ses_1')?.generation);
+
+    const updated = board.markRunningFromLiveSession('ses_1', 200);
+
+    expect(updated).toMatchObject({
+      state: 'running',
+      stopConfirmationStartedAt: undefined,
+      lastLiveBusyAt: 200,
+    });
+  });
+
+  test('noteStopConfirmation keeps the first observation and ignores later ones', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    const generation = board.get('ses_1')?.generation;
+
+    expect(board.noteStopConfirmation('ses_1', 11, generation)).toMatchObject({
+      stopConfirmationStartedAt: 11,
+    });
+    expect(board.noteStopConfirmation('ses_1', 21, generation)).toMatchObject({
+      stopConfirmationStartedAt: 11,
+    });
+  });
+
+  test('stale busy does not revive a confirmed stopped job after terminal wake', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      now: 100,
+    });
+    const generation = board.get('ses_1')?.generation;
+    board.markStopped('ses_1', 'no result', 150, generation, 150);
+    board.markReconciled('ses_1', 160);
+
+    const updated = board.markRunningFromLiveSession('ses_1', 200, generation);
+
+    expect(updated).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: false,
+      lastLiveBusyAt: 200,
+    });
+  });
+
+  test('stale busy at the stop timestamp does not revive an unreconciled stopped job', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      now: 100,
+    });
+    const generation = board.get('ses_1')?.generation;
+    board.markStopped('ses_1', 'no result', 150, generation, 150);
+
+    const stale = board.markRunningFromLiveSession('ses_1', 150, generation);
+    expect(stale).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: true,
+      lastLiveBusyAt: 150,
+    });
+
+    const later = board.markRunningFromLiveSession('ses_1', 151, generation);
+    expect(later).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
   test('live busy session does not reopen non-cancelled terminal jobs', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

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

@@ -70,6 +70,8 @@ export interface BackgroundJobRecord {
   deadlineExceededAt?: number;
   updatedAt: number;
   lastLiveBusyAt?: number;
+  /** First non-busy runtime observation for the current stop-confirmation grace. */
+  stopConfirmationStartedAt?: number;
   completedAt?: number;
   resultSummary?: string;
   lastStatusError?: string;
@@ -279,6 +281,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         runStartedAt: now,
         deadlineExceededAt: undefined,
         lastLiveBusyAt: now,
+        stopConfirmationStartedAt: undefined,
         lastUsedAt: now,
         updatedAt: now,
         totalErrors: existing.totalErrors ?? 0,
@@ -379,6 +382,10 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       terminalState: terminal ? input.state : existing.terminalState,
       resultSummary: input.resultSummary ?? existing.resultSummary,
       lastStatusError: input.lastStatusError,
+      stopConfirmationStartedAt:
+        input.state === 'running'
+          ? existing.stopConfirmationStartedAt
+          : undefined,
     };
 
     if (input.state === 'completed') {
@@ -428,7 +435,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
     const isStaleTerminal =
       isCanonicalTerminalState(existing.state) ||
-      existing.state === 'reconciled';
+      existing.state === 'reconciled' ||
+      (existing.state === 'stopped' && !existing.terminalUnreconciled);
     if (isStaleTerminal) {
       const updated: BackgroundJobRecord = {
         ...existing,
@@ -438,11 +446,25 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       return updated;
     }
 
+    if (
+      existing.state === 'stopped' &&
+      existing.completedAt !== undefined &&
+      now <= existing.completedAt
+    ) {
+      const updated: BackgroundJobRecord = {
+        ...existing,
+        lastLiveBusyAt: now,
+      };
+      this.jobs.set(taskID, updated);
+      return updated;
+    }
+
     const updated: BackgroundJobRecord = {
       ...existing,
       state: 'running',
       updatedAt: now,
       lastLiveBusyAt: now,
+      stopConfirmationStartedAt: undefined,
       timedOut: false,
       recoverableAfterLiveBusy:
         existing.recoverableAfterLiveBusy || existing.timedOut,
@@ -500,12 +522,36 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       completedAt: existing.completedAt ?? now,
       resultSummary,
       lastStatusError: undefined,
+      stopConfirmationStartedAt: undefined,
     };
     this.jobs.set(taskID, updated);
     this.notifyTerminalStateListeners(taskID);
     return updated;
   }
 
+  noteStopConfirmation(
+    taskID: string,
+    startedAt: number,
+    expectedGeneration?: number,
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(taskID);
+    if (existing?.state !== 'running') return existing;
+    if (
+      expectedGeneration !== undefined &&
+      existing.generation !== expectedGeneration
+    ) {
+      return existing;
+    }
+    if (existing.stopConfirmationStartedAt !== undefined) return existing;
+
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      stopConfirmationStartedAt: startedAt,
+    };
+    this.jobs.set(taskID, updated);
+    return updated;
+  }
+
   markStatusUncertain(
     taskID: string,
     lastStatusError: string,
@@ -639,6 +685,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       terminalState: 'cancelled',
       resultSummary: summary,
       lastStatusError: undefined,
+      stopConfirmationStartedAt: undefined,
     };
 
     this.jobs.set(taskID, updated);
@@ -840,6 +887,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       timeoutCount: (existing.timeoutCount ?? 0) + 1,
       lastErrorAt: now,
       totalErrors: (existing.totalErrors ?? 0) + 1,
+      stopConfirmationStartedAt: undefined,
     };
     this.jobs.set(input.taskID, updated);
     this.notifyTerminalStateListeners(input.taskID);

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

@@ -224,6 +224,18 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     );
   }
 
+  noteStopConfirmation(
+    taskID: string,
+    startedAt: number,
+    expectedGeneration?: number,
+  ): BackgroundJobRecord | undefined {
+    return this.board.noteStopConfirmation(
+      taskID,
+      startedAt,
+      expectedGeneration,
+    );
+  }
+
   markStatusUncertain(
     taskID: string,
     lastStatusError: string,

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

@@ -162,6 +162,11 @@ export interface BackgroundJobStore {
     expectedGeneration?: number,
     now?: number,
   ): BackgroundJobRecord | undefined;
+  noteStopConfirmation(
+    taskID: string,
+    startedAt: number,
+    expectedGeneration?: number,
+  ): BackgroundJobRecord | undefined;
   markStatusUncertain(
     taskID: string,
     lastStatusError: string,

+ 1 - 1
src/utils/codemap.md

@@ -15,7 +15,7 @@ Centralized utilities and shared abstractions used across the oh-my-opencode-sli
 
 ### 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, 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.
+- **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. Idle/absent observations start a 5s stop-confirmation grace (`stopConfirmationStartedAt`); live busy resets it. After a confirmed stop has been acknowledged, stale busy cannot reopen the job.
 
 - **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.