Răsfoiți Sursa

fix(orchestrator-wake): suppress archived sessions

Balogun Feranmi 6 zile în urmă
părinte
comite
2ffd1aaf8a

+ 4 - 0
docs/background-orchestration.md

@@ -387,6 +387,10 @@ Behavior:
 - Suppress/clear on question/permission input waits, `wait_for_user`, foreground
   fallback, session busy, session deletion, external user messages, and server
   disposal.
+- `session.time.archived` is authoritative when available. Archived sessions do
+  not receive periodic or stopped-job-recovery wakes; archive updates cancel
+  timers and stale evaluations, while an unarchive permits future lifecycle
+  activity. v2 hosts without `session.get()` use observed session updates.
 - One in-flight evaluation/wake per session. Status/waits/generation are
   rechecked immediately before `promptAsync`. Cooldown/reservation is recorded
   before the call so a failed `promptAsync` cannot storm retries.

+ 9 - 5
src/hooks/orchestrator-wake/codemap.md

@@ -23,7 +23,8 @@ fallback), the wake condition is children without a terminal `outcome`
 - **Scheduler** (`index.ts`): `createOrchestratorWakeScheduler(ctx, options)`
   returns `{ event, observeChatMessage, triggerStoppedJobRecovery, suppress }`.
   - Tracks per-session local state (`generation` symbol, timer, continuous
-    idle flag) only; progress lives in the process gate.
+    idle flag, and archive suppression) only; progress lives in the process
+    gate.
   - Capability record (`probeSessionApis`): v1 keeps exactly the historical
     probe set (get/todo/children/status/promptAsync); v2 requires only
     list+promptAsync (get optional). `resolveWakeMode` maps the configured
@@ -33,8 +34,8 @@ fallback), the wake condition is children without a terminal `outcome`
     session, no input wait (`hasInputWait`), no fallback in progress, gate
     not stopped.
   - Reads a host snapshot (todo mode: todos + children + status map +
-    session model; children mode: children list + event-tracked parent
-    status + optional model) and computes a fingerprint; unchanged
+    session model/archive state; children mode: children list + event-tracked
+    parent status + optional model/archive state) and computes a fingerprint; unchanged
     fingerprints across wake attempts hit `ORCHESTRATOR_WAKE_UNCHANGED_CAP`
     (2) and stop.
   - Checkpoint classification (`classifyTodoSnapshot` /
@@ -45,7 +46,8 @@ fallback), the wake condition is children without a terminal `outcome`
   - Event bookkeeping: `lastStatusBySession` (busy-set + race guard),
     `childSessions`/`childEvidence` from `session.created` parentID links
     (both v1-shape and flat v2 events), all bounded at 512 entries FIFO and
-    cleared on `session.deleted`/dispose.
+    cleared on `session.deleted`/dispose. `session.updated` archive state
+    suppresses or restores the local session timer/generation.
   - Wakes via `promptAsync` with a static `<system-reminder>` text
     (`ORCHESTRATOR_WAKE_TEXT`, `ORCHESTRATOR_CHILDREN_WAKE_TEXT`, or
     `ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT`), reserving the wake before prompt
@@ -82,7 +84,7 @@ evaluate() (one-flight via gate)
     ├─ todo mode: active child? → schedule later; no incomplete todos? → end
     ├─ children mode: no active (outcome-less, fresh) child? → end
     ├─ fingerprint unchanged ≥ cap? → stop
-    ├─ recheck immediately before promptAsync
+    ├─ recheck archive state immediately before promptAsync
     ├─ commitWakeReservation
     └─ promptAsync(internal wake reminder; v2 children mode: delivery 'queue')
@@ -121,6 +123,8 @@ busy (external) / errors / user activity → rearm cap
   committed), clear the expecting-busy marker, and log; the timer re-arms via
   the finally block unless stopped. Children-mode enumeration failures fall
   back to event tracking instead of suppressing.
