Просмотр исходного кода

feat(tasks): rebuild task lifecycle controls

Alvin Unreal 1 месяц назад
Родитель
Сommit
693aa4b79a
38 измененных файлов с 2688 добавлено и 1867 удалено
  1. 22 7
      docs/background-orchestration.md
  2. 1 1
      docs/loop-engineering-research.md
  3. 1 1
      docs/opencode-v2-compatibility.md
  4. 15 4
      docs/tools.md
  5. 1 1
      src/agents/custom.test.ts
  6. 42 25
      src/agents/index.test.ts
  7. 14 5
      src/agents/index.ts
  8. 4 3
      src/agents/orchestrator.ts
  9. 2 2
      src/codemap.md
  10. 13 10
      src/health-check.test.ts
  11. 8 3
      src/health-check.ts
  12. 4 3
      src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap
  13. 9 2
      src/hooks/task-session-manager/event-router.ts
  14. 11 1
      src/hooks/task-session-manager/idle-reconciliation.ts
  15. 55 4
      src/hooks/task-session-manager/index.test.ts
  16. 15 0
      src/hooks/task-session-manager/index.ts
  17. 354 0
      src/hooks/task-session-manager/revived-run-tracker.test.ts
  18. 453 0
      src/hooks/task-session-manager/revived-run-tracker.ts
  19. 1 1
      src/hooks/task-session-manager/tool-execute-hooks.ts
  20. 5 0
      src/index.test.ts
  21. 43 9
      src/index.ts
  22. 60 624
      src/tools/cancel-task.test.ts
  23. 220 447
      src/tools/cancel-task.ts
  24. 27 8
      src/tools/codemap.md
  25. 2 1
      src/tools/index.ts
  26. 288 0
      src/tools/task-message.test.ts
  27. 237 0
      src/tools/task-message.ts
  28. 0 474
      src/tools/task-nudge.test.ts
  29. 0 160
      src/tools/task-nudge.ts
  30. 1 53
      src/tools/task-policy.ts
  31. 253 0
      src/tools/task-revive.test.ts
  32. 241 0
      src/tools/task-revive.ts
  33. 134 13
      src/utils/background-job-board.test.ts
  34. 78 4
      src/utils/background-job-board.ts
  35. 47 0
      src/utils/background-job-coordinator.test.ts
  36. 14 0
      src/utils/background-job-coordinator.ts
  37. 12 0
      src/utils/background-job-store.ts
  38. 1 1
      src/utils/session.ts

+ 22 - 7
docs/background-orchestration.md

@@ -30,13 +30,17 @@ enabled:
 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
 ```
 
-The required native/background-control tools are:
+The task API and background-control tools are:
 
 | Tool | Purpose |
 |------|---------|
 | `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 |
+| `task_status` | Check the status of a tracked task |
+| `task_result` | Retrieve a tracked task's result |
+| `task_message` | Queue a non-interrupting message and return `queued` |
+| `task_cancel` | Stop a generation while retaining its session |
+| `task_revive` | Resume a retained session with a new instruction |
 | `wait_for_user` | Plugin-provided orchestrator tool that pauses automatic orchestrator wakes while the user performs external manual work |
 
 If these are not available, the scheduler cannot use the default background
@@ -144,7 +148,7 @@ Rules:
 - Review tasks can run in parallel with read-only discovery, but not with edits
   they are supposed to review.
 
-### 4. Wait and cancel
+### 4. Wait, message, cancel, and revive
 
 Background tasks are not complete until OpenCode injects their terminal result or
 hook-driven completion marks them terminal.
@@ -156,10 +160,16 @@ The orchestrator should use background completion events to:
 - collect outputs before final response,
 - surface failures or blocked tasks clearly.
 
-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 its partial file
-changes before launching a replacement lane.
+Use `task_status` to inspect a task and `task_result` to collect its result.
+`task_message` queues a non-interrupting message and returns `queued`; it does
+not stop the current generation. Use `task_cancel` to stop a generation while
+retaining its session, then inspect and reconcile any partial file changes before
+launching replacement work. Use `task_revive` to resume a retained session with a
+new instruction.
+
+A cancelled or errored retained session may be revived immediately once its
+retained state has been verified safe. Acknowledgement controls parent and
+job-board consumption and reusable-pool display, not same-session revival.
 
 Terminal jobs are reconciled automatically after their result is injected into
 the orchestrator session. That lifecycle state is not proof the output was used;
@@ -312,6 +322,11 @@ The prompt/runtime treats background tasks as a small job board:
 | result | Final task output once terminal |
 | status certainty | `status uncertain` when the live status map is malformed or unavailable; it never implies completion |
 
+Cancelled and errored sessions can remain retained for a later `task_revive`.
+They may be revived immediately once their retained state has been verified safe.
+Acknowledgement controls parent and job-board consumption and reusable-pool
+display, not same-session revival.
+
 The current todo list can represent user-visible work, but task IDs and file
 ownership need to be explicit in the orchestrator's working context.
 

+ 1 - 1
docs/loop-engineering-research.md

