Browse Source

fix: persist background task results to disk to survive plugin reinitialization

BackgroundTaskManager stored tasks in a plain in-memory Map. When context
compaction fired in the parent session, the plugin reinitialized with a fresh
empty Map — all completed task results were lost, causing background_output()
to always return 'Task not found'.

Fix:
- completeTask() now writes the completed task to
  ~/.local/share/opencode/bg-tasks/<id>.json via persistTask()
- getResult() falls back to loadPersistedTask() when the task is not in
  memory, then re-registers it so subsequent calls hit the fast path

Both I/O operations are wrapped in try/catch so file system errors degrade
gracefully (log warning, don't crash). Uses getLogDir() from utils/logger
so state is co-located with logs under the same base directory.
Thomas Dyar 4 months ago
parent
commit
4b7c57e71f
2 changed files with 186 additions and 4 deletions
  1. 104 1
      src/background/background-manager.test.ts
  2. 82 3
      src/background/background-manager.ts

+ 104 - 1
src/background/background-manager.test.ts

@@ -1,4 +1,7 @@
-import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { SLIM_INTERNAL_INITIATOR_MARKER } from '../utils';
 import { SLIM_INTERNAL_INITIATOR_MARKER } from '../utils';
 import { BackgroundTaskManager } from './background-manager';
 import { BackgroundTaskManager } from './background-manager';
 
 
@@ -283,6 +286,106 @@ describe('BackgroundTaskManager', () => {
       expect(result).toBeDefined();
       expect(result).toBeDefined();
       expect(result?.id).toBe(task.id);
       expect(result?.id).toBe(task.id);
     });
     });
