Skip to content

Commit 84dd3a9

Browse files
committed
fix: require 30s stable idle before sending task input
The agent can momentarily report idle between processing steps, causing sendTaskInput to hit HTTP 409 when waitForTaskActive exits on the first idle observation. Track consecutive idle duration and only return after stableIdleMs (default 30s) of uninterrupted idle. Also makes pollIntervalMs configurable so tests don't need real 2s sleeps between polls.
1 parent ebe0b7f commit 84dd3a9

4 files changed

Lines changed: 143 additions & 23 deletions

File tree

dist/index.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26795,17 +26795,30 @@ class RealCoderClient {
2679526795
body: JSON.stringify({ input })
2679626796
});
2679726797
}
26798-
async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000) {
26798+
async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000, stableIdleMs = 30000, pollIntervalMs = 2000) {
2679926799
const startTime = Date.now();
26800-
const pollIntervalMs = 2000;
26800+
let idleSince = null;
2680126801
while (Date.now() - startTime < timeoutMs) {
2680226802
const task = await this.getTaskById(owner, taskId);
2680326803
if (task.status === "error") {
2680426804
throw new CoderAPIError(`Task entered error state while waiting for active state`, 500, task);
2680526805
}
26806-
logFn(`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`);
26807-
if (task.status === "active" && task.current_state && task.current_state.state === "idle") {
26808-
return;
26806+
const isIdle = task.status === "active" && task.current_state?.state === "idle";
26807+
if (isIdle) {
26808+
if (idleSince === null) {
26809+
idleSince = Date.now();
26810+
}
26811+
const idleDuration = Date.now() - idleSince;
26812+
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`);
26813+
if (idleDuration >= stableIdleMs) {
26814+
return;
26815+
}
26816+
} else {
26817+
if (idleSince !== null) {
26818+
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`);
26819+
}
26820+
idleSince = null;
26821+
logFn(`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`);
2680926822
}
2681026823
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
2681126824
}

src/coder-client.test.ts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ describe("CoderClient", () => {
300300
});
301301

