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:
2026-03-12 10:56:39 +00:00
co-authored by Claude Opus 4.6
parent 92da03fcff
commit ea8113e61d
3 changed files with 56 additions and 17 deletions
+38 -16
View File
@@ -45,6 +45,7 @@ export type OutMessage =
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost } | { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
| { type: 'step:skip'; stepIndex: number; label: string; reason: string } | { type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number } | { 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:start'; stepIndex: number; label: string }
| { type: 'iteration:complete'; stepIndex: number; label: string; cost?: MessageCost } | { type: 'iteration:complete'; stepIndex: number; label: string; cost?: MessageCost }
| { type: 'iteration:error'; stepIndex: number; label: string; error: string } | { 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> { async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, model, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
const sessionId = randomUUID(); const sessionId = randomUUID();
const isClaudeCode = model.startsWith('claude-code'); 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')); if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
let cleanup: (() => void) | null = null; 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) => { const onEvent = (event: PiEvent) => {
if (abortSignal.aborted) return; if (abortSignal.aborted) return;
lastActivity = Date.now();
switch (event.type) { switch (event.type) {
case 'delta': 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 }); emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
break; break;
case 'result': case 'result':
clearInterval(abortPoll); settle(() => { cleanup?.(); resolve(event.cost); });
cleanup?.();
resolve(event.cost);
break; break;
case 'error': case 'error':
clearInterval(abortPoll); settle(() => { cleanup?.(); reject(new Error(event.message)); });
cleanup?.();
reject(new Error(event.message));
break; break;
case 'stopped': case 'stopped':
clearInterval(abortPoll); settle(() => { cleanup?.(); reject(new Error('Step was stopped')); });
cleanup?.();
reject(new Error('Step was stopped'));
break; break;
} }
}; };
// Poll for abort signal to kill the running agent // Poll for abort signal and activity timeout
const abortPoll = setInterval(() => { const abortPoll = setInterval(() => {
if (abortSignal.aborted) { if (abortSignal.aborted) {
clearInterval(abortPoll); settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); });
cleanup?.(); return;
reject(new Error('Pipeline was stopped')); }
// 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); }, 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 { try {
if (isClaudeCode) { if (isClaudeCode) {
const handle = await sendClaudeCodeStreaming({ const handle = await sendClaudeCodeStreaming({
@@ -171,9 +195,7 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
} }
} catch (err) { } catch (err) {
clearInterval(abortPoll); settle(() => { cleanup?.(); reject(err); });
cleanup?.();
reject(err);
} }
}); });
} }
@@ -690,6 +690,12 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
<span>{formatElapsed(pipeline.elapsed)}</span> <span>{formatElapsed(pipeline.elapsed)}</span>
{totalTokens > 0 && <span>{totalTokens.toLocaleString()} tok</span>} {totalTokens > 0 && <span>{totalTokens.toLocaleString()} tok</span>}
{rc.totalUSD > 0 && <span>${rc.totalUSD.toFixed(3)}</span>} {rc.totalUSD > 0 && <span>${rc.totalUSD.toFixed(3)}</span>}
{pipeline.waitingStatus && (
<span className="ml-auto flex items-center gap-1.5 text-amber-500">
<Loader2 className="h-3 w-3 animate-spin" />
Waiting for model ({formatElapsed(pipeline.waitingStatus.elapsed)})
</span>
)}
</div> </div>
)} )}
@@ -36,6 +36,7 @@ type ServerMessage =
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } } | { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string } | { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number } | { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| { jobId: string; type: 'step:waiting'; stepIndex: number; iterationLabel?: string; elapsed: number }
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string } | { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } } | { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string } | { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
@@ -64,6 +65,7 @@ export function usePipelineRunner() {
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]); const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [waitingStatus, setWaitingStatus] = useState<{ stepIndex: number; elapsed: number; iterationLabel?: string } | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const streamBufferRef = useRef(''); const streamBufferRef = useRef('');
const startTimeRef = useRef<number>(0); const startTimeRef = useRef<number>(0);
@@ -120,6 +122,7 @@ export function usePipelineRunner() {
flushStream(); flushStream();
setMessages([]); setMessages([]);
setParallelStep(null); setParallelStep(null);
setWaitingStatus(null);
inParallelRef.current = false; inParallelRef.current = false;
setCurrentStep({ setCurrentStep({
taskName: msg.taskName, taskName: msg.taskName,
@@ -130,6 +133,7 @@ export function usePipelineRunner() {
case 'step:complete': case 'step:complete':
flushStream(); flushStream();
setWaitingStatus(null);
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null); setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
if (msg.cost) addCost(msg.cost); if (msg.cost) addCost(msg.cost);
break; break;
@@ -138,6 +142,10 @@ export function usePipelineRunner() {
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]); setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break; break;
case 'step:waiting':
setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel });
break;
case 'step:parallel': case 'step:parallel':
flushStream(); flushStream();
setMessages([]); setMessages([]);
@@ -191,6 +199,7 @@ export function usePipelineRunner() {
case 'assistant:delta': case 'assistant:delta':
// Skip messages from parallel sub-agents (shown in iteration grid instead) // Skip messages from parallel sub-agents (shown in iteration grid instead)
if (inParallelRef.current && msg.iterationLabel) break; if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
streamBufferRef.current += msg.text; streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current); setStreamingText(streamBufferRef.current);
break; break;
@@ -208,6 +217,7 @@ export function usePipelineRunner() {
case 'tool:start': case 'tool:start':
if (inParallelRef.current && msg.iterationLabel) break; if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
flushStream(); flushStream();
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
@@ -302,6 +312,7 @@ export function usePipelineRunner() {
setSkippedItems([]); setSkippedItems([]);
setCurrentStep(null); setCurrentStep(null);
setParallelStep(null); setParallelStep(null);
setWaitingStatus(null);
setElapsed(0); setElapsed(0);
setJobId(null); setJobId(null);
jobIdRef.current = null; jobIdRef.current = null;
@@ -323,6 +334,6 @@ export function usePipelineRunner() {
return { return {
phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText, phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText,
totalCost, runningCost, hasError, skippedItems, elapsed, run, stop, totalCost, runningCost, hasError, skippedItems, elapsed, waitingStatus, run, stop,
}; };
} }