@@ -129,7 +129,7 @@ Osmani's framework identifies five primitives that compose a loop, plus durable
 | Depth tracking | Yes | Yes | Native (OpenCode's subagent_depth) |
 | Session reuse | Yes | Yes | Yes (BackgroundJobBoard) |
 | Job tracking | Limited | Limited | Yes (Background Job Board with aliases) |
-| Cancellation | Yes | Yes | Yes (cancel_task tool) |
+| Cancellation | Yes | Yes | Yes (`task_cancel` tool) |
 | Parallel dispatch | Yes | Yes | Yes (explicit in orchestrator prompt) |
 
 **Verdict:** All three have sub-agent support. OpenCode's is the most structured with 9 specialized agents, a formal Background Job Board, session reuse, and a dedicated orchestrator that never implements directly.

+ 1 - 1
docs/opencode-v2-compatibility.md

@@ -69,7 +69,7 @@ the rest.
 | Orchestrator + specialist agents | ✅ | ✅ | |
 | Agent prompts / system injection | ✅ | ✅ | via `session.hook("context")` |
 | Delegation to subagents | ✅ `task` | ✅ `subagent` | prompts rewritten for v2 |
-| Tools (ast-grep, webfetch, cancel_task, wait_for_user, acp_run) | ✅ | ✅* | `*` ast-grep/webfetch need `@ast-grep/napi`/`jsdom` resolvable |
+| Tools (ast-grep, webfetch, task_message, task_cancel, task_revive, wait_for_user, acp_run) | ✅ | ✅* | `*` ast-grep/webfetch need `@ast-grep/napi`/`jsdom` resolvable |
 | Slash commands `/deepwork` `/reflect` `/loop` | ✅ | ✅ | |
 | Message transforms (phase reminder, skills filter, image routing, display-name rewrite) | ✅ | ✅ | |
 | Event handling (session tracking, lifecycle) | ✅ | ✅ | |

+ 15 - 4
docs/tools.md

@@ -44,14 +44,25 @@ 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 |
+| `task` | Start a specialist task and return its task ID |
+| `task_status` | Check the status of a task |
+| `task_result` | Retrieve a task's result |
+| `task_message` | Queue a non-interrupting message and return `queued` |
+| `task_cancel` | Stop a generation while retaining its session |
+| `task_revive` | Resume a retained session with a new instruction |
 | `wait_for_user` | Pause automatic orchestrator wake prompts until the next distinct external user message |
 
-`cancel_task` is orchestrator-only. It only cancels background tasks tracked for
-the current orchestrator session, and it does not roll back partial edits. After
-cancelling a write-capable task, inspect and reconcile file changes before
+The task controls use the task ID or Background Job Board alias for the task being
+managed. `task_message` does not interrupt the current generation. `task_cancel`
+stops the generation but retains its session; it does not roll back partial edits.
+After cancelling a write-capable task, inspect and reconcile file changes before
 launching replacement work.
 
+`task_revive` resumes a retained session with a new instruction. A cancelled or
+errored retained session may be revived immediately once its retained state has
+been verified safe. Acknowledgement controls parent and job-board consumption and
+reusable-pool display, not same-session revival.
+
 `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/custom.test.ts

@@ -504,7 +504,7 @@ describe('permission edge cases', () => {
       (orchestrator?.config.permission as Record<string, unknown>)?.question,
     ).toBeDefined();
     expect(
-      (orchestrator?.config.permission as Record<string, unknown>)?.cancel_task,
+      (orchestrator?.config.permission as Record<string, unknown>)?.task_cancel,
     ).toBeDefined();
   });
 });

+ 42 - 25
src/agents/index.test.ts

@@ -191,13 +191,22 @@ describe('orchestrator agent', () => {
     ).toBe('allow');
   });
 
-  test('orchestrator is allowed to invoke cancel_task', () => {
+  test('orchestrator is allowed to invoke task-control tools', () => {
     const agents = createAgents(runtimeFor());
     const orchestrator = agents.find((a) => a.name === 'orchestrator');
-    expect(
-      (orchestrator as { config: { permission: Record<string, unknown> } })
-        .config.permission.cancel_task,
-    ).toBe('allow');
+    const permission = (
+      orchestrator as { config: { permission: Record<string, unknown> } }
+    ).config.permission;
+
+    for (const toolName of [
+      'task_cancel',
+      'task_message',
+      'task_revive',
+      'task_status',
+      'task_result',
+    ]) {
+      expect(permission[toolName]).toBe('allow');
+    }
   });
 
   test('orchestrator is allowed to invoke wait_for_user', () => {
@@ -353,31 +362,39 @@ describe('tool permissions', () => {
     expect(agents.some((a) => a.name === 'alpha')).toBe(false);
   });
 
-  test('oracle is denied access to cancel_task', () => {
+  test('oracle is denied access to task-control tools by default', () => {
     const agents = createAgents(runtimeFor());
     const oracle = agents.find((a) => a.name === 'oracle');
-    expect(
-      (oracle as { config: { permission: Record<string, unknown> } }).config
-        .permission.cancel_task,
-    ).toBe('deny');
-  });
-
-  test('explorer is denied access to cancel_task', () => {
-    const agents = createAgents(runtimeFor());
-    const explorer = agents.find((a) => a.name === 'explorer');
-    expect(
-      (explorer as { config: { permission: Record<string, unknown> } }).config
-        .permission.cancel_task,
-    ).toBe('deny');
+    const permission = (
+      oracle as { config: { permission: Record<string, unknown> } }
+    ).config.permission;
+
+    for (const toolName of [
+      'task_cancel',
+      'task_message',
+      'task_revive',
+      'task_status',
+      'task_result',
+    ]) {
+      expect(permission[toolName]).toBe('deny');
+    }
   });
 
-  test('fixer is denied access to cancel_task', () => {
-    const agents = createAgents(runtimeFor());
-    const fixer = agents.find((a) => a.name === 'fixer');
+  test('explicit task_cancel permission overrides the default gate', () => {
+    const agents = createAgents(
+      runtimeFor({
+        agents: {
+          oracle: {
+            permission: { task_cancel: 'allow' },
+          },
+        },
+      }),
+    );
+    const oracle = agents.find((a) => a.name === 'oracle');
     expect(
-      (fixer as { config: { permission: Record<string, unknown> } }).config
-        .permission.cancel_task,
-    ).toBe('deny');
+      (oracle as { config: { permission: Record<string, unknown> } }).config
+        .permission.task_cancel,
+    ).toBe('allow');
   });
 
   test('subagents are denied access to wait_for_user', () => {

+ 14 - 5
src/agents/index.ts

@@ -39,7 +39,13 @@ type AgentFactory = (
   customAppendPrompt?: string,
 ) => AgentDefinition;
 
-const CANCEL_TASK_ALLOWED_AGENTS = new Set(['orchestrator']);
+const TASK_CONTROL_TOOL_NAMES = [
+  'task_cancel',
+  'task_message',
+  'task_revive',
+  'task_status',
+  'task_result',
+] as const;
 const SAFE_AGENT_ALIAS_RE = /^[a-z][a-z0-9_-]*$/i;
 
 function getPrimaryModelFromOverride(
@@ -285,9 +291,12 @@ function applyDefaultPermissions(
 
   // Respect explicit deny on question (councillor)
   const questionPerm = existing.question === 'deny' ? 'deny' : 'allow';
-  const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name)
-    ? (existing.cancel_task ?? 'allow')
-    : 'deny';
+  const taskControlPermissions = Object.fromEntries(
+    TASK_CONTROL_TOOL_NAMES.map((toolName) => [
+      toolName,
+      existing[toolName] ?? (agent.name === 'orchestrator' ? 'allow' : 'deny'),
+    ]),
+  );
   const waitForUserPerm =
     agent.name === 'orchestrator'
       ? (existing.wait_for_user ?? 'allow')
@@ -296,7 +305,7 @@ function applyDefaultPermissions(
   agent.config.permission = {
     ...existing,
     question: questionPerm,
-    cancel_task: cancelTaskPerm,
+    ...taskControlPermissions,
     wait_for_user: waitForUserPerm,
     // Apply skill permissions as nested object under 'skill' key
     skill: {

+ 4 - 3
src/agents/orchestrator.ts

@@ -222,15 +222,16 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - \`task_result\` returns only a completed specialist's final assistant message, and can be called by any parent session that owns the task. Never use \`task(..., task_id: ...)\` to fetch output: that resumes the child and starts new model work.
 - Before retrying completed work whose result appears missing or incomplete, retrieve it with \`task_result\`. Dispatch again only when the retrieved result does not satisfy the objective.
 - For a live child task, call \`task_status\` for read-only state inspection. There is no safe live-prompt channel: never use \`task(..., task_id: ...)\` as a progress check or instruction because it resumes model work.
-- If \`task_status\` reports \`possibly_stuck: true\`, use \`task_nudge\` once to admit a concise follow-up without resuming or aborting the child; never use it as a polling loop.
+- For a live child task, use \`task_message\` only to queue a concise, non-interrupting communication. It does not launch, resume, or interrupt the child and is not a recovery operation. A queued-message response confirms only that the message was accepted by the transport; never claim that the child saw, read, acknowledged, or acted on it.
+- Use \`task_cancel\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan. Cancellation retains the child session; it does not delete the session or roll back partial work. Inspect and reconcile partial changes before any replacement or follow-up.
+- Use \`task_revive\` for the cancel-and-resume operation when the same retained child session should continue with a new prompt. It may cancel the current generation and then start a new generation in that existing session; do not use it as a status check or claim that the new prompt was seen until the child produces a result.
 - Prefer \`task(..., background: true)\` for delegated work that can run independently.
 - For work already chosen for delegation, launch independent specialist lanes in the background so the orchestrator stays unblocked and can reconcile results when they return.
 - Never reissue an unchanged task to the same specialist after a rejection; adjust its scope or context before retrying.
 - 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.
-- 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.
+- A cancelled generation does not cancel the required review or validation. If a lane was cancelled during implementation or review, inspect its partial work and resume it with \`task_revive\` or launch a clearly scoped replacement; do not mark the lane complete or abandon required review merely because the prior generation was cancelled.
 
 ${
   wakeSchedulerEnabled

+ 2 - 2
src/codemap.md

@@ -47,7 +47,7 @@ OpenCode Core → Plugin Initialization (index.ts)
 1. **Config Loading**: `loadPluginConfig()` reads and validates plugin configuration
 2. **Agent Creation**: `createAgents()` instantiates agent definitions with prompts and permissions
 3. **Agent Configuration**: `getAgentConfigs()` merges defaults with user overrides and runtime presets
-4. **Tool Registration**: Tools are created conditionally based on config (council, cancel_task, webfetch, AST-grep)
+4. **Tool Registration**: Tools are created conditionally based on config (council, task_cancel, task_message, task_revive, webfetch, AST-grep)
 5. **MCP Registration**: Built-in MCPs are created (filesystem, resource, tools, etc.)
 6. **Multiplexer Setup**: Multiplexer session manager initialized for task tool sessions
 7. **Hook Initialization**: Auto-update checker, phase reminders, skill filters, etc.
@@ -167,4 +167,4 @@ Key event flows:
 - **Dynamic Agent Registration**: Support runtime agent addition/removal
 - **State Migration**: Versioned state format for breaking changes
 - **TUI Customization**: Allow user-defined sidebar layouts
-- **Performance Metrics**: Track and display plugin performance in TUI
+- **Performance Metrics**: Track and display plugin performance in TUI

+ 13 - 10
src/health-check.test.ts

@@ -3,22 +3,25 @@ import { minimumExpectedToolCount } from './health-check';
 
 describe('plugin health thresholds', () => {
   test('accounts only for intentionally disabled baseline tools', () => {
-    expect(minimumExpectedToolCount()).toBe(5);
-    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(4);
+    expect(minimumExpectedToolCount()).toBe(9);
+    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(8);
     expect(minimumExpectedToolCount(['wait_for_user', 'wait_for_user'])).toBe(
-      4,
+      8,
     );
-    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(5);
-    expect(minimumExpectedToolCount([], false)).toBe(4);
-    expect(minimumExpectedToolCount(['wait_for_user'], false)).toBe(3);
-    expect(minimumExpectedToolCount(['webfetch'], false)).toBe(4);
+    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(9);
+    expect(minimumExpectedToolCount([], false)).toBe(8);
+    expect(minimumExpectedToolCount(['wait_for_user'], false)).toBe(7);
+    expect(minimumExpectedToolCount(['webfetch'], false)).toBe(8);
+    expect(
+      minimumExpectedToolCount(['task_cancel', 'task_message', 'task_revive']),
+    ).toBe(6);
   });
 
   test('never throws when disabledTools is not an array', () => {
     // Regression test: a malformed/non-array config.disabled_tools value
     // must degrade to "nothing disabled" instead of crashing plugin init.
-    expect(minimumExpectedToolCount('' as any)).toBe(5);
-    expect(minimumExpectedToolCount(null as any)).toBe(5);
-    expect(minimumExpectedToolCount({} as any)).toBe(5);
+    expect(minimumExpectedToolCount('' as any)).toBe(9);
+    expect(minimumExpectedToolCount(null as any)).toBe(9);
+    expect(minimumExpectedToolCount({} as any)).toBe(9);
   });
 });

+ 8 - 3
src/health-check.ts

@@ -16,13 +16,18 @@
 export 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.
-  minTools: 5,
+  // task_cancel, task_message, task_revive, task_status, task_result,
+  // wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
+  minTools: 9,
   minMcps: 1,
 } as const;
 
 const BASELINE_TOOL_NAMES = new Set([
-  'cancel_task',
+  'task_cancel',
+  'task_message',
+  'task_revive',
+  'task_status',
+  'task_result',
   'wait_for_user',
   'webfetch',
   'ast_grep_search',

+ 4 - 3
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -155,15 +155,16 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - \`task_result\` returns only a completed specialist's final assistant message, and can be called by any parent session that owns the task. Never use \`task(..., task_id: ...)\` to fetch output: that resumes the child and starts new model work.
 - Before retrying completed work whose result appears missing or incomplete, retrieve it with \`task_result\`. Dispatch again only when the retrieved result does not satisfy the objective.
 - For a live child task, call \`task_status\` for read-only state inspection. There is no safe live-prompt channel: never use \`task(..., task_id: ...)\` as a progress check or instruction because it resumes model work.
-- If \`task_status\` reports \`possibly_stuck: true\`, use \`task_nudge\` once to admit a concise follow-up without resuming or aborting the child; never use it as a polling loop.
+- For a live child task, use \`task_message\` only to queue a concise, non-interrupting communication. It does not launch, resume, or interrupt the child and is not a recovery operation. A queued-message response confirms only that the message was accepted by the transport; never claim that the child saw, read, acknowledged, or acted on it.
+- Use \`task_cancel\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan. Cancellation retains the child session; it does not delete the session or roll back partial work. Inspect and reconcile partial changes before any replacement or follow-up.
+- Use \`task_revive\` for the cancel-and-resume operation when the same retained child session should continue with a new prompt. It may cancel the current generation and then start a new generation in that existing session; do not use it as a status check or claim that the new prompt was seen until the child produces a result.
 - Prefer \`task(..., background: true)\` for delegated work that can run independently.
 - For work already chosen for delegation, launch independent specialist lanes in the background so the orchestrator stays unblocked and can reconcile results when they return.
 - Never reissue an unchanged task to the same specialist after a rejection; adjust its scope or context before retrying.
 - 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.
-- 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.
+- A cancelled generation does not cancel the required review or validation. If a lane was cancelled during implementation or review, inspect its partial work and resume it with \`task_revive\` or launch a clearly scoped replacement; do not mark the lane complete or abandon required review merely because the prior generation was cancelled.
 
 #### End Turn After Background Tasks
 After spawning all independent background tasks and any remaining non-overlapping work, end the turn immediately with a brief status message. Do not call \`wait_for_user\` to await background task completion — the system notifies you automatically via the Background Job Board when tasks finish, and the orchestrator wake scheduler resumes you. Do not poll for status with repeated tool calls. The correct flow is: launch tasks → brief status → end turn → completion hook or wake scheduler resumes → reconcile results.

+ 9 - 2
src/hooks/task-session-manager/event-router.ts

@@ -18,6 +18,7 @@ import type {
   RetainedBoardSnapshotState,
 } from './board-injection';
 import type { PendingTaskCall } from './pending-call-tracker';
+import type { RevivedRunTracker } from './revived-run-tracker';
 
 type BackgroundJobRecord = NonNullable<ReturnType<BackgroundJobStore['get']>>;
 
@@ -287,6 +288,7 @@ export async function handleEvent(
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     observeSyntheticTerminalPart?: (part: unknown) => void;
+    revivedRunTracker?: RevivedRunTracker;
   },
 ): Promise<void> {
   deps.inputWaits.trackInputWait(input.event);
@@ -379,6 +381,7 @@ export async function handleEvent(
 
   if (input.event.type === 'server.instance.disposed') {
     deps.backgroundJobSupervisor?.dispose();
+    deps.revivedRunTracker?.dispose();
     deps.pendingCallTracker.clearAll?.();
     deps.retainedBoardSnapshots.clear();
     eventFenceMap(deps.backgroundJobBoard).clear();
@@ -523,13 +526,15 @@ export async function handleEvent(
           ) {
             return;
           }
-          deps.backgroundJobBoard.updateStatus({
+          const updated = deps.backgroundJobBoard.updateStatus({
             taskID: sessionId,
             state: 'error',
+            expectedGeneration: observation?.generation,
             resultSummary:
               (props?.error as { message?: string } | undefined)?.message ??
               'Session error',
           });
+          if (updated) deps.revivedRunTracker?.onTerminal(updated);
         }
       } else if (isInlineFailoverError(props.error)) {
         // Recovery possible: defer. The idle backstop terminalizes this
@@ -555,13 +560,15 @@ export async function handleEvent(
         ) {
           return;
         }
-        deps.backgroundJobBoard.updateStatus({
+        const updated = deps.backgroundJobBoard.updateStatus({
           taskID: sessionId,
           state: 'error',
+          expectedGeneration: observation?.generation,
           resultSummary:
             (props?.error as { message?: string } | undefined)?.message ??
             'Session error',
         });
+        if (updated) deps.revivedRunTracker?.onTerminal(updated);
       }
     }
 

+ 11 - 1
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -1,5 +1,6 @@
 import type { BackgroundJobStore, ContextFile } from '../../utils';
 import { log } from '../../utils/logger';
+import type { RevivedRunTracker } from './revived-run-tracker';
 
 export function createIdleReconciler(options: {
   backgroundJobBoard: BackgroundJobStore;
@@ -19,6 +20,7 @@ export function createIdleReconciler(options: {
     contextFilesForPrompt(taskId: string): ContextFile[];
     prune(board: { taskIDs(): Set<string> }): void;
   };
+  revivedRunTracker?: RevivedRunTracker;
 }) {
   const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
   const childIdleReconcileTimers = new Map<
@@ -57,7 +59,7 @@ export function createIdleReconciler(options: {
     if (childIdleReconcileTimers.has(sessionID)) return;
     if (options.isFallbackInProgress?.(sessionID)) return;
 
-    const timer = setTimeout(() => {
+    const timer = setTimeout(async () => {
       childIdleReconcileTimers.delete(sessionID);
       if (options.isFallbackInProgress?.(sessionID)) return;
 
@@ -74,6 +76,14 @@ export function createIdleReconciler(options: {
         return;
       }
 
+      if (options.revivedRunTracker?.isTracked(sessionID, observedGeneration)) {
+        const terminalPublished = await options.revivedRunTracker.probe(
+          sessionID,
+          observedGeneration,
+        );
+        if (terminalPublished) return;
+      }
+
       // Idle is a quiescent runner observation, not proof that the background
       // task ended. Keep the job live so a late terminal task result can win.
       log('[task-session-manager] observed quiescent job from idle', {

+ 55 - 4
src/hooks/task-session-manager/index.test.ts

@@ -1430,6 +1430,53 @@ describe('task-session-manager hook', () => {
     expect(boardText(messages)).toContain('Result: plan is sound');
   });
 
+  test('resumes acknowledged cancelled and errored sessions through task_id', async () => {
+    for (const state of ['cancelled', 'error'] as const) {
+      const board = new BackgroundJobBoard();
+      const original = board.registerLaunch({
+        taskID: `child-${state}`,
+        parentSessionID: 'parent-1',
+        agent: 'oracle',
+        description: `${state} review`,
+      });
+      board.updateStatus({ taskID: original.taskID, state });
+      const { hook } = createHook({ backgroundJobBoard: board });
+
+      const beforeAcknowledgement = {
+        args: { subagent_type: 'oracle', task_id: original.alias },
+      };
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: `${state}-before-ack` },
+        beforeAcknowledgement,
+      );
+      expect(beforeAcknowledgement.args.task_id).toBeUndefined();
+
+      board.markReconciled(original.taskID);
+
+      const resume = {
+        args: { subagent_type: 'oracle', task_id: original.alias },
+      };
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: `${state}-resume` },
+        resume,
+      );
+      expect(resume.args.task_id).toBe(original.taskID);
+
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: 'parent-1', callID: `${state}-resume` },
+        {
+          output: [`task_id: ${original.taskID}`, 'state: running'].join('\n'),
+        },
+      );
+
+      expect(board.get(original.taskID)).toMatchObject({
+        generation: original.generation + 1,
+        state: 'running',
+        terminalUnreconciled: false,
+      });
+    }
+  });
+
   test('keeps task timeout as a running timed-out job', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -3161,9 +3208,9 @@ describe('task-session-manager hook', () => {
     await transformMessages(hook, messages);
 
     expect(messages.messages[0].parts.at(-1)?.text).toContain(
-      'state: cancelled',
+      'cancelled, reconciled',
     );
-    expect(messages.messages[0].parts.at(-1)?.text).toContain(
+    expect(board.get('child-1')?.resultSummary).toBe(
       'cancelled: user requested',
     );
     expect(messages.messages[0].parts[0].text).not.toContain(
@@ -4173,7 +4220,7 @@ describe('task-session-manager hook', () => {
     expect(resume.args.task_id).toBe('child-1');
   });
 
-  test('only reconciled completed jobs resolve as reusable task sessions', async () => {
+  test('only acknowledged terminal jobs resolve as reusable task sessions', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
 
@@ -4209,7 +4256,11 @@ describe('task-session-manager hook', () => {
       { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
       failed,
     );
-    expect(failed.args.task_id).toBeUndefined();
+    expect(failed.args.task_id).toBe('err-1');
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { output: ['task_id: err-1', 'state: running'].join('\n') },
+    );
 
     const completed = { args: { subagent_type: 'oracle', task_id: 'ora-1' } };
     await hook['tool.execute.before'](

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

@@ -30,6 +30,7 @@ import { createIdleReconciler } from './idle-reconciliation';
 import { createIdleSessionTokens } from './idle-session-tokens';
 import { createInputWaitTracker } from './input-wait-tracker';
 import { createPendingCallTracker } from './pending-call-tracker';
+import type { RevivedRunTracker } from './revived-run-tracker';
 import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
 import { createTaskContextTracker } from './task-context-tracker';
 import {
@@ -170,6 +171,7 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
     /** Test seam only; production uses the runtime reconciliation delay. */
     runtimeStatusReconcileDelayMs?: number;
+    revivedRunTracker?: RevivedRunTracker;
   },
 ) {
   const backgroundJobBoard =
@@ -239,6 +241,7 @@ export function createTaskSessionManagerHook(
     getIdleSessionToken: (s) => getIdleSessionToken(s),
     isCurrentIdleSessionToken: (s, t) => isCurrentIdleSessionToken(s, t),
     taskContextTracker,
+    revivedRunTracker: options.revivedRunTracker,
   });
   const runtimeStatusReconciler = createRuntimeStatusReconciler({
     input: _ctx,
@@ -323,6 +326,17 @@ export function createTaskSessionManagerHook(
   };
 
   return {
+    markRevivedRunPending: (taskID: string): void => {
+      taskContextTracker.pendingManagedTaskIds.add(taskID);
+    },
+    clearRevivedRunPending: (taskID: string): void => {
+      taskContextTracker.pendingManagedTaskIds.delete(taskID);
+    },
+    contextFilesForTask: (taskID: string) =>
+      taskContextTracker.contextFilesForPrompt(taskID),
+    pruneTaskContext: (): void => {
+      taskContextTracker.prune(backgroundJobBoard);
+    },
     beginUserWait: (sessionID: string): void => {
       inputWaits.beginUserWait(sessionID);
     },
@@ -520,6 +534,7 @@ export function createTaskSessionManagerHook(
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         observeSyntheticTerminalPart: (part) =>
           observeSyntheticTerminalPart(injectionState, part),
+        revivedRunTracker: options.revivedRunTracker,
       }).then(() => runtimeStatusReconciler.schedule());
     },
   };

+ 354 - 0
src/hooks/task-session-manager/revived-run-tracker.test.ts

@@ -0,0 +1,354 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils/background-job-board';
+import { createRevivedRunTracker } from './revived-run-tracker';
+
+function createHarness(
+  messages: () => unknown,
+  prompt = mock(async () => ({})),
+  assertBound = false,
+) {
+  const board = new BackgroundJobBoard();
+  board.registerLaunch({
+    taskID: 'ses_child',
+    parentSessionID: 'parent',
+    agent: 'explorer',
+    background: true,
+  });
+  board.updateStatus({
+    taskID: 'ses_child',
+    state: 'completed',
+    resultSummary: 'old result',
+  });
+  board.markReconciled('ses_child');
+  const lease = board.acquireRelaunchLease('ses_child', 1);
+  if (!lease) throw new Error('missing relaunch lease');
+  const run = board.registerLaunch({
+    taskID: 'ses_child',
+    parentSessionID: 'parent',
+    agent: 'explorer',
+    description: 'inspect the change',
+    background: true,
+    relaunchLease: lease,
+  });
+  board.releaseLease(lease);
+  let session: {
+    messages: ReturnType<typeof mock>;
+    promptAsync: ReturnType<typeof mock>;
+  };
+  session = {
+    messages: mock(function (this: unknown) {
+      if (assertBound) expect(this).toBe(session);
+      return messages();
+    }),
+    promptAsync: mock(function (this: unknown, ..._args: unknown[]) {
+      if (assertBound) expect(this).toBe(session);
+      return prompt();
+    }),
+  };
+  const input = {
+    directory: '/test',
+    client: {
+      session,
+    },
+  } as never;
+  const tracker = createRevivedRunTracker({
+    input,
+    backgroundJobBoard: board,
+    notificationRetryDelayMs: 0,
+  });
+  return { board, run, tracker, prompt: session.promptAsync };
+}
+
+describe('revived run tracker', () => {
+  test('publishes a newer completed assistant turn and notifies the parent', async () => {
+    let probe = false;
+    const harness = createHarness(
+      () =>
+        probe
+          ? {
+              data: [
+                { info: { id: 'baseline', role: 'user' }, parts: [] },
+                {
+                  info: {
+                    id: 'assistant-1',
+                    role: 'assistant',
+                    time: { completed: 2 },
+                  },
+                  parts: [{ type: 'text', text: 'new result' }],
+                },
+              ],
+            }
+          : { data: [{ info: { id: 'baseline', role: 'user' }, parts: [] }] },
+      undefined,
+      true,
+    );
+    const baseline = await harness.tracker.captureBaseline('ses_child');
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: baseline,
+      description: 'inspect the change',
+    });
+    probe = true;
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+
+    expect(harness.board.get('ses_child')).toMatchObject({
+      state: 'completed',
+      resultSummary: 'new result',
+    });
+    expect(harness.prompt).toHaveBeenCalledTimes(1);
+    expect(harness.prompt.mock.calls[0]?.[0]).toMatchObject({
+      path: { id: 'parent' },
+      body: {
+        agent: 'orchestrator',
+        parts: [{ type: 'text', synthetic: true }],
+      },
+    });
+  });
+
+  test('keeps a non-terminal idle turn running and rejects historical output', async () => {
+    const harness = createHarness(() => ({
+      data: [
+        { info: { id: 'baseline', role: 'user' }, parts: [] },
+        {
+          info: {
+            id: 'assistant-old',
+            role: 'assistant',
+            time: { completed: 1 },
+          },
+          parts: [{ type: 'text', text: 'old result' }],
+        },
+        {
+          info: { id: 'assistant-new', role: 'assistant' },
+          parts: [{ type: 'text', text: 'partial' }],
+        },
+      ],
+    }));
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+
+    expect(harness.board.get('ses_child')).toMatchObject({ state: 'running' });
+    expect(harness.prompt).not.toHaveBeenCalled();
+  });
+
+  test('publishes an explicitly empty completed turn but rejects tool-call finishes', async () => {
+    let toolCallFinish = true;
+    const harness = createHarness(() => ({
+      data: [
+        { info: { id: 'baseline', role: 'user' }, parts: [] },
+        {
+          info: {
+            id: 'assistant-new',
+            role: 'assistant',
+            time: { completed: 2 },
+            finish: toolCallFinish ? 'tool-calls' : 'stop',
+          },
+          parts: [],
+        },
+      ],
+    }));
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+    expect(
+      await harness.tracker.probe(harness.run.taskID, harness.run.generation),
+    ).toBe(false);
+    expect(harness.board.get('ses_child')?.state).toBe('running');
+
+    toolCallFinish = false;
+    expect(
+      await harness.tracker.probe(harness.run.taskID, harness.run.generation),
+    ).toBe(true);
+    expect(harness.board.get('ses_child')).toMatchObject({
+      state: 'completed',
+      resultSummary: '',
+    });
+  });
+
+  test('publishes immediate child errors and ignores stale generations', async () => {
+    const harness = createHarness(() => ({
+      data: [
+        { info: { id: 'baseline', role: 'user' }, parts: [] },
+        {
+          info: {
+            id: 'assistant-error',
+            role: 'assistant',
+            time: { completed: 3 },
+            error: { message: 'provider failed' },
+          },
+          parts: [],
+        },
+      ],
+    }));
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+    const staleLease = harness.board.acquireRelaunchLease(
+      harness.run.taskID,
+      harness.run.generation,
+    );
+    if (!staleLease) throw new Error('missing stale lease');
+    const newer = harness.board.registerLaunch({
+      taskID: harness.run.taskID,
+      parentSessionID: 'parent',
+      agent: 'explorer',
+      background: true,
+      relaunchLease: staleLease,
+    });
+    harness.board.releaseLease(staleLease);
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+    expect(harness.board.get('ses_child')).toMatchObject({
+      generation: newer.generation,
+      state: 'running',
+    });
+  });
+
+  test('retries parent notification without changing the terminal board state', async () => {
+    let attempts = 0;
+    const prompt = mock(async () => {
+      attempts += 1;
+      if (attempts === 1) throw new Error('parent unavailable');
+      return {};
+    });
+    const harness = createHarness(
+      () => ({
+        data: [
+          { info: { id: 'baseline', role: 'user' }, parts: [] },
+          {
+            info: {
+              id: 'assistant-1',
+              role: 'assistant',
+              time: { completed: 2 },
+            },
+            parts: [{ type: 'text', text: 'done' }],
+          },
+        ],
+      }),
+      prompt,
+    );
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      baselineMessageID: 'baseline',
+      description: 'inspect the change',
+    });
+    await harness.tracker.probe(harness.run.taskID, harness.run.generation);
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    harness.board.markReconciled(harness.run.taskID);
+    await new Promise((resolve) => setTimeout(resolve, 5));
+
+    expect(harness.board.get('ses_child')?.state).toBe('reconciled');
+    expect(prompt).toHaveBeenCalledTimes(2);
+  });
+
+  test('holds the terminal notification lease while parent transport is active', async () => {
+    const harness = createHarness(() => ({ data: [] }));
+    let relaunchLease: unknown;
+    harness.prompt.mockImplementation(async () => {
+      relaunchLease = harness.board.acquireRelaunchLease(
+        harness.run.taskID,
+        harness.run.generation,
+      );
+      return {};
+    });
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      description: 'inspect the change',
+    });
+    const terminal = harness.board.updateStatus({
+      taskID: harness.run.taskID,
+      expectedGeneration: harness.run.generation,
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    if (!terminal) throw new Error('missing terminal record');
+    harness.tracker.onTerminal(terminal);
+    await new Promise((resolve) => setTimeout(resolve, 0));
+
+    expect(relaunchLease).toBeUndefined();
+    expect(harness.board.get(harness.run.taskID)).toMatchObject({
+      generation: harness.run.generation,
+      state: 'completed',
+    });
+  });
+
+  test('forwards coordinator terminal outcomes to one parent notification', async () => {
+    const harness = createHarness(() => ({ data: [] }));
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      description: 'inspect the change',
+    });
+    const terminal = harness.board.updateStatus({
+      taskID: harness.run.taskID,
+      expectedGeneration: harness.run.generation,
+      state: 'error',
+      resultSummary: 'timeout',
+    });
+    if (!terminal) throw new Error('missing terminal record');
+    harness.tracker.onTerminal(terminal);
+    harness.tracker.onTerminal(terminal);
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    expect(harness.prompt).toHaveBeenCalledTimes(1);
+  });
+
+  test('discards a retry when the task generation is relaunched', async () => {
+    const prompt = mock(async () => {
+      throw new Error('parent unavailable');
+    });
+    const harness = createHarness(() => ({ data: [] }), prompt);
+    harness.tracker.register({
+      taskID: harness.run.taskID,
+      generation: harness.run.generation,
+      parentSessionID: 'parent',
+      description: 'inspect the change',
+    });
+    const terminal = harness.board.updateStatus({
+      taskID: harness.run.taskID,
+      expectedGeneration: harness.run.generation,
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    if (!terminal) throw new Error('missing terminal record');
+    harness.tracker.onTerminal(terminal);
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    const lease = harness.board.acquireRelaunchLease(
+      harness.run.taskID,
+      harness.run.generation,
+    );
+    if (!lease) throw new Error('missing relaunch lease');
+    const newer = harness.board.registerLaunch({
+      taskID: harness.run.taskID,
+      parentSessionID: 'parent',
+      agent: 'explorer',
+      background: true,
+      relaunchLease: lease,
+    });
+    harness.board.releaseLease(lease);
+    await new Promise((resolve) => setTimeout(resolve, 5));
+
+    expect(prompt).toHaveBeenCalledTimes(1);
+    expect(
+      harness.tracker.isTracked(harness.run.taskID, newer.generation),
+    ).toBe(false);
+  });
+});

+ 453 - 0
src/hooks/task-session-manager/revived-run-tracker.ts

@@ -0,0 +1,453 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import type {
+  BackgroundJobLease,
+  BackgroundJobRecord,
+  ContextFile,
+} from '../../utils/background-job-board';
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import type { BackgroundJobSupervisor } from '../../utils/background-job-supervisor';
+import { getClient } from '../../utils/opencode-client';
+
+const DEFAULT_NOTIFICATION_RETRIES = 3;
+const DEFAULT_RETRY_DELAY_MS = 1_000;
+const TERMINAL_NOTIFICATION_TIMEOUT_MS = 10_000;
+
+type SessionMessage = {
+  info?: {
+    id?: string;
+    role?: string;
+    error?: unknown;
+    finish?: string;
+    time?: { completed?: number };
+  };
+  parts?: Array<{
+    type?: string;
+    text?: string;
+    state?: { status?: string };
+  }>;
+};
+
+type RevivedRun = {
+  taskID: string;
+  generation: number;
+  parentSessionID: string;
+  baselineMessageID?: string;
+  description: string;
+  notification: {
+    attempts: number;
+    sent: boolean;
+    pending: boolean;
+    retryTimer?: ReturnType<typeof setTimeout>;
+  };
+  terminalState?: 'completed' | 'error';
+  probeInFlight?: Promise<boolean>;
+};
+
+export interface RevivedRunTracker {
+  captureBaseline(taskID: string): Promise<string | undefined>;
+  register(input: {
+    taskID: string;
+    generation: number;
+    parentSessionID: string;
+    baselineMessageID?: string;
+    description: string;
+  }): void;
+  isTracked(taskID: string, generation: number): boolean;
+  probe(taskID: string, generation: number): Promise<boolean>;
+  onTerminal(record: BackgroundJobRecord): void;
+  dispose(): void;
+}
+
+export function createRevivedRunTracker(options: {
+  input: PluginInput;
+  backgroundJobBoard: BackgroundJobStore;
+  backgroundJobSupervisor?: BackgroundJobSupervisor;
+  maxNotificationRetries?: number;
+  notificationRetryDelayMs?: number;
+  onRegister?: (taskID: string) => void;
+  onSettled?: (taskID: string) => void;
+  contextFilesForPrompt?: (taskID: string) => ContextFile[];
+  pruneContext?: () => void;
+}): RevivedRunTracker {
+  const runs = new Map<string, RevivedRun>();
+  const maxNotificationRetries =
+    options.maxNotificationRetries ?? DEFAULT_NOTIFICATION_RETRIES;
+  const retryDelayMs =
+    options.notificationRetryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
+  let disposed = false;
+
+  const captureBaseline = async (
+    taskID: string,
+  ): Promise<string | undefined> => {
+    const session = getClient(options.input).session;
+    const messages =
+      typeof session.messages === 'function'
+        ? session.messages.bind(session)
+        : undefined;
+    if (typeof messages !== 'function') return undefined;
+    const response = await messages({
+      path: { id: taskID },
+      query: { directory: options.input.directory },
+    });
+    const error = responseError(response);
+    if (error !== undefined) throw new Error(errorText(error));
+    const data = Array.isArray(response.data) ? response.data : [];
+    const last = data.at(-1) as SessionMessage | undefined;
+    return typeof last?.info?.id === 'string' ? last.info.id : undefined;
+  };
+
+  const isTracked = (taskID: string, generation: number): boolean => {
+    const run = runs.get(taskID);
+    return run?.generation === generation;
+  };
+
+  const probe = async (
+    taskID: string,
+    generation: number,
+  ): Promise<boolean> => {
+    const run = runs.get(taskID);
+    if (!run || run.generation !== generation || disposed) return false;
+    if (run.probeInFlight) return run.probeInFlight;
+
+    run.probeInFlight = probeRun(run).finally(() => {
+      run.probeInFlight = undefined;
+    });
+    return run.probeInFlight;
+  };
+
+  const onTerminal = (record: BackgroundJobRecord): void => {
+    const run = runs.get(record.taskID);
+    if (!run || run.generation !== record.generation) return;
+    if (
+      record.state !== 'completed' &&
+      record.state !== 'error' &&
+      record.state !== 'cancelled'
+    ) {
+      return;
+    }
+    finish(run, record);
+  };
+
+  const dispose = (): void => {
+    disposed = true;
+    for (const run of runs.values()) {
+      if (run.notification.retryTimer) {
+        clearTimeout(run.notification.retryTimer);
+      }
+    }
+    runs.clear();
+  };
+
+  async function probeRun(run: RevivedRun): Promise<boolean> {
+    const session = getClient(options.input).session;
+    const messages =
+      typeof session.messages === 'function'
+        ? session.messages.bind(session)
+        : undefined;
+    if (typeof messages !== 'function') return false;
+    let response: unknown;
+    try {
+      response = await messages({
+        path: { id: run.taskID },
+        query: { directory: options.input.directory },
+      });
+    } catch {
+      return false;
+    }
+
+    const data =
+      isRecord(response) && Array.isArray(response.data)
+        ? (response.data as SessionMessage[])
+        : [];
+    const baselineIndex = run.baselineMessageID
+      ? data.findIndex((message) => message.info?.id === run.baselineMessageID)
+      : -1;
+    if (run.baselineMessageID && baselineIndex < 0) return false;
+
+    const lastIndex = data.length - 1;
+    const last = data[lastIndex];
+    if (last?.info?.role !== 'assistant') return false;
+    if (lastIndex <= baselineIndex) return false;
+    if (typeof last.info.time?.completed !== 'number') return false;
+    if (last.info.finish === 'tool-calls' || last.info.finish === 'unknown') {
+      return false;
+    }
+    if (hasPendingToolCall(data, baselineIndex)) return false;
+    if (last.info.error !== undefined) {
+      const result = errorText(last.info.error);
+      const updated = options.backgroundJobBoard.updateStatus({
+        taskID: run.taskID,
+        expectedGeneration: run.generation,
+        state: 'error',
+        resultSummary: result || 'Revived child session failed.',
+      });
+      return updated?.generation === run.generation && finish(run, updated);
+    }
+    const text = (last.parts ?? [])
+      .filter(
+        (part) =>
+          (part.type === 'text' || part.type === 'reasoning') &&
+          typeof part.text === 'string' &&
+          part.text.length > 0,
+      )
+      .map((part) => part.text as string)
+      .join('\n\n')
+      .trim();
+    const updated = options.backgroundJobBoard.updateStatus({
+      taskID: run.taskID,
+      expectedGeneration: run.generation,
+      state: 'completed',
+      resultSummary: text,
+    });
+    return updated?.generation === run.generation && finish(run, updated);
+  }
+
+  function finish(run: RevivedRun, record: BackgroundJobRecord): boolean {
+    if (record.state !== 'completed' && record.state !== 'error') return false;
+    if (run.terminalState && run.terminalState !== record.state) return true;
+    run.terminalState = record.state;
+    options.backgroundJobBoard.addContext(
+      record.taskID,
+      options.contextFilesForPrompt?.(record.taskID) ?? [],
+    );
+    options.backgroundJobBoard.addContext(record.taskID, record.contextFiles);
+    options.pruneContext?.();
+    options.onSettled?.(run.taskID);
+    options.backgroundJobSupervisor?.onTerminal(record);
+    if (run.notification.sent || run.notification.pending) return true;
+    void notifyParent(run, record);
+    return true;
+  }
+
+  async function notifyParent(
+    run: RevivedRun,
+    record: BackgroundJobRecord,
+  ): Promise<void> {
+    if (disposed || run.notification.sent || run.notification.pending) return;
+    run.notification.pending = true;
+    run.notification.attempts += 1;
+    try {
+      const session = getClient(options.input).session;
+      const promptAsync =
+        typeof session.promptAsync === 'function'
+          ? session.promptAsync.bind(session)
+          : undefined;
+      if (typeof promptAsync !== 'function') {
+        throw new Error('session.promptAsync unavailable');
+      }
+      const current = options.backgroundJobBoard.get(run.taskID);
+      if (
+        !current ||
+        current.generation !== run.generation ||
+        terminalOutcome(current) !== run.terminalState ||
+        record.state !== run.terminalState
+      ) {
+        discardRun(run);
+        return;
+      }
+      const lease = options.backgroundJobBoard.acquireTerminalNotificationLease(
+        run.taskID,
+        run.generation,
+      );
+      if (!lease) {
+        scheduleNotificationRetry(run, record);
+        return;
+      }
+      const state = record.state === 'completed' ? 'completed' : 'error';
+      const tag = state === 'completed' ? 'task_result' : 'task_error';
+      const summary =
+        state === 'completed'
+          ? `Background task completed: ${run.description}`
+          : `Background task failed: ${run.description}`;
+      const text = [
+        `<task id="${run.taskID}" state="${state}">`,
+        `<summary>${summary}</summary>`,
+        `<${tag}>`,
+        record.resultSummary ??
+          (state === 'completed' ? 'Completed.' : 'Failed.'),
+        `</${tag}>`,
+        '</task>',
+      ].join('\n');
+      const response = await awaitNotificationTransport(
+        options.backgroundJobBoard,
+        lease,
+        () =>
+          promptAsync({
+            path: { id: run.parentSessionID },
+            query: { directory: options.input.directory },
+            body: {
+              agent: 'orchestrator',
+              parts: [{ type: 'text', synthetic: true, text }],
+            },
+          }),
+      );
+      const error = responseError(response);
+      if (error !== undefined) throw new Error(errorText(error));
+      const latest = options.backgroundJobBoard.get(run.taskID);
+      if (
+        !latest ||
+        latest.generation !== run.generation ||
+        terminalOutcome(latest) !== run.terminalState
+      ) {
+        discardRun(run);
+        return;
+      }
+      run.notification.sent = true;
+    } catch {
+      scheduleNotificationRetry(run, record);
+    } finally {
+      run.notification.pending = false;
+    }
+  }
+
+  function scheduleNotificationRetry(
+    run: RevivedRun,
+    record: BackgroundJobRecord,
+  ): void {
+    if (
+      disposed ||
+      runs.get(run.taskID) !== run ||
+      run.notification.attempts >= maxNotificationRetries ||
+      run.notification.retryTimer
+    ) {
+      return;
+    }
+    run.notification.retryTimer = setTimeout(() => {
+      run.notification.retryTimer = undefined;
+      void notifyParent(run, record);
+    }, retryDelayMs);
+    run.notification.retryTimer.unref?.();
+  }
+
+  function register(input: {
+    taskID: string;
+    generation: number;
+    parentSessionID: string;
+    baselineMessageID?: string;
+    description: string;
+  }): void {
+    const old = runs.get(input.taskID);
+    if (old?.notification.retryTimer) clearTimeout(old.notification.retryTimer);
+    runs.set(input.taskID, {
+      ...input,
+      notification: { attempts: 0, sent: false, pending: false },
+    });
+    options.onRegister?.(input.taskID);
+  }
+
+  function discardRun(run: RevivedRun): void {
+    if (runs.get(run.taskID) !== run) return;
+    if (run.notification.retryTimer) clearTimeout(run.notification.retryTimer);
+    runs.delete(run.taskID);
+  }
+
+  return {
+    captureBaseline,
+    register,
+    isTracked,
+    probe,
+    onTerminal,
+    dispose,
+  };
+}
+
+async function awaitNotificationTransport<T>(
+  backgroundJobBoard: BackgroundJobStore,
+  lease: BackgroundJobLease,
+  operation: () => Promise<T>,
+): Promise<T> {
+  let settled = false;
+  let timedOut = false;
+  let timer: ReturnType<typeof setTimeout> | undefined;
+  const transport = Promise.resolve()
+    .then(operation)
+    .then(
+      (value) => {
+        settled = true;
+        if (timedOut) backgroundJobBoard.releaseLease(lease);
+        return value;
+      },
+      (error: unknown) => {
+        settled = true;
+        if (timedOut) backgroundJobBoard.releaseLease(lease);
+        throw error;
+      },
+    );
+
+  try {
+    return await Promise.race([
+      transport,
+      new Promise<never>((_, reject) => {
+        timer = setTimeout(
+          () => reject(new NotificationTransportTimeoutError()),
+          TERMINAL_NOTIFICATION_TIMEOUT_MS,
+        );
+        timer.unref?.();
+      }),
+    ]);
+  } catch (error) {
+    if (error instanceof NotificationTransportTimeoutError) {
+      timedOut = true;
+      if (settled) backgroundJobBoard.releaseLease(lease);
+    }
+    throw error;
+  } finally {
+    if (timer) clearTimeout(timer);
+    if (!timedOut) backgroundJobBoard.releaseLease(lease);
+  }
+}
+
+class NotificationTransportTimeoutError extends Error {
+  constructor() {
+    super('Parent terminal notification transport timed out');
+    this.name = 'NotificationTransportTimeoutError';
+  }
+}
+
+function terminalOutcome(
+  record: BackgroundJobRecord,
+): 'completed' | 'error' | undefined {
+  if (record.state === 'reconciled') {
+    return record.terminalState === 'completed' ||
+      record.terminalState === 'error'
+      ? record.terminalState
+      : undefined;
+  }
+  return record.state === 'completed' || record.state === 'error'
+    ? record.state
+    : undefined;
+}
+
+function hasPendingToolCall(
+  messages: SessionMessage[],
+  baselineIndex: number,
+): boolean {
+  return messages.slice(baselineIndex + 1).some((message) =>
+    (message.parts ?? []).some((part) => {
+      if (part.type !== 'tool') return false;
+      const status = part.state?.status;
+      return status !== 'completed' && status !== 'error';
+    }),
+  );
+}
+
+function responseError(response: unknown): unknown {
+  if (!isRecord(response)) return undefined;
+  return response.error === undefined || response.error === null
+    ? undefined
+    : response.error;
+}
+
+function errorText(error: unknown): string {
+  if (error instanceof Error) return error.message;
+  if (typeof error === 'string') return error;
+  try {
+    return JSON.stringify(error);
+  } catch {
+    return String(error);
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null;
+}

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

@@ -145,7 +145,7 @@ export async function handleToolExecuteBefore(
       );
       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.`,
+          `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 session after that terminal notification is acknowledged if follow-up work is still needed.`,
         );
       }
 

