Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
84 changes: 78 additions & 6 deletions src/coder-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();

Expand All @@ -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",
Expand All @@ -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,
Expand Down
48 changes: 37 additions & 11 deletions src/coder-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface CoderClient {
taskId: TaskId,
logFn: (msg: string) => void,
timeoutMs?: number,
stableIdleMs?: number,
pollIntervalMs?: number,
): Promise<void>;
}

Expand Down Expand Up @@ -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<void> {
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);
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,17 @@ export class MockCoderClient implements CoderClient {
taskId: TaskId,
logFn: (msg: string) => void,
timeoutMs?: number,
stableIdleMs?: number,
pollIntervalMs?: number,
): Promise<void> {
return this.mockWaitForTaskActive(owner, taskId, logFn, timeoutMs);
return this.mockWaitForTaskActive(
owner,
taskId,
logFn,
timeoutMs,
stableIdleMs,
pollIntervalMs,
);
}
}

Expand Down
Loading