Jelajahi Sumber

fix(interview): eliminate circular import, add missing tests, fix fallback bug

- Extract createPerSessionInterviewServer to session-server.ts to break
  circular dependency between manager.ts and dashboard-manager.ts
- Add getState and field<K> tests to background-job-board.test.ts
- Add dashboard election failure fallback test to manager.test.ts
- Fix fallback bug: wiring createInterviewServer to existing service
  instead of discarding createPerSessionInterviewServer return value
Michael Henke 1 bulan lalu
induk
melakukan
66e2bd57a2

+ 22 - 3
src/interview/dashboard-manager.ts

@@ -1,3 +1,4 @@
+import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginConfig } from '../config';
 import { log } from '../utils';
@@ -6,7 +7,7 @@ import {
   readDashboardAuthFile,
   tryBecomeDashboard,
 } from './dashboard';
-import { createPerSessionInterviewServer } from './manager';
+import { createInterviewServer } from './server';
 import { createInterviewService } from './service';
 import type {
   InterviewRecord,
@@ -184,9 +185,27 @@ export function createDashboardManager(
         '[interview] dashboard election failed or unreachable. Falling back to per-session server.',
         { error: err instanceof Error ? err.message : String(err) },
       );
-      // Fallback: spawn a per-session server exactly like non-dashboard mode
+      // Fallback: wire up a local per-session server for the manager's
+      // service, exactly like the non-dashboard mode would.
       isDashboard = false;
-      createPerSessionInterviewServer(ctx, interviewConfig, outputFolder);
+      const resolvedOutputPath = path.join(ctx.directory, outputFolder);
+      const fallbackServer = createInterviewServer({
+        getState: async (interviewId) =>
+          service.getInterviewState(interviewId),
+        listInterviewFiles: async () => service.listInterviewFiles(),
+        listInterviews: () => service.listInterviews(),
+        submitAnswers: async (interviewId, answers) =>
+          service.submitAnswers(interviewId, answers),
+        submitBlockComment: async (interviewId, section, comment) =>
+          service.submitBlockComment(interviewId, section, comment),
+        submitChat: async (interviewId, message) =>
+          service.submitChat(interviewId, message),
+        handleNudgeAction: async (interviewId, action) =>
+          service.handleNudgeAction(interviewId, action),
+        outputFolder: resolvedOutputPath,
+        port: 0,
+      });
+      service.setBaseUrlResolver(() => fallbackServer.ensureStarted());
       service.setStatePushCallback(() => {}); // no-op on fallback
     } finally {
       initDone = true;

+ 66 - 0
src/interview/manager.test.ts

@@ -660,3 +660,69 @@ describe('interview manager - integration with real dashboard', () => {
     }
   });
 });
+
+describe('interview manager - dashboard election failure fallback', () => {
+  test('falls back to per-session mode when tryBecomeDashboard fails and dashboard is unreachable', async () => {
+    // Create a TCP server that blocks a port but immediately destroys
+    // connections. This simulates a port in use by a non-dashboard
+    // process, causing:
+    //   1. tryBecomeDashboard → probes fail, bind fails (EADDRINUSE),
+    //      returns null after retries
+    //   2. probeDashboard × 2 → fails (no valid HTTP response)
+    //   3. Throws → caught → falls back via createPerSessionInterviewServer
+    const tcpServer = createServer((socket) => {
+      socket.destroy();
+    });
+
+    const port = await new Promise<number>((resolve) => {
+      tcpServer.listen(0, () => {
+        const address = tcpServer.address();
+        if (address && typeof address !== 'string') {
+          resolve(address.port);
+        } else {
+          resolve(0);
+        }
+      });
+    });
+
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = createTestConfig({
+      port,
+      dashboard: true,
+    });
+
+    try {
+      const manager = createInterviewManager(ctx, config);
+
+      // handleCommandExecuteBefore calls ensureInitialized internally,
+      // which awaits initPromise. This naturally waits for all retries,
+      // probes, and fallback logic to complete before proceeding.
+      const output = {
+        parts: [] as Array<{ type: string; text?: string }>,
+      };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-fallback',
+          arguments: 'Fallback Test Idea',
+        },
+        output,
+      );
+
+      // Verify interview was created in per-session fallback mode
+      expect(output.parts.length).toBe(1);
+      expect(output.parts[0].type).toBe('text');
+      expect(output.parts[0].text).toContain('Fallback Test Idea');
+      expect(output.parts[0].text).toContain('<interview_state>');
+
+      // Verify interview file was created (per-session mode writes markdown)
+      const interviewDir = `${tempDir}/interview`;
+      const files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+      tcpServer.close();
+    }
+  });
+});

+ 2 - 43
src/interview/manager.ts

@@ -1,10 +1,8 @@
-import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
-import type { InterviewConfig, PluginConfig } from '../config';
+import type { PluginConfig } from '../config';
 import { DEFAULT_DASHBOARD_PORT } from './dashboard';
 import { createDashboardManager } from './dashboard-manager';