+ 5 - 0
src/index.test.ts

@@ -133,6 +133,11 @@ describe('plugin tool registration', () => {
       serverUrl: new URL('http://127.0.0.1:4096'),
     } as never);
 
+    expect(hooks.tool?.task_status).toBeDefined();
+    expect(hooks.tool?.task_result).toBeDefined();
+    expect(hooks.tool?.task_message).toBeDefined();
+    expect(hooks.tool?.task_cancel).toBeDefined();
+    expect(hooks.tool?.task_revive).toBeDefined();
     expect(hooks.tool?.wait_for_user).toBeDefined();
     await expect(
       hooks.tool?.wait_for_user?.execute(

+ 43 - 9
src/index.ts

@@ -31,6 +31,7 @@ import {
   SessionLifecycle,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
+import { createRevivedRunTracker } from './hooks/task-session-manager/revived-run-tracker';
 import { isMessageWithParts, type MessageWithParts } from './hooks/types';
 import { handleTaskSessionEvent } from './index-event';
 import { createInterviewManager } from './interview';
@@ -45,8 +46,9 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
-  createTaskNudgeTool,
+  createTaskMessageTool,
   createTaskResultTool,
+  createTaskReviveTool,
   createTaskStatusTool,
   createWaitForUserTool,
   createWebfetchTool,
@@ -65,6 +67,7 @@ import {
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
+import type { ContextFile } from './utils/background-job-board';
 import { isPluginDisabledByEnv } from './utils/env';
 import { initLogger, log } from './utils/logger';
 import { SessionMetadataStore } from './utils/session-metadata';
@@ -174,10 +177,16 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let backgroundJobSupervisor: BackgroundJobSupervisor;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
-  let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
+  let taskCancelTools: ReturnType<typeof createCancelTaskTool>;
+  let taskMessageTools: ReturnType<typeof createTaskMessageTool>;
   let taskResultTools: ReturnType<typeof createTaskResultTool>;
+  let taskReviveTools: ReturnType<typeof createTaskReviveTool>;
+  let revivedRunTracker: ReturnType<typeof createRevivedRunTracker>;
+  let markRevivedRunPending: (taskID: string) => void = () => {};
+  let markRevivedRunSettled: (taskID: string) => void = () => {};
+  let getRevivedContextFiles = (_taskID: string): ContextFile[] => [];
+  let pruneRevivedContext = () => {};
   let taskStatusTools: ReturnType<typeof createTaskStatusTool>;
-  let taskNudgeTools: ReturnType<typeof createTaskNudgeTool>;
   const taskActivityTracker = new TaskActivityTracker();
   let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
@@ -296,6 +305,18 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
       backgroundJobSupervisor.onTerminal(record);
     });
+    revivedRunTracker = createRevivedRunTracker({
+      input: ctx,
+      backgroundJobBoard: backgroundJobCoordinator,
+      backgroundJobSupervisor,
+      onRegister: (taskID) => markRevivedRunPending(taskID),
+      onSettled: (taskID) => markRevivedRunSettled(taskID),
+      contextFilesForPrompt: (taskID) => getRevivedContextFiles(taskID),
+      pruneContext: () => pruneRevivedContext(),
+    });
+    backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
+      revivedRunTracker.onTerminal(record);
+    });
 
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
@@ -356,7 +377,12 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       willAttemptFallback: (sessionID) =>
         foregroundFallback.willAttemptFallback(sessionID),
       coordinator: sessionLifecycle,
+      revivedRunTracker,
     });
