Explorar el Código

Merge origin/master into feat/v203-gated-adoption

Alvin Unreal hace 1 día
padre
commit
df4baf142c

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

@@ -133,6 +133,13 @@ function createScheduler(options?: {
   hasInputWait?: (id: string) => boolean;
   hasInputWait?: (id: string) => boolean;
   isFallbackInProgress?: (id: string) => boolean;
   isFallbackInProgress?: (id: string) => boolean;
   isStoppedJobRecoveryCurrent?: (taskID: string, generation: number) => boolean;
   isStoppedJobRecoveryCurrent?: (taskID: string, generation: number) => boolean;
+  hasPendingDelegatedWork?: (id: string) => boolean;
+  resolveSelection?: (sessionID: string) => Promise<{
+    agent?: string;
+    model?: { providerID: string; modelID: string };
+    variant?: string;
+    provenance: 'host-persisted' | 'observed-external' | 'unknown';
+  }>;
   coordinator?: SessionLifecycle;
   coordinator?: SessionLifecycle;
   directory?: string;
   directory?: string;
 }) {
 }) {
@@ -155,6 +162,8 @@ function createScheduler(options?: {
     hasInputWait: options?.hasInputWait ?? (() => false),
     hasInputWait: options?.hasInputWait ?? (() => false),
     isFallbackInProgress: options?.isFallbackInProgress,
     isFallbackInProgress: options?.isFallbackInProgress,
     isStoppedJobRecoveryCurrent: options?.isStoppedJobRecoveryCurrent,
     isStoppedJobRecoveryCurrent: options?.isStoppedJobRecoveryCurrent,
+    hasPendingDelegatedWork: options?.hasPendingDelegatedWork,
+    resolveSelection: options?.resolveSelection,
     coordinator: options?.coordinator,
     coordinator: options?.coordinator,
   });
   });
 
 
@@ -1784,6 +1793,43 @@ describe('children-driven degraded mode (v2)', () => {
     expect(call.modelVariant).toBe('max');
     expect(call.modelVariant).toBe('max');
   });
   });
 
 
