Răsfoiți Sursa

fix(task-session-manager): confirm stop from transcript evidence, own fallback delivery, treat missing status as idle

Stop confirmation no longer publishes `stopped` from an absent
session.status map entry. The gate reads the child transcript, shares
the tracker classifier, and only stops on a valid empty post-baseline
segment. Unknown evidence (hung/malformed/timeout) stays running with
statusUncertain; retries are per quiescence episode and never fall
through to stopped.

Background fallback re-prompts now hand off observation before
promptAsync: prepare fences every terminal publisher, admit converts
the preparation into a tracked run and probes, reject covers explicit
host refusal, and settleUnresolved keeps ownership when the transport
fails without a response. A v2 `switched: false` delivery is admitted
work without claiming a model switch. A stale background generation
aborts the replay instead of prompting the wrong execution.

task_cancel treats a valid status map with no entry as idle (the v1
host drops idle sessions from the map). Busy, retry, transport errors,
and malformed payloads still refuse.

Does not reopen #1157 (valid absence still stops), #998 (substituted
snapshots/outcomes cannot terminalize), or #1115 (textless stays
non-terminal).
dhaern 1 zi în urmă
părinte
comite
46022fe342

+ 220 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -719,6 +719,226 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(call[0].body.parts[0]?.text).toBe('real prompt');
   });
 