+    markRevivedRunPending = taskSessionManagerHook.markRevivedRunPending;
+    markRevivedRunSettled = taskSessionManagerHook.clearRevivedRunPending;
+    getRevivedContextFiles = taskSessionManagerHook.contextFilesForTask;
+    pruneRevivedContext = taskSessionManagerHook.pruneTaskContext;
 
     orchestratorWakeScheduler = createOrchestratorWakeScheduler(ctx, {
       config: runtime.backgroundJobs.orchestratorWake,
@@ -440,22 +466,29 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ctx.directory,
       runtime.companion,
     );
-    cancelTaskTools = createCancelTaskTool({
+    taskCancelTools = createCancelTaskTool({
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
     });
+    taskMessageTools = createTaskMessageTool({
+      input: ctx,
+      backgroundJobBoard: backgroundJobCoordinator,
+    });
     taskResultTools = createTaskResultTool({
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
     });
-    taskStatusTools = createTaskStatusTool({
+    taskReviveTools = createTaskReviveTool({
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
-      activityTracker: taskActivityTracker,
+      shouldManageSession: (sessionID) =>
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+      backgroundJobSupervisor,
+      revivedRunTracker,
     });
-    taskNudgeTools = createTaskNudgeTool({
+    taskStatusTools = createTaskStatusTool({
       input: ctx,
       backgroundJobBoard: backgroundJobCoordinator,
       activityTracker: taskActivityTracker,
@@ -475,10 +508,11 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     const shouldRegisterWebfetch = runtime.webfetch.enabled !== false;
     tools = {
-      ...cancelTaskTools,
+      ...taskCancelTools,
+      ...taskMessageTools,
       ...taskResultTools,
+      ...taskReviveTools,
       ...taskStatusTools,
-      ...taskNudgeTools,
       ...waitForUserTools,
       ...acpRunTools,
       ...(shouldRegisterWebfetch ? { webfetch } : {}),

+ 60 - 624
src/tools/cancel-task.test.ts

@@ -3,716 +3,152 @@ import { parseTaskStatusOutput } from '../utils';
 import { BackgroundJobBoard } from '../utils/background-job-board';
 import { createCancelTaskTool } from './cancel-task';
 
-let mockV2Client: Record<string, unknown>;
+let mockClient: Record<string, unknown>;
 
 mock.module('../utils/opencode-client', () => ({
-  getClient: () => mockV2Client,
+  getClient: () => mockClient,
 }));
 
 function createTool(overrides?: {
   abort?: () => Promise<unknown>;
-  delete?: () => Promise<unknown>;
-  get?: () => Promise<unknown>;
   status?: () => Promise<unknown>;
   shouldManageSession?: (sessionID: string) => boolean;
-  abortTimeoutMs?: number;
-  verifyAbortMs?: number;
-  abortRetryIntervalMs?: number;
-  stableStoppedMs?: number;
-  deleteVerifyMs?: number;
-  deleteStableStoppedMs?: number;
 }) {
   const board = new BackgroundJobBoard();
   const abort = mock(overrides?.abort ?? (async () => ({})));
-  const deleteSession = mock(overrides?.delete ?? (async () => ({})));
-  const get = mock(
-    overrides?.get ?? (async () => ({ data: { parentID: 'parent-1' } })),
+  const status = mock(
+    overrides?.status ?? (async () => ({ data: { ses_1: { type: 'idle' } } })),
   );
-  const status = mock(overrides?.status ?? (async () => ({ data: {} })));
-  mockV2Client = { session: { abort, delete: deleteSession, get, status } };
+  const deleteSession = mock(async () => ({}));
+  mockClient = {
+    session: { abort, status, delete: deleteSession },
+  };
   const tools = createCancelTaskTool({
     input: { directory: '/test/project' } as any,
     backgroundJobBoard: board,
     shouldManageSession: overrides?.shouldManageSession ?? (() => true),
-    abortTimeoutMs: overrides?.abortTimeoutMs,
-    verifyAbortMs: overrides?.verifyAbortMs ?? 1,
-    abortRetryIntervalMs: overrides?.abortRetryIntervalMs ?? 0,
-    stableStoppedMs: overrides?.stableStoppedMs ?? 0,
-    deleteVerifyMs: overrides?.deleteVerifyMs ?? 1,
-    deleteStableStoppedMs: overrides?.deleteStableStoppedMs ?? 0,
+    verifyAbortMs: 10,
+    abortRetryIntervalMs: 0,
+    stableStoppedMs: 0,
   });
-
-  return {
-    board,
-    abort,
-    deleteSession,
-    get,
-    status,
-    cancelTask: tools.cancel_task,
-  };
+  return { board, abort, status, deleteSession, taskCancel: tools.task_cancel };
 }
 
 const context = { sessionID: 'parent-1', agent: 'orchestrator' } as any;
 
-afterEach(() => {
-  mock.restore();
-});
+afterEach(() => mock.restore());
 
-describe('cancel_task tool', () => {
-  test('cancels a tracked running task by task ID', async () => {
-    const { board, abort, cancelTask } = createTool();
+describe('task_cancel tool', () => {
+  test('aborts and verifies quiescence without deleting the retained session', async () => {
+    const { board, abort, status, deleteSession, taskCancel } = createTool();
     board.registerLaunch({
       taskID: 'ses_1',
       parentSessionID: 'parent-1',
       agent: 'explorer',
     });
 
-    const output = await cancelTask.execute(
+    const output = await taskCancel.execute(
       { task_id: 'ses_1', reason: 'obsolete' },
       context,
     );
 
     expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
-    expect(String(output)).toContain('state: cancelled');
-    expect(String(output)).toContain('cancelled: obsolete');
+    expect(status).toHaveBeenCalled();
+    expect(deleteSession).not.toHaveBeenCalled();
     expect(parseTaskStatusOutput(String(output))).toMatchObject({
       taskID: 'ses_1',
       state: 'cancelled',
       result: 'cancelled: obsolete',
     });
-    expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
-  });
-
-  test('cancels a tracked running task by parent-scoped alias', async () => {
-    const { board, abort, cancelTask } = createTool();
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'oracle',
-    });
-
-    await cancelTask.execute({ task_id: 'ora-1' }, context);
-
-    expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
-  });
-
-  test('does not abort raw session IDs tracked by a different parent', async () => {
-    const { board, abort, cancelTask } = createTool();
-    board.registerLaunch({
-      taskID: 'ses_2',
-      parentSessionID: 'parent-2',
-      agent: 'fixer',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_2' }, context);
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('state: unknown');
-  });
-
-  test('does not abort unknown aliases', async () => {
-    const { abort, cancelTask } = createTool();
-
-    const output = await cancelTask.execute({ task_id: 'fix-99' }, context);
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('state: unknown');
-  });
-
-  test('refuses tracked jobs that are no longer running', async () => {
-    const { board, abort, cancelTask } = createTool();
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('stale/uncertain cancellation');
-    expect(board.get('ses_1')).toMatchObject({ state: 'completed' });
-  });
-
-  test('does not destructively cancel an owned raw session without a generation', async () => {
-    const { abort, deleteSession, cancelTask } = createTool();
-
-    const output = await cancelTask.execute(
-      { task_id: 'ses_lost', reason: 'stop ghost worker' },
-      context,
-    );
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(deleteSession).not.toHaveBeenCalled();
-    expect(String(output)).toContain('state: unknown');
-    expect(String(output)).toContain('best-effort/uncertain');
-  });
-
-  test('does not abort raw session ID without metadata ownership', async () => {
-    const { abort, cancelTask } = createTool({
-      get: async () => ({ data: { parentID: 'other-parent' } }),
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'cancelled',
+      terminalUnreconciled: true,
     });
-
-    const output = await cancelTask.execute({ task_id: 'ses_lost' }, context);
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('state: unknown');
-  });
-
-  test('does not abort the parent session ID', async () => {
-    const { abort, cancelTask } = createTool();
-
-    const output = await cancelTask.execute(
-      { task_id: 'ses_parent' },
-      { ...context, sessionID: 'ses_parent' },
-    );
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('state: unknown');
   });
 
-  test('refuses stale cancelled jobs without a running generation', async () => {
-    const { board, abort, cancelTask } = createTool();
+  test('retains the session and leaves it resumable after acknowledgement', async () => {
+    const { board, deleteSession, taskCancel } = createTool();
     board.registerLaunch({
       taskID: 'ses_1',
       parentSessionID: 'parent-1',
       agent: 'explorer',
     });
-    board.updateStatus({ taskID: 'ses_1', state: 'cancelled' });
 
-    const output = await cancelTask.execute(
-      { task_id: 'ses_1', reason: 'stop ghost worker' },
-      context,
-    );
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('stale/uncertain cancellation');
-  });
-
-  test('refuses reconciled stale cancellations without a running generation', async () => {
-    const { board, abort, cancelTask } = createTool();
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'explorer',
-    });
-    board.updateStatus({ taskID: 'ses_1', state: 'cancelled' });
+    await taskCancel.execute({ task_id: 'ses_1' }, context);
     board.markReconciled('ses_1');
 
-    const output = await cancelTask.execute(
-      { task_id: 'ses_1', reason: 'stop ghost worker' },
-      context,
-    );
-
-    expect(abort).not.toHaveBeenCalled();
-    expect(String(output)).toContain('stale/uncertain cancellation');
-  });
-
-  test('does not terminalize board when abort fails without delete', async () => {
-    const { board, abort, cancelTask } = createTool({
-      abort: async () => {
-        throw new Error('abort failed');
-      },
-      delete: async () => {
-        throw new Error('delete failed');
-      },
-      status: async () => ({ data: { ses_1: { type: 'busy' } } }),
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).toHaveBeenCalled();
-    expect(String(output)).toContain('state: running');
-    expect(board.get('ses_1')).toMatchObject({
-      state: 'running',
-      terminalUnreconciled: false,
-      statusUncertain: true,
-    });
-  });
-
-  test('deletes session when abort fails but delete succeeds', async () => {
-    const { board, abort, deleteSession, cancelTask } = createTool({
-      abort: async () => {
-        throw new Error('abort failed');
-      },
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledWith({
-      path: { id: 'ses_1' },
-      query: { directory: '/test/project' },
-    });
-    expect(String(output)).toContain('state: cancelled');
-    expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
-  });
-
-  test('treats delete not-found as success when status is missing', async () => {
-    const { board, deleteSession, cancelTask } = createTool({
-      delete: async () => {
-        throw new Error('not found');
-      },
-      status: async () => ({ data: {} }),
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(deleteSession).toHaveBeenCalledWith({
-      path: { id: 'ses_1' },
-      query: { directory: '/test/project' },
-    });
-    expect(String(output)).toContain('state: cancelled');
-    expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
-  });
-
-  test('keeps running/status uncertain when delete fails and status stays busy', async () => {
-    const { board, deleteSession, cancelTask } = createTool({
-      delete: async () => {
-        throw new Error('delete failed');
-      },
-      status: async () => ({ data: { ses_1: { type: 'busy' } } }),
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(deleteSession).toHaveBeenCalledWith({
-      path: { id: 'ses_1' },
-      query: { directory: '/test/project' },
-    });
-    expect(String(output)).toContain('state: running');
-    expect(board.get('ses_1')).toMatchObject({
-      state: 'running',
-      statusUncertain: true,
-      terminalUnreconciled: false,
-    });
-  });
-
-  test('does not confirm cancellation when abort/delete fail and status is absent', async () => {
-    const { board, abort, deleteSession, cancelTask } = createTool({
-      abort: async () => {
-        throw new Error('abort transport failed');
-      },
-      delete: async () => {
-        throw new Error('delete network failed');
-      },
-      status: async () => ({ data: {} }),
-    });
-    const terminalNotifications: string[] = [];
-    board.addTerminalStateListener((taskID) => {
-      terminalNotifications.push(taskID);
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalled();
-    expect(String(output)).toContain('state: running');
-    expect(String(output)).toContain('delete network failed');
     expect(board.get('ses_1')).toMatchObject({
-      state: 'running',
-      statusUncertain: true,
-      terminalUnreconciled: false,
-    });
-    expect(terminalNotifications).toEqual([]);
-  });
-
-  test('does not cancel a relaunched generation after status verification awaits', async () => {
-    let releaseStatus!: () => void;
-    let signalStatusStarted!: () => void;
-    const statusStarted = new Promise<void>((resolve) => {
-      signalStatusStarted = resolve;
-    });
-    const statusGate = new Promise<void>((resolve) => {
-      releaseStatus = resolve;
-    });
-    const { board, status, deleteSession, cancelTask } = createTool({
-      status: async () => {
-        signalStatusStarted();
-        await statusGate;
-        return { data: {} };
-      },
-    });
-    const terminalNotifications: string[] = [];
-    board.addTerminalStateListener((taskID) => {
-      terminalNotifications.push(taskID);
-    });
-    board.registerLaunch({
       taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-      now: 100,
+      state: 'reconciled',
+      terminalState: 'cancelled',
     });
-
-    const cancellation = cancelTask.execute({ task_id: 'ses_1' }, context);
-    await statusStarted;
-
-    expect(() =>
-      board.registerLaunch({
-        taskID: 'ses_1',
-        parentSessionID: 'parent-1',
-        agent: 'fixer',
-        now: 200,
-      }),
-    ).toThrow('cancellation lease');
-    releaseStatus();
-
-    const output = await cancellation;
-
-    expect(status).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledTimes(1);
-    expect(String(output)).toContain('state: cancelled');
-    expect(parseTaskStatusOutput(String(output))).toMatchObject({
-      taskID: 'ses_1',
-      state: 'cancelled',
-    });
-    expect(board.get('ses_1')).toMatchObject({
-      generation: 1,
-      state: 'cancelled',
-      cancellationRequested: true,
-    });
-    expect(terminalNotifications).toEqual(['ses_1']);
+    expect(deleteSession).not.toHaveBeenCalled();
+    expect(board.acquireRelaunchLease('ses_1', 1)).toBeDefined();
   });
 
-  test('keeps running/status uncertain when abort times out without delete', async () => {
-    const { board, cancelTask } = createTool({
-      abort: () => new Promise(() => {}),
-      abortTimeoutMs: 1,
-      delete: async () => {
-        throw new Error('delete failed');
-      },
+  test('returns an uncertain running result when quiescence cannot be verified', async () => {
+    const { board, taskCancel } = createTool({
       status: async () => ({ data: { ses_1: { type: 'busy' } } }),
     });
     board.registerLaunch({
       taskID: 'ses_1',
       parentSessionID: 'parent-1',
-      agent: 'fixer',
+      agent: 'explorer',
     });
 
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
+    const output = await taskCancel.execute({ task_id: 'ses_1' }, context);
 
     expect(String(output)).toContain('state: running');
-    expect(parseTaskStatusOutput(String(output))).toMatchObject({
-      taskID: 'ses_1',
-      state: 'running',
-    });
     expect(board.get('ses_1')).toMatchObject({
       state: 'running',
-      terminalUnreconciled: false,
       statusUncertain: true,
     });
   });
 
-  test('keeps the cancellation quarantine while the abort promise is pending', async () => {
-    let releaseAbort!: (value: unknown) => void;
-    const abortPending = new Promise<unknown>((resolve) => {
-      releaseAbort = resolve;
-    });
-    const { board, abort, deleteSession, cancelTask } = createTool({
-      abort: () => abortPending,
-      abortTimeoutMs: 1,
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const cancellation = cancelTask.execute({ task_id: 'ses_1' }, context);
-    await new Promise((resolve) => setTimeout(resolve, 10));
-
-    expect(abort).toHaveBeenCalledTimes(1);
-    expect(deleteSession).not.toHaveBeenCalled();
-    expect(board.acquireRelaunchLease('ses_1', 1)).toBeUndefined();
-    expect(() =>
-      board.registerLaunch({
-        taskID: 'ses_1',
-        parentSessionID: 'parent-1',
-        agent: 'fixer',
-      }),
-    ).toThrow('cancellation lease');
-    releaseAbort({});
-    await cancellation;
-    await Promise.resolve();
-    await Promise.resolve();
-
-    const relaunchLease = board.acquireRelaunchLease('ses_1', 1);
-    expect(relaunchLease).toBeDefined();
-    if (!relaunchLease) throw new Error('relaunch lease was not released');
-    board.releaseLease(relaunchLease);
-  });
-
-  test('keeps the cancellation quarantine while the delete promise is pending', async () => {
-    let releaseDelete!: (value: unknown) => void;
-    const deletePending = new Promise<unknown>((resolve) => {
-      releaseDelete = resolve;
-    });
-    const { board, deleteSession, cancelTask } = createTool({
-      delete: () => deletePending,
-      deleteTimeoutMs: 1,
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const cancellation = cancelTask.execute({ task_id: 'ses_1' }, context);
-    await new Promise((resolve) => setTimeout(resolve, 10));
-
-    expect(deleteSession).toHaveBeenCalledTimes(1);
-    expect(board.acquireRelaunchLease('ses_1', 1)).toBeUndefined();
-    expect(() =>
-      board.registerLaunch({
-        taskID: 'ses_1',
-        parentSessionID: 'parent-1',
-        agent: 'fixer',
-      }),
-    ).toThrow('cancellation lease');
-    releaseDelete({});
-    await cancellation;
-    await Promise.resolve();
-    await Promise.resolve();
-
-    const relaunchLease = board.acquireRelaunchLease('ses_1', 1);
-    expect(relaunchLease).toBeDefined();
-    if (!relaunchLease) throw new Error('relaunch lease was not released');
-    board.releaseLease(relaunchLease);
-  });
-
-  test('keeps the cancellation quarantine while status verification is pending', async () => {
-    let releaseStatus!: (value: unknown) => void;
-    const statusPending = new Promise<unknown>((resolve) => {
-      releaseStatus = resolve;
-    });
-    const { board, deleteSession, cancelTask } = createTool({
-      status: () => statusPending,
-      deleteVerifyMs: 1,
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-    });
-
-    const cancellation = cancelTask.execute({ task_id: 'ses_1' }, context);
-    await new Promise((resolve) => setTimeout(resolve, 10));
-
-    expect(deleteSession).toHaveBeenCalledTimes(1);
-    expect(board.acquireRelaunchLease('ses_1', 1)).toBeUndefined();
-    expect(() =>
-      board.registerLaunch({
-        taskID: 'ses_1',
-        parentSessionID: 'parent-1',
-        agent: 'fixer',
-      }),
-    ).toThrow('cancellation lease');
-    releaseStatus({ data: {} });
-    await cancellation;
-    await Promise.resolve();
-    await Promise.resolve();
-
-    const relaunchLease = board.acquireRelaunchLease('ses_1', 1);
-    expect(relaunchLease).toBeDefined();
-    if (!relaunchLease) throw new Error('relaunch lease was not released');
-    board.releaseLease(relaunchLease);
-  });
-
-  test('deletes session when abort returns but session stays busy', async () => {
-    let deleted = false;
-    const { board, abort, deleteSession, cancelTask } = createTool({
-      delete: async () => {
-        deleted = true;
-        return {};
-      },
-      status: async () =>
-        deleted ? { data: {} } : { data: { ses_1: { type: 'busy' } } },
-      verifyAbortMs: 1,
-    });
+  test('rejects foreign, stale, and unsafe cancellation requests', async () => {
+    const { board, abort, taskCancel } = createTool();
     board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'oracle',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledWith({
-      path: { id: 'ses_1' },
-      query: { directory: '/test/project' },
-    });
-    expect(String(output)).toContain('state: cancelled');
-    expect(board.get('ses_1')).toMatchObject({
-      state: 'cancelled',
-      terminalUnreconciled: true,
-      cancellationRequested: true,
-    });
-  });
-
-  test('deletes and marks cancelled when session idles then becomes busy', async () => {
-    let deleted = false;
-    const statuses = [{ data: {} }, { data: { ses_1: { type: 'busy' } } }];
-    const { board, abort, deleteSession, cancelTask } = createTool({
-      delete: async () => {
-        deleted = true;
-        return {};
-      },
-      status: async () =>
-        deleted ? { data: {} } : (statuses.shift() ?? { data: {} }),
-      verifyAbortMs: 10,
-      abortRetryIntervalMs: 0,
-      stableStoppedMs: 2,
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'oracle',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).toHaveBeenCalled();
-    expect(deleteSession).toHaveBeenCalledWith({
-      path: { id: 'ses_1' },
-      query: { directory: '/test/project' },
-    });
-    expect(String(output)).toContain('state: cancelled');
-    expect(board.get('ses_1')).toMatchObject({
-      state: 'cancelled',
-      cancellationRequested: true,
-      terminalUnreconciled: true,
-    });
-  });
-
-  test('deletes session when board observes busy after abort despite idle status map', async () => {
-    let deleted = false;
-    const { board, deleteSession, cancelTask } = createTool({
-      delete: async () => {
-        deleted = true;
-        return {};
-      },
-      status: async () => (deleted ? { data: {} } : { data: {} }),
-      verifyAbortMs: 20,
-      abortRetryIntervalMs: 1,
-      stableStoppedMs: 10,
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'oracle',
-      now: Date.now() - 1000,
-    });
-
-    queueMicrotask(() => board.markRunningFromLiveSession('ses_1'));
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(deleteSession).toHaveBeenCalledWith({
-      path: { id: 'ses_1' },
-      query: { directory: '/test/project' },
-    });
-    expect(String(output)).toContain('state: cancelled');
-    expect(board.get('ses_1')).toMatchObject({
-      state: 'cancelled',
-      cancellationRequested: true,
-    });
-  });
-
-  test('marks cancelled when session disappears from status map', async () => {
-    const { board, abort, cancelTask } = createTool({
-      status: async () => ({ data: {} }),
-    });
-    board.registerLaunch({
-      taskID: 'ses_1',
-      parentSessionID: 'parent-1',
-      agent: 'oracle',
-    });
-
-    const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
-
-    expect(abort).toHaveBeenCalled();
-    expect(String(output)).toContain('state: cancelled');
-    expect(board.get('ses_1')).toMatchObject({
-      state: 'cancelled',
-      cancellationRequested: true,
-    });
-  });
-
-  test('does not destructively cancel a raw session when the board lookup is unavailable', async () => {
-    const { board, abort, deleteSession, cancelTask } = createTool({
-      abort: async () => {
-        throw new Error('network timeout');
-      },
-      delete: async () => {
-        throw new Error('delete failed');
-      },
-      status: async () => ({ data: { ses_running: 'active' } }),
+      taskID: 'ses_foreign',
+      parentSessionID: 'parent-2',
+      agent: 'explorer',
     });
-    // Register a running job so that isRunning(taskID) would be true
-    // if the function incorrectly checks it.
     board.registerLaunch({
-      taskID: 'ses_running',
+      taskID: 'ses_done',
       parentSessionID: 'parent-1',
-      agent: 'fixer',
+      agent: 'explorer',
     });
-    // Override resolve to return undefined, forcing the untracked raw-session
-    // path instead of the tracked task path.
-    board.resolve = mock(() => undefined);
+    board.updateStatus({ taskID: 'ses_done', state: 'completed' });
 
-    const output = await cancelTask.execute(
-      { task_id: 'ses_running', reason: 'regression guard' },
+    const foreign = await taskCancel.execute(
+      { task_id: 'ses_foreign' },
       context,
     );
+    const stale = await taskCancel.execute({ task_id: 'ses_done' }, context);
+    const parent = await taskCancel.execute(
+      { task_id: 'ses_parent' },
+      { ...context, sessionID: 'ses_parent' },
+    );
 
     expect(abort).not.toHaveBeenCalled();
-    expect(deleteSession).not.toHaveBeenCalled();
-    expect(String(output)).toContain('state: unknown');
-    expect(String(output)).toContain('best-effort/uncertain');
+    expect(String(foreign)).toContain('state: unknown');
+    expect(String(stale)).toContain('stale/uncertain cancellation');
+    expect(String(parent)).toContain('cannot cancel parent session');
   });
 
-  test('denies non-orchestrator agents', async () => {
-    const { cancelTask } = createTool();
+  test('enforces orchestrator ownership', async () => {
+    const { taskCancel } = createTool({ shouldManageSession: () => false });
 
     await expect(
-      cancelTask.execute({ task_id: 'ses_1' }, {
+      taskCancel.execute({ task_id: 'ses_1' }, {
+        sessionID: 'parent-1',
+        agent: 'orchestrator',
+      } as any),
+    ).rejects.toThrow('orchestrator sessions');
+    await expect(
+      taskCancel.execute({ task_id: 'ses_1' }, {
         sessionID: 'parent-1',
         agent: 'fixer',
       } as any),
     ).rejects.toThrow('orchestrator');
   });
-
-  test('denies unmanaged sessions', async () => {
-    const { cancelTask } = createTool({ shouldManageSession: () => false });
-
-    await expect(
-      cancelTask.execute({ task_id: 'ses_1' }, context),
-    ).rejects.toThrow('orchestrator sessions');
-  });
 });

+ 220 - 447
src/tools/cancel-task.ts

@@ -5,7 +5,6 @@ import {
 } from '@opencode-ai/plugin';
 import type { BackgroundJobLease } from '../utils/background-job-board';
 import type { BackgroundJobStore } from '../utils/background-job-store';
-import { log } from '../utils/logger';
 import { getClient } from '../utils/opencode-client';
 import { delay } from '../utils/polling';
 import {
@@ -13,10 +12,14 @@ import {
   SESSION_ID_PATTERN,
   withTimeout,
 } from '../utils/session';
+import {
+  getRuntimeSessionStatusSnapshot,
+  runtimeSessionStatus,
+} from '../utils/session-runtime-status';
 
 const z = tool.schema;
 
-interface CancelTaskToolOptions {
+export interface TaskControlToolOptions {
   input: PluginInput;
   backgroundJobBoard: BackgroundJobStore;
   shouldManageSession: (sessionID: string) => boolean;
@@ -24,12 +27,14 @@ interface CancelTaskToolOptions {
   verifyAbortMs?: number;
   abortRetryIntervalMs?: number;
   stableStoppedMs?: number;
-  deleteTimeoutMs?: number;
-  deleteVerifyMs?: number;
-  deleteStableStoppedMs?: number;
 }
 
-class SessionStillRunningError extends Error {}
+interface CapturedExecution {
+  taskID: string;
+  generation: number;
+}
+
+export class SessionStillRunningError extends Error {}
 
 class LeaseOwnershipLostError extends Error {}
 
@@ -44,12 +49,12 @@ class LeaseOperationTimeoutError extends Error {
 }
 
 export function createCancelTaskTool(
-  options: CancelTaskToolOptions,
-): Record<string, ToolDefinition> {
-  const cancel_task = tool({
-    description: `Cancel a tracked background specialist task.
+  options: TaskControlToolOptions,
+): Record<'task_cancel', ToolDefinition> {
+  const task_cancel = tool({
+    description: `Cancel a tracked background specialist task without deleting its session.
 
-Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accepts either the native task_id/session ID or the parent-scoped alias shown in the Background Job Board. Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before replacing the lane.`,
+Use only for obsolete, wrong, conflicting, or user-requested cancellation. The retained session can be revived after the lifecycle lane acknowledges its terminal state.`,
     args: {
       task_id: z
         .string()
@@ -57,155 +62,45 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
       reason: z.string().optional().describe('Short cancellation reason'),
     },
     async execute(args, toolContext) {
-      const parentSessionID = toolContext?.sessionID;
-      if (!parentSessionID) throw new Error('cancel_task requires sessionID');
-      if (toolContext.agent && toolContext.agent !== 'orchestrator') {
-        throw new Error('cancel_task can only be used by orchestrator');
-      }
-      if (!options.shouldManageSession(parentSessionID)) {
-        throw new Error(
-          'cancel_task can only be used in orchestrator sessions',
-        );
-      }
-
+      const parentSessionID = assertOrchestrator(
+        options,
+        toolContext,
+        'task_cancel',
+      );
       const requested = args.task_id.trim();
-      if (!requested) throw new Error('cancel_task requires task_id');
+      if (!requested) throw new Error('task_cancel requires task_id');
 
       const job = options.backgroundJobBoard.resolve(
         parentSessionID,
         requested,
       );
-      log('[cancel-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,
-        terminalState: job
-          ? options.backgroundJobBoard.field(job.taskID, 'terminalState')
-          : undefined,
-        cancellationRequested: job?.cancellationRequested,
-      });
       if (!job) {
-        if (SESSION_ID_PATTERN.test(requested)) {
-          if (requested === parentSessionID) {
-            log('[cancel-task] rejected parent session cancellation', {
-              parentSessionID,
-              taskID: requested,
-            });
-            return unknownTaskOutput(requested, 'cannot cancel parent session');
-          }
-
-          const knownJob = options.backgroundJobBoard.get(requested);
-          const ownerParentSessionID =
-            options.backgroundJobBoard.getParentSessionID(requested);
-          if (knownJob && ownerParentSessionID !== parentSessionID) {
-            log('[cancel-task] rejected unowned tracked raw session', {
-              parentSessionID,
-              taskID: requested,
-              ownerParentSessionID,
-            });
-            return unknownTaskOutput(
-              requested,
-              'unknown or unowned background task',
-            );
-          }
-
-          const parentID = await getSessionParentID(options.input, requested);
-          if (parentID !== parentSessionID) {
-            log('[cancel-task] rejected raw session without parent ownership', {
-              parentSessionID,
-              taskID: requested,
-              actualParentID: parentID,
-            });
-            return unknownTaskOutput(
-              requested,
-              'unknown or unowned background task',
-            );
-          }
-
-          log(
-            '[cancel-task] refusing destructive action for untracked raw session',
-            {
-              parentSessionID,
-              taskID: requested,
-            },
-          );
-          return unknownTaskOutput(
-            requested,
-            'best-effort/uncertain cancellation: session ownership was observed, but no tracked generation exists; no remote abort or delete was attempted',
-          );
-        }
-
         return unknownTaskOutput(
           requested,
-          'unknown or unowned background task',
+          await untrackedTaskReason(options, parentSessionID, requested),
         );
       }
 
-      const capturedExecution = {
+      const execution = {
         taskID: job.taskID,
         generation: job.generation,
       };
-      const cancellationLease =
-        options.backgroundJobBoard.acquireCancellationLease(
-          capturedExecution.taskID,
-          capturedExecution.generation,
-        );
-      if (!cancellationLease) {
+      if (job.state !== 'running') {
         return staleCancellationOutput(
           options,
-          capturedExecution,
-          'cancellation lease unavailable; no remote operation was attempted',
+          execution,
+          `task is ${job.state}, not running`,
         );
       }
+
       try {
-        await abortAndVerifySession(
-          options,
-          capturedExecution,
-          cancellationLease,
-        );
-        if (!options.backgroundJobBoard.validateLease(cancellationLease)) {
-          return staleCancellationOutput(options, capturedExecution);
-        }
+        await cancelTrackedExecution(options, execution, args.reason);
       } catch (error) {
-        const stillRunning = error instanceof SessionStillRunningError;
-        const boardRunning = options.backgroundJobBoard.isRunning(
-          capturedExecution.taskID,
-        );
-        log('[cancel-task] abort failed', {
-          taskID: capturedExecution.taskID,
-          stillRunning,
-          boardRunning,
-          error: error instanceof Error ? error.message : String(error),
-        });
-        if (!options.backgroundJobBoard.validateLease(cancellationLease)) {
-          return staleCancellationOutput(options, capturedExecution);
-        }
+        const current = options.backgroundJobBoard.get(execution.taskID);
         const message = error instanceof Error ? error.message : String(error);
-        const updated = options.backgroundJobBoard.markStatusUncertain(
-          capturedExecution.taskID,
-          message,
-          capturedExecution.generation,
-        );
-        const quarantined =
-          error instanceof LeaseOperationTimeoutError && error.pending;
-        if (!isCapturedExecution(updated, capturedExecution)) {
-          if (!quarantined) {
-            options.backgroundJobBoard.releaseLease(cancellationLease);
-          }
-          return staleCancellationOutput(options, capturedExecution);
-        }
-        if (!quarantined) {
-          options.backgroundJobBoard.releaseLease(cancellationLease);
-        }
         return [
-          `task_id: ${capturedExecution.taskID}`,
-          `state: ${updated?.state ?? 'unknown'}`,
+          `task_id: ${execution.taskID}`,
+          `state: ${current?.state ?? 'unknown'}`,
           '',
           '<task_error>',
           message,
@@ -213,328 +108,181 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
         ].join('\n');
       }
 
-      const cancellationOptions = {
-        force: true,
-        expectedGeneration: capturedExecution.generation,
-        cancellationLease,
-      };
-      const marked = options.backgroundJobBoard.markCancelled(
-        capturedExecution.taskID,
-        args.reason,
-        Date.now(),
-        cancellationOptions,
-      );
-      if (!isCapturedExecution(marked, capturedExecution)) {
-        options.backgroundJobBoard.releaseLease(cancellationLease);
-        return staleCancellationOutput(options, capturedExecution);
-      }
-      if (!options.backgroundJobBoard.validateLease(cancellationLease)) {
-        return staleCancellationOutput(options, capturedExecution);
-      }
-      const state = options.backgroundJobBoard.getState(
-        capturedExecution.taskID,
-      );
-      log('[cancel-task] marked job cancelled after verified abort', {
-        taskID: capturedExecution.taskID,
-        alias: options.backgroundJobBoard.field(
-          capturedExecution.taskID,
-          'alias',
-        ),
-        state,
-        cancellationRequested: options.backgroundJobBoard.field(
-          capturedExecution.taskID,
-          'cancellationRequested',
-        ),
-      });
-      options.backgroundJobBoard.releaseLease(cancellationLease);
-
+      const state = options.backgroundJobBoard.getState(execution.taskID);
       return [
-        `task_id: ${capturedExecution.taskID}`,
+        `task_id: ${execution.taskID}`,
         `state: ${state ?? 'cancelled'}`,
         '',
         '<task_error>',
-        options.backgroundJobBoard.getResultSummary(capturedExecution.taskID) ??
+        options.backgroundJobBoard.getResultSummary(execution.taskID) ??
           'cancelled',
         '</task_error>',
       ].join('\n');
     },
   });
 
-  return { cancel_task };
+  return { task_cancel };
 }
 
-async function abortAndVerifySession(
-  options: CancelTaskToolOptions,
-  execution: { taskID: string; generation: number },
-  lease: BackgroundJobLease,
+/**
+ * Abort one captured generation and prove that its retained host session is
+ * quiescent. This is shared by task_cancel and task_revive; neither operation
+ * ever deletes the session.
+ */
+export async function cancelTrackedExecution(
+  options: TaskControlToolOptions,
+  execution: CapturedExecution,
+  reason?: string,
 ): Promise<void> {
-  const taskID = execution.taskID;
-  let abortConfirmed = false;
-  log('[cancel-task] abort attempt starting', { taskID });
-  assertLease(options.backgroundJobBoard, lease, execution);
-  try {
-    const response = await awaitLeaseOperation(
-      options.backgroundJobBoard,
-      lease,
-      () => getClient(options.input).session.abort({ path: { id: taskID } }),
-      options.abortTimeoutMs ?? 10_000,
-      `Session abort timed out after ${options.abortTimeoutMs ?? 10_000}ms`,
+  const lease = options.backgroundJobBoard.acquireCancellationLease(
+    execution.taskID,
+    execution.generation,
+  );
+  if (!lease) {
+    throw new Error(
+      `stale/uncertain cancellation: cancellation lease unavailable for ${execution.taskID}`,
     );
-    assertLease(options.backgroundJobBoard, lease, execution);
-    const responseError = operationError(response);
-    if (responseError !== undefined) throw responseError;
-    const responseData = operationBoolean(response);
-    if (responseData === false) {
-      throw new Error(`Session abort was not confirmed: ${taskID}`);
-    }
-    abortConfirmed = responseData === true;
-    log('[cancel-task] abort call returned', { taskID });
-  } catch (error) {
-    if (error instanceof LeaseOperationTimeoutError) throw error;
-    assertLease(options.backgroundJobBoard, lease, execution);
-    abortConfirmed = isExplicitSessionAbsence(error);
-    log('[cancel-task] abort call failed', {
-      taskID,
-      error: error instanceof Error ? error.message : String(error),
-    });
   }
 
-  // ponytail: v1 had a polling loop here that verified abort succeeded before
-  // proceeding to delete. v2 abort is server-side and synchronous — the delete
-  // verification loop below catches any remaining running state.
-  assertLease(options.backgroundJobBoard, lease, execution);
+  let keepLeaseUntilSettled = false;
   try {
-    await deleteAndVerifySession(
-      options,
-      execution,
-      lease,
-      'cancel-task-after-abort',
+    await abortAndVerifySession(options, execution, lease);
+    assertCapturedExecution(options.backgroundJobBoard, execution);
+    const marked = options.backgroundJobBoard.markCancelled(
+      execution.taskID,
+      reason,
+      Date.now(),
+      {
+        force: true,
+        expectedGeneration: execution.generation,
+        cancellationLease: lease,
+      },
     );
+    if (!isCapturedExecution(marked, execution)) {
+      throw new Error(
+        `stale/uncertain cancellation: ${execution.taskID} generation changed`,
+      );
+    }
   } catch (error) {
-    if (error instanceof LeaseOperationTimeoutError) throw error;
-    // A confirmed native abort or an explicit not-found response is already
-    // terminal evidence. A transport/unknown abort failure is not evidence;
-    // in that case a failed delete must remain uncertain as well.
-    if (abortConfirmed) return;
+    keepLeaseUntilSettled =
+      error instanceof LeaseOperationTimeoutError && error.pending;
+    const message = error instanceof Error ? error.message : String(error);
+    options.backgroundJobBoard.markStatusUncertain(
+      execution.taskID,
+      message,
+      execution.generation,
+    );
     throw error;
+  } finally {
+    if (!keepLeaseUntilSettled) {
+      options.backgroundJobBoard.releaseLease(lease);
+    }
   }
 }
 
-async function deleteAndVerifySession(
-  options: CancelTaskToolOptions,
-  execution: { taskID: string; generation: number },
+async function abortAndVerifySession(
+  options: TaskControlToolOptions,
+  execution: CapturedExecution,
   lease: BackgroundJobLease,
-  reason: string,
 ): Promise<void> {
-  const taskID = execution.taskID;
-  const client = getClient(options.input);
-
   assertLease(options.backgroundJobBoard, lease, execution);
-  log('[cancel-task] deleting session after abort attempt', {
-    taskID,
-    reason,
-  });
+  const taskID = execution.taskID;
+  let response: unknown;
   try {
-    const response = await awaitLeaseOperation(
+    response = await awaitLeaseOperation(
       options.backgroundJobBoard,
       lease,
-      () =>
-        client.session.delete({
-          path: { id: taskID },
-          query: { directory: options.input.directory },
-        }),
-      options.deleteTimeoutMs ?? 10_000,
-      `Session delete timed out after ${options.deleteTimeoutMs ?? 10_000}ms`,
+      () => getClient(options.input).session.abort({ path: { id: taskID } }),
+      options.abortTimeoutMs ?? 10_000,
+      `Session abort timed out after ${options.abortTimeoutMs ?? 10_000}ms`,
     );
-    assertLease(options.backgroundJobBoard, lease, execution);
-    const responseError = operationError(response);
-    if (responseError !== undefined) throw responseError;
-    const responseData = operationBoolean(response);
-    if (responseData === false) {
-      throw new Error(`Session delete was not confirmed: ${taskID}`);
-    }
-    log('[cancel-task] session delete returned', { taskID, reason });
   } catch (error) {
-    if (error instanceof LeaseOperationTimeoutError) throw error;
     assertLease(options.backgroundJobBoard, lease, execution);
-    if (isExplicitSessionAbsence(error)) {
-      log('[cancel-task] session delete confirmed missing/deleted', {
-        taskID,
-        reason,
-        error: error instanceof Error ? error.message : String(error),
-      });
-      return;
-    }
-    log('[cancel-task] session delete failed; verifying live state', {
-      taskID,
-      reason,
-      error: error instanceof Error ? error.message : String(error),
-    });
-    const status = await getSessionStatus(
-      options.input,
-      taskID,
-      options.deleteVerifyMs ?? 1_500,
-      lease,
-      options.backgroundJobBoard,
-    );
-    assertLease(options.backgroundJobBoard, lease, execution);
-    log('[cancel-task] delete failure verification status', {
-      taskID,
-      reason,
-      status: status.status,
-      statusSource: status.source,
-      statusKeys: status.keys,
-    });
-    if (status.status === 'busy' || status.status === 'retry') {
-      throw new SessionStillRunningError(
-        `Session delete failed and task is still busy: ${taskID}`,
-      );
-    }
-    // An idle or missing status entry is only a liveness observation. It does
-    // not prove that a failed delete or abort reached the server, so preserve
-    // the operation error and let the caller expose an uncertain/error state.
     throw error;
   }
+  assertLease(options.backgroundJobBoard, lease, execution);
+  const responseError = operationError(response);
+  if (responseError !== undefined) throw new Error(errorText(responseError));
+  if (operationBoolean(response) === false) {
+    throw new Error(`Session abort was not confirmed: ${taskID}`);
+  }
 
-  const deadline = Date.now() + (options.deleteVerifyMs ?? 1_500);
-  const stableStoppedMs = options.deleteStableStoppedMs ?? 300;
+  await verifyQuiescentSession(options, execution, lease);
+}
+
+async function verifyQuiescentSession(
+  options: TaskControlToolOptions,
+  execution: CapturedExecution,
+  lease: BackgroundJobLease,
+): Promise<void> {
+  const deadline = Date.now() + (options.verifyAbortMs ?? 1_500);
+  const stableStoppedMs = options.stableStoppedMs ?? 300;
   const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
   let stableStoppedSince: number | undefined;
-  let attempts = 0;
   let lastStatus: string | undefined;
+
   while (Date.now() <= deadline) {
-    attempts += 1;
     assertLease(options.backgroundJobBoard, lease, execution);
     const status = await getSessionStatus(
       options.input,
-      taskID,
+      execution.taskID,
       Math.max(1, deadline - Date.now()),
       lease,
       options.backgroundJobBoard,
     );
     assertLease(options.backgroundJobBoard, lease, execution);
     lastStatus = status.status;
-    log('[cancel-task] delete verification status', {
-      taskID,
-      reason,
-      attempts,
-      status: status.status,
-      statusSource: status.source,
-      statusKeys: status.keys,
-      stableStoppedSince,
-    });
-    const quiescent =
-      status.status === 'idle' || status.source === 'missing-from-map';
+    const quiescent = status.status === 'idle';
     if (!quiescent) {
       stableStoppedSince = undefined;
       await delay(retryIntervalMs);
-      assertLease(options.backgroundJobBoard, lease, execution);
       continue;
     }
     stableStoppedSince ??= Date.now();
     if (Date.now() - stableStoppedSince >= stableStoppedMs) return;
     await delay(retryIntervalMs);
-    assertLease(options.backgroundJobBoard, lease, execution);
   }
 
   throw new SessionStillRunningError(
-    `Session delete returned but task did not stay stopped: ${taskID} (${lastStatus ?? 'unknown'})`,
+    `Session abort returned but task did not stay stopped: ${execution.taskID} (${lastStatus ?? 'unknown'})`,
   );
 }
 
 async function getSessionStatus(
   input: PluginInput,
   taskID: string,
-  timeoutMs?: number,
-  lease?: BackgroundJobLease,
-  backgroundJobBoard?: BackgroundJobStore,
-): Promise<{
-  status: string | undefined;
-  source: string;
-  keys: string[];
-}> {
-  if (!lease || !backgroundJobBoard) {
-    throw new LeaseOwnershipLostError(
-      `Session status lookup requires a live cancellation lease: ${taskID}`,
-    );
-  }
+  timeoutMs: number,
+  lease: BackgroundJobLease,
+  backgroundJobBoard: BackgroundJobStore,
+): Promise<{ status: 'busy' | 'retry' | 'idle' | undefined; source: string }> {
   assertLease(backgroundJobBoard, lease, {
     taskID: lease.taskID,
     generation: lease.generation,
   });
-
-  let response: unknown;
   try {
-    response = await awaitLeaseOperation(
+    const snapshot = await awaitLeaseOperation(
       backgroundJobBoard,
       lease,
       () =>
-        getClient(input).session.status({
-          query: { directory: input.directory },
+        getRuntimeSessionStatusSnapshot(input, {
+          timeoutMs: Math.max(1, timeoutMs),
         }),
-      Math.max(1, timeoutMs ?? 5_000),
-      `Session status lookup timed out after ${Math.max(1, timeoutMs ?? 5_000)}ms`,
+      Math.max(1, timeoutMs),
+      `Session status lookup timed out after ${Math.max(1, timeoutMs)}ms`,
     );
-  } catch (error) {
-    if (error instanceof LeaseOperationTimeoutError) throw error;
-    if (error instanceof LeaseOwnershipLostError) throw error;
+    const status = runtimeSessionStatus(snapshot, taskID);
+    if (status !== undefined) return { status, source: 'task-map-entry' };
     return {
       status: undefined,
-      source: 'lookup-error',
-      keys: [],
+      source: snapshot.error
+        ? 'lookup-error'
+        : snapshot.malformedSessionIDs.has(taskID)
+          ? 'malformed-task-map-entry'
+          : 'missing-from-map',
     };
-  }
-  assertLease(backgroundJobBoard, lease, {
-    taskID: lease.taskID,
-    generation: lease.generation,
-  });
-
-  const data = isRecord(response) ? response.data : undefined;
-  if (
-    !isRecord(data) ||
-    Object.hasOwn(data, 'type') ||
-    Object.hasOwn(data, 'status')
-  ) {
-    return { status: undefined, source: 'lookup-error', keys: [] };
-  }
-
-  const statuses = new Map<string, 'busy' | 'retry' | 'idle'>();
-  const malformedSessionIDs = new Set<string>();
-  for (const [sessionID, value] of Object.entries(data)) {
-    if (
-      isRecord(value) &&
-      (value.type === 'busy' || value.type === 'retry' || value.type === 'idle')
-    ) {
-      statuses.set(sessionID, value.type);
-    } else {
-      malformedSessionIDs.add(sessionID);
-    }
-  }
-  return {
-    status: malformedSessionIDs.has(taskID) ? undefined : statuses.get(taskID),
-    source: malformedSessionIDs.has(taskID)
-      ? 'malformed-entry'
-      : statuses.has(taskID)
-        ? 'task-map-entry'
-        : 'missing-from-map',
-    keys: [...statuses.keys()].slice(0, 20),
-  };
-}
-
-function assertLease(
-  backgroundJobBoard: BackgroundJobStore,
-  lease: BackgroundJobLease,
-  execution: { taskID: string; generation: number },
-): void {
-  if (
-    lease.taskID !== execution.taskID ||
-    lease.generation !== execution.generation ||
-    lease.kind !== 'cancellation' ||
-    !backgroundJobBoard.validateLease(lease)
-  ) {
-    throw new LeaseOwnershipLostError(
-      `Cancellation lease is no longer valid for ${execution.taskID} generation ${execution.generation}`,
-    );
+  } catch (error) {
+    if (error instanceof LeaseOperationTimeoutError) throw error;
+    return { status: undefined, source: 'lookup-error' };
   }
 }
 
@@ -572,10 +320,79 @@ async function awaitLeaseOperation<T>(
   }
 }
 
+function assertLease(
+  backgroundJobBoard: BackgroundJobStore,
+  lease: BackgroundJobLease,
+  execution: CapturedExecution,
+): void {
+  if (
+    lease.taskID !== execution.taskID ||
+    lease.generation !== execution.generation ||
+    lease.kind !== 'cancellation' ||
+    !backgroundJobBoard.validateLease(lease)
+  ) {
+    throw new LeaseOwnershipLostError(
+      `Cancellation lease is no longer valid for ${execution.taskID} generation ${execution.generation}`,
+    );
+  }
+}
+
+function assertOrchestrator(
+  options: TaskControlToolOptions,
+  toolContext: { sessionID?: string; agent?: string } | undefined,
+  toolName: string,
+): string {
+  const parentSessionID = toolContext?.sessionID;
+  if (!parentSessionID) throw new Error(`${toolName} requires sessionID`);
+  if (toolContext.agent && toolContext.agent !== 'orchestrator') {
+    throw new Error(`${toolName} can only be used by orchestrator`);
+  }
+  if (!options.shouldManageSession(parentSessionID)) {
+    throw new Error(`${toolName} can only be used in orchestrator sessions`);
+  }
+  return parentSessionID;
+}
+
+async function untrackedTaskReason(
+  options: TaskControlToolOptions,
+  parentSessionID: string,
+  requested: string,
+): Promise<string> {
+  if (!SESSION_ID_PATTERN.test(requested))
+    return 'unknown or unowned background task';
+  if (requested === parentSessionID) return 'cannot cancel parent session';
+  const knownJob = options.backgroundJobBoard.get(requested);
+  if (
+    knownJob &&
+    options.backgroundJobBoard.getParentSessionID(requested) !== parentSessionID
+  ) {
+    return 'unknown or unowned background task';
+  }
+  const owner = await getSessionParentID(options.input, requested);
+  if (owner !== parentSessionID) return 'unknown or unowned background task';
+  return 'best-effort/uncertain cancellation: session ownership was observed, but no tracked generation exists; no remote abort was attempted';
+}
+
+async function getSessionParentID(
+  input: PluginInput,
+  taskID: string,
+): Promise<string | undefined> {
+  try {
+    const response = await getClient(input).session.get({
+      path: { id: taskID },
+      query: { directory: input.directory },
+    });
+    return response.data?.parentID;
+  } catch {
+    return undefined;
+  }
+}
+
 function operationError(response: unknown): unknown {
   if (!isRecord(response)) return undefined;
-  const error = response.error;
-  return error === undefined || error === null ? undefined : error;
+  return response.error === undefined || response.error === null
+    ? undefined
+    : response.error;
 }
 
 function operationBoolean(response: unknown): boolean | undefined {
@@ -584,31 +401,6 @@ function operationBoolean(response: unknown): boolean | undefined {
   return typeof response.data === 'boolean' ? response.data : undefined;
 }
 
-function isExplicitSessionAbsence(error: unknown): boolean {
-  const statusCode = findStatusCode(error);
-  if (statusCode === 404) return true;
-
-  const text = errorText(error);
-  return /\b(?:not[\s_-]?found(?:error)?|no such (?:session|resource)|does not exist|already[\s_-]?deleted|session[\s_-]?deleted)\b/i.test(
-    text,
-  );
-}
-
-function findStatusCode(value: unknown, depth = 0): number | undefined {
-  if (depth > 3 || !isRecord(value)) return undefined;
-  for (const key of ['statusCode', 'status']) {
-    const candidate = value[key];
-    if (typeof candidate === 'number') return candidate;
-    if (typeof candidate === 'string' && /^\d+$/.test(candidate)) {
-      return Number(candidate);
-    }
-  }
-  return (
-    findStatusCode(value.data, depth + 1) ??
-    findStatusCode(value.cause, depth + 1)
-  );
-}
-
 function errorText(error: unknown): string {
   if (error instanceof Error) return error.message;
   if (typeof error === 'string') return error;
@@ -623,27 +415,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null;
 }
 
-async function getSessionParentID(
-  input: PluginInput,
-  taskID: string,
-): Promise<string | undefined> {
-  try {
-    const response = await getClient(input).session.get({
-      path: { id: taskID },
-      query: { directory: input.directory },
-    });
-    const session = response.data;
-    if (!session) return undefined;
-    return session.parentID;
-  } catch (error) {
-    log('[cancel-task] session metadata lookup failed', {
-      taskID,
-      error: error instanceof Error ? error.message : String(error),
-    });
-    return undefined;
-  }
-}
-
 function unknownTaskOutput(taskID: string, message: string): string {
   return [
     `task_id: ${taskID}`,
@@ -657,7 +428,7 @@ function unknownTaskOutput(taskID: string, message: string): string {
 
 function isCapturedExecution(
   record: ReturnType<BackgroundJobStore['get']>,
-  capturedExecution: { taskID: string; generation: number },
+  capturedExecution: CapturedExecution,
 ): boolean {
   return (
     record?.taskID === capturedExecution.taskID &&
@@ -665,29 +436,31 @@ function isCapturedExecution(
   );
 }
 
+function assertCapturedExecution(
+  backgroundJobBoard: BackgroundJobStore,
+  execution: CapturedExecution,
+): void {
+  if (
+    !isCapturedExecution(backgroundJobBoard.get(execution.taskID), execution)
+  ) {
+    throw new Error(
+      `stale/uncertain cancellation: ${execution.taskID} generation changed`,
+    );
+  }
+}
+
 function staleCancellationOutput(
-  options: CancelTaskToolOptions,
-  capturedExecution: { taskID: string; generation: number },
-  detail?: string,
+  options: TaskControlToolOptions,
+  execution: CapturedExecution,
+  detail: string,
 ): string {
-  const current = options.backgroundJobBoard.get(capturedExecution.taskID);
-  const message = detail
-    ? `stale/uncertain cancellation: ${detail}`
-    : current
-      ? `stale/uncertain cancellation: ${capturedExecution.taskID} changed from generation ${capturedExecution.generation} to generation ${current.generation}; the newer execution was not cancelled`
-      : `stale/uncertain cancellation: ${capturedExecution.taskID} is no longer tracked; generation ${capturedExecution.generation} was not cancelled`;
-  log('[cancel-task] refusing stale cancellation terminal transition', {
-    taskID: capturedExecution.taskID,
-    capturedGeneration: capturedExecution.generation,
-    currentGeneration: current?.generation,
-    currentState: current?.state,
-  });
+  const current = options.backgroundJobBoard.get(execution.taskID);
   return [
-    `task_id: ${capturedExecution.taskID}`,
+    `task_id: ${execution.taskID}`,
     `state: ${current?.state ?? 'unknown'}`,
     '',
     '<task_error>',
-    message,
+    `stale/uncertain cancellation: ${detail}`,
     '</task_error>',
   ].join('\n');
 }

+ 27 - 8
src/tools/codemap.md

@@ -4,7 +4,7 @@
 
 Centralized tool factory and registry for the OpenCode plugin system. This directory defines all executable tools exposed to OpenCode agents, including:
 
-- **Agent orchestration tools**: Multi-LLM council synthesis, task cancellation, and ACP agent execution
+- **Agent orchestration tools**: Multi-LLM council synthesis, background task lifecycle controls, and ACP agent execution
 - **Code intelligence tools**: AST-grep pattern matching and transformation across languages
 - **Web capabilities**: Smart web fetching with caching and secondary model processing
 - **Runtime configuration**: Preset management for dynamic agent configuration switching
@@ -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 communication, cancellation, status, results, revival, and HITL continuation control | `task-message.ts`, `cancel-task.ts`, `task-status.ts`, `task-result.ts`, `task-revive.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` |
@@ -44,7 +44,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 
 - **Runtime Presets**: Preset state persists across plugin reloads via `RuntimeConfig` (`src/config/runtime.ts`)
 - **TUI Integration**: Preset changes persist to the config file only; the sidebar is NOT refreshed mid-session (the agent registry is unchanged until reload) — hot-swapping the agent tree during an active conversation risks context truncation, drifted prior turns, and stale subagent references
-- **Background Jobs**: Task cancellation uses a centralized job board for tracking and cleanup
+- **Background Jobs**: Task communication, cancellation, status, results, and revival use a centralized job board for tracking and lifecycle coordination
 
 ## Flow
 
@@ -68,16 +68,29 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
    └─> OpenCode presents result to agent
 ```
 
-### Task Cancellation Flow
+### Task Control Flows
 
 ```
-1. Orchestrator invokes cancel_task tool
+1. Orchestrator invokes task_cancel
    ├─> Validates calling agent is 'orchestrator'
    ├─> Resolves task_id to BackgroundJobBoard entry
    ├─> Calls abortSessionWithTimeout() to signal cancellation
    ├─> Verifies session stopped via status polling
    ├─> Marks job as cancelled in BackgroundJobBoard
-   └─> Returns cancellation confirmation
+   └─> Returns cancellation confirmation while retaining the child session
+
+2. Orchestrator invokes task_message
+   ├─> Resolves task_id to a live BackgroundJobBoard entry
+   ├─> Acquires a generation-scoped message lease
+   ├─> Queues a bounded no-reply message without interrupting or resuming the child
+   └─> Returns transport-confirmed queue status
+
+3. Orchestrator invokes task_revive
+   ├─> Resolves the retained BackgroundJobBoard entry
+   ├─> Cancels a running generation when necessary
+   ├─> Launches a new prompt in the existing child session
+   ├─> Registers the new generation and tracks its completion
+   └─> Returns the new running generation
 ```
 
 ### Explicit User-Wait Flow
@@ -162,8 +175,10 @@ 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
+├─ task_cancel → BackgroundJobBoard.resolve() → abortSessionWithTimeout()
+├─ task_message → BackgroundJobBoard.resolve() → no-reply prompt transport
+├─ task_revive → BackgroundJobBoard.resolve() → retained-session relaunch
+└─> Returns lifecycle or transport status
 
 Tools Layer → Config Layer
 ├─ acp_run tool → AcpAgentsConfig from config system
@@ -204,6 +219,10 @@ export { ast_grep_replace, ast_grep_search } from './ast-grep';
 
 // Task management
 export { createCancelTaskTool } from './cancel-task';
+export { createTaskMessageTool } from './task-message';
+export { createTaskResultTool } from './task-result';
+export { createTaskReviveTool } from './task-revive';
+export { createTaskStatusTool } from './task-status';
 export { createWaitForUserTool } from './wait-for-user';
 
 // Preset management

+ 2 - 1
src/tools/index.ts

@@ -3,7 +3,8 @@ export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
 export { createWebfetchTool } from './smartfetch';
-export { createTaskNudgeTool } from './task-nudge';
+export { createTaskMessageTool } from './task-message';
 export { createTaskResultTool } from './task-result';
+export { createTaskReviveTool } from './task-revive';
 export { createTaskStatusTool } from './task-status';
 export { createWaitForUserTool } from './wait-for-user';

+ 288 - 0
src/tools/task-message.test.ts

@@ -0,0 +1,288 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../utils/background-job-board';
+import { createTaskMessageTool } from './task-message';
+
+let client: Record<string, any>;
+mock.module('../utils/opencode-client', () => ({ getClient: () => client }));
+
+function registerRunningChild(
+  board: BackgroundJobBoard,
+  taskID = 'ses_child1',
+  parent = 'parent-1',
+): void {
+  board.registerLaunch({
+    taskID,
+    parentSessionID: parent,
+    agent: 'fixer',
+    description: 'implement',
+    now: 0,
+  });
+}
+
+function makePrompt(): ReturnType<typeof mock> {
+  return mock(async () => ({}));
+}
+
+function createTool(board: BackgroundJobBoard) {
+  return createTaskMessageTool({
+    input: { directory: '/test' } as any,
+    backgroundJobBoard: board,
+  }).task_message;
+}
+
+function createToolWithTimeout(board: BackgroundJobBoard, timeoutMs: number) {
+  return createTaskMessageTool({
+    input: { directory: '/test' } as any,
+    backgroundJobBoard: board,
+    messageTimeoutMs: timeoutMs,
+  }).task_message;
+}
+
+describe('task_message', () => {
+  test('queues messages for a parent-owned running child', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    const prompt = makePrompt();
+    client = { session: { prompt } };
+
+    await expect(
+      createTool(board).execute(
+        { task_id: 'ses_child1', message: 'Please continue.' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).resolves.toContain('queued');
+
+    expect(prompt).toHaveBeenCalledWith({
+      path: { id: 'ses_child1' },
+      body: {
+        noReply: true,
+        parts: [{ type: 'text', text: 'Please continue.' }],
+      },
+      throwOnError: true,
+    });
+  });
+
+  test('uses only the noReply transport and permits repeated updates', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    const prompt = makePrompt();
+    client = { session: { prompt } };
+    const task_message = createTool(board);
+
+    await task_message.execute({ task_id: 'ses_child1', message: 'First' }, {
+      sessionID: 'parent-1',
+    } as any);
+    await task_message.execute({ task_id: 'ses_child1', message: 'Second' }, {
+      sessionID: 'parent-1',
+    } as any);
+
+    expect(prompt).toHaveBeenCalledTimes(2);
+    expect((client.session as any).promptAsync).toBeUndefined();
+    expect(prompt.mock.calls[0]?.[0].body.noReply).toBe(true);
+    expect(prompt.mock.calls[1]?.[0].body.noReply).toBe(true);
+  });
+
+  test('serializes message transport against cancellation and relaunch', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    let releasePrompt!: () => void;
+    const prompt = mock(
+      () =>
+        new Promise<unknown>((resolve) => {
+          releasePrompt = () => resolve({});
+        }),
+    );
+    client = { session: { prompt } };
+
+    const pending = createTool(board).execute(
+      { task_id: 'ses_child1', message: 'Hold the lane.' },
+      { sessionID: 'parent-1' } as any,
+    );
+    await Bun.sleep(0);
+
+    const job = board.get('ses_child1');
+    expect(job).toBeDefined();
+    if (!job) throw new Error('missing running job');
+    expect(
+      board.acquireCancellationLease(job.taskID, job.generation),
+    ).toBeUndefined();
+    expect(
+      board.acquireRelaunchLease(job.taskID, job.generation),
+    ).toBeUndefined();
+    expect(() =>
+      board.registerLaunch({
+        taskID: job.taskID,
+        parentSessionID: job.parentSessionID,
+        agent: job.agent,
+      }),
+    ).toThrow('message lease');
+
+    releasePrompt();
+    await expect(pending).resolves.toContain('queued');
+    expect(
+      board.acquireCancellationLease(job.taskID, job.generation),
+    ).toBeDefined();
+  });
+
+  test('rejects API failures and releases the message lease', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    const prompt = mock(async () => ({ error: { message: 'HTTP 409' } }));
+    client = { session: { prompt } };
+
+    await expect(
+      createTool(board).execute(
+        { task_id: 'ses_child1', message: 'Please continue.' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).rejects.toThrow('HTTP 409');
+
+    const job = board.get('ses_child1');
+    expect(job).toBeDefined();
+    if (!job) throw new Error('missing running job');
+    expect(
+      board.acquireCancellationLease(job.taskID, job.generation),
+    ).toBeDefined();
+  });
+
+  test('quarantines a timed-out pending transport until it settles', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    let settlePrompt!: () => void;
+    const prompt = mock(
+      () =>
+        new Promise<unknown>((resolve) => {
+          settlePrompt = () => resolve({});
+        }),
+    );
+    client = { session: { prompt } };
+
+    await expect(
+      createToolWithTimeout(board, 5).execute(
+        { task_id: 'ses_child1', message: 'Please continue.' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).rejects.toThrow('timed out');
+
+    const job = board.get('ses_child1');
+    expect(job).toBeDefined();
+    if (!job) throw new Error('missing running job');
+    expect(
+      board.acquireCancellationLease(job.taskID, job.generation),
+    ).toBeUndefined();
+
+    settlePrompt();
+    await Bun.sleep(0);
+    expect(
+      board.acquireCancellationLease(job.taskID, job.generation),
+    ).toBeDefined();
+  });
+
+  test('rejects a task that is no longer tracked', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    const prompt = makePrompt();
+    const session = {
+      get prompt() {
+        board.drop('ses_child1');
+        return prompt;
+      },
+    };
+    client = { session };
+
+    await expect(
+      createTool(board).execute(
+        { task_id: 'ses_child1', message: 'Please continue.' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).rejects.toThrow('no longer tracked');
+    expect(prompt).not.toHaveBeenCalled();
+  });
+
+  test('rejects terminal and cancelling tasks', async () => {
+    const terminalBoard = new BackgroundJobBoard();
+    registerRunningChild(terminalBoard);
+    terminalBoard.updateStatus({ taskID: 'ses_child1', state: 'completed' });
+    const terminalPrompt = makePrompt();
+    client = { session: { prompt: terminalPrompt } };
+
+    await expect(
+      createTool(terminalBoard).execute(
+        { task_id: 'ses_child1', message: 'Too late' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).rejects.toThrow('not running');
+    expect(terminalPrompt).not.toHaveBeenCalled();
+
+    const cancellingBoard = new BackgroundJobBoard();
+    registerRunningChild(cancellingBoard);
+    cancellingBoard.markCancelled('ses_child1', 'stop requested');
+    const cancellingPrompt = makePrompt();
+    client = { session: { prompt: cancellingPrompt } };
+
+    await expect(
+      createTool(cancellingBoard).execute(
+        { task_id: 'ses_child1', message: 'Do not send' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).rejects.toThrow('cancellation was requested');
+    expect(cancellingPrompt).not.toHaveBeenCalled();
+  });
+
+  test('rejects a child owned by another parent', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    const prompt = makePrompt();
+    client = { session: { prompt } };
+
+    await expect(
+      createTool(board).execute(
+        { task_id: 'ses_child1', message: 'Do not send' },
+        { sessionID: 'parent-2' } as any,
+      ),
+    ).rejects.toThrow('Unknown task ID or alias');
+    expect(prompt).not.toHaveBeenCalled();
+  });
+
+  test('rejects a relaunch attempt at the transport boundary', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    const prompt = makePrompt();
+    const session = {
+      get prompt() {
+        board.registerLaunch({
+          taskID: 'ses_child1',
+          parentSessionID: 'parent-1',
+          agent: 'fixer',
+          now: 1,
+        });
+        return prompt;
+      },
+    };
+    client = { session };
+
+    await expect(
+      createTool(board).execute(
+        { task_id: 'ses_child1', message: 'Do not send' },
+        { sessionID: 'parent-1' } as any,
+      ),
+    ).rejects.toThrow('message lease');
+    expect(prompt).not.toHaveBeenCalled();
+  });
+
+  test('uses explicit queue wording without legacy delivery terms', async () => {
+    const board = new BackgroundJobBoard();
+    registerRunningChild(board);
+    client = { session: { prompt: makePrompt() } };
+
+    const result = await createTool(board).execute(
+      { task_id: 'ses_child1', message: 'Status update' },
+      { sessionID: 'parent-1' } as any,
+    );
+
+    expect(result).toContain('queued');
+    expect(result).not.toContain('delivered');
+    expect(result).not.toContain('admitted');
+    expect(result).not.toContain('nudge');
+  });
+});

+ 237 - 0
src/tools/task-message.ts

@@ -0,0 +1,237 @@
+import {
+  type PluginInput,
+  type ToolDefinition,
+  tool,
+} from '@opencode-ai/plugin';
+import type { BackgroundJobStore } from '../utils/background-job-store';
+import { getClient } from '../utils/opencode-client';
+import { OperationTimeoutError, withTimeout } from '../utils/session';
+
+const z = tool.schema;
+const MAX_MESSAGE_LENGTH = 500;
+const DEFAULT_MESSAGE_TIMEOUT_MS = 10_000;
+
+class MessageLeaseOperationTimeoutError extends Error {
+  constructor(
+    message: string,
+    readonly pending: boolean,
+  ) {
+    super(message);
+    this.name = 'MessageLeaseOperationTimeoutError';
+  }
+}
+
+export function createTaskMessageTool(options: {
+  input: PluginInput;
+  backgroundJobBoard: BackgroundJobStore;
+  messageTimeoutMs?: number;
+}): Record<'task_message', ToolDefinition> {
+  const task_message = tool({
+    description:
+      'Queue a bounded message for a live child task without launching, resuming, or interrupting it.',
+    args: {
+      task_id: z
+        .string()
+        .describe('Tracked live task ID or parent-scoped alias'),
+      message: z
+        .string()
+        .trim()
+        .min(1)
+        .max(MAX_MESSAGE_LENGTH)
+        .describe('Short message to queue for the child task'),
+    },
+    async execute(args, toolContext) {
+      const parentSessionID = toolContext?.sessionID;
+      if (!parentSessionID) throw new Error('task_message requires sessionID');
+
+      const requested = args.task_id.trim();
+      const job = options.backgroundJobBoard.resolve(
+        parentSessionID,
+        requested,
+      );
+      if (!job) throw new Error(`Unknown task ID or alias: ${args.task_id}`);
+
+      const currentJob = getCurrentTaskMessageJob(
+        options.backgroundJobBoard,
+        parentSessionID,
+        requested,
+        job.taskID,
+        job.generation,
+      );
+
+      const lease = options.backgroundJobBoard.acquireMessageLease(
+        currentJob.taskID,
+        currentJob.generation,
+      );
+      if (!lease) {
+        throw new Error(
+          `Task ${requested} cannot queue a message: message/control lease unavailable`,
+        );
+      }
+
+      let keepLeaseUntilSettled = false;
+      try {
+        assertMessageLease(options.backgroundJobBoard, lease, requested);
+        getCurrentTaskMessageJob(
+          options.backgroundJobBoard,
+          parentSessionID,
+          requested,
+          lease.taskID,
+          lease.generation,
+        );
+
+        const session = getClient(options.input).session;
+        const prompt = session.prompt.bind(session);
+        getCurrentTaskMessageJob(
+          options.backgroundJobBoard,
+          parentSessionID,
+          requested,
+          lease.taskID,
+          lease.generation,
+        );
+        const response = await awaitMessageTransport(
+          options.backgroundJobBoard,
+          lease,
+          () =>
+            prompt({
+              path: { id: lease.taskID },
+              body: {
+                noReply: true,
+                parts: [{ type: 'text', text: args.message.trim() }],
+              },
+              throwOnError: true,
+            }),
+          options.messageTimeoutMs ?? DEFAULT_MESSAGE_TIMEOUT_MS,
+        );
+        assertMessageLease(options.backgroundJobBoard, lease, requested);
+        assertSuccessfulMessageResponse(response);
+
+        const latestJob = getCurrentTaskMessageJob(
+          options.backgroundJobBoard,
+          parentSessionID,
+          requested,
+          lease.taskID,
+          lease.generation,
+        );
+        return `Message queued for ${latestJob.alias} (${latestJob.taskID}) without launching or resuming it.`;
+      } catch (error) {
+        keepLeaseUntilSettled =
+          error instanceof MessageLeaseOperationTimeoutError && error.pending;
+        throw error;
+      } finally {
+        if (!keepLeaseUntilSettled) {
+          options.backgroundJobBoard.releaseLease(lease);
+        }
+      }
+    },
+  });
+
+  return { task_message };
+}
+
+function assertMessageLease(
+  backgroundJobBoard: BackgroundJobStore,
+  lease: NonNullable<ReturnType<BackgroundJobStore['acquireMessageLease']>>,
+  requested: string,
+): void {
+  if (lease.kind !== 'message' || !backgroundJobBoard.validateLease(lease)) {
+    throw new Error(
+      `Task ${requested} message lease is no longer valid; refusing stale message`,
+    );
+  }
+}
+
+async function awaitMessageTransport<T>(
+  backgroundJobBoard: BackgroundJobStore,
+  lease: NonNullable<ReturnType<BackgroundJobStore['acquireMessageLease']>>,
+  operation: () => Promise<T>,
+  timeoutMs: number,
+): Promise<T> {
+  let timedOut = false;
+  let settled = false;
+  const underlying = Promise.resolve().then(operation);
+  const tracked = underlying.then(
+    (value) => {
+      settled = true;
+      if (timedOut) backgroundJobBoard.releaseLease(lease);
+      return value;
+    },
+    (error: unknown) => {
+      settled = true;
+      if (timedOut) backgroundJobBoard.releaseLease(lease);
+      throw error;
+    },
+  );
+
+  try {
+    return await withTimeout(
+      tracked,
+      timeoutMs,
+      `Task message transport timed out after ${timeoutMs}ms`,
+    );
+  } catch (error) {
+    if (!(error instanceof OperationTimeoutError)) throw error;
+    timedOut = true;
+    const pending = !settled;
+    if (!pending) backgroundJobBoard.releaseLease(lease);
+    throw new MessageLeaseOperationTimeoutError(error.message, pending);
+  }
+}
+
+function assertSuccessfulMessageResponse(response: unknown): void {
+  if (!isRecord(response) || response.error === undefined) return;
+  if (response.error === null) return;
+  throw new Error(
+    `Task message transport failed: ${errorText(response.error)}`,
+  );
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null;
+}
+
+function errorText(error: unknown): string {
+  if (error instanceof Error) return error.message;
+  if (typeof error === 'string') return error;
+  try {
+    return JSON.stringify(error);
+  } catch {
+    return String(error);
+  }
+}
+
+function getCurrentTaskMessageJob(
+  backgroundJobBoard: BackgroundJobStore,
+  parentSessionID: string,
+  requested: string,
+  expectedTaskID: string,
+  expectedGeneration: number,
+): NonNullable<ReturnType<BackgroundJobStore['get']>> {
+  const current = backgroundJobBoard.get(expectedTaskID);
+  const resolved = backgroundJobBoard.resolve(parentSessionID, requested);
+  if (!current || !resolved || resolved.taskID !== expectedTaskID) {
+    throw new Error(
+      `Task ${requested} is no longer tracked; refusing stale message`,
+    );
+  }
+  if (
+    current.taskID !== expectedTaskID ||
+    current.generation !== expectedGeneration ||
+    resolved.generation !== expectedGeneration
+  ) {
+    throw new Error(
+      `Task ${requested} run generation changed; refusing stale message`,
+    );
+  }
+  if (current.cancellationRequested) {
+    throw new Error(
+      `Task ${requested} cannot queue a message: cancellation was requested`,
+    );
+  }
+  if (current.state !== 'running') {
+    throw new Error(
+      `Task ${requested} cannot queue a message: board state is ${current.state}, not running`,
+    );
+  }
+  return current;
+}

+ 0 - 474
src/tools/task-nudge.test.ts

@@ -1,474 +0,0 @@
-import { describe, expect, mock, test } from 'bun:test';
-import { BackgroundJobBoard } from '../utils/background-job-board';
-import { createTaskNudgeTool } from './task-nudge';
-
-let client: Record<string, any>;
-mock.module('../utils/opencode-client', () => ({ getClient: () => client }));
-
-function registerStuckChild(
-  board: BackgroundJobBoard,
-  taskID = 'ses_child1',
-  parent = 'parent-1',
-): void {
-  board.registerLaunch({
-    taskID,
-    parentSessionID: parent,
-    agent: 'fixer',
-    description: 'implement',
-    now: 0,
-  });
-}
-
-function busyClient(promptCalls: Array<() => Promise<unknown>> = []): void {
-  client = {
-    session: {
-      status: mock(async () => ({
-        data: { ses_child1: { type: 'busy' } },
-      })),
-      prompt: mock(async () => {
-        const next = promptCalls.shift();
-        if (next) return next();
-        return {};
-      }),
-    },
-  };
-}
-
-function deferred<T>(): {
-  promise: Promise<T>;
-  resolve: (value: T) => void;
-} {
-  let resolve!: (value: T) => void;
-  const promise = new Promise<T>((resolvePromise) => {
-    resolve = resolvePromise;
-  });
-  return { promise, resolve };
-}
-
-describe('task_nudge', () => {
-  test('admits a parent-owned live, possibly-stuck child via noReply without resuming it', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'busy' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    await expect(
-      task_nudge.execute(
-        { task_id: 'ses_child1', message: 'Please continue.' },
-        { sessionID: 'parent-1' } as any,
-      ),
-    ).resolves.toContain('without resuming');
-    expect(prompt).toHaveBeenCalledTimes(1);
-    expect(prompt).toHaveBeenCalledWith({
-      path: { id: 'ses_child1' },
-      body: {
-        noReply: true,
-        parts: [{ type: 'text', text: 'Please continue.' }],
-      },
-    });
-    // The mocked client exposes no resume/promptAsync channel at all, so an
-    // admitted nudge can only have used the noReply prompt path.
-    expect((client.session as any).promptAsync).toBeUndefined();
-  });
-
-  test('rejects a second nudge within the 30s window', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'busy' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'First' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).resolves.toContain('without resuming');
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Second' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('nudged recently');
-    expect(prompt).toHaveBeenCalledTimes(1);
-  });
-
-  test('concurrent nudges admit exactly one (atomic rate-limit reservation)', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'busy' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    const results = await Promise.allSettled([
-      task_nudge.execute({ task_id: 'ses_child1', message: 'First' }, {
-        sessionID: 'parent-1',
-      } as any),
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Second' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ]);
-    const fulfilled = results.filter((result) => result.status === 'fulfilled');
-    const rejected = results.filter((result) => result.status === 'rejected');
-    expect(fulfilled).toHaveLength(1);
-    expect(rejected).toHaveLength(1);
-    expect((rejected[0] as PromiseRejectedResult).reason?.message).toContain(
-      'nudged recently',
-    );
-    expect(prompt).toHaveBeenCalledTimes(1);
-  });
-
-  test('refuses to nudge an active child that is not possibly stuck', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'busy' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 10_000, // idle for 10s: active, not possibly stuck
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('not possibly stuck');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('refuses to nudge when the live status read fails', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => {
-          throw new Error('host status read failed');
-        }),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('live status unavailable');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('refuses to nudge an absent child session as unknown', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = { session: { status: mock(async () => ({ data: {} })), prompt } };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('child session status unknown');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('keeps malformed live status distinct from an absent session', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'suspended' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('child session status malformed');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('refuses to nudge a board-terminal task', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    board.updateStatus({
-      taskID: 'ses_child1',
-      state: 'completed',
-      resultSummary: 'done',
-    });
-    const prompt = mock(async () => ({}));
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'busy' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('board state is completed, not running');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('does not prompt after the task completes during live status lookup', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    const statusResult = deferred<{ data: Record<string, unknown> }>();
-    const status = mock(async () => statusResult.promise);
-    client = { session: { status, prompt } };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-
-    const pending = task_nudge.execute(
-      { task_id: 'ses_child1', message: 'Nudge' },
-      { sessionID: 'parent-1' } as any,
-    );
-    await Promise.resolve();
-    board.updateStatus({
-      taskID: 'ses_child1',
-      state: 'completed',
-      resultSummary: 'done',
-    });
-    statusResult.resolve({ data: { ses_child1: { type: 'busy' } } });
-
-    await expect(pending).rejects.toThrow(
-      'board state is completed, not running',
-    );
-    expect(status).toHaveBeenCalledTimes(1);
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('does not prompt after the task is deleted during live status lookup', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    const statusResult = deferred<{ data: Record<string, unknown> }>();
-    const status = mock(async () => statusResult.promise);
-    client = { session: { status, prompt } };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-
-    const pending = task_nudge.execute(
-      { task_id: 'ses_child1', message: 'Nudge' },
-      { sessionID: 'parent-1' } as any,
-    );
-    await Promise.resolve();
-    board.drop('ses_child1');
-    statusResult.resolve({ data: { ses_child1: { type: 'busy' } } });
-
-    await expect(pending).rejects.toThrow('no longer tracked');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('does not prompt an old generation after relaunch during live status lookup', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    const statusResult = deferred<{ data: Record<string, unknown> }>();
-    const status = mock(async () => statusResult.promise);
-    client = { session: { status, prompt } };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-
-    const pending = task_nudge.execute(
-      { task_id: 'ses_child1', message: 'Nudge' },
-      { sessionID: 'parent-1' } as any,
-    );
-    await Promise.resolve();
-    board.registerLaunch({
-      taskID: 'ses_child1',
-      parentSessionID: 'parent-1',
-      agent: 'fixer',
-      now: 121_000,
-    });
-    statusResult.resolve({ data: { ses_child1: { type: 'busy' } } });
-
-    await expect(pending).rejects.toThrow('run generation changed');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('does not prompt when relaunch happens while acquiring the prompt boundary', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const prompt = mock(async () => ({}));
-    let promptPropertyRead = false;
-    const session = {
-      status: mock(async () => ({
-        data: { ses_child1: { type: 'busy' } },
-      })),
-      get prompt() {
-        if (!promptPropertyRead) {
-          promptPropertyRead = true;
-          board.registerLaunch({
-            taskID: 'ses_child1',
-            parentSessionID: 'parent-1',
-            agent: 'fixer',
-            now: 121_000,
-          });
-        }
-        return prompt;
-      },
-    };
-    client = { session };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('run generation changed');
-    expect(prompt).not.toHaveBeenCalled();
-  });
-
-  test('rejects a task id owned by a different parent', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => 120_000,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Nudge' }, {
-        sessionID: 'parent-2',
-      } as any),
-    ).rejects.toThrow('Unknown task ID or alias');
-  });
-
-  test('rolls back the rate-limit reservation when the prompt fails', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    let nowValue = 120_000;
-    let failPrompt = true;
-    const prompt = mock(async () => {
-      if (failPrompt) throw new Error('prompt transport failed');
-      return {};
-    });
-    client = {
-      session: {
-        status: mock(async () => ({
-          data: { ses_child1: { type: 'busy' } },
-        })),
-        prompt,
-      },
-    };
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => nowValue,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'First' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).rejects.toThrow('prompt transport failed');
-    // A failed nudge must not lock the task out: advance 1s and retry.
-    nowValue = 121_000;
-    failPrompt = false;
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Second' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).resolves.toContain('without resuming');
-    expect(prompt).toHaveBeenCalledTimes(2);
-  });
-
-  test('allows a second nudge after the 30s window elapses', async () => {
-    const board = new BackgroundJobBoard();
-    registerStuckChild(board);
-    let nowValue = 120_000;
-    busyClient();
-    const { task_nudge } = createTaskNudgeTool({
-      input: { directory: '/test' } as any,
-      backgroundJobBoard: board,
-      now: () => nowValue,
-    });
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'First' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).resolves.toContain('without resuming');
-    nowValue = 150_000;
-    await expect(
-      task_nudge.execute({ task_id: 'ses_child1', message: 'Second' }, {
-        sessionID: 'parent-1',
-      } as any),
-    ).resolves.toContain('without resuming');
-    expect(client.session.prompt).toHaveBeenCalledTimes(2);
-  });
-});

+ 0 - 160
src/tools/task-nudge.ts

@@ -1,160 +0,0 @@
-import {
-  type PluginInput,
-  type ToolDefinition,
-  tool,
-} from '@opencode-ai/plugin';
-import type { BackgroundJobStore } from '../utils/background-job-store';
-import { getClient } from '../utils/opencode-client';
-import { getRuntimeSessionStatusSnapshot } from '../utils/session-runtime-status';
-import type { TaskActivityTracker } from './task-activity';
-import {
-  evaluateNudgeEligibility,
-  observationFromSnapshot,
-} from './task-policy';
-
-const z = tool.schema;
-const NUDGE_INTERVAL_MS = 30_000;
-
-export function createTaskNudgeTool(options: {
-  input: PluginInput;
-  backgroundJobBoard: BackgroundJobStore;
-  activityTracker?: TaskActivityTracker;
-  now?: () => number;
-  statusTimeoutMs?: number;
-}): Record<'task_nudge', ToolDefinition> {
-  const lastNudgeAt = new Map<string, number>();
-  const now = options.now ?? Date.now;
-  const task_nudge = tool({
-    description:
-      'Safely send a bounded follow-up to a live child task without resuming, aborting, or starting another model run. Use only after task_status indicates the child may be stuck.',
-    args: {
-      task_id: z
-        .string()
-        .describe('Tracked live task ID or parent-scoped alias'),
-      message: z
-        .string()
-        .min(1)
-        .max(500)
-        .describe('Short follow-up instruction'),
-    },
-    async execute(args, toolContext) {
-      const parentSessionID = toolContext?.sessionID;
-      if (!parentSessionID) throw new Error('task_nudge requires sessionID');
-      const job = options.backgroundJobBoard.resolve(
-        parentSessionID,
-        args.task_id.trim(),
-      );
-      if (!job) throw new Error(`Unknown task ID or alias: ${args.task_id}`);
-
-      // Reserve the per-task rate-limit slot synchronously, before the first
-      // await, so a concurrent nudge in the same window observes it. Any
-      // failure rolls the reservation back so a refused or failed nudge
-      // cannot lock the task out for the full interval.
-      const at = now();
-      const previous = lastNudgeAt.get(job.taskID);
-      if (previous !== undefined && at - previous < NUDGE_INTERVAL_MS) {
-        throw new Error(
-          `Task ${args.task_id} was nudged recently; wait 30 seconds`,
-        );
-      }
-      lastNudgeAt.set(job.taskID, at);
-
-      try {
-        // Admission requires the same live status/activity policy as
-        // task_status: board-running, live-confirmed busy/retry, and
-        // possibly stuck. A stale board record alone never admits a nudge.
-        const snapshot = await getRuntimeSessionStatusSnapshot(options.input, {
-          timeoutMs: options.statusTimeoutMs,
-        });
-        const currentJob = getCurrentNudgeJob(
-          options.backgroundJobBoard,
-          parentSessionID,
-          args.task_id.trim(),
-          job.taskID,
-          job.generation,
-        );
-        const observation = observationFromSnapshot(snapshot, job.taskID);
-        const lastActivityAt =
-          options.activityTracker?.lastActivityAt(job.taskID) ??
-          currentJob.lastLiveBusyAt ??
-          currentJob.runStartedAt;
-        const eligibility = evaluateNudgeEligibility(
-          currentJob,
-          observation,
-          lastActivityAt,
-          at,
-        );
-        if (!eligibility.eligible) {
-          throw new Error(
-            `Task ${args.task_id} cannot be nudged: ${eligibility.reason}`,
-          );
-        }
-
-        // Resolve the transport before the final fence so no property access
-        // or client lookup remains between that fence and prompt invocation.
-        // OpenCode's prompt API has no expected-generation/CAS argument: a
-        // relaunch that races after this synchronous boundary is outside what
-        // this lane can prove, and is intentionally not described as atomic.
-        const session = getClient(options.input).session;
-        const prompt = session.prompt.bind(session);
-        const promptJob = getCurrentNudgeJob(
-          options.backgroundJobBoard,
-          parentSessionID,
-          args.task_id.trim(),
-          job.taskID,
-          job.generation,
-        );
-        await prompt({
-          path: { id: promptJob.taskID },
-          body: {
-            noReply: true,
-            parts: [{ type: 'text', text: args.message.trim() }],
-          },
-        });
-      } catch (error) {
-        if (lastNudgeAt.get(job.taskID) === at) {
-          lastNudgeAt.delete(job.taskID);
-        }
-        throw error;
-      }
-      return `Nudge admitted to ${job.alias} (${job.taskID}) without resuming it.`;
-    },
-  });
-  return { task_nudge };
-}
-
-function getCurrentNudgeJob(
-  backgroundJobBoard: BackgroundJobStore,
-  parentSessionID: string,
-  requested: string,
-  expectedTaskID: string,
-  expectedGeneration: number,
-): NonNullable<ReturnType<BackgroundJobStore['get']>> {
-  const current = backgroundJobBoard.get(expectedTaskID);
-  const resolved = backgroundJobBoard.resolve(parentSessionID, requested);
-  if (!current || !resolved || resolved.taskID !== expectedTaskID) {
-    throw new Error(
-      `Task ${requested} is no longer tracked; refusing to nudge stale execution`,
-    );
-  }
-  if (
-    current.taskID !== expectedTaskID ||
-    current.generation !== expectedGeneration ||
-    resolved.generation !== expectedGeneration
-  ) {
-    throw new Error(
-      `Task ${requested} run generation changed; refusing to nudge stale execution`,
-    );
-  }
-  if (current.state !== 'running') {
-    throw new Error(
-      `Task ${requested} cannot be nudged: board state is ${current.state}, not running`,
-    );
-  }
-  if (current.cancellationRequested) {
-    throw new Error(
-      `Task ${requested} cannot be nudged: cancellation was requested`,
-    );
-  }
-  return current;
-}

+ 1 - 53
src/tools/task-policy.ts

@@ -34,11 +34,6 @@ export interface TaskStatusReport {
   possiblyStuck: boolean;
 }
 
-export interface NudgeEligibility {
-  eligible: boolean;
-  reason: string;
-}
-
 /**
  * Converts a bounded snapshot into a single-session observation. A failed
  * read becomes `ok: false`; a malformed entry becomes `ok: true` with
@@ -100,7 +95,7 @@ export function summarizeTaskStatus(
     Math.floor((now - (lastActivityAt ?? now)) / 1000),
   );
   // possibly_stuck requires a live-confirmed busy/retry signal: an
-  // uncertain board fallback must never drive an automatic nudge admission.
+  // uncertain board fallback must never report a positive stuck state.
   const possiblyStuck =
     !uncertain &&
     (state === 'busy' || state === 'retry') &&
@@ -114,50 +109,3 @@ export function summarizeTaskStatus(
     possiblyStuck,
   };
 }
-
-/**
- * Shared nudge admission policy used by task_nudge: the child must be
- * board-running, live-confirmed busy/retry, and reported possibly stuck by
- * the same status/activity policy task_status exposes.
- */
-export function evaluateNudgeEligibility(
-  job: BackgroundJobRecord,
-  observation: LiveStatusObservation,
-  lastActivityAt: number | undefined,
-  now: number,
-): NudgeEligibility {
-  if (job.state !== 'running') {
-    return {
-      eligible: false,
-      reason: `board state is ${job.state}, not running`,
-    };
-  }
-  if (!observation.ok) {
-    return {
-      eligible: false,
-      reason: `live status unavailable: ${observation.error ?? 'unknown error'}`,
-    };
-  }
-  if (observation.status === undefined) {
-    return {
-      eligible: false,
-      reason: observation.error
-        ? 'child session status malformed; refusing to nudge'
-        : 'child session status unknown; refusing to nudge without confirmed live activity',
-    };
-  }
-  if (observation.status === 'idle') {
-    return {
-      eligible: false,
-      reason: 'child session is idle, not live',
-    };
-  }
-  const report = summarizeTaskStatus(job, observation, lastActivityAt, now);
-  if (!report.possiblyStuck) {
-    return {
-      eligible: false,
-      reason: 'child is active and not possibly stuck; no nudge needed',
-    };
-  }
-  return { eligible: true, reason: 'confirmed live and possibly stuck' };
-}

+ 253 - 0
src/tools/task-revive.test.ts

@@ -0,0 +1,253 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test';
+import { createRevivedRunTracker } from '../hooks/task-session-manager/revived-run-tracker';
+import { BackgroundJobBoard } from '../utils/background-job-board';
+import { createCancelTaskTool } from './cancel-task';
+import { createTaskReviveTool } from './task-revive';
+
+let mockClient: Record<string, unknown>;
+
+mock.module('../utils/opencode-client', () => ({
+  getClient: () => mockClient,
+}));
+
+function createTool(overrides?: {
+  abort?: () => Promise<unknown>;
+  status?: () => Promise<unknown>;
+  promptAsync?: () => Promise<unknown>;
+}) {
+  const board = new BackgroundJobBoard();
+  const abort = mock(overrides?.abort ?? (async () => ({})));
+  const status = mock(
+    overrides?.status ?? (async () => ({ data: { ses_1: { type: 'idle' } } })),
+  );
+  const promptAsync = mock(overrides?.promptAsync ?? (async () => ({})));
+  mockClient = { session: { abort, status, promptAsync } };
+  const revivedRunTracker = createRevivedRunTracker({
+    input: { directory: '/test/project' } as any,
+    backgroundJobBoard: board,
+  });
+  const tools = createTaskReviveTool({
+    input: { directory: '/test/project' } as any,
+    backgroundJobBoard: board,
+    shouldManageSession: () => true,
+    verifyAbortMs: 10,
+    abortRetryIntervalMs: 0,
+    stableStoppedMs: 0,
+    revivedRunTracker,
+  });
+  const cancelTools = createCancelTaskTool({
+    input: { directory: '/test/project' } as any,
+    backgroundJobBoard: board,
+    shouldManageSession: () => true,
+    verifyAbortMs: 10,
+    abortRetryIntervalMs: 0,
+    stableStoppedMs: 0,
+  });
+  return {
+    board,
+    abort,
+    status,
+    promptAsync,
+    taskCancel: cancelTools.task_cancel,
+    taskRevive: tools.task_revive,
+  };
+}
+
+const context = { sessionID: 'parent-1', agent: 'orchestrator' } as any;
+
+afterEach(() => mock.restore());
+
+function acknowledgedCompleted(board: BackgroundJobBoard, taskID = 'ses_1') {
+  board.registerLaunch({
+    taskID,
+    parentSessionID: 'parent-1',
+    agent: 'explorer',
+  });
+  board.updateStatus({ taskID, state: 'completed', resultSummary: 'done' });
+  board.markReconciled(taskID);
+}
+
+describe('task_revive tool', () => {
+  test('uses promptAsync, starts a new board generation, and retains the session', async () => {
+    const { board, promptAsync, taskRevive } = createTool();
+    acknowledgedCompleted(board);
+
+    const output = await taskRevive.execute(
+      { task_id: 'ses_1', prompt: 'Continue the investigation' },
+      context,
+    );
+
+    expect(promptAsync).toHaveBeenCalledWith({
+      path: { id: 'ses_1' },
+      query: { directory: '/test/project' },
+      body: {
+        agent: 'explorer',
+        parts: [{ type: 'text', text: 'Continue the investigation' }],
+      },
+    });
+    const call = promptAsync.mock.calls[0]?.[0] as Record<string, unknown>;
+    expect(call.body).not.toHaveProperty('noReply', true);
+    expect(String(output)).toContain('state: running');
+    expect(String(output)).toContain('status: started');
+    expect(board.get('ses_1')).toMatchObject({
+      generation: 2,
+      state: 'running',
+    });
+    const lease = board.acquireRelaunchLease('ses_1', 2);
+    expect(lease).toBeDefined();
+    if (lease) board.releaseLease(lease);
+  });
+
+  test('cancels a running generation and launches its replacement in order', async () => {
+    const events: string[] = [];
+    const { board, abort, promptAsync, taskRevive } = createTool({
+      abort: async () => {
+        events.push('abort');
+        return {};
+      },
+      status: async () => ({ data: { ses_1: { type: 'idle' } } }),
+      promptAsync: async () => {
+        events.push('promptAsync');
+        return {};
+      },
+    });
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+
+    await taskRevive.execute(
+      { task_id: 'ses_1', prompt: 'Resume with a new objective' },
+      context,
+    );
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    expect(events).toEqual(['abort', 'promptAsync']);
+    expect(board.get('ses_1')).toMatchObject({
+      generation: 2,
+      state: 'running',
+    });
+  });
+
+  test('revives a directly cancelled retained session before acknowledgement', async () => {
+    const { board, promptAsync, taskCancel, taskRevive } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+
+    await taskCancel.execute({ task_id: 'ses_1', reason: 'obsolete' }, context);
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'cancelled',
+      terminalUnreconciled: true,
+      statusUncertain: false,
+    });
+
+    const output = await taskRevive.execute(
+      { task_id: 'ses_1', prompt: 'try again' },
+      context,
+    );
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    expect(String(output)).toContain('state: running');
+    expect(board.get('ses_1')).toMatchObject({
+      generation: 2,
+      state: 'running',
+    });
+  });
+
+  test('rejects an uncertain retained terminal job', async () => {
+    const { board, promptAsync, taskRevive } = createTool();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({
+      taskID: 'ses_1',
+      state: 'error',
+      statusUncertain: true,
+    });
+
+    await expect(
+      taskRevive.execute({ task_id: 'ses_1', prompt: 'try again' }, context),
+    ).rejects.toThrow('verified retained terminal session');
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('rejects foreign, parent, and stale task requests', async () => {
+    const { board, promptAsync, taskRevive } = createTool();
+    acknowledgedCompleted(board, 'ses_foreign');
+    const foreignRecord = board.get('ses_foreign');
+    if (!foreignRecord) throw new Error('missing foreign record');
+    board.updateStatus({ taskID: 'ses_foreign', state: 'completed' });
+    board.markReconciled('ses_foreign');
+    board.registerLaunch({
+      taskID: 'ses_stale',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({ taskID: 'ses_stale', state: 'completed' });
+    board.markReconciled('ses_stale');
+
+    await expect(
+      taskRevive.execute({ task_id: 'ses_foreign', prompt: 'x' }, {
+        sessionID: 'parent-2',
+        agent: 'orchestrator',
+      } as any),
+    ).rejects.toThrow('Unknown or unowned');
+    await expect(
+      taskRevive.execute({ task_id: 'parent-1', prompt: 'x' }, context),
+    ).rejects.toThrow('Unknown or unowned');
+
+    const originalResolve = board.resolve.bind(board);
+    let mutated = false;
+    board.resolve = mock((parent, requested) => {
+      const result = originalResolve(parent, requested);
+      if (result && requested === 'ses_stale' && !mutated) {
+        mutated = true;
+        const lease = board.acquireRelaunchLease(
+          'ses_stale',
+          result.generation,
+        );
+        if (!lease) throw new Error('missing stale relaunch lease');
+        board.registerLaunch({
+          taskID: 'ses_stale',
+          parentSessionID: 'parent-1',
+          agent: 'explorer',
+          relaunchLease: lease,
+        });
+      }
+      return result;
+    });
+    await expect(
+      taskRevive.execute({ task_id: 'ses_stale', prompt: 'x' }, context),
+    ).rejects.toThrow('run generation changed');
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('releases the relaunch lease when promptAsync fails', async () => {
+    const { board, promptAsync, taskRevive } = createTool({
+      promptAsync: async () => {
+        throw new Error('host unavailable');
+      },
+    });
+    acknowledgedCompleted(board);
+
+    await expect(
+      taskRevive.execute({ task_id: 'ses_1', prompt: 'retry' }, context),
+    ).rejects.toThrow('host unavailable');
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const lease = board.acquireRelaunchLease('ses_1', 1);
+    expect(lease).toBeDefined();
+    if (lease) board.releaseLease(lease);
+    expect(board.get('ses_1')).toMatchObject({
+      generation: 1,
+      state: 'reconciled',
+      statusUncertain: false,
+    });
+  });
+});

+ 241 - 0
src/tools/task-revive.ts

@@ -0,0 +1,241 @@
+import { type ToolDefinition, tool } from '@opencode-ai/plugin';
+import type { RevivedRunTracker } from '../hooks/task-session-manager/revived-run-tracker';
+import type { BackgroundJobSupervisor } from '../utils/background-job-supervisor';
+import { getClient } from '../utils/opencode-client';
+import {
+  cancelTrackedExecution,
+  type TaskControlToolOptions,
+} from './cancel-task';
+
+const z = tool.schema;
+
+export interface TaskReviveToolOptions extends TaskControlToolOptions {
+  backgroundJobSupervisor?: BackgroundJobSupervisor;
+  revivedRunTracker: RevivedRunTracker;
+}
+
+export function createTaskReviveTool(
+  options: TaskReviveToolOptions,
+): Record<'task_revive', ToolDefinition> {
+  const revivedRunTracker = options.revivedRunTracker;
+  const task_revive = tool({
+    description:
+      'Revive a retained background task in its existing session with a new prompt.',
+    args: {
+      task_id: z
+        .string()
+        .describe('Tracked background task ID or Background Job Board alias'),
+      prompt: z.string().min(1).describe('Prompt for the revived task'),
+    },
+    async execute(args, toolContext) {
+      const parentSessionID = assertOrchestrator(options, toolContext);
+      const requested = args.task_id.trim();
+      const prompt = args.prompt.trim();
+      if (!requested) throw new Error('task_revive requires task_id');
+      if (!prompt) throw new Error('task_revive requires prompt');
+
+      const resolved = options.backgroundJobBoard.resolve(
+        parentSessionID,
+        requested,
+      );
+      if (!resolved) {
+        throw new Error(`Unknown or unowned background task: ${requested}`);
+      }
+
+      let current = getCurrentReviveJob(
+        options,
+        parentSessionID,
+        requested,
+        resolved.taskID,
+        resolved.generation,
+      );
+      const captured = {
+        taskID: current.taskID,
+        generation: current.generation,
+      };
+
+      let cancelledForRevive = false;
+      if (current.state === 'running') {
+        await cancelTrackedExecution(options, captured, 'revived');
+        cancelledForRevive = true;
+        current = getCurrentReviveJob(
+          options,
+          parentSessionID,
+          requested,
+          captured.taskID,
+          captured.generation,
+        );
+      }
+
+      if (!cancelledForRevive && !isReviveableRetainedJob(current)) {
+        throw new Error(
+          `Task ${requested} cannot be revived: state ${current.state} is not a verified retained terminal session`,
+        );
+      }
+
+      const relaunchLease = options.backgroundJobBoard.acquireRelaunchLease(
+        current.taskID,
+        current.generation,
+      );
+      if (!relaunchLease) {
+        throw new Error(
+          `Task ${requested} cannot be revived: relaunch lease unavailable`,
+        );
+      }
+
+      let baselineMessageID: string | undefined;
+      let launched:
+        | ReturnType<
+            TaskControlToolOptions['backgroundJobBoard']['registerLaunch']
+          >
+        | undefined;
+      try {
+        baselineMessageID = await revivedRunTracker.captureBaseline(
+          current.taskID,
+        );
+        const session = getClient(options.input).session;
+        if (typeof session.promptAsync !== 'function') {
+          throw new Error('The host session does not support promptAsync');
+        }
+        const response = await session.promptAsync({
+          path: { id: current.taskID },
+          query: { directory: options.input.directory },
+          body: {
+            agent: current.agent,
+            parts: [{ type: 'text', text: prompt }],
+          },
+        });
+        const responseError = getApiError(response);
+        if (responseError !== undefined) {
+          throw new Error(errorText(responseError));
+        }
+
+        launched = options.backgroundJobBoard.registerLaunch({
+          taskID: current.taskID,
+          parentSessionID,
+          agent: current.agent,
+          description: current.description,
+          objective: current.objective,
+          background: true,
+          relaunchLease,
+        });
+        if (launched.generation <= current.generation) {
+          throw new Error(`Task ${requested} did not receive a new generation`);
+        }
+        revivedRunTracker.register({
+          taskID: launched.taskID,
+          generation: launched.generation,
+          parentSessionID,
+          baselineMessageID,
+          description: launched.description,
+        });
+        options.backgroundJobSupervisor?.onLaunch(launched);
+        await revivedRunTracker.probe(launched.taskID, launched.generation);
+      } catch (error) {
+        const message = error instanceof Error ? error.message : String(error);
+        if (launched) {
+          options.backgroundJobBoard.markStatusUncertain(
+            current.taskID,
+            `task_revive failed: ${message}`,
+            launched.generation,
+          );
+        }
+        throw new Error(`Task ${requested} revive failed: ${message}`);
+      } finally {
+        options.backgroundJobBoard.releaseLease(relaunchLease);
+      }
+
+      if (!launched) {
+        throw new Error(`Task ${requested} revive did not launch`);
+      }
+      const latest = options.backgroundJobBoard.get(current.taskID);
+      if (!latest || latest.generation !== launched.generation) {
+        throw new Error(
+          `Task ${requested} revive became stale before launch completed`,
+        );
+      }
+      return [
+        `task_id: ${latest.taskID}`,
+        `generation: ${latest.generation}`,
+        'state: running',
+        'status: started',
+      ].join('\n');
+    },
+  });
+
+  return { task_revive };
+}
+
+function getCurrentReviveJob(
+  options: TaskReviveToolOptions,
+  parentSessionID: string,
+  requested: string,
+  taskID: string,
+  generation: number,
+): NonNullable<ReturnType<TaskReviveToolOptions['backgroundJobBoard']['get']>> {
+  const current = options.backgroundJobBoard.get(taskID);
+  const resolved = options.backgroundJobBoard.resolve(
+    parentSessionID,
+    requested,
+  );
+  if (!current || !resolved || resolved.taskID !== taskID) {
+    throw new Error(
+      `Task ${requested} is no longer tracked; refusing stale revive`,
+    );
+  }
+  if (current.generation !== generation || resolved.generation !== generation) {
+    throw new Error(
+      `Task ${requested} run generation changed; refusing stale revive`,
+    );
+  }
+  return current;
+}
+
+function isReviveableRetainedJob(
+  job: NonNullable<
+    ReturnType<TaskReviveToolOptions['backgroundJobBoard']['get']>
+  >,
+): boolean {
+  if (job.statusUncertain) return false;
+  if (
+    job.state === 'completed' ||
+    job.state === 'error' ||
+    job.state === 'cancelled'
+  ) {
+    return true;
+  }
+  return job.state === 'reconciled' && job.terminalState !== undefined;
+}
+
+function assertOrchestrator(
+  options: TaskReviveToolOptions,
+  toolContext: { sessionID?: string; agent?: string } | undefined,
+): string {
+  const parentSessionID = toolContext?.sessionID;
+  if (!parentSessionID) throw new Error('task_revive requires sessionID');
+  if (toolContext.agent && toolContext.agent !== 'orchestrator') {
+    throw new Error('task_revive can only be used by orchestrator');
+  }
+  if (!options.shouldManageSession(parentSessionID)) {
+    throw new Error('task_revive can only be used in orchestrator sessions');
+  }
+  return parentSessionID;
+}
+
+function getApiError(response: unknown): unknown {
+  if (!response || typeof response !== 'object') return undefined;
+  const record = response as Record<string, unknown>;
+  return record.error === undefined || record.error === null
+    ? undefined
+    : record.error;
+}
+
+function errorText(error: unknown): string {
+  if (error instanceof Error) return error.message;
+  if (typeof error === 'string') return error;
+  try {
+    return JSON.stringify(error);
+  } catch {
+    return String(error);
+  }
+}

+ 134 - 13
src/utils/background-job-board.test.ts

@@ -97,6 +97,45 @@ describe('BackgroundJobBoard', () => {
     expect(board.releaseLease(relaunchLease)).toBe(true);
   });
 
+  test('message lease is mutually exclusive with cancellation and relaunch', () => {
+    const board = new BackgroundJobBoard();
+    const first = board.registerLaunch({
+      taskID: 'ses_message_lease',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    const messageLease = board.acquireMessageLease(
+      first.taskID,
+      first.generation,
+    );
+
+    expect(messageLease).toMatchObject({
+      taskID: first.taskID,
+      generation: first.generation,
+      kind: 'message',
+    });
+    expect(board.acquireCancellationLease(first.taskID, first.generation)).toBe(
+      undefined,
+    );
+    expect(board.acquireRelaunchLease(first.taskID, first.generation)).toBe(
+      undefined,
+    );
+    expect(() =>
+      board.registerLaunch({
+        taskID: first.taskID,
+        parentSessionID: first.parentSessionID,
+        agent: first.agent,
+      }),
+    ).toThrow('message lease');
+
+    if (!messageLease) throw new Error('message lease was not acquired');
+    expect(board.validateLease(messageLease)).toBe(true);
+    expect(board.releaseLease(messageLease)).toBe(true);
+    expect(
+      board.acquireCancellationLease(first.taskID, first.generation),
+    ).toBeDefined();
+  });
+
   test('expected generation and cancellation token fence markCancelled', () => {
     const board = new BackgroundJobBoard();
     const first = board.registerLaunch({
@@ -403,28 +442,110 @@ describe('BackgroundJobBoard', () => {
     expect(prompt).toContain('#### Reusable Sessions\n- none');
   });
 
-  test('does not expose cancelled or errored jobs as reusable', () => {
+  test('reuses cancelled and errored jobs only after terminal acknowledgement', () => {
+    const board = new BackgroundJobBoard();
+
+    for (const [taskID, state] of [
+      ['ses_cancelled', 'cancelled'],
+      ['ses_error', 'error'],
+    ] as const) {
+      board.registerLaunch({
+        taskID,
+        parentSessionID: 'parent-1',
+        agent: 'oracle',
+        description: `${state} review`,
+      });
+      board.updateStatus({ taskID, state });
+
+      expect(board.get(taskID)).toMatchObject({
+        state,
+        terminalUnreconciled: true,
+      });
+      expect(
+        board.resolveReusable('parent-1', taskID, 'oracle'),
+      ).toBeUndefined();
+
+      board.markReconciled(taskID);
+
+      expect(board.resolveReusable('parent-1', taskID, 'oracle')).toMatchObject(
+        {
+          taskID,
+          state: 'reconciled',
+          terminalState: state,
+          terminalUnreconciled: false,
+        },
+      );
+    }
+
+    const prompt = board.formatForPrompt('parent-1');
+    expect(prompt).toContain('ses_cancelled / oracle / cancelled, reconciled');
+    expect(prompt).toContain(
+      'Acknowledged terminal sessions are reusable by alias',
+    );
+    expect(prompt).toContain('ses_error / oracle / error, reconciled');
+  });
+
+  test('does not reuse an acknowledged terminal job with uncertain status', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
-      taskID: 'ses_cancelled',
+      taskID: 'ses_uncertain',
       parentSessionID: 'parent-1',
       agent: 'oracle',
-      description: 'cancelled review',
     });
-    board.updateStatus({ taskID: 'ses_cancelled', state: 'cancelled' });
-    board.markReconciled('ses_cancelled');
-    board.registerLaunch({
-      taskID: 'ses_error',
+    board.claimWallClockDeadline({
+      taskID: 'ses_uncertain',
+      generation: board.get('ses_uncertain')?.generation ?? -1,
+    });
+    board.finalizeWallClockTimeout({
+      taskID: 'ses_uncertain',
+      generation: board.get('ses_uncertain')?.generation ?? -1,
+      statusUncertain: true,
+      resultSummary: 'status unavailable',
+    });
+    board.markReconciled('ses_uncertain');
+
+    expect(board.resolveReusable('parent-1', 'ses_uncertain')).toBeUndefined();
+  });
+
+  test('stale generations cannot alter a newer relaunch after terminal acknowledgement', () => {
+    const board = new BackgroundJobBoard();
+    const first = board.registerLaunch({
+      taskID: 'ses_generation_terminal',
       parentSessionID: 'parent-1',
       agent: 'oracle',
-      description: 'errored review',
     });
-    board.updateStatus({ taskID: 'ses_error', state: 'error' });
-    board.markReconciled('ses_error');
+    board.updateStatus({
+      taskID: first.taskID,
+      state: 'error',
+      expectedGeneration: first.generation,
+    });
+    board.markReconciled(first.taskID);
+
+    const relaunchLease = board.acquireRelaunchLease(
+      first.taskID,
+      first.generation,
+    );
+    expect(relaunchLease).toBeDefined();
+    if (!relaunchLease) throw new Error('relaunch lease was not acquired');
+    const second = board.registerLaunch({
+      taskID: first.taskID,
+      parentSessionID: first.parentSessionID,
+      agent: first.agent,
+      relaunchLease,
+    });
 
-    expect(board.formatForPrompt('parent-1')).toBeUndefined();
-    expect(board.resolveReusable('parent-1', 'ses_cancelled')).toBeUndefined();
-    expect(board.resolveReusable('parent-1', 'ses_error')).toBeUndefined();
+    const stale = board.updateStatus({
+      taskID: first.taskID,
+      state: 'cancelled',
+      expectedGeneration: first.generation,
+    });
+
+    expect(stale).toMatchObject({
+      generation: second.generation,
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+    expect(board.get(first.taskID)?.generation).toBe(second.generation);
   });
 
   test('prompt distinguishes reusable and recoverable sessions', () => {

+ 78 - 4
src/utils/background-job-board.ts

@@ -25,7 +25,11 @@ export interface BackgroundJobExecution {
   generation: number;
 }
 
-export type BackgroundJobLeaseKind = 'cancellation' | 'relaunch';
+export type BackgroundJobLeaseKind =
+  | 'cancellation'
+  | 'relaunch'
+  | 'message'
+  | 'terminal-notification';
 
 /** Process-local ownership of a remote operation or same-ID relaunch. */
 export interface BackgroundJobLease {
@@ -682,6 +686,56 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return lease;
   }
 
+  acquireMessageLease(
+    taskID: string,
+    generation: number,
+  ): BackgroundJobLease | undefined {
+    const existing = this.jobs.get(taskID);
+    if (
+      existing?.generation !== generation ||
+      existing.state !== 'running' ||
+      this.liveLeases.has(taskID)
+    ) {
+      return undefined;
+    }
+    const lease: BackgroundJobLease = {
+      taskID,
+      generation,
+      token: this.nextLeaseToken('message'),
+      kind: 'message',
+    };
+    this.liveLeases.set(taskID, lease);
+    return lease;
+  }
+
+  acquireTerminalNotificationLease(
+    taskID: string,
+    generation: number,
+  ): BackgroundJobLease | undefined {
+    const existing = this.jobs.get(taskID);
+    const terminal =
+      existing?.state === 'completed' ||
+      existing?.state === 'error' ||
+      (existing?.state === 'reconciled' &&
+        (existing.terminalState === 'completed' ||
+          existing.terminalState === 'error'));
+    if (
+      existing?.generation !== generation ||
+      !terminal ||
+      this.liveLeases.has(taskID)
+    ) {
+      return undefined;
+    }
+    const lease: BackgroundJobLease = {
+      taskID,
+      generation,
+      token: this.nextLeaseToken('terminal-notification'),
+      kind: 'terminal-notification',
+    };
+    this.liveLeases.set(taskID, lease);
+    return lease;
+  }
+
   validateLease(lease: BackgroundJobLease): boolean {
     const activeLease = this.liveLeases.get(lease.taskID);
     return (
@@ -912,6 +966,10 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
     const reusable = jobs.filter((j) => isReusable(j, this.maxContextLines));
+    const acknowledgedFailedSession = reusable.some((job) => {
+      const terminal = job.terminalState ?? terminalStateOf(job.state);
+      return terminal === 'cancelled' || terminal === 'error';
+    });
 
     if (active.length === 0 && reusable.length === 0) return undefined;
 
@@ -919,9 +977,19 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       [
         '### Background Job Board',
         'SENTINEL: background-job-board-v2',
-        'Completed or reconciled sessions are reusable by alias for the same specialist/context.',
+        ...(acknowledgedFailedSession
+          ? [
+              'Acknowledged terminal sessions are reusable by alias for the same specialist/context.',
+            ]
+          : [
+              'Completed or reconciled sessions are reusable by alias for the same specialist/context.',
+            ]),
         'Timed-out running sessions are recoverable by alias for safe resume after a live busy signal.',
-        'Cancelled or errored sessions are not reusable.',
+        ...(acknowledgedFailedSession
+          ? [
+              'Active, uncertain, or unacknowledged terminal sessions are not reusable.',
+            ]
+          : ['Cancelled or errored sessions are not reusable.']),
         '',
         '#### Active / Unreconciled',
         ...(active.length > 0 ? active.map(formatJob) : ['- none']),
@@ -1063,7 +1131,13 @@ function isReusable(
   maxContextLines: number,
 ): boolean {
   const terminal = job.terminalState ?? terminalStateOf(job.state);
-  if (terminal !== 'completed' || job.terminalUnreconciled) return false;
+  if (
+    terminal === undefined ||
+    job.terminalUnreconciled ||
+    job.statusUncertain
+  ) {
+    return false;
+  }
 
   return sumContextLines(job) <= maxContextLines;
 }

+ 47 - 0
src/utils/background-job-coordinator.test.ts

@@ -201,4 +201,51 @@ describe('BackgroundJobCoordinator', () => {
     expect(coordinator.validateLease(relaunchLease)).toBe(true);
     expect(coordinator.releaseLease(relaunchLease)).toBe(true);
   });
+
+  test('forwards mutually exclusive message lease acquisition', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const job = coordinator.registerLaunch({
+      taskID: 'ses_message_coordinator',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    const lease = coordinator.acquireMessageLease(job.taskID, job.generation);
+
+    expect(lease).toMatchObject({ kind: 'message' });
+    expect(
+      coordinator.acquireCancellationLease(job.taskID, job.generation),
+    ).toBe(undefined);
+    expect(coordinator.acquireRelaunchLease(job.taskID, job.generation)).toBe(
+      undefined,
+    );
+    if (!lease) throw new Error('message lease was not acquired');
+    expect(coordinator.releaseLease(lease)).toBe(true);
+  });
+
+  test('forwards terminal notification lease acquisition after completion', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const job = coordinator.registerLaunch({
+      taskID: 'ses_terminal_notification',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    coordinator.updateStatus({
+      taskID: job.taskID,
+      expectedGeneration: job.generation,
+      state: 'completed',
+    });
+
+    const lease = coordinator.acquireTerminalNotificationLease(
+      job.taskID,
+      job.generation,
+    );
+    expect(lease).toMatchObject({ kind: 'terminal-notification' });
+    expect(
+      coordinator.acquireRelaunchLease(job.taskID, job.generation),
+    ).toBeUndefined();
+    if (!lease) throw new Error('terminal notification lease was not acquired');
+    expect(coordinator.releaseLease(lease)).toBe(true);
+  });
 });

+ 14 - 0
src/utils/background-job-coordinator.ts

@@ -152,6 +152,20 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.acquireRelaunchLease(taskID, generation);
   }
 
+  acquireMessageLease(
+    taskID: string,
+    generation: number,
+  ): BackgroundJobLease | undefined {
+    return this.board.acquireMessageLease(taskID, generation);
+  }
+
+  acquireTerminalNotificationLease(
+    taskID: string,
+    generation: number,
+  ): BackgroundJobLease | undefined {
+    return this.board.acquireTerminalNotificationLease(taskID, generation);
+  }
+
   validateLease(lease: BackgroundJobLease): boolean {
     return this.board.validateLease(lease);
   }

+ 12 - 0
src/utils/background-job-store.ts

@@ -130,6 +130,14 @@ export interface BackgroundJobStore {
     taskID: string,
     generation: number,
   ): BackgroundJobLease | undefined;
+  acquireMessageLease(
+    taskID: string,
+    generation: number,
+  ): BackgroundJobLease | undefined;
+  acquireTerminalNotificationLease(
+    taskID: string,
+    generation: number,
+  ): BackgroundJobLease | undefined;
   validateLease(lease: BackgroundJobLease): boolean;
   releaseLease(lease: BackgroundJobLease): boolean;
   updateStatus(
@@ -160,6 +168,10 @@ export interface BackgroundJobStore {
     expectedGeneration?: number,
     now?: number,
   ): BackgroundJobRecord | undefined;
+  /**
+   * Acknowledge the terminal notification delivered to the parent session.
+   * This is a prompt-lifecycle acknowledgement, not filesystem reconciliation.
+   */
   markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
   markCancelled(
     taskID: string,

+ 1 - 1
src/utils/session.ts

@@ -197,7 +197,7 @@ export async function extractFinalSessionResult(
           ? part.type === 'text' || part.type === 'reasoning'
           : part.type === 'text') && Boolean(part.text),
     )
-    .map((part) => part.text!)
+    .flatMap((part) => (typeof part.text === 'string' ? [part.text] : []))
     .join('\n\n');
 
   const last = messages[messages.length - 1];