+
+    describe('disk persistence (survives manager reinitialization)', () => {
+      let testDir: string;
+      const origEnv = process.env.OPENCODE_LOG_DIR;
+
+      beforeEach(() => {
+        testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omo-bg-test-'));
+        process.env.OPENCODE_LOG_DIR = testDir;
+      });
+
+      afterEach(() => {
+        fs.rmSync(testDir, { recursive: true, force: true });
+        if (origEnv === undefined) {
+          delete process.env.OPENCODE_LOG_DIR;
+        } else {
+          process.env.OPENCODE_LOG_DIR = origEnv;
+        }
+      });
+
+      test('completed task is retrievable by a new manager instance after reinitialization', async () => {
+        const ctx = createMockContext({
+          sessionMessagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Task result here' }],
+              },
+            ],
+          },
+        });
+
+        // First manager: completes the task
+        const manager1 = new BackgroundTaskManager(ctx);
+        const task = manager1.launch({
+          agent: 'explorer',
+          prompt: 'test',
+          description: 'Persistence test task',
+          parentSessionId: 'parent-session',
+        });
+
+        await Promise.resolve();
+        await Promise.resolve();
+
+        await manager1.handleSessionStatus({
+          type: 'session.status',
+          properties: {
+            sessionID: task.sessionId,
+            status: { type: 'idle' },
+          },
+        });
+
+        expect(task.status).toBe('completed');
+        expect(task.result).toBe('Task result here');
+
+        // Simulate reinitialization: new manager with empty in-memory state
+        const manager2 = new BackgroundTaskManager(ctx);
+
+        // Should recover from disk
+        const recovered = manager2.getResult(task.id);
+        expect(recovered).not.toBeNull();
+        expect(recovered?.id).toBe(task.id);
+        expect(recovered?.status).toBe('completed');
+        expect(recovered?.result).toBe('Task result here');
+        expect(recovered?.description).toBe('Persistence test task');
+      });
+
+      test('failed task is also recoverable after reinitialization', async () => {
+        const ctx = createMockContext({
+          sessionCreateResult: { data: {} }, // causes launch failure
+        });
+
+        const manager1 = new BackgroundTaskManager(ctx);
+        const task = manager1.launch({
+          agent: 'explorer',
+          prompt: 'test',
+          description: 'Failing task',
+          parentSessionId: 'parent-session',
+        });
+
+        await Promise.resolve();
+        await Promise.resolve();
+
+        expect(task.status).toBe('failed');
+
+        const manager2 = new BackgroundTaskManager(ctx);
+        const recovered = manager2.getResult(task.id);
+        expect(recovered).not.toBeNull();
+        expect(recovered?.status).toBe('failed');
+        expect(recovered?.error).toBe('Failed to create background session');
+      });
+
+      test('returns null for task that never completed (no disk file)', () => {
+        const ctx = createMockContext();
+        const manager = new BackgroundTaskManager(ctx);
+
+        // Task that was only launched on a previous (lost) manager
+        const result = manager.getResult('bg_nonexistent99');
+        expect(result).toBeNull();
+      });
+    });
   });
   });
 
 
   describe('waitForCompletion', () => {
   describe('waitForCompletion', () => {

+ 82 - 3
src/background/background-manager.ts

@@ -13,6 +13,8 @@
  * - Supports task cancellation and result retrieval
  * - Supports task cancellation and result retrieval
  */
  */
 
 
+import * as fs from 'node:fs';
+import * as path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
 import { getDisabledAgents } from '../agents';
 import { getDisabledAgents } from '../agents';
 import type { BackgroundTaskConfig, PluginConfig } from '../config';
 import type { BackgroundTaskConfig, PluginConfig } from '../config';
@@ -27,7 +29,7 @@ import {
   createInternalAgentTextPart,
   createInternalAgentTextPart,
   resolveAgentVariant,
   resolveAgentVariant,
 } from '../utils';
 } from '../utils';
-import { log } from '../utils/logger';
+import { getLogDir, log } from '../utils/logger';
 import {
 import {
   extractSessionResult,
   extractSessionResult,
   type PromptBody,
   type PromptBody,
@@ -36,6 +38,66 @@ import {
 } from '../utils/session';
 } from '../utils/session';
 import { SubagentDepthTracker } from './subagent-depth';
 import { SubagentDepthTracker } from './subagent-depth';
 
 
+/** Persisted shape — only serializable fields, no methods or Map references. */
+interface PersistedTask {
+  id: string;
+  sessionId?: string;
+  parentSessionId: string;
+  description: string;
+  agent: string;
+  status: BackgroundTask['status'];
+  result?: string;
+  error?: string;
+  startedAt: string;
+  completedAt?: string;
+}
+
+function persistTask(task: BackgroundTask): void {
+  try {
+    const dir = path.join(getLogDir(), 'bg-tasks');
+    fs.mkdirSync(dir, { recursive: true });
+    const data: PersistedTask = {
+      id: task.id,
+      sessionId: task.sessionId,
+      parentSessionId: task.parentSessionId,
+      description: task.description,
+      agent: task.agent,
+      status: task.status,
+      result: task.result,
+      error: task.error,
+      startedAt: task.startedAt.toISOString(),
+      completedAt: task.completedAt?.toISOString(),
+    };
+    fs.writeFileSync(path.join(dir, `${task.id}.json`), JSON.stringify(data), 'utf-8');
+  } catch (e) {
+    log(`[background-manager] failed to persist task ${task.id}: ${e}`);
+  }
+}
+
+function loadPersistedTask(taskId: string): BackgroundTask | null {
+  try {
+    const file = path.join(getLogDir(), 'bg-tasks', `${taskId}.json`);
+    const data: PersistedTask = JSON.parse(fs.readFileSync(file, 'utf-8'));
+    return {
+      id: data.id,
+      sessionId: data.sessionId,
+      parentSessionId: data.parentSessionId,
+      description: data.description,
+      agent: data.agent,
+      status: data.status,
+      result: data.result,
+      error: data.error,
+      startedAt: new Date(data.startedAt),
+      completedAt: data.completedAt ? new Date(data.completedAt) : undefined,
+      // Not persisted; callers use status/result only
+      prompt: '',
+      config: { maxConcurrentStarts: 10 },
+    };
+  } catch {
+    return null;
+  }
+}
+
 type OpencodeClient = PluginInput['client'];
 type OpencodeClient = PluginInput['client'];
 
 
 /**
 /**
@@ -614,6 +676,10 @@ export class BackgroundTaskManager {
     log(`[background-manager] task ${status}: ${task.id}`, {
     log(`[background-manager] task ${status}: ${task.id}`, {
       description: task.description,
       description: task.description,
     });
     });
+
+    // Persist to disk so getResult() survives plugin reinitialization
+    // (e.g. after context compaction causes BackgroundTaskManager to be recreated)
+    persistTask(task);
   }
   }
 
 
   /**
   /**
@@ -638,11 +704,24 @@ export class BackgroundTaskManager {
   /**
   /**
    * Retrieve the current state of a background task.
    * Retrieve the current state of a background task.
    *
    *
+   * Checks in-memory first. If not found (e.g. after plugin reinitialization
+   * caused by context compaction), falls back to the persisted state on disk.
+   *
    * @param taskId - The task ID to retrieve
    * @param taskId - The task ID to retrieve
-   * @returns The task object, or null if not found
+   * @returns The task object, or null if not found in memory or on disk
    */
    */
   getResult(taskId: string): BackgroundTask | null {
   getResult(taskId: string): BackgroundTask | null {
-    return this.tasks.get(taskId) ?? null;
+    const inMemory = this.tasks.get(taskId);
+    if (inMemory) return inMemory;
+
+    // Fallback: task completed before this manager instance was created
+    const fromDisk = loadPersistedTask(taskId);
+    if (fromDisk) {
+      // Re-register in memory so subsequent calls are fast
+      this.tasks.set(taskId, fromDisk);
+      log(`[background-manager] restored task from disk: ${taskId}`);
+    }
+    return fromDisk;
   }
   }
 
 
   /**
   /**