Browse Source

Merge pull request #876 from alvinunreal/fix/task-lifecycle-guards

fix(tasks): prevent duplicate active task launches
Alvin 1 week ago
parent
commit
04cbb7a08c

+ 8 - 15
docs/background-orchestration.md

@@ -37,7 +37,6 @@ 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
@@ -145,7 +144,7 @@ Rules:
 - Review tasks can run in parallel with read-only discovery, but not with edits
   they are supposed to review.
 
-### 4. Wait, cancel, and reconcile
+### 4. Wait and cancel
 
 Background tasks are not complete until OpenCode injects their terminal result or
 hook-driven completion marks them terminal.
@@ -159,19 +158,13 @@ The orchestrator should use background completion events to:
 
 The orchestrator should 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.
-
-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
-explicitly acknowledged or used. The orchestrator should still verify it consumed
-the relevant outputs before finalizing.
+Cancellation is not rollback: if cancelling a writer, inspect its partial file
+changes before launching a replacement lane.
+
+Terminal jobs are reconciled automatically after their result is injected into
+the orchestrator session. That lifecycle state is not proof the output was used;
+the orchestrator must still verify it consumed the relevant result before
+finalizing.
 
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 against each other and the original user goal.

+ 0 - 7
docs/tools.md

@@ -39,7 +39,6 @@ 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
@@ -47,12 +46,6 @@ 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

+ 7 - 1
src/agents/orchestrator.ts

@@ -210,10 +210,15 @@ 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, 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.
 
