Przeglądaj źródła

fix(task-session-manager): recover stopped sessions and refuse silent task_id drops (#1191)

* fix(task-session-manager): recover stopped sessions and refuse dropped task_id resume

- task() no longer silently deletes an explicit task_id it cannot resume;
  it throws with the concrete state and recovery path instead
- task_revive now recovers stopped sessions (idle without a native
  terminal result), before and after acknowledgement
- revive revalidates lease, generation, and state after baseline capture;
  a late busy observation aborts the revive before promptAsync is sent
- releaseLease re-applies retention caps shielded by in-flight leases
- sentinel lists acknowledged stopped sessions under Retained / Recovery
  with task_revive guidance, bounded by existing retention limits

* fix(task-session-manager): hold relaunch lease against late busy observations

A busy observation arriving while a revive is in flight can no longer
resurrect the stopped record underneath it: markRunningFromLiveSession
keeps the record stopped under a live relaunch lease and only advances
lastLiveBusyAt. The revive refuses on that fresh-activity signal before
promptAsync, so no duplicate work is launched over a session that
became active again mid-send.

* fix(task-session-manager): fence revive sends against the live host status map

The relaunch lease keeps the board record stopped during a revive, but
an independently resumed session only shows up in the host's live
status map; on v2 hosts promptAsync degrades to steering an in-flight
run instead of rejecting it. The revive now refuses on a busy or retry
entry (and on an unverifiable map) after baseline capture and before
promptAsync, closing the remaining window where a revived prompt could
steer an already-active session and register a duplicate generation.
Raxxoor 2 dni temu
rodzic
commit
c4631c3283

+ 17 - 12
docs/background-orchestration.md

@@ -167,9 +167,10 @@ retaining its session, then inspect and reconcile any partial file changes befor
 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.
+A cancelled, errored, or stopped 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.
+`task()` never drops an explicit `task_id` to spawn another session.
 
 Terminal jobs are reconciled automatically after their result is injected into
 the orchestrator session. That lifecycle state is not proof the output was used;
@@ -339,10 +340,12 @@ 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.
+Cancelled, errored, and stopped 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. Stopped sessions stay out
+of the ordinary `task()` reuse pool because that generation has no terminal
+result; after ack they appear under Retained / Recovery.
 
 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.
@@ -496,11 +499,13 @@ valid map is not immediately terminal: the first observation starts a 5s
 confirmation grace and keeps the job `running, status uncertain`. Repeat
 non-busy evidence after that grace records `stopped, unreconciled` rather than
 `completed`: it means execution ended before a native terminal task result was
-delivered, not that the task succeeded. Stopped sessions are never reusable and
-stay visible to the parent for recovery. A later live `busy` observation can
-revive an unreconciled stopped job. After the parent has been woken and the stop
-acknowledged, stale busy cannot flip the job back to running. Only explicit
-terminal task output proves completion, error, or cancellation.
+delivered, not that the task succeeded. Stopped sessions are never reusable
+through `task()` and stay visible to the parent for recovery with `task_revive`.
+A later live `busy` observation can revive an unreconciled stopped job. After
+the parent has been woken and the stop acknowledged, stale busy cannot flip the
+job back to running; the session remains listed under Retained / Recovery until
+revived or evicted. Only explicit terminal task output proves completion, error,
+or cancellation.
 
 Stopped-job recovery facts are checked again by task ID and run generation
 before a queued recovery wake is delivered. The inline detail queue is bounded;

+ 6 - 4
docs/tools.md

@@ -68,10 +68,12 @@ stops the generation but retains its session; it does not roll back partial edit
 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.
+`task_revive` resumes a retained session with a new instruction. A cancelled,
+errored, or stopped 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.
+`task()` refuses an explicit `task_id` it cannot resume instead of dropping it
+and spawning another session.
 
 `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

+ 3 - 3
src/agents/orchestrator.ts

@@ -231,7 +231,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - For a live child task, call \`task_status\` for read-only state inspection. There is no safe live-prompt channel: never use \`${vocab.tool}(..., task_id: ...)\` as a progress check or instruction because it resumes model work.
 - 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.
+- Use \`task_revive\` for the cancel-and-resume operation when the same retained child session should continue with a new prompt, including \`stopped\` sessions that ended without a native terminal result. 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 \`${vocab.tool}(..., 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.
@@ -265,10 +265,10 @@ After spawning all independent background tasks and any remaining non-overlappin
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
-- Only sessions listed under Reusable Sessions may be resumed. Active / Unreconciled sessions are not resumable.
+- Only sessions listed under Reusable Sessions may be resumed with \`${vocab.tool}()\`. Active / Unreconciled sessions are not resumable with \`${vocab.tool}()\`. Stopped sessions listed under Retained / Recovery are recovered with \`task_revive\`, not \`${vocab.tool}()\`.
 - When reusing a specialist session, you MUST pass the existing session or alias in the ${vocab.tool} tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
 - If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call ${vocab.tool} with \`${vocab.agentParam}: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
-- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
+- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session. If a call with an explicit \`task_id\` is refused, do not retry the same objective as a new spawn.
 
 ## 5. Verify
 - Reconcile all writer lanes before final validation.

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

@@ -157,7 +157,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - 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.
 - 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.
+- Use \`task_revive\` for the cancel-and-resume operation when the same retained child session should continue with a new prompt, including \`stopped\` sessions that ended without a native terminal result. 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.
@@ -187,10 +187,10 @@ After spawning all independent background tasks and any remaining non-overlappin
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
-- Only sessions listed under Reusable Sessions may be resumed. Active / Unreconciled sessions are not resumable.
+- Only sessions listed under Reusable Sessions may be resumed with \`task()\`. Active / Unreconciled sessions are not resumable with \`task()\`. Stopped sessions listed under Retained / Recovery are recovered with \`task_revive\`, not \`task()\`.
 - When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
 - If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
-- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
+- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session. If a call with an explicit \`task_id\` is refused, do not retry the same objective as a new spawn.
 
 ## 5. Verify
 - Reconcile all writer lanes before final validation.

+ 91 - 36
src/hooks/task-session-manager/index.test.ts

@@ -1792,11 +1792,17 @@ describe('task-session-manager hook', () => {
       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();
+      await expect(
+        hook['tool.execute.before'](
+          {
+            tool: 'task',
+            sessionID: 'parent-1',
+            callID: `${state}-before-ack`,
+          },
+          beforeAcknowledgement,
+        ),
+      ).rejects.toThrow(/unreconciled; task\(\) cannot resume/);
+      expect(beforeAcknowledgement.args.task_id).toBe(original.alias);
 
       board.markReconciled(original.taskID);
 
@@ -1824,6 +1830,47 @@ describe('task-session-manager hook', () => {
     }
   });
 
+  test('refuses stopped sessions through task() before and after acknowledgement', async () => {
+    const board = new BackgroundJobBoard();
+    const original = board.registerLaunch({
+      taskID: 'child-stopped',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'idle review',
+      now: 100,
+    });
+    board.markStopped(original.taskID, 'no native result', 110, undefined, 110);
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    const beforeAcknowledgement = {
+      args: { subagent_type: 'oracle', task_id: original.alias },
+    };
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'stopped-before-ack' },
+        beforeAcknowledgement,
+      ),
+    ).rejects.toThrow(/stopped, unreconciled; task\(\) cannot resume/);
+    expect(beforeAcknowledgement.args.task_id).toBe(original.alias);
+
+    board.markReconciled(original.taskID);
+
+    const afterAcknowledgement = {
+      args: { subagent_type: 'oracle', task_id: original.alias },
+    };
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'stopped-after-ack' },
+        afterAcknowledgement,
+      ),
+    ).rejects.toThrow(/stopped, acknowledged; task\(\) cannot resume/);
+    expect(afterAcknowledgement.args.task_id).toBe(original.alias);
+    expect(board.get(original.taskID)).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: false,
+    });
+  });
+
   test('keeps task timeout as a running timed-out job', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -2201,6 +2248,7 @@ describe('task-session-manager hook', () => {
       taskID: 'child-1',
       parentSessionID: 'parent-1',
     });
+    board.markReconciled('child-1');
     const { hook } = createHook({ backgroundJobBoard: board });
 
     await hook['tool.execute.before'](
@@ -4725,11 +4773,13 @@ describe('task-session-manager hook', () => {
     const unreconciled = {
       args: { subagent_type: 'oracle', task_id: 'ora-1' },
     };
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
-      unreconciled,
-    );
-    expect(unreconciled.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+        unreconciled,
+      ),
+    ).rejects.toThrow(/unreconciled; task\(\) cannot resume/);
+    expect(unreconciled.args.task_id).toBe('ora-1');
 
     board.markReconciled('done-1');
 
@@ -4779,7 +4829,7 @@ describe('task-session-manager hook', () => {
     expect(resume.args.task_id).toBe('exp-1');
   });
 
-  test('task alias is dropped when subagent_type is missing', async () => {
+  test('task alias is refused when subagent_type is missing', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     board.registerLaunch({
@@ -4790,15 +4840,16 @@ describe('task-session-manager hook', () => {
     });
 
     const resume = { args: { task_id: 'exp-1' } };
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
-      resume,
-    );
-
-    expect(resume.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+        resume,
+      ),
+    ).rejects.toThrow(/requires a valid subagent_type/);
+    expect(resume.args.task_id).toBe('exp-1');
   });
 
-  test('task alias is dropped when subagent_type is invalid', async () => {
+  test('task alias is refused when subagent_type is invalid', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     board.registerLaunch({
@@ -4811,12 +4862,13 @@ describe('task-session-manager hook', () => {
     const resume = {
       args: { subagent_type: 123, task_id: 'exp-1' },
     };
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
-      resume,
-    );
-
-    expect(resume.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+        resume,
+      ),
+    ).rejects.toThrow(/requires a valid subagent_type/);
+    expect(resume.args.task_id).toBe('exp-1');
   });
 
   test('custom subagent raw session task_id is preserved', async () => {
@@ -4869,11 +4921,13 @@ describe('task-session-manager hook', () => {
     board.markReconciled('child-1');
 
     const wrongAgent = { args: { subagent_type: 'oracle', task_id: 'exp-1' } };
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'agent' },
-      wrongAgent,
-    );
-    expect(wrongAgent.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'agent' },
+        wrongAgent,
+      ),
+    ).rejects.toThrow(/agent is explorer, not oracle/);
+    expect(wrongAgent.args.task_id).toBe('exp-1');
   });
 
   test('resuming reusable job relaunches running and removes reusable entry', async () => {
@@ -5019,16 +5073,17 @@ describe('task-session-manager hook', () => {
     expect(resume.args.task_id).toBe('ses_existing');
   });
 
-  test('still drops unknown reusable aliases', async () => {
+  test('refuses unknown reusable aliases without dropping task_id', async () => {
     const { hook } = createHook();
     const resume = { args: { subagent_type: 'fixer', task_id: 'fix-99' } };
 
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
-      resume,
-    );
-
-    expect(resume.args.task_id).toBeUndefined();
+    await expect(
+      hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+        resume,
+      ),
+    ).rejects.toThrow(/Unknown task ID or alias: fix-99/);
+    expect(resume.args.task_id).toBe('fix-99');
   });
 
   test('reads before and after launch attach with unique-line counts and caps', async () => {

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

@@ -37,10 +37,65 @@ interface TaskArgs {
   background?: unknown;
 }
 
+interface ResumeRefusalJob {
+  taskID: string;
+  alias: string;
+  agent: string;
+  state: string;
+  terminalUnreconciled: boolean;
+}
+
 function normalizeObjectiveKey(value: string): string {
   return value.replace(/\s+/g, ' ').trim().toLowerCase();
 }
 
+function refuseExplicitTaskId(
+  requested: string,
+  message: string,
+  details?: Record<string, unknown>,
+): never {
+  log('[task-session-manager] refused explicit task_id', {
+    task_id: requested,
+    ...details,
+  });
+  throw new Error(message);
+}
+
+function refuseKnownTaskResume(
+  requested: string,
+  job: ResumeRefusalJob,
+  agentType: string,
+): never {
+  const label = `${job.alias} / ${job.taskID}`;
+  if (job.agent !== agentType) {
+    refuseExplicitTaskId(
+      requested,
+      `${label}: agent is ${job.agent}, not ${agentType}. task() cannot resume this session. No new session was created.`,
+      { state: job.state, agent: job.agent, requestedAgent: agentType },
+    );
+  }
+  if (job.state === 'stopped') {
+    const ack = job.terminalUnreconciled ? 'unreconciled' : 'acknowledged';
+    refuseExplicitTaskId(
+      requested,
+      `${label}: stopped, ${ack}; task() cannot resume this session. Use task_revive with a new prompt. No new session was created.`,
+      { state: job.state, acknowledged: !job.terminalUnreconciled },
+    );
+  }
+  if (job.terminalUnreconciled) {
+    refuseExplicitTaskId(
+      requested,
+      `${label}: ${job.state}, unreconciled; task() cannot resume until acknowledgement. Use task_revive now, or wait for ack then task(). No new session was created.`,
+      { state: job.state, terminalUnreconciled: true },
+    );
+  }
+  refuseExplicitTaskId(
+    requested,
+    `${label}: ${job.state}; task() cannot resume this session. Use task_revive with a new prompt. No new session was created.`,
+    { state: job.state },
+  );
+}
+
 export async function handleToolExecuteBefore(
   input: { tool: string; sessionID?: string; callID?: string },
   output: { args?: unknown },
@@ -95,7 +150,11 @@ export async function handleToolExecuteBefore(
     args.subagent_type.trim() === ''
   ) {
     if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
-      delete args.task_id;
+      const requested = args.task_id.trim();
+      refuseExplicitTaskId(
+        requested,
+        `Task ${requested}: task() requires a valid subagent_type with an explicit task_id. The task_id was not dropped; no new session was created.`,
+      );
     }
     return;
   }
@@ -175,11 +234,14 @@ export async function handleToolExecuteBefore(
       }
 
       if (knownManagedTask) {
-        delete args.task_id;
+        refuseKnownTaskResume(requested, knownManagedTask, agentType);
       } else if (SESSION_ID_PATTERN.test(requested)) {
         pendingCall.resumedTaskId = requested;
       } else {
-        delete args.task_id;
+        refuseExplicitTaskId(
+          requested,
+          `Unknown task ID or alias: ${requested}. task() did not drop the id and did not create another session.`,
+        );
       }
     } else {
       const relaunchLease = deps.backgroundJobBoard.acquireRelaunchLease(

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

@@ -77,6 +77,21 @@ function acknowledgedCompleted(board: BackgroundJobBoard, taskID = 'ses_1') {
   board.markReconciled(taskID);
 }
 
+function stoppedSession(
+  board: BackgroundJobBoard,
+  taskID = 'ses_1',
+  acknowledge = false,
+) {
+  board.registerLaunch({
+    taskID,
+    parentSessionID: 'parent-1',
+    agent: 'explorer',
+    now: 100,
+  });
+  board.markStopped(taskID, 'no native result', 110, undefined, 110);
+  if (acknowledge) 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();
@@ -206,6 +221,102 @@ describe('task_revive tool', () => {
     });
   });
 
+  test('revives a stopped session before and after acknowledgement', async () => {
+    for (const acknowledge of [false, true]) {
+      const { board, promptAsync, taskRevive } = createTool();
+      stoppedSession(board, 'ses_1', acknowledge);
+      expect(board.get('ses_1')).toMatchObject({
+        state: 'stopped',
+        terminalUnreconciled: !acknowledge,
+      });
+
+      const output = await taskRevive.execute(
+        { task_id: 'ses_1', prompt: 'continue from the retained session' },
+        context,
+      );
+
+      expect(promptAsync).toHaveBeenCalledTimes(1);
+      expect(String(output)).toContain('state: running');
+      expect(board.get('ses_1')).toMatchObject({
+        generation: 2,
+        state: 'running',
+      });
+    }
+  });
+
+  test('refuses to relaunch when a late busy revives the generation during baseline capture', async () => {
+    // P1 regression: captureBaseline awaits network I/O. If a live busy
+    // observation arrives while the baseline is in flight, the revive
+    // must NOT send promptAsync over the still-active generation, must
+    // not bump the board generation, and must release the relaunch
+    // lease. With the lease held, the busy observation keeps the record
+    // stopped and only advances lastLiveBusyAt; the revive refuses on
+    // that fresh-activity signal.
+    let resolveBaseline: (id: string | undefined) => void = () => {};
+    const baselineGate = new Promise<string | undefined>((resolve) => {
+      resolveBaseline = resolve;
+    });
+    const deferredTracker = {
+      captureBaseline: () => baselineGate,
+      register: () => {},
+      isTracked: () => false,
+      probe: () => Promise.resolve(true),
+      onTerminal: () => {},
+      dispose: () => {},
+    };
+    const { board, promptAsync, taskRevive } = createTool({
+      revivedRunTracker: deferredTracker as any,
+    });
+    stoppedSession(board);
+
+    const pending = taskRevive.execute(
+      { task_id: 'ses_1', prompt: 'continue' },
+      context,
+    );
+    // Late busy observation lands while captureBaseline is in flight.
+    board.markRunningFromLiveSession('ses_1', 115);
+    resolveBaseline(undefined);
+
+    await expect(pending).rejects.toThrow(/became active again/);
+    expect(promptAsync).toHaveBeenCalledTimes(0);
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'stopped',
+      generation: 1,
+      lastLiveBusyAt: 115,
+    });
+    // The relaunch lease was released: a new acquire on the same
+    // generation succeeds.
+    const reLease = board.acquireRelaunchLease('ses_1', 1);
+    expect(reLease).toBeDefined();
+    if (reLease) board.releaseLease(reLease);
+  });
+
+  test('refuses to relaunch when the host reports the session busy even if the board is stopped', async () => {
+    // P1 regression (host fence): the board record stays stopped under
+    // the relaunch lease, but the session may have resumed
+    // independently at the host. On v2 hosts promptAsync degrades to
+    // steering an in-flight run instead of rejecting it, so a live
+    // busy/retry entry must refuse before the prompt is sent.
+    const { board, promptAsync, status, taskRevive } = createTool({
+      status: async () => ({ data: { ses_1: { type: 'busy' } } }),
+    });
+    stoppedSession(board);
+
+    await expect(
+      taskRevive.execute({ task_id: 'ses_1', prompt: 'continue' }, context),
+    ).rejects.toThrow(/executing at the host/);
+
+    expect(promptAsync).toHaveBeenCalledTimes(0);
+    expect(status).toHaveBeenCalled();
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'stopped',
+      generation: 1,
+    });
+    const reLease = board.acquireRelaunchLease('ses_1', 1);
+    expect(reLease).toBeDefined();
+    if (reLease) board.releaseLease(reLease);
+  });
+
   test('rejects an uncertain retained terminal job', async () => {
     const { board, promptAsync, taskRevive } = createTool();
     board.registerLaunch({

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

@@ -2,6 +2,7 @@ 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 { getRuntimeSessionStatusSnapshot } from '../utils/session-runtime-status';
 import {
   assertOrchestrator,
   cancelTrackedExecution,
@@ -95,9 +96,63 @@ export function createTaskReviveTool(
           >
         | undefined;
       try {
+        const observedLiveBusyAt = current.lastLiveBusyAt;
         baselineMessageID = await revivedRunTracker.captureBaseline(
           current.taskID,
         );
+        // captureBaseline awaits network I/O; the record may have changed
+        // while we waited. Revalidate against the live record before
+        // sending anything: never relaunch over a session that is
+        // running again. A live relaunch lease keeps the board record
+        // stopped while a revive is in flight (the busy observation only
+        // advances lastLiveBusyAt), so treat any movement of that
+        // timestamp as fresh activity and refuse.
+        const rechecked = getCurrentReviveJob(
+          options,
+          parentSessionID,
+          requested,
+          captured.taskID,
+          captured.generation,
+        );
+        const freshLiveActivity =
+          rechecked.lastLiveBusyAt !== undefined &&
+          rechecked.lastLiveBusyAt !== observedLiveBusyAt;
+        if (
+          !options.backgroundJobBoard.validateLease(relaunchLease) ||
+          rechecked.state === 'running' ||
+          !isReviveableRetainedJob(rechecked) ||
+          freshLiveActivity
+        ) {
+          throw new Error(
+            `Task ${requested} became active again (${rechecked.state}) before the revive prompt was sent; the prompt was NOT sent and no duplicate was launched. Use task_status to inspect it.`,
+          );
+        }
+        current = rechecked;
+        // Fence the send against independent host-level resumes. The
+        // board record stays stopped under the relaunch lease, so the
+        // host's live status map is the only place an independently
+        // resumed session shows up. On v2 hosts promptAsync degrades to
+        // steering an in-flight run instead of rejecting it, so a busy
+        // or retry entry must refuse here; an unverifiable map refuses
+        // rather than guessing. A verified-absent entry means no active
+        // runner: the session is idle and safe to prompt.
+        const liveSnapshot = await getRuntimeSessionStatusSnapshot(
+          options.input,
+        );
+        const liveStatus = liveSnapshot.statuses.get(current.taskID);
+        if (liveStatus === 'busy' || liveStatus === 'retry') {
+          throw new Error(
+            `Task ${requested} is executing at the host (live status: ${liveStatus}); the revive prompt was NOT sent and no duplicate was launched. Use task_status to inspect it.`,
+          );
+        }
+        if (
+          liveSnapshot.error !== undefined ||
+          liveSnapshot.malformedSessionIDs.has(current.taskID)
+        ) {
+          throw new Error(
+            `Task ${requested} could not be verified against the live session map (${liveSnapshot.error ?? 'malformed entry'}); the revive prompt was NOT sent. Retry task_revive.`,
+          );
+        }
         const session = getClient(options.input).session;
         if (typeof session.promptAsync !== 'function') {
           throw new Error('The host session does not support promptAsync');
@@ -219,6 +274,7 @@ function isReviveableRetainedJob(
   >,
 ): boolean {
   if (job.statusUncertain) return false;
+  if (job.state === 'stopped') return true;
   if (
     job.state === 'completed' ||
     job.state === 'error' ||

+ 146 - 0
src/utils/background-job-board.test.ts

@@ -465,6 +465,117 @@ describe('BackgroundJobBoard', () => {
     expect(board.formatForPrompt('parent-1')).toContain('Reusable Sessions');
   });
 
+  test('lists acknowledged stopped sessions as retained recovery, not reusable', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_stopped',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'idle review',
+      now: 100,
+    });
+    board.markStopped('ses_stopped', 'no native result', 110, undefined, 110);
+
+    const unreconciled = board.formatForPrompt('parent-1');
+    expect(unreconciled).toContain(
+      'ora-1 / ses_stopped / oracle / stopped, unreconciled',
+    );
+    expect(unreconciled).not.toContain('#### Retained / Recovery');
+    expect(
+      board.resolveReusable('parent-1', 'ses_stopped', 'oracle'),
+    ).toBeUndefined();
+
+    board.markReconciled('ses_stopped');
+
+    const prompt = board.formatForPrompt('parent-1');
+    expect(prompt).toContain('#### Retained / Recovery');
+    expect(prompt).toContain(
+      'ora-1 / ses_stopped / oracle / stopped, retained',
+    );
+    expect(prompt).toContain(
+      'Recovery: no terminal result; recoverable with task_revive, not task()',
+    );
+    expect(prompt).toContain(
+      'Stopped sessions without a terminal result are retained for task_revive, not task().',
+    );
+    expect(prompt).toContain('#### Reusable Sessions\n- none');
+    expect(prompt).not.toContain('stopped, unreconciled');
+    expect(
+      board.resolveReusable('parent-1', 'ses_stopped', 'oracle'),
+    ).toBeUndefined();
+    expect(
+      board.formatForPromptWithMetadata('parent-1')
+        ?.terminalUnreconciledTaskIDs,
+    ).toEqual([]);
+  });
+
+  test('trimRetained evicts acknowledged stopped sessions beyond the per-agent cap', () => {
+    const board = new BackgroundJobBoard({ maxReusablePerAgent: 1 });
+    board.registerLaunch({
+      taskID: 'ses_old',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      now: 100,
+    });
+    board.markStopped('ses_old', 'no native result', 110, undefined, 110);
+    board.markReconciled('ses_old', 120);
+
+    board.registerLaunch({
+      taskID: 'ses_new',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      now: 200,
+    });
+    board.markStopped('ses_new', 'no native result', 210, undefined, 210);
+    board.markReconciled('ses_new', 220);
+
+    expect(board.get('ses_old')).toBeUndefined();
+    expect(board.get('ses_new')).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('releaseLease re-applies retention caps shielded by an in-flight lease', () => {
+    // P2 regression: an ACK landing while a revive holds the relaunch
+    // lease shields retained-stopped entries from trimRetained. When the
+    // revive fails and releases the lease, the per-agent cap must be
+    // re-applied — evicting the oldest acknowledged stopped entry that
+    // was shielded while the lease was live.
+    const board = new BackgroundJobBoard({ maxReusablePerAgent: 1 });
+    board.registerLaunch({
+      taskID: 'ses_a',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      now: 100,
+    });
+    board.markStopped('ses_a', 'no native result', 110, undefined, 110);
+    board.markReconciled('ses_a', 120);
+
+    // ses_a holds a relaunch lease (revive in flight).
+    const lease = board.acquireRelaunchLease('ses_a', 1);
+    expect(lease).toBeDefined();
+
+    board.registerLaunch({
+      taskID: 'ses_b',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      now: 200,
+    });
+    board.markStopped('ses_b', 'no native result', 210, undefined, 210);
+    // ACK of ses_b while ses_a is leased: the shielded ses_a survives the
+    // per-agent cap of 1 because leased entries are excluded.
+    board.markReconciled('ses_b', 220);
+    expect(board.get('ses_a')).toBeDefined();
+    expect(board.get('ses_b')).toBeDefined();
+
+    // Releasing the lease re-applies the cap without the shield: the
+    // oldest retained entry (ses_a) is evicted.
+    if (lease) expect(board.releaseLease(lease)).toBe(true);
+    expect(board.get('ses_a')).toBeUndefined();
+    expect(board.get('ses_b')).toMatchObject({ state: 'stopped' });
+  });
+
   test('does not expose unreconciled terminal jobs as reusable', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -1153,6 +1264,41 @@ describe('BackgroundJobBoard', () => {
     });
   });
 
+  test('live busy cannot resurrect a stopped job under a relaunch lease', () => {
+    // P1 regression: while a task_revive holds the relaunch lease for a
+    // stopped generation, a busy observation must not flip the record
+    // back to running underneath the in-flight revive. The observation
+    // is recorded (lastLiveBusyAt) so the revive can refuse on fresh
+    // activity; once the lease is released, a later busy can revive.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      now: 100,
+    });
+    const generation = board.get('ses_1')?.generation;
+    board.markStopped('ses_1', 'no result', 150, generation, 150);
+
+    const lease = board.acquireRelaunchLease('ses_1', generation ?? 1);
+    expect(lease).toBeDefined();
+
+    const leased = board.markRunningFromLiveSession('ses_1', 200, generation);
+    expect(leased).toMatchObject({
+      state: 'stopped',
+      terminalUnreconciled: true,
+      lastLiveBusyAt: 200,
+    });
+
+    if (lease) board.releaseLease(lease);
+
+    const revived = board.markRunningFromLiveSession('ses_1', 201, generation);
+    expect(revived).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
   test('live busy session does not reopen non-cancelled terminal jobs', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

+ 93 - 2
src/utils/background-job-board.ts

@@ -463,6 +463,25 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
     if (existing.deadlineExceededAt !== undefined) return existing;
 
+    // A live relaunch lease owns the relaunch decision for this
+    // generation: a busy observation arriving while a revive is in
+    // flight must not resurrect the stopped record underneath it. The
+    // observation is still recorded so the revive's revalidation can
+    // refuse on fresh activity instead of sending the prompt.
+    const relaunchLease = this.liveLeases.get(taskID);
+    if (
+      relaunchLease?.kind === 'relaunch' &&
+      relaunchLease.generation === existing.generation &&
+      existing.state === 'stopped'
+    ) {
+      const leased: BackgroundJobRecord = {
+        ...existing,
+        lastLiveBusyAt: now,
+      };
+      this.jobs.set(taskID, leased);
+      return leased;
+    }
+
     const isStaleTerminal =
       isCanonicalTerminalState(existing.state) ||
       existing.state === 'reconciled' ||
@@ -516,7 +535,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   /**
    * The host reports that this child no longer executes, but no native task
    * result established success, cancellation, or failure. Keep that ambiguity
-   * visible to the parent and never permit session reuse.
+   * visible to the parent and never permit ordinary `task()` reuse. Recovery
+   * of the retained session is `task_revive`, not silent spawn.
    */
   markStopped(
     taskID: string,
@@ -628,6 +648,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         lastUsedAt: now,
       };
       this.jobs.set(taskID, updated);
+      this.trimRetained(taskID);
       return updated;
     }
 
@@ -825,6 +846,11 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   releaseLease(lease: BackgroundJobLease): boolean {
     if (!this.validateLease(lease)) return false;
     this.liveLeases.delete(lease.taskID);
+    // A lease may have shielded retained-stopped entries from trimRetained
+    // while a revive was in flight. Re-apply the retention cap now that the
+    // shield is gone so acknowledged stopped records cannot accumulate past
+    // the configured limits (guarded to retained stopped records only).
+    this.trimRetained(lease.taskID);
     return true;
   }
 
@@ -1060,12 +1086,15 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
     const reusable = jobs.filter((j) => isReusable(j, this.maxContextLines));
+    const retained = jobs.filter(isRetainedStopped);
     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;
+    if (active.length === 0 && reusable.length === 0 && retained.length === 0) {
+      return undefined;
+    }
 
     const text = formatSystemReminder(
       [
@@ -1084,6 +1113,11 @@ export class BackgroundJobBoard implements BackgroundJobStore {
               'Active, uncertain, or unacknowledged terminal sessions are not reusable.',
             ]
           : ['Cancelled or errored sessions are not reusable.']),
+        ...(retained.length > 0
+          ? [
+              'Stopped sessions without a terminal result are retained for task_revive, not task().',
+            ]
+          : []),
         '',
         '#### Active / Unreconciled',
         ...(active.length > 0 ? active.map(formatJob) : ['- none']),
@@ -1092,6 +1126,13 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         ...(reusable.length > 0
           ? reusable.map((job) => this.formatReusableJob(job))
           : ['- none']),
+        ...(retained.length > 0
+          ? [
+              '',
+              '#### Retained / Recovery',
+              ...retained.map((job) => this.formatRetainedJob(job)),
+            ]
+          : []),
       ].join('\n'),
     );
 
@@ -1168,6 +1209,36 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     }
   }
 
+  private trimRetained(taskID: string): void {
+    const job = this.jobs.get(taskID);
+    if (!job || !isRetainedStopped(job)) return;
+
+    for (const entry of this.list(job.parentSessionID)) {
+      if (
+        entry.agent === job.agent &&
+        isRetainedStopped(entry) &&
+        !this.liveLeases.has(entry.taskID) &&
+        sumContextLines(entry) > this.maxContextLines
+      ) {
+        recordBackgroundJobSuppression(this, entry.taskID);
+        this.jobs.delete(entry.taskID);
+      }
+    }
+
+    const retained = this.list(job.parentSessionID)
+      .filter(
+        (candidate) =>
+          candidate.agent === job.agent &&
+          isRetainedStopped(candidate) &&
+          !this.liveLeases.has(candidate.taskID),
+      )
+      .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
+    for (const stale of retained.slice(this.maxReusablePerAgent)) {
+      recordBackgroundJobSuppression(this, stale.taskID);
+      this.jobs.delete(stale.taskID);
+    }
+  }
+
   private formatReusableJob(job: BackgroundJobRecord): string {
     const terminal = job.terminalState ?? terminalStateOf(job.state);
     const reconciliation = job.terminalUnreconciled
@@ -1185,6 +1256,20 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return lines.join('\n');
   }
 
+  private formatRetainedJob(job: BackgroundJobRecord): string {
+    const lines = [
+      `- ${promptSafe(job.alias)} / ${promptSafe(job.taskID)} / ${promptSafe(job.agent)} / stopped, retained`,
+      `  Objective: ${promptSafe(job.description || job.objective || '')}`,
+      '  Recovery: no terminal result; recoverable with task_revive, not task()',
+    ];
+    const context = formatContextFiles(
+      job.contextFiles,
+      this.readContextMaxFiles,
+    );
+    if (context) lines.push(`  Context read by ${job.alias}: ${context}`);
+    return lines.join('\n');
+  }
+
   private nextAlias(parentSessionID: string, agent: string): string {
     const prefix = AGENT_PREFIX[agent] ?? (agent.slice(0, 3) || 'job');
     const key = `${parentSessionID}:${prefix}`;
@@ -1262,6 +1347,12 @@ function isReusable(
   return sumContextLines(job) <= maxContextLines;
 }
 
+function isRetainedStopped(job: BackgroundJobRecord): boolean {
+  return (
+    job.state === 'stopped' && !job.terminalUnreconciled && !job.statusUncertain
+  );
+}
+
 function terminalStateOf(
   state: BackgroundJobState,
 ): TaskOutputState | undefined {

+ 1 - 1
src/utils/codemap.md

@@ -17,7 +17,7 @@ Centralized utilities and shared abstractions used across the oh-my-opencode-sli
 
 ### Core Abstractions
 
-- **BackgroundJobBoard** (`background-job-board.ts`): Singleton registry and lifecycle manager for background tasks spawned by sub-agents. Implements a reusable session pool pattern with automatic cleanup and reconciliation hooks. Tracks task state (running, stopped, completed, error, cancelled), maintains context files, and provides prompt-ready summaries for agent coordination. `stopped` records an ended runtime session without fabricated task success and is never reusable. Idle/absent observations start a 5s stop-confirmation grace (`stopConfirmationStartedAt`); live busy resets it. After a confirmed stop has been acknowledged, stale busy cannot reopen the job.
+- **BackgroundJobBoard** (`background-job-board.ts`): Singleton registry and lifecycle manager for background tasks spawned by sub-agents. Implements a reusable session pool pattern with automatic cleanup and reconciliation hooks. Tracks task state (running, stopped, completed, error, cancelled), maintains context files, and provides prompt-ready summaries for agent coordination. `stopped` records an ended runtime session without fabricated task success and is never reusable through `task()`; recovery is `task_revive`, and acknowledged stopped jobs appear under Retained / Recovery. Idle/absent observations start a 5s stop-confirmation grace (`stopConfirmationStartedAt`); live busy resets it. After a confirmed stop has been acknowledged, stale busy cannot reopen the job.
 
 - **BackgroundJobStore** (`background-job-store.ts`): Atomic state-store contract (terminal transitions, leases, wall-clock deadline claims) implemented by the board; the single terminal-publication boundary.
 

+ 6 - 6
src/v2/setup.e2e.test.ts

@@ -377,8 +377,9 @@ describe('createV2Setup e2e', () => {
       });
 
       // (2) Write-back rewrite: a v2 `sessionID` that is not a
-      // resolvable/valid task id maps to v1 `task_id`, gets deleted by
-      // the v1 guard, and disappears from the v2 input on write-back.
+      // resolvable/valid task id maps to v1 `task_id`, the v1 guard
+      // refuses it explicitly (no silent drop, no duplicate spawn), and
+      // the rejection propagates through the v2 before-bridge.
       const resumeEvent = {
         tool: 'subagent',
         sessionID: 'ses_parent',
@@ -395,10 +396,9 @@ describe('createV2Setup e2e', () => {
       };
       const resumeHook = calls.toolBeforeCb;
       if (!resumeHook) throw new Error('tool:execute.before not captured');
-      await resumeHook(resumeEvent);
-      expect(resumeEvent.input).not.toHaveProperty('sessionID');
-      expect(resumeEvent.input).not.toHaveProperty('task_id');
-      expect(resumeEvent.input).not.toHaveProperty('subagent_type');
+      await expect(resumeHook(resumeEvent)).rejects.toThrow(
+        /did not drop the id and did not create another session/,
+      );
 
       // (3) v2 subagent result: plain-text background output. The
       // after-bridge maps content → v1 `output` under tool 'task'; the