+  function handoffMock() {
+    const calls = {
+      prepare: [] as Array<[string, number | undefined, string | undefined]>,
+      admit: [] as Array<[string, number | undefined]>,
+      reject: [] as Array<[string, number | undefined]>,
+      settleUnresolved: [] as Array<[string, number | undefined]>,
+    };
+    return {
+      calls,
+      handoff: {
+        prepare: (
+          sessionID: string,
+          generation: number | undefined,
+          baseline: string | undefined,
+        ) => {
+          calls.prepare.push([sessionID, generation, baseline]);
+          return true;
+        },
+        admit: (sessionID: string, generation: number | undefined) => {
+          calls.admit.push([sessionID, generation]);
+        },
+        reject: (sessionID: string, generation: number | undefined) => {
+          calls.reject.push([sessionID, generation]);
+        },
+        settleUnresolved: (
+          sessionID: string,
+          generation: number | undefined,
+        ) => {
+          calls.settleUnresolved.push([sessionID, generation]);
+        },
+      },
+    };
+  }
+
+  /** Common handoff-scenario runner: builds the mock client, the
+   * manager (with optional handoff/reader/modelChanged) and fires the
+   * message.updated → session.error sequence that triggers a fallback
+   * attempt on 'sess-1'. */
+  async function runFallbackScenario(options?: {
+    promptAsyncImpl?: () => Promise<unknown>;
+    abortImpl?: () => Promise<unknown>;
+    messagesData?: unknown[];
+    handoff?: ReturnType<typeof handoffMock>['handoff'];
+    readBackgroundGeneration?: (sessionID: string) => number | undefined;
+    modelChanged?: () => void;
+  }) {
+    ({ mocks } = createMockClient({
+      promptAsyncImpl: options?.promptAsyncImpl,
+      abortImpl: options?.abortImpl,
+      messagesData: options?.messagesData,
+    }));
+    mgr = new ForegroundFallbackManager(
+      makeChains(),
+      true,
+      { directory: '/test' } as any,
+      3,
+      undefined,
+      options?.modelChanged,
+      0,
+      500,
+      options?.handoff,
+      options?.readBackgroundGeneration,
+    );
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-1',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+    return mocks;
+  }
+
+  const taskPrompt = [
+    {
+      info: { id: 'm1', role: 'user' },
+      parts: [{ type: 'text', text: 'task prompt' }],
+    },
+  ];
+
+  test('arms the handoff before the admission await and admits after acceptance', async () => {
+    // False-stop incident: for a background child the fallback PREPARES
+    // the observation handoff before promptAsync is awaited (stop gate
+    // defers terminal publication) and ADMITS it once the host accepts
+    // the re-prompt — baseline = trailing message with a string id from
+    // the same read that produced the replay.
+    const { calls, handoff } = handoffMock();
+    const mocks = await runFallbackScenario({
+      handoff,
+      messagesData: [
+        ...taskPrompt,
+        { info: { id: 'm2', role: 'assistant' }, parts: [] },
+      ],
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(calls.prepare).toEqual([['sess-1', undefined, 'm2']]);
+    expect(calls.admit).toEqual([['sess-1', undefined]]);
+    expect(calls.reject).toEqual([]);
+  });
+
+  test('rejects the handoff when promptAsync resolves with an error envelope', async () => {
+    const { calls, handoff } = handoffMock();
+    const mocks = await runFallbackScenario({
+      handoff,
+      messagesData: taskPrompt,
+      promptAsyncImpl: async () => ({
+        error: { message: 'admission refused' },
+      }),
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(calls.prepare).toHaveLength(1);
+    expect(calls.admit).toEqual([]);
+    expect(calls.reject).toHaveLength(1);
+  });
+
+  test('converts the handoff to a owner when every promptAsync attempt rejects', async () => {
+    // A transport failure without a response does NOT prove the host
+    // refused — the replay may have been accepted. The prepared
+    // ownership converts into a tracked run instead of being dropped.
+    const { calls, handoff } = handoffMock();
+    await runFallbackScenario({
+      handoff,
+      messagesData: taskPrompt,
+      promptAsyncImpl: async () => {
+        throw new Error('transport failed');
+      },
+      abortImpl: async () => {
+        throw new Error('abort also failed');
+      },
+    });
+
+    expect(calls.prepare).toHaveLength(1);
+    expect(calls.admit).toEqual([]);
+    expect(calls.reject).toEqual([]);
+    expect(calls.settleUnresolved).toHaveLength(1);
+  });
+
+  test('switched:false still delivers — the handoff is admitted without the switch claim', async () => {
+    // The v2 shim runs s.prompt even when switchModel fails;
+    // `switched: false` means the replay WAS delivered on the current
+    // model. Admission and switch confirmation are different facts:
+    // the delivery keeps its owner; only sessionModel stays.
+    const { calls, handoff } = handoffMock();
+    const modelChanged = mock(() => {});
+    const mocks = await runFallbackScenario({
+      handoff,
+      modelChanged,
+      messagesData: taskPrompt,
+      promptAsyncImpl: async () => ({ switched: false }),
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(calls.admit).toHaveLength(1);
+    expect(calls.reject).toEqual([]);
+    expect(calls.settleUnresolved).toEqual([]);
+    // The switch claim is suppressed: no model migration.
+    expect(modelChanged).not.toHaveBeenCalled();
+  });
+
+  test('a stale background generation during the transcript read aborts the replay', async () => {
+    // The reader confirmed a BACKGROUND child, but the preparation lost
+    // validity (generation changed during the read) — sending the stale
+    // replay/baseline to a session that belongs to another execution
+    // must not happen.
+    const calls = {
+      prepare: [] as Array<[string, number | undefined, string | undefined]>,
+    };
+    const mocks = await runFallbackScenario({
+      messagesData: taskPrompt,
+      handoff: {
+        prepare: (
+          sessionID: string,
+          generation: number | undefined,
+          baseline: string | undefined,
+        ) => {
+          calls.prepare.push([sessionID, generation, baseline]);
+          return false; // superseded between the read and the arming
+        },
+        admit: () => {},
+        reject: () => {},
+        settleUnresolved: () => {},
+      },
+      readBackgroundGeneration: () => 7, // confirmed background child
+    });
+
+    expect(calls.prepare).toEqual([['sess-1', 7, 'm1']]);
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('passes the generation captured before any await', async () => {
+    let generation = 7;
+    const { calls, handoff } = handoffMock();
+    await runFallbackScenario({
+      handoff,
+      messagesData: taskPrompt,
+      readBackgroundGeneration: () => generation,
+      promptAsyncImpl: async () => {
+        generation = 8;
+        return {};
+      },
+    });
+
+    expect(calls.prepare).toEqual([['sess-1', 7, 'm1']]);
+    expect(calls.admit).toEqual([['sess-1', 7]]);
+    expect(generation).toBe(8);
+  });
+
   test('replays the last user message from v2-shaped session.messages data', async () => {
     // OpenCode 1.18+ session.messages() returns v2 SessionMessage objects
     // ({ type, text }) instead of the v1 { info, parts } shape. The fallback

+ 175 - 15
src/hooks/foreground-fallback/index.ts

@@ -18,6 +18,7 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
+import { responseError } from '../../utils/child-transcript';
 import { isRecord } from '../../utils/guards';
 import { createInternalAgentTextPart } from '../../utils/internal-initiator';
 import { log } from '../../utils/logger';
@@ -338,6 +339,37 @@ export class ForegroundFallbackManager {
     sessionID: string,
     model: string,
   ) => void;
+  /** sessionID + transcript baseline + the board generation captured
+   *  BEFORE the admission await, notified when a fallback re-prompt was
+   *  admitted for a background child. The host's native task notifier is
+   *  bound to the original background job and does not re-arm for the
+   *  re-prompted execution, so without this transfer nobody observes the
+   *  substituted run's transcript — the quiescent stop-confirmation then
+   *  publishes a false `stopped` even though the fallback's final answer
+   *  is already persisted (false-stop incident). The pre-await generation
+   *  fences relaunches: a generation change during the admission must not
+   *  enroll the new run under the stale attempt's baseline. */
+  private readonly backgroundFallbackHandoff?: {
+    prepare: (
+      sessionID: string,
+      preparedGeneration: number | undefined,
+      baselineMessageID: string | undefined,
+    ) => boolean;
+    admit: (sessionID: string, preparedGeneration: number | undefined) => void;
+    reject: (sessionID: string, preparedGeneration: number | undefined) => void;
+    settleUnresolved: (
+      sessionID: string,
+      preparedGeneration: number | undefined,
+    ) => void;
+  };
+  /** Synchronous board read returning the tracked generation for a
+   *  confirmed BACKGROUND child only — undefined for foreground or
+   *  unmanaged sessions (that undefined means "handoff not
+   *  applicable", never a wildcard). Captured before ANY await in the
+   *  fallback preparation. */
+  private readonly readBackgroundGeneration?: (
+    sessionID: string,
+  ) => number | undefined;
 
   /** Exposed for task-session-manager: prevents idle reconciliation
    *  while a fallback abort/re-prompt is in flight for this session. */
@@ -402,8 +434,39 @@ export class ForegroundFallbackManager {
     private readonly initialRetryDelayMs: number = 0,
     /** Delay between consecutive fallback attempts. */
     private readonly retryDelayMs: number = 500,
+    /** Terminal-observation handoff for background children: prepare()
+     *  arms the stop-gate deferral BEFORE the admission await (with the
+     *  baseline from the same transcript read that produced the replay);
+     *  admit() converts it into a tracked run once the host accepts the
+     *  re-prompt; reject() withdraws it on any non-admitted outcome. */
+    backgroundFallbackHandoff?: {
+      prepare: (
+        sessionID: string,
+        preparedGeneration: number | undefined,
+        baselineMessageID: string | undefined,
+      ) => boolean;
+      admit: (
+        sessionID: string,
+        preparedGeneration: number | undefined,
+      ) => void;
+      reject: (
+        sessionID: string,
+        preparedGeneration: number | undefined,
+      ) => void;
+      settleUnresolved: (
+        sessionID: string,
+        preparedGeneration: number | undefined,
+      ) => void;
+    },
+    /** Synchronous board read returning the tracked generation for a
+     *  confirmed BACKGROUND child only (undefined = foreground or
+     *  unmanaged — the handoff is not applicable, never a wildcard).
+     *  Captured before ANY await in the fallback preparation. */
+    readBackgroundGeneration?: (sessionID: string) => number | undefined,
   ) {
     this.onSessionModelChanged = onSessionModelChanged;
+    this.backgroundFallbackHandoff = backgroundFallbackHandoff;
+    this.readBackgroundGeneration = readBackgroundGeneration;
     if (coordinator) {
       coordinator.onSessionDeleted((id) => {
         this.sessionModel.delete(id);
@@ -888,6 +951,14 @@ export class ForegroundFallbackManager {
       }
 
       // Retrieve the last user message to re-submit with the fallback model.
+      // Fence captured BEFORE any await in the preparation: a board
+      // relaunch during the transcript read or the admission await must
+      // not enroll the new generation under this (stale) attempt's
+      // baseline. undefined = not a tracked background child
+      // (foreground/untracked) → the handoff is a no-op, never a
+      // wildcard.
+      const preparedGeneration = this.readBackgroundGeneration?.(sessionID);
+
       const result = await session.messages({
         path: { id: sessionID },
       });
@@ -951,38 +1022,127 @@ export class ForegroundFallbackManager {
       };
 
       let promptResult: unknown;
+      // Arm the observation handoff BEFORE the admission await: while
+      // promptAsync is pending the stop gate defers terminal
+      // publication — the re-prompted result may already be persisted
+      // but has no delivery owner yet. Baseline = trailing message WITH
+      // a string id from the transcript read that produced the replay,
+      // so the substituted run's answer is always post-baseline.
+      const baselineMessageID = [...messages]
+        .reverse()
+        .find(
+          (m) =>
+            isRecord(m) &&
+            typeof (m as { info?: { id?: unknown } }).info?.id === 'string',
+        ) as { info: { id: string } } | undefined;
+      const handoffArmed =
+        this.backgroundFallbackHandoff?.prepare(
+          sessionID,
+          preparedGeneration,
+          baselineMessageID?.info?.id,
+        ) ?? false;
+      // Distinguish "not applicable" (foreground or unmanaged session —
+      // preparedGeneration undefined, the fallback proceeds) from "was
+      // a confirmed background child whose preparation lost validity"
+      // (generation changed during the transcript read): the replay
+      // prompt and baseline are stale for an execution that no longer
+      // exists — do NOT send them.
+      if (preparedGeneration !== undefined && !handoffArmed) {
+        log(
+          '[foreground-fallback] background child superseded during preparation; replay aborted',
+          { sessionID, preparedGeneration },
+        );
+        return;
+      }
+      const withdrawHandoff = (): void => {
+        if (handoffArmed) {
+          this.backgroundFallbackHandoff?.reject(sessionID, preparedGeneration);
+        }
+      };
+      const settleUnresolvedHandoff = (): void => {
+        if (handoffArmed) {
+          this.backgroundFallbackHandoff?.settleUnresolved(
+            sessionID,
+            preparedGeneration,
+          );
+        }
+      };
       try {
         promptResult = await promptAsync(promptBody);
       } catch (promptErr) {
         if (isSwitchModelUnavailableError(promptErr)) {
-          // Not a busy session — the host cannot switch models at all, so
-          // aborting and retrying cannot help (same missing capability on
-          // every attempt). Surface the real cause via the outer handler.
+          // Explicit typed refusal: the host cannot switch models at
+          // all, so aborting and retrying cannot help (same missing
+          // capability on every attempt). Nothing was admitted.
+          withdrawHandoff();
           throw promptErr;
         }
         log('[foreground-fallback] promptAsync on busy session, aborting', {
           sessionID,
         });
-        await abortSessionWithTimeout(getClient(this.input), sessionID);
+        try {
+          await abortSessionWithTimeout(getClient(this.input), sessionID);
+        } catch (abortErr) {
+          // Unknown outcome: the abort transport failed — the admission
+          // state cannot be proven either way, so the prepared ownership
+          // CONVERTS into a tracked run instead of being dropped.
+          settleUnresolvedHandoff();
+          throw abortErr;
+        }
         await new Promise((r) => setTimeout(r, REPROMPT_DELAY_MS));
-        promptResult = await promptAsync(promptBody);
+        try {
+          promptResult = await promptAsync(promptBody);
+        } catch (retryErr) {
+          // Transport failed without a response: the host may still
+          // have accepted the replay — convert, never drop.
+          settleUnresolvedHandoff();
+          throw retryErr;
+        }
       }
 
-      // v2 shim truthfulness: when the replay was delivered on the CURRENT
-      // model (session.switchModel failed mid-replay, `switched: false`),
-      // the switch claim must not be recorded — sessionModel feeds chain
-      // descent and onSessionModelChanged migrates provider accounting;
-      // both would lie. v1 results carry no `switched` key and keep the
-      // claim (v1 parity).
-      if (isRecord(promptResult) && promptResult.switched === false) {
+      // SDK envelopes can resolve (not reject) with `{ error }` — an
+      // unresolved admission must not be treated as an accepted switch:
+      // state migration and observation transfer only happen after the
+      // same error-envelope contract the other SDK call sites apply.
+      if (isRecord(promptResult) && responseError(promptResult) !== undefined) {
+        log(
+          '[foreground-fallback] fallback re-prompt rejected by host error envelope',
+          {
+            sessionID,
+            agentName,
+            intended: nextModel,
+          },
+        );
+        withdrawHandoff();
+        return;
+      }
+
+      // v2 shim: `switched: false` means the replay WAS DELIVERED on
+      // the current model — the work is admitted, so the observation
+      // handoff is kept (delivery needs an owner); only the model-switch
+      // CLAIM is suppressed (sessionModel feeds chain descent and
+      // onSessionModelChanged migrates provider accounting; both would
+      // lie). Prompt admission and switch confirmation are two
+      // different facts.
+      const deliveredWithoutSwitch =
+        isRecord(promptResult) && promptResult.switched === false;
+      if (deliveredWithoutSwitch) {
         log(
           '[foreground-fallback] fallback prompt delivered on the current model (model switch failed)',
           { sessionID, agentName, from: currentModel, intended: nextModel },
         );
-        return;
+      } else {
+        this.sessionModel.set(sessionID, nextModel);
+        this.onSessionModelChanged?.(sessionID, nextModel);
+      }
+      // Admission accepted (with or without the switch): convert the
+      // prepared handoff into a tracked run (register + immediate
+      // probe) so the substituted run's result is observed and
+      // delivered to the parent.
+      if (handoffArmed) {
+        this.backgroundFallbackHandoff?.admit(sessionID, preparedGeneration);
       }
-      this.sessionModel.set(sessionID, nextModel);
-      this.onSessionModelChanged?.(sessionID, nextModel);
+      if (deliveredWithoutSwitch) return;
       log('[foreground-fallback] switched to fallback model', {
         sessionID,
         agentName,

+ 128 - 0
src/hooks/task-session-manager/fallback-observation-transfer.test.ts

@@ -0,0 +1,128 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils';
+import { createBackgroundFallbackHandoff } from './fallback-observation-transfer';
+import type { RevivedRunTracker } from './revived-run-tracker';
+
+function fakeTracker() {
+  const prepared: Array<{
+    taskID: string;
+    generation: number;
+    baselineMessageID?: string;
+  }> = [];
+  const admitted: Array<{ taskID: string; generation: number }> = [];
+  const rejected: Array<{ taskID: string; generation: number }> = [];
+  const unresolved: Array<{ taskID: string; generation: number }> = [];
+  const tracker = {
+    prepareObservation: mock(
+      (input: {
+        taskID: string;
+        generation: number;
+        baselineMessageID?: string;
+      }) => {
+        prepared.push(input);
+        return true;
+      },
+    ),
+    admitObservation: mock((taskID: string, generation: number) => {
+      admitted.push({ taskID, generation });
+      return true;
+    }),
+    rejectObservation: mock((taskID: string, generation: number) => {
+      rejected.push({ taskID, generation });
+    }),
+    settleObservationUnresolved: mock((taskID: string, generation: number) => {
+      unresolved.push({ taskID, generation });
+      return true;
+    }),
+  } as unknown as RevivedRunTracker;
+  return { tracker, prepared, admitted, rejected, unresolved };
+}
+
+function launchBackground(board: BackgroundJobBoard, taskID = 'ses_child') {
+  return board.registerLaunch({
+    taskID,
+    parentSessionID: 'parent',
+    agent: 'explorer',
+    description: 'inspect',
+    background: true,
+  });
+}
+
+describe('createBackgroundFallbackHandoff', () => {
+  test('prepare arms the deferral; admit and reject delegate to the tracker', () => {
+    const board = new BackgroundJobBoard();
+    const run = launchBackground(board);
+    const { tracker, prepared, admitted, rejected } = fakeTracker();
+    const handoff = createBackgroundFallbackHandoff({
+      backgroundJobBoard: board,
+      revivedRunTracker: tracker,
+    });
+
+    expect(handoff.prepare('ses_child', run.generation, 'm2')).toBe(true);
+    expect(prepared).toEqual([
+      {
+        taskID: 'ses_child',
+        generation: run.generation,
+        baselineMessageID: 'm2',
+        parentSessionID: 'parent',
+        description: 'inspect',
+      },
+    ]);
+
+    handoff.admit('ses_child', run.generation);
+    handoff.reject('ses_child', run.generation);
+    expect(admitted).toEqual([
+      { taskID: 'ses_child', generation: run.generation },
+    ]);
+    // The immediate probe on admission is the TRACKER's contract (see
+    // revived-run-tracker.test.ts); the handoff only delegates.
+    expect(rejected).toEqual([
+      { taskID: 'ses_child', generation: run.generation },
+    ]);
+  });
+
+  test('eligibility: only a running background record with the exact generation', () => {
+    const board = new BackgroundJobBoard();
+    const run = launchBackground(board);
+    board.registerLaunch({
+      taskID: 'ses_fg',
+      parentSessionID: 'parent',
+      agent: 'explorer',
+      background: false,
+    });
+    const { tracker, prepared } = fakeTracker();
+    const handoff = createBackgroundFallbackHandoff({
+      backgroundJobBoard: board,
+      revivedRunTracker: tracker,
+    });
+
+    // Background + running + exact generation.
+    expect(handoff.prepare('ses_child', run.generation, 'm2')).toBe(true);
+    // Foreground record.
+    expect(
+      handoff.prepare('ses_fg', board.get('ses_fg')?.generation, 'm2'),
+    ).toBe(false);
+    // Unknown session.
+    expect(handoff.prepare('ses_missing', 1, 'm2')).toBe(false);
+    // undefined generation is NEVER a wildcard.
+    expect(handoff.prepare('ses_fg', undefined, 'm2')).toBe(false);
+    // Stale generation for a live record.
+    expect(handoff.prepare('ses_child', run.generation + 1, 'm2')).toBe(false);
+    expect(prepared).toHaveLength(1);
+  });
+
+  test('settleUnresolved delegates to the tracker', () => {
+    const board = new BackgroundJobBoard();
+    const run = launchBackground(board);
+    const { tracker, unresolved } = fakeTracker();
+    const handoff = createBackgroundFallbackHandoff({
+      backgroundJobBoard: board,
+      revivedRunTracker: tracker,
+    });
+
+    handoff.settleUnresolved('ses_child', run.generation);
+    expect(unresolved).toEqual([
+      { taskID: 'ses_child', generation: run.generation },
+    ]);
+  });
+});

+ 113 - 0
src/hooks/task-session-manager/fallback-observation-transfer.ts

@@ -0,0 +1,113 @@
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import type { RevivedRunTracker } from './revived-run-tracker';
+
+/**
+ * Terminal-observation handoff for background children re-prompted by
+ * the foreground fallback (false-stop incident): the host's native task
+ * notifier is bound to the original background job and never re-arms
+ * for the re-prompted execution, so slim owns observing the
+ * substituted run.
+ *
+ * Three-phase contract — the handoff brackets the fallback's
+ * ADMISSION await instead of following it:
+ *
+ * - `prepare` runs BEFORE `promptAsync` is awaited (baseline in hand
+ *   from the same transcript read that produced the replay). While the
+ *   admission is pending, the stop-evidence gate DEFERS terminal
+ *   publication (`isObservationPending`) — the job may already hold
+ *   the re-prompted result, but no delivery owner exists yet, and
+ *   publishing then would strand the result without a parent
+ *   notification (the exact incident shape). Preparing also SUPPLANTS
+ *   the previous publisher (its in-flight probe fences out).
+ * - `admit` converts the preparation into a tracked run (register +
+ *   immediate probe) once the host accepts the re-prompt — including a
+ *   v2 `switched: false` delivery (prompt admitted on the current
+ *   model is still admitted work).
+ * - `reject` releases ownership on an EXPLICIT host refusal (error
+ *   envelope, typed capability rejection): nothing was admitted.
+ * - `settleUnresolved` handles unknown outcomes (transport failed
+ *   without a response — the host may still have accepted the replay):
+ *   the prepared ownership CONVERTS into a tracked run instead of being
+ *   dropped.
+ *
+ * Guards: only confirmed BACKGROUND jobs (`background === true`,
+ * `state === 'running'`) participate; the generation is REQUIRED (never
+ * a wildcard) and must still match the board record at prepare time.
+ * Expiry does not drop a pending preparation — it converts it into the
+ * owning run, so a late admission finds delivery already owned.
+ */
+export function createBackgroundFallbackHandoff(options: {
+  backgroundJobBoard: BackgroundJobStore;
+  revivedRunTracker: RevivedRunTracker;
+}): {
+  prepare: (
+    sessionID: string,
+    preparedGeneration: number | undefined,
+    baselineMessageID: string | undefined,
+  ) => boolean;
+  admit: (sessionID: string, preparedGeneration: number | undefined) => void;
+  /** Explicit host refusal (error envelope / typed capability
+   * rejection): nothing was admitted, ownership is released. */
+  reject: (sessionID: string, preparedGeneration: number | undefined) => void;
+  /** Unknown admission outcome (transport failed without a response —
+   * the host may still have accepted the replay): the prepared
+   * ownership CONVERTS into a tracked run instead of being dropped. */
+  settleUnresolved: (
+    sessionID: string,
+    preparedGeneration: number | undefined,
+  ) => void;
+} {
+  const resolveEligibleRecord = (
+    sessionID: string,
+    preparedGeneration: number | undefined,
+  ) => {
+    if (preparedGeneration === undefined) return undefined;
+    const record = options.backgroundJobBoard.get(sessionID);
+    if (
+      record?.state !== 'running' ||
+      record.background !== true ||
+      record.generation !== preparedGeneration
+    ) {
+      return undefined;
+    }
+    return record;
+  };
+
+  return {
+    prepare: (sessionID, preparedGeneration, baselineMessageID) => {
+      const record = resolveEligibleRecord(sessionID, preparedGeneration);
+      if (!record) return false;
+      return options.revivedRunTracker.prepareObservation({
+        taskID: sessionID,
+        generation: record.generation,
+        parentSessionID: record.parentSessionID,
+        baselineMessageID,
+        description: record.description,
+      });
+    },
+    admit: (sessionID, preparedGeneration) => {
+      if (preparedGeneration === undefined) return;
+      // Resolve the preparation for the SAME generation idempotently
+      // even when the board record already left 'running' — a promoted
+      // owner may have found and delivered the result BEFORE this ack
+      // arrived; the entry must still be cleaned (no reinstall, no
+      // notification reset — that is the tracker's promoted-admit
+      // contract).
+      options.revivedRunTracker.admitObservation(sessionID, preparedGeneration);
+    },
+    reject: (sessionID, preparedGeneration) => {
+      if (preparedGeneration === undefined) return;
+      options.revivedRunTracker.rejectObservation(
+        sessionID,
+        preparedGeneration,
+      );
+    },
+    settleUnresolved: (sessionID, preparedGeneration) => {
+      if (preparedGeneration === undefined) return;
+      options.revivedRunTracker.settleObservationUnresolved(
+        sessionID,
+        preparedGeneration,
+      );
+    },
+  };
+}

+ 241 - 1
src/hooks/task-session-manager/idle-reconciliation.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, mock, test } from 'bun:test';
 import { BackgroundJobBoard } from '../../utils';
 import { COMPLETED_WITHOUT_TEXT_DIAGNOSTIC } from '../../utils/task';
 import { createIdleReconciler } from './idle-reconciliation';
+import { createStopEvidenceGate } from './stop-confirmation';
 
 async function flushChildIdleReconcile(): Promise<void> {
   await new Promise((resolve) => setTimeout(resolve, 5));
@@ -12,7 +13,9 @@ async function flushChildIdleReconcile(): Promise<void> {
  * macrotask hops (delay(0) per probe). */
 async function waitForBoardRecord(
   board: BackgroundJobBoard,
-  predicate: (record: { state: string } | undefined) => boolean,
+  predicate: (
+    record: { state: string; statusUncertain?: boolean } | undefined,
+  ) => boolean,
   timeoutMs = 500,
 ): Promise<void> {
   const deadline = Date.now() + timeoutMs;
@@ -28,12 +31,32 @@ function createHarness(options?: {
     sessionID: string,
   ) => Promise<{ outcome?: string; resultText?: string } | undefined>;
   outcomeStabilization?: { probes: number; intervalMs: number };
+  readTerminalEvidence?: (taskID: string) => Promise<unknown>;
+  baselineFor?: (taskID: string, generation: number) => string | undefined;
+  maxEvidenceRetries?: number;
+  readTimeoutMs?: number;
+  /** Minimal tracker surface the outcome publisher fences on:
+   * pending-handoff checks + observation revision. */
+  revivedRunTracker?: {
+    isTracked: (sessionID: string, generation: number) => boolean;
+    isObservationPending: (sessionID: string, generation: number) => boolean;
+    revisionFor: (sessionID: string, generation: number) => number | undefined;
+  };
 }) {
   const board = new BackgroundJobBoard();
   const terminalListener = mock(() => {});
   board.addTerminalStateListener(terminalListener);
   const contextFilesForPrompt = mock(() => []);
   const prune = mock(() => {});
+  const stopEvidenceGate = options?.readTerminalEvidence
+    ? createStopEvidenceGate({
+        backgroundJobBoard: board,
+        readTerminalEvidence: options.readTerminalEvidence,
+        baselineFor: options.baselineFor,
+        maxEvidenceRetries: options.maxEvidenceRetries,
+        readTimeoutMs: options.readTimeoutMs,
+      })
+    : undefined;
   const reconciler = createIdleReconciler({
     backgroundJobBoard: board,
     reconcileInjectedTerminalJobs: mock(() => {}),
@@ -49,6 +72,8 @@ function createHarness(options?: {
     },
     readSessionOutcome: options?.readSessionOutcome,
     outcomeStabilization: options?.outcomeStabilization,
+    stopEvidenceGate,
+    revivedRunTracker: options?.revivedRunTracker as never,
   });
   board.registerLaunch({
     taskID: 'child-1',
@@ -257,6 +282,41 @@ describe('host outcome confirmation (v2: no session.status map)', () => {
     expect(board.get('child-1')).toMatchObject({ state: 'running' });
   });
 
+  test('a fallback handoff armed during the outcome probe fences its publication', async () => {
+    // The outcome query is a third terminal publisher.
+    // A handoff prepared while it is pending substitutes the
+    // observation — the stale outcome must NOT publish or reconcile.
+    let pendingHandoff = false;
+    let resolveOutcome:
+      | ((value: { outcome?: string; resultText?: string } | undefined) => void)
+      | undefined;
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 60_000,
+      readSessionOutcome: () =>
+        new Promise((resolve) => {
+          resolveOutcome = resolve;
+        }),
+      revivedRunTracker: {
+        isTracked: () => false,
+        isObservationPending: () => pendingHandoff,
+        revisionFor: () => 1,
+      },
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    const observed = observeIdle(reconciler, 10, generation);
+    await new Promise((resolve) => setTimeout(resolve, 5));
+    // The fallback prepares its handoff while the outcome is pending.
+    pendingHandoff = true;
+    resolveOutcome?.({ outcome: 'succeeded', resultText: 'stale text' });
+    await observed;
+    await new Promise((resolve) => setTimeout(resolve, 20));
+
+    const record = board.get('child-1');
+    expect(record?.state).toBe('running');
+    expect(record?.resultSummary).toBeUndefined();
+  });
+
   test('quiescent job without a host outcome self-confirms stopped after grace', async () => {
     const { board, reconciler, terminalListener } = createHarness({
       stopConfirmationGraceMs: 5,
@@ -275,3 +335,183 @@ describe('host outcome confirmation (v2: no session.status map)', () => {
     expect(terminalListener).toHaveBeenCalledTimes(1);
   });
 });
+
+describe('transcript-evidence stop gate (false-stop incident)', () => {
+  // v1-shaped transcript: last message after baseline is a completed
+  // assistant turn — exactly what a fallback re-prompt leaves behind.
+  const assistantDone = (id: string, baseline?: string) => ({
+    data: [
+      ...(baseline ? [{ info: { id: baseline, role: 'user' } }] : []),
+      {
+        info: { id, role: 'assistant', finish: 'stop', time: { completed: 1 } },
+        parts: [{ type: 'text', text: 'Fallback final answer.' }],
+      },
+    ],
+  });
+
+  test('post-grace confirmation publishes completed (not stopped) when the transcript already holds the result', async () => {
+    const { board, reconciler, terminalListener } = createHarness({
+      stopConfirmationGraceMs: 5,
+      readTerminalEvidence: async () => assistantDone('m2', 'm1'),
+      baselineFor: () => 'm1',
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    await waitForBoardRecord(board, (r) => r?.state !== 'running');
+
+    const record = board.get('child-1');
+    expect(record?.terminalState).toBe('completed');
+    expect(record?.resultSummary).toBe('Fallback final answer.');
+    expect(record?.terminalState ?? '').not.toBe('stopped');
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+  });
+
+  test('post-grace confirmation settles error when the transcript holds a terminal error', async () => {
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 5,
+      readTerminalEvidence: async () => ({
+        data: [
+          { info: { id: 'm1', role: 'user' }, parts: [] },
+          {
+            info: {
+              id: 'm2',
+              role: 'assistant',
+              finish: 'stop',
+              time: { completed: 1 },
+              error: { message: 'model unavailable' },
+            },
+            parts: [],
+          },
+        ],
+      }),
+      baselineFor: () => 'm1',
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    await waitForBoardRecord(board, (r) => r?.state !== 'running');
+
+    expect(board.get('child-1')?.terminalState).toBe('error');
+  });
+
+  test('a pre-baseline trailing answer is never attributed to the current run (stays running, retries bounded)', async () => {
+    // Transcript holds a stale assistant BEFORE the baseline and nothing
+    // after it — a substituted attempt's old output must not settle this
+    // run as completed; valid post-baseline emptiness resolves stopped.
+    const staleBeforeBaseline = {
+      data: [
+        {
+          info: {
+            id: 'm0',
+            role: 'assistant',
+            finish: 'stop',
+            time: { completed: 1 },
+          },
+          parts: [
+            { type: 'text', text: 'Stale answer from the failed attempt.' },
+          ],
+        },
+        {
+          info: { id: 'm1', role: 'user' },
+          parts: [{ type: 'text', text: 'replayed prompt' }],
+        },
+      ],
+    };
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 5,
+      maxEvidenceRetries: 1,
+      readTerminalEvidence: async () => staleBeforeBaseline,
+      baselineFor: () => 'm1',
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    await waitForBoardRecord(board, (r) => r?.state === 'stopped');
+    // Settled via the #1157 path, never a false completed with the
+    // stale pre-baseline text.
+    expect(board.get('child-1')?.resultSummary).toBe(
+      'Background session stopped before a terminal task result was received.',
+    );
+  });
+
+  test('unknown evidence never terminates into stopped through the idle path', async () => {
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 5,
+      maxEvidenceRetries: 1,
+      readTerminalEvidence: async () => undefined,
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    await waitForBoardRecord(board, (r) => r?.statusUncertain === true);
+    await new Promise((resolve) => setTimeout(resolve, 80));
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(board.get('child-1')?.terminalState).toBeUndefined();
+  });
+
+  test('malformed transcript entries are unknown evidence, not a provably-empty transcript', async () => {
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 5,
+      readTerminalEvidence: async () => ({ data: [{}] }),
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    // Never stopped: malformed ≠ absent.
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+
+  test('an assistant turn followed by a newer user message is pending work, never the older answer', async () => {
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 5,
+      readTerminalEvidence: async () => ({
+        data: [
+          { info: { id: 'm0', role: 'user' }, parts: [] },
+          {
+            info: {
+              id: 'm1',
+              role: 'assistant',
+              finish: 'stop',
+              time: { completed: 1 },
+            },
+            parts: [{ type: 'text', text: 'Older answer.' }],
+          },
+          {
+            info: { id: 'm2', role: 'user' },
+            parts: [{ type: 'text', text: 'new instruction' }],
+          },
+        ],
+      }),
+      baselineFor: () => 'm0',
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(board.get('child-1')?.resultSummary).toBeUndefined();
+  });
+
+  test('a hung transcript read is bounded by the read deadline and does not block confirmation', async () => {
+    const { board, reconciler } = createHarness({
+      stopConfirmationGraceMs: 5,
+      readTerminalEvidence: () => new Promise(() => {}), // never resolves
+      readTimeoutMs: 20,
+    });
+    const generation = board.get('child-1')?.generation ?? 1;
+
+    await observeIdle(reconciler, 10, generation);
+    const settled = await Promise.race([
+      waitForBoardRecord(board, (r) => r?.statusUncertain === true).then(
+        () => true,
+      ),
+      new Promise((resolve) => setTimeout(() => resolve(false), 2_000)),
+    ]);
+    expect(settled).toBe(true);
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+});

+ 94 - 15
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -5,8 +5,11 @@ import {
   isHostTerminalOutcome,
 } from '../../utils/task';
 import type { RevivedRunTracker } from './revived-run-tracker';
+import type { StopEvidenceGate } from './stop-confirmation';
 import {
+  DEFAULT_EVIDENCE_READ_TIMEOUT_MS,
   observeNonBusyRuntime,
+  raceEvidenceDeadline,
   STOP_CONFIRMATION_GRACE_MS,
 } from './stop-confirmation';
 
@@ -30,6 +33,10 @@ export function createIdleReconciler(options: {
   onErrorTerminalize?: (sessionID: string) => void;
   idleReconcileDelayMs: number;
   stopConfirmationGraceMs?: number;
+  /** Deadline for the tracker-probe and outcome-probe awaits on the
+   * quiescent path: a hung read must not park the stop confirmation
+   * indefinitely. */
+  evidenceReadTimeoutMs?: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   hasInputWait: (sessionID: string) => boolean;
   getIdleSessionToken: (sessionID: string) => symbol;
@@ -52,6 +59,10 @@ export function createIdleReconciler(options: {
   ) => Promise<{ outcome?: string; resultText?: string } | undefined>;
   /** Stabilization retries for a succeeded-but-textless outcome. */
   outcomeStabilization?: { probes: number; intervalMs: number };
+  /** Transcript-backed stop gate: consulted before publishing `stopped`
+   * so a quiescent job whose transcript already holds the terminal
+   * result settles completed/error instead (false-stop incident). */
+  stopEvidenceGate?: StopEvidenceGate;
 }) {
   const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
   const childIdleReconcileTimers = new Map<
@@ -67,6 +78,23 @@ export function createIdleReconciler(options: {
     ReturnType<typeof setTimeout>
   >();
 
+  function confirmViaGate(generation: number, onRetry?: () => void) {
+    const gate = options.stopEvidenceGate;
+    if (!gate) return undefined;
+    return (confirmation: {
+      taskID: string;
+      observedAt: number;
+      idleObservedAt: number;
+      lastStatusError: string;
+    }) =>
+      gate.confirm({
+        ...confirmation,
+        generation,
+        taskContextTracker: options.taskContextTracker,
+        onRetry,
+      });
+  }
+
   function scheduleIdleReconciliation(parentSessionID: string): void {
     if (
       idleReconcileTimers.has(parentSessionID) ||
@@ -112,14 +140,18 @@ export function createIdleReconciler(options: {
       }
 
       if (options.revivedRunTracker?.isTracked(sessionID, observedGeneration)) {
-        const terminalPublished = await options.revivedRunTracker.probe(
-          sessionID,
-          observedGeneration,
+        // Bounded wait: the tracker probe performs its own transcript
+        // read; on a hung transport the idle path must still reach the
+        // gate instead of parking here. The probe's identity fencing
+        // handles late settlement.
+        const terminalPublished = await raceEvidenceDeadline(
+          options.revivedRunTracker.probe(sessionID, observedGeneration),
+          options.evidenceReadTimeoutMs ?? DEFAULT_EVIDENCE_READ_TIMEOUT_MS,
         );
-        if (terminalPublished) return;
+        if (terminalPublished === true) return;
       }
 
-      const updated = observeNonBusyRuntime({
+      const updated = await observeNonBusyRuntime({
         backgroundJobBoard: options.backgroundJobBoard,
         taskID: sessionID,
         observedAt: idleObservedAt,
@@ -128,6 +160,14 @@ export function createIdleReconciler(options: {
         lastStatusError:
           'Runtime session is idle; task termination is unconfirmed.',
         taskContextTracker: options.taskContextTracker,
+        idleObservedAt,
+        confirmStop: confirmViaGate(observedGeneration, () => {
+          scheduleQuiescentStopConfirmation(
+            sessionID,
+            observedGeneration,
+            idleObservedAt,
+          );
+        }),
       });
       if (updated?.state === 'stopped') {
         log('[task-session-manager] confirmed runtime-stopped job from idle', {
@@ -147,17 +187,43 @@ export function createIdleReconciler(options: {
       // rejected per the incident #1115 precedent (never reconcile a
       // completed job without a usable answer).
       if (options.readSessionOutcome) {
+        // Observation-identity fence for THIS publisher: the outcome
+        // query is a terminal publication path the stop gate and the
+        // tracker probe do not cover. A fallback handoff armed while
+        // the query is pending substitutes the observation — this
+        // episode's outcome must not publish, and a re-registration
+        // (revision change) must not be crossed either.
+        const revisionAtStart = options.revivedRunTracker?.revisionFor(
+          sessionID,
+          observedGeneration,
+        );
         const guardsIntact = (): boolean => {
           const latest = options.backgroundJobBoard.get(sessionID);
-          return (
-            latest !== undefined &&
-            latest.state === 'running' &&
-            latest.generation === observedGeneration &&
-            !(
-              latest.lastLiveBusyAt !== undefined &&
-              latest.lastLiveBusyAt > idleObservedAt
+          if (
+            latest?.state !== 'running' ||
+            latest.generation !== observedGeneration
+          ) {
+            return false;
+          }
+          if (
+            latest.lastLiveBusyAt !== undefined &&
+            latest.lastLiveBusyAt > idleObservedAt
+          ) {
+            return false;
+          }
+          if (
+            options.revivedRunTracker?.isObservationPending(
+              sessionID,
+              observedGeneration,
             )
+          ) {
+            return false;
+          }
+          const revisionNow = options.revivedRunTracker?.revisionFor(
+            sessionID,
+            observedGeneration,
           );
+          return revisionNow === revisionAtStart;
         };
         const stabilization = options.outcomeStabilization ?? {
           probes: DEFAULT_OUTCOME_STABILIZATION_PROBES,
@@ -172,7 +238,12 @@ export function createIdleReconciler(options: {
         ) {
           if (attempt > 0) await delay(stabilization.intervalMs);
           try {
-            const probe = await options.readSessionOutcome(sessionID);
+            // Bounded read: a hung outcome probe must not park the
+            // quiescent confirmation indefinitely.
+            const probe = await raceEvidenceDeadline(
+              options.readSessionOutcome(sessionID),
+              options.evidenceReadTimeoutMs ?? DEFAULT_EVIDENCE_READ_TIMEOUT_MS,
+            );
             outcome = probe?.outcome;
             resultText = probe?.resultText;
           } catch (error) {
@@ -251,7 +322,7 @@ export function createIdleReconciler(options: {
     if (quiescentConfirmTimers.has(sessionID)) return;
     const graceMs =
       options.stopConfirmationGraceMs ?? STOP_CONFIRMATION_GRACE_MS;
-    const timer = setTimeout(() => {
+    const timer = setTimeout(async () => {
       quiescentConfirmTimers.delete(sessionID);
       if (options.isFallbackInProgress?.(sessionID)) return;
       const job = options.backgroundJobBoard.get(sessionID);
@@ -264,7 +335,7 @@ export function createIdleReconciler(options: {
       ) {
         return;
       }
-      const updated = observeNonBusyRuntime({
+      const updated = await observeNonBusyRuntime({
         backgroundJobBoard: options.backgroundJobBoard,
         taskID: sessionID,
         observedAt: idleObservedAt + graceMs + 1,
@@ -273,6 +344,14 @@ export function createIdleReconciler(options: {
         lastStatusError:
           'Runtime session is idle; task termination is unconfirmed.',
         taskContextTracker: options.taskContextTracker,
+        idleObservedAt,
+        confirmStop: confirmViaGate(observedGeneration, () => {
+          scheduleQuiescentStopConfirmation(
+            sessionID,
+            observedGeneration,
+            idleObservedAt,
+          );
+        }),
       });
       if (updated?.state === 'stopped') {
         log(

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

@@ -45,6 +45,7 @@ import {
 } from './pending-call-tracker';
 import type { RevivedRunTracker } from './revived-run-tracker';
 import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
+import { createStopEvidenceGate } from './stop-confirmation';
 import { createTaskContextTracker } from './task-context-tracker';
 import {
   handleToolExecuteAfter,
@@ -258,6 +259,27 @@ export function createTaskSessionManagerHook(
   const rehydrateState = getBackgroundJobLifecycleLedger(backgroundJobBoard);
   const rehydrateTombstones = rehydrateState.tombstones;
 
+  // Transcript-backed stop gate (false-stop incident): shared by the
+  // quiescent stop-confirmation timer and the periodic runtime-status
+  // reconciler so neither can publish `stopped` while the child's
+  // transcript already holds the terminal result. Unknown reads never
+  // terminate into stopped; the #1157 guarantee lives on a valid
+  // transcript that provably holds no result for this run.
+  const stopEvidenceGate = createStopEvidenceGate({
+    backgroundJobBoard,
+    readTerminalEvidence: async (taskID) =>
+      fetchChildTranscript(getClient(_ctx), taskID, _ctx.directory).catch(
+        () => undefined,
+      ),
+    baselineFor: (taskID, generation) =>
+      options.revivedRunTracker?.baselineFor(taskID, generation),
+    observationRevisionFor: (taskID, generation) =>
+      options.revivedRunTracker?.revisionFor(taskID, generation),
+    isObservationPending: (taskID, generation) =>
+      options.revivedRunTracker?.isObservationPending(taskID, generation) ??
+      false,
+  });
+
   const rememberDeletedSession = (sessionID: string): void => {
     const remember = (taskID: string): void => {
       recordBackgroundJobSuppression(backgroundJobBoard, taskID);
@@ -447,6 +469,7 @@ export function createTaskSessionManagerHook(
     isCurrentIdleSessionToken: (s, t) => isCurrentIdleSessionToken(s, t),
     taskContextTracker,
     revivedRunTracker: options.revivedRunTracker,
+    stopEvidenceGate,
     // v2: no live session-status map exists, but Session.Info.outcome
     // publishes the terminal transition — use it to settle quiescent jobs
     // to their accurate terminal state (v1 hosts keep the status-map
@@ -486,6 +509,7 @@ export function createTaskSessionManagerHook(
     backgroundJobBoard,
     delayMs: options.runtimeStatusReconcileDelayMs,
     taskContextTracker,
+    stopEvidenceGate,
   });
 
   const idleSessionTokens = createIdleSessionTokens({
@@ -786,6 +810,7 @@ export function createTaskSessionManagerHook(
 
       if (input.event.type === 'server.instance.disposed') {
         runtimeStatusReconciler.dispose();
+        stopEvidenceGate.dispose();
       }
       return handleEvent(input, {
         inputWaits,

+ 213 - 0
src/hooks/task-session-manager/revived-run-tracker.test.ts

@@ -9,6 +9,7 @@ function createHarness(
   assertBound = false,
   options: {
     stabilizationProbeDelayMs?: number;
+    handoffExpiryMs?: number;
     resolveSelection?: (sessionID: string) => Promise<{
       agent?: string;
       model?: { providerID: string; modelID: string };
@@ -891,4 +892,216 @@ describe('revived run tracker', () => {
 
     expect(harness.prompt).toHaveBeenCalledTimes(1);
   });
+
+  test('a pending probe replaced by another same-generation registration does not terminalize', async () => {
+    let resolveMessages: ((value: unknown) => void) | undefined;
+    const harness = createHarness(
+      () =>
+        new Promise((resolve) => {
+          resolveMessages = resolve;
+        }),
+    );
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline-1',
+      description: 'first fallback',
+    });
+    const firstProbe = harness.tracker.probe(
+      harness.run.taskID,
+      harness.run.generation,
+    );
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline-2',
+      description: 'second fallback',
+    });
+    resolveMessages?.({
+      data: [
+        { info: { id: 'baseline-1', role: 'user' }, parts: [] },
+        {
+          info: {
+            id: 'assistant-1',
+            role: 'assistant',
+            time: { completed: 2 },
+          },
+          parts: [{ type: 'text', text: 'stale first-run answer' }],
+        },
+      ],
+    });
+    expect(await firstProbe).toBe(false);
+    expect(harness.board.get('ses_child')?.state).toBe('running');
+    expect(harness.prompt).not.toHaveBeenCalled();
+  });
+
+  test('handoff: prepare defers, admit enrolls and probes immediately', async () => {
+    // A fallback re-prompt whose result is ALREADY persisted must be
+    // delivered on admission — no idle event will fire again.
+    const harness = createHarness(
+      completedTranscript(() => true),
+      undefined,
+      false,
+      {
+        stabilizationProbeDelayMs: 0,
+      },
+    );
+    const gen = harness.run.generation;
+
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(false);
+    expect(
+      harness.tracker.prepareObservation({
+        taskID: 'ses_child',
+        generation: gen,
+        parentSessionID: 'parent',
+        baselineMessageID: 'baseline',
+        description: 'inspect the change',
+      }),
+    ).toBe(true);
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(true);
+
+    expect(harness.tracker.admitObservation('ses_child', gen)).toBe(true);
+    expect(harness.tracker.isTracked('ses_child', gen)).toBe(true);
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(false);
+
+    await flushNotify();
+    expect(harness.board.get('ses_child')?.state).toBe('completed');
+    expect(harness.board.get('ses_child')?.resultSummary).toBe('new result');
+    expect(harness.prompt).toHaveBeenCalledTimes(1);
+  });
+
+  test('handoff: reject withdraws the preparation without enrolling', () => {
+    const harness = createHarness(completedTranscript(() => false));
+    const gen = harness.run.generation;
+    harness.tracker.prepareObservation({
+      taskID: 'ses_child',
+      generation: gen,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+
+    harness.tracker.rejectObservation('ses_child', gen);
+
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(false);
+    expect(harness.tracker.isTracked('ses_child', gen)).toBe(false);
+  });
+
+  test('handoff: promotion keeps fencing the gate and a late admit delivers without reinstalling', async () => {
+    // Expiry converts the preparation into the owning run, but the
+    // ADMISSION is still unresolved — the gate must stay deferred
+    // (no absent→stopped while the re-prompt may yet start). The late
+    // acceptance then resolves it: probe runs and the already persisted
+    // result is delivered exactly once, without resetting the installed
+    // owner's identity.
+    let probeCount = 0;
+    const harness = createHarness(
+      () => {
+        probeCount += 1;
+        return completedTranscript(() => probeCount > 1)();
+      },
+      undefined,
+      false,
+      { handoffExpiryMs: 5, stabilizationProbeDelayMs: 0 },
+    );
+    const gen = harness.run.generation;
+
+    expect(
+      harness.tracker.prepareObservation({
+        taskID: 'ses_child',
+        generation: gen,
+        parentSessionID: 'parent',
+        baselineMessageID: 'baseline',
+        description: 'inspect the change',
+      }),
+    ).toBe(true);
+
+    // Expiry promotes the preparation into the owning run.
+    await new Promise((resolve) => setTimeout(resolve, 15));
+    expect(harness.tracker.isTracked('ses_child', gen)).toBe(true);
+    // Still fencing: the admission itself is unresolved.
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(true);
+    expect(harness.board.get('ses_child')?.state).toBe('running');
+
+    // The late acceptance resolves it and fires the delivering probe.
+    expect(harness.tracker.admitObservation('ses_child', gen)).toBe(true);
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(false);
+    await flushNotify();
+    expect(harness.board.get('ses_child')?.state).toBe('completed');
+    expect(harness.board.get('ses_child')?.resultSummary).toBe('new result');
+    expect(harness.prompt).toHaveBeenCalledTimes(1);
+  });
+
+  test('handoff: unresolved transport failure converts the preparation into the owner', () => {
+    // A transport failure without a response does not prove refusal —
+    // ownership converts instead of being dropped.
+    const harness = createHarness(completedTranscript(() => false));
+    const gen = harness.run.generation;
+    harness.tracker.prepareObservation({
+      taskID: 'ses_child',
+      generation: gen,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+
+    expect(harness.tracker.settleObservationUnresolved('ses_child', gen)).toBe(
+      true,
+    );
+    // Owner installed, admission still unresolved → gate still fenced.
+    expect(harness.tracker.isTracked('ses_child', gen)).toBe(true);
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(true);
+
+    // A subsequent explicit host refusal releases it.
+    harness.tracker.rejectObservation('ses_child', gen);
+    expect(harness.tracker.isObservationPending('ses_child', gen)).toBe(false);
+  });
+
+  test('handoff: prepare refuses a stale generation', () => {
+    const harness = createHarness(completedTranscript(() => false));
+    const gen = harness.run.generation;
+
+    expect(
+      harness.tracker.prepareObservation({
+        taskID: 'ses_child',
+        generation: gen + 1,
+        parentSessionID: 'parent',
+        description: 'stale attempt',
+      }),
+    ).toBe(false);
+    expect(harness.tracker.isObservationPending('ses_child', gen + 1)).toBe(
+      false,
+    );
+  });
+
+  test('revision changes on re-registration even with an identical baseline', () => {
+    // Baseline value alone is not an observation identity — two
+    // fallback observations can both carry undefined (or the same)
+    // baseline; the monotonic revision fences them.
+    const harness = createHarness(completedTranscript(() => false));
+    const gen = harness.run.generation;
+    harness.tracker.register({
+      taskID: 'ses_child',
+      generation: gen,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'first',
+    });
+    const first = harness.tracker.revisionFor('ses_child', gen);
+    expect(typeof first).toBe('number');
+
+    harness.tracker.register({
+      taskID: 'ses_child',
+      generation: gen,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'second',
+    });
+    expect(harness.tracker.revisionFor('ses_child', gen)).not.toBe(first);
+
+    // Stale generations never resolve a revision.
+    expect(harness.tracker.revisionFor('ses_child', gen + 1)).toBeUndefined();
+  });
 });

+ 282 - 9
src/hooks/task-session-manager/revived-run-tracker.ts

@@ -23,6 +23,7 @@ const DEFAULT_RETRY_DELAY_MS = 1_000;
 const TERMINAL_NOTIFICATION_TIMEOUT_MS = 10_000;
 const DEFAULT_STABILIZATION_PROBES = 3;
 const DEFAULT_STABILIZATION_DELAY_MS = 150;
+const DEFAULT_HANDOFF_EXPIRY_MS = 30_000;
 
 type SessionMessage = {
   info?: {
@@ -45,6 +46,10 @@ type RevivedRun = {
   parentSessionID: string;
   baselineMessageID?: string;
   description: string;
+  /** Monotonic observation identity: incremented on every
+   * registration so evidence consumers can fence a snapshot against a
+   * same-generation substitution. */
+  revision: number;
   notification: {
     attempts: number;
     sent: boolean;
@@ -67,8 +72,41 @@ export interface RevivedRunTracker {
     description: string;
   }): void;
   isTracked(taskID: string, generation: number): boolean;
+  /** Baseline anchor for a tracked run, so transcript-evidence consumers
+   * (stop gate) can attribute the trailing answer to THIS run instead of
+   * a substituted attempt. Undefined for untracked/stale generations. */
+  baselineFor(taskID: string, generation: number): string | undefined;
   probe(taskID: string, generation: number): Promise<boolean>;
   onTerminal(record: BackgroundJobRecord): void;
+  /** Fallback observation handoff: prepare before the admission await
+   * so the stop gate defers terminal publication until a delivery owner
+   * exists. Admit converts the preparation into a tracked run
+   * (immediate probe, no reinstall). Reject withdraws on an explicit
+   * host refusal (error envelope / capability rejection). A hung
+   * admission PROMOTES the preparation into the owning run instead of
+   * dropping it — `isObservationPending` stays true until admit/reject,
+   * so an `absent` verdict cannot become a stop while admission is
+   * unresolved. */
+  prepareObservation(input: {
+    taskID: string;
+    generation: number;
+    parentSessionID: string;
+    baselineMessageID?: string;
+    description: string;
+  }): boolean;
+  admitObservation(taskID: string, generation: number): boolean;
+  /** Explicit host refusal (error envelope / capability rejection):
+   * nothing was admitted, ownership is released. */
+  rejectObservation(taskID: string, generation: number): void;
+  /** Unknown admission outcome (transport failed without a response):
+   * the prepared ownership CONVERTS into a tracked run instead of being
+   * dropped — the host may still have accepted the replay. */
+  settleObservationUnresolved(taskID: string, generation: number): boolean;
+  isObservationPending(taskID: string, generation: number): boolean;
+  /** Observation-identity fence for the stop gate: a monotonic
+   * revision per tracked run; changes on re-registration even when the
+   * baseline value is identical (especially undefined). */
+  revisionFor(taskID: string, generation: number): number | undefined;
   dispose(): void;
 }
 
@@ -80,6 +118,7 @@ export function createRevivedRunTracker(options: {
   notificationRetryDelayMs?: number;
   maxStabilizationProbes?: number;
   stabilizationProbeDelayMs?: number;
+  handoffExpiryMs?: number;
   onRegister?: (taskID: string) => void;
   onSettled?: (taskID: string) => void;
   contextFilesForPrompt?: (taskID: string) => ContextFile[];
@@ -92,6 +131,9 @@ export function createRevivedRunTracker(options: {
   resolveSelection?: (sessionID: string) => Promise<SessionSelection>;
 }): RevivedRunTracker {
   const runs = new Map<string, RevivedRun>();
+  // Monotonic observation identity across registrations (fence for the
+  // stop gate's evidence snapshot; see RevivedRun.revision).
+  let revisionSequence = 0;
   const maxNotificationRetries =
     options.maxNotificationRetries ?? DEFAULT_NOTIFICATION_RETRIES;
   const retryDelayMs =
@@ -177,6 +219,10 @@ export function createRevivedRunTracker(options: {
       // settled" — the probe retries on its stabilization schedule.
       return false;
     }
+    // Identity fencing after the await: a same-taskID re-registration
+    // (e.g. a second fallback in the SAME generation) replaces this run
+    // object; its stale transcript read must not terminalize the job.
+    if (disposed || runs.get(run.taskID) !== run) return false;
     if (response === undefined) return false;
 
     const evidence = extractChildTerminalEvidence(response, {
@@ -209,6 +255,7 @@ export function createRevivedRunTracker(options: {
         break;
     }
 
+    if (disposed || runs.get(run.taskID) !== run) return false;
     if (run.stabilizationProbes >= maxStabilizationProbes) {
       const updated = options.backgroundJobBoard.updateStatus({
         taskID: run.taskID,
@@ -226,6 +273,7 @@ export function createRevivedRunTracker(options: {
   }
 
   function finish(run: RevivedRun, record: BackgroundJobRecord): boolean {
+    if (disposed || runs.get(run.taskID) !== run) return false;
     if (record.state !== 'completed' && record.state !== 'error') return false;
     if (run.stabilizationTimer) {
       clearTimeout(run.stabilizationTimer);
@@ -285,7 +333,7 @@ export function createRevivedRunTracker(options: {
       // Resolve BEFORE acquiring the lease: a hung host read must not
       // pin the notification lease. Host `session.get` is bounded inside
       // resolveCurrentSelection; metadata still completes the hierarchy
-      // if that read times out (#1079 Oracle r2).
+      // if that read times out (#1079).
       const selection = options.resolveSelection
         ? await options
             .resolveSelection(run.parentSessionID)
@@ -297,7 +345,7 @@ export function createRevivedRunTracker(options: {
       // Revalidate AFTER the selection await: a late success from a
       // previous attempt may have marked this notification sent while the
       // retry was pending here — sending again would duplicate the
-      // terminal result (Oracle r2 P1.2).
+      // terminal result.
       const latestBeforeSend = options.backgroundJobBoard.get(run.taskID);
       if (
         !latestBeforeSend ||
@@ -426,32 +474,257 @@ export function createRevivedRunTracker(options: {
     parentSessionID: string;
     baselineMessageID?: string;
     description: string;
+  }): void {
+    // External registration (e.g. task_revive) supersedes any pending
+    // fallback handoff for this task: it replaces the prepared owner
+    // with its own observation identity.
+    deleteHandoff(input.taskID);
+    installRun(input);
+  }
+
+  function discardRun(run: RevivedRun): void {
+    if (runs.get(run.taskID) !== run) return;
+    if (run.notification.retryTimer) clearTimeout(run.notification.retryTimer);
+    if (run.stabilizationTimer) clearTimeout(run.stabilizationTimer);
+    runs.delete(run.taskID);
+  }
+
+  const baselineFor = (
+    taskID: string,
+    generation: number,
+  ): string | undefined => {
+    const run = runs.get(taskID);
+    if (run?.generation !== generation) return undefined;
+    return run.baselineMessageID;
+  };
+
+  // --- Fallback observation handoff -----------------------------------
+  // A prepared handoff fences the stop gate from publishing a terminal
+  // state while a fallback's admission await is still pending: the job
+  // may ALREADY hold the re-prompted result, but no delivery owner
+  // exists yet — publishing then would strand the result again (the
+  // exact false-stop-incident shape).
+  //
+  // Preparing SUPPLANTS the previous publisher (an in-flight probe of
+  // the substituted observation fences out on its identity check
+  // instead of publishing), and an UNRESOLVED outcome (expiry / unknown
+  // transport failure) CONVERTS the preparation into a tracked run —
+  // the prepared owner — so a late admission finds delivery already
+  // owned. The preparation is never dropped while the admission
+  // outcome is unknown.
+  const pendingHandoffs = new Map<
+    string,
+    {
+      generation: number;
+      parentSessionID: string;
+      baselineMessageID?: string;
+      description: string;
+      state: 'pending' | 'promoted';
+      expiryTimer?: ReturnType<typeof setTimeout>;
+    }
+  >();
+  const handoffExpiryMs = options.handoffExpiryMs ?? DEFAULT_HANDOFF_EXPIRY_MS;
+
+  function deleteHandoff(taskID: string): void {
+    const pending = pendingHandoffs.get(taskID);
+    if (!pending) return;
+    if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
+    pendingHandoffs.delete(taskID);
+  }
+
+  function isObservationPending(taskID: string, generation: number): boolean {
+    // BOTH states fence the gate: 'pending' = admission await in
+    // flight; 'promoted' = the owner was installed by expiry or an
+    // unresolved transport failure, but the ADMISSION itself is still
+    // unresolved — the re-prompt may yet start, so an `absent` verdict
+    // must not become a terminal stop meanwhile. The entry is cleaned
+    // only when the admission resolves (admit/reject) or an external
+    // registration supersedes it.
+    const pending = pendingHandoffs.get(taskID);
+    return pending?.generation === generation;
+  }
+
+  /** Install a run WITHOUT touching handoff bookkeeping (admit/expiry
+   * manage their own entries); public register() resolves any pending
+   * handoff first — an external registration (revive) supersedes it. */
+  function installRun(input: {
+    taskID: string;
+    generation: number;
+    parentSessionID: string;
+    baselineMessageID?: string;
+    description: string;
   }): void {
     const old = runs.get(input.taskID);
     if (old?.notification.retryTimer) clearTimeout(old.notification.retryTimer);
     if (old?.stabilizationTimer) clearTimeout(old.stabilizationTimer);
     runs.set(input.taskID, {
       ...input,
+      revision: ++revisionSequence,
       notification: { attempts: 0, sent: false, pending: false },
       stabilizationProbes: 0,
     });
     options.onRegister?.(input.taskID);
   }
 
-  function discardRun(run: RevivedRun): void {
-    if (runs.get(run.taskID) !== run) return;
-    if (run.notification.retryTimer) clearTimeout(run.notification.retryTimer);
-    if (run.stabilizationTimer) clearTimeout(run.stabilizationTimer);
-    runs.delete(run.taskID);
+  /** Convert a pending preparation into the owning tracked run. Used
+   * by expiry (hung admission) and unresolved transport failures: the
+   * prepared owner must survive so a late acceptance — or the
+   * already-persisted result — is still delivered. */
+  function promoteHandoffToOwner(taskID: string): boolean {
+    const pending = pendingHandoffs.get(taskID);
+    if (pending?.state !== 'pending') return false;
+    if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
+    pending.state = 'promoted';
+    pending.expiryTimer = undefined;
+    const record = options.backgroundJobBoard.get(taskID);
+    if (
+      record?.state !== 'running' ||
+      record.generation !== pending.generation ||
+      record.background !== true
+    ) {
+      // The execution was superseded while the admission was unknown:
+      // nothing to own.
+      deleteHandoff(taskID);
+      return false;
+    }
+    installRun({
+      taskID,
+      generation: pending.generation,
+      parentSessionID: pending.parentSessionID,
+      baselineMessageID: pending.baselineMessageID,
+      description: pending.description,
+    });
+    // The re-prompt may already be persisted (admission is async): own
+    // it now rather than waiting for an idle that already happened.
+    void probe(taskID, pending.generation);
+    return true;
+  }
+
+  function prepareObservation(input: {
+    taskID: string;
+    generation: number;
+    parentSessionID: string;
+    baselineMessageID?: string;
+    description: string;
+  }): boolean {
+    if (disposed) return false;
+    const record = options.backgroundJobBoard.get(input.taskID);
+    if (
+      record?.state !== 'running' ||
+      record.generation !== input.generation ||
+      record.background !== true
+    ) {
+      return false;
+    }
+    // Supplant the previous publisher: the run being substituted is
+    // discarded NOW — its in-flight probe fences out on the identity
+    // check instead of publishing the substituted attempt's terminal.
+    const old = runs.get(input.taskID);
+    if (old) discardRun(old);
+    deleteHandoff(input.taskID);
+    const expiryTimer = setTimeout(() => {
+      // Hung admission: the preparation converts into the owning run;
+      // responsibility is never dropped on a timer.
+      void promoteHandoffToOwner(input.taskID);
+    }, handoffExpiryMs);
+    expiryTimer.unref?.();
+    pendingHandoffs.set(input.taskID, {
+      generation: input.generation,
+      parentSessionID: input.parentSessionID,
+      baselineMessageID: input.baselineMessageID,
+      description: input.description,
+      state: 'pending',
+      expiryTimer,
+    });
+    return true;
+  }
+
+  function admitObservation(taskID: string, generation: number): boolean {
+    const pending = pendingHandoffs.get(taskID);
+    if (!pending || pending.generation !== generation) return false;
+    if (pending.state === 'promoted') {
+      // Late acceptance of an already-promoted owner: the admission is
+      // NOW resolved — clean the preparation and run the probe WITHOUT
+      // reinstalling the run or resetting sent/pending (the installed
+      // owner keeps its identity and notification state). The probe is
+      // the missing trigger when the result was persisted while the
+      // admission ack was in flight and no idle event will fire again.
+      deleteHandoff(taskID);
+      void probe(taskID, generation);
+      return true;
+    }
+    deleteHandoff(taskID);
+    const record = options.backgroundJobBoard.get(taskID);
+    if (
+      record?.state !== 'running' ||
+      record.generation !== generation ||
+      record.background !== true
+    ) {
+      return false;
+    }
+    installRun({
+      taskID,
+      generation,
+      parentSessionID: pending.parentSessionID,
+      baselineMessageID: pending.baselineMessageID,
+      description: pending.description,
+    });
+    // Immediate probe: the re-prompt admission is async — if the
+    // substituted run already went idle (fast answer + delayed
+    // admission accounting), no idle event will fire again.
+    void probe(taskID, generation);
+    return true;
+  }
+
+  /** Explicit host refusal (error envelope, capability rejection): no
+   * work was admitted, so nothing is owned. Unknown transport failures
+   * must use settleObservationUnresolved instead. */
+  function rejectObservation(taskID: string, generation: number): void {
+    const pending = pendingHandoffs.get(taskID);
+    if (!pending || pending.generation !== generation) return;
+    deleteHandoff(taskID);
+    if (pending.state === 'promoted') {
+      const run = runs.get(taskID);
+      if (run?.generation === generation) discardRun(run);
+    }
+  }
+
+  /** Unknown admission outcome (transport failed without a response —
+   * the host may still have accepted the replay): the prepared
+   * ownership CONVERTS into a tracked run instead of being dropped. */
+  function settleObservationUnresolved(
+    taskID: string,
+    generation: number,
+  ): boolean {
+    const pending = pendingHandoffs.get(taskID);
+    if (!pending || pending.generation !== generation) return false;
+    return promoteHandoffToOwner(taskID);
   }
 
   return {
     captureBaseline,
     register,
     isTracked,
+    baselineFor,
     probe,
     onTerminal,
-    dispose,
+    prepareObservation,
+    admitObservation,
+    rejectObservation,
+    settleObservationUnresolved,
+    isObservationPending,
+    revisionFor: (taskID, generation) => {
+      const run = runs.get(taskID);
+      return run?.generation === generation ? run.revision : undefined;
+    },
+    dispose: () => {
+      disposed = true;
+      for (const pending of pendingHandoffs.values()) {
+        clearTimeout(pending.expiryTimer);
+      }
+      pendingHandoffs.clear();
+      dispose();
+    },
   };
 }
 
@@ -473,7 +746,7 @@ async function awaitNotificationTransport<T>(
           backgroundJobBoard.releaseLease(lease);
           // A resolved promise is NOT delivery: the SDK can resolve with
           // an `{ error }` envelope when throwOnError is off. Classify
-          // with the same check the normal path uses (Oracle r2 P1.1).
+          // with the same check the normal path uses.
           onLateSettlement?.({
             ok: responseError(value) === undefined,
           });

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

@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
 import { BackgroundJobBoard } from '../../utils';
 import { buildPluginInput } from '../../v2/client-shim';
 import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
+import { createStopEvidenceGate } from './stop-confirmation';
 
 function createReconciler(
   status: () => Promise<unknown>,
@@ -686,4 +687,84 @@ describe('runtime status reconciliation', () => {
     });
     reconciler.dispose();
   });
+
+  test('a hung transcript read on one job does not stall confirmation of the rest', async () => {
+    const board = new BackgroundJobBoard();
+    const contextFilesForPrompt = mock(() => []);
+    const prune = mock(() => {});
+    const hung = board.registerLaunch({
+      taskID: 'child-hung',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'hung read',
+      now: 0,
+    });
+    const other = board.registerLaunch({
+      taskID: 'child-other',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'other',
+      now: 0,
+    });
+    board.noteStopConfirmation('child-hung', 1, hung.generation);
+    board.noteStopConfirmation('child-other', 1, other.generation);
+    const gate = createStopEvidenceGate({
+      backgroundJobBoard: board,
+      readTimeoutMs: 30,
+      readTerminalEvidence: async (taskID) => {
+        if (taskID === 'child-hung') return new Promise(() => {});
+        return {
+          data: [
+            { info: { id: 'm1', role: 'user' }, parts: [] },
+            {
+              info: {
+                id: 'm2',
+                role: 'assistant',
+                finish: 'stop',
+                time: { completed: 1 },
+              },
+              parts: [{ type: 'text', text: 'other answer' }],
+            },
+          ],
+        };
+      },
+      baselineFor: (taskID) => (taskID === 'child-other' ? 'm1' : undefined),
+    });
+    const reconciler = createRuntimeStatusReconciler({
+      input: {
+        directory: '/test/project',
+        client: {
+          session: {
+            status: async () => ({
+              data: {
+                'child-hung': { type: 'idle' },
+                'child-other': { type: 'idle' },
+              },
+            }),
+          },
+        },
+      } as never,
+      backgroundJobBoard: board,
+      stopConfirmationGraceMs: 0,
+      taskContextTracker: {
+        pendingManagedTaskIds: new Set(['child-hung', 'child-other']),
+        contextFilesForPrompt,
+        prune,
+      },
+      stopEvidenceGate: gate,
+    });
+
+    const settled = await Promise.race([
+      reconciler.reconcile().then(() => 'done'),
+      new Promise((resolve) => setTimeout(() => resolve('timeout'), 400)),
+    ]);
+    expect(settled).toBe('done');
+    expect(board.get('child-other')).toMatchObject({
+      state: 'completed',
+      resultSummary: 'other answer',
+    });
+    expect(board.get('child-hung')).toMatchObject({ state: 'running' });
+    reconciler.dispose();
+    gate.dispose();
+  });
 });

+ 43 - 22
src/hooks/task-session-manager/runtime-status-reconciliation.ts

@@ -5,6 +5,7 @@ import {
   runtimeSessionStatus,
 } from '../../utils';
 import { log } from '../../utils/logger';
+import type { StopEvidenceGate } from './stop-confirmation';
 import {
   observeNonBusyRuntime,
   STOP_CONFIRMATION_GRACE_MS,
@@ -23,6 +24,10 @@ export function createRuntimeStatusReconciler(options: {
     contextFilesForPrompt(taskId: string): ContextFile[];
     prune(board: { taskIDs(): Set<string> }): void;
   };
+  /** Transcript-backed stop gate: consulted before publishing `stopped`
+   * so a quiescent job whose transcript already holds the terminal
+   * result settles completed/error instead (false-stop incident). */
+  stopEvidenceGate?: StopEvidenceGate;
 }) {
   const delayMs = options.delayMs ?? RUNTIME_STATUS_RECONCILE_DELAY_MS;
   let timer: ReturnType<typeof setTimeout> | undefined;
@@ -107,8 +112,9 @@ export function createRuntimeStatusReconciler(options: {
       return;
     }
 
+    const confirmations: Array<Promise<void>> = [];
     for (const job of running) {
-      if (disposed) return;
+      if (disposed) break;
       const current = options.backgroundJobBoard.get(job.taskID);
       if (
         current?.state !== 'running' ||
@@ -141,31 +147,46 @@ export function createRuntimeStatusReconciler(options: {
         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',
-        {
+      const gate = options.stopEvidenceGate;
+      // Fire-and-await later: a hung transcript read on one job must not
+      // stall confirmation of the rest of the pass.
+      confirmations.push(
+        observeNonBusyRuntime({
+          backgroundJobBoard: options.backgroundJobBoard,
           taskID: job.taskID,
+          observedAt: requestStartedAt,
           generation: job.generation,
-        },
+          graceMs,
+          lastStatusError,
+          taskContextTracker: options.taskContextTracker,
+          confirmStop: gate
+            ? (confirmation) =>
+                gate.confirm({
+                  ...confirmation,
+                  generation: job.generation,
+                  taskContextTracker: options.taskContextTracker,
+                })
+            : undefined,
+        }).then((updated) => {
+          if (updated?.state === 'stopped') {
+            log('[task-session-manager] confirmed runtime-stopped job', {
+              taskID: updated.taskID,
+              alias: updated.alias,
+              parentSessionID: updated.parentSessionID,
+            });
+            return;
+          }
+          log(
+            '[task-session-manager] runtime session quiescent; terminal result pending',
+            {
+              taskID: job.taskID,
+              generation: job.generation,
+            },
+          );
+        }),
       );
     }
+    await Promise.all(confirmations);
   }
 
   async function reconcile(): Promise<void> {

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

@@ -0,0 +1,553 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils';
+import {
+  classifyTerminalEvidence,
+  createStopEvidenceGate,
+  EVIDENCE_UNAVAILABLE_DIAGNOSTIC,
+  raceEvidenceDeadline,
+  STOPPED_WITHOUT_TERMINAL_RESULT,
+} from './stop-confirmation';
+
+function tracker() {
+  return {
+    pendingManagedTaskIds: new Set(['child-1']),
+    contextFilesForPrompt: mock(() => []),
+    prune: mock(() => {}),
+  };
+}
+
+function launch(board: BackgroundJobBoard) {
+  return board.registerLaunch({
+    taskID: 'child-1',
+    parentSessionID: 'parent-1',
+    agent: 'fixer',
+    description: 'gate',
+    now: 0,
+  });
+}
+
+const assistantDone = {
+  data: [
+    { info: { id: 'm1', role: 'user' }, parts: [] },
+    {
+      info: {
+        id: 'm2',
+        role: 'assistant',
+        finish: 'stop',
+        time: { completed: 1 },
+      },
+      parts: [{ type: 'text', text: 'Fallback final answer.' }],
+    },
+  ],
+};
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
+const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
+
+describe('classifyTerminalEvidence', () => {
+  test('malformed entries are retry, not a provably-empty transcript', () => {
+    expect(classifyTerminalEvidence({ data: [{}] })).toEqual({
+      verdict: 'retry',
+      reason: 'malformed transcript entries',
+    });
+    expect(classifyTerminalEvidence({ data: [null] })).toEqual({
+      verdict: 'retry',
+      reason: 'malformed transcript entries',
+    });
+  });
+
+  test('an assistant turn followed by a newer user message is pending work', () => {
+    expect(
+      classifyTerminalEvidence(
+        {
+          data: [
+            { info: { id: 'm0', role: 'user' }, parts: [] },
+            {
+              info: {
+                id: 'm1',
+                role: 'assistant',
+                finish: 'stop',
+                time: { completed: 1 },
+              },
+              parts: [{ type: 'text', text: 'Older answer.' }],
+            },
+            {
+              info: { id: 'm2', role: 'user' },
+              parts: [{ type: 'text', text: 'new instruction' }],
+            },
+          ],
+        },
+        { baselineMessageID: 'm0' },
+      ),
+    ).toEqual({
+      verdict: 'retry',
+      reason: 'user message after last assistant',
+    });
+  });
+
+  test('a valid empty post-baseline segment is absent', () => {
+    expect(
+      classifyTerminalEvidence(
+        {
+          data: [
+            {
+              info: {
+                id: 'm0',
+                role: 'assistant',
+                finish: 'stop',
+                time: { completed: 1 },
+              },
+              parts: [{ type: 'text', text: 'stale' }],
+            },
+            { info: { id: 'm1', role: 'user' }, parts: [] },
+          ],
+        },
+        { baselineMessageID: 'm1' },
+      ),
+    ).toEqual({ verdict: 'absent' });
+  });
+
+  test('a pending tool call in the segment blocks attribution (extractor contract)', () => {
+    // The exact fixture of child-transcript.test.ts: baseline, an
+    // assistant with a RUNNING tool, then a completed assistant with
+    // text. The strict tracker probe answers pending; the gate must
+    // agree instead of publishing the trailing answer.
+    expect(
+      classifyTerminalEvidence(
+        {
+          data: [
+            { info: { id: 'base', role: 'user' }, parts: [] },
+            {
+              info: {
+                id: 'tool',
+                role: 'assistant',
+                time: { completed: 4 },
+              },
+              parts: [{ type: 'tool', state: { status: 'running' } }],
+            },
+            {
+              info: {
+                id: 'last',
+                role: 'assistant',
+                finish: 'stop',
+                time: { completed: 5 },
+              },
+              parts: [{ type: 'text', text: 'the answer' }],
+            },
+          ],
+        },
+        { baselineMessageID: 'base' },
+      ),
+    ).toEqual({ verdict: 'retry', reason: 'pending' });
+  });
+
+  test('invalid entries mixed with valid ones make the whole read malformed', () => {
+    // Dropping the null entry would "repair" the transcript into
+    // attributing the previous assistant as trailing.
+    expect(
+      classifyTerminalEvidence({
+        data: [
+          {
+            info: {
+              id: 'last',
+              role: 'assistant',
+              finish: 'stop',
+              time: { completed: 5 },
+            },
+            parts: [{ type: 'text', text: 'answer' }],
+          },
+          null,
+        ],
+      }),
+    ).toEqual({ verdict: 'retry', reason: 'malformed transcript entries' });
+  });
+
+  test('terminal error precedence over residual finish flags', () => {
+    // An error turn with leftover finish state settles as error rather
+    // than parking forever in retry.
+    expect(
+      classifyTerminalEvidence({
+        data: [
+          {
+            info: {
+              id: 'last',
+              role: 'assistant',
+              finish: 'tool-calls',
+              time: { completed: 5 },
+              error: 'model exploded',
+            },
+            parts: [],
+          },
+        ],
+      }),
+    ).toEqual({ verdict: 'error', text: 'model exploded' });
+  });
+});
+
+describe('raceEvidenceDeadline', () => {
+  test('timeoutMs <= 0 disables the deadline', async () => {
+    const value = await raceEvidenceDeadline(
+      new Promise((resolve) => setTimeout(() => resolve('ok'), 15)),
+      0,
+    );
+    expect(value).toBe('ok');
+  });
+});
+
+describe('createStopEvidenceGate', () => {
+  /** Standard single-child gate fixture. The default read is a manually
+   * resolved deferred (counted); every scenario action — busy recovery,
+   * episode re-arm, identity mutation, dispose — stays explicit in the
+   * test body. */
+  function gateHarness(options?: {
+    maxEvidenceRetries?: number;
+    readTimeoutMs?: number;
+    readTerminalEvidence?: (taskID: string) => Promise<unknown>;
+    baselineFor?: (taskID: string, generation: number) => string | undefined;
+    observationRevisionFor?: (
+      taskID: string,
+      generation: number,
+    ) => number | undefined;
+    isObservationPending?: (taskID: string, generation: number) => boolean;
+  }) {
+    const board = new BackgroundJobBoard();
+    const run = launch(board);
+    board.noteStopConfirmation('child-1', 1, run.generation);
+    let reads = 0;
+    let resolveRead: ((value: unknown) => void) | undefined;
+    const gate = createStopEvidenceGate({
+      backgroundJobBoard: board,
+      maxEvidenceRetries: options?.maxEvidenceRetries,
+      readTimeoutMs: options?.readTimeoutMs,
+      readTerminalEvidence:
+        options?.readTerminalEvidence ??
+        (() => {
+          reads += 1;
+          return new Promise((resolve) => {
+            resolveRead = resolve;
+          });
+        }),
+      baselineFor: options?.baselineFor,
+      observationRevisionFor: options?.observationRevisionFor,
+      isObservationPending: options?.isObservationPending,
+    });
+    const confirm = (
+      overrides: {
+        idleObservedAt?: number;
+        observedAt?: number;
+        onRetry?: () => void;
+      } = {},
+    ) =>
+      gate.confirm({
+        taskID: 'child-1',
+        generation: run.generation,
+        observedAt: overrides.observedAt ?? 10,
+        idleObservedAt: overrides.idleObservedAt ?? 1,
+        lastStatusError: 'idle',
+        taskContextTracker: tracker(),
+        onRetry: overrides.onRetry,
+      });
+    return {
+      board,
+      run,
+      gate,
+      confirm,
+      readCount: () => reads,
+      resolveRead: (value: unknown) => resolveRead?.(value),
+    };
+  }
+
+  test('unknown-evidence retries stay running; a later valid read settles', async () => {
+    let reads = 0;
+    const h = gateHarness({
+      maxEvidenceRetries: 2,
+      baselineFor: () => 'm1',
+      readTerminalEvidence: async () => {
+        reads += 1;
+        return reads >= 5 ? assistantDone : undefined;
+      },
+    });
+
+    for (let i = 0; i < 4; i += 1) {
+      await h.confirm();
+      expect(h.board.get('child-1')?.state).toBe('running');
+    }
+    expect(h.board.get('child-1')?.lastStatusError).toBe(
+      EVIDENCE_UNAVAILABLE_DIAGNOSTIC,
+    );
+
+    const settled = await h.confirm();
+    expect(settled).toMatchObject({
+      state: 'completed',
+      resultSummary: 'Fallback final answer.',
+    });
+    expect(h.board.get('child-1')?.state).not.toBe('stopped');
+  });
+
+  test('busy recovery opens a new episode and resets the retry budget', async () => {
+    let reads = 0;
+    const h = gateHarness({
+      maxEvidenceRetries: 1,
+      readTerminalEvidence: async () => {
+        reads += 1;
+        return undefined;
+      },
+    });
+
+    await h.confirm();
+    await h.confirm();
+    expect(h.board.get('child-1')?.lastStatusError).toBe(
+      EVIDENCE_UNAVAILABLE_DIAGNOSTIC,
+    );
+
+    h.board.markRunningFromLiveSession('child-1', 20, h.run.generation);
+    h.board.noteStopConfirmation('child-1', 21, h.run.generation);
+    await h.confirm({ idleObservedAt: 20, observedAt: 29 });
+    expect(h.board.get('child-1')).toMatchObject({
+      state: 'running',
+      lastStatusError: 'idle',
+    });
+    expect(reads).toBe(3);
+  });
+
+  test('a hung read is bounded by the deadline and does not block other jobs', async () => {
+    const board = new BackgroundJobBoard();
+    const hung = board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'hung',
+      now: 0,
+    });
+    board.noteStopConfirmation('child-1', 1, hung.generation);
+    const other = board.registerLaunch({
+      taskID: 'child-2',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'other',
+      now: 0,
+    });
+    board.noteStopConfirmation('child-2', 1, other.generation);
+
+    const gate = createStopEvidenceGate({
+      backgroundJobBoard: board,
+      readTimeoutMs: 20,
+      readTerminalEvidence: async (taskID) => {
+        if (taskID === 'child-1') return new Promise(() => {});
+        return assistantDone;
+      },
+      baselineFor: (taskID) => (taskID === 'child-2' ? 'm1' : undefined),
+    });
+    const context = tracker();
+
+    const hungConfirm = gate.confirm({
+      taskID: 'child-1',
+      generation: hung.generation,
+      observedAt: 10,
+      idleObservedAt: 1,
+      lastStatusError: 'idle',
+      taskContextTracker: context,
+    });
+    const otherConfirm = gate.confirm({
+      taskID: 'child-2',
+      generation: other.generation,
+      observedAt: 10,
+      idleObservedAt: 1,
+      lastStatusError: 'idle',
+      taskContextTracker: context,
+    });
+
+    const otherSettled = await Promise.race([
+      otherConfirm,
+      new Promise((resolve) => setTimeout(() => resolve('timeout'), 200)),
+    ]);
+    expect(otherSettled).toMatchObject({
+      state: 'completed',
+      resultSummary: 'Fallback final answer.',
+    });
+
+    await hungConfirm;
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+
+  test('joined callers all receive onRetry from ONE shared open read', async () => {
+    const h = gateHarness();
+    const retries: string[] = [];
+
+    const first = h.confirm({ onRetry: () => retries.push('a') });
+    // Joins the in-flight observation AFTER the read started, while it
+    // is still deferred — joining must be observable, not two
+    // independent confirmations.
+    await tick();
+    const second = h.confirm({ onRetry: () => retries.push('b') });
+    await tick();
+    expect(h.readCount()).toBe(1);
+
+    h.resolveRead(undefined); // unknown evidence → retry verdict
+    await Promise.all([first, second]);
+
+    expect(retries).toContain('a');
+    expect(retries).toContain('b');
+    expect(h.readCount()).toBe(1);
+    expect(h.board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+
+  // Identity substitution while an open read is pending: each row keeps
+  // generation and baseline identical and changes ONE identity
+  // component. The stale open read must never become the new
+  // observation's evidence, and the single-open limit holds.
+  const substitutions: Array<{
+    label: string;
+    idle: number;
+    mutate: (
+      h: ReturnType<typeof gateHarness>,
+      ctx: { revision: number },
+    ) => void;
+  }> = [
+    {
+      label:
+        'busy recovery re-arms the episode with same gen/revision/baseline',
+      idle: 32,
+      mutate: (h) => {
+        h.board.markRunningFromLiveSession('child-1', 30);
+        h.board.noteStopConfirmation('child-1', 31, h.run.generation);
+      },
+    },
+    {
+      label: 're-registration substitutes the observation revision',
+      idle: 1,
+      mutate: (_h, ctx) => {
+        ctx.revision = 2;
+      },
+    },
+  ];
+  for (const { label, idle, mutate } of substitutions) {
+    test(`a stale open read is never evidence: ${label}`, async () => {
+      const ctx = { revision: 1 };
+      const h = gateHarness({
+        readTimeoutMs: 20,
+        baselineFor: () => 'm1',
+        observationRevisionFor: () => ctx.revision,
+      });
+
+      // The first consumer is released on its deadline; the operation
+      // stays open under the original identity.
+      await h.confirm();
+      expect(h.readCount()).toBe(1);
+
+      mutate(h, ctx);
+
+      // No join (identity differs) and no new operation (single-open).
+      const next = await h.confirm({
+        idleObservedAt: idle,
+        observedAt: idle + 1,
+      });
+      expect(next).toMatchObject({ state: 'running' });
+      expect(h.readCount()).toBe(1);
+
+      // The stale snapshot finally resolves with a terminal — it can
+      // never be the substituted observation's evidence.
+      h.resolveRead(assistantDone);
+      await settle();
+      expect(h.board.get('child-1')).toMatchObject({ state: 'running' });
+      expect(h.board.get('child-1')?.resultSummary).toBeUndefined();
+    });
+  }
+
+  test('dispose ignores a late read', async () => {
+    const h = gateHarness();
+    const pending = h.confirm();
+
+    h.gate.dispose();
+    h.resolveRead(assistantDone);
+    await pending;
+    expect(h.board.get('child-1')?.state).toBe('running');
+    expect(h.board.get('child-1')?.resultSummary).toBeUndefined();
+  });
+
+  test('a pending handoff defers terminal publication without consuming budget', async () => {
+    let pendingHandoff = true;
+    const h = gateHarness({
+      readTerminalEvidence: async () => assistantDone,
+      baselineFor: () => 'm1',
+      isObservationPending: () => pendingHandoff,
+    });
+
+    // Handoff armed before the admission resolved: the already-persisted
+    // result must NOT be published (no delivery owner yet).
+    await h.confirm();
+    expect(h.board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(h.board.get('child-1')?.resultSummary).toBeUndefined();
+
+    // Admission rejected/expired: the gate proceeds and settles.
+    pendingHandoff = false;
+    const settled = await h.confirm();
+    expect(settled).toMatchObject({
+      state: 'completed',
+      resultSummary: 'Fallback final answer.',
+    });
+  });
+
+  test('a handoff armed during the read also defers classification', async () => {
+    let pendingHandoff = false;
+    const h = gateHarness({
+      baselineFor: () => 'm1',
+      isObservationPending: () => pendingHandoff,
+    });
+    const pending = h.confirm();
+
+    // The fallback prepares its handoff while the read is in flight.
+    pendingHandoff = true;
+    h.resolveRead(assistantDone);
+    await pending;
+    expect(h.board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(h.board.get('child-1')?.resultSummary).toBeUndefined();
+  });
+
+  test('a hung read is rejoined, not piled up: one underlying read across deadlines', async () => {
+    const h = gateHarness({ readTimeoutMs: 20 });
+
+    // Two deadline-expired attempts while the transport hangs: both
+    // consumers were released, but only ONE SDK operation was opened.
+    await h.confirm();
+    await tick();
+    await h.confirm();
+    expect(h.readCount()).toBe(1);
+    expect(h.board.get('child-1')).toMatchObject({ state: 'running' });
+
+    // The hung transport finally settles: the (still single) read's
+    // late value must not retroactively terminalize a consumer that
+    // already timed out — identity revalidation discards it.
+    h.resolveRead(assistantDone);
+    await settle();
+    expect(h.board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+
+  test('a baseline change during the read invalidates the snapshot', async () => {
+    let baseline = 'm1';
+    const h = gateHarness({ baselineFor: () => baseline });
+    const pending = h.confirm();
+
+    baseline = 'm9';
+    h.resolveRead(assistantDone);
+    await pending;
+    expect(h.board.get('child-1')?.state).toBe('running');
+    expect(h.board.get('child-1')?.resultSummary).not.toBe(
+      'Fallback final answer.',
+    );
+  });
+
+  test('valid absence after grace still stops (#1157)', async () => {
+    const h = gateHarness({
+      readTerminalEvidence: async () => ({
+        data: [{ info: { id: 'm1', role: 'user' }, parts: [] }],
+      }),
+      baselineFor: () => 'm1',
+    });
+    const stopped = await h.confirm();
+    expect(stopped).toMatchObject({
+      state: 'stopped',
+      resultSummary: STOPPED_WITHOUT_TERMINAL_RESULT,
+    });
+  });
+});

+ 544 - 2
src/hooks/task-session-manager/stop-confirmation.ts

@@ -3,12 +3,57 @@ import type {
   BackgroundJobStore,
   ContextFile,
 } from '../../utils';
+import {
+  type ChildTerminalEvidence,
+  classifyAssistantTurnEvidence,
+  type TranscriptMessage,
+} from '../../utils/child-transcript';
+import { isRecord } from '../../utils/guards';
 
 export const STOP_CONFIRMATION_GRACE_MS = 5_000;
 
+/** Deadline for a single transcript read inside the stop gate. A hung
+ * read must not block the reconciler loop (or join other confirmations)
+ * indefinitely; timeout degrades to an unknown-evidence verdict. */
+export const DEFAULT_EVIDENCE_READ_TIMEOUT_MS = 5_000;
+
+/** Race a promise against a deadline. On timeout the CONSUMER is
+ * released with `undefined` (unknown evidence) while the underlying
+ * operation may keep running — callers that must not pile up reads
+ * layer a single-open policy on top (see createStopEvidenceGate).
+ * `timeoutMs <= 0` disables the deadline (the promise still maps
+ * rejection to `undefined`). */
+export function raceEvidenceDeadline<T>(
+  promise: Promise<T>,
+  timeoutMs: number,
+): Promise<T | undefined> {
+  const settled = promise.catch(() => undefined);
+  if (timeoutMs <= 0) return settled;
+  let timer: ReturnType<typeof setTimeout>;
+  const timeout = new Promise<undefined>((resolve) => {
+    timer = setTimeout(() => resolve(undefined), timeoutMs);
+    timer.unref?.();
+  });
+  return Promise.race([settled, timeout]).finally(() => clearTimeout(timer));
+}
+
+/** Retry budget per quiescence EPISODE (the stop-confirmation anchor;
+ * busy recovery opens a new episode and resets it). Unknown evidence
+ * NEVER terminates into `stopped` — the budget only escalates the
+ * statusUncertain diagnostic. Retries stay bounded in frequency (the
+ * grace cadence) and self-heal: a later valid read settles the job.
+ * The #1157 termination guarantee lives on the `absent` path, which
+ * requires a VALID read that provably holds no result for this run
+ * (declared limit: without any readable result source, "no false stop"
+ * and "always terminates" cannot both hold). */
+const DEFAULT_MAX_EVIDENCE_RETRIES = 3;
+
 export const STOPPED_WITHOUT_TERMINAL_RESULT =
   'Background session stopped before a terminal task result was received.';
 
+export const EVIDENCE_UNAVAILABLE_DIAGNOSTIC =
+  'Terminal evidence could not be read after repeated attempts; task termination is unconfirmed (observation unavailable).';
+
 export type StopConfirmationTracker = {
   pendingManagedTaskIds: Set<string>;
   contextFilesForPrompt(taskId: string): ContextFile[];
@@ -42,8 +87,14 @@ export function applyConfirmedStop(options: {
  * 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.
+ *
+ * When `confirmStop` is provided, the post-grace confirmation is delegated
+ * to it (terminal-evidence gate): quiescence alone proves the session is
+ * not running, not that no result exists — the transcript must be
+ * consulted before publishing `stopped` (false-stop incident: a fallback
+ * re-prompt completed its answer while the grace timer was still armed).
  */
-export function observeNonBusyRuntime(options: {
+export async function observeNonBusyRuntime(options: {
   backgroundJobBoard: BackgroundJobStore;
   taskID: string;
   observedAt: number;
@@ -51,7 +102,20 @@ export function observeNonBusyRuntime(options: {
   graceMs: number;
   lastStatusError: string;
   taskContextTracker: StopConfirmationTracker;
-}): BackgroundJobRecord | undefined {
+  /** Idle timestamp the quiescence was first observed at (busy guard);
+   * defaults to observedAt for periodic-poll callers. Stays IMMUTABLE
+   * across retries — it is the real idle anchor that opened the
+   * decision, never a synthetic grace-consuming timestamp. */
+  idleObservedAt?: number;
+  /** Delegated post-grace confirmation. Returns the updated record. */
+  confirmStop?: (options: {
+    taskID: string;
+    observedAt: number;
+    idleObservedAt: number;
+    lastStatusError: string;
+    onRetry?: () => void;
+  }) => Promise<BackgroundJobRecord | undefined>;
+}): Promise<BackgroundJobRecord | undefined> {
   const job = options.backgroundJobBoard.get(options.taskID);
   if (job?.state !== 'running' || job.generation !== options.generation) {
     return job;
@@ -84,6 +148,15 @@ export function observeNonBusyRuntime(options: {
     );
   }
 
+  if (options.confirmStop) {
+    return options.confirmStop({
+      taskID: options.taskID,
+      observedAt: observationTime,
+      idleObservedAt: options.idleObservedAt ?? options.observedAt,
+      lastStatusError: options.lastStatusError,
+    });
+  }
+
   return applyConfirmedStop({
     backgroundJobBoard: options.backgroundJobBoard,
     taskID: options.taskID,
@@ -92,3 +165,472 @@ export function observeNonBusyRuntime(options: {
     taskContextTracker: options.taskContextTracker,
   });
 }
+
+export type TerminalEvidenceVerdict =
+  | { verdict: 'completed'; text: string }
+  | { verdict: 'error'; text: string }
+  | { verdict: 'absent' }
+  | { verdict: 'retry'; reason: string };
+
+function messageRole(message: TranscriptMessage): unknown {
+  return message.info?.role;
+}
+
+function isRecognizableMessage(message: TranscriptMessage): boolean {
+  const role = messageRole(message);
+  if (role === 'assistant' || role === 'user' || role === 'system') {
+    return true;
+  }
+  return typeof message.info?.id === 'string';
+}
+
+/** Structural (non-assistant, non-user) tail entries the backward scan
+ * may skip: synthetic/system/skill markers carrying a DEFINED string
+ * role. An undefined role is never skippable — the entry may be the
+ * newest tail. */
+function isStructuralTail(message: TranscriptMessage): boolean {
+  const role = messageRole(message);
+  return typeof role === 'string' && role !== 'assistant' && role !== 'user';
+}
+
+/** Map the shared terminal classifier onto the stop decision. The gate
+ * owns validity/provenance/absence/segment selection; the ONE-turn
+ * terminality contract (pending finish states, completion time,
+ * segment-wide pending tool calls, terminal error precedence, usable
+ * text) is delegated to `classifyAssistantTurnEvidence` — the same
+ * contract the revived-run tracker probe uses — so the two cannot
+ * diverge. */
+function verdictFromEvidence(
+  evidence: ChildTerminalEvidence,
+): TerminalEvidenceVerdict {
+  switch (evidence.kind) {
+    case 'ready':
+      return { verdict: 'completed', text: evidence.text };
+    case 'error':
+      return { verdict: 'error', text: evidence.errorText };
+    case 'pending':
+      return { verdict: 'retry', reason: 'pending' };
+    case 'textless':
+      return { verdict: 'retry', reason: 'textless' };
+    default:
+      return { verdict: 'retry', reason: 'unrecognized segment shape' };
+  }
+}
+
+/**
+ * Classify a child transcript response for the stop decision. The
+ * distinction that matters: `absent` means the transcript was read
+ * correctly and provably holds no result for THIS run; `retry` means
+ * the evidence is unknown (unreadable, malformed, provenance
+ * unverifiable, or the answer has not materialized) and must never be
+ * treated as proof of no result.
+ *
+ * Provenance rules:
+ * - With a baseline: only the post-baseline segment is considered. An
+ *   assistant turn is attributed to this run only when nothing newer
+ *   than it represents pending work — the backward scan from a
+ *   structural tail may NOT cross a real user message (that user
+ *   message is work whose answer has not arrived yet → retry).
+ * - Without a baseline (untracked native jobs): only the ABSOLUTE
+ *   trailing message counts (strict semantics — no scan back through
+ *   history), and an assistant turn is only attributed when its
+ *   completion timestamp is at/after the run started.
+ */
+export function classifyTerminalEvidence(
+  response: unknown,
+  options: { baselineMessageID?: string; runStartedAt?: number } = {},
+): TerminalEvidenceVerdict {
+  if (response === undefined) {
+    return { verdict: 'retry', reason: 'transcript source unavailable' };
+  }
+  if (!isRecord(response) || !Array.isArray(response.data)) {
+    return { verdict: 'retry', reason: 'malformed transcript response' };
+  }
+  // Structural validity: an entry that is not a record or carries no
+  // recognizable message shape makes the WHOLE read malformed — entries
+  // are never silently dropped, because a dropped entry may be the
+  // newest tail whose loss would "repair" the transcript into a false
+  // absence or attribution.
+  const all: TranscriptMessage[] = [];
+  for (const entry of response.data) {
+    if (!isRecord(entry) || !isRecognizableMessage(entry)) {
+      return { verdict: 'retry', reason: 'malformed transcript entries' };
+    }
+    all.push(entry);
+  }
+
+  if (options.baselineMessageID) {
+    const baselineIndex = all.findIndex(
+      (m) => m.info?.id === options.baselineMessageID,
+    );
+    if (baselineIndex < 0) {
+      // The baseline anchor is gone (compaction/revert): provenance for
+      // "which run does this trailing answer belong to" cannot be verified.
+      return { verdict: 'retry', reason: 'baseline message missing' };
+    }
+    const segment = all.slice(baselineIndex + 1);
+    // Scan back from the tail across STRUCTURAL non-assistant tails
+    // (synthetic/system markers). A real USER message after the last
+    // assistant means re-prompted work whose answer has not arrived —
+    // the older assistant is partial progress, never this observation's
+    // final result.
+    let targetIndex = segment.length - 1;
+    while (targetIndex >= 0 && isStructuralTail(segment[targetIndex])) {
+      targetIndex -= 1;
+    }
+    const target = segment[targetIndex];
+    if (!target) {
+      // Empty (or purely structural) post-baseline segment in an idle
+      // session: no assistant output exists for this run.
+      return { verdict: 'absent' };
+    }
+    if (messageRole(target) === 'user') {
+      // The run's prompt is the newest message and the session is idle:
+      // no answer will ever arrive — provably no result. (Partial
+      // progress cases are caught below: an assistant followed by a
+      // newer user message.)
+      const hasAssistant = segment.some((m) => messageRole(m) === 'assistant');
+      if (hasAssistant) {
+        // assistant → newer user: partial progress + pending re-prompt —
+        // delivering the older answer as final would be wrong.
+        return {
+          verdict: 'retry',
+          reason: 'user message after last assistant',
+        };
+      }
+      return { verdict: 'absent' };
+    }
+    return verdictFromEvidence(
+      classifyAssistantTurnEvidence(
+        all,
+        baselineIndex + 1 + targetIndex,
+        baselineIndex,
+      ),
+    );
+  }
+
+  // No baseline: strict trailing-message semantics only.
+  const trailing = all[all.length - 1];
+  if (!trailing) return { verdict: 'absent' };
+  const trailingRole = messageRole(trailing);
+  if (trailingRole === 'user') {
+    // Idle session whose newest message is the prompt itself: no answer
+    // exists to attribute.
+    return { verdict: 'absent' };
+  }
+  if (trailingRole !== 'assistant') {
+    return {
+      verdict: 'retry',
+      reason: 'no baseline; cannot attribute a historical assistant turn',
+    };
+  }
+  const completedAt = trailing.info?.time?.completed;
+  if (
+    options.runStartedAt !== undefined &&
+    typeof completedAt === 'number' &&
+    completedAt < options.runStartedAt
+  ) {
+    // Pre-run answer: provably not this execution's output.
+    return { verdict: 'absent' };
+  }
+  return verdictFromEvidence(
+    classifyAssistantTurnEvidence(all, all.length - 1, -1),
+  );
+}
+
+export interface StopEvidenceGate {
+  confirm(options: {
+    taskID: string;
+    generation: number;
+    observedAt: number;
+    idleObservedAt: number;
+    lastStatusError: string;
+    taskContextTracker: StopConfirmationTracker;
+    /** Invoked (for EVERY joined caller) when the verdict is a bounded
+     * retry — callers re-arm their own confirmation timers with their
+     * own immutable idle anchor. */
+    onRetry?: () => void;
+  }): Promise<BackgroundJobRecord | undefined>;
+  dispose(): void;
+}
+
+interface EvidenceEpisode {
+  generation: number;
+  /** stopConfirmationStartedAt anchor identifying this quiescence
+   * episode; a busy recovery clears it and the next idle opens a new
+   * one, resetting the retry budget. */
+  episode: number | undefined;
+  count: number;
+}
+
+/**
+ * Shared post-grace stop confirmation backed by transcript evidence.
+ * Coalesces concurrent confirmations per task AND generation (timer +
+ * periodic poll share one in-flight read and every joined `onRetry`
+ * fires), bounds each read with a deadline, revalidates state, busy,
+ * generation and observation identity after every await, and keeps the
+ * #1157 termination guarantee exclusively on the `absent` path (valid
+ * read, provably no result). Unknown evidence never terminates into
+ * `stopped`; after the episode budget it stays `running` +
+ * `statusUncertain` with an explicit diagnostic.
+ */
+export function createStopEvidenceGate(options: {
+  backgroundJobBoard: BackgroundJobStore;
+  readTerminalEvidence: (taskID: string) => Promise<unknown>;
+  /** Baseline anchoring for tracker-registered runs (revive/fallback):
+   * results from before the baseline belong to a substituted attempt. */
+  baselineFor?: (taskID: string, generation: number) => string | undefined;
+  /** Observation-identity fence: two distinct observations can share
+   * the same baseline (especially undefined), so the tracker also
+   * exposes a monotonic revision per tracked run; a revision change
+   * during the read invalidates the snapshot. */
+  observationRevisionFor?: (
+    taskID: string,
+    generation: number,
+  ) => number | undefined;
+  /** Fallback handoff deferral: while a fallback's admission await is
+   * pending, terminal publication is deferred — the job may already
+   * hold the re-prompted result but no delivery owner exists yet. */
+  isObservationPending?: (taskID: string, generation: number) => boolean;
+  maxEvidenceRetries?: number;
+  readTimeoutMs?: number;
+}): StopEvidenceGate {
+  const maxRetries = options.maxEvidenceRetries ?? DEFAULT_MAX_EVIDENCE_RETRIES;
+  const readTimeoutMs =
+    options.readTimeoutMs ?? DEFAULT_EVIDENCE_READ_TIMEOUT_MS;
+  const episodes = new Map<string, EvidenceEpisode>();
+  const inFlight = new Map<
+    string,
+    {
+      promise: Promise<BackgroundJobRecord | undefined>;
+      retryCallbacks: Array<(() => void) | undefined>;
+    }
+  >();
+  let disposed = false;
+
+  // Single-open read policy: at most ONE underlying evidence read per
+  // taskID is tracked at any moment, and the entry records the
+  // OBSERVATION IDENTITY it was opened for (generation + revision +
+  // baseline + quiescence episode at open time). Rules:
+  // - A consumer with the SAME identity joins the open operation
+  //   instead of piling up new SDK calls.
+  // - A consumer with a DIFFERENT identity (the observation was
+  //   substituted while the old read is still open) never reuses the
+  //   stale snapshot: it settles as uncertainty immediately, and no new
+  //   operation may open while the non-cancelable old one is pending —
+  //   the explicit single-open limit. The entry is released ONLY when
+  //   its underlying operation settles; age never drops it.
+  const openReads = new Map<
+    string,
+    { identity: string; underlying: Promise<unknown> }
+  >();
+
+  const settle = (
+    taskID: string,
+    generation: number,
+    fn: () => BackgroundJobRecord | undefined,
+  ): BackgroundJobRecord | undefined => {
+    const tracked = episodes.get(taskID);
+    if (tracked?.generation === generation) episodes.delete(taskID);
+    return fn();
+  };
+
+  async function confirmInternal(input: {
+    taskID: string;
+    generation: number;
+    observedAt: number;
+    idleObservedAt: number;
+    lastStatusError: string;
+    taskContextTracker: StopConfirmationTracker;
+    onRetry?: () => void;
+  }): Promise<BackgroundJobRecord | undefined> {
+    const pre = options.backgroundJobBoard.get(input.taskID);
+    if (pre?.state !== 'running' || pre.generation !== input.generation) {
+      return pre;
+    }
+
+    // Fallback handoff deferral: no terminal publication while the
+    // admission await is pending (the result may already exist but its
+    // delivery owner does not). Deferral consumes NO retry budget.
+    const deferForHandoff = (): BackgroundJobRecord | undefined => {
+      input.onRetry?.();
+      return options.backgroundJobBoard.markStatusUncertain(
+        input.taskID,
+        input.lastStatusError,
+        input.generation,
+        input.observedAt,
+      );
+    };
+    if (options.isObservationPending?.(input.taskID, input.generation)) {
+      return deferForHandoff();
+    }
+
+    const baseline = options.baselineFor?.(input.taskID, input.generation);
+    const revisionBefore = options.observationRevisionFor?.(
+      input.taskID,
+      input.generation,
+    );
+    // Quiescence episode: a busy recovery can start a NEW confirmation
+    // with the SAME generation, revision and baseline — those three
+    // alone do not identify the observation a read was opened for.
+    const episodeAnchor = pre.stopConfirmationStartedAt;
+    // Open-time identity: the snapshot a read produces is only evidence
+    // for the observation it was opened for. A consumer joins an open
+    // read ONLY on identical identity.
+    const identity = `${input.generation}:${revisionBefore ?? 'none'}:${baseline ?? 'none'}:${episodeAnchor ?? 'none'}`;
+    let response: unknown;
+    const existing = openReads.get(input.taskID);
+    if (existing && existing.identity !== identity) {
+      // The open read belongs to a SUBSTITUTED observation (or a prior
+      // quiescence episode): its late snapshot can never be this
+      // consumer's evidence. No new operation may open while the
+      // non-cancelable one is pending (explicit single-open limit) →
+      // settle as uncertainty via the unknown-evidence path below
+      // (never a terminal, never a stop).
+      response = undefined;
+    } else {
+      let read: Promise<unknown>;
+      if (existing) {
+        read = existing.underlying;
+      } else {
+        const underlying = Promise.resolve(
+          options.readTerminalEvidence(input.taskID),
+        );
+        openReads.set(input.taskID, { identity, underlying });
+        const release = () => {
+          const entry = openReads.get(input.taskID);
+          if (entry?.underlying === underlying) openReads.delete(input.taskID);
+        };
+        underlying.then(release, release);
+        read = underlying;
+      }
+      try {
+        response = await raceEvidenceDeadline(read, readTimeoutMs);
+      } catch {
+        response = undefined;
+      }
+    }
+
+    // Revalidate after the await: disposed, generation, state, the
+    // original idle anchor (a busy recovery wins and leaves the job
+    // running), the observation identity (baseline, revision AND the
+    // quiescence episode — a fallback that replaced the observation or
+    // a busy recovery that opened a new episode during the read must
+    // not classify this snapshot), and a handoff armed while we read.
+    if (disposed) return undefined;
+    const job = options.backgroundJobBoard.get(input.taskID);
+    if (job?.state !== 'running' || job.generation !== input.generation) {
+      return job;
+    }
+    if (
+      job.lastLiveBusyAt !== undefined &&
+      job.lastLiveBusyAt > input.idleObservedAt
+    ) {
+      return job;
+    }
+    if (options.isObservationPending?.(input.taskID, input.generation)) {
+      return deferForHandoff();
+    }
+    const baselineAfter = options.baselineFor?.(input.taskID, input.generation);
+    const revisionAfter = options.observationRevisionFor?.(
+      input.taskID,
+      input.generation,
+    );
+    const verdict =
+      baseline !== baselineAfter ||
+      revisionBefore !== revisionAfter ||
+      job.stopConfirmationStartedAt !== episodeAnchor
+        ? {
+            verdict: 'retry' as const,
+            reason: 'observation identity changed',
+          }
+        : classifyTerminalEvidence(response, {
+            baselineMessageID: baseline,
+            runStartedAt: job.runStartedAt,
+          });
+    if (verdict.verdict === 'completed' || verdict.verdict === 'error') {
+      return settle(input.taskID, input.generation, () =>
+        options.backgroundJobBoard.updateStatus({
+          taskID: input.taskID,
+          expectedGeneration: input.generation,
+          state: verdict.verdict === 'completed' ? 'completed' : 'error',
+          resultSummary: verdict.text,
+        }),
+      );
+    }
+    if (verdict.verdict === 'absent') {
+      return settle(input.taskID, input.generation, () =>
+        applyConfirmedStop({
+          backgroundJobBoard: options.backgroundJobBoard,
+          taskID: input.taskID,
+          observedAt: input.observedAt,
+          generation: input.generation,
+          taskContextTracker: input.taskContextTracker,
+        }),
+      );
+    }
+
+    // Unknown evidence: bounded per-episode retries; NEVER terminates
+    // into the absence-asserting stop. After the budget, stay running +
+    // uncertain with an explicit observation-unavailable diagnostic.
+    const currentEpisode = job.stopConfirmationStartedAt;
+    const tracked = episodes.get(input.taskID);
+    const isNewEpisode =
+      !tracked ||
+      tracked.generation !== input.generation ||
+      tracked.episode !== currentEpisode;
+    const count = isNewEpisode ? 1 : tracked.count + 1;
+    episodes.set(input.taskID, {
+      generation: input.generation,
+      episode: currentEpisode,
+      count,
+    });
+    input.onRetry?.();
+    return options.backgroundJobBoard.markStatusUncertain(
+      input.taskID,
+      count > maxRetries
+        ? EVIDENCE_UNAVAILABLE_DIAGNOSTIC
+        : input.lastStatusError,
+      input.generation,
+      input.observedAt,
+    );
+  }
+
+  return {
+    confirm: (input) => {
+      if (disposed) return Promise.resolve(undefined);
+      // Join key includes the quiescence episode: callers from
+      // DIFFERENT episodes must not share a decision, even with
+      // identical generation/revision/baseline.
+      const key = `${input.taskID}:${input.generation}:${input.idleObservedAt}`;
+      const existing = inFlight.get(key);
+      if (existing) {
+        // Join the in-flight observation; every joined caller's retry
+        // arming must fire, not just the first.
+        existing.retryCallbacks.push(input.onRetry);
+        return existing.promise;
+      }
+      const entry = {
+        promise: undefined as unknown as Promise<
+          BackgroundJobRecord | undefined
+        >,
+        retryCallbacks: [input.onRetry],
+      };
+      entry.promise = confirmInternal({
+        ...input,
+        onRetry: () => {
+          for (const cb of entry.retryCallbacks) cb?.();
+        },
+      }).finally(() => {
+        if (inFlight.get(key) === entry) inFlight.delete(key);
+      });
+      inFlight.set(key, entry);
+      return entry.promise;
+    },
+    dispose: () => {
+      disposed = true;
+      episodes.clear();
+      inFlight.clear();
+    },
+  };
+}

+ 23 - 0
src/index.ts

@@ -46,6 +46,7 @@ import {
   SessionLifecycle,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
+import { createBackgroundFallbackHandoff } from './hooks/task-session-manager/fallback-observation-transfer';
 import { createRevivedRunTracker } from './hooks/task-session-manager/revived-run-tracker';
 import type { ToolLoopGuardHook } from './hooks/tool-loop-guard/hook';
 import { isMessageWithParts, type MessageWithParts } from './hooks/types';
@@ -618,6 +619,15 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Initialize foreground fallback manager for runtime model switching.
     // Agents without a chain (e.g. councillor, owned by CouncilManager) are
     // left alone — FG only aborts/re-prompts when it has a model to switch to.
+    // The observation handoff brackets the re-prompt admission for
+    // BACKGROUND children (false-stop incident): prepare() defers the stop
+    // gate before the await, admit() enrolls the run tracker after host
+    // acceptance, reject() withdraws on failure; see
+    // fallback-observation-transfer.ts.
+    const backgroundFallbackHandoff = createBackgroundFallbackHandoff({
+      backgroundJobBoard: backgroundJobCoordinator,
+      revivedRunTracker,
+    });
     foregroundFallback = new ForegroundFallbackManager(
       runtime.runtimeChains,
       runtime.fallback.enabled !== false,
@@ -631,6 +641,19 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         backgroundTaskConcurrency.migrateTask(sessionID, model),
       runtime.fallback.initialRetryDelayMs,
       runtime.fallback.retryDelayMs,
+      backgroundFallbackHandoff,
+      // Generation fence captured BEFORE any await in the fallback
+      // preparation, and ONLY for confirmed BACKGROUND children:
+      // undefined for foreground/unmanaged sessions means "observation
+      // handoff not applicable" — never a wildcard — so a stale-
+      // generation rejection can be distinguished from a legitimate
+      // foreground fallback.
+      (sessionID) => {
+        const record = backgroundJobCoordinator.get(sessionID);
+        return record?.state === 'running' && record.background === true
+          ? record.generation
+          : undefined;
+      },
     );
 
     deepworkCommandHook = createDeepworkCommandHook();

+ 68 - 3
src/tools/cancel-task.test.ts

@@ -13,6 +13,9 @@ function createTool(overrides?: {
   abort?: () => Promise<unknown>;
   status?: () => Promise<unknown>;
   shouldManageSession?: (sessionID: string) => boolean;
+  verifyAbortMs?: number;
+  abortRetryIntervalMs?: number;
+  stableStoppedMs?: number;
 }) {
   const board = new BackgroundJobBoard();
   const abort = mock(overrides?.abort ?? (async () => ({})));
@@ -27,9 +30,9 @@ function createTool(overrides?: {
     input: { directory: '/test/project' } as any,
     backgroundJobBoard: board,
     shouldManageSession: overrides?.shouldManageSession ?? (() => true),
-    verifyAbortMs: 10,
-    abortRetryIntervalMs: 0,
-    stableStoppedMs: 0,
+    verifyAbortMs: overrides?.verifyAbortMs ?? 10,
+    abortRetryIntervalMs: overrides?.abortRetryIntervalMs ?? 0,
+    stableStoppedMs: overrides?.stableStoppedMs ?? 0,
   });
   return { board, abort, status, deleteSession, taskCancel: tools.task_cancel };
 }
@@ -155,6 +158,68 @@ describe('task_cancel tool', () => {
     });
   });
 
+  test('a valid status map without an entry confirms quiescence after abort (activity-map contract)', async () => {
+    // False-stop incident follow-up: the host's status map REMOVES a
+    // session's entry when it goes idle, so "map received correctly, no
+    // entry for this session" is quiescence evidence — the old verify
+    // loop threw "did not stay stopped: unknown" on it.
+    const { board, taskCancel } = createTool({
+      status: async () => ({ data: {} }), // valid map, no ses_1 entry
+    });
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+
+    const output = await taskCancel.execute(
+      { task_id: 'ses_1', reason: 'obsolete' },
+      context,
+    );
+
+    expect(parseTaskStatusOutput(String(output))).toMatchObject({
+      taskID: 'ses_1',
+      state: 'cancelled',
+    });
+  });
+
+  test('busy between absences restarts the quiescence stability window', async () => {
+    // Deterministic reset proof: alternating
+    // absence/busy lookups never let the stability window mature —
+    // without the reset, the stale quiet timestamp from the FIRST
+    // absence would confirm once verifyAbortMs elapses. With the reset,
+    // every busy observation restarts the window and the cancel ends
+    // uncertain-running instead of falsely cancelled.
+    let lookups = 0;
+    const { board, taskCancel } = createTool({
+      verifyAbortMs: 150,
+      abortRetryIntervalMs: 0,
+      stableStoppedMs: 60,
+      status: async () => {
+        lookups += 1;
+        return lookups % 2 === 0
+          ? { data: { ses_1: { type: 'busy' } } }
+          : { data: {} };
+      },
+    });
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+
+    const output = await taskCancel.execute(
+      { task_id: 'ses_1', reason: 'obsolete' },
+      context,
+    );
+
+    expect(String(output)).toContain('state: running');
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+    });
+  });
+
   test('retains the session and leaves it resumable after acknowledgement', async () => {
     const { board, deleteSession, taskCancel } = createTool();
     board.registerLaunch({

+ 8 - 1
src/tools/cancel-task.ts

@@ -245,7 +245,14 @@ async function verifyQuiescentSession(
       break;
     }
     lastStatus = status.status;
-    const quiescent = status.status === 'idle';
+    // Activity-map contract (verified on the host core): entries are
+    // REMOVED when a session goes idle, so a valid status map without an
+    // entry for this session is quiescence evidence — not a failed
+    // lookup. Explicit busy/retry entries and real failures (lookup
+    // error, malformed entry) still refuse to confirm.
+    const quiescent =
+      status.status === 'idle' ||
+      (status.status === undefined && status.source === 'missing-from-map');
     if (!quiescent) {
       stableStoppedSince = undefined;
       await delay(retryIntervalMs);

+ 34 - 6
src/utils/child-transcript.ts

@@ -110,6 +110,8 @@ interface LooseMessage {
   parts?: unknown[];
 }
 
+export type TranscriptMessage = LooseMessage;
+
 export function extractChildTerminalEvidence(
   response: unknown,
   options: ChildTranscriptOptions = {},
@@ -147,10 +149,40 @@ export function extractChildTerminalEvidence(
     if (targetIndex < 0) return { kind: 'no-assistant' };
   }
 
+  return classifyAssistantTurnEvidence(
+    messages,
+    targetIndex,
+    baselineIndex,
+    options.requireCompletionTime ?? true,
+  );
+}
+
+/**
+ * Single source of truth for classifying ONE assistant turn as the
+ * terminal evidence of a run: pending finish states, completion time,
+ * segment-wide pending tool calls, terminal error precedence, and
+ * usable text. Both the revived-run tracker probe and the stop gate's
+ * evidence classifier delegate here so their terminality contracts
+ * cannot diverge (a second independent classifier had already dropped
+ * the pending-tool rule).
+ */
+export function classifyAssistantTurnEvidence(
+  messages: TranscriptMessage[],
+  targetIndex: number,
+  baselineIndex: number,
+  requireCompletionTime = true,
+): ChildTerminalEvidence {
   const last = messages[targetIndex];
-  if (last.info?.role !== 'assistant') return { kind: 'no-assistant' };
+  if (!last || last.info?.role !== 'assistant') return { kind: 'no-assistant' };
+
+  // Terminal error precedence: an assistant turn that carries a
+  // terminal error is an error EVEN when a residual `finish` value
+  // (e.g. 'tool-calls'/'unknown') survived the failure — the error is
+  // the outcome, the finish flag is leftover state.
+  if (last.info?.error !== undefined && last.info?.error !== null) {
+    return { kind: 'error', errorText: stringifyError(last.info.error) };
+  }
 
-  const requireCompletionTime = options.requireCompletionTime ?? true;
   const finish = last.info?.finish;
   if (finish === 'tool-calls' || finish === 'unknown') {
     return { kind: 'pending' };
@@ -176,10 +208,6 @@ export function extractChildTerminalEvidence(
   );
   if (hasPendingToolCall) return { kind: 'pending' };
 
-  if (last.info?.error !== undefined && last.info?.error !== null) {
-    return { kind: 'error', errorText: stringifyError(last.info.error) };
-  }
-
   const text = (Array.isArray(last.parts) ? last.parts : [])
     .filter(
       (part) =>