Skip to content

Commit f6c9c1a

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 f6c9c1a

File tree

4 files changed

+150
-48
lines changed

4 files changed

+150
-48
lines changed

dist/index.js

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,25 @@ var __getProtoOf = Object.getPrototypeOf;
33
var __defProp = Object.defineProperty;
44
var __getOwnPropNames = Object.getOwnPropertyNames;
55
var __hasOwnProp = Object.prototype.hasOwnProperty;
6-
function __accessProp(key) {
7-
return this[key];
8-
}
9-
var __toESMCache_node;
10-
var __toESMCache_esm;
116
var __toESM = (mod, isNodeMode, target) => {
12-
var canCache = mod != null && typeof mod === "object";
13-
if (canCache) {
14-
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
15-
var cached = cache.get(mod);
16-
if (cached)
17-
return cached;
18-
}
197
target = mod != null ? __create(__getProtoOf(mod)) : {};
208
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
219
for (let key of __getOwnPropNames(mod))
2210
if (!__hasOwnProp.call(to, key))
2311
__defProp(to, key, {
24-
get: __accessProp.bind(mod, key),
12+
get: () => mod[key],
2513
enumerable: true
2614
});
27-
if (canCache)
28-
cache.set(mod, to);
2915
return to;
3016
};
3117
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
32-
var __returnValue = (v) => v;
33-
function __exportSetter(name, newValue) {
34-
this[name] = __returnValue.bind(null, newValue);
35-
}
3618
var __export = (target, all) => {
3719
for (var name in all)
3820
__defProp(target, name, {
3921
get: all[name],
4022
enumerable: true,
4123
configurable: true,
42-
set: __exportSetter.bind(all, name)
24+
set: (newValue) => all[name] = () => newValue
4325
});
4426
};
4527

@@ -3465,7 +3447,7 @@ var require_constants2 = __commonJS((exports2, module2) => {
34653447
}
34663448
})();
34673449
var channel;
3468-
var structuredClone = globalThis.structuredClone ?? function structuredClone2(value, options = undefined) {
3450+
var structuredClone = globalThis.structuredClone ?? function structuredClone(value, options = undefined) {
34693451
if (arguments.length === 0) {
34703452
throw new TypeError("missing argument");
34713453
}
@@ -16390,7 +16372,7 @@ var require_undici = __commonJS((exports2, module2) => {
1639016372
module2.exports.getGlobalDispatcher = getGlobalDispatcher;
1639116373
if (util.nodeMajor > 16 || util.nodeMajor === 16 && util.nodeMinor >= 8) {
1639216374
let fetchImpl = null;
16393-
module2.exports.fetch = async function fetch2(resource) {
16375+
module2.exports.fetch = async function fetch(resource) {
1639416376
if (!fetchImpl) {
1639516377
fetchImpl = require_fetch().fetch;
1639616378
}
@@ -22726,11 +22708,11 @@ var require_github = __commonJS((exports2) => {
2272622708
});
2272722709

2272822710
// src/index.ts
22729-
var core2 = __toESM(require_core(), 1);
22730-
var github = __toESM(require_github(), 1);
22711+
var core2 = __toESM(require_core());
22712+
var github = __toESM(require_github());
2273122713

2273222714
// src/action.ts
22733-
var core = __toESM(require_core(), 1);
22715+
var core = __toESM(require_core());
2273422716

2273522717
// node_modules/zod/v3/external.js
2273622718
var exports_external = {};
@@ -26795,17 +26777,30 @@ class RealCoderClient {
2679526777
body: JSON.stringify({ input })
2679626778
});
2679726779
}
26798-
async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000) {
26780+
async waitForTaskActive(owner, taskId, logFn, timeoutMs = 120000, stableIdleMs = 30000, pollIntervalMs = 2000) {
2679926781
const startTime = Date.now();
26800-
const pollIntervalMs = 2000;
26782+
let idleSince = null;
2680126783
while (Date.now() - startTime < timeoutMs) {
2680226784
const task = await this.getTaskById(owner, taskId);
2680326785
if (task.status === "error") {
2680426786
throw new CoderAPIError(`Task entered error state while waiting for active state`, 500, task);
2680526787
}
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;
26788+
const isIdle = task.status === "active" && task.current_state?.state === "idle";
26789+
if (isIdle) {
26790+
if (idleSince === null) {
26791+
idleSince = Date.now();
26792+
}
26793+
const idleDuration = Date.now() - idleSince;
26794+
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`);
26795+
if (idleDuration >= stableIdleMs) {
26796+
return;
26797+
}
26798+
} else {
26799+
if (idleSince !== null) {
26800+
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`);
26801+
}
26802+
idleSince = null;
26803+
logFn(`waitForTaskActive: task_id: ${taskId} status: ${task.status} current_state: ${task.current_state?.state}`);
2680926804
}
2681026805
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
2681126806
}

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)