Ver código fonte

Harden Divoom display updates

Alvin Unreal 4 meses atrás
pai
commit
14af1915f0
3 arquivos alterados com 168 adições e 46 exclusões
  1. 6 0
      scripts/verify-release-artifact.ts
  2. 89 15
      src/divoom/manager.test.ts
  3. 73 31
      src/divoom/manager.ts

+ 6 - 0
scripts/verify-release-artifact.ts

@@ -28,7 +28,13 @@ const packagedRequiredFiles = [
   'dist/index.js',
   'dist/index.d.ts',
   'dist/cli/index.js',
+  'dist/divoom/council.gif',
+  'dist/divoom/designer.gif',
+  'dist/divoom/explorer.gif',
+  'dist/divoom/fixer.gif',
   'dist/divoom/intro.gif',
+  'dist/divoom/librarian.gif',
+  'dist/divoom/oracle.gif',
   'dist/divoom/orchestrator.gif',
   'oh-my-opencode-slim.schema.json',
   'src/skills/simplify/SKILL.md',

+ 89 - 15
src/divoom/manager.test.ts

@@ -13,10 +13,16 @@ function createGifAssets(dir: string, names: string[]): void {
 describe('DivoomManager', () => {
   let tempDir: string;
   let calls: DivoomSenderCall[];
+  let pythonPath: string;
+  let scriptPath: string;
 
   beforeEach(() => {
     tempDir = mkdtempSync(path.join(tmpdir(), 'divoom-test-'));
     calls = [];
+    pythonPath = path.join(tempDir, 'python');
+    scriptPath = path.join(tempDir, 'divoom_send.py');
+    writeFileSync(pythonPath, 'python');
+    writeFileSync(scriptPath, 'script');
     createGifAssets(tempDir, [
       'intro.gif',
       'orchestrator.gif',
@@ -34,13 +40,17 @@ describe('DivoomManager', () => {
     return new DivoomManager(
       {
         enabled: config.enabled ?? true,
+        python: pythonPath,
+        script: scriptPath,
+      },
+      (call) => {
+        calls.push(call);
       },
-      (call) => calls.push(call),
       { assetDir: tempDir },
     );
   }
 
-  test('does nothing when disabled', () => {
+  test('does nothing when disabled', async () => {
     const manager = createManager({ enabled: false });
 
     manager.onPluginLoad();
@@ -50,20 +60,22 @@ describe('DivoomManager', () => {
       args: { subagent_type: 'explorer' },
     });
     manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
 
     expect(calls).toHaveLength(0);
   });
 
-  test('shows intro on plugin load when enabled', () => {
+  test('shows intro on plugin load when enabled', async () => {
     const manager = createManager();
 
     manager.onPluginLoad();
+    await manager.flush();
 
     expect(calls).toHaveLength(1);
     expect(calls[0].args[1]).toBe(path.join(tempDir, 'intro.gif'));
   });
 
-  test('shows task agent then orchestrator after a single task', () => {
+  test('shows task agent then orchestrator after a single task', async () => {
     const manager = createManager();
 
     manager.onTaskStart({
@@ -71,7 +83,9 @@ describe('DivoomManager', () => {
       callId: 'call-1',
       args: { subagent_type: 'explorer' },
     });
+    await manager.flush();
     manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
 
     expect(calls.map((call) => call.args[1])).toEqual([
       path.join(tempDir, 'explorer.gif'),
@@ -79,7 +93,7 @@ describe('DivoomManager', () => {
     ]);
   });
 
-  test('shows orchestrator while busy and intro when idle', () => {
+  test('shows orchestrator while busy and intro when idle', async () => {
     const manager = createManager();
 
     manager.onOrchestratorStatus({
@@ -87,11 +101,13 @@ describe('DivoomManager', () => {
       status: 'busy',
       isOrchestrator: true,
     });
+    await manager.flush();
     manager.onOrchestratorStatus({
       sessionId: 'parent',
       status: 'idle',
       isOrchestrator: true,
     });
+    await manager.flush();
 
     expect(calls.map((call) => call.args[1])).toEqual([
       path.join(tempDir, 'orchestrator.gif'),
@@ -99,7 +115,7 @@ describe('DivoomManager', () => {
     ]);
   });
 
-  test('does not override active child agent with orchestrator status', () => {
+  test('idle clears active child state and returns to intro', async () => {
     const manager = createManager();
 
     manager.onTaskStart({
@@ -107,31 +123,35 @@ describe('DivoomManager', () => {
       callId: 'call-1',
       args: { subagent_type: 'explorer' },
     });
+    await manager.flush();
     manager.onOrchestratorStatus({
       sessionId: 'parent',
       status: 'busy',
       isOrchestrator: true,
     });
+    await manager.flush();
     manager.onOrchestratorStatus({
       sessionId: 'parent',
       status: 'idle',
       isOrchestrator: true,
     });
+    await manager.flush();
     manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
     manager.onOrchestratorStatus({
       sessionId: 'parent',
       status: 'idle',
       isOrchestrator: true,
     });
+    await manager.flush();
 
     expect(calls.map((call) => call.args[1])).toEqual([
       path.join(tempDir, 'explorer.gif'),
-      path.join(tempDir, 'orchestrator.gif'),
       path.join(tempDir, 'intro.gif'),
     ]);
   });
 
-  test('keeps first agent visible for parallel tasks', () => {
+  test('keeps first agent visible for parallel tasks', async () => {
     const manager = createManager();
 
     manager.onTaskStart({
@@ -139,13 +159,17 @@ describe('DivoomManager', () => {
       callId: 'call-1',
       args: { subagent_type: 'explorer' },
     });
+    await manager.flush();
     manager.onTaskStart({
       parentSessionId: 'parent',
       callId: 'call-2',
       args: { subagent_type: 'fixer' },
     });
+    await manager.flush();
     manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
     manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-2' });
+    await manager.flush();
 
     expect(calls.map((call) => call.args[1])).toEqual([
       path.join(tempDir, 'explorer.gif'),
@@ -153,7 +177,7 @@ describe('DivoomManager', () => {
     ]);
   });
 
-  test('falls back to orchestrator gif for unknown agents', () => {
+  test('falls back to orchestrator gif for unknown agents', async () => {
     const manager = createManager();
 
     manager.onTaskStart({
@@ -161,20 +185,25 @@ describe('DivoomManager', () => {
       callId: 'call-1',
       args: { subagent_type: 'custom-agent' },
     });
+    await manager.flush();
 
     expect(calls.map((call) => call.args[1])).toEqual([
       path.join(tempDir, 'orchestrator.gif'),
     ]);
   });
 
-  test('uses configured sender settings and gif overrides', () => {
+  test('uses configured sender settings and gif overrides', async () => {
     const customGif = path.join(tempDir, 'custom-oracle.gif');
+    const customPython = path.join(tempDir, 'custom-python');
+    const customScript = path.join(tempDir, 'custom-divoom-send.py');
     writeFileSync(customGif, 'gif');
+    writeFileSync(customPython, 'python');
+    writeFileSync(customScript, 'script');
     const manager = new DivoomManager(
       {
         enabled: true,
-        python: '/custom/python',
-        script: '/custom/divoom_send.py',
+        python: customPython,
+        script: customScript,
         size: 64,
         fps: 12,
         speed: 250,
@@ -182,7 +211,9 @@ describe('DivoomManager', () => {
         posterizeBits: 4,
         gifs: { oracle: customGif },
       },
-      (call) => calls.push(call),
+      (call) => {
+        calls.push(call);
+      },
       { assetDir: tempDir },
     );
 
@@ -191,11 +222,12 @@ describe('DivoomManager', () => {
       callId: 'call-1',
       args: { subagent_type: 'oracle' },
     });
+    await manager.flush();
 
     expect(calls[0]).toEqual({
-      command: '/custom/python',
+      command: customPython,
       args: [
-        '/custom/divoom_send.py',
+        customScript,
         customGif,
         '--size',
         '64',
@@ -210,4 +242,46 @@ describe('DivoomManager', () => {
       ],
     });
   });
+
+  test('drops stale queued sends and keeps latest requested gif', async () => {
+    const manager = createManager();
+
+    manager.onPluginLoad();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'fixer' },
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'fixer.gif'),
+    ]);
+  });
+
+  test('missing executable does not poison future attempts', async () => {
+    const manager = new DivoomManager(
+      {
+        enabled: true,
+        python: path.join(tempDir, 'missing-python'),
+        script: scriptPath,
+      },
+      (call) => {
+        calls.push(call);
+      },
+      { assetDir: tempDir },
+    );
+
+    manager.onPluginLoad();
+    await manager.flush();
+    manager.onPluginLoad();
+    await manager.flush();
+
+    expect(calls).toHaveLength(0);
+  });
 });

+ 73 - 31
src/divoom/manager.ts

@@ -14,7 +14,7 @@ export type DivoomSenderCall = {
   args: string[];
 };
 
-export type DivoomSender = (call: DivoomSenderCall) => void;
+export type DivoomSender = (call: DivoomSenderCall) => Promise<void> | void;
 
 type ParentState = {
   activeCalls: Map<string, string>;
@@ -79,7 +79,9 @@ export class DivoomManager {
   private assetDir: string | null;
   private config: DivoomConfig;
   private parentStates = new Map<string, ParentState>();
+  private latestRequestedGifPath?: string;
   private lastGifPath?: string;
+  private sendQueue = Promise.resolve();
 
   constructor(
     config?: Partial<DivoomConfig>,
@@ -143,14 +145,14 @@ export class DivoomManager {
     if (!input.sessionId || !input.isOrchestrator) return;
 
     const state = this.parentStates.get(input.sessionId);
-    if (state && state.activeCalls.size > 0) return;
-
     if (input.status === 'busy') {
+      if (state && state.activeCalls.size > 0) return;
       this.show('orchestrator');
       return;
     }
 
     if (input.status === 'idle') {
+      this.parentStates.delete(input.sessionId);
       this.show('intro');
     }
   }
@@ -189,30 +191,63 @@ export class DivoomManager {
       return;
     }
 
-    if (gifPath === this.lastGifPath) return;
-    this.lastGifPath = gifPath;
-
-    try {
-      this.sender({
-        command: this.config.python,
-        args: [
-          this.config.script,
-          gifPath,
-          '--size',
-          String(this.config.size),
-          '--fps',
-          String(this.config.fps),
-          '--speed',
-          String(this.config.speed),
-          '--max-frames',
-          String(this.config.maxFrames),
-          '--posterize-bits',
-          String(this.config.posterizeBits),
-        ],
+    if (gifPath === this.latestRequestedGifPath) return;
+    this.latestRequestedGifPath = gifPath;
+
+    const call = {
+      command: this.config.python,
+      args: [
+        this.config.script,
+        gifPath,
+        '--size',
+        String(this.config.size),
+        '--fps',
+        String(this.config.fps),
+        '--speed',
+        String(this.config.speed),
+        '--max-frames',
+        String(this.config.maxFrames),
+        '--posterize-bits',
+        String(this.config.posterizeBits),
+      ],
+    };
+
+    this.sendQueue = this.sendQueue
+      .catch(() => {})
+      .then(async () => {
+        if (gifPath !== this.latestRequestedGifPath) return;
+        if (!existsSync(this.config.python)) {
+          this.clearLatestIfCurrent(gifPath);
+          log('[divoom] python executable not found', this.config.python);
+          return;
+        }
+        if (!existsSync(this.config.script)) {
+          this.clearLatestIfCurrent(gifPath);
+          log('[divoom] sender script not found', this.config.script);
+          return;
+        }
+
+        try {
+          await this.sender(call);
+          this.lastGifPath = gifPath;
+          log('[divoom] showing gif', { agent, gifPath });
+        } catch (error) {
+          this.clearLatestIfCurrent(gifPath);
+          log('[divoom] failed to send gif', String(error));
+        }
       });
-      log('[divoom] showing gif', { agent, gifPath });
-    } catch (error) {
-      log('[divoom] failed to spawn sender', String(error));
+  }
+
+  async flush(): Promise<void> {
+    await this.sendQueue.catch(() => {});
+  }
+
+  private clearLatestIfCurrent(gifPath: string): void {
+    if (this.latestRequestedGifPath === gifPath) {
+      this.latestRequestedGifPath = undefined;
+    }
+    if (this.lastGifPath === gifPath) {
+      this.lastGifPath = undefined;
     }
   }
 }
@@ -221,12 +256,19 @@ function isTaskArgs(value: unknown): value is TaskArgs {
   return typeof value === 'object' && value !== null;
 }
 
-function defaultSender(call: DivoomSenderCall): void {
-  const child = spawn(call.command, call.args, {
-    detached: true,
-    stdio: 'ignore',
+function defaultSender(call: DivoomSenderCall): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(call.command, call.args, {
+      detached: true,
+      stdio: 'ignore',
+    });
+
+    child.once('error', reject);
+    child.once('spawn', () => {
+      child.unref();
+      resolve();
+    });
   });
-  child.unref();
 }
 
 export function createDivoomManager(