302302
describe("waitForTaskActive", () => {
303-
test("returns immediately when task is already active", async () => {
303+
test("returns after stable idle period when task is already active and idle", async () => {
304304
const readyTask: ExperimentalCoderSDKTask = {
305305
...mockTask,
306306
status: "active",
@@ -310,12 +310,15 @@ describe("CoderClient", () => {
310310
};
311311
mockFetch.mockResolvedValue(createMockResponse(readyTask));
312312

313-
expect(
313+
// With stableIdleMs=0, should return after first idle observation.
314+
await expect(
314315
client.waitForTaskActive(
315316
mockUser.username,
316317
mockTask.id,
317318
console.log,
318-
1000,
319+
10000,
320+
0,
321+
10,
319322
),
320323
).resolves.toBeUndefined();
321324

@@ -329,7 +332,7 @@ describe("CoderClient", () => {
329332
);
330333
});
331334

332-
test("polls until task becomes active", async () => {
335+
test("polls until task becomes active and idle", async () => {
333336
const pendingTask: ExperimentalCoderSDKTask = {
334337
...mockTask,
335338
status: "pending",
@@ -351,18 +354,87 @@ describe("CoderClient", () => {
351354
.mockResolvedValueOnce(createMockResponse(activeTask))
352355
.mockResolvedValueOnce(createMockResponse(readyTask));
353356

354-
expect(
357+
await expect(
355358
client.waitForTaskActive(
356359
mockUser.username,
357360
mockTask.id,
358361
console.log,
359-
7000,
362+
10000,
363+
0, // No stable idle requirement for this test.
364+
10,
360365
),
361366
).resolves.toBeUndefined();
362367

363368
expect(mockFetch).toHaveBeenCalledTimes(3);
364369
});
365370

371+
test("resets idle timer when state flips back to working", async () => {
372+
const idleTask: ExperimentalCoderSDKTask = {
373+
...mockTask,
374+
status: "active",
375+
current_state: { state: "idle" },
376+
};
377+
const workingTask: ExperimentalCoderSDKTask = {
378+
...mockTask,
379+
status: "active",
380+
current_state: { state: "working" },
381+
};
382+
383+
// idle -> working -> idle... (stable idle reached after
384+
// stableIdleMs elapses on the second idle stretch).
385+
// Use mockResolvedValue for the tail so polls after the
386+
// "once" entries keep returning idle.
387+
mockFetch
388+
.mockResolvedValueOnce(createMockResponse(idleTask)) // idle timer starts
389+
.mockResolvedValueOnce(createMockResponse(workingTask)) // idle interrupted, timer reset
390+
.mockResolvedValue(createMockResponse(idleTask)); // idle resumes, stays idle
391+
392+
const logs: string[] = [];
393+
await client.waitForTaskActive(
394+
mockUser.username,
395+
mockTask.id,
396+
(msg) => logs.push(msg),
397+
30000,
398+
50, // Short stable idle for test speed.
399+
10, // Short poll interval.
400+
);
401+
402+
// Must have polled at least 3 times (idle, working, idle...).
403+
expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(3);
404+
// Verify the idle interruption was logged.
405+
expect(logs.some((l) => l.includes("idle interrupted"))).toBe(true);
406+
});
407+
408+
test("requires stable idle period before returning", async () => {
409+
// This test verifies that even with immediate idle, the function
410+
// does NOT return until stableIdleMs has elapsed.
411+
const idleTask: ExperimentalCoderSDKTask = {
412+
...mockTask,
413+
status: "active",
414+
current_state: { state: "idle" },
415+
};
416+
mockFetch.mockResolvedValue(createMockResponse(idleTask));
417+
418+
// Use a short stable idle so the test finishes quickly but
419+
// still requires multiple polls.
420+
const stableMs = 100;
421+
const start = Date.now();
422+
await client.waitForTaskActive(
423+
mockUser.username,
424+
mockTask.id,
425+
console.log,
426+
10000,
427+
stableMs,
428+
10, // Short poll interval.
429+
);
430+
const elapsed = Date.now() - start;
431+
432+
// Must have waited at least stableMs.
433+
expect(elapsed).toBeGreaterThanOrEqual(stableMs);
434+
// Must have polled more than once.
435+
expect(mockFetch.mock.calls.length).toBeGreaterThan(1);
436+
});
437+
366438
test("throws error when task enters error state", async () => {
367439
const errorTask: ExperimentalCoderSDKTask = {
368440
...mockTask,

src/coder-client.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface CoderClient {
3333
taskId: TaskId,
3434
logFn: (msg: string) => void,
3535
timeoutMs?: number,
36+
stableIdleMs?: number,
37+
pollIntervalMs?: number,
3638
): Promise<void>;
3739
}
3840

@@ -207,16 +209,24 @@ export class RealCoderClient implements CoderClient {
207209
}
208210

209211
/**
210-
* waitForTaskActive polls the task status until it reaches "active" state or times out.
212+
* waitForTaskActive polls the task status until it reaches "active" state
213+
* with a stable idle period, or times out.
214+
*
215+
* The agent can momentarily report "idle" before transitioning back to
216+
* "working" (e.g. between processing steps). To avoid sending input
217+
* during this window, we require the task to remain active+idle for
218+
* stableIdleMs consecutive milliseconds before returning.
211219
*/
212220
async waitForTaskActive(
213221
owner: string,
214222
taskId: TaskId,
215223
logFn: (msg: string) => void,
216224
timeoutMs = 120000, // 2 minutes default
225+
stableIdleMs = 30000, // 30 seconds of continuous idle required
226+
pollIntervalMs = 2000, // Poll every 2 seconds
217227
): Promise<void> {
218228
const startTime = Date.now();
219-
const pollIntervalMs = 2000; // Poll every 2 seconds
229+
let idleSince: number | null = null;
220230

221231
while (Date.now() - startTime < timeoutMs) {
222232
const task = await this.getTaskById(owner, taskId);
@@ -228,15 +238,31 @@ export class RealCoderClient implements CoderClient {
228238
task,
229239
);
230240
}
231-
logFn(
232-
`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`,
233-
);
234-
if (
235-
task.status === "active" &&
236-
task.current_state &&
237-
task.current_state.state === "idle"
238-
) {
239-
return;
241+
242+
const isIdle =
243+
task.status === "active" && task.current_state?.state === "idle";
244+
245+
if (isIdle) {
246+
if (idleSince === null) {
247+
idleSince = Date.now();
248+
}
249+
const idleDuration = Date.now() - idleSince;
250+
logFn(
251+
`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`,
252+
);
253+
if (idleDuration >= stableIdleMs) {
254+
return;
255+
}
256+
} else {
257+
if (idleSince !== null) {
258+
logFn(
259+
`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`,
260+
);
261+
}
262+
idleSince = null;
263+
logFn(
264+
`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`,
265+
);
240266
}
241267

242268
// Wait before next poll

src/test-helpers.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,17 @@ export class MockCoderClient implements CoderClient {
189189
taskId: TaskId,
190190
logFn: (msg: string) => void,
191191
timeoutMs?: number,
192+
stableIdleMs?: number,
193+
pollIntervalMs?: number,
192194
): Promise<void> {
193-
return this.mockWaitForTaskActive(owner, taskId, logFn, timeoutMs);
195+
return this.mockWaitForTaskActive(
196+
owner,
197+
taskId,
198+
logFn,
199+
timeoutMs,
200+
stableIdleMs,
201+
pollIntervalMs,
202+
);
194203
}
195204
}
196205

0 commit comments

Comments
 (0)