-import { createInterviewServer } from './server';
-import { createInterviewService } from './service';
+import { createPerSessionInterviewServer } from './session-server';
 
 export function createInterviewManager(
   ctx: PluginInput,
@@ -37,42 +35,3 @@ export function createInterviewManager(
   return createDashboardManager(ctx, config, dashboardPort, outputFolder);
 }
 
-export function createPerSessionInterviewServer(
-  ctx: PluginInput,
-  interviewConfig: InterviewConfig | undefined,
-  outputFolder: string,
-): {
-  registerCommand: (config: Record<string, unknown>) => void;
-  handleCommandExecuteBefore: (
-    input: { command: string; sessionID: string; arguments: string },
-    output: { parts: Array<{ type: string; text?: string }> },
-  ) => Promise<void>;
-  handleEvent: (input: {
-    event: { type: string; properties?: Record<string, unknown> };
-  }) => Promise<void>;
-} {
-  const service = createInterviewService(ctx, interviewConfig);
-  const resolvedOutputPath = path.join(ctx.directory, outputFolder);
-  const server = createInterviewServer({
-    getState: async (interviewId) => service.getInterviewState(interviewId),
-    listInterviewFiles: async () => service.listInterviewFiles(),
-    listInterviews: () => service.listInterviews(),
-    submitAnswers: async (interviewId, answers) =>
-      service.submitAnswers(interviewId, answers),
-    submitBlockComment: async (interviewId, section, comment) =>
-      service.submitBlockComment(interviewId, section, comment),
-    submitChat: async (interviewId, message) =>
-      service.submitChat(interviewId, message),
-    handleNudgeAction: async (interviewId, action) =>
-      service.handleNudgeAction(interviewId, action),
-    outputFolder: resolvedOutputPath,
-    port: 0,
-  });
-  service.setBaseUrlResolver(() => server.ensureStarted());
-  return {
-    registerCommand: (c) => service.registerCommand(c),
-    handleCommandExecuteBefore: async (input, output) =>
-      service.handleCommandExecuteBefore(input, output),
-    handleEvent: async (input) => service.handleEvent(input),
-  };
-}

+ 45 - 0
src/interview/session-server.ts

@@ -0,0 +1,45 @@
+import path from 'node:path';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { InterviewConfig } from '../config';
+import { createInterviewServer } from './server';
+import { createInterviewService } from './service';
+
+export function createPerSessionInterviewServer(
+  ctx: PluginInput,
+  interviewConfig: InterviewConfig | undefined,
+  outputFolder: string,
+): {
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }) => Promise<void>;
+} {
+  const service = createInterviewService(ctx, interviewConfig);
+  const resolvedOutputPath = path.join(ctx.directory, outputFolder);
+  const server = createInterviewServer({
+    getState: async (interviewId) => service.getInterviewState(interviewId),
+    listInterviewFiles: async () => service.listInterviewFiles(),
+    listInterviews: () => service.listInterviews(),
+    submitAnswers: async (interviewId, answers) =>
+      service.submitAnswers(interviewId, answers),
+    submitBlockComment: async (interviewId, section, comment) =>
+      service.submitBlockComment(interviewId, section, comment),
+    submitChat: async (interviewId, message) =>
+      service.submitChat(interviewId, message),
+    handleNudgeAction: async (interviewId, action) =>
+      service.handleNudgeAction(interviewId, action),
+    outputFolder: resolvedOutputPath,
+    port: 0,
+  });
+  service.setBaseUrlResolver(() => server.ensureStarted());
+  return {
+    registerCommand: (c) => service.registerCommand(c),
+    handleCommandExecuteBefore: async (input, output) =>
+      service.handleCommandExecuteBefore(input, output),
+    handleEvent: async (input) => service.handleEvent(input),
+  };
+}

+ 29 - 0
src/utils/background-job-board.test.ts

@@ -783,5 +783,34 @@ describe('BackgroundJobBoard', () => {
       expect(board.getParentSessionID('job-1')).toBe('parent-1');
       expect(board.getParentSessionID('unknown-1')).toBeUndefined();
     });
+
+    test('getState: returns state after mutation, undefined for unknown taskID', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'job-1',
+        parentSessionID: 'parent-1',
+        agent: 'fixer',
+        now: 100,
+      });
+
+      expect(board.getState('job-1')).toBe('running');
+      board.updateStatus({ taskID: 'job-1', state: 'completed', now: 200 });
+      expect(board.getState('job-1')).toBe('completed');
+      expect(board.getState('unknown-1')).toBeUndefined();
+    });
+
+    test('field<K>: returns specific field for valid taskID, undefined for unknown', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'job-1',
+        parentSessionID: 'parent-1',
+        agent: 'oracle',
+        now: 100,
+      });
+
+      expect(board.field('job-1', 'alias')).toBe('ora-1');
+      expect(board.field('job-1', 'parentSessionID')).toBe('parent-1');
+      expect(board.field('unknown-1', 'alias')).toBeUndefined();
+    });
   });
 });