diff --git a/dist/index.js b/dist/index.js index 3d81bf5..fcc3a31 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26795,17 +26795,30 @@ class RealCoderClient { body: JSON.stringify({ input }) }); } - async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000) { + async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000, stableIdleMs = 30000, pollIntervalMs = 2000) { const startTime = Date.now(); - const pollIntervalMs = 2000; + let idleSince = null; while (Date.now() - startTime < timeoutMs) { const task = await this.getTaskById(owner, taskId); if (task.status === "error") { throw new CoderAPIError(`Task entered error state while waiting for active state`, 500, task); } - logFn(`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`); - if (task.status === "active" && task.current_state && task.current_state.state === "idle") { - return; + const isIdle = task.status === "active" && task.current_state?.state === "idle"; + if (isIdle) { + if (idleSince === null) { + idleSince = Date.now(); + } + const idleDuration = Date.now() - idleSince; + logFn(`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state} idle_for: ${Math.round(idleDuration / 1000)}s/${Math.round(stableIdleMs / 1000)}s`); + if (idleDuration >= stableIdleMs) { + return; + } + } else { + if (idleSince !== null) { + logFn(`waitForTaskActive: task_id: ${taskId} idle interrupted after ${Math.round((Date.now() - idleSince) / 1000)}s (status: ${task.status} current_state: ${task.current_state?.state}), resetting idle timer`); + } + idleSince = null; + logFn(`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } diff --git a/src/coder-client.test.ts b/src/coder-client.test.ts index 6cb39c4..0d8ee85 100644 --- a/src/coder-client.test.ts +++ b/src/coder-client.test.ts @@ -300,7 +300,7 @@ describe("CoderClient", () => { }); describe("waitForTaskActive", () => { - test("returns immediately when task is already active", async () => { + test("returns after stable idle period when task is already active and idle", async () => { const readyTask: ExperimentalCoderSDKTask = { ...mockTask, status: "active", @@ -310,12 +310,15 @@ describe("CoderClient", () => { }; mockFetch.mockResolvedValue(createMockResponse(readyTask)); - expect( + // With stableIdleMs=0, should return after first idle observation. + await expect( client.waitForTaskActive( mockUser.username, mockTask.id, console.log, - 1000, + 10000, + 0, + 10, ), ).resolves.toBeUndefined(); @@ -329,7 +332,7 @@ describe("CoderClient", () => { ); }); - test("polls until task becomes active", async () => { + test("polls until task becomes active and idle", async () => { const pendingTask: ExperimentalCoderSDKTask = { ...mockTask, status: "pending", @@ -351,18 +354,87 @@ describe("CoderClient", () => { .mockResolvedValueOnce(createMockResponse(activeTask)) .mockResolvedValueOnce(createMockResponse(readyTask)); - expect( + await expect( client.waitForTaskActive( mockUser.username, mockTask.id, console.log, - 7000, + 10000, + 0, // No stable idle requirement for this test. + 10, ), ).resolves.toBeUndefined(); expect(mockFetch).toHaveBeenCalledTimes(3); }); + test("resets idle timer when state flips back to working", async () => { + const idleTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "active", + current_state: { state: "idle" }, + }; + const workingTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "active", + current_state: { state: "working" }, + }; + + // idle -> working -> idle... (stable idle reached after + // stableIdleMs elapses on the second idle stretch). + // Use mockResolvedValue for the tail so polls after the + // "once" entries keep returning idle. + mockFetch + .mockResolvedValueOnce(createMockResponse(idleTask)) // idle timer starts + .mockResolvedValueOnce(createMockResponse(workingTask)) // idle interrupted, timer reset + .mockResolvedValue(createMockResponse(idleTask)); // idle resumes, stays idle + + const logs: string[] = []; + await client.waitForTaskActive( + mockUser.username, + mockTask.id, + (msg) => logs.push(msg), + 30000, + 50, // Short stable idle for test speed. + 10, // Short poll interval. + ); + + // Must have polled at least 3 times (idle, working, idle...). + expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(3); + // Verify the idle interruption was logged. + expect(logs.some((l) => l.includes("idle interrupted"))).toBe(true); + }); + + test("requires stable idle period before returning", async () => { + // This test verifies that even with immediate idle, the function + // does NOT return until stableIdleMs has elapsed. + const idleTask: ExperimentalCoderSDKTask = { + ...mockTask, + status: "active", + current_state: { state: "idle" }, + }; + mockFetch.mockResolvedValue(createMockResponse(idleTask)); + + // Use a short stable idle so the test finishes quickly but + // still requires multiple polls. + const stableMs = 100; + const start = Date.now(); + await client.waitForTaskActive( + mockUser.username, + mockTask.id, + console.log, + 10000, + stableMs, + 10, // Short poll interval. + ); + const elapsed = Date.now() - start; + + // Must have waited at least stableMs. + expect(elapsed).toBeGreaterThanOrEqual(stableMs); + // Must have polled more than once. + expect(mockFetch.mock.calls.length).toBeGreaterThan(1); + }); + test("throws error when task enters error state", async () => { const errorTask: ExperimentalCoderSDKTask = { ...mockTask, diff --git a/src/coder-client.ts b/src/coder-client.ts index 0f26fa8..22ad29f 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -33,6 +33,8 @@ export interface CoderClient { taskId: TaskId, logFn: (msg: string) => void, timeoutMs?: number, + stableIdleMs?: number, + pollIntervalMs?: number, ): Promise; } @@ -207,16 +209,24 @@ export class RealCoderClient implements CoderClient { } /** - * waitForTaskActive polls the task status until it reaches "active" state or times out. + * waitForTaskActive polls the task status until it reaches "active" state + * with a stable idle period, or times out. + * + * The agent can momentarily report "idle" before transitioning back to + * "working" (e.g. between processing steps). To avoid sending input + * during this window, we require the task to remain active+idle for + * stableIdleMs consecutive milliseconds before returning. */ async waitForTaskActive( owner: string, taskId: TaskId, logFn: (msg: string) => void, timeoutMs = 120000, // 2 minutes default + stableIdleMs = 30000, // 30 seconds of continuous idle required + pollIntervalMs = 2000, // Poll every 2 seconds ): Promise { const startTime = Date.now(); - const pollIntervalMs = 2000; // Poll every 2 seconds + let idleSince: number | null = null; while (Date.now() - startTime < timeoutMs) { const task = await this.getTaskById(owner, taskId); @@ -228,15 +238,31 @@ export class RealCoderClient implements CoderClient { task, ); } - logFn( - `waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`, - ); - if ( - task.status === "active" && - task.current_state && - task.current_state.state === "idle" - ) { - return; + + const isIdle = + task.status === "active" && task.current_state?.state === "idle"; + + if (isIdle) { + if (idleSince === null) { + idleSince = Date.now(); + } + const idleDuration = Date.now() - idleSince; + logFn( + `waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state} idle_for: ${Math.round(idleDuration / 1000)}s/${Math.round(stableIdleMs / 1000)}s`, + ); + if (idleDuration >= stableIdleMs) { + return; + } + } else { + if (idleSince !== null) { + logFn( + `waitForTaskActive: task_id: ${taskId} idle interrupted after ${Math.round((Date.now() - idleSince) / 1000)}s (status: ${task.status} current_state: ${task.current_state?.state}), resetting idle timer`, + ); + } + idleSince = null; + logFn( + `waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`, + ); } // Wait before next poll diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 6c3cb1a..594f63e 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -189,8 +189,17 @@ export class MockCoderClient implements CoderClient { taskId: TaskId, logFn: (msg: string) => void, timeoutMs?: number, + stableIdleMs?: number, + pollIntervalMs?: number, ): Promise { - return this.mockWaitForTaskActive(owner, taskId, logFn, timeoutMs); + return this.mockWaitForTaskActive( + owner, + taskId, + logFn, + timeoutMs, + stableIdleMs, + pollIntervalMs, + ); } }