+- Archived sessions clear their timer and generation on `session.updated`; v2
+  hosts without `session.get()` rely on that observed archive state.
 - `server.instance.disposed` clears timers, releases owners, and drops pending
   recovery + event-tracking state.
 - Model enrichment from `session.get` is fail-soft.

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

@@ -338,6 +338,150 @@ describe('orchestrator wake scheduler', () => {
     );
   });
 
+  test('does not wake an archived v1 session', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        get: mock(async () => ({
+          data: {
+            time: { created: 1, updated: 1, archived: 123 },
+          },
+        })),
+      }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('archive update cancels an armed timer', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    expect(clock.pendingCount()).toBe(1);
+
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        properties: { info: { id: 'p1', time: { archived: 123 } } },
+      },
+    });
+    await clock.advance(60_000);
+
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('archive update during evaluation blocks promptAsync', async () => {
+    const promptAsync = mock(async () => ({}));
+    let getCalls = 0;
+    let releaseLatestGet!: () => void;
+    const latestGet = new Promise<void>((resolve) => {
+      releaseLatestGet = resolve;
+    });
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        get: mock(async () => {
+          if (getCalls++ === 1) await latestGet;
+          return { data: { time: { created: 1, updated: 1 } } };
+        }),
+      }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(getCalls).toBe(2);
+
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        properties: { info: { id: 'p1', time: { archived: 123 } } },
+      },
+    });
+    releaseLatestGet();
+    await clock.advance(0);
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('suppresses stopped-job recovery for an archived v1 session', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        get: mock(async () => ({
+          data: { time: { created: 1, updated: 1, archived: 123 } },
+        })),
+      }),
+    });
+
+    scheduler.triggerStoppedJobRecovery('p1');
+    await clock.advance(0);
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('unarchive allows future normal lifecycle activity', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        properties: { info: { id: 'p1', time: { archived: 123 } } },
+      },
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        properties: { info: { id: 'p1', time: { created: 1, updated: 2 } } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('unrelated session updates do not cancel the parent timer', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        properties: { info: { id: 'other', time: { archived: 123 } } },
+      },
+    });
+    expect(clock.pendingCount()).toBe(1);
+
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
   test('targets only orchestrator-managed sessions', async () => {
     const promptAsync = mock(async () => ({}));
     const { scheduler } = createScheduler({
@@ -1121,6 +1265,70 @@ describe('children-driven degraded mode (v2)', () => {
     );
   });
 
+  test('does not wake an archived v2 session', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1', time: { updated: Date.now() } }],
+        get: mock(async () => ({
+          data: { time: { created: 1, updated: 1, archived: 123 } },
+        })),
+      }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('v2 without get uses observed archive state and preserves queue delivery', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1', time: { updated: Date.now() } }],
+      }),
+    });
+
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        data: { sessionID: 'p1', time: { archived: 123 } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await scheduler.event({
+      event: {
+        type: 'session.updated',
+        data: { sessionID: 'p1', time: { created: 1, updated: 2 } },
+      },
+    });
+    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<[{ delivery?: string }]>
+    )[0]?.[0];
+    expect(call?.delivery).toBe('queue');
+  });
+
   test('does not wake when every child has a terminal outcome', async () => {
     const promptAsync = mock(async () => ({}));
     const { scheduler } = createScheduler({

+ 182 - 27
src/hooks/orchestrator-wake/index.ts

@@ -86,6 +86,7 @@ type TodoModeSnapshot = {
   children: Array<Record<string, unknown>>;
   status: Record<string, unknown>;
   model?: ContinuationModelSelection;
+  archiveState?: boolean;
 };
 
 /** Children-mode snapshot (v2 degraded mode / explicit 'children'). */
@@ -96,6 +97,7 @@ type ChildrenModeSnapshot = {
    * race guard covers it). */
   hostParentActive: boolean;
   model?: ContinuationModelSelection;
+  archiveState?: boolean;
 };
 
 type WakeSnapshot = TodoModeSnapshot | ChildrenModeSnapshot;
@@ -108,6 +110,7 @@ type LocalSessionState = {
   generation: symbol;
   timer: ReturnType<typeof setTimeout> | undefined;
   continuousIdle: boolean;
+  archived: boolean;
 };
 
 export type OrchestratorWakeConfig = {
@@ -379,9 +382,45 @@ export function buildOrchestratorWakeFingerprint(
 }
 
 function extractSessionID(event: {
-  properties?: { info?: { id?: string }; sessionID?: string };
+  properties?: unknown;
+  data?: unknown;
 }): string | undefined {
-  return event.properties?.info?.id || event.properties?.sessionID;
+  const payload = isObjectRecord(event.data)
+    ? event.data
+    : isObjectRecord(event.properties)
+      ? event.properties
+      : undefined;
+  const info = isObjectRecord(payload?.info) ? payload.info : payload;
+  if (typeof info?.id === 'string' && info.id) return info.id;
+  if (typeof payload?.sessionID === 'string' && payload.sessionID) {
+    return payload.sessionID;
+  }
+  return undefined;
+}
+
+function readSessionArchiveState(session: unknown): boolean | undefined {
+  if (
+    !isObjectRecord(session) ||
+    Array.isArray(session) ||
+    !isObjectRecord(session.time) ||
+    Array.isArray(session.time)
+  ) {
+    return undefined;
+  }
+  return session.time.archived !== undefined;
+}
+
+function readEventArchiveState(event: {
+  properties?: unknown;
+  data?: unknown;
+}): boolean | undefined {
+  const payload = isObjectRecord(event.data)
+    ? event.data
+    : isObjectRecord(event.properties)
+      ? event.properties
+      : undefined;
+  const info = isObjectRecord(payload?.info) ? payload.info : payload;
+  return readSessionArchiveState(info);
 }
 
 function isIdleEvent(
@@ -496,6 +535,7 @@ export function createOrchestratorWakeScheduler(
       generation: Symbol(sessionID),
       timer: undefined,
       continuousIdle: false,
+      archived: false,
     };
     localSessions.set(sessionID, created);
     return created;
@@ -534,6 +574,26 @@ export function createOrchestratorWakeScheduler(
     pendingStoppedRecoveries.delete(sessionID);
   }
 
+  function suppressArchivedSession(sessionID: string): void {
+    const state = touchLocal(sessionID);
+    clearTimer(state);
+    bumpGeneration(state);
+    state.continuousIdle = false;
+    state.archived = true;
+    releaseLocalWakeOwner(sessionID);
+  }
+
+  function restoreArchivedSession(sessionID: string): void {
+    const state = localSessions.get(sessionID);
+    if (!state?.archived) return;
+    clearTimer(state);
+    bumpGeneration(state);
+    state.continuousIdle = false;
+    state.archived = false;
+    releaseLocalWakeOwner(sessionID);
+    rearmWakeProgress(sessionID);
+  }
+
   /**
    * Suppress scheduling without dropping process-global progress.
    * Used for input waits and temporary blocks.
@@ -567,6 +627,7 @@ export function createOrchestratorWakeScheduler(
     if (!enabled) return false;
     if (!capabilities.ready) return false;
     if (!options.shouldManageSession(sessionID)) return false;
+    if (localSessions.get(sessionID)?.archived) return false;
     if (options.hasInputWait(sessionID)) return false;
     if (options.isFallbackInProgress?.(sessionID)) return false;
     if (getWakeProgress(sessionID).stopped) return false;
@@ -590,19 +651,33 @@ export function createOrchestratorWakeScheduler(
   }
 
   function beginContinuousIdle(sessionID: string): void {
+    const state = localSessions.get(sessionID);
+    if (state?.archived) {
+      if (
+        enabled &&
+        capabilities.ready &&
+        typeof sessionSdk.get === 'function'
+      ) {
+        void refreshArchivedSession(sessionID, state.generation);
+      }
+      return;
+    }
     if (!canSchedule(sessionID)) return;
-    const state = touchLocal(sessionID);
-    if (state.continuousIdle && state.timer !== undefined) return;
-    state.continuousIdle = true;
+    const idleState = touchLocal(sessionID);
+    if (idleState.continuousIdle && idleState.timer !== undefined) return;
+    idleState.continuousIdle = true;
     if (getWakeProgress(sessionID).stopped) return;
-    if (state.timer === undefined) schedule(sessionID);
+    if (idleState.timer === undefined) schedule(sessionID);
   }
 
-  /** Fail-soft session-model enrichment (v2 `get` is optional). */
-  async function readSessionModel(
-    sessionID: string,
-  ): Promise<ContinuationModelSelection | undefined> {
-    if (typeof sessionSdk?.get !== 'function') return undefined;
+  type SessionMetadata = {
+    model?: ContinuationModelSelection;
+    archiveState?: boolean;
+  };
+
+  /** Fail-soft session-model and archive-state enrichment. */
+  async function readSessionModel(sessionID: string): Promise<SessionMetadata> {
+    if (typeof sessionSdk?.get !== 'function') return {};
     try {
       const sessionResponse = await sessionSdk.get({
         path: { id: sessionID },
@@ -613,13 +688,54 @@ export function createOrchestratorWakeScheduler(
       const session = isObjectRecord(sessionResponse?.data)
         ? sessionResponse.data
         : undefined;
-      return parseContinuationModelSelection(
-        session ? (session as Record<string, unknown>).model : undefined,
-      );
+      return {
+        model: parseContinuationModelSelection(
+          session ? (session as Record<string, unknown>).model : undefined,
+        ),
+        archiveState: readSessionArchiveState(session),
+      };
     } catch {
-      // Model enrichment is fail-soft.
-      return undefined;
+      // Model and archive enrichment are fail-soft; lifecycle events remain
+      // the v2 source when session.get is unavailable or fails.
+      return {};
+    }
+  }
+
+  async function refreshArchivedSession(
+    sessionID: string,
+    generation: symbol,
+  ): Promise<void> {
+    const { archiveState } = await readSessionModel(sessionID);
+    const state = localSessions.get(sessionID);
+    if (
+      !state ||
+      state.generation !== generation ||
+      !state.archived ||
+      archiveState !== false
+    ) {
+      return;
+    }
+    restoreArchivedSession(sessionID);
+  }
+
+  function applyArchiveState(
+    sessionID: string,
+    state: LocalSessionState,
+    archiveState: boolean | undefined,
+  ): boolean {
+    if (state.archived) {
+      if (archiveState === false) {
+        restoreArchivedSession(sessionID);
+      } else {
+        suppressArchivedSession(sessionID);
+      }
+      return true;
     }
+    if (archiveState === true) {
+      suppressArchivedSession(sessionID);
+      return true;
+    }
+    return false;
   }
 
   async function readHostSnapshot(
@@ -669,7 +785,7 @@ export function createOrchestratorWakeScheduler(
       return undefined;
     }
 
-    const model = await readSessionModel(sessionID);
+    const { model, archiveState } = await readSessionModel(sessionID);
 
     return {
       kind: 'todo',
@@ -677,6 +793,7 @@ export function createOrchestratorWakeScheduler(
       children: children as Array<Record<string, unknown>>,
       status,
       model,
+      archiveState,
     };
   }
 
@@ -765,9 +882,15 @@ export function createOrchestratorWakeScheduler(
       (child) => child.directory === undefined || child.directory === directory,
     );
 
-    const model = await readSessionModel(sessionID);
+    const { model, archiveState } = await readSessionModel(sessionID);
 
-    return { kind: 'children', children, hostParentActive, model };
+    return {
+      kind: 'children',
+      children,
+      hostParentActive,
+      model,
+      archiveState,
+    };
   }
 
   /** Active-child check for children-driven mode (see isWakeChildActive). */
@@ -870,6 +993,7 @@ export function createOrchestratorWakeScheduler(
     const state = localSessions.get(sessionID);
     if (!state || state.generation !== generation) return;
     if (!state.continuousIdle) return;
+    if (state.archived) return;
     if (!canSchedule(sessionID)) {
       suppress(sessionID);
       return;
@@ -898,6 +1022,7 @@ export function createOrchestratorWakeScheduler(
           : await readHostSnapshot(sessionID);
       if (!snapshot || state.generation !== generation) return;
       if (!state.continuousIdle) return;
+      if (applyArchiveState(sessionID, state, snapshot.archiveState)) return;
       if (!canSchedule(sessionID)) {
         suppress(sessionID);
         return;
@@ -933,6 +1058,7 @@ export function createOrchestratorWakeScheduler(
           : await readHostSnapshot(sessionID);
       if (!latest || state.generation !== generation) return;
       if (!state.continuousIdle) return;
+      if (applyArchiveState(sessionID, state, latest.archiveState)) return;
       if (!canSchedule(sessionID)) {
         suppress(sessionID);
         return;
@@ -964,6 +1090,7 @@ export function createOrchestratorWakeScheduler(
 
       // Reserve before promptAsync so a failed call cannot storm retries and
       // concurrent hook instances cannot double-wake.
+      if (applyArchiveState(sessionID, state, latest.archiveState)) return;
       if (!commitWakeReservation(sessionID, owner, latestFingerprint)) {
         return;
       }
@@ -1101,6 +1228,10 @@ export function createOrchestratorWakeScheduler(
     ) {
       return;
     }
+    if (localSessions.get(sessionID)?.archived) {
+      pendingStoppedRecoveries.add(sessionID);
+      return;
+    }
     pendingStoppedRecoveries.add(sessionID);
     rearmWakeProgress(sessionID);
     if (!canSchedule(sessionID)) return;
@@ -1115,15 +1246,23 @@ export function createOrchestratorWakeScheduler(
   async function event(input: {
     event: {
       type: string;
-      properties?: {
-        info?: { id?: string; parentID?: string };
-        sessionID?: string;
-        parentID?: string;
-        status?: { type?: string };
-      };
+      properties?: unknown;
+      data?: unknown;
     };
   }): Promise<void> {
-    const { type, properties } = input.event;
+    const { type } = input.event;
+    const properties = (
+      isObjectRecord(input.event.data)
+        ? input.event.data
+        : isObjectRecord(input.event.properties)
+          ? input.event.properties
+          : {}
+    ) as {
+      info?: { id?: string; parentID?: string; time?: unknown };
+      sessionID?: string;
+      parentID?: string;
+      status?: { type?: string };
+    };
 
     if (type === 'server.instance.disposed') {
       disposed = true;
@@ -1143,6 +1282,18 @@ export function createOrchestratorWakeScheduler(
     const sessionID = extractSessionID(input.event);
     if (!sessionID) return;
 
+    if (type === 'session.updated') {
+      if (options.shouldManageSession(sessionID)) {
+        const archiveState = readEventArchiveState(input.event);
+        if (archiveState === true) {
+          suppressArchivedSession(sessionID);
+        } else if (archiveState === false) {
+          restoreArchivedSession(sessionID);
+        }
+      }
+      return;
+    }
+
     // Event bookkeeping (children-driven mode + parent-active race guard).
     // Status tracking covers ALL sessions: child entries feed the busy-set
     // and update evidence, the parent entry is the race guard on hosts
@@ -1179,7 +1330,11 @@ export function createOrchestratorWakeScheduler(
       if (options.shouldManageSession(sessionID)) {
         clearExpectingWakeBusy(sessionID);
         if (pendingStoppedRecoveries.has(sessionID)) {
-          triggerStoppedJobRecovery(sessionID);
+          if (localSessions.get(sessionID)?.archived) {
+            beginContinuousIdle(sessionID);
+          } else {
+            triggerStoppedJobRecovery(sessionID);
+          }
           return;
         }
         beginContinuousIdle(sessionID);