+### Active Task Amendments
+- A task in the Active / Unreconciled section is still running and cannot receive another \`task\` call, even with its \`task_id\`. Do not try to resume, replace, or cancel it merely because the user adds to its existing scope.
+- For an additive request to a running lane, record the amendment in the parent conversation, tell the user it is queued, and wait for that lane's terminal result. Then resume the same specialist only after its session appears in Reusable Sessions.
+- Cancel a running task only when its current objective is genuinely obsolete or must be replaced. Never create-and-cancel speculative duplicate sessions.
+- A \`running [resumed]\` board label reflects lifecycle bookkeeping, not confirmation that a new instruction reached the specialist.
+
 ### Design Handoff Discipline
 - When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
 - Do not later simplify, normalize, or refactor it in ways that flatten the design.
@@ -226,6 +231,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
+- Only sessions listed under Reusable Sessions may be resumed. Active / Unreconciled sessions are not resumable.
 - When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
 - If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
 - Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.

+ 7 - 1
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -155,10 +155,15 @@ 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, 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.
 
+### Active Task Amendments
+- A task in the Active / Unreconciled section is still running and cannot receive another \`task\` call, even with its \`task_id\`. Do not try to resume, replace, or cancel it merely because the user adds to its existing scope.
+- For an additive request to a running lane, record the amendment in the parent conversation, tell the user it is queued, and wait for that lane's terminal result. Then resume the same specialist only after its session appears in Reusable Sessions.
+- Cancel a running task only when its current objective is genuinely obsolete or must be replaced. Never create-and-cancel speculative duplicate sessions.
+- A \`running [resumed]\` board label reflects lifecycle bookkeeping, not confirmation that a new instruction reached the specialist.
+
 ### Design Handoff Discipline
 - When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
 - Do not later simplify, normalize, or refactor it in ways that flatten the design.
@@ -171,6 +176,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
+- Only sessions listed under Reusable Sessions may be resumed. Active / Unreconciled sessions are not resumable.
 - When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
 - If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
 - Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.

+ 15 - 12
src/hooks/task-session-manager/index.test.ts

@@ -1163,12 +1163,13 @@ describe('task-session-manager hook', () => {
     const resumeBeforeLiveBusy = {
       args: { subagent_type: 'explorer', task_id: 'ses_timeout' },
     };
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
-      resumeBeforeLiveBusy,
-    );
-
-    expect(resumeBeforeLiveBusy.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+        resumeBeforeLiveBusy,
+      ),
+    ).rejects.toThrow('still running');
+    expect(resumeBeforeLiveBusy.args.task_id).toBe('ses_timeout');
 
     await hook.event({
       event: {
@@ -2373,7 +2374,7 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).not.toContain('err-1');
   });
 
-  test('running alias is not resumed by task', async () => {
+  test('running aliases fail closed instead of spawning a new task', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     board.registerLaunch({
@@ -2384,11 +2385,13 @@ describe('task-session-manager hook', () => {
     });
 
     const resume = { args: { subagent_type: 'explorer', task_id: 'exp-1' } };
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
-      resume,
-    );
-    expect(resume.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+        resume,
+      ),
+    ).rejects.toThrow('still running');
+    expect(resume.args.task_id).toBe('exp-1');
   });
 
   test('task alias is dropped when subagent_type is missing', async () => {

+ 40 - 42
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -87,6 +87,46 @@ export async function handleToolExecuteBefore(
     agentType,
     label,
   };
+  if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+    const requested = args.task_id.trim();
+    const remembered =
+      deps.backgroundJobBoard.resolveReusable(
+        input.sessionID,
+        requested,
+        agentType,
+      ) ??
+      deps.backgroundJobBoard.resolveRecoverable(
+        input.sessionID,
+        requested,
+        agentType,
+      );
+
+    if (!remembered) {
+      const knownManagedTask = deps.backgroundJobBoard.resolve(
+        input.sessionID,
+        requested,
+      );
+      if (knownManagedTask?.state === 'running') {
+        throw new Error(
+          `Task ${requested} is still running and cannot be resumed or amended with task(). Do not spawn or cancel a duplicate for an additive request. Wait for its terminal result, then resume the automatically reconciled session if follow-up work is still needed.`,
+        );
+      }
+
+      if (knownManagedTask) {
+        delete args.task_id;
+      } else if (RAW_SESSION_ID_PATTERN.test(requested)) {
+        pendingCall.resumedTaskId = requested;
+      } else {
+        delete args.task_id;
+      }
+    } else {
+      args.task_id = remembered.taskID;
+      deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
+      deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+      pendingCall.resumedTaskId = remembered.taskID;
+    }
+  }
+
   deps.pendingCallTracker.add(pendingCall);
   log(
     '[task-session-manager] tool.execute.before task — pending call created',
@@ -99,48 +139,6 @@ export async function handleToolExecuteBefore(
       inputSessionID: input.sessionID,
     },
   );
-
-  if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
-    return;
-  }
-
-  const requested = args.task_id.trim();
-  const remembered =
-    deps.backgroundJobBoard.resolveReusable(
-      input.sessionID,
-      requested,
-      agentType,
-    ) ??
-    deps.backgroundJobBoard.resolveRecoverable(
-      input.sessionID,
-      requested,
-      agentType,
-    );
-
-  if (!remembered) {
-    const knownManagedTask = deps.backgroundJobBoard.resolve(
-      input.sessionID,
-      requested,
-    );
-    if (knownManagedTask) {
-      delete args.task_id;
-      return;
-    }
-
-    if (RAW_SESSION_ID_PATTERN.test(requested)) {
-      pendingCall.resumedTaskId = requested;
-      deps.pendingCallTracker.add(pendingCall);
-      return;
-    }
-    delete args.task_id;
-    return;
-  }
-
-  args.task_id = remembered.taskID;
-  deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
-  deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
-  pendingCall.resumedTaskId = remembered.taskID;
-  deps.pendingCallTracker.add(pendingCall);
 }
 
 export async function handleToolExecuteAfter(

+ 1 - 10
src/index.ts

@@ -61,7 +61,6 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
-  createReconcileTaskTool,
   createWaitForUserTool,
   createWebfetchTool,
 } from './tools';
@@ -103,14 +102,13 @@ async function appLog(
 const HEALTH_CHECK = {
   minAgents: 5,
   // Default tool set when council and ACP agents are not configured:
-  // cancel_task, reconcile_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
+  // cancel_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',
@@ -200,7 +198,6 @@ 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>;
@@ -443,11 +440,6 @@ 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',
@@ -461,7 +453,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     tools = {
       ...cancelTaskTools,
-      ...reconcileTaskTools,
       ...waitForUserTools,
       ...acpRunTools,
       webfetch,

+ 2 - 15
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`, `reconcile-task.ts`, `wait-for-user.ts`, `background-job-board.ts` |
+| **Task Management** | Background task lifecycle and HITL continuation control | `cancel-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,17 +80,6 @@ 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
 
 ```
@@ -174,8 +163,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 ```
 Tools Layer → Background Layer
 ├─ cancel_task tool → BackgroundJobBoard.resolve() → abortSessionWithTimeout()
-├─ reconcile_task tool → BackgroundJobBoard.resolve() → BackgroundJobBoard.markReconciled()
-└─> Returns reconciliation status
+└─> Returns cancellation status
 
 Tools Layer → Config Layer
 ├─ acp_run tool → AcpAgentsConfig from config system
@@ -216,7 +204,6 @@ 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

+ 0 - 1
src/tools/index.ts

@@ -2,6 +2,5 @@
 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';

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

@@ -1,169 +0,0 @@
-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');
-  });
-});

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

@@ -1,117 +0,0 @@
-import { 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 };
-}