+  test('v2 children wake does not mix a new model with a leftover variant', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      resolveSelection: async () => ({
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-b' },
+        provenance: 'host-persisted',
+      }),
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1', time: { updated: Date.now() } }],
+        get: mock(async () => ({
+          data: {
+            model: { providerID: 'test', id: 'model-a', variant: 'high' },
+          },
+        })),
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[Record<string, unknown>]>
+    )[0]?.[0] as {
+      modelVariant?: string;
+      body: { model?: { providerID: string; modelID: string } };
+    };
+    expect(call.body.model).toEqual({
+      providerID: 'test',
+      modelID: 'model-b',
+    });
+    expect(call.modelVariant).toBeUndefined();
+  });
+
   test('v2 children wake omits modelVariant when the model has none', async () => {
   test('v2 children wake omits modelVariant when the model has none', async () => {
     const promptAsync = mock(async () => ({}));
     const promptAsync = mock(async () => ({}));
     const { scheduler } = createScheduler({
     const { scheduler } = createScheduler({
@@ -2417,6 +2463,206 @@ describe('children enumeration fallback (v2)', () => {
     await clock.advance(60_000);
     await clock.advance(60_000);
     expect(promptAsync).toHaveBeenCalledTimes(1);
     expect(promptAsync).toHaveBeenCalledTimes(1);
   });
   });
+
+  test('wakes a non-orchestrator parent in its current selection when delegated work is pending', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      shouldManageSession: (id) => id === 'orch',
+      hasPendingDelegatedWork: (id) => id === 'plan',
+      resolveSelection: async () => ({
+        agent: 'plan',
+        model: { providerID: 'test', modelID: 'plan-model' },
+        variant: 'max',
+        provenance: 'host-persisted',
+      }),
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'plan' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[Record<string, unknown>]>
+    )[0]?.[0] as {
+      body: {
+        agent: string;
+        model?: { providerID: string; modelID: string };
+      };
+    };
+    expect(call.body.agent).toBe('plan');
+    expect(call.body.model).toEqual({
+      providerID: 'test',
+      modelID: 'plan-model',
+    });
+  });
+
+  test('does not wake a non-orchestrator parent with no delegated work', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      shouldManageSession: (id) => id === 'orch',
+      hasPendingDelegatedWork: () => false,
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'plan' } },
+    });
+    await clock.advance(120_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('recovers a stopped job on a Plan parent with pending delegated work', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      shouldManageSession: () => false,
+      hasPendingDelegatedWork: (id) => id === 'plan',
+      resolveSelection: async () => ({
+        agent: 'plan',
+        provenance: 'observed-external',
+      }),
+      sessionClient: makeClient({
+        todos: [],
+        promptAsync,
+        childrenData: [{ id: 'child-2' }],
+        statusData: { 'child-2': { type: 'busy' } },
+      }),
+    });
+    scheduler.triggerStoppedJobRecovery('plan');
+    await clock.advance(0);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[Record<string, unknown>]>
+    )[0]?.[0] as { body: { agent: string } };
+    expect(call.body.agent).toBe('plan');
+  });
+
+  test('does not mix a new model with a leftover variant from another model', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      shouldManageSession: () => true,
+      resolveSelection: async () => ({
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-b' },
+        provenance: 'host-persisted',
+      }),
+      sessionClient: makeClient({
+        promptAsync,
+        model: { providerID: 'test', id: 'model-a', variant: 'high' },
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[Record<string, unknown>]>
+    )[0]?.[0] as {
+      modelVariant?: string;
+      body: { model?: { providerID: string; modelID: string } };
+    };
+    expect(call.body.model).toEqual({
+      providerID: 'test',
+      modelID: 'model-b',
+    });
+    expect(call.modelVariant).toBeUndefined();
+  });
+
+  test('aborts the wake when an external message arrives during selection resolve', async () => {
+    const promptAsync = mock(async () => ({}));
+    let release: (() => void) | undefined;
+    const gate = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+    const { scheduler } = createScheduler({
+      shouldManageSession: () => true,
+      resolveSelection: async () => {
+        await gate;
+        return { agent: 'orchestrator', provenance: 'host-persisted' };
+      },
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    scheduler.observeChatMessage(
+      {
+        sessionID: 'p1',
+        messageID: 'm-user',
+        model: { providerID: 'obs', modelID: 'seen' },
+      },
+      {
+        message: { id: 'm-user', role: 'user', sessionID: 'p1' },
+        parts: [{ type: 'text', text: 'user typed' }],
+      },
+    );
+    release?.();
+    await Promise.resolve();
+    await Promise.resolve();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not wake Plan when host selection is Plan and no delegated work remains', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      shouldManageSession: () => true,
+      hasPendingDelegatedWork: () => false,
+      resolveSelection: async () => ({
+        agent: 'plan',
+        provenance: 'host-persisted',
+      }),
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('drops a stop fact that goes stale during selection resolve', async () => {
+    const promptAsync = mock(async () => ({}));
+    let release: (() => void) | undefined;
+    const gate = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+    const current = new Set(['ses_stale:1']);
+    const { scheduler } = createScheduler({
+      isStoppedJobRecoveryCurrent: (taskID, generation) =>
+        current.has(`${taskID}:${generation}`),
+      hasPendingDelegatedWork: () => true,
+      resolveSelection: async () => {
+        await gate;
+        return { agent: 'orchestrator', provenance: 'host-persisted' };
+      },
+      sessionClient: makeClient({
+        todos: [],
+        promptAsync,
+        childrenData: [{ id: 'child-2' }],
+        statusData: { 'child-2': { type: 'busy' } },
+      }),
+    });
+    scheduler.triggerStoppedJobRecovery(
+      'p1',
+      formatStoppedJobDelta({
+        alias: 'ses_stale',
+        taskID: 'ses_stale',
+        generation: 1,
+        state: 'stopped',
+        reason: 'stopped without a terminal result',
+      }),
+      'ses_stale:1',
+    );
+    await clock.advance(0);
+    current.delete('ses_stale:1');
+    release?.();
+    // Drain microtasks past the resolver continuation, the post-await
+    // guards and the second prune before asserting the negative (#1079
+    // Oracle r3 P2: two ticks could observe the pre-await state).
+    for (let i = 0; i < 20; i += 1) await Promise.resolve();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
 });
 });
 
 
 describe('children mode on v1 (explicit opt-in)', () => {
 describe('children mode on v1 (explicit opt-in)', () => {

+ 132 - 35
src/hooks/orchestrator-wake/index.ts

@@ -26,6 +26,7 @@ import {
 } from '../../utils';
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
+import type { SessionSelection } from '../../utils/session-selection';
 import type { SessionLifecycle } from '../session-lifecycle';
 import type { SessionLifecycle } from '../session-lifecycle';
 import {
 import {
   type ContinuationModelSelection,
   type ContinuationModelSelection,
@@ -166,6 +167,17 @@ export type OrchestratorWakeOptions = {
    * The callback must check both the task generation and that the current
    * The callback must check both the task generation and that the current
    * record is still stopped and terminal-unreconciled. */
    * record is still stopped and terminal-unreconciled. */
   isStoppedJobRecoveryCurrent?: (taskID: string, generation: number) => boolean;
   isStoppedJobRecoveryCurrent?: (taskID: string, generation: number) => boolean;
+  /** Resolve the session's CURRENT agent/model selection at send time
+   * (#1079): a lifecycle wake must continue the parent in the mode the
+   * session uses now, never a hardcoded `orchestrator`. When absent or
+   * unresolved, behavior falls back to the historical orchestrator wake. */
+  resolveSelection?: (sessionID: string) => Promise<SessionSelection>;
+  /** True when the parent session has delegated work pending: live
+   * children or terminal-unreconciled records (#1079). In that state a
+   * lifecycle wake stays eligible even when the user switched the
+   * session to a non-orchestrator agent — the wake continues in the
+   * CURRENT selection instead of forcing `orchestrator`. */
+  hasPendingDelegatedWork?: (sessionID: string) => boolean;
   /** Test seam: override interval without changing config validation. */
   /** Test seam: override interval without changing config validation. */
   intervalMs?: number;
   intervalMs?: number;
 };
 };
@@ -541,6 +553,35 @@ export function createOrchestratorWakeScheduler(
       : undefined;
       : undefined;
   }
   }
 
 
+  /** Drop stop facts that are no longer current. Repeat after every
+   * await so a child revived during selection resolve is not sent. */
+  function pruneStoppedRecoveryDeltas(
+    batch: PendingStoppedRecovery | undefined,
+  ): boolean {
+    if (!batch) return false;
+    const hadRecoveryDetails = batch.deltas.size > 0;
+    if (options.isStoppedJobRecoveryCurrent) {
+      for (const key of batch.deltas.keys()) {
+        const parsed = parseRecoveryKey(key);
+        if (!parsed) {
+          batch.deltas.delete(key);
+          continue;
+        }
+        let current = false;
+        try {
+          current = options.isStoppedJobRecoveryCurrent(
+            parsed.taskID,
+            parsed.generation,
+          );
+        } catch {
+          current = false;
+        }
+        if (!current) batch.deltas.delete(key);
+      }
+    }
+    return hadRecoveryDetails;
+  }
+
   /** Queue a stop delta for the session's next recovery wake. */
   /** Queue a stop delta for the session's next recovery wake. */
   const addStoppedRecoveryDelta = (
   const addStoppedRecoveryDelta = (
     sessionID: string,
     sessionID: string,
@@ -717,10 +758,23 @@ export function createOrchestratorWakeScheduler(
     if (rearmProgress) rearmWakeProgress(sessionID);
     if (rearmProgress) rearmWakeProgress(sessionID);
   }
   }
 
 
+  /** #1079: a parent with delegated work pending stays wake-eligible
+   * even after the user switched it to a non-orchestrator agent — the
+   * wake then continues in the CURRENT selection (resolveSelection)
+   * instead of being dropped or forcing `orchestrator`. Without
+   * pending delegated work, only orchestrator sessions schedule wakes
+   * (a random Plan/Build TODO must not become a wake reason). */
+  function canObserveSelection(sessionID: string): boolean {
+    return (
+      options.shouldManageSession(sessionID) ||
+      (options.hasPendingDelegatedWork?.(sessionID) ?? false)
+    );
+  }
+
   function canSchedule(sessionID: string): boolean {
   function canSchedule(sessionID: string): boolean {
     if (!enabled) return false;
     if (!enabled) return false;
     if (!capabilities.ready) return false;
     if (!capabilities.ready) return false;
-    if (!options.shouldManageSession(sessionID)) return false;
+    if (!canObserveSelection(sessionID)) return false;
     if (localSessions.get(sessionID)?.archived) return false;
     if (localSessions.get(sessionID)?.archived) return false;
     if (options.hasInputWait(sessionID)) return false;
     if (options.hasInputWait(sessionID)) return false;
     if (options.isFallbackInProgress?.(sessionID)) return false;
     if (options.isFallbackInProgress?.(sessionID)) return false;
@@ -1231,26 +1285,7 @@ export function createOrchestratorWakeScheduler(
         ? pendingStoppedRecoveries.get(sessionID)
         ? pendingStoppedRecoveries.get(sessionID)
         : undefined;
         : undefined;
       if (recoveryBatch) {
       if (recoveryBatch) {
-        const hadRecoveryDetails = recoveryBatch.deltas.size > 0;
-        if (options.isStoppedJobRecoveryCurrent) {
-          for (const key of recoveryBatch.deltas.keys()) {
-            const parsed = parseRecoveryKey(key);
-            if (!parsed) {
-              recoveryBatch.deltas.delete(key);
-              continue;
-            }
-            let current = false;
-            try {
-              current = options.isStoppedJobRecoveryCurrent(
-                parsed.taskID,
-                parsed.generation,
-              );
-            } catch {
-              current = false;
-            }
-            if (!current) recoveryBatch.deltas.delete(key);
-          }
-        }
+        const hadRecoveryDetails = pruneStoppedRecoveryDeltas(recoveryBatch);
         // A stale, revived, or already-reconciled detail must not cause a
         // A stale, revived, or already-reconciled detail must not cause a
         // recovery wake by itself. An overflow marker remains actionable even
         // recovery wake by itself. An overflow marker remains actionable even
         // when all retained details have since gone stale.
         // when all retained details have since gone stale.
@@ -1267,6 +1302,50 @@ export function createOrchestratorWakeScheduler(
       const modelSelection =
       const modelSelection =
         latest.model ?? snapshot.model ?? getObservedWakeModel(sessionID);
         latest.model ?? snapshot.model ?? getObservedWakeModel(sessionID);
 
 
+      // #1079: resolve the session's CURRENT selection at send time. A
+      // lifecycle wake continues the parent in the agent/model it uses
+      // now; `orchestrator` is only the fallback when nothing else is
+      // observable (matching the historical behavior for sessions that
+      // always ran orchestrator).
+      const selection = options.resolveSelection
+        ? await options.resolveSelection(sessionID).catch(() => undefined)
+        : undefined;
+      // The await above is a new race window: an external message can
+      // bump generation / end idle, and a Plan/Build host selection
+      // must not ride in on stale orchestrator metadata.
+      if (state.generation !== generation) return;
+      if (!state.continuousIdle) return;
+      if (!canSchedule(sessionID)) {
+        suppress(sessionID);
+        return;
+      }
+      const wakeAgent = selection?.agent ?? 'orchestrator';
+      if (
+        wakeAgent !== 'orchestrator' &&
+        !(options.hasPendingDelegatedWork?.(sessionID) ?? false)
+      ) {
+        return;
+      }
+      // Re-prune stop facts after the selection await: a child can leave
+      // stopped/unreconciled while parent generation stays put (#1079 r2).
+      if (recoveryBatch) {
+        const hadRecoveryDetails = pruneStoppedRecoveryDeltas(recoveryBatch);
+        if (
+          hadRecoveryDetails &&
+          recoveryBatch.deltas.size === 0 &&
+          recoveryBatch.overflowCount === 0
+        ) {
+          pendingStoppedRecoveries.delete(sessionID);
+          return;
+        }
+      }
+      // Keep model+variant as one selection. Mixing a new model with a
+      // leftover variant from another model produces B/max from A/max.
+      const wakeModel = selection?.model ?? modelSelection?.model;
+      const wakeVariant = selection?.model
+        ? selection.variant
+        : modelSelection?.variant;
+
       // Reserve before promptAsync so a failed call cannot storm retries and
       // Reserve before promptAsync so a failed call cannot storm retries and
       // concurrent hook instances cannot double-wake.
       // concurrent hook instances cannot double-wake.
       if (!commitWakeReservation(sessionID, owner, latestFingerprint)) {
       if (!commitWakeReservation(sessionID, owner, latestFingerprint)) {
@@ -1297,8 +1376,8 @@ export function createOrchestratorWakeScheduler(
         .filter(Boolean)
         .filter(Boolean)
         .join('\n');
         .join('\n');
       const body = {
       const body = {
-        agent: 'orchestrator',
-        ...(modelSelection ? { model: modelSelection.model } : {}),
+        agent: wakeAgent,
+        ...(wakeModel ? { model: wakeModel } : {}),
         parts: [
         parts: [
           createInternalAgentTextPart(
           createInternalAgentTextPart(
             recoveryDetails ? `${wakeText}\n${recoveryDetails}` : wakeText,
             recoveryDetails ? `${wakeText}\n${recoveryDetails}` : wakeText,
@@ -1311,6 +1390,9 @@ export function createOrchestratorWakeScheduler(
         // slot, so the wake model's reasoning-effort variant travels as the
         // slot, so the wake model's reasoning-effort variant travels as the
         // v2-only `modelVariant`; the shim merges it into the switchModel
         // v2-only `modelVariant`; the shim merges it into the switchModel
         // ref. Absent variant leaves the call shape unchanged.
         // ref. Absent variant leaves the call shape unchanged.
+        // `modelSelection: 'inherit'` marks this as a lifecycle
+        // continuation (#1079): the v2 shim takes the host's persisted
+        // selection instead of re-pinning this snapshot model.
         await (
         await (
           sessionSdk.promptAsync as (
           sessionSdk.promptAsync as (
             args: Record<string, unknown>,
             args: Record<string, unknown>,
@@ -1320,16 +1402,26 @@ export function createOrchestratorWakeScheduler(
           query: { directory },
           query: { directory },
           body,
           body,
           delivery: 'queue',
           delivery: 'queue',
-          ...(modelSelection?.variant
-            ? { modelVariant: modelSelection.variant }
-            : {}),
+          modelSelection: 'inherit',
+          ...(wakeVariant ? { modelVariant: wakeVariant } : {}),
           throwOnError: true,
           throwOnError: true,
         });
         });
       } else {
       } else {
-        await sessionSdk.promptAsync({
+        // v1 path: the send-time-resolved body model is applied directly
+        // by the host (no switchModel, so no stale-pin revert race). The
+        // cast drops nothing on v1 — the SDK discards unknown root fields
+        // (same RequestInit path as `delivery`, #1192) — while v2 hosts
+        // in todo mode read `modelSelection` and get the same
+        // lifecycle-inherit semantics as children mode.
+        await (
+          sessionSdk.promptAsync as (
+            args: Record<string, unknown>,
+          ) => Promise<unknown>
+        )({
           path: { id: sessionID },
           path: { id: sessionID },
           query: { directory },
           query: { directory },
           body,
           body,
+          modelSelection: 'inherit',
           throwOnError: true,
           throwOnError: true,
         });
         });
       }
       }
@@ -1395,7 +1487,6 @@ export function createOrchestratorWakeScheduler(
       !sessionID ||
       !sessionID ||
       (typeof outputMessage?.role === 'string' &&
       (typeof outputMessage?.role === 'string' &&
         outputMessage.role !== 'user') ||
         outputMessage.role !== 'user') ||
-      !options.shouldManageSession(sessionID) ||
       !Array.isArray(parts) ||
       !Array.isArray(parts) ||
       parts.some(isInternalInitiatorPart) ||
       parts.some(isInternalInitiatorPart) ||
       !parts.some(
       !parts.some(
@@ -1406,7 +1497,13 @@ export function createOrchestratorWakeScheduler(
           ((part.type === 'text' && typeof part.text === 'string') ||
           ((part.type === 'text' && typeof part.text === 'string') ||
             part.type === 'file' ||
             part.type === 'file' ||
             part.type === 'image'),
             part.type === 'image'),
-      )
+      ) ||
+      // #1079: observe external selections for ANY wake-eligible session
+      // (orchestrator OR a parent with pending delegated work). Gating on
+      // orchestrator identity alone would leave a Plan/Build parent's
+      // observed model stale — and that parent can now be woken in its
+      // CURRENT agent.
+      !canObserveSelection(sessionID)
     ) {
     ) {
       return;
       return;
     }
     }
@@ -1446,7 +1543,7 @@ export function createOrchestratorWakeScheduler(
       disposed ||
       disposed ||
       !enabled ||
       !enabled ||
       !capabilities.ready ||
       !capabilities.ready ||
-      !options.shouldManageSession(sessionID)
+      !canObserveSelection(sessionID)
     ) {
     ) {
       return;
       return;
     }
     }
@@ -1510,7 +1607,7 @@ export function createOrchestratorWakeScheduler(
     if (!sessionID) return;
     if (!sessionID) return;
 
 
     if (type === 'session.updated') {
     if (type === 'session.updated') {
-      if (options.shouldManageSession(sessionID)) {
+      if (canObserveSelection(sessionID)) {
         const archiveState = readEventArchiveState(input.event);
         const archiveState = readEventArchiveState(input.event);
         if (archiveState === true) {
         if (archiveState === true) {
           suppressArchivedSession(sessionID);
           suppressArchivedSession(sessionID);
@@ -1547,14 +1644,14 @@ export function createOrchestratorWakeScheduler(
     }
     }
 
 
     if (isInputWaitAskEvent(type)) {
     if (isInputWaitAskEvent(type)) {
-      if (options.shouldManageSession(sessionID)) {
+      if (canObserveSelection(sessionID)) {
         suppress(sessionID);
         suppress(sessionID);
       }
       }
       return;
       return;
     }
     }
 
 
     if (isIdleEvent(type, properties)) {
     if (isIdleEvent(type, properties)) {
-      if (options.shouldManageSession(sessionID)) {
+      if (canObserveSelection(sessionID)) {
         clearExpectingWakeBusy(sessionID);
         clearExpectingWakeBusy(sessionID);
         if (pendingStoppedRecoveries.has(sessionID)) {
         if (pendingStoppedRecoveries.has(sessionID)) {
           if (localSessions.get(sessionID)?.archived) {
           if (localSessions.get(sessionID)?.archived) {
@@ -1570,7 +1667,7 @@ export function createOrchestratorWakeScheduler(
     }
     }
 
 
     if (isBusyEvent(type, properties)) {
     if (isBusyEvent(type, properties)) {
-      if (options.shouldManageSession(sessionID)) {
+      if (canObserveSelection(sessionID)) {
         // Wake-initiated busy preserves the no-progress cap; external busy rearms.
         // Wake-initiated busy preserves the no-progress cap; external busy rearms.
         const wakeBusy = isExpectingWakeBusy(sessionID);
         const wakeBusy = isExpectingWakeBusy(sessionID);
         endIdleSpell(sessionID, !wakeBusy);
         endIdleSpell(sessionID, !wakeBusy);
@@ -1584,7 +1681,7 @@ export function createOrchestratorWakeScheduler(
         properties?.status?.type !== 'idle' &&
         properties?.status?.type !== 'idle' &&
         properties?.status?.type !== 'busy')
         properties?.status?.type !== 'busy')
     ) {
     ) {
-      if (options.shouldManageSession(sessionID)) {
+      if (canObserveSelection(sessionID)) {
         // Errors / retry are external lifecycle — rearm.
         // Errors / retry are external lifecycle — rearm.
         clearExpectingWakeBusy(sessionID);
         clearExpectingWakeBusy(sessionID);
         endIdleSpell(sessionID, true);
         endIdleSpell(sessionID, true);

+ 223 - 17
src/hooks/task-session-manager/revived-run-tracker.test.ts

@@ -7,7 +7,15 @@ function createHarness(
   messages: () => unknown,
   messages: () => unknown,
   prompt = mock(async () => ({})),
   prompt = mock(async () => ({})),
   assertBound = false,
   assertBound = false,
-  options: { stabilizationProbeDelayMs?: number } = {},
+  options: {
+    stabilizationProbeDelayMs?: number;
+    resolveSelection?: (sessionID: string) => Promise<{
+      agent?: string;
+      model?: { providerID: string; modelID: string };
+      variant?: string;
+      provenance: 'host-persisted' | 'observed-external' | 'unknown';
+    }>;
+  } = {},
 ) {
 ) {
   const board = new BackgroundJobBoard();
   const board = new BackgroundJobBoard();
   board.registerLaunch({
   board.registerLaunch({
@@ -76,6 +84,35 @@ function createHarness(
 const realSetTimeout = globalThis.setTimeout;
 const realSetTimeout = globalThis.setTimeout;
 const realClearTimeout = globalThis.clearTimeout;
 const realClearTimeout = globalThis.clearTimeout;
 
 
+/** notifyParent is fire-and-forget from probe(); drain its microtasks. */
+async function flushNotify(): Promise<void> {
+  for (let i = 0; i < 15; i += 1) await Promise.resolve();
+}
+
+/** Toggle-able transcript: baseline only until `probe` flips true, then a
+ * completed assistant turn after the baseline. */
+function completedTranscript(
+  probe: () => boolean,
+  text = 'new result',
+): () => unknown {
+  return () =>
+    probe()
+      ? {
+          data: [
+            { info: { id: 'baseline', role: 'user' }, parts: [] },
+            {
+              info: {
+                id: 'assistant-1',
+                role: 'assistant',
+                time: { completed: 2 },
+              },
+              parts: [{ type: 'text', text }],
+            },
+          ],
+        }
+      : { data: [{ info: { id: 'baseline', role: 'user' }, parts: [] }] };
+}
+
 afterEach(() => {
 afterEach(() => {
   globalThis.setTimeout = realSetTimeout;
   globalThis.setTimeout = realSetTimeout;
   globalThis.clearTimeout = realClearTimeout;
   globalThis.clearTimeout = realClearTimeout;
@@ -85,22 +122,7 @@ describe('revived run tracker', () => {
   test('publishes a newer completed assistant turn and notifies the parent', async () => {
   test('publishes a newer completed assistant turn and notifies the parent', async () => {
     let probe = false;
     let probe = false;
     const harness = createHarness(
     const harness = createHarness(
-      () =>
-        probe
-          ? {
-              data: [
-                { info: { id: 'baseline', role: 'user' }, parts: [] },
-                {
-                  info: {
-                    id: 'assistant-1',
-                    role: 'assistant',
-                    time: { completed: 2 },
-                  },
-                  parts: [{ type: 'text', text: 'new result' }],
-                },
-              ],
-            }
-          : { data: [{ info: { id: 'baseline', role: 'user' }, parts: [] }] },
+      completedTranscript(() => probe),
       undefined,
       undefined,
       true,
       true,
     );
     );
@@ -145,6 +167,122 @@ describe('revived run tracker', () => {
     )?.body?.parts?.[0]?.text;
     )?.body?.parts?.[0]?.text;
     expect(notifiedText).toContain('<task ');
     expect(notifiedText).toContain('<task ');
     expect(notifiedText).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
     expect(notifiedText).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
+    expect(
+      (harness.prompt.mock.calls[0]?.[0] as { delivery?: string } | undefined)
+        ?.delivery,
+    ).toBe('queue');
+  });
+
+  test('notifies the parent in its current selection instead of hardcoded orchestrator', async () => {
+    let probe = false;
+    const harness = createHarness(
+      completedTranscript(() => probe),
+      undefined,
+      false,
+      {
+        resolveSelection: async () => ({
+          agent: 'plan',
+          model: { providerID: 'test', modelID: 'plan-model' },
+          provenance: 'host-persisted',
+        }),
+      },
+    );
+    const baseline = await harness.tracker.captureBaseline('ses_child');
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: baseline,
+      description: 'inspect the change',
+    });
+    probe = true;
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+    await flushNotify();
+
+    expect(harness.prompt.mock.calls[0]?.[0]).toMatchObject({
+      delivery: 'queue',
+      body: {
+        agent: 'plan',
+        model: { providerID: 'test', modelID: 'plan-model' },
+      },
+    });
+  });
+
+  test('forwards the resolved variant as modelVariant on the notification', async () => {
+    let probe = false;
+    const harness = createHarness(
+      completedTranscript(() => probe),
+      undefined,
+      false,
+      {
+        resolveSelection: async () => ({
+          agent: 'plan',
+          model: { providerID: 'test', modelID: 'plan-model' },
+          variant: 'max',
+          provenance: 'host-persisted',
+        }),
+      },
+    );
+    const baseline = await harness.tracker.captureBaseline('ses_child');
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: baseline,
+      description: 'inspect the change',
+    });
+    probe = true;
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+    await flushNotify();
+
+    expect(harness.prompt.mock.calls[0]?.[0]).toMatchObject({
+      delivery: 'queue',
+      modelVariant: 'max',
+      body: {
+        agent: 'plan',
+        model: { providerID: 'test', modelID: 'plan-model' },
+      },
+    });
+  });
+
+  test('does not send after dispose during selection resolve', async () => {
+    let probe = false;
+    let entered = false;
+    let release: (() => void) | undefined;
+    const gate = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+    const harness = createHarness(
+      completedTranscript(() => probe),
+      undefined,
+      false,
+      {
+        resolveSelection: async () => {
+          entered = true;
+          await gate;
+          return { agent: 'plan', provenance: 'host-persisted' };
+        },
+      },
+    );
+    const baseline = await harness.tracker.captureBaseline('ses_child');
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: baseline,
+      description: 'inspect the change',
+    });
+    probe = true;
+    const pending = harness.tracker.probe(
+      harness.run.taskID,
+      harness.run.generation,
+    );
+    for (let i = 0; i < 20 && !entered; i += 1) await Promise.resolve();
+    expect(entered).toBe(true);
+    harness.tracker.dispose();
+    release?.();
+    await pending;
+    expect(harness.prompt).not.toHaveBeenCalled();
   });
   });
 
 
   test('keeps a non-terminal idle turn running and rejects historical output', async () => {
   test('keeps a non-terminal idle turn running and rejects historical output', async () => {
@@ -327,6 +465,74 @@ describe('revived run tracker', () => {
     expect(prompt).toHaveBeenCalledTimes(2);
     expect(prompt).toHaveBeenCalledTimes(2);
   });
   });
 
 
+  test('re-resolves agent and model on each notification retry', async () => {
+    let attempts = 0;
+    const prompt = mock(async () => {
+      attempts += 1;
+      if (attempts === 1) throw new Error('parent unavailable');
+      return {};
+    });
+    const selections = [
+      {
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-a' },
+        provenance: 'host-persisted' as const,
+      },
+      {
+        agent: 'plan',
+        model: { providerID: 'test', modelID: 'model-b' },
+        provenance: 'host-persisted' as const,
+      },
+    ];
+    const harness = createHarness(
+      () => ({
+        data: [
+          { info: { id: 'baseline', role: 'user' }, parts: [] },
+          {
+            info: {
+              id: 'assistant-1',
+              role: 'assistant',
+              time: { completed: 2 },
+            },
+            parts: [{ type: 'text', text: 'done' }],
+          },
+        ],
+      }),
+      prompt,
+      false,
+      {
+        resolveSelection: async () =>
+          selections[Math.min(attempts, selections.length - 1)] ??
+          selections[0],
+      },
+    );
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+    await flushNotify();
+    await new Promise((resolve) => setTimeout(resolve, 5));
+    await flushNotify();
+
+    expect(harness.prompt).toHaveBeenCalledTimes(2);
+    expect(harness.prompt.mock.calls[0]?.[0]).toMatchObject({
+      body: {
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-a' },
+      },
+    });
+    expect(harness.prompt.mock.calls[1]?.[0]).toMatchObject({
+      body: {
+        agent: 'plan',
+        model: { providerID: 'test', modelID: 'model-b' },
+      },
+    });
+  });
+
   test('holds the terminal notification lease while parent transport is active', async () => {
   test('holds the terminal notification lease while parent transport is active', async () => {
     const harness = createHarness(() => ({ data: [] }));
     const harness = createHarness(() => ({ data: [] }));
     let relaunchLease: unknown;
     let relaunchLease: unknown;

+ 48 - 8
src/hooks/task-session-manager/revived-run-tracker.ts

@@ -15,6 +15,7 @@ import {
 import { isRecord } from '../../utils/guards';
 import { isRecord } from '../../utils/guards';
 import { createInternalAgentTextPart } from '../../utils/internal-initiator';
 import { createInternalAgentTextPart } from '../../utils/internal-initiator';
 import { getClient } from '../../utils/opencode-client';
 import { getClient } from '../../utils/opencode-client';
+import type { SessionSelection } from '../../utils/session-selection';
 import { COMPLETED_WITHOUT_TEXT_DIAGNOSTIC } from '../../utils/task';
 import { COMPLETED_WITHOUT_TEXT_DIAGNOSTIC } from '../../utils/task';
 
 
 const DEFAULT_NOTIFICATION_RETRIES = 3;
 const DEFAULT_NOTIFICATION_RETRIES = 3;
@@ -83,6 +84,12 @@ export function createRevivedRunTracker(options: {
   onSettled?: (taskID: string) => void;
   onSettled?: (taskID: string) => void;
   contextFilesForPrompt?: (taskID: string) => ContextFile[];
   contextFilesForPrompt?: (taskID: string) => ContextFile[];
   pruneContext?: () => void;
   pruneContext?: () => void;
+  /** Resolve the parent session's CURRENT agent/model selection at send
+   * time (#1079): a terminal notification must continue the parent in
+   * the mode the session uses now, never a hardcoded `orchestrator`.
+   * Resolved on EVERY attempt (retries re-enter the send path). When
+   * absent or unresolved, behavior falls back to `orchestrator`. */
+  resolveSelection?: (sessionID: string) => Promise<SessionSelection>;
 }): RevivedRunTracker {
 }): RevivedRunTracker {
   const runs = new Map<string, RevivedRun>();
   const runs = new Map<string, RevivedRun>();
   const maxNotificationRetries =
   const maxNotificationRetries =
@@ -269,6 +276,31 @@ export function createRevivedRunTracker(options: {
         discardRun(run);
         discardRun(run);
         return;
         return;
       }
       }
+      const state = record.state === 'completed' ? 'completed' : 'error';
+      const tag = state === 'completed' ? 'task_result' : 'task_error';
+      const summary =
+        state === 'completed'
+          ? `Background task completed: ${run.description}`
+          : `Background task failed: ${run.description}`;
+      // 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).
+      const selection = options.resolveSelection
+        ? await options
+            .resolveSelection(run.parentSessionID)
+            .catch((): undefined => undefined)
+        : undefined;
+      if (disposed || runs.get(run.taskID) !== run) return;
+      const latestBeforeSend = options.backgroundJobBoard.get(run.taskID);
+      if (
+        !latestBeforeSend ||
+        latestBeforeSend.generation !== run.generation ||
+        terminalOutcome(latestBeforeSend) !== run.terminalState
+      ) {
+        discardRun(run);
+        return;
+      }
       const lease = options.backgroundJobBoard.acquireTerminalNotificationLease(
       const lease = options.backgroundJobBoard.acquireTerminalNotificationLease(
         run.taskID,
         run.taskID,
         run.generation,
         run.generation,
@@ -277,12 +309,7 @@ export function createRevivedRunTracker(options: {
         scheduleNotificationRetry(run, record);
         scheduleNotificationRetry(run, record);
         return;
         return;
       }
       }
-      const state = record.state === 'completed' ? 'completed' : 'error';
-      const tag = state === 'completed' ? 'task_result' : 'task_error';
-      const summary =
-        state === 'completed'
-          ? `Background task completed: ${run.description}`
-          : `Background task failed: ${run.description}`;
+      const notifyAgent = selection?.agent ?? 'orchestrator';
       const text = [
       const text = [
         `<task id="${run.taskID}" state="${state}">`,
         `<task id="${run.taskID}" state="${state}">`,
         `<summary>${summary}</summary>`,
         `<summary>${summary}</summary>`,
@@ -296,11 +323,24 @@ export function createRevivedRunTracker(options: {
         options.backgroundJobBoard,
         options.backgroundJobBoard,
         lease,
         lease,
         () =>
         () =>
-          promptAsync({
+          (promptAsync as (args: Record<string, unknown>) => Promise<unknown>)({
             path: { id: run.parentSessionID },
             path: { id: run.parentSessionID },
             query: { directory: options.input.directory },
             query: { directory: options.input.directory },
+            // v1 prompt_async queues; 'queue' preserves that on v2 hosts
+            // ('steer' — the shim default — would hijack an in-flight
+            // parent run, the same TOCTOU #1192 closed for task-revive).
+            // Extra root fields are dropped by the v1 SDK RequestInit
+            // path (same pattern as task-revive #1192).
+            delivery: 'queue',
+            // Lifecycle continuation (#1079): on v2 the shim inherits the
+            // host's persisted selection instead of re-pinning the resolved
+            // snapshot model. On v1 the flag is dropped by the SDK and the
+            // explicit body model applies.
+            modelSelection: 'inherit',
+            ...(selection?.variant ? { modelVariant: selection.variant } : {}),
             body: {
             body: {
-              agent: 'orchestrator',
+              agent: notifyAgent,
+              ...(selection?.model ? { model: selection.model } : {}),
               // Internal-initiator part (synthetic flag + metadata + marker):
               // Internal-initiator part (synthetic flag + metadata + marker):
               // the v2 client-shim routes these through session.synthetic so
               // the v2 client-shim routes these through session.synthetic so
               // the notification stays machine-context instead of a visible
               // the notification stays machine-context instead of a visible

+ 172 - 20
src/index.test.ts

@@ -8,6 +8,7 @@ import pluginModuleDefault, {
   shouldEnableMultiplexer,
   shouldEnableMultiplexer,
 } from './index';
 } from './index';
 import { readTuiSnapshot } from './tui-state';
 import { readTuiSnapshot } from './tui-state';
+import { createInternalAgentTextPart } from './utils/internal-initiator';
 
 
 function createPluginClient(
 function createPluginClient(
   noop: () => Promise<unknown>,
   noop: () => Promise<unknown>,
@@ -555,6 +556,12 @@ describe('plugin TUI agent activity', () => {
       expect(readTuiSnapshot(projectDir).sessionParents).toEqual({});
       expect(readTuiSnapshot(projectDir).sessionParents).toEqual({});
 
 
       // A later activation must retry: the failed slot was released.
       // A later activation must retry: the failed slot was released.
+      await retryHooks?.event?.({
+        event: {
+          type: 'session.status',
+          properties: { sessionID: 'orphan-a', status: { type: 'busy' } },
+        },
+      } as never);
       await retryHooks?.['chat.message']?.(
       await retryHooks?.['chat.message']?.(
         { sessionID: 'orphan-a', agent: 'fixer' } as never,
         { sessionID: 'orphan-a', agent: 'fixer' } as never,
         {} as never,
         {} as never,
@@ -605,6 +612,12 @@ describe('plugin TUI agent activity', () => {
       expect(readTuiSnapshot(projectDir).sessionParents).toEqual({});
       expect(readTuiSnapshot(projectDir).sessionParents).toEqual({});
 
 
       // A later activation must retry: the malformed slot was released.
       // A later activation must retry: the malformed slot was released.
+      await malformedHooks?.event?.({
+        event: {
+          type: 'session.status',
+          properties: { sessionID: 'broken-a', status: { type: 'busy' } },
+        },
+      } as never);
       await malformedHooks?.['chat.message']?.(
       await malformedHooks?.['chat.message']?.(
         { sessionID: 'broken-a', agent: 'fixer' } as never,
         { sessionID: 'broken-a', agent: 'fixer' } as never,
         {} as never,
         {} as never,
@@ -743,24 +756,13 @@ describe('background task admission model resolution', () => {
     await rm(projectDir, { recursive: true, force: true });
     await rm(projectDir, { recursive: true, force: true });
   });
   });
 
 
-  test('chat.message records the session model so session-inheriting tasks queue behind the parent provider cap', async () => {
-    // chat.message fires before message.updated and carries the message's
-    // model. Without recording it, a session-inheriting fixer task would be
-    // admitted with no model (default tier, no provider cap).
-    await hooks?.['chat.message']?.(
-      {
-        sessionID: 'orchestrator-1',
-        agent: 'orchestrator',
-        model: { providerID: 'openai', modelID: 'gpt-4o' },
-      } as never,
-      {} as never,
-    );
-
+  /** Admit two session-inheriting fixer tasks; the second must stay
+   * queued behind the parent's single provider slot. */
+  async function expectSecondTaskQueued(sessionID: string): Promise<void> {
     const before = hooks?.['tool.execute.before'];
     const before = hooks?.['tool.execute.before'];
     expect(before).toBeFunction();
     expect(before).toBeFunction();
-
     const first = before?.(
     const first = before?.(
-      { tool: 'task', sessionID: 'orchestrator-1', callID: 'call-1' } as never,
+      { tool: 'task', sessionID, callID: 'call-1' } as never,
       {
       {
         args: {
         args: {
           background: true,
           background: true,
@@ -770,7 +772,7 @@ describe('background task admission model resolution', () => {
       } as never,
       } as never,
     );
     );
     const second = before?.(
     const second = before?.(
-      { tool: 'task', sessionID: 'orchestrator-1', callID: 'call-2' } as never,
+      { tool: 'task', sessionID, callID: 'call-2' } as never,
       {
       {
         args: {
         args: {
           background: true,
           background: true,
@@ -779,10 +781,7 @@ describe('background task admission model resolution', () => {
         },
         },
       } as never,
       } as never,
     );
     );
-
-    // The first fixer task holds the single openai slot (resolved from the
-    // parent's model recorded by chat.message); the second must stay queued.
-    // (Slot release happens via board terminal outcomes, out of scope here.)
+    // Slot release happens via board terminal outcomes, out of scope here.
     await first;
     await first;
     const outcome = await Promise.race([
     const outcome = await Promise.race([
       second?.then(
       second?.then(
@@ -794,6 +793,159 @@ describe('background task admission model resolution', () => {
       ),
       ),
     ]);
     ]);
     expect(outcome).toBe('still-queued');
     expect(outcome).toBe('still-queued');
+  }
+
+  test('chat.message records the session model so session-inheriting tasks queue behind the parent provider cap', async () => {
+    // chat.message fires before message.updated and carries the message's
+    // model. Without recording it, a session-inheriting fixer task would be
+    // admitted with no model (default tier, no provider cap).
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'orchestrator-1',
+        agent: 'orchestrator',
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
+      } as never,
+      {} as never,
+    );
+
+    await expectSecondTaskQueued('orchestrator-1');
+  });
+
+  test('internal initiator chat.message does not overwrite the tracked session model', async () => {
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'plan',
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
+      } as never,
+      {} as never,
+    );
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'orchestrator',
+        model: { providerID: 'anthropic', modelID: 'claude' },
+        parts: [createInternalAgentTextPart('child completed')],
+      } as never,
+      {} as never,
+    );
+
+    await expectSecondTaskQueued('plan-1');
+  });
+
+  test('message.updated of an internal admission does not overwrite the tracked model', async () => {
+    const { __resetInternalAdmissionsForTesting } = await import(
+      './v2/internal-admissions'
+    );
+    __resetInternalAdmissionsForTesting();
+
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'plan',
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
+      } as never,
+      {} as never,
+    );
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'orchestrator',
+        model: { providerID: 'anthropic', modelID: 'claude' },
+        messageID: 'msg_internal',
+        parts: [createInternalAgentTextPart('child completed')],
+      } as never,
+      {} as never,
+    );
+    await hooks?.event?.({
+      event: {
+        type: 'message.updated',
+        properties: {
+          info: {
+            id: 'msg_internal',
+            sessionID: 'plan-1',
+            agent: 'orchestrator',
+            providerID: 'anthropic',
+            modelID: 'claude',
+          },
+        },
+      },
+    } as never);
+
+    await expectSecondTaskQueued('plan-1');
+    __resetInternalAdmissionsForTesting();
+  });
+
+  test('message.updated of an assistant reply to an internal admission does not overwrite the tracked model', async () => {
+    const { __resetInternalAdmissionsForTesting } = await import(
+      './v2/internal-admissions'
+    );
+    __resetInternalAdmissionsForTesting();
+
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'plan',
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
+      } as never,
+      {} as never,
+    );
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'orchestrator',
+        model: { providerID: 'anthropic', modelID: 'claude' },
+        messageID: 'msg_internal',
+        parts: [createInternalAgentTextPart('child completed')],
+      } as never,
+      {} as never,
+    );
+    await hooks?.event?.({
+      event: {
+        type: 'message.updated',
+        properties: {
+          info: {
+            id: 'msg_assistant',
+            parentID: 'msg_internal',
+            sessionID: 'plan-1',
+            agent: 'orchestrator',
+            providerID: 'anthropic',
+            modelID: 'claude',
+          },
+        },
+      },
+    } as never);
+
+    await expectSecondTaskQueued('plan-1');
+    __resetInternalAdmissionsForTesting();
+  });
+
+  test('v2 agent-discovery without parts does not overwrite tracked selection', async () => {
+    const { recordInternalAdmission, __resetInternalAdmissionsForTesting } =
+      await import('./v2/internal-admissions');
+    __resetInternalAdmissionsForTesting();
+    recordInternalAdmission('plan-1', 'msg_discovery');
+
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'plan',
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
+      } as never,
+      {} as never,
+    );
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'plan-1',
+        agent: 'orchestrator',
+        model: { providerID: 'anthropic', modelID: 'claude' },
+        messageID: 'msg_discovery',
+      } as never,
+      {} as never,
+    );
+
+    await expectSecondTaskQueued('plan-1');
+    __resetInternalAdmissionsForTesting();
   });
   });
 });
 });
 
 

+ 85 - 15
src/index.ts

@@ -94,11 +94,20 @@ import {
 } from './utils';
 } from './utils';
 import type { ContextFile } from './utils/background-job-board';
 import type { ContextFile } from './utils/background-job-board';
 import { isPluginDisabledByEnv } from './utils/env';
 import { isPluginDisabledByEnv } from './utils/env';
+import { isInternalInitiatorPart } from './utils/internal-initiator';
 import { probeJSDOM } from './utils/jsdom';
 import { probeJSDOM } from './utils/jsdom';
 import { initLogger, log } from './utils/logger';
 import { initLogger, log } from './utils/logger';
 import { SessionMetadataStore } from './utils/session-metadata';
 import { SessionMetadataStore } from './utils/session-metadata';
+import {
+  createSessionSelectionReader,
+  resolveCurrentSelection,
+} from './utils/session-selection';
 import { collapseSystemInPlace } from './utils/system-collapse';
 import { collapseSystemInPlace } from './utils/system-collapse';
 import { createV2Setup } from './v2';
 import { createV2Setup } from './v2';
+import {
+  isInternalAdmission,
+  recordInternalAdmission,
+} from './v2/internal-admissions';
 
 
 /**
 /**
  * Best-effort log to opencode's app logger.
  * Best-effort log to opencode's app logger.
@@ -209,6 +218,21 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     },
     },
   });
   });
   const ownedTuiActivitySessions = new Map<string, string>();
   const ownedTuiActivitySessions = new Map<string, string>();
+  // #1079: lifecycle continuations (orchestrator wake, terminal
+  // notifications) resolve the session's CURRENT agent/model at send
+  // time instead of hardcoding `orchestrator`. Host-persisted selection
+  // wins; slim metadata (fed only by external admissions — see the
+  // chat.message filter) is the fallback.
+  const lifecycleSelectionReader = createSessionSelectionReader(
+    ctx.client,
+    ctx.directory,
+  );
+  const lifecycleSelectionResolver = (sessionID: string) =>
+    resolveCurrentSelection(
+      sessionID,
+      lifecycleSelectionReader,
+      sessionMetadata,
+    );
   // Busy/retry arrived before the session's agent was known. chat.message
   // Busy/retry arrived before the session's agent was known. chat.message
   // latches the agent and flushes these so the spinner still starts.
   // latches the agent and flushes these so the spinner still starts.
   const pendingTuiBusySessions = new Set<string>();
   const pendingTuiBusySessions = new Set<string>();
@@ -500,6 +524,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: ctx,
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobSupervisor,
       backgroundJobSupervisor,
+      resolveSelection: lifecycleSelectionResolver,
       onRegister: (taskID) => markRevivedRunPending(taskID),
       onRegister: (taskID) => markRevivedRunPending(taskID),
       onSettled: (taskID) => markRevivedRunSettled(taskID),
       onSettled: (taskID) => markRevivedRunSettled(taskID),
       contextFilesForPrompt: (taskID) => getRevivedContextFiles(taskID),
       contextFilesForPrompt: (taskID) => getRevivedContextFiles(taskID),
@@ -583,9 +608,11 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       sameProviderPolicy: runtime.backgroundJobs.sameProviderPolicy,
       sameProviderPolicy: runtime.backgroundJobs.sameProviderPolicy,
       getSessionModel: (sessionID) => sessionMetadata.getModel(sessionID),
       getSessionModel: (sessionID) => sessionMetadata.getModel(sessionID),
       shouldManageSession: (sessionID) =>
       shouldManageSession: (sessionID) =>
-        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator' ||
+        sessionMetadata.isTaskManaged(sessionID),
       registerSessionAsOrchestrator: (sessionID) => {
       registerSessionAsOrchestrator: (sessionID) => {
-        sessionMetadata.setAgent(sessionID, 'orchestrator');
+        // Membership in task management, not a selection rewrite (#1079).
+        sessionMetadata.markTaskManaged(sessionID);
       },
       },
       isFallbackInProgress: (sessionID) =>
       isFallbackInProgress: (sessionID) =>
         foregroundFallback.isFallbackInProgress(sessionID),
         foregroundFallback.isFallbackInProgress(sessionID),
@@ -607,6 +634,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         taskSessionManagerHook.hasInputWait(sessionID),
         taskSessionManagerHook.hasInputWait(sessionID),
       isFallbackInProgress: (sessionID) =>
       isFallbackInProgress: (sessionID) =>
         foregroundFallback.isFallbackInProgress(sessionID),
         foregroundFallback.isFallbackInProgress(sessionID),
+      resolveSelection: lifecycleSelectionResolver,
       isStoppedJobRecoveryCurrent: (taskID, generation) => {
       isStoppedJobRecoveryCurrent: (taskID, generation) => {
         const record = backgroundJobCoordinator.get(taskID);
         const record = backgroundJobCoordinator.get(taskID);
         return (
         return (
@@ -615,6 +643,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           record.terminalUnreconciled
           record.terminalUnreconciled
         );
         );
       },
       },
+      hasPendingDelegatedWork: (sessionID) =>
+        backgroundJobCoordinator.hasRunning(sessionID) ||
+        backgroundJobCoordinator.hasTerminalUnreconciled(sessionID),
       coordinator: sessionLifecycle,
       coordinator: sessionLifecycle,
     });
     });
     backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
     backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
@@ -714,7 +745,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: ctx,
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
       shouldManageSession: (sessionID) =>
-        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator' ||
+        sessionMetadata.isTaskManaged(sessionID),
     });
     });
     taskMessageTools = createTaskMessageTool({
     taskMessageTools = createTaskMessageTool({
       input: ctx,
       input: ctx,
@@ -728,7 +760,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: ctx,
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
       shouldManageSession: (sessionID) =>
-        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator' ||
+        sessionMetadata.isTaskManaged(sessionID),
       backgroundJobSupervisor,
       backgroundJobSupervisor,
       revivedRunTracker,
       revivedRunTracker,
     });
     });
@@ -739,10 +772,11 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
     });
     waitForUserTools = createWaitForUserTool({
     waitForUserTools = createWaitForUserTool({
       shouldManageSession: (sessionID) =>
       shouldManageSession: (sessionID) =>
-        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator' ||
+        sessionMetadata.isTaskManaged(sessionID),
       resolveAgentName: (agent) => resolveRuntimeAgentName(runtime, agent),
       resolveAgentName: (agent) => resolveRuntimeAgentName(runtime, agent),
       registerSessionAsOrchestrator: (sessionID) => {
       registerSessionAsOrchestrator: (sessionID) => {
-        sessionMetadata.setAgent(sessionID, 'orchestrator');
+        sessionMetadata.markTaskManaged(sessionID);
       },
       },
       beginUserWait: (sessionID) => {
       beginUserWait: (sessionID) => {
         taskSessionManagerHook.beginUserWait(sessionID);
         taskSessionManagerHook.beginUserWait(sessionID);
@@ -1288,12 +1322,17 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         // can resolve the model a model-less subagent will inherit.
         // can resolve the model a model-less subagent will inherit.
         if (typeof info?.sessionID === 'string' && providerID && modelID) {
         if (typeof info?.sessionID === 'string' && providerID && modelID) {
           const model = `${providerID}/${modelID}`;
           const model = `${providerID}/${modelID}`;
-          sessionMetadata.setModel(info.sessionID, model);
-          // Managed background-task sessions are identified by their session
-          // ID. If the model serving one changed (fallback re-prompt, runtime
-          // switch), migrate the admission accounting so provider/model caps
-          // keep tracking the model actually in use. No-op for other
-          // sessions and idempotent when the model is unchanged.
+          // Accounting/fallback follows the model actually executing.
+          // External selection tracking does not: a synthetic wake's
+          // message.updated must not poison Plan/Build metadata (#1079).
+          const internalAdmission =
+            (typeof info.id === 'string' &&
+              isInternalAdmission(info.sessionID, info.id)) ||
+            (typeof info.parentID === 'string' &&
+              isInternalAdmission(info.sessionID, info.parentID));
+          if (!internalAdmission) {
+            sessionMetadata.setModel(info.sessionID, model);
+          }
           backgroundTaskConcurrency.migrateTask(info.sessionID, model);
           backgroundTaskConcurrency.migrateTask(info.sessionID, model);
         }
         }
         if (typeof info?.agent === 'string' && providerID && modelID) {
         if (typeof info?.agent === 'string' && providerID && modelID) {
@@ -1553,9 +1592,39 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         output.message.agent = agent;
         output.message.agent = agent;
       }
       }
 
 
+      // #1079: internal admissions (lifecycle wakes, terminal
+      // notifications) must not overwrite the user's tracked selection.
+      // Without this filter, a synthetic orchestrator wake flips a
+      // Plan/Build session's tracked agent back to 'orchestrator' and
+      // task-management tooling keeps treating it as orchestrated.
+      // Inspect BOTH part surfaces: `input.parts ?? output.parts` would
+      // skip output when input carries an empty array. Also honor the
+      // v2 admission tracker — agent-discovery forwards agent/model
+      // without parts.
+      const messageID = input.messageID ?? output?.message?.id;
+      const inputParts = Array.isArray(input.parts) ? input.parts : [];
+      const outputParts = Array.isArray(output?.parts) ? output.parts : [];
+      const partsInternal = [...inputParts, ...outputParts].some((part) =>
+        isInternalInitiatorPart(part),
+      );
+      // v1 chat.message sees the internal parts but historically never
+      // recorded the message id, so the later message.updated could not
+      // classify the same admission (#1079 Oracle r2). Record it here
+      // so assistant replies (parentID) and message.updated share the
+      // registry the v2 shim already maintains.
+      if (partsInternal && typeof messageID === 'string') {
+        recordInternalAdmission(input.sessionID, messageID);
+      }
+      const internalAdmission =
+        partsInternal ||
+        (typeof messageID === 'string' &&
+          isInternalAdmission(input.sessionID, messageID));
+
       if (agent) {
       if (agent) {
         foregroundFallback.registerSessionAgent(input.sessionID, agent);
         foregroundFallback.registerSessionAgent(input.sessionID, agent);
-        sessionMetadata.setAgent(input.sessionID, agent);
+        if (!internalAdmission) {
+          sessionMetadata.setAgent(input.sessionID, agent);
+        }
         // Spinner follows session.status, not chat.message: v2 context
         // Spinner follows session.status, not chat.message: v2 context
         // hooks re-deliver chat.message after idle and would otherwise
         // hooks re-deliver chat.message after idle and would otherwise
         // relight a finished row (and the parent of a background child).
         // relight a finished row (and the parent of a background child).
@@ -1588,12 +1657,13 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         typeof messageModel.modelID === 'string'
         typeof messageModel.modelID === 'string'
       ) {
       ) {
         const model = `${messageModel.providerID}/${messageModel.modelID}`;
         const model = `${messageModel.providerID}/${messageModel.modelID}`;
-        sessionMetadata.setModel(input.sessionID, model);
+        if (!internalAdmission) {
+          sessionMetadata.setModel(input.sessionID, model);
+        }
         backgroundTaskConcurrency.migrateTask(input.sessionID, model);
         backgroundTaskConcurrency.migrateTask(input.sessionID, model);
       }
       }
       taskSessionManagerHook.observeChatMessage(input, output);
       taskSessionManagerHook.observeChatMessage(input, output);
       orchestratorWakeScheduler.observeChatMessage(input, output);
       orchestratorWakeScheduler.observeChatMessage(input, output);
-      const messageID = input.messageID ?? output?.message?.id;
       if (messageID) {
       if (messageID) {
         toolLoopGuard.observeNewUserMessage(input.sessionID, messageID);
         toolLoopGuard.observeNewUserMessage(input.sessionID, messageID);
       }
       }

+ 43 - 0
src/utils/session-metadata.test.ts

@@ -60,4 +60,47 @@ describe('SessionMetadataStore', () => {
     expect(store.hasAgent('old-session')).toBe(false);
     expect(store.hasAgent('old-session')).toBe(false);
     expect(evicted).toEqual(['old-session']);
     expect(evicted).toEqual(['old-session']);
   });
   });
+
+  test('task-managed membership does not rewrite the selected agent', () => {
+    const store = new SessionMetadataStore({ maxEntries: 4 });
+    store.setAgent('plan-1', 'plan');
+    store.markTaskManaged('plan-1');
+
+    expect(store.getAgent('plan-1')).toBe('plan');
+    expect(store.isTaskManaged('plan-1')).toBe(true);
+  });
+
+  test('keeps a task-managed Plan session through metadata overflow', () => {
+    const store = new SessionMetadataStore({ maxEntries: 2 });
+    store.setAgent('plan-1', 'plan');
+    store.markTaskManaged('plan-1');
+    store.setAgent('old-specialist', 'explore');
+    store.setAgent('newer-specialist', 'fixer');
+
+    expect(store.getAgent('plan-1')).toBe('plan');
+    expect(store.isTaskManaged('plan-1')).toBe(true);
+    expect(store.hasAgent('old-specialist')).toBe(false);
+  });
+
+  test('evicts the oldest task-managed session when nothing else is evictable', () => {
+    // Greptile P2 on #1195: membership is permanent, so a run of
+    // delegating parents must not grow the store past its bound. The cap
+    // is the last line of defense against missed deletion events and must
+    // stay enforceable.
+    const store = new SessionMetadataStore({ maxEntries: 2 });
+    store.setAgent('parent-a', 'plan');
+    store.markTaskManaged('parent-a');
+    store.setAgent('parent-b', 'plan');
+    store.markTaskManaged('parent-b');
+    // A third parent delegates without any prior metadata (the
+    // registerSessionAsOrchestrator path); the store is at capacity with
+    // only protected entries, so the OLDEST task-managed entry goes.
+    store.markTaskManaged('parent-c');
+
+    expect(store.size).toBe(2);
+    expect(store.hasAgent('parent-a')).toBe(false);
+    expect(store.isTaskManaged('parent-a')).toBe(false);
+    expect(store.getAgent('parent-b')).toBe('plan');
+    expect(store.isTaskManaged('parent-c')).toBe(true);
+  });
 });
 });

+ 37 - 3
src/utils/session-metadata.ts

@@ -6,6 +6,10 @@ export class SessionMetadataStore {
   readonly #directories = new Map<string, string>();
   readonly #directories = new Map<string, string>();
   readonly #insertionOrder = new Map<string, undefined>();
   readonly #insertionOrder = new Map<string, undefined>();
   readonly #activeOrchestratorSessionIDs = new Set<string>();
   readonly #activeOrchestratorSessionIDs = new Set<string>();
+  /** Sessions that dispatched background work. Distinct from the user's
+   * current agent selection (#1079): a Plan/Build parent that called
+   * `task` stays task-managed without being rewritten to orchestrator. */
+  readonly #taskManagedSessionIDs = new Set<string>();
   readonly #maxEntries: number;
   readonly #maxEntries: number;
   readonly #onEvict?: SessionMetadataEviction;
   readonly #onEvict?: SessionMetadataEviction;
 
 
@@ -61,12 +65,22 @@ export class SessionMetadataStore {
     this.#activeOrchestratorSessionIDs.delete(sessionID);
     this.#activeOrchestratorSessionIDs.delete(sessionID);
   }
   }
 
 
+  markTaskManaged(sessionID: string): void {
+    this.#taskManagedSessionIDs.add(sessionID);
+    this.#track(sessionID);
+  }
+
+  isTaskManaged(sessionID: string): boolean {
+    return this.#taskManagedSessionIDs.has(sessionID);
+  }
+
   delete(sessionID: string): void {
   delete(sessionID: string): void {
     this.#agents.delete(sessionID);
     this.#agents.delete(sessionID);
     this.#models.delete(sessionID);
     this.#models.delete(sessionID);
     this.#directories.delete(sessionID);
     this.#directories.delete(sessionID);
     this.#insertionOrder.delete(sessionID);
     this.#insertionOrder.delete(sessionID);
     this.#activeOrchestratorSessionIDs.delete(sessionID);
     this.#activeOrchestratorSessionIDs.delete(sessionID);
+    this.#taskManagedSessionIDs.delete(sessionID);
   }
   }
 
 
   get size(): number {
   get size(): number {
@@ -87,15 +101,35 @@ export class SessionMetadataStore {
     }
     }
 
 
     while (this.#insertionOrder.size > this.#maxEntries) {
     while (this.#insertionOrder.size > this.#maxEntries) {
-      const evictableSessionID = [...this.#insertionOrder.keys()].find(
-        (candidate) => !this.#activeOrchestratorSessionIDs.has(candidate),
-      );
+      // Eviction preference: unprotected entries first, then task-managed
+      // ones (oldest first — membership is permanent, so without this
+      // fallback a run of delegating parents would grow the store past
+      // its configured bound), and only as a last resort in-flight
+      // orchestrator sessions. The cap exists precisely to bound retention
+      // when deletion events are missed, so it must always be enforceable.
+      const candidates = [...this.#insertionOrder.keys()];
+      const evictableSessionID =
+        candidates.find(
+          (candidate) =>
+            !this.#activeOrchestratorSessionIDs.has(candidate) &&
+            !this.#taskManagedSessionIDs.has(candidate),
+        ) ??
+        candidates.find(
+          (candidate) =>
+            !this.#activeOrchestratorSessionIDs.has(candidate) &&
+            this.#taskManagedSessionIDs.has(candidate),
+        ) ??
+        candidates.find((candidate) =>
+          this.#activeOrchestratorSessionIDs.has(candidate),
+        );
       if (evictableSessionID === undefined) return;
       if (evictableSessionID === undefined) return;
 
 
       this.#insertionOrder.delete(evictableSessionID);
       this.#insertionOrder.delete(evictableSessionID);
       this.#agents.delete(evictableSessionID);
       this.#agents.delete(evictableSessionID);
       this.#models.delete(evictableSessionID);
       this.#models.delete(evictableSessionID);
       this.#directories.delete(evictableSessionID);
       this.#directories.delete(evictableSessionID);
+      this.#taskManagedSessionIDs.delete(evictableSessionID);
+      this.#activeOrchestratorSessionIDs.delete(evictableSessionID);
       this.#onEvict?.(evictableSessionID);
       this.#onEvict?.(evictableSessionID);
     }
     }
   }
   }

+ 174 - 0
src/utils/session-selection.test.ts

@@ -0,0 +1,174 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  createSessionSelectionReader,
+  resolveCurrentSelection,
+} from './session-selection';
+
+describe('resolveCurrentSelection', () => {
+  test('prefers a host-persisted agent and model over slim metadata', async () => {
+    const selection = await resolveCurrentSelection(
+      'ses_1',
+      {
+        readHostSelection: async () => ({
+          agent: 'plan',
+          model: { providerID: 'test', id: 'plan-model', variant: 'max' },
+        }),
+      },
+      {
+        getAgent: () => 'orchestrator',
+        getModel: () => 'test/stale-model',
+      },
+    );
+
+    expect(selection).toEqual({
+      agent: 'plan',
+      model: { providerID: 'test', modelID: 'plan-model' },
+      variant: 'max',
+      provenance: 'host-persisted',
+    });
+  });
+
+  test('falls back to slim metadata when the host has no agent', async () => {
+    const selection = await resolveCurrentSelection(
+      'ses_1',
+      { readHostSelection: async () => undefined },
+      {
+        getAgent: () => 'build',
+        getModel: () => 'openai/gpt-4o',
+      },
+    );
+
+    expect(selection).toEqual({
+      agent: 'build',
+      model: { providerID: 'openai', modelID: 'gpt-4o' },
+      provenance: 'observed-external',
+    });
+  });
+
+  test('returns unknown when neither host nor metadata has a selection', async () => {
+    const selection = await resolveCurrentSelection(
+      'ses_1',
+      { readHostSelection: async () => undefined },
+      { getAgent: () => undefined, getModel: () => undefined },
+    );
+
+    expect(selection).toEqual({ provenance: 'unknown' });
+  });
+
+  test('swallows a host read failure and uses metadata', async () => {
+    const selection = await resolveCurrentSelection(
+      'ses_1',
+      {
+        readHostSelection: async () => {
+          throw new Error('session.get failed');
+        },
+      },
+      {
+        getAgent: () => 'plan',
+        getModel: () => undefined,
+      },
+    );
+
+    expect(selection).toEqual({
+      agent: 'plan',
+      provenance: 'observed-external',
+    });
+  });
+
+  test('host timeout still uses metadata instead of unknown', async () => {
+    const selection = await resolveCurrentSelection(
+      'ses_1',
+      {
+        readHostSelection: () =>
+          new Promise((resolve) => {
+            setTimeout(() => resolve({ agent: 'build' }), 50);
+          }),
+      },
+      {
+        getAgent: () => 'plan',
+        getModel: () => 'openai/gpt-4o',
+      },
+      10,
+    );
+
+    expect(selection).toEqual({
+      agent: 'plan',
+      model: { providerID: 'openai', modelID: 'gpt-4o' },
+      provenance: 'observed-external',
+    });
+  });
+
+  test('host timeout without metadata stays unknown', async () => {
+    const selection = await resolveCurrentSelection(
+      'ses_1',
+      {
+        readHostSelection: () =>
+          new Promise((resolve) => {
+            setTimeout(() => resolve({ agent: 'build' }), 50);
+          }),
+      },
+      { getAgent: () => undefined, getModel: () => undefined },
+      10,
+    );
+
+    expect(selection).toEqual({ provenance: 'unknown' });
+  });
+});
+
+describe('createSessionSelectionReader', () => {
+  test('calls session.get with the v1 path/query shape', async () => {
+    const get = async (args: Record<string, unknown>) => {
+      expect(args).toEqual({
+        path: { id: 'ses_1' },
+        query: { directory: '/project' },
+      });
+      return {
+        data: {
+          agent: 'plan',
+          model: { providerID: 'test', id: 'm1' },
+        },
+      };
+    };
+    const reader = createSessionSelectionReader(
+      { session: { get } },
+      '/project',
+    );
+    await expect(reader.readHostSelection('ses_1')).resolves.toEqual({
+      agent: 'plan',
+      model: { providerID: 'test', id: 'm1' },
+    });
+  });
+
+  test('preserves the session.get receiver (v1 SDK this._client)', async () => {
+    const session: {
+      get: (args: Record<string, unknown>) => Promise<unknown>;
+    } = {
+      get(this: unknown, args: Record<string, unknown>) {
+        expect(this).toBe(session);
+        expect(args).toEqual({ path: { id: 'ses_1' } });
+        return Promise.resolve({
+          data: { agent: 'build', model: { providerID: 'x', id: 'y' } },
+        });
+      },
+    };
+    const reader = createSessionSelectionReader({ session });
+    await expect(reader.readHostSelection('ses_1')).resolves.toEqual({
+      agent: 'build',
+      model: { providerID: 'x', id: 'y' },
+    });
+  });
+
+  test('parses a host {info} envelope', async () => {
+    const reader = createSessionSelectionReader({
+      session: {
+        get: async () => ({
+          info: { agent: 'plan', model: { providerID: 'p', id: 'm' } },
+        }),
+      },
+    });
+    await expect(reader.readHostSelection('ses_1')).resolves.toEqual({
+      agent: 'plan',
+      model: { providerID: 'p', id: 'm' },
+    });
+  });
+});

+ 204 - 0
src/utils/session-selection.ts

@@ -0,0 +1,204 @@
+import { parseContinuationModelSelection } from '../hooks/task-session-manager/continuation-model-selection';
+import { isRecord } from './guards';
+
+/** Which source produced a resolved selection. */
+export type SessionSelectionProvenance =
+  /** Selection persisted in the host (`session.get()`). */
+  | 'host-persisted'
+  /** Last selection observed from an external (user-initiated) chat
+   * message, kept by slim's session metadata. */
+  | 'observed-external'
+  /** No selection could be resolved. */
+  | 'unknown';
+
+export interface SessionSelection {
+  agent?: string;
+  /** Prompt-shaped model ref (`{providerID, modelID}`), the same form
+   * promptAsync bodies expect. */
+  model?: { providerID: string; modelID: string };
+  variant?: string;
+  provenance: SessionSelectionProvenance;
+}
+
+export interface SessionSelectionReader {
+  /** Read the host-persisted selection for a session, or undefined when
+   * the host does not expose one. Implementations must not throw. */
+  readHostSelection(sessionID: string): Promise<
+    | {
+        agent?: string;
+        model?: unknown;
+      }
+    | undefined
+  >;
+}
+
+/** Bound only the host `session.get` read. Metadata fallback must still
+ * run if that read is slow or hangs (#1079 Oracle r2). */
+export const HOST_SELECTION_TIMEOUT_MS = 2_000;
+
+/**
+ * Resolve the session's CURRENT selection for lifecycle continuations
+ * (#1079): a background-task lifecycle event must continue the parent in
+ * the agent/model the session actually uses now, never a hardcoded
+ * `orchestrator`.
+ *
+ * Hierarchy:
+ * 1. Host-persisted selection (`session.get()`). This is whatever the
+ *    host last stored; user-message admission updates it, and synthetic
+ *    prompts may also rewrite the host copy.
+ * 2. Last externally observed selection from slim metadata (internal
+ *    admissions are filtered out of this store).
+ * 3. Unknown — callers apply a conservative fallback (`orchestrator`,
+ *    matching historical wake behavior).
+ */
+export async function resolveCurrentSelection(
+  sessionID: string,
+  host: SessionSelectionReader,
+  metadata: {
+    getAgent(sessionID: string): string | undefined;
+    getModel(sessionID: string): string | undefined;
+  },
+  timeoutMs: number = HOST_SELECTION_TIMEOUT_MS,
+): Promise<SessionSelection> {
+  const hostSelection = await readHostSelectionBounded(
+    host,
+    sessionID,
+    timeoutMs,
+  );
+  const hostParsed = parseContinuationModelSelection(hostSelection?.model);
+  const hostAgent =
+    typeof hostSelection?.agent === 'string' ? hostSelection.agent : undefined;
+  const metaAgent = metadata.getAgent(sessionID);
+  const metaModel = modelFromMetadataString(metadata.getModel(sessionID));
+  if (hostAgent !== undefined) {
+    return {
+      agent: hostAgent,
+      model: hostParsed?.model ?? metaModel,
+      variant: hostParsed?.variant,
+      provenance: 'host-persisted',
+    };
+  }
+  if (metaAgent !== undefined) {
+    return {
+      agent: metaAgent,
+      model: hostParsed?.model ?? metaModel,
+      variant: hostParsed?.variant,
+      provenance: hostParsed ? 'host-persisted' : 'observed-external',
+    };
+  }
+  if (hostParsed) {
+    return {
+      model: hostParsed.model,
+      variant: hostParsed.variant,
+      provenance: 'host-persisted',
+    };
+  }
+  return { provenance: 'unknown' };
+}
+
+/** Slim stores models as `"provider/modelID"`; promptAsync wants the
+ * object form. A slash-less string cannot be a continuation pin. */
+export function modelFromMetadataString(
+  model: string | undefined,
+): { providerID: string; modelID: string } | undefined {
+  if (!model) return undefined;
+  const parsed = parseContinuationModelSelection(model);
+  if (parsed) return parsed.model;
+  const slash = model.indexOf('/');
+  if (slash <= 0 || slash === model.length - 1) return undefined;
+  return {
+    providerID: model.slice(0, slash),
+    modelID: model.slice(slash + 1),
+  };
+}
+
+async function readHostSelectionBounded(
+  host: SessionSelectionReader,
+  sessionID: string,
+  timeoutMs: number,
+): Promise<
+  | {
+      agent?: string;
+      model?: unknown;
+    }
+  | undefined
+> {
+  let settled = false;
+  return await new Promise((resolve) => {
+    const timer = setTimeout(() => {
+      if (settled) return;
+      settled = true;
+      resolve(undefined);
+    }, timeoutMs);
+    timer.unref?.();
+    host
+      .readHostSelection(sessionID)
+      .then((value) => {
+        if (settled) return;
+        settled = true;
+        clearTimeout(timer);
+        resolve(value);
+      })
+      .catch(() => {
+        if (settled) return;
+        settled = true;
+        clearTimeout(timer);
+        resolve(undefined);
+      });
+  });
+}
+
+function sessionFromGetResponse(
+  response: unknown,
+): Record<string, unknown> | undefined {
+  if (!isRecord(response)) return undefined;
+  if (isRecord(response.data)) return response.data;
+  if (isRecord(response.info)) return response.info;
+  if (typeof response.agent === 'string' || response.model !== undefined) {
+    return response;
+  }
+  return undefined;
+}
+
+/** Build a {@link SessionSelectionReader} from a plugin client. Works
+ * for the v1 SDK client and the v2 client shim alike (the shim
+ * delegates `session.get` without transforming it, wrapping the result
+ * in `{data}`). The published v1 SDK type for `Session` omits
+ * `agent`/`model`, but the runtime host sends both; read them
+ * defensively. */
+export function createSessionSelectionReader(
+  client: unknown,
+  directory?: string,
+): SessionSelectionReader {
+  const sessionSdk = isRecord(client)
+    ? (client as { session?: unknown }).session
+    : undefined;
+  const get = isRecord(sessionSdk)
+    ? (sessionSdk as { get?: unknown }).get
+    : undefined;
+  return {
+    async readHostSelection(sessionID) {
+      if (typeof get !== 'function') return undefined;
+      // Call through the session object: the generated v1 SDK method
+      // reads `this._client` (#595 / Oracle r1 #1079). Detaching `.get`
+      // silently fails and degrades to stale metadata.
+      const response = await (
+        get as (
+          this: unknown,
+          args: Record<string, unknown>,
+        ) => Promise<unknown>
+      ).call(sessionSdk, {
+        path: { id: sessionID },
+        ...(directory ? { query: { directory } } : {}),
+      });
+      const session = sessionFromGetResponse(response);
+      if (!session) return undefined;
+      const agent =
+        typeof session.agent === 'string' ? session.agent : undefined;
+      if (agent === undefined && session.model === undefined) {
+        return undefined;
+      }
+      return { agent, model: session.model };
+    },
+  };
+}

+ 99 - 3
src/v2/client-shim.test.ts

@@ -309,7 +309,10 @@ describe('v2 client shim delegation', () => {
     expect(result).toMatchObject({ admitted: true });
     expect(result).toMatchObject({ admitted: true });
   });
   });
 
 
-  test('internal-initiator synthetic routing keeps switchModel ordering', async () => {
+  test("lifecycle continuation with modelSelection:'inherit' inherits the persisted host selection", async () => {
+    // #1079: a lifecycle pin (with or without variant) is a snapshot.
+    // By delivery time the host may already be on another model/variant;
+    // switchModel would revert the user's selection.
     const seq: Array<{ m: string; i: unknown }> = [];
     const seq: Array<{ m: string; i: unknown }> = [];
     const input = buildPluginInput(
     const input = buildPluginInput(
       makeCtx({
       makeCtx({
@@ -337,11 +340,14 @@ describe('v2 client shim delegation', () => {
         model: { providerID: 'anthropic', modelID: 'claude-x' },
         model: { providerID: 'anthropic', modelID: 'claude-x' },
         parts: [createInternalAgentTextPart('wake with model pin')],
         parts: [createInternalAgentTextPart('wake with model pin')],
       },
       },
+      modelSelection: 'inherit',
+      modelVariant: 'high',
     });
     });
-    expect(seq.map((c) => c.m)).toEqual(['switchModel', 'synthetic']);
-    expect(seq[1].i).toMatchObject({
+    expect(seq.map((c) => c.m)).toEqual(['synthetic']);
+    expect(seq[0].i).toMatchObject({
       sessionID: 'ses_1',
       sessionID: 'ses_1',
       delivery: 'steer',
       delivery: 'steer',
+      resume: true,
     });
     });
   });
   });
 
 
@@ -1027,6 +1033,96 @@ describe('v2 client shim promptAsync model-switch hardening (#1125)', () => {
     });
     });
     expect(seq.map((e) => e.m)).toEqual(['prompt']);
     expect(seq.map((e) => e.m)).toEqual(['prompt']);
   });
   });
+
+  test('interview-style internal continuation keeps its explicit pin (switchModel runs)', async () => {
+    // Pure-internal body WITHOUT modelSelection:'inherit': the interview
+    // runtime tracks its own model, so the pin is real and must apply
+    // (Greptile P1 on #1195).
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => ({
+        model: { providerID: 'test', id: 'model-b', variant: 'default' },
+      }),
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      synthetic: async (i: unknown) => {
+        seq.push({ m: 'synthetic', i });
+        return {};
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [createInternalAgentTextPart('interview next question')],
+      },
+      delivery: 'queue',
+      modelVariant: 'high',
+    });
+    expect(seq.map((c) => c.m)).toEqual(['switchModel', 'synthetic']);
+  });
+
+  test('lifecycle inherit without synthetic still skips the stale pin on the degraded prompt path', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => ({
+        model: { providerID: 'test', id: 'model-b' },
+      }),
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        agent: 'plan',
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [createInternalAgentTextPart('wake reminder')],
+      },
+      delivery: 'queue',
+      modelSelection: 'inherit',
+      modelVariant: 'high',
+    });
+    expect(seq.map((c) => c.m)).toEqual(['prompt']);
+  });
+
+  test('required fallback switch still runs for mixed replay bodies', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => ({
+        model: { providerID: 'test', id: 'model-b' },
+      }),
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'anthropic', modelID: 'claude-fallback' },
+        parts: [
+          { type: 'text', text: 'user replay' },
+          createInternalAgentTextPart('retry reminder'),
+        ],
+      },
+      modelSwitch: 'required',
+    });
+    expect(seq.map((c) => c.m)).toEqual(['switchModel', 'prompt']);
+  });
 });
 });
 
 
 describe('v2 client shim foreground-fallback integration', () => {
 describe('v2 client shim foreground-fallback integration', () => {

+ 34 - 15
src/v2/client-shim.ts

@@ -353,11 +353,17 @@ export function buildPluginInput(
       // without session.switchModel must fail loudly instead of silently
       // without session.switchModel must fail loudly instead of silently
       // replaying on the model that just failed); default callers pass the
       // replaying on the model that just failed); default callers pass the
       // session's CURRENT model as a pin (orchestrator-wake) and keep the
       // session's CURRENT model as a pin (orchestrator-wake) and keep the
-      // honest degrade-with-log steer.
+      // honest degrade-with-log steer. The optional `modelSelection:
+      // 'inherit'` argument opts a pure-internal caller into LIFECYCLE
+      // continuation semantics (#1079): the body pin is a snapshot, so the
+      // host's persisted selection wins and switchModel is skipped. Callers
+      // with a REAL pin must not pass it (interview continuations track
+      // their own model and keep the switch).
       promptAsync: async (
       promptAsync: async (
         args: Record<string, unknown> & {
         args: Record<string, unknown> & {
           delivery?: 'steer' | 'queue';
           delivery?: 'steer' | 'queue';
           modelSwitch?: 'required';
           modelSwitch?: 'required';
+          modelSelection?: 'inherit';
           modelVariant?: string;
           modelVariant?: string;
         },
         },
       ) => {
       ) => {
@@ -392,7 +398,18 @@ export function buildPluginInput(
         }
         }
         const ref = modelRefFromBody(body);
         const ref = modelRefFromBody(body);
         let switched = false;
         let switched = false;
-        if (ref) {
+        // Lifecycle continuations that OPT IN via `modelSelection:
+        // 'inherit'` (wake / terminal notify) take the host's persisted
+        // selection: their body pin — with or without variant — is a
+        // snapshot that can be stale by delivery time (#1079). Purely
+        // internal bodies WITHOUT the flag keep the pin semantics: the
+        // interview runtime tracks its own model and must keep switching.
+        // Mixed bodies (foreground-fallback replay) still switch;
+        // `modelSwitch: 'required'` still switches.
+        const inheritPersistedSelection =
+          args?.modelSelection === 'inherit' &&
+          args?.modelSwitch !== 'required';
+        if (ref && !inheritPersistedSelection) {
           // `modelVariant` is the v2-only channel for the wake model's
           // `modelVariant` is the v2-only channel for the wake model's
           // reasoning-effort variant (v1 prompt bodies carry no variant
           // reasoning-effort variant (v1 prompt bodies carry no variant
           // slot). A non-empty string overrides the ref's variant so
           // slot). A non-empty string overrides the ref's variant so
@@ -404,26 +421,23 @@ export function buildPluginInput(
           const switchRef = explicitVariant
           const switchRef = explicitVariant
             ? { ...ref, variant: explicitVariant }
             ? { ...ref, variant: explicitVariant }
             : ref;
             : ref;
-          // Variant preservation: internal callers (orchestrator-wake,
-          // task-message, foreground-fallback) pin the session's CURRENT
-          // model without a variant opinion. Re-asserting such a pin via
-          // switchModel resets the host-side reasoning-effort variant to
-          // default (the wake-variant-reset regression). A variant-less
-          // ref that already matches the session model is therefore a
-          // no-op pin: skip the switch entirely, read at delivery time so
-          // a mid-flight user variant change wins. Explicit variants
-          // (including 'default') and cross-model pins still switch.
-          // Hosts without session.get (or failing it) keep the legacy
-          // variant-free switch behavior.
+          // Variant preservation: pin-callers that name the session's
+          // CURRENT model without a variant opinion must not reset the
+          // host-side reasoning-effort variant (wake-variant-reset).
+          // Explicit variants and required fallback switches still
+          // switch. Hosts without session.get keep the legacy switch.
           let skipSwitch = false;
           let skipSwitch = false;
-          if (!explicitVariant && s.get) {
+          if (s.get && args?.modelSwitch !== 'required') {
             try {
             try {
               const info = await s.get({ sessionID: sessionIDOf(args) });
               const info = await s.get({ sessionID: sessionIDOf(args) });
               const current = isRecord(info) ? info.model : undefined;
               const current = isRecord(info) ? info.model : undefined;
-              skipSwitch =
+              const pinMatchesCurrent =
                 isRecord(current) &&
                 isRecord(current) &&
                 current.providerID === switchRef.providerID &&
                 current.providerID === switchRef.providerID &&
                 current.id === switchRef.id;
                 current.id === switchRef.id;
+              if (pinMatchesCurrent && !explicitVariant) {
+                skipSwitch = true;
+              }
             } catch {
             } catch {
               // Fail-soft: cannot prove the pin matches — switch as before.
               // Fail-soft: cannot prove the pin matches — switch as before.
             }
             }
@@ -466,6 +480,11 @@ export function buildPluginInput(
               { id: sessionIDOf(args) },
               { id: sessionIDOf(args) },
             );
             );
           }
           }
+        } else if (inheritPersistedSelection && ref) {
+          log(
+            '[v2][shim] internal continuation inherits persisted host selection; skip session.switchModel',
+            { id: sessionIDOf(args), model: ref },
+          );
         }
         }
         if (internalViaSynthetic) {
         if (internalViaSynthetic) {
           // Client-chosen message id: v2 `Session.synthetic` honors
           // Client-chosen message id: v2 `Session.synthetic` honors