Browse Source

fix: add reconcile_task tool to prevent self-amplifying task reconciliation loop

Adds a dedicated `reconcile_task` tool that lets the orchestrator
explicitly mark terminal background tasks as reconciled in the
Background Job Board. This is a state-only operation that does not
invoke any specialist session.

Previously, the orchestrator prompt instructed 'reconcile any terminal
jobs' but no tool existed to do this. Calling `task()` without
`task_id` to 'reconcile' created new sessions instead, causing a
self-amplifying loop of unreconciled tasks (#863).

Changes:
- New src/tools/reconcile-task.ts with 11 tests
- Register in src/tools/index.ts and src/index.ts
- Update orchestrator prompt to reference reconcile_task
- Update docs: tools.md, background-orchestration.md, codemap.md
- Add to BASELINE_TOOL_NAMES and health check

Fixes #863
kaze 1 week ago
parent
commit
8ac5aa8595

+ 2 - 2
codemap.md

@@ -41,7 +41,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/hooks/json-error-recovery/` | JSON/tool-output recovery helpers for malformed model responses. | [View Map](src/hooks/json-error-recovery/codemap.md) |
 | `src/hooks/phase-reminder/` | Message-transform reminder enforcing orchestrator workflow phases. | [View Map](src/hooks/phase-reminder/codemap.md) |
 | `src/hooks/post-file-tool-nudge/` | Post-read/write reminder path that nudges delegation-aware next steps. | [View Map](src/hooks/post-file-tool-nudge/codemap.md) |
-| `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, and stale-session cleanup. | [View Map](src/hooks/task-session-manager/codemap.md) |
+| `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, stale-session cleanup, and terminal task reconciliation. | [View Map](src/hooks/task-session-manager/codemap.md) |
 | `src/interview/` | `/interview` feature: per-session and dashboard prompt/state orchestration, persistence, local UI, and cross-process coordination. | [View Map](src/interview/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and per-provider MCP definitions. | [View Map](src/mcp/codemap.md) |
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
@@ -97,7 +97,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
   multiplexer behavior remains on the upstream path.
 - Council mode is implemented in `src/agents/`; the orchestrator dispatches councillors as subagents and the council agent synthesizes responses.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
-- `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
+- `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, terminal task reconciliation, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
 - `src/interview/` hooks into plugin command/event surfaces exposed by `src/index.ts`.
 

+ 6 - 0
docs/background-orchestration.md

@@ -37,6 +37,7 @@ The required native/background-control tools are:
 | `task(..., background: true)` | Start a specialist in the background and immediately return a task ID |
 | hook-driven completion | OpenCode injects terminal background task results automatically |
 | `cancel_task` | Plugin-provided tool to cancel a tracked background task by task ID or Background Job Board alias |
+| `reconcile_task` | Plugin-provided tool to mark a terminal task as reconciled in the Background Job Board (state-only, no specialist invocation) |
 | `wait_for_user` | Plugin-provided orchestrator tool that pauses automatic continuation while the user performs external manual work |
 
 If these are not available, the scheduler cannot use the default background
@@ -161,6 +162,11 @@ running lane is obsolete, wrong, or conflicts with a safer replacement plan.
 Cancellation is not rollback: if cancelling a writer, inspect and reconcile
 partial file changes before launching a replacement lane.
 
+Before final response, use `reconcile_task` to mark any terminal jobs shown in
+the Background Job Board as reconciled. Idle-based auto-reconciliation also
+handles this when the orchestrator session goes idle, but explicit reconciliation
+via `reconcile_task` is preferred for deterministic state management.
+
 **Note on reconciliation:** Idle-based reconciliation is a heuristic. A job marked
 as reconciled means its terminal result was injected into an orchestrator turn
 that completed and the parent returned to idle; it is not proof the result was

+ 7 - 0
docs/tools.md

@@ -39,6 +39,7 @@ Fast, structural code search and refactoring - more powerful than plain text gre
 | Tool | Description |
 |------|-------------|
 | `cancel_task` | Cancel a tracked background specialist task by native task ID or Background Job Board alias |
+| `reconcile_task` | Mark a terminal (completed/error/cancelled) background task as reconciled so it no longer appears as unreconciled |
 | `wait_for_user` | Pause automatic incomplete-todo continuation until the next distinct external user message |
 
 `cancel_task` is orchestrator-only. It only cancels background tasks tracked for
@@ -46,6 +47,12 @@ the current orchestrator session, and it does not roll back partial edits. After
 cancelling a write-capable task, inspect and reconcile file changes before
 launching replacement work.
 
+`reconcile_task` is orchestrator-only. It marks a terminal task as reconciled
+in the Background Job Board without invoking any specialist session. This is a
+state-only operation — use it before final response when a terminal result has
+been received and consumed. Idle-based auto-reconciliation also handles this
+when the orchestrator session goes idle.
+
 `wait_for_user` is also orchestrator-only. The orchestrator uses it as the final
 tool action after providing concrete instructions for external manual work. Its
 `reason` is diagnostic text only; the plugin does not parse assistant prose to

+ 1 - 1
src/agents/orchestrator.ts

@@ -210,7 +210,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
 - Before local edits or another writer task, compare against running task scopes.
 - Parallel background tasks are allowed only when their write scopes do not conflict.
-- Before final response, reconcile any terminal jobs shown in the Background Job Board.
+- Before final response, use \`reconcile_task\` to mark any terminal jobs shown in the Background Job Board as reconciled.
 - Use \`cancel_task\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan.
 - Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before launching a replacement lane.
 

+ 10 - 1
src/index.ts

@@ -61,6 +61,7 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
+  createReconcileTaskTool,
   createWaitForUserTool,
   createWebfetchTool,
 } from './tools';
@@ -102,13 +103,14 @@ async function appLog(
 const HEALTH_CHECK = {
   minAgents: 5,
   // Default tool set when council and ACP agents are not configured:
-  // cancel_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
+  // cancel_task, reconcile_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
   minTools: 5,
   minMcps: 1,
 } as const;
 
 const BASELINE_TOOL_NAMES = new Set([
   'cancel_task',
+  'reconcile_task',
   'wait_for_user',
   'webfetch',
   'ast_grep_search',
@@ -198,6 +200,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
+  let reconcileTaskTools: ReturnType<typeof createReconcileTaskTool>;
   let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
@@ -440,6 +443,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
+    reconcileTaskTools = createReconcileTaskTool({
+      backgroundJobBoard: backgroundJobCoordinator,
+      shouldManageSession: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+    });
     waitForUserTools = createWaitForUserTool({
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
@@ -453,6 +461,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     tools = {
       ...cancelTaskTools,
+      ...reconcileTaskTools,
       ...waitForUserTools,
       ...acpRunTools,
       webfetch,

+ 15 - 2
src/tools/codemap.md

@@ -27,7 +27,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 | Tool Family | Purpose | Key Components |
 |------------|---------|----------------|
 | **Council** | Multi-LLM consensus synthesis (orchestrator dispatches councillors as subagents) | `agents/council.ts`, `agents/index.ts` |
-| **Task Management** | Background task lifecycle and HITL continuation control | `cancel-task.ts`, `wait-for-user.ts`, `background-job-board.ts` |
+| **Task Management** | Background task lifecycle and HITL continuation control | `cancel-task.ts`, `reconcile-task.ts`, `wait-for-user.ts`, `background-job-board.ts` |
 | **ACP Integration** | External agent protocol execution | `acp-run.ts`, ACP client implementation |
 | **Code Intelligence** | AST-based code manipulation | `ast-grep/` directory, `tools.ts` |
 | **Web Fetching** | Intelligent web content retrieval | `smartfetch/` directory, `tool.ts` |
@@ -80,6 +80,17 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
    └─> Returns cancellation confirmation
 ```
 
+### Task Reconciliation Flow
+
+```
+1. Orchestrator invokes reconcile_task tool
+   ├─> Validates calling agent is 'orchestrator'
+   ├─> Resolves task_id/alias to BackgroundJobBoard entry
+   ├─> Validates job is terminal-unreconciled
+   ├─> Calls BackgroundJobBoard.markReconciled()
+   └─> Returns reconciliation confirmation
+```
+
 ### Explicit User-Wait Flow
 
 ```
@@ -163,7 +174,8 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 ```
 Tools Layer → Background Layer
 ├─ cancel_task tool → BackgroundJobBoard.resolve() → abortSessionWithTimeout()
-└─> Returns cancellation status
+├─ reconcile_task tool → BackgroundJobBoard.resolve() → BackgroundJobBoard.markReconciled()
+└─> Returns reconciliation status
 
 Tools Layer → Config Layer
 ├─ acp_run tool → AcpAgentsConfig from config system
@@ -204,6 +216,7 @@ export { ast_grep_replace, ast_grep_search } from './ast-grep';
 
 // Task management
 export { createCancelTaskTool } from './cancel-task';
+export { createReconcileTaskTool } from './reconcile-task';
 export { createWaitForUserTool } from './wait-for-user';
 
 // Preset management

+ 1 - 0
src/tools/index.ts

@@ -2,5 +2,6 @@
 export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
+export { createReconcileTaskTool } from './reconcile-task';
 export { createWebfetchTool } from './smartfetch';
 export { createWaitForUserTool } from './wait-for-user';

+ 169 - 0
src/tools/reconcile-task.test.ts

@@ -0,0 +1,169 @@
+import { describe, expect, test } from 'bun:test';
+import { BackgroundJobBoard } from '../utils/background-job-board';
+import { createReconcileTaskTool } from './reconcile-task';
+
+function createTool(overrides?: {
+  shouldManageSession?: (sessionID: string) => boolean;
+}) {
+  const board = new BackgroundJobBoard();
+  const tools = createReconcileTaskTool({
+    backgroundJobBoard: board,
+    shouldManageSession: overrides?.shouldManageSession ?? (() => true),
+  });
+
+  return { board, reconcileTask: tools.reconcile_task };
+}
+
+const context = { sessionID: 'parent-1', agent: 'orchestrator' } as any;
+
+describe('reconcile_task tool', () => {
+  test('reconciles a terminal-unreconciled task by task ID', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+
+    const output = await reconcileTask.execute({ task_id: 'ses_1' }, context);
+
+    expect(String(output)).toContain('state: reconciled');
+    expect(String(output)).toContain('Task reconciled');
+    expect(board.get('ses_1')?.state).toBe('reconciled');
+    expect(board.get('ses_1')?.terminalUnreconciled).toBe(false);
+  });
+
+  test('reconciles a terminal-unreconciled task by alias', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+
+    const output = await reconcileTask.execute({ task_id: 'ora-1' }, context);
+
+    expect(String(output)).toContain('state: reconciled');
+    expect(board.get('ses_1')?.state).toBe('reconciled');
+  });
+
+  test('reconciles an errored task', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'error' });
+
+    const output = await reconcileTask.execute({ task_id: 'ses_1' }, context);
+
+    expect(String(output)).toContain('state: reconciled');
+    expect(board.get('ses_1')?.state).toBe('reconciled');
+  });
+
+  test('reconciles a cancelled task', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    board.markCancelled('ses_1', 'obsolete');
+
+    const output = await reconcileTask.execute({ task_id: 'ses_1' }, context);
+
+    expect(String(output)).toContain('state: reconciled');
+    expect(board.get('ses_1')?.state).toBe('reconciled');
+  });
+
+  test('includes reason in output when provided', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+
+    const output = await reconcileTask.execute(
+      { task_id: 'ses_1', reason: 'result consumed' },
+      context,
+    );
+
+    expect(String(output)).toContain('Reason: result consumed');
+  });
+
+  test('returns already-reconciled message for non-terminal tasks', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+
+    const output = await reconcileTask.execute({ task_id: 'ses_1' }, context);
+
+    expect(String(output)).toContain('already reconciled');
+    expect(String(output)).toContain('state: running');
+  });
+
+  test('returns error for unknown task ID', async () => {
+    const { reconcileTask } = createTool();
+
+    const output = await reconcileTask.execute(
+      { task_id: 'ses_unknown' },
+      context,
+    );
+
+    expect(String(output)).toContain('state: unknown');
+    expect(String(output)).toContain('unknown or unowned');
+  });
+
+  test('does not reconcile tasks owned by a different parent', async () => {
+    const { board, reconcileTask } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-2',
+      agent: 'explorer',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+
+    const output = await reconcileTask.execute({ task_id: 'ses_1' }, context);
+
+    expect(String(output)).toContain('state: unknown');
+    expect(board.get('ses_1')?.state).toBe('completed');
+    expect(board.get('ses_1')?.terminalUnreconciled).toBe(true);
+  });
+
+  test('denies non-orchestrator agents', async () => {
+    const { reconcileTask } = createTool();
+
+    await expect(
+      reconcileTask.execute({ task_id: 'ses_1' }, {
+        sessionID: 'parent-1',
+        agent: 'fixer',
+      } as any),
+    ).rejects.toThrow('orchestrator');
+  });
+
+  test('denies unmanaged sessions', async () => {
+    const { reconcileTask } = createTool({
+      shouldManageSession: () => false,
+    });
+
+    await expect(
+      reconcileTask.execute({ task_id: 'ses_1' }, context),
+    ).rejects.toThrow('orchestrator sessions');
+  });
+
+  test('requires task_id', async () => {
+    const { reconcileTask } = createTool();
+
+    await expect(
+      reconcileTask.execute({ task_id: '  ' }, context),
+    ).rejects.toThrow('requires task_id');
+  });
+});

