"""Timeout policy configuration for the idle watchdog."""
from __future__ import annotations
from dataclasses import dataclass, field
from ralph.agents.idle_watchdog._workspace_change_kind import (
DEFAULT_AGENT_WORKSPACE_CHANGE_WEIGHTS,
WorkspaceChangeKind,
)
from ralph.timeout_defaults import (
AGENT_IDLE_ACTIVITY_EVIDENCE_TTL_SECONDS,
CPU_IDLE_SECONDS,
DESCENDANT_WAIT_POLL_SECONDS,
DESCENDANT_WAIT_TIMEOUT_SECONDS,
DRAIN_WINDOW_SECONDS,
IDLE_POLL_INTERVAL_SECONDS,
LOG_GROWTH_SECONDS,
MAX_WAITING_ON_CHILD_NO_PROGRESS_SECONDS,
MAX_WAITING_ON_CHILD_SECONDS,
NO_OUTPUT_AT_START_SECONDS,
NO_PROGRESS_QUIET_HEARTBEAT_CEILING_SECONDS,
NO_PROGRESS_QUIET_MINIMUM_INVOCATION_SECONDS,
NO_PROGRESS_QUIET_SECONDS,
NO_PROGRESS_QUIET_STRICTLY_STUCK_SECONDS,
OS_DESCENDANT_ONLY_CEILING_SECONDS,
OS_DESCENDANT_ONLY_SUSPECT_SECONDS,
PARENT_EXIT_GRACE_SECONDS,
POST_TOOL_RESULT_PROGRESSION_SECONDS,
PROCESS_EXIT_WAIT_SECONDS,
PROCESS_MONITOR_ENABLED,
REPEATED_ERROR_CONSECUTIVE_THRESHOLD,
REPEATED_ERROR_WINDOW_COUNT,
REPEATED_ERROR_WINDOW_SECONDS,
SILENT_SUBAGENT_SECONDS,
STUCK_JOB_SUB_CEILING_SECONDS,
SUBAGENT_OUTPUT_CAPTURE_ENABLED,
SUBAGENT_OUTPUT_POLL_INTERVAL_SECONDS,
SUSPECT_WAITING_ON_CHILD_SECONDS,
WAITING_STATUS_INTERVAL_SECONDS,
WATCHDOG_LOG_THROTTLE_SECONDS,
WATCHDOG_SUBAGENT_PROGRESS_INTERVAL_SECONDS,
)
_VALID_WORKSPACE_CHANGE_WEIGHT_KEYS: frozenset[str] = frozenset(
{kind.value for kind in WorkspaceChangeKind}
)
_VALID_WORKSPACE_CHANGE_WEIGHT_VALUES: frozenset[float] = frozenset({0.0, 1.0})
[docs]
@dataclass(frozen=True)
class TimeoutPolicy:
"""Consolidated timeout configuration for all agent timeout dimensions.
All timeout constants that previously appeared as module-level magic numbers
in invoke.py are consolidated here so a single config-built TimeoutPolicy
governs every timeout decision.
Precedence of fire conditions (in evaluation order):
1. SESSION_CEILING_EXCEEDED — absolute wall-clock cap; activity cannot reset it.
2. CHILDREN_PERSIST_TOO_LONG — cumulative WAITING_ON_CHILD ceiling; this is an
absolute ceiling across the session and never decays. Checked before
NO_OUTPUT_DEADLINE so the cumulative ceiling fires even while
non-stdout activity channels are still fresh.
3. NO_OUTPUT_DEADLINE (+ drain window) — idle deadline since last output.
When ``activity_evidence_ttl_seconds`` is set and any non-stdout
evidence channel (MCP tool call, subagent work, workspace file
change) is fresher than that TTL, the fire is deferred and the
watchdog returns CONTINUE. The SESSION_CEILING and
CHILDREN_PERSIST_TOO_LONG checks run before this so absolute
ceilings remain absolute.
4. PROCESS_EXIT_HANG — subprocess closed stdout but did not exit within budget.
5. DESCENDANT_HANG — descendant-wait deadline elapsed with persistent WAITING_ON_CHILD
(post-exit only, owned by PostExitWatchdog).
Suspicion is purely informational and does NOT affect any fire condition. The
``suspect_waiting_on_child_seconds`` threshold exists only to emit an elevated
warning event before the hard stop; crossing it never shortens the hard-stop
ceiling.
Attributes:
idle_timeout_seconds: Maximum seconds without output before watchdog may fire.
None disables the idle-timeout watchdog entirely.
drain_window_seconds: After a potential timeout, the watchdog enters a drain
window of this duration to allow late output to flush.
max_waiting_on_child_seconds: Hard cumulative ceiling on time spent in
WAITING_ON_CHILD state across the entire session. Activity cannot decay
or reset it; once exceeded, fires CHILDREN_PERSIST_TOO_LONG even while
children are still alive.
max_session_seconds: Absolute wall-clock ceiling for the entire session.
Activity cannot reset this ceiling. None means no ceiling (opt-in).
When set, must be >= idle_timeout_seconds.
idle_poll_interval_seconds: How often the read loop polls for new lines.
Values < 0.01s are intended for tests only.
parent_exit_grace_seconds: Grace window after parent rc=0 exit during which
we poll for late completion signals or appearing children.
descendant_wait_timeout_seconds: Maximum time to wait for descendant processes
to finish before declaring failure.
descendant_wait_poll_seconds: Poll interval for descendant-wait and
process-exit-wait loops. Values < 0.01s are intended for tests only.
process_exit_wait_seconds: Maximum time to wait for a subprocess to exit after
its stdout closes. Prevents hanging on subprocesses that close stdout but
never call exit().
waiting_status_interval_seconds: How often to emit a PROGRESS status event
while WAITING_ON_CHILD deferral is active. Controls only the status
emission cadence; does NOT affect timeout safety or ceiling math.
suspect_waiting_on_child_seconds: Cumulative WAITING time after which a
SUSPECTED_FROZEN event is emitted. Purely informational — does NOT
shorten the hard-stop ceiling or change the watchdog verdict.
Must be strictly less than max_waiting_on_child_seconds when set.
None disables suspicion events.
max_waiting_on_child_no_progress_seconds: Hard ceiling on cumulative
WAITING_ON_CHILD time when corroboration shows the child is alive but
not making progress (e.g., heartbeat-only, stale-label, or OS-descendant-only
evidence). When set, must be <= max_waiting_on_child_seconds. When None,
the no-progress ceiling is disabled and max_waiting_on_child_seconds is
used for all WAITING_ON_CHILD states.
post_tool_result_progression_seconds: When set, the watchdog fires
STALLED_AFTER_TOOL_RESULT if no follow-up STREAM_DELTA / OUTPUT_LINE
activity arrives within this many seconds of a TOOL_RESULT activity.
This is a NEW BEHAVIOR for direct wedge detection: pre-fix, the
watchdog only fired NO_OUTPUT_DEADLINE at the full
idle_timeout_seconds deadline, which meant a post-tool-result
wedge was detected in ~300s (the default idle timeout) rather
than ~120s (the default post-tool-result budget). When None,
the legacy NO_OUTPUT_DEADLINE-only behavior is preserved.
Must be > 0 when set.
"""
idle_timeout_seconds: float | None
drain_window_seconds: float = DRAIN_WINDOW_SECONDS
max_waiting_on_child_seconds: float = MAX_WAITING_ON_CHILD_SECONDS
max_session_seconds: float | None = None
idle_poll_interval_seconds: float = IDLE_POLL_INTERVAL_SECONDS
parent_exit_grace_seconds: float = PARENT_EXIT_GRACE_SECONDS
descendant_wait_timeout_seconds: float = DESCENDANT_WAIT_TIMEOUT_SECONDS
descendant_wait_poll_seconds: float = DESCENDANT_WAIT_POLL_SECONDS
process_exit_wait_seconds: float = PROCESS_EXIT_WAIT_SECONDS
waiting_status_interval_seconds: float = WAITING_STATUS_INTERVAL_SECONDS
suspect_waiting_on_child_seconds: float | None = SUSPECT_WAITING_ON_CHILD_SECONDS
max_waiting_on_child_no_progress_seconds: float | None = (
MAX_WAITING_ON_CHILD_NO_PROGRESS_SECONDS
)
no_progress_quiet_seconds: float | None = NO_PROGRESS_QUIET_SECONDS
# Dumb-kill floor for NO_PROGRESS_QUIET: the watchdog cannot fire this
# reason within the first ``no_progress_quiet_minimum_invocation_seconds``
# of an agent run, so a recently-launched agent that is doing real
# thinking work (planning, exploration, dispatching subagents) but has
# not yet produced first-party activity evidence is not killed. Must be
# > 0 when set (zero is rejected by the validator). When None, the
# floor is disabled (not recommended). The default of 120.0s matches
# the OS_DESCENDANT_ONLY_CEILING default. SESSION_CEILING_EXCEEDED
# is unaffected (operator-set hard cap).
no_progress_quiet_minimum_invocation_seconds: float | None = (
NO_PROGRESS_QUIET_MINIMUM_INVOCATION_SECONDS
)
# When set, the watchdog fires NO_OUTPUT_AT_START if the agent has been alive
# for this many seconds with zero recorded activity (no stdout, no tool call,
# no file change, no subagent output). Fires BEFORE the 600s cumulative
# no-progress ceiling. Set to None to opt out and restore the legacy
# CHILDREN_PERSIST_TOO_LONG-only behavior. Must be > 0 when set and strictly
# less than max_waiting_on_child_no_progress_seconds when both are set.
no_output_at_start_seconds: float | None = NO_OUTPUT_AT_START_SECONDS
# When set, the watchdog fires STALLED_AFTER_TOOL_RESULT if no
# follow-up STREAM_DELTA/OUTPUT_LINE activity arrives within this
# many seconds of a TOOL_RESULT activity. When None, the legacy
# NO_OUTPUT_DEADLINE-only behavior is preserved (the wedge is
# detected only at the full idle_timeout_seconds deadline). The
# default of 120.0s is generous enough to cover the typical 60s
# 95th-percentile tool-result-to-output-line latency in
# production while still detecting the wedge in ~120s rather
# than waiting for the 300s default.
post_tool_result_progression_seconds: float | None = POST_TOOL_RESULT_PROGRESSION_SECONDS
# Repeated-error circuit breaker thresholds. The watchdog fires
# REPEATED_ERROR_LOOP when an agent re-emits the same error fingerprint
# either ``repeated_error_consecutive_threshold`` times in a row with no
# intervening forward progress, or ``repeated_error_window_count`` times
# within ``repeated_error_window_seconds``. Each rule is independently
# disablable by setting its threshold to None.
repeated_error_consecutive_threshold: int | None = REPEATED_ERROR_CONSECUTIVE_THRESHOLD
repeated_error_window_count: int | None = REPEATED_ERROR_WINDOW_COUNT
repeated_error_window_seconds: float | None = REPEATED_ERROR_WINDOW_SECONDS
# Per-channel activity evidence TTL (seconds). When set, the watchdog
# defers a NO_OUTPUT_DEADLINE fire (returning CONTINUE) while ANY
# non-stdout channel is fresher than this TTL. Each non-stdout channel
# (MCP tool call, subagent work, workspace file change) records its own
# last_at timestamp; the watchdog computes the channel age relative to
# the injected clock and returns CONTINUE when at least one channel
# age is below the TTL. The default of 30.0s is well under the 300s
# idle-timeout default, so a silent subagent (or silent MCP path) is
# detected at the regular idle deadline once its own channel goes
# stale. The SESSION_CEILING and CHILDREN_PERSIST_TOO_LONG ceilings are
# checked BEFORE this deferral, so they remain absolute. Setting this
# to 0.0 disables the activity-aware verdict and restores the
# legacy stdout-only NO_OUTPUT_DEADLINE behavior. Must be >= 0 when
# set. The operator-facing config surface exposes this as a
# non-negative float (0.0 disables). TimeoutPolicy also accepts None
# internally for test callers and layered defaults; None is treated the
# same as 0.0 (disabled).
activity_evidence_ttl_seconds: float | None = AGENT_IDLE_ACTIVITY_EVIDENCE_TTL_SECONDS
# Staleness threshold (seconds) for the SILENT_SUBAGENT diagnostic.
# The watchdog's StuckClassifier returns ``StuckKind.SILENT_SUBAGENT``
# when the subagent channel has evidence (count >= 1) but the most
# recent signal is older than this threshold AND no first-party /
# side-channel activity is fresh AND classify_quiet is ACTIVE. The
# SILENT_SUBAGENT kind is a post-mortem label parallel to
# DEFERRED_BY_STUCK_CLASSIFIER; it tells operators "a subagent was
# dispatched but went silent for >180s". The default of 180.0s is
# deliberately looser than ``activity_evidence_ttl_seconds`` (30.0s)
# so the diagnostic surfaces once the short-term deferral gate has
# expired but before the 600s no-progress ceiling. Set to None to
# disable the diagnostic. When set, must be > 0.
silent_subagent_seconds: float | None = SILENT_SUBAGENT_SECONDS
# Per-kind workspace file-change weights. Each value is BINARY:
# weight==0.0 means the change is dropped (does not defer the
# NO_OUTPUT_DEADLINE verdict); weight==1.0 means the change
# counts as full activity. Intermediate values are rejected
# by ``_validate_workspace_change_weights``; the binary-only
# semantics are reserved for a future fractional-TTL feature.
# The default policy is conservative: only source-code
# changes count. Operators can opt kinds in (e.g.
# ``{"source": 1.0, "log": 1.0}``) to restore the legacy
# ``every file change counts`` behavior on a per-kind basis.
workspace_change_weights: dict[str, float] | None = field(
default_factory=lambda: dict(DEFAULT_AGENT_WORKSPACE_CHANGE_WEIGHTS)
)
# Process monitor and subagent output capture controls. These are
# feature flags that let operators disable the new evidence channels
# when the agent CLI does not expose observable subagent output.
process_monitor_enabled: bool = PROCESS_MONITOR_ENABLED
subagent_output_capture_enabled: bool = SUBAGENT_OUTPUT_CAPTURE_ENABLED
subagent_output_poll_interval_seconds: float = SUBAGENT_OUTPUT_POLL_INTERVAL_SECONDS
# Short ceiling on cumulative WAITING_ON_CHILD time when alive_by is
# OS_DESCENDANT_ONLY_STALE_PROGRESS and no probe override has fired.
# When set, must be <= max_waiting_on_child_seconds. None disables
# the override and falls back to max_waiting_on_child_no_progress_seconds.
os_descendant_only_ceiling_seconds: float | None = OS_DESCENDANT_ONLY_CEILING_SECONDS
# Earlier SUSPECTED_FROZEN threshold when alive_by is OS_DESCENDANT_ONLY_STALE_PROGRESS.
# The watchdog fires suspect at min(suspect_waiting_on_child_seconds, this value).
# When set, must be < os_descendant_only_ceiling_seconds and
# < max_waiting_on_child_seconds. None disables the override.
os_descendant_only_suspect_seconds: float | None = OS_DESCENDANT_ONLY_SUSPECT_SECONDS
# A known descendant PID with 0 user+system CPU time over this window is reported
# as alive_by=CPU_IDLE_WHILE_ALIVE by the read-loop corroborator. The override
# short-circuits the OS-descendant-only ceiling and falls back to the no-progress
# ceiling. None disables the CPU probe.
cpu_idle_seconds: float | None = CPU_IDLE_SECONDS
# The per-run .agent/raw/{safe_id}.log file is reported as alive_by=LOG_STALE_WHILE_ALIVE
# when its size has not grown for this many seconds. The override short-circuits
# the OS-descendant-only ceiling and falls back to the no-progress ceiling.
# None disables the log-growth probe; the probe no-ops when the file is absent.
log_growth_seconds: float | None = LOG_GROWTH_SECONDS
# STRICTLY_STUCK ceiling: orthogonal to ``no_progress_quiet_seconds``, the
# watchdog fires ``WatchdogFireReason.STRICTLY_STUCK`` when the corroborator
# reports a stuck-but-alive state (``alive_by`` in
# ``{OS_DESCENDANT_ONLY_STALE_PROGRESS, CPU_IDLE_WHILE_ALIVE,
# LOG_STALE_WHILE_ALIVE}``) AND no first-party channel is fresh for this
# many seconds. Set to None to disable. When set, the value MUST be > 0
# AND <= ``no_progress_quiet_seconds`` (the strictly-stuck ceiling is a
# SHORTER, ORTHOGONAL ceiling -- if it were equal or longer it would be
# redundant with the standard no-progress ceiling).
no_progress_quiet_strictly_stuck_seconds: float | None = (
NO_PROGRESS_QUIET_STRICTLY_STUCK_SECONDS
)
# Heartbeat-only ceiling: fires ``NO_PROGRESS_QUIET`` when a
# heartbeat-only subagent (``AliveBy.FRESH_HEARTBEAT_ONLY`` -- alive
# per the corroborator but no first-party progress) has been alive for
# this many seconds. Without this ceiling, a heartbeat-only subagent
# would bypass ``NO_PROGRESS_QUIET`` (which requires ``alive_by is
# None``) AND ``STRICTLY_STUCK`` (which requires a stale alive_by) and
# only trip the cumulative 600s ``CHILDREN_PERSIST_TOO_LONG`` ceiling
# -- too late for a heartbeat-only subagent that emits heartbeats but
# no real work. Must be > 0 when set and <=
# ``no_progress_quiet_seconds`` when both are set (the heartbeat-only
# ceiling is a SHORTER, ORTHOGONAL ceiling that fires BEFORE the
# dumb-kill ``NO_PROGRESS_QUIET`` ceiling; if the heartbeat ceiling
# were longer than the outer ceiling the heartbeat branch would
# never fire and the operator's intent would be silently
# defeated). When ``None``, the heartbeat-only ceiling is disabled
# and the watchdog falls back to the cumulative
# ``CHILDREN_PERSIST_TOO_LONG`` ceiling. The 240s default equals
# ``no_progress_quiet_seconds`` so the heartbeat-only branch fires
# AT the dumb-kill ceiling in the default configuration; operators
# can RAISE ``no_progress_quiet_seconds`` to give heartbeat-only
# subagents more headroom, or LOWER
# ``no_progress_quiet_heartbeat_ceiling_seconds`` to fire the
# heartbeat branch EARLIER (must remain <= ``no_progress_quiet_seconds``
# per the cross-field validator). The 240s default still tolerates
# long-running legitimate work that emits heartbeats but no first-party
# progress (e.g. multi-step exploration, dispatching subagents).
no_progress_quiet_heartbeat_ceiling_seconds: float | None = (
NO_PROGRESS_QUIET_HEARTBEAT_CEILING_SECONDS
)
# Per-(fire_reason, deferred_kind) log throttle in seconds for the
# ``_gate_fire`` DEBUG emissions. The PROMPT log showed ~10 DEBUG
# records/sec while a fire was deferred (SILENT_SUBAGENT or generic
# non-STUCK kind); that per-tick emission is log spam. The throttle
# keeps emissions to at most one per ``(fire_reason, deferred_kind)``
# key per ``watchdog_log_throttle_seconds``. Must be > 0.
watchdog_log_throttle_seconds: float = WATCHDOG_LOG_THROTTLE_SECONDS
# Cadence (seconds) for the SUBAGENT_PROGRESS waiting-status event.
# The watchdog emits a SUBAGENT_PROGRESS event at most once per this
# many seconds while WAITING_ON_CHILD deferral is active. The 30s
# default matches the existing PROGRESS cadence so the new event does
# not introduce additional churn. Must be > 0.
watchdog_subagent_progress_interval_seconds: float = (
WATCHDOG_SUBAGENT_PROGRESS_INTERVAL_SECONDS
)
# Stuck-job sub-ceiling. When set, the watchdog fires
# ``CHILDREN_PERSIST_TOO_LONG`` when the cumulative ``WAITING_ON_CHILD``
# time exceeds this value AND the corroborator reports a STALE alive_by
# (``OS_DESCENDANT_ONLY_STALE_PROGRESS`` / ``CPU_IDLE_WHILE_ALIVE`` /
# ``LOG_STALE_WHILE_ALIVE`` / ``STALE_LABEL_ONLY``). This is a SHORTER,
# ORTHOGONAL ceiling that kills a "stuck-but-alive" job well before the
# full ``max_waiting_on_child_seconds`` cumulative ceiling fires. Pre-fix,
# the gate could climb to 2365s without firing because ``classify_stuck``
# never returned STUCK while the corroborator reported a stale alive_by
# (the OS-descendant / CPU-idle / log-stale overrides short-circuited the
# no-progress ceiling via ``max_waiting_on_child_no_progress_seconds``
# which is itself wider than this sub-ceiling). The 600s default is half
# the cumulative ceiling and tolerates the typical "I would have noticed
# if the agent was alive" run budget. When None, the sub-ceiling is
# disabled and the legacy ``max_waiting_on_child_no_progress_seconds``
# behavior is preserved. Must be > 0 and <= ``max_waiting_on_child_seconds``
# when set.
stuck_job_sub_ceiling_seconds: float | None = STUCK_JOB_SUB_CEILING_SECONDS
def __post_init__(self) -> None:
self._validate_idle_fields()
self._validate_session_and_poll_fields()
self._validate_waiting_status_fields()
self._validate_no_progress_quiet_seconds()
self._validate_no_progress_quiet_minimum_invocation_seconds()
self._validate_no_output_at_start_seconds()
self._validate_post_tool_result_progression()
self._validate_repeated_error_fields()
self._validate_activity_evidence_ttl()
self._validate_silent_subagent_seconds()
self._validate_workspace_change_weights()
self._validate_subagent_output_poll_interval()
self._validate_os_descendant_only_fields()
self._validate_strictly_stuck_seconds()
self._validate_no_progress_quiet_heartbeat_ceiling_seconds()
self._validate_watchdog_log_throttle_seconds()
self._validate_watchdog_subagent_progress_interval_seconds()
self._validate_stuck_job_sub_ceiling_seconds()
def _validate_idle_fields(self) -> None:
if self.idle_timeout_seconds is not None and self.idle_timeout_seconds <= 0:
msg = "idle_timeout_seconds must be positive"
raise ValueError(msg)
if self.drain_window_seconds < 0:
msg = "drain_window_seconds must be >= 0"
raise ValueError(msg)
if (
self.idle_timeout_seconds is not None
and self.max_waiting_on_child_seconds < self.idle_timeout_seconds
):
msg = "max_waiting_on_child_seconds must be >= idle_timeout_seconds when both set"
raise ValueError(msg)
def _validate_session_and_poll_fields(self) -> None:
if self.max_session_seconds is not None and self.max_session_seconds <= 0:
msg = "max_session_seconds must be positive"
raise ValueError(msg)
if (
self.max_session_seconds is not None
and self.idle_timeout_seconds is not None
and self.max_session_seconds < self.idle_timeout_seconds
):
msg = "max_session_seconds must be >= idle_timeout_seconds"
raise ValueError(msg)
if self.idle_poll_interval_seconds <= 0:
msg = "idle_poll_interval_seconds must be positive"
raise ValueError(msg)
if self.parent_exit_grace_seconds < 0:
msg = "parent_exit_grace_seconds must be >= 0"
raise ValueError(msg)
if self.descendant_wait_timeout_seconds < 0:
msg = "descendant_wait_timeout_seconds must be >= 0"
raise ValueError(msg)
if self.descendant_wait_poll_seconds <= 0:
msg = "descendant_wait_poll_seconds must be positive"
raise ValueError(msg)
if self.process_exit_wait_seconds < 0:
msg = "process_exit_wait_seconds must be >= 0"
raise ValueError(msg)
def _validate_waiting_status_fields(self) -> None:
if self.waiting_status_interval_seconds <= 0:
msg = "waiting_status_interval_seconds must be positive"
raise ValueError(msg)
if self.suspect_waiting_on_child_seconds is not None:
if self.suspect_waiting_on_child_seconds <= 0:
msg = "suspect_waiting_on_child_seconds must be positive"
raise ValueError(msg)
if self.suspect_waiting_on_child_seconds >= self.max_waiting_on_child_seconds:
msg = (
"suspect_waiting_on_child_seconds must be strictly less than"
" max_waiting_on_child_seconds"
)
raise ValueError(msg)
if self.max_waiting_on_child_no_progress_seconds is not None:
if self.max_waiting_on_child_no_progress_seconds <= 0:
msg = "max_waiting_on_child_no_progress_seconds must be positive"
raise ValueError(msg)
if self.max_waiting_on_child_no_progress_seconds > self.max_waiting_on_child_seconds:
msg = (
"max_waiting_on_child_no_progress_seconds must be <="
" max_waiting_on_child_seconds"
)
raise ValueError(msg)
def _validate_no_progress_quiet_seconds(self) -> None:
if self.no_progress_quiet_seconds is None:
return
if self.no_progress_quiet_seconds <= 0:
msg = "no_progress_quiet_seconds must be positive when set"
raise ValueError(msg)
limit = self.max_waiting_on_child_no_progress_seconds
if limit is not None and self.no_progress_quiet_seconds > limit:
msg = "no_progress_quiet_seconds must be <= max_waiting_on_child_no_progress_seconds"
raise ValueError(msg)
def _validate_no_progress_quiet_minimum_invocation_seconds(self) -> None:
"""Validate the dumb-kill floor for NO_PROGRESS_QUIET.
The floor prevents the watchdog from killing a recently-launched
agent that is doing real work (planning, exploration, dispatching
subagents) but has not yet produced first-party activity evidence.
When set, the field MUST be > 0 (zero is rejected). When None,
the floor is disabled. When both the floor and
``no_progress_quiet_seconds`` are set, the floor MUST be <= the
ceiling (the floor is a sub-window of the ceiling).
"""
if self.no_progress_quiet_minimum_invocation_seconds is None:
return
if self.no_progress_quiet_minimum_invocation_seconds <= 0:
msg = "no_progress_quiet_minimum_invocation_seconds must be positive when set"
raise ValueError(msg)
if (
self.no_progress_quiet_seconds is not None
and self.no_progress_quiet_minimum_invocation_seconds > self.no_progress_quiet_seconds
):
msg = (
"no_progress_quiet_minimum_invocation_seconds must be <= no_progress_quiet_seconds"
)
raise ValueError(msg)
def _validate_no_output_at_start_seconds(self) -> None:
if self.no_output_at_start_seconds is None:
return
if self.no_output_at_start_seconds <= 0:
msg = "no_output_at_start_seconds must be positive when set"
raise ValueError(msg)
limit = self.max_waiting_on_child_no_progress_seconds
if limit is not None and self.no_output_at_start_seconds >= limit:
if self.no_output_at_start_seconds == NO_OUTPUT_AT_START_SECONDS:
object.__setattr__(self, "no_output_at_start_seconds", None)
return
msg = (
"no_output_at_start_seconds must be strictly less than "
"max_waiting_on_child_no_progress_seconds"
)
raise ValueError(msg)
def _validate_post_tool_result_progression(self) -> None:
if self.post_tool_result_progression_seconds is None:
return
if not self.post_tool_result_progression_seconds > 0:
msg = "post_tool_result_progression_seconds must be positive when set"
raise ValueError(msg)
def _validate_repeated_error_fields(self) -> None:
if (
self.repeated_error_consecutive_threshold is not None
and self.repeated_error_consecutive_threshold <= 0
):
msg = "repeated_error_consecutive_threshold must be positive when set"
raise ValueError(msg)
if self.repeated_error_window_count is not None and self.repeated_error_window_count <= 0:
msg = "repeated_error_window_count must be positive when set"
raise ValueError(msg)
if (
self.repeated_error_window_seconds is not None
and self.repeated_error_window_seconds <= 0
):
msg = "repeated_error_window_seconds must be positive when set"
raise ValueError(msg)
def _validate_activity_evidence_ttl(self) -> None:
if self.activity_evidence_ttl_seconds is None:
return
if self.activity_evidence_ttl_seconds < 0:
msg = "activity_evidence_ttl_seconds must be >= 0 when set"
raise ValueError(msg)
def _validate_silent_subagent_seconds(self) -> None:
if self.silent_subagent_seconds is None:
return
if self.silent_subagent_seconds <= 0:
msg = "silent_subagent_seconds must be > 0 when set"
raise ValueError(msg)
def _validate_workspace_change_weights(self) -> None:
if self.workspace_change_weights is None:
return
for key, value in self.workspace_change_weights.items():
if key not in _VALID_WORKSPACE_CHANGE_WEIGHT_KEYS:
msg = (
f"workspace_change_weights[{key!r}] is not a valid"
f" WorkspaceChangeKind value; allowed:"
f" {sorted(_VALID_WORKSPACE_CHANGE_WEIGHT_KEYS)}"
)
raise ValueError(msg)
if value not in _VALID_WORKSPACE_CHANGE_WEIGHT_VALUES:
msg = (
f"workspace_change_weights[{key!r}]={value!r}"
f" is not a binary weight; allowed: {{0.0, 1.0}}"
)
raise ValueError(msg)
def _validate_subagent_output_poll_interval(self) -> None:
if self.subagent_output_poll_interval_seconds <= 0:
msg = "subagent_output_poll_interval_seconds must be positive"
raise ValueError(msg)
def _validate_os_descendant_only_fields(self) -> None:
if (
self.os_descendant_only_ceiling_seconds is not None
and self.os_descendant_only_ceiling_seconds <= 0
):
msg = "os_descendant_only_ceiling_seconds must be positive when set"
raise ValueError(msg)
if (
self.os_descendant_only_ceiling_seconds is not None
and self.os_descendant_only_ceiling_seconds > self.max_waiting_on_child_seconds
):
msg = "os_descendant_only_ceiling_seconds must be <= max_waiting_on_child_seconds"
raise ValueError(msg)
if (
self.os_descendant_only_ceiling_seconds is not None
and self.max_waiting_on_child_no_progress_seconds is not None
and self.os_descendant_only_ceiling_seconds
> self.max_waiting_on_child_no_progress_seconds
):
msg = (
"os_descendant_only_ceiling_seconds must be <="
" max_waiting_on_child_no_progress_seconds"
)
raise ValueError(msg)
if self.os_descendant_only_suspect_seconds is not None:
if self.os_descendant_only_suspect_seconds <= 0:
msg = "os_descendant_only_suspect_seconds must be positive when set"
raise ValueError(msg)
if (
self.os_descendant_only_ceiling_seconds is not None
and self.os_descendant_only_suspect_seconds
>= self.os_descendant_only_ceiling_seconds
):
msg = (
"os_descendant_only_suspect_seconds must be <"
" os_descendant_only_ceiling_seconds"
)
raise ValueError(msg)
if self.suspect_waiting_on_child_seconds is not None and (
self.os_descendant_only_suspect_seconds >= self.max_waiting_on_child_seconds
):
msg = "os_descendant_only_suspect_seconds must be < max_waiting_on_child_seconds"
raise ValueError(msg)
def _validate_strictly_stuck_seconds(self) -> None:
if self.no_progress_quiet_strictly_stuck_seconds is None:
return
if self.no_progress_quiet_strictly_stuck_seconds <= 0:
msg = "no_progress_quiet_strictly_stuck_seconds must be positive when set"
raise ValueError(msg)
# The strictly-stuck ceiling must be SHORTER than the outer
# ``max_waiting_on_child_seconds`` ceiling or it is redundant
# with the standard ceiling. We compare against the outermost
# bound because the inner ``no_progress_quiet_seconds`` /
# ``max_waiting_on_child_no_progress_seconds`` pair are the
# dumb-kill floor's twin ceilings and tests routinely
# configure them with shorter values (e.g. 180 s) than the
# default strictly-stuck (300 s); the strictly-stuck ceiling
# is an ORTHOGONAL addition that fires before any of the
# outer cumulative ceilings, so the relevant upper bound is
# the outer ceiling itself.
if self.no_progress_quiet_strictly_stuck_seconds > self.max_waiting_on_child_seconds:
msg = (
"no_progress_quiet_strictly_stuck_seconds must be <="
" max_waiting_on_child_seconds"
)
raise ValueError(msg)
def _validate_no_progress_quiet_heartbeat_ceiling_seconds(self) -> None:
if self.no_progress_quiet_heartbeat_ceiling_seconds is None:
return
if self.no_progress_quiet_heartbeat_ceiling_seconds <= 0:
msg = "no_progress_quiet_heartbeat_ceiling_seconds must be positive when set"
raise ValueError(msg)
# The heartbeat-only ceiling MUST be <= ``no_progress_quiet_seconds``
# when both fields are set. The heartbeat-only branch is a
# SHORTER, ORTHOGONAL ceiling that fires BEFORE the dumb-kill
# ``NO_PROGRESS_QUIET`` ceiling; if the heartbeat ceiling were
# longer than the outer ``no_progress_quiet_seconds`` ceiling,
# the heartbeat branch would never fire (the outer ceiling
# would trip first) and the operator's intent (a faster kill
# for heartbeat-only subagents) would be silently defeated.
if (
self.no_progress_quiet_seconds is not None
and self.no_progress_quiet_heartbeat_ceiling_seconds
> self.no_progress_quiet_seconds
):
msg = (
"no_progress_quiet_heartbeat_ceiling_seconds must be <="
" no_progress_quiet_seconds when both are set"
)
raise ValueError(msg)
def _validate_watchdog_log_throttle_seconds(self) -> None:
if self.watchdog_log_throttle_seconds <= 0:
msg = "watchdog_log_throttle_seconds must be positive"
raise ValueError(msg)
def _validate_watchdog_subagent_progress_interval_seconds(self) -> None:
if self.watchdog_subagent_progress_interval_seconds <= 0:
msg = "watchdog_subagent_progress_interval_seconds must be positive"
raise ValueError(msg)
def _validate_stuck_job_sub_ceiling_seconds(self) -> None:
"""Validate the stuck-job sub-ceiling for ``CHILDREN_PERSIST_TOO_LONG``.
When set, the sub-ceiling MUST be > 0 (zero or negative is
rejected) AND <= ``max_waiting_on_child_seconds`` (the sub-ceiling
is a SHORTER, ORTHOGONAL ceiling; if it were equal or longer it
would be redundant with the cumulative ceiling). When None, the
sub-ceiling is disabled and the legacy behavior is preserved.
"""
if self.stuck_job_sub_ceiling_seconds is None:
return
if self.stuck_job_sub_ceiling_seconds <= 0:
msg = "stuck_job_sub_ceiling_seconds must be positive when set"
raise ValueError(msg)
if self.stuck_job_sub_ceiling_seconds > self.max_waiting_on_child_seconds:
msg = (
"stuck_job_sub_ceiling_seconds must be <="
" max_waiting_on_child_seconds"
)
raise ValueError(msg)