Files
oh-my-pi/packages/coding-agent/src/eval/idle-timeout.ts
T
can1357 76ca917bfd fix(coding-agent-eval): suspended idle timeout during delegated bridge calls
- Added timeout pause/resume control ops and helper to suspend idle timers during bridge work.
- Fixed TimeoutError on long delegated agent()/llm() calls by pausing timeout while silent.
- Updated IdleTimeout with reference-counted pauses, ignored checks while paused, and resumed fresh.
- Replaced heartbeat keepalives with timeout-control events in bridge paths and status routing.
2026-06-06 14:02:04 +02:00

99 lines
3.0 KiB
TypeScript

/**
* Watchdog for eval cell work.
*
* A cell's `timeout` bounds time while the Python kernel or JS VM is in control.
* Host-side bridge calls can {@link pause} the watchdog so delegated
* `agent()`/`parallel()`/`llm()` work is ignored completely, then {@link resume}
* starts a fresh timeout window once the runtime gets control back.
*
* The active timer self-reschedules instead of being torn down on every
* activity event, so frequent activity costs one timestamp write per event.
* Pause is reference-counted because `parallel()` can have multiple bridge calls
* in flight at once.
*/
export class IdleTimeout {
readonly #controller = new AbortController();
readonly #idleMs: number;
/** Absolute time (epoch ms) at which inactivity is considered to have expired. */
#deadlineMs: number;
#timer: NodeJS.Timeout | undefined;
#settled = false;
#pauseDepth = 0;
constructor(idleMs: number) {
this.#idleMs = Math.max(1, Math.floor(idleMs));
this.#deadlineMs = Date.now() + this.#idleMs;
this.#arm(this.#idleMs);
}
/** Aborts with a `TimeoutError` reason once the active timeout window is exhausted. */
get signal(): AbortSignal {
return this.#controller.signal;
}
/** Configured active timeout window in milliseconds. */
get idleMs(): number {
return this.#idleMs;
}
/** Record runtime activity, pushing the active deadline forward by `idleMs`. */
bump(): void {
if (this.#settled || this.#pauseDepth > 0) return;
this.#deadlineMs = Date.now() + this.#idleMs;
}
/** Suspend timeout accounting while control is delegated to host-side work. */
pause(): void {
if (this.#settled) return;
this.#pauseDepth++;
if (this.#pauseDepth !== 1) return;
if (this.#timer) {
clearTimeout(this.#timer);
this.#timer = undefined;
}
}
/** Resume timeout accounting with a fresh timeout window. */
resume(): void {
if (this.#settled || this.#pauseDepth === 0) return;
this.#pauseDepth--;
if (this.#pauseDepth > 0) return;
this.#deadlineMs = Date.now() + this.#idleMs;
this.#arm(this.#idleMs);
}
/** Stop the watchdog. Safe to call multiple times. */
dispose(): void {
if (this.#settled) return;
this.#settled = true;
if (this.#timer) {
clearTimeout(this.#timer);
this.#timer = undefined;
}
}
[Symbol.dispose](): void {
this.dispose();
}
#arm(delayMs: number): void {
const timer = setTimeout(() => this.#onExpire(), Math.max(0, delayMs));
// Never keep the event loop alive for the watchdog itself.
timer.unref?.();
this.#timer = timer;
}
#onExpire(): void {
if (this.#settled || this.#pauseDepth > 0) return;
const remainingMs = this.#deadlineMs - Date.now();
if (remainingMs > 0) {
// A bump moved the deadline forward after this timer was armed; wait
// out the remaining window instead of firing early.
this.#arm(remainingMs);
return;
}
this.#settled = true;
this.#timer = undefined;
this.#controller.abort(new DOMException(`Idle for ${Math.round(this.#idleMs / 1000)}s`, "TimeoutError"));
}
}