ADR-019: DAG Executor Top-Level Timeout Watchdog¶
Status: Accepted
Date: 2026-05-14
Implements: Sprint 1, item S1-3
Related: agentic_v2/engine/dag_executor.py, OTEL integration
Context¶
The native DAG executor already supports per-step timeouts via StepExecutor. If a single step exceeds its budget, the step is cancelled, marked FAILED, and its transitive dependents are cascade-skipped via BFS. This protects against individual misbehaving steps.
However, a workflow can hang indefinitely at the whole-workflow level through patterns that per-step timeouts alone cannot prevent:
- A step is allocated an overly generous or unbounded per-step timeout.
- Multiple steps each finish within their individual budgets but their combined chain duration exceeds what is operationally acceptable.
- An LLM provider enters a hung state that the HTTP client's own timeout does not surface within the step budget.
- Deadlock or scheduling logic bug leaves a step waiting for a dependency that was already completed but not properly signalled.
In strict production environments, unattended server processes that run indefinitely consume quota, hold session tokens, and may trigger operational alerts. A workflow-level timeout ceiling is required.
Decision¶
Add an optional timeout: float | None parameter to DAGExecutor.execute().
When timeout is not None:
- The entire scheduling loop is wrapped with
async with asyncio.timeout(timeout):(the Python 3.11+ context-manager API, notasyncio.wait_for). - On
TimeoutError: all in-flight step tasks are structurally cancelled viatask.cancel()for each runningasyncio.Task, then awaited viaasyncio.gather(..., return_exceptions=True)to prevent "Task was destroyed but it is pending" warnings. - Every step still in
RUNNINGstate is transitioned toFAILEDwitherror_type="TimeoutError"and a descriptive error message. - All transitive dependents of those failed steps are cascade-skipped via the same BFS skip propagation used for step-level failures — no dependent is left in
PENDINGorRUNNING. - The top-level OTEL span for the workflow execution receives the attribute
workflow.timeout_exceeded=Trueand a span event"workflow.timeout"carryingworkflow.timeout_seconds=<n>. The span is also markedERRORso a timed-out workflow does not render green in exported traces. DAGExecutor.execute()returns aWorkflowResultwithoverall_status=FAILEDandmetadata["timeout_exceeded"]=True(plusmetadata["timeout_seconds"]andmetadata["error"]). It does not raise an exception — the timeout is handled internally and reflected in the return value so callers can inspectresult.metadata.get("timeout_exceeded")to distinguish a timeout from an ordinary step failure.
When timeout is None (the default), behavior is unchanged. Per-step timeouts in StepExecutor remain active regardless of whether a top-level timeout is set — both can be active simultaneously and are additive, not exclusive.
Consequences¶
Positive¶
- Prevents indefinitely-running workflows at the platform level. Any workflow that cannot complete within its deadline is cleanly cancelled rather than held open until a process restart.
- Composes additively with existing per-step timeouts. Operators can set both a tight per-step budget for individual agent calls and a looser whole-workflow ceiling as a backstop.
- Structural cancellation ensures the server's event loop is not blocked after timeout — all cancelled tasks are drained, no dangling coroutines remain.
- OTEL attribute emission provides a queryable signal for timeout events in Jaeger/Tempo dashboards.
result.metadata["timeout_exceeded"]gives the route handler a reliable signal for emitting an appropriateworkflow_endevent withstatus: "timeout"rather than"failed", without requiring exception handling at the call site.
Negative¶
asyncio.timeoutwraps the scheduling loop as a whole, not individual tasks within it. Cancellation propagation to in-flight coroutines depends on their cooperativeawaitpoints; a tightly-looping synchronous step body may not cancel promptly. This is the same constraint that affects allasyncio-based cooperative cancellation.- The
timeoutparameter is passed at the call site (engine.execute(..., timeout=N)). Exposing this via the HTTP API (e.g.,execution_profile.max_duration_seconds) is a follow-on task not included in S1-3.
Alternatives Considered¶
| Alternative | Disposition |
|---|---|
| Watchdog task with explicit deadline polling | A separate asyncio.Task that periodically checks wall-clock time and calls executor.cancel() when the deadline passes. Rejected — introduces a race between the watchdog and the scheduler; asyncio.timeout provides the same semantics more cleanly as a language primitive. |
| Per-step timeout only (existing behavior) | Retained as the existing mechanism but insufficient alone. A step with an intentionally generous timeout, or a chain of many short steps, can still produce an unbounded workflow duration. The top-level watchdog is a necessary complement, not a replacement. |
Process-level SIGALRM / signal.alarm |
POSIX only; not available on Windows (a first-class target). Rejected. |
| No timeout; rely on operator process management | Rejected. Operators cannot reliably kill individual runs without killing the whole server process, which loses all in-flight run state. |
Implementation References¶
- Timeout parameter and scheduling loop:
agentic_v2/engine/dag_executor.py(DAGExecutor.execute,_run_dag,_scheduling_loop,_handle_timeout) - OTEL span attribute and event emission:
agentic_v2/engine/dag_executor.py(_handle_timeout,_mark_span_error) - Existing per-step timeout (composes with this):
agentic_v2/engine/step_executor.py
Review Cadence¶
Re-evaluate when:
- The HTTP API exposes
timeoutas a first-classexecution_profilefield (planned follow-on to S1-3). - A production incident demonstrates that cooperative cancellation is insufficient for a specific step type (may require adding cancellation checkpoints to long-running step bodies).
- The OTEL attribute names are standardised by an OpenTelemetry semantic-conventions update for workflow systems.