pipeline: add activity timeout and waiting indicator for local models
Agentic steps now have a 5-minute inactivity timeout (non-Claude-Code models only) so stalled local LLM requests fail with a clear error instead of hanging forever. A "Waiting for model" indicator is emitted every 10s and shown in the pipeline UI stats bar, clearing as soon as the model starts responding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ export type OutMessage =
|
||||
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
|
||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||
| { type: 'step:waiting'; stepIndex: number; iterationLabel?: string; elapsed: number }
|
||||
| { type: 'iteration:start'; stepIndex: number; label: string }
|
||||
| { type: 'iteration:complete'; stepIndex: number; label: string; cost?: MessageCost }
|
||||
| { type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||
@@ -86,6 +87,11 @@ async function refreshProxyToken(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// How long (ms) to wait with zero activity before timing out an agentic step.
|
||||
// Only applies to non-Claude-Code models (local LLMs etc).
|
||||
const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const WAITING_INTERVAL_MS = 10 * 1000; // emit "waiting" every 10s
|
||||
|
||||
async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, model, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
|
||||
const sessionId = randomUUID();
|
||||
const isClaudeCode = model.startsWith('claude-code');
|
||||
@@ -99,9 +105,21 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
let settled = false;
|
||||
let lastActivity = Date.now();
|
||||
const stepStart = Date.now();
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearInterval(abortPoll);
|
||||
clearInterval(waitingPoll);
|
||||
fn();
|
||||
};
|
||||
|
||||
const onEvent = (event: PiEvent) => {
|
||||
if (abortSignal.aborted) return;
|
||||
lastActivity = Date.now();
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta':
|
||||
@@ -117,32 +135,38 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
|
||||
break;
|
||||
case 'result':
|
||||
clearInterval(abortPoll);
|
||||
cleanup?.();
|
||||
resolve(event.cost);
|
||||
settle(() => { cleanup?.(); resolve(event.cost); });
|
||||
break;
|
||||
case 'error':
|
||||
clearInterval(abortPoll);
|
||||
cleanup?.();
|
||||
reject(new Error(event.message));
|
||||
settle(() => { cleanup?.(); reject(new Error(event.message)); });
|
||||
break;
|
||||
case 'stopped':
|
||||
clearInterval(abortPoll);
|
||||
cleanup?.();
|
||||
reject(new Error('Step was stopped'));
|
||||
settle(() => { cleanup?.(); reject(new Error('Step was stopped')); });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for abort signal to kill the running agent
|
||||
// Poll for abort signal and activity timeout
|
||||
const abortPoll = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
clearInterval(abortPoll);
|
||||
cleanup?.();
|
||||
reject(new Error('Pipeline was stopped'));
|
||||
settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); });
|
||||
return;
|
||||
}
|
||||
// Activity timeout (skip for Claude Code which has its own mechanisms)
|
||||
if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) {
|
||||
const elapsed = Math.round((Date.now() - stepStart) / 1000);
|
||||
console.error(`[pipeline] step ${stepIndex} timed out after ${elapsed}s of inactivity (session=${sessionId})`);
|
||||
settle(() => { cleanup?.(); reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`)); });
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Emit periodic "waiting" events so the frontend knows the step is alive
|
||||
const waitingPoll = setInterval(() => {
|
||||
if (settled) return;
|
||||
const elapsed = Math.round((Date.now() - stepStart) / 1000);
|
||||
emit({ type: 'step:waiting', stepIndex, iterationLabel, elapsed });
|
||||
}, WAITING_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
if (isClaudeCode) {
|
||||
const handle = await sendClaudeCodeStreaming({
|
||||
@@ -171,9 +195,7 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
clearInterval(abortPoll);
|
||||
cleanup?.();
|
||||
reject(err);
|
||||
settle(() => { cleanup?.(); reject(err); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user