Browse Source

fix(task-session): fail closed on malformed input waits

Alvin Unreal 3 weeks ago
parent
commit
bd578f4464

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

@@ -2794,6 +2794,48 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).not.toHaveBeenCalled();
   });
 
+  test('fails closed when an id-less ask races a scheduled continuation', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1' },
+      },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: {
+        type: 'question.replied',
+        properties: { sessionID: 'parent-1', requestID: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
   test('clears only the resolved input wait and resumes on a later idle', async () => {
     const promptAsync = mock(async () => ({}));
     const { hook } = createHook({

+ 10 - 3
src/hooks/task-session-manager/index.ts

@@ -52,6 +52,7 @@ const IDLE_RECONCILE_DELAY_MS = 2_000;
 
 const CONTINUATION_NUDGE =
   'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
+const IDLESS_INPUT_WAIT = Symbol('idless-input-wait');
 const INPUT_WAIT_ASK_EVENTS = {
   'permission.asked': 'permission',
   'question.asked': 'question',
@@ -154,7 +155,7 @@ export function createTaskSessionManagerHook(
   const continuationSessionTokens = new Map<string, symbol>();
   const activeContinuationEvaluations = new Map<string, Set<symbol>>();
   const continuationConsumed = new Set<string>();
-  const inputWaitsByParent = new Map<string, Set<string>>();
+  const inputWaitsByParent = new Map<string, Set<string | symbol>>();
   const idleReconcileDelayMs =
     options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS;
 
@@ -228,9 +229,15 @@ export function createTaskSessionManagerHook(
 
     if (isInputWaitAskEvent(event.type)) {
       const requestID = event.properties?.id;
-      if (!requestID) return;
+      const waits =
+        inputWaitsByParent.get(sessionID) ?? new Set<string | symbol>();
+      if (!requestID) {
+        waits.add(IDLESS_INPUT_WAIT);
+        inputWaitsByParent.set(sessionID, waits);
+        invalidateContinuation(sessionID);
+        return;
+      }
       const key = inputWaitKey(INPUT_WAIT_ASK_EVENTS[event.type], requestID);
-      const waits = inputWaitsByParent.get(sessionID) ?? new Set<string>();
       waits.add(key);
       inputWaitsByParent.set(sessionID, waits);
       invalidateContinuation(sessionID);