diff --git a/src/integration-tests/TaskRunner.test.ts b/src/integration-tests/TaskRunner.test.ts index 6182769b..74a70565 100644 --- a/src/integration-tests/TaskRunner.test.ts +++ b/src/integration-tests/TaskRunner.test.ts @@ -9,7 +9,6 @@ import { } from "../sdk"; import { cleanupWorkflowsAndTasks } from "./utils/cleanup"; import { waitForWorkflowStatus } from "./utils/waitForWorkflowStatus"; -import { describeForOrkesV5 } from "./utils/customJestDescribe"; describe("TaskRunner", () => { const clientPromise = orkesConductorClient(); @@ -27,87 +26,85 @@ describe("TaskRunner", () => { ); }); - describeForOrkesV5("worker example (requires update-v2)", () => { - test("worker example ", async () => { - const client = await clientPromise; - const executor = new WorkflowExecutor(client); - const taskName = `jsSdkTest-task-manager-int-test-${Date.now()}`; - const workflowName = `jsSdkTest-task-manager-int-test-wf-${Date.now()}`; + test("worker example ", async () => { + const client = await clientPromise; + const executor = new WorkflowExecutor(client); + const taskName = `jsSdkTest-task-manager-int-test-${Date.now()}`; + const workflowName = `jsSdkTest-task-manager-int-test-wf-${Date.now()}`; - const taskRunner = new TaskRunner({ - client: client, - worker: { - taskDefName: taskName, - execute: async () => { - return { - outputData: { - hello: "From your worker", - }, - status: "COMPLETED", - }; - }, - }, - options: { - pollInterval: 1000, - domain: undefined, - concurrency: 2, - workerID: "", + const taskRunner = new TaskRunner({ + client: client, + worker: { + taskDefName: taskName, + execute: async () => { + return { + outputData: { + hello: "From your worker", + }, + status: "COMPLETED", + }; }, - }); - taskRunner.startPolling(); + }, + options: { + pollInterval: 1000, + domain: undefined, + concurrency: 2, + workerID: "", + }, + }); + taskRunner.startPolling(); - expect(taskRunner.isPolling).toEqual(true); + expect(taskRunner.isPolling).toEqual(true); - await executor.registerWorkflow(true, { + await executor.registerWorkflow(true, { + name: workflowName, + version: 1, + ownerEmail: "developers@orkes.io", + tasks: [simpleTask(taskName, taskName, {})], + inputParameters: [], + outputParameters: {}, + timeoutSeconds: 0, + }); + workflowsToCleanup.push({ name: workflowName, version: 1 }); + + const { workflowId: executionId } = await executor.executeWorkflow( + { name: workflowName, version: 1, - ownerEmail: "developers@orkes.io", - tasks: [simpleTask(taskName, taskName, {})], - inputParameters: [], - outputParameters: {}, - timeoutSeconds: 0, - }); - workflowsToCleanup.push({ name: workflowName, version: 1 }); - - const { workflowId: executionId } = await executor.executeWorkflow( - { - name: workflowName, - version: 1, - }, - workflowName, - 1, - `${workflowName}-id` - ); - expect(executionId).toBeDefined(); + }, + workflowName, + 1, + `${workflowName}-id` + ); + expect(executionId).toBeDefined(); - taskRunner.updateOptions({ concurrency: 1, pollInterval: 100 }); + taskRunner.updateOptions({ concurrency: 1, pollInterval: 100 }); - expect(executionId).toBeDefined(); - if (!executionId) { - throw new Error("Execution ID is undefined"); - } + expect(executionId).toBeDefined(); + if (!executionId) { + throw new Error("Execution ID is undefined"); + } - const workflowStatus = await waitForWorkflowStatus( - executor, - executionId, - "COMPLETED" - ); + const workflowStatus = await waitForWorkflowStatus( + executor, + executionId, + "COMPLETED" + ); - const [firstTask] = workflowStatus.tasks || []; - expect(firstTask?.taskType).toEqual(taskName); - expect(workflowStatus.status).toEqual("COMPLETED"); + const [firstTask] = workflowStatus.tasks || []; + expect(firstTask?.taskType).toEqual(taskName); + expect(workflowStatus.status).toEqual("COMPLETED"); - await taskRunner.stopPolling(); + await taskRunner.stopPolling(); - expect(taskRunner.isPolling).toEqual(false); - const taskDetails = await executor.getTask(firstTask?.taskId || ""); - expect(taskDetails?.status).toEqual("COMPLETED"); + expect(taskRunner.isPolling).toEqual(false); + const taskDetails = await executor.getTask(firstTask?.taskId || ""); + expect(taskDetails?.status).toEqual("COMPLETED"); - const metadataClient = new OrkesClients(client).getMetadataClient(); - await cleanupWorkflowsAndTasks(metadataClient, { - workflows: [{ name: workflowName, version: 1 }], - tasks: [taskName], - }); - }, 120000); - }); + const metadataClient = new OrkesClients(client).getMetadataClient(); + await cleanupWorkflowsAndTasks(metadataClient, { + workflows: [{ name: workflowName, version: 1 }], + tasks: [taskName], + }); + }, 120000); }); diff --git a/src/integration-tests/WorkerRegistration.test.ts b/src/integration-tests/WorkerRegistration.test.ts index 21eeab2a..4683e4e8 100644 --- a/src/integration-tests/WorkerRegistration.test.ts +++ b/src/integration-tests/WorkerRegistration.test.ts @@ -4,6 +4,7 @@ import { MetadataClient, NonRetryableException, TaskHandler, + TaskRunner, WorkflowExecutor, clearWorkerRegistry, getRegisteredWorkers, @@ -668,4 +669,56 @@ describe("SDK Worker Registration", () => { expect(getRegisteredWorkers().length).toBe(0); }); + + describeForOrkesV5("update-v2 endpoint verification", () => { + test("SDK detects and uses /api/tasks/update-v2 on v5 server", async () => { + const client = await clientPromise; + const taskName = `sdk_test_update_v2_verify_${Date.now()}`; + const workflowName = `sdk_test_update_v2_verify_wf_${Date.now()}`; + + // Reset the static probe so this test measures a live response from the server, + // regardless of what earlier tests in this shard may have set. + (TaskRunner as unknown as { updateV2Available: boolean | null }).updateV2Available = null; + + const taskRunner = new TaskRunner({ + client, + worker: { + taskDefName: taskName, + execute: async () => ({ outputData: {}, status: "COMPLETED" }), + }, + options: { pollInterval: 100, concurrency: 1, workerID: "" }, + }); + taskRunner.startPolling(); + + await executor.registerWorkflow(true, { + name: workflowName, + version: 1, + ownerEmail: "developers@orkes.io", + tasks: [simpleTask(taskName, taskName, {})], + inputParameters: [], + outputParameters: {}, + timeoutSeconds: 0, + }); + workflowsToCleanup.push({ name: workflowName, version: 1 }); + + const executionId = await executor.startWorkflow({ + name: workflowName, + input: {}, + version: 1, + }); + + if (!executionId) throw new Error("Execution ID is undefined"); + + await waitForWorkflowStatus(executor, executionId, "COMPLETED"); + taskRunner.stopPolling(); + + // The workflow only reaches COMPLETED if a task update was accepted. + // This assertion verifies the SDK used /api/tasks/update-v2 (not the v4 + // legacy /api/tasks fallback) — if update-v2 broke silently and the SDK + // fell back, this would be false. + expect( + (TaskRunner as unknown as { updateV2Available: boolean | null }).updateV2Available + ).toBe(true); + }, 60000); + }); }); diff --git a/src/integration-tests/readme.test.ts b/src/integration-tests/readme.test.ts index 143747e2..82b466dc 100644 --- a/src/integration-tests/readme.test.ts +++ b/src/integration-tests/readme.test.ts @@ -9,7 +9,6 @@ import { } from "../sdk"; import { TaskType } from "../open-api"; import { waitForWorkflowStatus } from "./utils/waitForWorkflowStatus"; -import { describeForOrkesV5 } from "./utils/customJestDescribe"; describe("TaskManager", () => { const clientPromise = orkesConductorClient(); @@ -27,71 +26,69 @@ describe("TaskManager", () => { ); }); - describeForOrkesV5("worker example (requires update-v2)", () => { - test("worker example ", async () => { - const client = await clientPromise; - const executor = new WorkflowExecutor(client); - const workflowName = `jsSdkTest-my_first_js_wf-${Date.now()}`; - const taskName = `jsSdkTest-taskmanager-test-${Date.now()}`; - - const taskRunner = new TaskRunner({ - client: client, - worker: { - taskDefName: taskName, - execute: async () => { - return { - outputData: { - hello: "From your worker", - }, - status: "COMPLETED", - }; - }, - }, - options: { - pollInterval: 10, - domain: undefined, - concurrency: 1, - workerID: "", + test("worker example ", async () => { + const client = await clientPromise; + const executor = new WorkflowExecutor(client); + const workflowName = `jsSdkTest-my_first_js_wf-${Date.now()}`; + const taskName = `jsSdkTest-taskmanager-test-${Date.now()}`; + + const taskRunner = new TaskRunner({ + client: client, + worker: { + taskDefName: taskName, + execute: async () => { + return { + outputData: { + hello: "From your worker", + }, + status: "COMPLETED", + }; }, - }); - taskRunner.startPolling(); + }, + options: { + pollInterval: 10, + domain: undefined, + concurrency: 1, + workerID: "", + }, + }); + taskRunner.startPolling(); - await executor.registerWorkflow(true, { - name: workflowName, - version: 1, - ownerEmail: "developers@orkes.io", - tasks: [simpleTask(taskName, taskName, {})], - inputParameters: [], - outputParameters: {}, - timeoutSeconds: 0, - }); - workflowsToCleanup.push({ name: workflowName, version: 1 }); - - const executionId = await executor.startWorkflow({ - name: workflowName, - input: {}, - version: 1, - }); - - if (!executionId) { - throw new Error("Execution ID is undefined"); - } - - const workflowStatus = await waitForWorkflowStatus( - executor, - executionId, - "COMPLETED" - ); - - const [firstTask] = workflowStatus.tasks || []; - expect(firstTask?.taskType).toEqual(taskName); - expect(workflowStatus.status).toEqual("COMPLETED"); - - taskRunner.stopPolling(); - const taskDetails = await executor.getTask(firstTask?.taskId || ""); - expect(taskDetails?.status).toEqual("COMPLETED"); - }, 120000); - }); + await executor.registerWorkflow(true, { + name: workflowName, + version: 1, + ownerEmail: "developers@orkes.io", + tasks: [simpleTask(taskName, taskName, {})], + inputParameters: [], + outputParameters: {}, + timeoutSeconds: 0, + }); + workflowsToCleanup.push({ name: workflowName, version: 1 }); + + const executionId = await executor.startWorkflow({ + name: workflowName, + input: {}, + version: 1, + }); + + if (!executionId) { + throw new Error("Execution ID is undefined"); + } + + const workflowStatus = await waitForWorkflowStatus( + executor, + executionId, + "COMPLETED" + ); + + const [firstTask] = workflowStatus.tasks || []; + expect(firstTask?.taskType).toEqual(taskName); + expect(workflowStatus.status).toEqual("COMPLETED"); + + taskRunner.stopPolling(); + const taskDetails = await executor.getTask(firstTask?.taskId || ""); + expect(taskDetails?.status).toEqual("COMPLETED"); + }, 120000); test("update task example ", async () => { const client = await clientPromise; diff --git a/src/sdk/clients/worker/TaskRunner.ts b/src/sdk/clients/worker/TaskRunner.ts index 586659ab..c899a0e2 100644 --- a/src/sdk/clients/worker/TaskRunner.ts +++ b/src/sdk/clients/worker/TaskRunner.ts @@ -187,6 +187,12 @@ export class TaskRunner { } }; + /** + * Probed once per process. null = unknown, true = v2 endpoint available, + * false = legacy server (no /api/tasks/update-v2 endpoint). + */ + private static updateV2Available: boolean | null = null; + updateTaskWithRetry = async ( task: Task, taskResult: TaskResult @@ -197,16 +203,100 @@ export class TaskRunner { while (retryCount < this.maxRetries) { try { + if (process.env.CI) { + console.log( + `[TaskRunner] Submitting task result taskId=${taskResult.taskId} workflowId=${taskResult.workflowInstanceId} taskType=${this.worker.taskDefName} attempt=${retryCount + 1}/${this.maxRetries}` + ); + } const updateStart = Date.now(); - const { data: nextTask } = await TaskResource.updateTaskV2({ - client: this._client, - body: { - ...taskResult, + + if (TaskRunner.updateV2Available === false) { + // Already detected a legacy server — skip the probe, call legacy directly. + await TaskResource.updateTask({ + client: this._client, + body: { ...taskResult, workerId: workerID }, + throwOnError: true, + }); + const updateDurationMs = Date.now() - updateStart; + if (process.env.CI) { + console.log( + `[TaskRunner] Task result accepted (legacy) taskId=${taskResult.taskId} durationMs=${updateDurationMs}` + ); + } + await this.eventDispatcher.publishTaskUpdateCompleted({ + taskType: this.worker.taskDefName, + taskId: taskResult.taskId ?? "", workerId: workerID, - }, + workflowInstanceId: taskResult.workflowInstanceId, + durationMs: updateDurationMs, + timestamp: new Date(), + }); + // Legacy /api/tasks does not return a next task for chaining. + return undefined; + } + + // Try v2 endpoint (preferred: supports task chaining). + // Use throwOnError: false so we can inspect the HTTP status directly + // and fall back on 404/405 without consuming a retry slot. + const { + data: nextTask, + error, + response, + } = await TaskResource.updateTaskV2({ + client: this._client, + body: { ...taskResult, workerId: workerID }, + throwOnError: false, }); - const updateDurationMs = Date.now() - updateStart; + if (response.status === 404 || response.status === 405) { + // Endpoint absent or wrong method — switch to legacy for all future calls. + if (TaskRunner.updateV2Available === null) { + console.log( + `[TaskRunner] /api/tasks/update-v2 not available (HTTP ${response.status}), ` + + `falling back to legacy /api/tasks endpoint` + ); + TaskRunner.updateV2Available = false; + } + // Immediately fall back without counting this as a retry failure. + await TaskResource.updateTask({ + client: this._client, + body: { ...taskResult, workerId: workerID }, + throwOnError: true, + }); + const updateDurationMs = Date.now() - updateStart; + if (process.env.CI) { + console.log( + `[TaskRunner] Task result accepted (legacy) taskId=${taskResult.taskId} durationMs=${updateDurationMs}` + ); + } + await this.eventDispatcher.publishTaskUpdateCompleted({ + taskType: this.worker.taskDefName, + taskId: taskResult.taskId ?? "", + workerId: workerID, + workflowInstanceId: taskResult.workflowInstanceId, + durationMs: updateDurationMs, + timestamp: new Date(), + }); + return undefined; + } + + if (!response.ok) { + throw ( + (error as Error | undefined) ?? + new Error(`Task update failed with HTTP ${response.status}`) + ); + } + + // v2 success — record capability on first probe + if (TaskRunner.updateV2Available === null) { + TaskRunner.updateV2Available = true; + } + const updateDurationMs = Date.now() - updateStart; + if (process.env.CI) { + console.log( + `[TaskRunner] Task result accepted taskId=${taskResult.taskId} durationMs=${updateDurationMs}` + ); + } await this.eventDispatcher.publishTaskUpdateCompleted({ taskType: this.worker.taskDefName, taskId: taskResult.taskId ?? "", @@ -215,7 +305,6 @@ export class TaskRunner { durationMs: updateDurationMs, timestamp: new Date(), }); - return nextTask ?? undefined; } catch (error: unknown) { lastError = error as Error; @@ -224,6 +313,9 @@ export class TaskRunner { `Error updating task ${taskResult.taskId} on retry ${retryCount + 1}/${this.maxRetries}`, error ); + console.log( + `[TaskRunner] Task update failed taskId=${taskResult.taskId} attempt=${retryCount + 1}/${this.maxRetries} error=${(lastError as Error)?.message ?? String(error)}` + ); retryCount++; if (retryCount < this.maxRetries) { diff --git a/src/sdk/clients/worker/__tests__/TaskRunner.test.ts b/src/sdk/clients/worker/__tests__/TaskRunner.test.ts index f4704f3c..61cc8da7 100644 --- a/src/sdk/clients/worker/__tests__/TaskRunner.test.ts +++ b/src/sdk/clients/worker/__tests__/TaskRunner.test.ts @@ -22,7 +22,16 @@ import { mockLogger } from "@test-utils/mockLogger"; jest.mock("@open-api/generated", () => ({ TaskResource: { batchPoll: jest.fn(), - updateTaskV2: jest.fn(), + updateTaskV2: jest.fn().mockResolvedValue({ + data: null, + error: undefined, + response: { status: 200, ok: true }, + }), + updateTask: jest.fn().mockResolvedValue({ + data: null, + error: undefined, + response: { status: 200, ok: true }, + }), }, })); @@ -62,6 +71,9 @@ afterEach(async () => { // Wait for async operations to complete await new Promise(resolve => setTimeout(resolve, 50)); + // Reset static capability cache so each test starts with a clean probe state + (TaskRunner as unknown as { updateV2Available: boolean | null }).updateV2Available = null; + jest.clearAllMocks(); }); @@ -131,6 +143,7 @@ test("polls tasks", async () => { expect(TaskResource.updateTaskV2).toHaveBeenCalledWith({ client: mockClient, body: expected, + throwOnError: false, }); }); @@ -197,6 +210,7 @@ test("Should set the task as failed if the task has an error", async () => { }), ]), }), + throwOnError: false, }); }); @@ -262,6 +276,7 @@ describe("NonRetryableException handling", () => { }), ]), }), + throwOnError: false, }); }); }); @@ -329,7 +344,7 @@ describe("Task update retry logic", () => { // Fail first attempt, succeed on second (return null = no chained task) mockUpdateTask .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValue({ data: null } as Awaited>); + .mockResolvedValue({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -828,7 +843,7 @@ describe("Event listeners", () => { const mockUpdateTask = TaskResource.updateTaskV2 as jest.MockedFunction< typeof TaskResource.updateTaskV2 >; - mockUpdateTask.mockResolvedValue({} as Awaited>); + mockUpdateTask.mockResolvedValue({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -1077,7 +1092,7 @@ describe("Error handling", () => { typeof TaskResource.updateTaskV2 >; // Ensure updateTask succeeds for this test - mockUpdateTask.mockResolvedValue({} as Awaited>); + mockUpdateTask.mockResolvedValue({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -1217,8 +1232,8 @@ describe("Polling state", () => { (TaskResource.updateTaskV2 as jest.MockedFunction) .mockResolvedValue({ data: null, - request: {} as Request, - response: {} as Response, + error: undefined, + response: { status: 200, ok: true }, } as Awaited>); const runner = new TaskRunner(args); @@ -1278,9 +1293,9 @@ describe("V2 task chaining", () => { const mockUpdateTask = TaskResource.updateTaskV2 as jest.MockedFunction; mockUpdateTask - .mockResolvedValueOnce({ data: task2 } as Awaited>) - .mockResolvedValueOnce({ data: task3 } as Awaited>) - .mockResolvedValueOnce({ data: null } as Awaited>); + .mockResolvedValueOnce({ data: task2, error: undefined, response: { status: 200, ok: true } } as Awaited>) + .mockResolvedValueOnce({ data: task3, error: undefined, response: { status: 200, ok: true } } as Awaited>) + .mockResolvedValueOnce({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -1326,7 +1341,7 @@ describe("V2 task chaining", () => { const mockUpdateTask = TaskResource.updateTaskV2 as jest.MockedFunction; // V2 returns null both times — no chaining, must fall back to polling - mockUpdateTask.mockResolvedValue({ data: null } as Awaited>); + mockUpdateTask.mockResolvedValue({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -1379,8 +1394,8 @@ describe("V2 task chaining", () => { const mockUpdateTask = TaskResource.updateTaskV2 as jest.MockedFunction; mockUpdateTask - .mockResolvedValueOnce({ data: task2 } as Awaited>) - .mockResolvedValueOnce({ data: null } as Awaited>); + .mockResolvedValueOnce({ data: task2, error: undefined, response: { status: 200, ok: true } } as Awaited>) + .mockResolvedValueOnce({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -1433,8 +1448,8 @@ describe("V2 task chaining", () => { const mockUpdateTask = TaskResource.updateTaskV2 as jest.MockedFunction; // task-1 fails, but V2 still returns task-2 as the next task mockUpdateTask - .mockResolvedValueOnce({ data: task2 } as Awaited>) - .mockResolvedValueOnce({ data: null } as Awaited>); + .mockResolvedValueOnce({ data: task2, error: undefined, response: { status: 200, ok: true } } as Awaited>) + .mockResolvedValueOnce({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); const runner = new TaskRunner(args); activeRunners.push(runner); @@ -1449,3 +1464,142 @@ describe("V2 task chaining", () => { expect(mockUpdateTask).toHaveBeenCalledTimes(2); }); }); + +describe("v5/v4 backend detection", () => { + const makeTask = (taskId: string, workflowInstanceId = "wf-1"): Task => ({ + taskId, + workflowInstanceId, + status: "IN_PROGRESS", + inputData: {}, + }); + + const makeArgs = (mockClient: Client): RunnerArgs => ({ + worker: { + taskDefName: "test", + execute: async () => ({ outputData: {}, status: "COMPLETED" }), + }, + options: { pollInterval: 10, domain: "", concurrency: 1, workerID: "worker-id" }, + logger: mockLogger, + client: mockClient, + }); + + test("uses updateTaskV2 when server returns 200 (v5)", async () => { + const mockClient = createMockClient(); + + (TaskResource.batchPoll as jest.MockedFunction) + .mockResolvedValueOnce({ data: [makeTask("task-1")] } as Awaited>) + .mockResolvedValue({ data: [] } as Awaited>); + + // Default mock already returns 200 — no override needed + + const runner = new TaskRunner(makeArgs(mockClient)); + activeRunners.push(runner); + runner.startPolling(); + + await new Promise((r) => setTimeout(() => r(true), 200)); + runner.stopPolling(); + + expect(TaskResource.updateTaskV2).toHaveBeenCalledTimes(1); + expect(TaskResource.updateTask).not.toHaveBeenCalled(); + expect( + (TaskRunner as unknown as { updateV2Available: boolean | null }).updateV2Available + ).toBe(true); + }); + + test("falls back to updateTask when server returns 404 (v4 — endpoint absent)", async () => { + const mockClient = createMockClient(); + + (TaskResource.batchPoll as jest.MockedFunction) + .mockResolvedValueOnce({ data: [makeTask("task-1")] } as Awaited>) + .mockResolvedValue({ data: [] } as Awaited>); + + (TaskResource.updateTaskV2 as jest.MockedFunction) + .mockResolvedValue({ data: null, error: undefined, response: { status: 404, ok: false } } as Awaited>); + + const runner = new TaskRunner(makeArgs(mockClient)); + activeRunners.push(runner); + runner.startPolling(); + + await new Promise((r) => setTimeout(() => r(true), 200)); + runner.stopPolling(); + + // Probed once, then fell back + expect(TaskResource.updateTaskV2).toHaveBeenCalledTimes(1); + expect(TaskResource.updateTask).toHaveBeenCalledTimes(1); + expect( + (TaskRunner as unknown as { updateV2Available: boolean | null }).updateV2Available + ).toBe(false); + }); + + test("falls back to updateTask when server returns 405 (v4 — wrong method)", async () => { + const mockClient = createMockClient(); + + (TaskResource.batchPoll as jest.MockedFunction) + .mockResolvedValueOnce({ data: [makeTask("task-1")] } as Awaited>) + .mockResolvedValue({ data: [] } as Awaited>); + + (TaskResource.updateTaskV2 as jest.MockedFunction) + .mockResolvedValue({ data: null, error: undefined, response: { status: 405, ok: false } } as Awaited>); + + const runner = new TaskRunner(makeArgs(mockClient)); + activeRunners.push(runner); + runner.startPolling(); + + await new Promise((r) => setTimeout(() => r(true), 200)); + runner.stopPolling(); + + expect(TaskResource.updateTaskV2).toHaveBeenCalledTimes(1); + expect(TaskResource.updateTask).toHaveBeenCalledTimes(1); + expect( + (TaskRunner as unknown as { updateV2Available: boolean | null }).updateV2Available + ).toBe(false); + }); + + test("skips probe on subsequent tasks after v5 detection — uses updateTaskV2 directly", async () => { + const mockClient = createMockClient(); + + (TaskResource.batchPoll as jest.MockedFunction) + .mockResolvedValueOnce({ data: [makeTask("task-1")] } as Awaited>) + .mockResolvedValueOnce({ data: [makeTask("task-2")] } as Awaited>) + .mockResolvedValue({ data: [] } as Awaited>); + + // Explicitly reset to 200: jest.clearAllMocks() preserves mockResolvedValue overrides + // from previous tests (e.g. the 405-fallback test), so we cannot rely on the module-level + // default here. + (TaskResource.updateTaskV2 as jest.MockedFunction) + .mockResolvedValue({ data: null, error: undefined, response: { status: 200, ok: true } } as Awaited>); + + const runner = new TaskRunner(makeArgs(mockClient)); + activeRunners.push(runner); + runner.startPolling(); + + await new Promise((r) => setTimeout(() => r(true), 300)); + runner.stopPolling(); + + expect(TaskResource.updateTaskV2).toHaveBeenCalledTimes(2); + expect(TaskResource.updateTask).not.toHaveBeenCalled(); + }); + + test("skips probe on subsequent tasks after v4 detection — uses updateTask directly", async () => { + const mockClient = createMockClient(); + + (TaskResource.batchPoll as jest.MockedFunction) + .mockResolvedValueOnce({ data: [makeTask("task-1")] } as Awaited>) + .mockResolvedValueOnce({ data: [makeTask("task-2")] } as Awaited>) + .mockResolvedValue({ data: [] } as Awaited>); + + (TaskResource.updateTaskV2 as jest.MockedFunction) + .mockResolvedValue({ data: null, error: undefined, response: { status: 405, ok: false } } as Awaited>); + + const runner = new TaskRunner(makeArgs(mockClient)); + activeRunners.push(runner); + runner.startPolling(); + + await new Promise((r) => setTimeout(() => r(true), 300)); + runner.stopPolling(); + + // Probe fires once for task-1, then task-2 goes directly to updateTask + expect(TaskResource.updateTaskV2).toHaveBeenCalledTimes(1); + expect(TaskResource.updateTask).toHaveBeenCalledTimes(2); + }); +});