Skip to content

Commit 50d8d11

Browse files
bugerclaude
andauthored
fix: replace extension capping with timeout.extended/windingDown events (#522) (#524)
Instead of capping extensions to an external hard timeout (wrong model), emit events that let the parent dynamically extend its own deadline: - `timeout.extended`: emitted when observer grants extension, includes grantedMs so parent can adjust its Promise.race/deadline - `timeout.windingDown`: emitted when observer declines, so parent knows agent is producing final answer Remove externalHardTimeout option — the parent should wait for the child, not cap it. The parent listens to events and stays in sync. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 33842ca commit 50d8d11

File tree

3 files changed

+193
-172
lines changed

3 files changed

+193
-172
lines changed

npm/src/agent/ProbeAgent.d.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,38 @@ export interface ProbeAgentOptions {
116116
negotiatedTimeoutMaxRequests?: number;
117117
/** Max ms per extension request for negotiated timeout (default: 600000 = 10 min). Env var: NEGOTIATED_TIMEOUT_MAX_PER_REQUEST */
118118
negotiatedTimeoutMaxPerRequest?: number;
119-
/** External hard timeout ceiling in ms (e.g., visor's Promise.race timeout). When set, the observer caps extensions so granted time never exceeds this ceiling. Env var: EXTERNAL_HARD_TIMEOUT */
120-
externalHardTimeout?: number | null;
119+
}
120+
121+
/**
122+
* Emitted when the negotiated timeout observer grants a time extension.
123+
* Parent processes should listen to this event and extend their own deadlines accordingly.
124+
*/
125+
export interface TimeoutExtendedEvent {
126+
/** Duration of the granted extension in milliseconds */
127+
grantedMs: number;
128+
/** Reason the observer granted the extension */
129+
reason: string;
130+
/** Number of extensions used so far */
131+
extensionsUsed: number;
132+
/** Number of extensions remaining */
133+
extensionsRemaining: number;
134+
/** Total extra time granted across all extensions in ms */
135+
totalExtraTimeMs: number;
136+
/** Remaining budget for future extensions in ms */
137+
budgetRemainingMs: number;
138+
}
139+
140+
/**
141+
* Emitted when the negotiated timeout observer declines an extension and begins wind-down.
142+
* After this event, the agent will produce its final answer and no more extensions will be granted.
143+
*/
144+
export interface TimeoutWindingDownEvent {
145+
/** Reason the observer declined the extension */
146+
reason: string;
147+
/** Number of extensions used before declining */
148+
extensionsUsed: number;
149+
/** Total extra time granted across all extensions in ms */
150+
totalExtraTimeMs: number;
121151
}
122152

123153
/**

npm/src/agent/ProbeAgent.js

Lines changed: 20 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -425,14 +425,6 @@ export class ProbeAgent {
425425
return (isNaN(parsed) || parsed < 60000 || parsed > 3600000) ? 600000 : parsed;
426426
})();
427427

428-
// External hard timeout: the caller's hard ceiling (e.g., visor's Promise.race timeout).
429-
// When set, the observer caps extensions so granted time never exceeds this ceiling,
430-
// preventing the agent from being killed mid-work with no partial results. (#522)
431-
this.externalHardTimeout = options.externalHardTimeout ?? (() => {
432-
const parsed = parseInt(process.env.EXTERNAL_HARD_TIMEOUT, 10);
433-
return (isNaN(parsed) || parsed < 0) ? null : parsed;
434-
})();
435-
436428
// Graceful stop deadline: how long to wait for subagents/MCP after observer declines (default 45s)
437429
this.gracefulStopDeadline = options.gracefulStopDeadline ?? (() => {
438430
const parsed = parseInt(process.env.GRACEFUL_STOP_DEADLINE, 10);
@@ -3712,31 +3704,6 @@ Follow these instructions carefully:
37123704
return;
37133705
}
37143706

3715-
// Check external hard timeout headroom — if the caller has a hard ceiling
3716-
// (e.g., visor's Promise.race), decline extensions when headroom is insufficient (#522)
3717-
const MINIMUM_USEFUL_HEADROOM_MS = 60000; // 60s — less than this isn't useful for an extension
3718-
if (this.externalHardTimeout) {
3719-
const elapsed = Date.now() - negotiatedTimeoutState.startTime;
3720-
const externalHeadroom = Math.max(0, this.externalHardTimeout - elapsed);
3721-
if (externalHeadroom < MINIMUM_USEFUL_HEADROOM_MS) {
3722-
if (this.debug) {
3723-
console.log(`[DEBUG] Timeout observer: external hard timeout headroom exhausted (${Math.round(externalHeadroom / 1000)}s < ${MINIMUM_USEFUL_HEADROOM_MS / 1000}s minimum) — triggering graceful wind-down`);
3724-
}
3725-
if (this.tracer) {
3726-
this.tracer.addEvent('negotiated_timeout.external_headroom_exhausted', {
3727-
external_hard_timeout_ms: this.externalHardTimeout,
3728-
elapsed_ms: elapsed,
3729-
headroom_ms: externalHeadroom,
3730-
minimum_useful_ms: MINIMUM_USEFUL_HEADROOM_MS,
3731-
extensions_used: negotiatedTimeoutState.extensionsUsed,
3732-
});
3733-
}
3734-
await this._initiateGracefulStop(gracefulTimeoutState, 'external hard timeout headroom exhausted');
3735-
negotiatedTimeoutState.observerRunning = false;
3736-
return;
3737-
}
3738-
}
3739-
37403707
// Build context for the observer
37413708
const activeToolsList = Array.from(activeTools.values());
37423709
const now = Date.now();
@@ -3843,22 +3810,7 @@ or
38433810

38443811
if (decision.extend && decision.minutes > 0) {
38453812
const requestedMs = Math.min(decision.minutes, maxPerReqMin) * 60000;
3846-
// Cap to external hard timeout headroom if set (#522)
3847-
const externalCap = this.externalHardTimeout
3848-
? Math.max(0, this.externalHardTimeout - (Date.now() - negotiatedTimeoutState.startTime))
3849-
: Infinity;
3850-
const grantedMs = Math.min(requestedMs, remainingBudgetMs, negotiatedTimeoutState.maxPerRequestMs, externalCap);
3851-
3852-
// If capped below minimum useful time, decline instead of granting a useless extension
3853-
if (grantedMs < MINIMUM_USEFUL_HEADROOM_MS) {
3854-
if (this.debug) {
3855-
console.log(`[DEBUG] Timeout observer: extension capped to ${Math.round(grantedMs / 1000)}s (below ${MINIMUM_USEFUL_HEADROOM_MS / 1000}s minimum) — declining and triggering graceful wind-down`);
3856-
}
3857-
await this._initiateGracefulStop(gracefulTimeoutState, `extension capped below minimum useful time (${Math.round(grantedMs / 1000)}s)`);
3858-
negotiatedTimeoutState.observerRunning = false;
3859-
return;
3860-
}
3861-
3813+
const grantedMs = Math.min(requestedMs, remainingBudgetMs, negotiatedTimeoutState.maxPerRequestMs);
38623814
const grantedMin = Math.round(grantedMs / 60000 * 10) / 10;
38633815

38643816
// Update state
@@ -3895,6 +3847,18 @@ or
38953847
active_tools_count: activeToolsList.length,
38963848
});
38973849
}
3850+
3851+
// Notify the parent that the agent extended its timeout (#522).
3852+
// The parent can listen to this event and extend its own deadline
3853+
// (e.g., adjust Promise.race timeout) instead of killing the agent.
3854+
this.events.emit('timeout.extended', {
3855+
grantedMs,
3856+
reason: decision.reason || 'work in progress',
3857+
extensionsUsed: negotiatedTimeoutState.extensionsUsed,
3858+
extensionsRemaining: negotiatedTimeoutState.maxRequests - negotiatedTimeoutState.extensionsUsed,
3859+
totalExtraTimeMs: negotiatedTimeoutState.totalExtraTimeMs,
3860+
budgetRemainingMs: remainingBudgetMs - grantedMs,
3861+
});
38983862
} else {
38993863
// Observer decided not to extend — two-phase graceful stop
39003864
if (this.debug) {
@@ -3911,6 +3875,13 @@ or
39113875
});
39123876
}
39133877

3878+
// Notify the parent that the agent is winding down — no more extensions (#522)
3879+
this.events.emit('timeout.windingDown', {
3880+
reason: decision.reason || 'observer declined',
3881+
extensionsUsed: negotiatedTimeoutState.extensionsUsed,
3882+
totalExtraTimeMs: negotiatedTimeoutState.totalExtraTimeMs,
3883+
});
3884+
39143885
await this._initiateGracefulStop(gracefulTimeoutState, `observer declined: ${decision.reason}`);
39153886
}
39163887
};

0 commit comments

Comments
 (0)