Explorar o código

Merge pull request #505 from OmerFarukOruc/feat/configurable-subtask-timeout

feat(subtask): make worker timeout configurable via subtask.timeoutMs
Alvin hai 3 meses
pai
achega
5afd8f457c

+ 1 - 0
docs/configuration.md

@@ -136,6 +136,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `council.timeout` | number | `180000` | Per-councillor timeout (ms) |
 | `council.councillor_execution_mode` | string | `"parallel"` | Run councillors in `parallel` or `serial`; use `serial` for single-model setups |
 | `council.councillor_retries` | number | `3` | Max retries per councillor on empty provider response (0–5) |
+| `subtask.timeoutMs` | integer | `300000` | Subtask worker timeout in ms. `0` disables the timeout. Max `86400000` (24h) |
 | `todoContinuation.maxContinuations` | integer | `5` | Max consecutive auto-continuations before stopping (1–50) |
 | `todoContinuation.cooldownMs` | integer | `3000` | Delay in ms before auto-continuing — gives user time to abort (0–30000) |
 | `todoContinuation.autoEnable` | boolean | `false` | Automatically enable auto-continue when session has enough todos |

+ 21 - 0
docs/subtask.md

@@ -89,6 +89,27 @@ Safety rules:
 - large files are capped before injection,
 - unreadable or missing files are skipped.
 
+## Timeout
+
+Each subtask worker has a timeout. If the worker has not returned a summary
+before the timeout elapses, Slim aborts the child session and the `subtask` tool
+call fails with `Prompt timed out after <ms>ms`.
+
+The default timeout is **5 minutes** (`300000` ms). Override it via
+`subtask.timeoutMs` in your plugin config:
+
+```jsonc
+{
+  // Give workers up to 30 minutes before timing out
+  "subtask": {
+    "timeoutMs": 1800000
+  }
+}
+```
+
+Set `timeoutMs` to `0` to disable the timeout entirely. The maximum accepted
+value is `86400000` (24 hours).
+
 ## Summary format
 
 The worker is instructed to finish with:

+ 11 - 0
oh-my-opencode-slim.schema.json

@@ -599,6 +599,17 @@
         }
       }
     },
+    "subtask": {
+      "type": "object",
+      "properties": {
+        "timeoutMs": {
+          "description": "Subtask worker timeout in ms. 0 disables the timeout. Defaults to 300000 (5 minutes).",
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 86400000
+        }
+      }
+    },
     "fallback": {
       "type": "object",
       "properties": {

+ 20 - 0
src/config/loader.test.ts

@@ -695,6 +695,26 @@ describe('deepMerge behavior', () => {
     const config = loadPluginConfig(projectDir);
     expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.5']);
   });
+
+  test('empty project subtask block does not clobber user subtask.timeoutMs', () => {
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ subtask: { timeoutMs: 1800000 } }),
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ subtask: {} }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.subtask?.timeoutMs).toBe(1800000);
+  });
 });
 
 describe('preset resolution', () => {

+ 1 - 0
src/config/loader.ts

@@ -204,6 +204,7 @@ export function mergePluginConfigs(
     divoom: deepMerge(base.divoom, override.divoom),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
+    subtask: deepMerge(base.subtask, override.subtask),
   };
 }
 

+ 18 - 0
src/config/schema.ts

@@ -252,6 +252,23 @@ export type TodoContinuationConfig = z.infer<
   typeof TodoContinuationConfigSchema
 >;
 
+export const SubtaskConfigSchema = z.object({
+  // Intentionally no .default(): an empty `subtask: {}` block must parse to
+  // `{}` so it cannot shallow-overwrite an inherited value during config
+  // merging. The runtime fallback in createSubtaskTool applies the default.
+  timeoutMs: z
+    .number()
+    .int()
+    .min(0)
+    .max(24 * 60 * 60 * 1000)
+    .optional()
+    .describe(
+      'Subtask worker timeout in ms. 0 disables the timeout. Defaults to 300000 (5 minutes).',
+    ),
+});
+
+export type SubtaskConfig = z.infer<typeof SubtaskConfigSchema>;
+
 export const FailoverConfigSchema = z.object({
   enabled: z.boolean().default(true),
   timeoutMs: z.number().min(0).default(15000),
@@ -335,6 +352,7 @@ export const PluginConfigSchema = z
     sessionManager: SessionManagerConfigSchema.optional(),
     divoom: DivoomConfigSchema.optional(),
     todoContinuation: TodoContinuationConfigSchema.optional(),
+    subtask: SubtaskConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
   })

+ 3 - 1
src/index.ts

@@ -401,7 +401,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ...todoContinuationHook.tool,
       ast_grep_search,
       ast_grep_replace,
-      subtask: createSubtaskTool(ctx, subtaskState, depthTracker),
+      subtask: createSubtaskTool(ctx, subtaskState, depthTracker, {
+        timeoutMs: config.subtask?.timeoutMs,
+      }),
       read_session: createReadSessionTool(ctx.client, subtaskState),
     },
 

+ 40 - 0
src/tools/subtask/tools.test.ts

@@ -243,6 +243,46 @@ describe('subtask tool', () => {
       fs.rmSync(directory, { recursive: true, force: true });
     }
   });
