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:
can1357
2026-06-14 05:19:02 +02:00
parent 86245d722a
commit 529950711a
5 changed files with 126 additions and 7 deletions
@@ -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.
+14 -2
View File
@@ -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);
});
});