Browse Source

feat: surface interrupted task sessions for recovery

dhaern 3 months ago
parent
commit
c2da7902da

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

@@ -567,6 +567,129 @@ describe('task-session-manager hook', () => {
     expect(prompt).not.toContain('config schema');
   });
 
+  test('adds recovery note for line-based task id with empty task_result', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'interrupted work' } },
+    );
+
+    const result = {
+      output: [
+        'task_id: child-1 (for resuming to continue this task if needed)',
+        '',
+        '<task_result>',
+        '</task_result>',
+      ].join('\n'),
+    };
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      result,
+    );
+
+    expect(result.output).toContain('[task partial state available]');
+    expect(result.output).toContain('task_id: child-1');
+    expect(result.output).toContain('agent: @explorer');
+  });
+
+  test('adds recovery note for provider 429 task output', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'oracle', description: 'rate limited work' } },
+    );
+
+    const result = {
+      output: [
+        'task_id: child-1 (for resuming to continue this task if needed)',
+        '',
+        '[ERROR] Provider error: 429 Too Many Requests',
+      ].join('\n'),
+    };
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      result,
+    );
+
+    expect(result.output).toContain('[task partial state available]');
+    expect(result.output).toContain('task_id: child-1');
+    expect(result.output).toContain('agent: @oracle');
+  });
+
+  test('does not add recovery note for invalid task arguments', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'bad args' } },
+    );
+
+    const result = {
+      output: [
+        'task_id: child-1 (for resuming to continue this task if needed)',
+        '',
+        '[ERROR] Invalid arguments: must provide a valid prompt',
+      ].join('\n'),
+    };
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      result,
+    );
+
+    expect(result.output).not.toContain('[task partial state available]');
+  });
+
+  test('does not add recovery note for missing task session', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'missing session' } },
+    );
+
+    const result = {
+      output: [
+        'task_id: child-1 (for resuming to continue this task if needed)',
+        '',
+        '[ERROR] Session not found',
+      ].join('\n'),
+    };
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      result,
+    );
+
+    expect(result.output).not.toContain('[task partial state available]');
+  });
+
+  test('does not duplicate an existing task partial state marker', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'already marked' } },
+    );
+
+    const result = {
+      output: [
+        'task_id: child-1 (for resuming to continue this task if needed)',
+        '',
+        '[ERROR] Provider error: 429 Too Many Requests',
+        '[task partial state available]',
+      ].join('\n'),
+    };
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      result,
+    );
+
+    expect(
+      result.output.match(/\[task partial state available\]/g),
+    ).toHaveLength(1);
+  });
+
   test('does not drop remembered session on non-runtime session text', async () => {
     const { hook } = createHook();
 

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

@@ -61,6 +61,30 @@ interface ChatMessage {
 
 const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
 const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
+const TASK_PARTIAL_STATE_MARKER = '[task partial state available]';
+
+const RECOVERABLE_TASK_ERROR_PATTERNS = [
+  /provider.*error/i,
+  /server.*error/i,
+  /connection.*error/i,
+  /\b429\b/,
+  /rate.?limit/i,
+  /too many requests/i,
+  /timeout/i,
+  /overloaded/i,
+  /quota.?exceeded/i,
+  /usage.?exceeded/i,
+  /resource.?exhausted/i,
+  /insufficient.?quota/i,
+];
+
+const NON_RECOVERABLE_TASK_ERROR_PATTERNS = [
+  /invalid arguments/i,
+  /must provide/i,
+  /is not allowed\. allowed agents:/i,
+  /session.*not found/i,
+  /no session/i,
+];
 
 function isAgentName(value: unknown): value is AgentName {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
@@ -109,6 +133,46 @@ function countReadLines(output: string): number[] {
   return [...lines];
 }
 
+function isRecoverableInterruptedTaskOutput(output: string): boolean {
+  if (output.includes(TASK_PARTIAL_STATE_MARKER)) return false;
+
+  const trimmed = output.trim();
+  if (trimmed.length === 0) return true;
+
+  if (
+    NON_RECOVERABLE_TASK_ERROR_PATTERNS.some((pattern) => pattern.test(output))
+  ) {
+    return false;
+  }
+
+  if (/<task_result>\s*<\/task_result>/.test(trimmed)) return true;
+
+  return RECOVERABLE_TASK_ERROR_PATTERNS.some((pattern) =>
+    pattern.test(output),
+  );
+}
+
+function appendRecoveryNote(
+  output: string,
+  taskId: string,
+  agentType: AgentName,
+): string {
+  if (output.includes(TASK_PARTIAL_STATE_MARKER)) return output;
+
+  return [
+    output.trimEnd(),
+    '',
+    TASK_PARTIAL_STATE_MARKER,
+    'This task returned no final result, but its subagent session may contain partial state.',
+    'If the user asks to continue or recover this work, reuse this task_id to reconnect.',
+    `  task_id: ${taskId}`,
+    `  agent: @${agentType}`,
+    '',
+    'The subagent can see its prior messages, tool calls, reads, patches, and interrupted/error state.',
+    'This is recovery context, not an instruction to resume automatically.',
+  ].join('\n');
+}
+
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   options: {
@@ -349,6 +413,13 @@ export function createTaskSessionManagerHook(
         agentType: pending.agentType,
         label: pending.label,
       });
+      if (isRecoverableInterruptedTaskOutput(output.output)) {
+        output.output = appendRecoveryNote(
+          output.output,
+          taskId,
+          pending.agentType,
+        );
+      }
       pendingManagedTaskIds.delete(taskId);
       const contextFiles = contextFilesForPrompt(contextByTask.get(taskId));
       sessionManager.addContext(taskId, contextFiles);

+ 11 - 0
src/utils/task.test.ts

@@ -21,4 +21,15 @@ describe('parseTaskIdFromTaskOutput', () => {
 
     expect(parseTaskIdFromTaskOutput(output)).toBeUndefined();
   });
+
+  test('falls back to parsing XML task_id', () => {
+    const output = [
+      '<task_id>session-xml-123</task_id>',
+      '<task_result>',
+      'done',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseTaskIdFromTaskOutput(output)).toBe('session-xml-123');
+  });
 });

+ 2 - 1
src/utils/task.ts

@@ -16,5 +16,6 @@ export function parseTaskIdFromTaskOutput(output: string): string | undefined {
     return match[1];
   }
 
-  return undefined;
+  const xmlMatch = /<task_id>([^<]+)<\/task_id>/.exec(output);
+  return xmlMatch?.[1];
 }