소스 검색

fix(task-session-manager): deliver revive prompts with queue semantics (#1192)

* fix(task-session-manager): deliver revive prompts with queue semantics

The live-status check before promptAsync still leaves a check-then-act
window: a session can become active between the read and the send, and
on v2 hosts the default prompt delivery is steer, which injects into an
in-flight run instead of rejecting. The revive now sends with
delivery: 'queue' (the same fence orchestrator-wake uses): a raced
prompt waits for idle instead of steering an active run, and v1 keeps
its server-side assertNotBusy fence. The board lease plus the live-map
check plus queue delivery close the revive race end to end.

* test(task-session-manager): prove the v1 SDK keeps the delivery hint off the wire

Captured-fetch test against the real @opencode-ai/sdk request pipeline:
promptAsync serializes only `body` into the HTTP request, so the
client-side `delivery: 'queue` hint cannot leak into v1 requests.
Raxxoor 1 일 전
부모
커밋
6aeeedb7c4
2개의 변경된 파일50개의 추가작업 그리고 1개의 파일을 삭제
  1. 36 0
      src/tools/task-revive.test.ts
  2. 14 1
      src/tools/task-revive.ts

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

@@ -1,4 +1,5 @@
 import { afterEach, describe, expect, mock, test } from 'bun:test';
+import { createOpencodeClient } from '@opencode-ai/sdk';
 import { createRevivedRunTracker } from '../hooks/task-session-manager/revived-run-tracker';
 import { BackgroundJobBoard } from '../utils/background-job-board';
 import { createCancelTaskTool } from './cancel-task';
@@ -109,6 +110,7 @@ describe('task_revive tool', () => {
         agent: 'explorer',
         parts: [{ type: 'text', text: 'Continue the investigation' }],
       },
+      delivery: 'queue',
     });
     const call = promptAsync.mock.calls[0]?.[0] as Record<string, unknown>;
     expect(call.body).not.toHaveProperty('noReply', true);
@@ -408,4 +410,38 @@ describe('task_revive tool', () => {
       statusUncertain: false,
     });
   });
+
+  test('v1 SDK serializes only the body: the delivery hint never reaches the wire', async () => {
+    // v1 compatibility evidence for the queue-delivery fence: the hint
+    // travels as a client-side argument, and the real @opencode-ai/sdk
+    // request pipeline must serialize ONLY `body` into the HTTP request.
+    // A captured fetch observes the wire shape directly.
+    const captured = new Map<string, unknown>();
+    const client = createOpencodeClient({
+      baseUrl: 'http://127.0.0.1:1',
+      fetch: async (request: Request) => {
+        captured.set('url', request.url);
+        captured.set('body', await request.text());
+        return new Response('{}', {
+          status: 200,
+          headers: { 'Content-Type': 'application/json' },
+        });
+      },
+    });
+    await client.session.promptAsync({
+      path: { id: 'ses_1' },
+      query: { directory: '/test/project' },
+      body: { agent: 'explorer', parts: [{ type: 'text', text: 'go' }] },
+      // Extra top-level argument, exactly as task-revive sends it.
+      delivery: 'queue',
+    } as Parameters<typeof client.session.promptAsync>[0] &
+      Record<string, unknown>);
+
+    expect(captured.get('url')).toContain('/session/ses_1/prompt_async');
+    const wireBody = JSON.parse(String(captured.get('body')));
+    expect(wireBody).toEqual({
+      agent: 'explorer',
+      parts: [{ type: 'text', text: 'go' }],
+    });
+  });
 });

+ 14 - 1
src/tools/task-revive.ts

@@ -157,13 +157,26 @@ export function createTaskReviveTool(
         if (typeof session.promptAsync !== 'function') {
           throw new Error('The host session does not support promptAsync');
         }
-        const response = await session.promptAsync({
+        // Close the check-then-act window for good: a session can become
+        // active between the live-status read above and this send. On v2
+        // hosts the default prompt delivery is `steer`, which injects into
+        // an in-flight run instead of rejecting; `queue` makes the send
+        // safe (the prompt waits for idle, v1 prompt_async semantics) so a
+        // raced revive can never steer or duplicate an active run. The v1
+        // SDK ignores the extra client-side argument (not part of the HTTP
+        // request); the v2 shim threads it to the host.
+        const response = await (
+          session.promptAsync as (
+            args: Record<string, unknown>,
+          ) => Promise<unknown>
+        )({
           path: { id: current.taskID },
           query: { directory: options.input.directory },
           body: {
             agent: current.agent,
             parts: [{ type: 'text', text: prompt }],
           },
+          delivery: 'queue',
         });
         const responseError = getApiError(response);
         if (responseError !== undefined) {