Browse Source

fix(task-session): attribute parallel child sessions to correct pending call

When a parent launched several task tools in parallel with different
subagent types (e.g. council reviewer-a/b/c), `session.created` early
registration called `peekByParent(parentID)`, which returns the FIRST
pending call in insertion order. Every child was then registered on the
board with the first subagent's agentType — metadata corruption and, in
the observed case, a council reviewer marked cancelled while its child
session kept running.

OpenCode sets `info.agent` on the child SessionInfo to the subagent
that started it (sst/opencode task.ts → sessions.create({ agent: next.name })).
Prefer a pending call whose agentType matches info.agent; fall back to
the oldest pending call only when no agent match exists.
Jiajun0413 3 weeks ago
parent
commit
510144202d

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

@@ -2553,6 +2553,57 @@ describe('task-session-manager hook', () => {
     });
     });
   });
   });
 
 
+  test('session.created early registration attributes each parallel child to its own pending call', async () => {
+    // Regression: when a parent launches several task tools in parallel with
+    // different subagent types (e.g. council reviewers a/b/c), the old
+    // peekByParent() returned the FIRST pending call for every child, so
+    // all children were registered with the first subagent's agentType.
+    // info.agent on the child session disambiguates which pending call
+    // started it.
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    // Parent fires three task tools in parallel: oracle / explorer / fixer.
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-a' },
+      { args: { subagent_type: 'oracle', description: 'audit loss' } },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-b' },
+      { args: { subagent_type: 'explorer', description: 'audit data' } },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-c' },
+      { args: { subagent_type: 'fixer', description: 'audit fix' } },
+    );
+
+    // Each child session is created while the parent tool calls are still
+    // in flight (before any tool.execute.after). info.agent identifies the
+    // subagent that owns each child.
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child-a', parentID: 'parent-1', agent: 'oracle' } },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child-b', parentID: 'parent-1', agent: 'explorer' } },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child-c', parentID: 'parent-1', agent: 'fixer' } },
+      },
+    });
+
+    expect(board.get('child-a')).toMatchObject({ agent: 'oracle', description: 'audit loss' });
+    expect(board.get('child-b')).toMatchObject({ agent: 'explorer', description: 'audit data' });
+    expect(board.get('child-c')).toMatchObject({ agent: 'fixer', description: 'audit fix' });
+  });
+
   test('cancelled job is not reconciled from idle', async () => {
   test('cancelled job is not reconciled from idle', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({

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

@@ -1002,7 +1002,7 @@ export function createTaskSessionManagerHook(
       event: {
       event: {
         type: string;
         type: string;
         properties?: {
         properties?: {
-          info?: { id?: string; parentID?: string };
+          info?: { id?: string; parentID?: string; agent?: string };
           id?: string;
           id?: string;
           requestID?: string;
           requestID?: string;
           sessionID?: string;
           sessionID?: string;
@@ -1034,7 +1034,15 @@ export function createTaskSessionManagerHook(
           // reports runningJobForSession:false and the orchestrator sees
           // reports runningJobForSession:false and the orchestrator sees
           // "Task cancelled" while the child is still working (#765).
           // "Task cancelled" while the child is still working (#765).
           // Peek (don't take) so tool.execute.after can still re-register.
           // Peek (don't take) so tool.execute.after can still re-register.
-          const pending = pendingCallTracker.peekByParent(info.parentID);
+          //
+          // When the parent has multiple task calls in flight at once (e.g.
+          // parallel council reviewers), `info.agent` on the child session
+          // identifies which subagent started it; prefer the matching
+          // pending call so we don't attribute the child to the wrong agent.
+          const pending = pendingCallTracker.peekByParentAndAgent(
+            info.parentID,
+            info.agent,
+          );
           if (
           if (
             pending &&
             pending &&
             !pending.resumedTaskId &&
             !pending.resumedTaskId &&

+ 24 - 0
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -47,6 +47,30 @@ export function createPendingCallTracker() {
       return undefined;
       return undefined;
     },
     },
 
 
+    /**
+     * Peek a pending call for a parent, preferring one whose agentType
+     * matches `agentHint`. Used by session.created early registration:
+     * when a parent launches several parallel task tools with different
+     * subagent types (e.g. council reviewers), `info.agent` on the
+     * child session identifies which subagent started it, so we can
+     * avoid attributing the child to the wrong pending call.
+     * Falls back to the oldest pending call for the parent when no
+     * agent match is found (preserves prior behavior).
+     */
+    peekByParentAndAgent(
+      parentSessionId: string,
+      agentHint?: string,
+    ) {
+      if (!agentHint) return this.peekByParent(parentSessionId);
+      let fallback: PendingTaskCall | undefined;
+      for (const call of pendingCalls.values()) {
+        if (call.parentSessionId !== parentSessionId) continue;
+        if (!fallback) fallback = call;
+        if (call.agentType === agentHint) return call;
+      }
+      return fallback;
+    },
+
     clearSession(sessionId: string) {
     clearSession(sessionId: string) {
       for (const [callId, pending] of pendingCalls.entries()) {
       for (const [callId, pending] of pendingCalls.entries()) {
         if (pending.parentSessionId === sessionId) {
         if (pending.parentSessionId === sessionId) {