Browse Source

feat: add serial/parallel execution mode for council councillors (#218)

Jacob Myers 2 weeks ago
parent
commit
098091148f
3 changed files with 123 additions and 38 deletions
  1. 9 0
      oh-my-opencode-slim.schema.json
  2. 21 1
      src/config/council-schema.ts
  3. 93 37
      src/council/council-manager.ts

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

@@ -505,6 +505,15 @@
             "type": "string",
             "pattern": "^[^/\\s]+\\/[^\\s]+$"
           }
+        },
+        "councillor_execution_mode": {
+          "default": "parallel",
+          "description": "Execution mode for councillors. \"serial\" runs them one at a time (required for single-model systems). \"parallel\" runs them concurrently (default, faster for multi-model systems).",
+          "type": "string",
+          "enum": [
+            "parallel",
+            "serial"
+          ]
         }
       },
       "required": [

+ 21 - 1
src/config/council-schema.ts

@@ -122,6 +122,19 @@ export const CouncilMasterConfigSchema = z.object({
 export type CouncilMasterConfig = z.infer<typeof CouncilMasterConfigSchema>;
 
 /**
+ * Execution mode for councillors.
+ * - parallel: Run all councillors concurrently (default, fastest for multi-model systems)
+ * - serial: Run councillors one at a time (required for single-model systems to avoid conflicts)
+ */
+export const CouncillorExecutionModeSchema = z
+  .enum(['parallel', 'serial'])
+  .default('parallel')
+  .describe(
+    'Execution mode for councillors. Use "serial" for single-model systems to avoid conflicts. ' +
+      'Use "parallel" for multi-model systems for faster execution.',
+  );
+
+/**
  * Top-level council configuration.
  *
  * Example JSONC:
@@ -137,7 +150,8 @@ export type CouncilMasterConfig = z.infer<typeof CouncilMasterConfigSchema>;
  *       }
  *     },
  *     "master_timeout": 300000,
- *     "councillors_timeout": 180000
+ *     "councillors_timeout": 180000,
+ *     "councillor_execution_mode": "serial"
  *   }
  * }
  * ```
@@ -165,9 +179,15 @@ export const CouncilConfigSchema = z.object({
       'Fallback models for the council master. Tried in order if the primary model fails. ' +
         'Example: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"]',
     ),
+  councillor_execution_mode: CouncillorExecutionModeSchema.describe(
+    'Execution mode for councillors. "serial" runs them one at a time (required for single-model systems). "parallel" runs them concurrently (default, faster for multi-model systems).',
+  ),
 });
 
 export type CouncilConfig = z.infer<typeof CouncilConfigSchema>;
+export type CouncillorExecutionMode = z.infer<
+  typeof CouncillorExecutionModeSchema
+>;
 
 /**
  * A sensible default council configuration that users can copy into their

+ 93 - 37
src/council/council-manager.ts

@@ -122,6 +122,7 @@ export class CouncilManager {
 
     const councillorsTimeout = councilConfig.councillors_timeout ?? 180000;
     const masterTimeout = councilConfig.master_timeout ?? 300000;
+    const executionMode = councilConfig.councillor_execution_mode ?? 'parallel';
 
     const councillorCount = Object.keys(preset.councillors).length;
 
@@ -138,12 +139,13 @@ export class CouncilManager {
       },
     );
 
-    // Phase 1: Run councillors in parallel
+    // Phase 1: Run councillors (parallel or serial based on config)
     const councillorResults = await this.runCouncillors(
       prompt,
       preset.councillors,
       parentSessionId,
       councillorsTimeout,
+      executionMode,
     );
 
     const completedCount = councillorResults.filter(
@@ -325,18 +327,24 @@ export class CouncilManager {
     councillors: Record<string, CouncillorConfig>,
     parentSessionId: string,
     timeout: number,
+    executionMode: 'parallel' | 'serial' = 'parallel',
   ): Promise<CouncilResult['councillorResults']> {
     const entries = Object.entries(councillors);
-    const promises = entries.map(([name, config], index) =>
-      (async () => {
-        // Stagger launches to avoid tmux split-window collisions
-        if (index > 0) {
-          await new Promise((r) =>
-            setTimeout(r, index * COUNCILLOR_STAGGER_MS),
-          );
-        }
-
+    const results: Array<{
+      name: string;
+      model: string;
+      status: 'completed' | 'failed' | 'timed_out';
+      result?: string;
+      error?: string;
+    }> = [];
+
+    if (executionMode === 'serial') {
+      // Serial execution: run each councillor one at a time
+      for (const [name, config] of entries) {
         const modelLabel = shortModelLabel(config.model);
+        log(
+          `[council-manager] Running councillor "${name}" (${modelLabel}) serially`,
+        );
 
         try {
           const result = await this.runAgentSession({
@@ -350,52 +358,100 @@ export class CouncilManager {
             includeReasoning: false,
           });
 
-          return {
+          results.push({
             name,
             model: config.model,
             status: 'completed' as const,
             result,
-          };
+          });
         } catch (error) {
           const msg = error instanceof Error ? error.message : String(error);
 
-          return {
+          results.push({
             name,
             model: config.model,
             status: msg.includes('timed out')
               ? ('timed_out' as const)
               : ('failed' as const),
             error: `Councillor "${name}": ${msg}`,
-          };
+          });
         }
-      })(),
-    );
+      }
+    } else {
+      // Parallel execution (default): run all councillors concurrently
+      const promises = entries.map(([name, config], index) =>
+        (async () => {
+          // Stagger launches to avoid tmux split-window collisions
+          if (index > 0) {
+            await new Promise((r) =>
+              setTimeout(r, index * COUNCILLOR_STAGGER_MS),
+            );
+          }
+
+          const modelLabel = shortModelLabel(config.model);
+
+          try {
+            const result = await this.runAgentSession({
+              parentSessionId,
+              title: `Council ${name} (${modelLabel})`,
+              agent: 'councillor',
+              model: config.model,
+              promptText: formatCouncillorPrompt(prompt, config.prompt),
+              variant: config.variant,
+              timeout,
+              includeReasoning: false,
+            });
+
+            return {
+              name,
+              model: config.model,
+              status: 'completed' as const,
+              result,
+            };
+          } catch (error) {
+            const msg = error instanceof Error ? error.message : String(error);
+
+            return {
+              name,
+              model: config.model,
+              status: msg.includes('timed out')
+                ? ('timed_out' as const)
+                : ('failed' as const),
+              error: `Councillor "${name}": ${msg}`,
+            };
+          }
+        })(),
+      );
 
-    const settled = await Promise.allSettled(promises);
+      const settled = await Promise.allSettled(promises);
 
-    return settled.map((result, index) => {
-      const [name, cfg] = entries[index];
+      for (let index = 0; index < settled.length; index++) {
+        const result = settled[index];
+        const [name, cfg] = entries[index];
 
-      if (result.status === 'fulfilled') {
-        return {
-          name,
-          model: cfg.model,
-          status: result.value.status,
-          result: result.value.result,
-          error: result.value.error,
-        };
+        if (result.status === 'fulfilled') {
+          results.push({
+            name,
+            model: cfg.model,
+            status: result.value.status,
+            result: result.value.result,
+            error: result.value.error,
+          });
+        } else {
+          results.push({
+            name,
+            model: cfg.model,
+            status: 'failed' as const,
+            error:
+              result.reason instanceof Error
+                ? result.reason.message
+                : String(result.reason),
+          });
+        }
       }
+    }
 
-      return {
-        name,
-        model: cfg.model,
-        status: 'failed' as const,
-        error:
-          result.reason instanceof Error
-            ? result.reason.message
-            : String(result.reason),
-      };
-    });
+    return results;
   }
 
   // -------------------------------------------------------------------------