+
+  test('honors custom timeoutMs option', async () => {
+    const directory = makeTempDir();
+    try {
+      const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
+      const sessionPrompt = mock(() => new Promise(() => {}));
+      const sessionMessages = mock(async () => ({ data: [] }));
+      const sessionAbort = mock(async () => ({}));
+      const state = createSubtaskState();
+      const tool = createSubtaskTool(
+        {
+          directory,
+          client: {
+            session: {
+              abort: sessionAbort,
+              create: sessionCreate,
+              messages: sessionMessages,
+              prompt: sessionPrompt,
+            },
+          },
+        } as any,
+        state,
+        undefined,
+        { timeoutMs: 5 },
+      );
+
+      await expect(
+        tool.execute({ prompt: 'Will time out' }, {
+          sessionID: 'ses_old',
+        } as any),
+      ).rejects.toThrow('Prompt timed out after 5ms');
+
+      expect(sessionAbort).toHaveBeenCalledWith({
+        path: { id: 'ses_new' },
+        query: { directory },
+      });
+    } finally {
+      fs.rmSync(directory, { recursive: true, force: true });
+    }
+  });
 });
 
 describe('read_session tool', () => {

+ 9 - 2
src/tools/subtask/tools.ts

@@ -18,9 +18,14 @@ import {
 import type { SubtaskState } from './state';
 
 export type OpencodeClient = PluginInput['client'];
-const SUBTASK_TIMEOUT_MS = 5 * 60 * 1000;
+export const DEFAULT_SUBTASK_TIMEOUT_MS = 5 * 60 * 1000;
 const SUBTASK_SUMMARY_TAG_REGEX = /<\/?subtask_summary>/g;
 
+export interface CreateSubtaskToolOptions {
+  /** Worker timeout in ms. 0 disables the timeout. */
+  timeoutMs?: number;
+}
+
 function normalizeSubtaskSummary(text: string): string {
   return text.replace(SUBTASK_SUMMARY_TAG_REGEX, '').trim();
 }
@@ -49,8 +54,10 @@ export function createSubtaskTool(
   ctx: PluginInput,
   state: SubtaskState,
   depthTracker?: SubagentDepthTracker,
+  options: CreateSubtaskToolOptions = {},
 ): ToolDefinition {
   const client = ctx.client;
+  const timeoutMs = options.timeoutMs ?? DEFAULT_SUBTASK_TIMEOUT_MS;
 
   return tool({
     description:
@@ -150,7 +157,7 @@ Do not spawn another subtask.`;
               ],
             },
           },
-          SUBTASK_TIMEOUT_MS,
+          timeoutMs,
           abortSignal,
         );
 

+ 38 - 0
src/utils/session.test.ts

@@ -57,6 +57,44 @@ describe('session utilities', () => {
     ).rejects.toThrow('Prompt timed out after 5ms');
   });
 
+  test('promptWithTimeout honors abort signal when timeout is disabled', async () => {
+    const controller = new AbortController();
+    const abort = mock(async () => ({}));
+    const prompt = mock(() => never());
+    const client = {
+      session: {
+        abort,
+        prompt,
+      },
+    } as any;
+
+    queueMicrotask(() => controller.abort());
+
+    await expect(
+      promptWithTimeout(
+        client,
+        { path: { id: 's1' }, body: { parts: [] } },
+        0,
+        controller.signal,
+      ),
+    ).rejects.toThrow('Prompt cancelled');
+  });
+
+  test('promptWithTimeout returns when prompt resolves with no timeout', async () => {
+    const abort = mock(async () => ({}));
+    const prompt = mock(async () => ({}));
+    const client = {
+      session: {
+        abort,
+        prompt,
+      },
+    } as any;
+
+    await expect(
+      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 0),
+    ).resolves.toBeUndefined();
+  });
+
   test('abortSessionWithTimeout rejects if abort hangs', async () => {
     const client = {
       session: {

+ 31 - 24
src/utils/session.ts

@@ -102,12 +102,8 @@ export async function promptWithTimeout(
 ): Promise<void> {
   if (signal?.aborted) throw new Error('Prompt cancelled');
 
-  if (timeoutMs <= 0) {
-    await client.session.prompt(args);
-    return;
-  }
-
   const sessionId = args.path.id;
+  const hasTimeout = timeoutMs > 0;
   let timer: ReturnType<typeof setTimeout> | undefined;
   let onAbort: (() => void) | undefined;
 
@@ -115,25 +111,36 @@ export async function promptWithTimeout(
     const promptPromise = client.session.prompt(args);
     promptPromise.catch(() => {});
 
-    await Promise.race([
-      promptPromise,
-      new Promise<never>((_, reject) => {
-        timer = setTimeout(() => {
-          reject(
-            new OperationTimeoutError(`Prompt timed out after ${timeoutMs}ms`),
-          );
-        }, timeoutMs);
-      }),
-      new Promise<never>((_, reject) => {
-        if (!signal) return;
-        if (signal.aborted) {
-          reject(new Error('Prompt cancelled'));
-          return;
-        }
-        onAbort = () => reject(new Error('Prompt cancelled'));
-        signal.addEventListener('abort', onAbort, { once: true });
-      }),
-    ]);
+    const racers: Array<Promise<unknown>> = [promptPromise];
+
+    if (hasTimeout) {
+      racers.push(
+        new Promise<never>((_, reject) => {
+          timer = setTimeout(() => {
+            reject(
+              new OperationTimeoutError(
+                `Prompt timed out after ${timeoutMs}ms`,
+              ),
+            );
+          }, timeoutMs);
+        }),
+      );
+    }
+
+    if (signal) {
+      racers.push(
+        new Promise<never>((_, reject) => {
+          if (signal.aborted) {
+            reject(new Error('Prompt cancelled'));
+            return;
+          }
+          onAbort = () => reject(new Error('Prompt cancelled'));
+          signal.addEventListener('abort', onAbort, { once: true });
+        }),
+      );
+    }
+
+    await Promise.race(racers);
   } catch (error) {
     if (error instanceof OperationTimeoutError) {
       try {