feat(coding-agent): added smart adaptive poll wait mode for job polling
- Added a smart poll-wait mode to AsyncJobManager with per-owner escalation ladder logic and a reset timer for idle pauses. - Updated job polling to use the adaptive wait when `async.pollWaitDuration` is `smart` and to record poll completion timing for subsequent waits. - Expanded async job settings and tests so `smart` is the default option and escalation, reset, and owner isolation behavior are covered.
This commit is contained in:
@@ -6,6 +6,27 @@ const DELIVERY_RETRY_JITTER_MS = 200;
|
||||
const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_MAX_RUNNING_JOBS = 15;
|
||||
|
||||
/**
|
||||
* Adaptive ("smart") `job` poll-wait ladder (ms). A tight poll loop climbs
|
||||
* these rungs so each immediate re-poll backs off and stops spending turns on
|
||||
* "still running" frames; the floor (first rung) is the shortest wait and the
|
||||
* top rung is the longest a smart poll will ever block. Only used when
|
||||
* `async.pollWaitDuration` is set to `smart`; fixed durations wait verbatim.
|
||||
*/
|
||||
const POLL_WAIT_LADDER_MS = [5_000, 10_000, 30_000, 60_000, 300_000] as const;
|
||||
/**
|
||||
* Going at least this long between poll calls means the agent stepped out of
|
||||
* the poll loop to do real work — the next poll drops back to the ladder floor.
|
||||
*/
|
||||
const POLL_ESCALATION_RESET_MS = 60_000;
|
||||
|
||||
interface PollEscalationState {
|
||||
/** Index into POLL_WAIT_LADDER_MS used for the most recent poll wait. */
|
||||
level: number;
|
||||
/** Timestamp (ms) when the most recent poll wait returned. */
|
||||
lastPollEndAt: number;
|
||||
}
|
||||
|
||||
export interface AsyncJob {
|
||||
id: string;
|
||||
type: "bash" | "task";
|
||||
@@ -96,6 +117,7 @@ export class AsyncJobManager {
|
||||
readonly #suppressedDeliveries = new Set<string>();
|
||||
readonly #watchedJobs = new Set<string>();
|
||||
readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
|
||||
readonly #pollEscalation = new Map<string | undefined, PollEscalationState>();
|
||||
readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
|
||||
readonly #maxRunningJobs: number;
|
||||
readonly #retentionMs: number;
|
||||
@@ -295,6 +317,32 @@ export class AsyncJobManager {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next adaptive ("smart") wait (ms) for a blocking `job` poll by
|
||||
* the given owner. Consecutive polls — those starting within
|
||||
* POLL_ESCALATION_RESET_MS of the previous poll returning — climb
|
||||
* POLL_WAIT_LADDER_MS so a tight wait loop backs off; a longer gap means the
|
||||
* agent left to do real work, so the wait resets to the floor. Pair each call
|
||||
* with `recordPollWaitEnd()` once the wait returns.
|
||||
*/
|
||||
nextPollWaitMs(ownerId: string | undefined, now: number = Date.now()): number {
|
||||
const prev = this.#pollEscalation.get(ownerId);
|
||||
const reset = !prev || now - prev.lastPollEndAt >= POLL_ESCALATION_RESET_MS;
|
||||
const level = reset ? 0 : Math.min(prev.level + 1, POLL_WAIT_LADDER_MS.length - 1);
|
||||
this.#pollEscalation.set(ownerId, { level, lastPollEndAt: prev?.lastPollEndAt ?? now });
|
||||
return POLL_WAIT_LADDER_MS[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a blocking poll wait as finished so the idle-reset window is measured
|
||||
* from now. Polling again before POLL_ESCALATION_RESET_MS elapses keeps
|
||||
* climbing the ladder; waiting longer resets it to the floor.
|
||||
*/
|
||||
recordPollWaitEnd(ownerId: string | undefined, now: number = Date.now()): void {
|
||||
const prev = this.#pollEscalation.get(ownerId);
|
||||
this.#pollEscalation.set(ownerId, { level: prev?.level ?? 0, lastPollEndAt: now });
|
||||
}
|
||||
|
||||
acknowledgeDeliveries(jobIds: string[]): number {
|
||||
const uniqueJobIds = Array.from(new Set(jobIds.map(id => id.trim()).filter(id => id.length > 0)));
|
||||
if (uniqueJobIds.length === 0) return 0;
|
||||
@@ -405,6 +453,7 @@ export class AsyncJobManager {
|
||||
this.#inFlightDeliveries.length = 0;
|
||||
this.#suppressedDeliveries.clear();
|
||||
this.#watchedJobs.clear();
|
||||
this.#pollEscalation.clear();
|
||||
return drained;
|
||||
}
|
||||
|
||||
|
||||
@@ -3150,19 +3150,21 @@ export const SETTINGS_SCHEMA = {
|
||||
|
||||
"async.pollWaitDuration": {
|
||||
type: "enum",
|
||||
values: ["5s", "10s", "30s", "1m", "5m"] as const,
|
||||
default: "30s",
|
||||
values: ["5s", "10s", "30s", "1m", "5m", "smart"] as const,
|
||||
default: "smart",
|
||||
ui: {
|
||||
tab: "tools",
|
||||
group: "Execution",
|
||||
label: "Poll Wait Duration",
|
||||
description: "How long the poll tool waits for background job updates before returning the current state",
|
||||
label: "Max Poll Time",
|
||||
description:
|
||||
"How long the poll tool waits for background job updates before returning the current state. A fixed value waits that exact duration every time. `smart` adapts: it starts at 5s and lengthens with each back-to-back poll (up to 5m), then resets to 5s after about a minute without polling.",
|
||||
options: [
|
||||
{ value: "5s", label: "5 seconds" },
|
||||
{ value: "10s", label: "10 seconds" },
|
||||
{ value: "30s", label: "30 seconds", description: "Default" },
|
||||
{ value: "30s", label: "30 seconds" },
|
||||
{ value: "1m", label: "1 minute" },
|
||||
{ value: "5m", label: "5 minutes" },
|
||||
{ value: "smart", label: "Smart", description: "Default — adaptive 5s→5m, resets when you stop polling" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ Block until the specified jobs finish or the wait window elapses. Omit `poll` (w
|
||||
- Use when you are genuinely blocked on a result and have no other work to do.
|
||||
- Returns the current snapshot when the timer elapses; running jobs remain running.
|
||||
- Completed jobs include their final output in the returned snapshot.
|
||||
- With Max Poll Time set to `smart` (the default), the wait window adapts: it starts at ~5s and lengthens with each back-to-back poll (up to ~5m), then resets to ~5s after you go a while without polling. Spinning in a poll loop costs progressively more; do real work between polls.
|
||||
|
||||
## `cancel: [id, …]`
|
||||
Stop running jobs.
|
||||
|
||||
@@ -184,9 +184,16 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
|
||||
return this.#buildResult(manager, [...cancelledJobs, ...jobsToWatch], cancelOutcomes);
|
||||
}
|
||||
|
||||
// Wait until at least one running job finishes, the wait duration elapses, or the call is aborted.
|
||||
// Wait until at least one running job finishes, the wait window elapses,
|
||||
// or the call is aborted. With `async.pollWaitDuration` set to `smart`,
|
||||
// the window adapts: it starts at the ladder floor and climbs as the agent
|
||||
// polls in a tight loop, then resets to the floor once the agent steps
|
||||
// away from polling (see AsyncJobManager.nextPollWaitMs). Any fixed value
|
||||
// waits that exact duration every time.
|
||||
const racePromises: Promise<unknown>[] = runningJobs.map(j => j.promise);
|
||||
const waitMs = parseWaitDurationMs(this.session.settings.get("async.pollWaitDuration"));
|
||||
const pollSetting = this.session.settings.get("async.pollWaitDuration");
|
||||
const smartPoll = pollSetting === "smart";
|
||||
const waitMs = smartPoll ? manager.nextPollWaitMs(ownerId) : parseWaitDurationMs(pollSetting);
|
||||
const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers<void>();
|
||||
const timeoutHandle = setTimeout(() => timeoutResolve(), waitMs);
|
||||
racePromises.push(timeoutPromise);
|
||||
@@ -232,6 +239,11 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
|
||||
manager.unwatchJobs(watchedJobIds);
|
||||
clearTimeout(timeoutHandle);
|
||||
if (progressTimer) clearInterval(progressTimer);
|
||||
if (smartPoll) {
|
||||
// Reset the idle-gap clock: escalate if the agent polls again soon,
|
||||
// drop back to the floor once it goes quiet for a while.
|
||||
manager.recordPollWaitEnd(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
return this.#buildResult(manager, allTrackedJobs, cancelOutcomes);
|
||||
|
||||
@@ -416,3 +416,58 @@ describe("AsyncJobManager", () => {
|
||||
expect(manager.getJob(parentJobId)?.status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AsyncJobManager smart poll-wait escalation", () => {
|
||||
const newManager = () => new AsyncJobManager({ onJobComplete: async () => {} });
|
||||
|
||||
test("first poll waits the ladder floor", () => {
|
||||
const m = newManager();
|
||||
expect(m.nextPollWaitMs("Main", 1_000)).toBe(5_000);
|
||||
// A fresh owner also starts at the floor.
|
||||
expect(m.nextPollWaitMs("Other", 1_000)).toBe(5_000);
|
||||
});
|
||||
|
||||
test("back-to-back polls climb the ladder to the top rung", () => {
|
||||
const m = newManager();
|
||||
const owner = "Main";
|
||||
const t = 1_000;
|
||||
const waits: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// Same timestamp every time → zero gap → always escalates.
|
||||
waits.push(m.nextPollWaitMs(owner, t));
|
||||
m.recordPollWaitEnd(owner, t);
|
||||
}
|
||||
// Climbs the rungs, then saturates at the top.
|
||||
expect(waits).toEqual([5_000, 10_000, 30_000, 60_000, 300_000, 300_000]);
|
||||
});
|
||||
|
||||
test("a quiet gap of a minute resets back to the floor", () => {
|
||||
const m = newManager();
|
||||
const owner = "Main";
|
||||
|
||||
expect(m.nextPollWaitMs(owner, 0)).toBe(5_000);
|
||||
m.recordPollWaitEnd(owner, 0);
|
||||
|
||||
// Still within the reset window (just under a minute) → keeps climbing.
|
||||
expect(m.nextPollWaitMs(owner, 59_999)).toBe(10_000);
|
||||
m.recordPollWaitEnd(owner, 60_000);
|
||||
|
||||
// A full minute without polling resets the climb to the floor.
|
||||
expect(m.nextPollWaitMs(owner, 120_000)).toBe(5_000);
|
||||
});
|
||||
|
||||
test("escalation is tracked independently per owner", () => {
|
||||
const m = newManager();
|
||||
const t = 1_000;
|
||||
|
||||
m.nextPollWaitMs("A", t);
|
||||
m.recordPollWaitEnd("A", t);
|
||||
m.nextPollWaitMs("A", t);
|
||||
m.recordPollWaitEnd("A", t);
|
||||
|
||||
// A fresh owner starts at the floor regardless of A's escalation.
|
||||
expect(m.nextPollWaitMs("B", t)).toBe(5_000);
|
||||
// A keeps climbing from where it left off.
|
||||
expect(m.nextPollWaitMs("A", t)).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user