+ 121 - 0
src/tools/reconcile-task.ts

@@ -0,0 +1,121 @@
+import {
+  type PluginInput,
+  type ToolDefinition,
+  tool,
+} from '@opencode-ai/plugin';
+import type { BackgroundJobStore } from '../utils/background-job-store';
+import { log } from '../utils/logger';
+
+const z = tool.schema;
+
+interface ReconcileTaskToolOptions {
+  backgroundJobBoard: BackgroundJobStore;
+  shouldManageSession: (sessionID: string) => boolean;
+}
+
+export function createReconcileTaskTool(
+  options: ReconcileTaskToolOptions,
+): Record<string, ToolDefinition> {
+  const reconcile_task = tool({
+    description: `Reconcile a completed background specialist task in the Background Job Board.
+
+Marks a terminal (completed, error, or cancelled) task as reconciled so it no longer appears as unreconciled.
+This is a state-only operation — it does not invoke or resume any specialist session.
+Use when a terminal task result has been received and consumed, before issuing a final response.
+
+Accepts either the native task_id/session ID or the parent-scoped alias shown in the Background Job Board.`,
+    args: {
+      task_id: z
+        .string()
+        .describe(
+          'Background task ID or Background Job Board alias to reconcile',
+        ),
+      reason: z
+        .string()
+        .optional()
+        .describe('Optional short reason for reconciliation'),
+    },
+    async execute(args, toolContext) {
+      const parentSessionID = toolContext?.sessionID;
+      if (!parentSessionID)
+        throw new Error('reconcile_task requires sessionID');
+      if (toolContext.agent && toolContext.agent !== 'orchestrator') {
+        throw new Error('reconcile_task can only be used by orchestrator');
+      }
+      if (!options.shouldManageSession(parentSessionID)) {
+        throw new Error(
+          'reconcile_task can only be used in orchestrator sessions',
+        );
+      }
+
+      const requested = args.task_id.trim();
+      if (!requested) throw new Error('reconcile_task requires task_id');
+
+      const job = options.backgroundJobBoard.resolve(
+        parentSessionID,
+        requested,
+      );
+      log('[reconcile-task] request received', {
+        parentSessionID,
+        requested,
+        resolvedTaskID: job?.taskID,
+        alias: job
+          ? options.backgroundJobBoard.field(job.taskID, 'alias')
+          : undefined,
+        state: job
+          ? options.backgroundJobBoard.field(job.taskID, 'state')
+          : undefined,
+        terminalUnreconciled: job
+          ? options.backgroundJobBoard.field(job.taskID, 'terminalUnreconciled')
+          : undefined,
+      });
+
+      if (!job) {
+        log('[reconcile-task] unknown or unowned task', {
+          parentSessionID,
+          requested,
+        });
+        return [
+          `task_id: ${requested}`,
+          'state: unknown',
+          '',
+          '<task_error>',
+          'unknown or unowned background task',
+          '</task_error>',
+        ].join('\n');
+      }
+
+      if (!job.terminalUnreconciled) {
+        const state = options.backgroundJobBoard.getState(job.taskID);
+        log('[reconcile-task] task already reconciled or not terminal', {
+          taskID: job.taskID,
+          state,
+        });
+        return [
+          `task_id: ${job.taskID}`,
+          `state: ${state ?? 'unknown'}`,
+          '',
+          'Task is already reconciled or not in a terminal state.',
+        ].join('\n');
+      }
+
+      const updated = options.backgroundJobBoard.markReconciled(job.taskID);
+      log('[reconcile-task] marked reconciled', {
+        taskID: job.taskID,
+        alias: options.backgroundJobBoard.field(job.taskID, 'alias'),
+        previousTerminalState: job.terminalState,
+        reason: args.reason,
+      });
+
+      const finalState = updated?.state ?? 'reconciled';
+      return [
+        `task_id: ${job.taskID}`,
+        `state: ${finalState}`,
+        '',
+        `Task reconciled.${args.reason ? ` Reason: ${args.reason}` : ''}`,
+      ].join('\n');
+    },
+  });
+
+  return { reconcile_task };
+}