diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index 58f05f61f..ebe3b6c32 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -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(); readonly #watchedJobs = new Set(); readonly #evictionTimers = new Map(); + readonly #pollEscalation = new Map(); 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; } diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index eeb26f495..8c00ee005 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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" }, ], }, }, diff --git a/packages/coding-agent/src/prompts/tools/job.md b/packages/coding-agent/src/prompts/tools/job.md index 58593cbcb..8ec4248da 100644 --- a/packages/coding-agent/src/prompts/tools/job.md +++ b/packages/coding-agent/src/prompts/tools/job.md @@ -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. diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 98ec19eb2..b7b1f40c3 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -184,9 +184,16 @@ export class JobTool implements AgentTool { 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[] = 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(); const timeoutHandle = setTimeout(() => timeoutResolve(), waitMs); racePromises.push(timeoutPromise); @@ -232,6 +239,11 @@ export class JobTool implements AgentTool { 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); diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 821496bb0..7933f6daa 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -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); + }); +});