Skip to content

fix(agent): arm harness wall-clock ceiling so a wedged turn can't ship an empty reply#4751

Merged
senamakel merged 8 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/GH-4746-turn-wall-clock-backstop
Jul 13, 2026
Merged

fix(agent): arm harness wall-clock ceiling so a wedged turn can't ship an empty reply#4751
senamakel merged 8 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/GH-4746-turn-wall-clock-backstop

Conversation

@M3gA-Mind

@M3gA-Mind M3gA-Mind commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Problem (issue #4746)

A delegated turn shipped an empty reply with no chat_done and no chat_error — the client spun on inference_heartbeat until the socket died (~443s). Two evidence cases: a sub-agent that streamed but never emitted a completion, and a main agent wedged mid tool-call.

Root cause — in openhuman's adapter, not the tinyagents harness

I checked vendor/tinyagents first. The harness is correct and robust: with_call_budget wraps every model call (incl. the streaming path, agent_loop/model_call.rs) and every tool/sub-agent call (agent_loop/tools.rs) in tokio::time::timeout(remaining_wall_clock), interrupting a hung call and returning TinyAgentsError::Timeout.

The catch: call_budget() returns None — i.e. calls are awaited unbounded — when neither the run config's timeout_ms nor the harness policy's max_wall_clock_ms is set. And openhuman's tinyagents::run_policy_for sets the iteration caps but never max_wall_clock_ms. So the harness's per-call timeout was disabled: a hung model stream / a sub-agent that never returned parked the loop inside an await, the between-call deadline check never ran, and no terminal event ever fired.

No tinyagents change needed — the fix is a one-liner in the openhuman adapter that arms the ceiling.

Fix

  1. tinyagents/mod.rs (root cause)run_policy_for now sets policy.limits.max_wall_clock_ms (OPENHUMAN_AGENT_TURN_TIMEOUT_SECS, default 600s, 0 disables). This arms the harness's existing per-call timeout so a wedged model/tool/sub-agent call is interrupted mid-flight and the turn degrades to a graceful Timeout → chat_error. It also bounds sub-agents — the parent's remaining budget wraps the sub-agent tool call, and a child turn with no per-run timeout inherits this policy-level cap.
  2. web_errors.rs — a dedicated, retryable turn_timeout classification branch matching both the harness Timeout message (run timed out: … wall-clock budget/deadline) and the outer backstop marker, so the user gets actionable copy instead of the generic "something went wrong".
  3. web/ops.rs (defense-in-depth) — a channel-level wall-clock backstop around the whole turn future, as an outer net (raised to 900s, above the 600s harness ceiling) so a hang outside the harness run (session assembly / persistence plumbing) still ends in a terminal chat_error. The harness ceiling is the primary mechanism and fires first.

The iteration-cap path already degrades gracefully (core.rs synthesizes a checkpoint/summary); this closes the remaining never-returns gap.

Tests

  • run_policy_for_arms_the_wall_clock_ceiling + agent_turn_wall_clock_ms_parses_env_override (0=unbounded, garbage=default).
  • classify_inference_error_turn_timeout_gets_dedicated_branch + classify_inference_error_harness_wall_clock_timeout_is_turn_timeout.
  • wedged_turn_hits_wall_clock_backstop_and_emits_turn_timeout_chat_error — parks a turn, asserts a terminal chat_error{turn_timeout, retryable} and cooperative teardown.

Checks

Per an active no-local-builds directive (disk pressure on the runner), CI verifies. cargo check --lib passed on an earlier run of the initial change. Note: the lib test target has pre-existing compile errors in several unrelated domains at this base (reproduced on clean main with these changes stashed) — left to CI.

Closes #4746

Summary by CodeRabbit

  • Bug Fixes
    • Added a wall-clock limit for web chat turns, ensuring stalled or unresponsive turns end with a terminal error.
    • Applied the same protection to parallel conversation branches.
    • Added a clear, retryable message when a turn exceeds its time budget.
    • Prevented internal timeout details from appearing in user-facing messages.
    • Suppressed expected timeout events from error reporting.

@M3gA-Mind M3gA-Mind requested a review from a team July 9, 2026 16:23
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d64f9ac-e145-4961-82e0-6d60df456a79

📥 Commits

Reviewing files that changed from the base of the PR and between 5455964 and 767297c.

📒 Files selected for processing (3)
  • src/openhuman/channels/providers/web/mod.rs
  • src/openhuman/channels/providers/web/ops.rs
  • src/openhuman/channels/providers/web_tests.rs
📝 Walkthrough

Walkthrough

Adds a configurable per-turn wall-clock timeout for web chat turns, applies it to main and parallel execution, classifies timeout failures as retryable turn_timeout errors, suppresses deterministic timeout reports, and tests terminal event behavior for wedged turns.

Changes

Web turn timeout backstop

Layer / File(s) Summary
Deadline helper and execution wiring
src/openhuman/channels/providers/web/ops.rs, src/openhuman/channels/providers/web/mod.rs
Adds the environment-based timeout resolver, cancellation/deadline wrapper, main and parallel turn integration, and centralized Sentry suppression predicate with test/debug re-export.
Synthetic timeout classification
src/openhuman/channels/providers/web_errors.rs
Adds timeout marker helpers and classifies synthetic and TinyAgents wall-clock timeout messages as retryable turn_timeout errors.
Timeout and integration coverage
src/openhuman/channels/providers/web_tests.rs
Tests classification, user messaging, Sentry suppression, terminal timeout events, marker redaction, and cooperative teardown of wedged turns.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested labels: rust-core, agent, bug

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant TurnRunner as start_chat / spawn_parallel_turn
    participant Deadline as run_turn_under_cancel_and_deadline
    participant Task as run_chat_task
    participant Classifier as classify_inference_error

    Client->>TurnRunner: initiate chat turn
    TurnRunner->>Deadline: run with cancellation and deadline
    Deadline->>Task: await turn future
    alt turn completes before deadline
        Task-->>Deadline: turn result
        Deadline-->>TurnRunner: forward result
        TurnRunner-->>Client: terminal chat event
    else deadline elapses
        Deadline-->>TurnRunner: synthetic turn timeout error
        TurnRunner->>Classifier: classify timeout
        Classifier-->>TurnRunner: retryable turn_timeout
        TurnRunner-->>Client: terminal chat_error
    end
Loading

Poem

A rabbit winds the timeout clock,
No wedged turn hides behind a lock.
When time runs out, an error hops,
The thread may retry when waiting stops. 🐇⏱️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: adding a wall-clock ceiling to prevent wedged turns from ending in an empty reply.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. bug labels Jul 9, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 270febc1f6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/openhuman/channels/providers/web/ops.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
src/openhuman/channels/providers/web_errors.rs (1)

160-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc comment omits two existing error_type values.

The enumerated list is missing session_expired (returned at Line 544) and network (returned at Line 888) — pre-existing gaps, but since this line is being edited to add turn_timeout anyway, worth completing the list while touching it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/channels/providers/web_errors.rs` at line 160, The doc comment
for the `error_type` list is incomplete; update the `web_errors.rs` comment near
the existing enum documentation to include all supported values. When editing
the `error_type` documentation, add the missing `session_expired` and `network`
entries alongside `capability_unsupported`, `empty_response`, `turn_timeout`,
and `inference` so the comment matches the values returned by the relevant error
handling paths.
src/openhuman/channels/providers/web/ops.rs (2)

62-113: 🩺 Stability & Availability | 🔵 Trivial

Solid backstop design; one operational caveat worth flagging.

drive_turn_with_deadline correctly drops the wedged future on elapse, which cooperatively tears down anything polled directly within run_chat_task's own future tree. However, if run_chat_task (or a delegated sub-agent) uses detached tokio::spawn internally rather than being polled inline, dropping this outer future won't cancel that background work — it will keep running (and could still touch shared state, IN_FLIGHT-adjacent resources, or emit late events) even after the client has already received the turn_timeout chat_error. The linked issue explicitly scopes "underlying sub-agent slowness" out, so this is likely accepted as-is, but it's worth confirming no detached spawns exist on the hot path this backstop targets, to avoid stray late side effects after the terminal event is sent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/channels/providers/web/ops.rs` around lines 62 - 113,
`drive_turn_with_deadline` only cancels the outer future, so detached work
spawned inside `run_chat_task` or any delegated sub-agent may keep running after
a `turn_timeout` is emitted. Review the hot path in `run_chat_task` and related
web channel task/sub-agent code for any `tokio::spawn` usage, and either
eliminate those detached spawns or wire them into explicit cancellation so the
timeout backstop fully stops late side effects after the terminal event.

844-862: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the shared select!+deadline+run_chat_task wiring.

This block is a near-exact duplicate of the start_chat wiring at Lines 613-631 (same select! { biased; cancel => None, drive_turn_with_deadline(web_turn_deadline(), with_origin(...)) => Some(res) } shape, differing only in the fork flag and queue handle). Since this PR touches both sites identically, it's a good opportunity to extract a small helper (e.g. run_turn_under_cancel_and_deadline(cancel_token, origin, approval_ctx, fut)) to avoid the two copies drifting apart later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/channels/providers/web/ops.rs` around lines 844 - 862, The
cancel/deadline/run_chat_task wiring in the web channel ops code is duplicated
between the shared turn path and start_chat, so extract it into a small helper
to keep both call sites aligned. Refactor the repeated
select!/drive_turn_with_deadline/with_origin/APPROVAL_CHAT_CONTEXT.scope pattern
into a reusable function or wrapper (for example around
run_turn_under_cancel_and_deadline), and keep the only differences as the fork
flag and queue handle passed into run_chat_task.
src/openhuman/channels/providers/web_tests.rs (1)

1986-2041: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cleanup (test hook + env var) isn't panic-safe.

If any assert!/assert_eq!/.expect() between lines 2019-2033 panics, execution never reaches set_test_run_chat_task_block(None) / env::remove_var at Line 2039-2040. The leaked global test hook and OPENHUMAN_WEB_TURN_TIMEOUT_SECS=1 env var would then leak into whatever test runs next in this binary, turning one failure into a cascade of unrelated flaky failures.

Wrap the setup/teardown in an RAII guard so cleanup runs on unwind too.

♻️ Proposed fix using an RAII guard
+struct BackstopTestGuard;
+impl Drop for BackstopTestGuard {
+    fn drop(&mut self) {
+        // Best-effort synchronous reset; async cleanup (`set_test_run_chat_task_block`)
+        // still needs an explicit await somewhere reachable, or move the hook to a
+        // sync `OnceLock`/`Mutex` so Drop can clear it directly.
+        std::env::remove_var("OPENHUMAN_WEB_TURN_TIMEOUT_SECS");
+    }
+}
+
 async fn wedged_turn_hits_wall_clock_backstop_and_emits_turn_timeout_chat_error() {
     let _serial = FORCED_ERROR_TEST_LOCK.lock().await;
     std::env::set_var("OPENHUMAN_WEB_TURN_TIMEOUT_SECS", "1");
+    let _guard = BackstopTestGuard;
     let block = make_block();
     set_test_run_chat_task_block(Some(block.clone())).await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/channels/providers/web_tests.rs` around lines 1986 - 2041, The
test setup for FORCED_ERROR_TEST_LOCK, set_test_run_chat_task_block, and
OPENHUMAN_WEB_TURN_TIMEOUT_SECS is not panic-safe, so a failure in the
assertions or timeout path can leak the global hook and env var into later
tests. Wrap the temporary state in an RAII guard inside the backstop test so
teardown happens on unwind, and make the guard clear the test block and remove
the env var automatically even if the assertions around
recv/error_type/error_retryable panic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/openhuman/channels/providers/web_errors.rs`:
- Line 160: The doc comment for the `error_type` list is incomplete; update the
`web_errors.rs` comment near the existing enum documentation to include all
supported values. When editing the `error_type` documentation, add the missing
`session_expired` and `network` entries alongside `capability_unsupported`,
`empty_response`, `turn_timeout`, and `inference` so the comment matches the
values returned by the relevant error handling paths.

In `@src/openhuman/channels/providers/web_tests.rs`:
- Around line 1986-2041: The test setup for FORCED_ERROR_TEST_LOCK,
set_test_run_chat_task_block, and OPENHUMAN_WEB_TURN_TIMEOUT_SECS is not
panic-safe, so a failure in the assertions or timeout path can leak the global
hook and env var into later tests. Wrap the temporary state in an RAII guard
inside the backstop test so teardown happens on unwind, and make the guard clear
the test block and remove the env var automatically even if the assertions
around recv/error_type/error_retryable panic.

In `@src/openhuman/channels/providers/web/ops.rs`:
- Around line 62-113: `drive_turn_with_deadline` only cancels the outer future,
so detached work spawned inside `run_chat_task` or any delegated sub-agent may
keep running after a `turn_timeout` is emitted. Review the hot path in
`run_chat_task` and related web channel task/sub-agent code for any
`tokio::spawn` usage, and either eliminate those detached spawns or wire them
into explicit cancellation so the timeout backstop fully stops late side effects
after the terminal event.
- Around line 844-862: The cancel/deadline/run_chat_task wiring in the web
channel ops code is duplicated between the shared turn path and start_chat, so
extract it into a small helper to keep both call sites aligned. Refactor the
repeated
select!/drive_turn_with_deadline/with_origin/APPROVAL_CHAT_CONTEXT.scope pattern
into a reusable function or wrapper (for example around
run_turn_under_cancel_and_deadline), and keep the only differences as the fork
flag and queue handle passed into run_chat_task.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 18578e58-a3bb-4997-bfb3-f89aa0d98db8

📥 Commits

Reviewing files that changed from the base of the PR and between e5c6507 and 270febc.

📒 Files selected for processing (3)
  • src/openhuman/channels/providers/web/ops.rs
  • src/openhuman/channels/providers/web_errors.rs
  • src/openhuman/channels/providers/web_tests.rs

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 9, 2026
@M3gA-Mind M3gA-Mind changed the title fix(agent): wall-clock backstop so a wedged turn always emits a terminal event fix(agent): arm harness wall-clock ceiling so a wedged turn can't ship an empty reply Jul 9, 2026
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

Pushed a follow-up (2dd12b1) that moves the fix to the root cause after checking vendor/tinyagents.

Finding: the tinyagents harness is not buggy — it already bounds every model/tool/sub-agent call with tokio::time::timeout(remaining_wall_clock) via with_call_budget. But that only engages when a deadline is configured, and openhuman's tinyagents::run_policy_for set the iteration caps while leaving RunLimits.max_wall_clock_ms = Nonecall_budget() returns None → calls awaited unbounded → the hang with no terminal event.

What changed since the first push:

  • tinyagents/mod.rs: run_policy_for now arms max_wall_clock_ms (OPENHUMAN_AGENT_TURN_TIMEOUT_SECS, default 600s, 0 disables) — the primary root-cause fix; activates the harness's per-call timeout and also bounds sub-agents.
  • web_errors.rs: turn_timeout classification now also matches the harness Timeout message (run timed out: … wall-clock budget/deadline).
  • web/ops.rs: the channel-level backstop is retained but reframed as the outer net, default raised 600s→900s so the harness ceiling fires first.
  • Tests added for run_policy_for, env-override parsing, and the harness-timeout classification.

No changes to the vendored crate — the fix is entirely in the openhuman adapter, so this stays a single openhuman PR (no separate tinyagents PR).

@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

CI guardian: ran rustfmt on the wedged-turn retry assert! in web_tests.rs:417 (the only cargo fmt --check diff) — fixes the Rust Quality (fmt, clippy) failure. Rebased onto the latest branch head (2dd12b1c2).

@YellowSnnowmann

Copy link
Copy Markdown
Contributor

Verdict: changes requested (feature works, one Sentry-taxonomy issue to fix)

Feature: satisfied. The RCA is correct and the fix targets the true root cause. with_call_budget (vendor/tinyagents model_call.rs:255-275, tools.rs) already wraps every model/tool/sub-agent call in tokio::time::timeout(remaining), but call_budget() (model_call.rs:227-240) returns None when both the run config and policy.limits.max_wall_clock_ms are unset — and run_policy_for set the iteration caps while leaving max_wall_clock_ms = None, so calls were awaited unbounded. Arming max_wall_clock_ms (tinyagents/mod.rs) activates the harness timeout, bounds sub-agents via the policy source, and degrades a wedged turn to Timeout -> turn_timeout chat_error. The 900s outer channel backstop and the dedicated retryable turn_timeout classification (ordered before the generic timeout/429/5xx arms, so it wins) close the gap cleanly, with good test coverage. This genuinely fixes #4746.

Major — turn_timeout still pages Sentry as an unexpected error

web/ops.rs:632-655: the Err branch only suppresses the Sentry emit for is_max_iterations_error; everything else, including the new turn_timeout, falls into report_error_or_expected at ops.rs:644. But:

  • expected_error_kind (core/observability.rs:355) has no branch for the synthetic marker (openhuman_turn_wall_clock_timeout: ...) or the harness run timed out: ... exceeded its remaining wall-clock budget (N ms), and
  • AgentError::skips_sentry() (agent/error.rs:163-168) covers only MaxIterationsExceeded | EmptyProviderResponse; the harness Timeout is not an AgentError variant.

So every turn that trips the ceiling emits a spurious Sentry event — contradicting this PR's own framing of turn_timeout as a graceful, deterministic, retryable, user-surfaced outcome. That is the same category as MaxIterationsExceeded and EmptyProviderResponse, both of which are explicitly demoted (skips_sentry + the ExpectedErrorKind arm at observability.rs ~2131 that literally reads "same tier as MaxIterationsExceeded"). Please suppress it at the emit site — mirror the is_max_iterations_error guard using web_errors::is_turn_timeout_error(&detailed) — and/or add an ExpectedErrorKind arm anchored on the wall-clock marker, plus a test asserting turn_timeout does not capture.

Minor — parked-approval cleanup on external teardown (codex P2)

Real interaction, and you've correctly triaged it as an out-of-scope follow-up: when the backstop drops the future while a tool is parked in ApprovalGate, gate.rs cleanup (no Drop guard) is skipped and the waiter/pending row dangles until its own TTL. Blast radius is narrow and the turn ends retryable, so deferring is fine — please just make sure the follow-up issue is actually filed.

Nit

  • web_errors.rs:160: while touching the error_type doc list, confirm session_expired and network are both included (the two pre-existing omissions CodeRabbit flagged).

CI is fully green.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 10, 2026
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review @YellowSnnowmann — addressed in 42340d4c0.

Major — turn_timeout no longer pages Sentry. You're right: the run_chat_task Err branch only gated the Sentry emit on is_max_iterations_error, so every turn_timeout fell into report_error_or_expected and paged as an unexpected error — contradicting the graceful/deterministic/retryable framing (same tier as MaxIterationsExceeded / EmptyProviderResponse, both explicitly demoted).

Fix: extracted a pure sentry_suppression_reason(detailed) -> Option<&'static str> predicate and gated the emit site on it. It suppresses both the max-iteration cap and the wall-clock backstop / harness Timeout (web_errors::is_turn_timeout_error), mirroring the guard you pointed at. Went with the emit-site guard rather than a new ExpectedErrorKind arm because web_errors is a #[path]-included private module of web, so the central expected_error_kind in core::observability can't reuse the matcher without duplicating the marker/phrase list — the guard keeps a single source of truth. Added a unit test (turn_timeout_error_is_suppressed_from_sentry) asserting both the synthetic marker and the harness Timeout renderings are suppressed while a genuine provider error still pages. (Note: the parallel-turn path at ops.rs:903 never called report_error_or_expected, so it was already Sentry-quiet.)

Minor — parked-approval cleanup follow-up filed: #4774 (RCA, blast radius, proposed Drop-guard / budget-exclusion fix + regression test). Resolved the Codex thread pointing at it.

Nit — error_type doc list: already complete on the current head — session_expired and network are both present (web_errors.rs:166-170, landed in 396b2ef9b).

…nal event

A web chat turn ran in a tokio::select! with only cancel + run_chat_task
arms, so if the turn future never resolved (a main agent wedged mid
tool-call, or a delegated sub-agent that streams but never returns a
completion), run_chat_task hung forever: the progress bridge kept beating
inference_heartbeat until the socket died and NO chat_done/chat_error was
ever emitted, delivering an empty string to the client.

Wrap both the primary and parallel/fork turn futures in a wall-clock
backstop (default 600s, env OPENHUMAN_WEB_TURN_TIMEOUT_SECS, 0 disables).
On elapse the wedged future is dropped (cooperative teardown at its next
await) and a synthetic turn_timeout error flows through the existing Err
arm, guaranteeing a graceful, retryable chat_error terminal event. Add a
dedicated turn_timeout classification branch so the user sees actionable
copy instead of the generic error.

The iteration-cap path already degrades gracefully (core.rs synthesizes a
checkpoint/summary); this closes the remaining never-returns gap.

Closes tinyhumansai#4746
…p an empty reply

Root cause (openhuman adapter, not the harness): run_policy_for set the
iteration caps but left RunLimits.max_wall_clock_ms = None. The tinyagents
harness already bounds every model AND tool/sub-agent call by the run's
remaining wall-clock budget (with_call_budget -> tokio::time::timeout), but
ONLY when a deadline is configured; with None, call_budget() returns None and
each call is awaited UNBOUNDED. So a hung/slow model stream or a delegated
sub-agent that never returned left the loop parked inside an await, the
between-call deadline check never ran, and no terminal event ever fired ->
the client received an empty string while inference_heartbeat beat on until
the socket died.

Primary fix: run_policy_for now arms policy.limits.max_wall_clock_ms
(OPENHUMAN_AGENT_TURN_TIMEOUT_SECS, default 600s, 0 disables). This activates
the harness's per-call timeout so a wedged call is interrupted mid-flight and
the turn degrades to a graceful Timeout -> chat_error. It also bounds
sub-agents: the parent's remaining budget wraps the sub-agent tool call.

Supporting:
- web_errors: classify the harness Timeout ("run timed out: ... wall-clock
  budget/deadline") plus the outer backstop marker as a dedicated, retryable
  turn_timeout chat_error with actionable copy.
- web/ops: keep the channel-level wall-clock backstop as an OUTER net (raised
  to 900s, above the 600s harness ceiling) so a hang outside the harness run
  still ends in a terminal event.

Tests: run_policy_for arms the ceiling; env override parsing (0=unbounded,
garbage=default); harness Timeout + backstop marker both classify as
turn_timeout.

Closes tinyhumansai#4746
Add the previously-omitted `session_expired` and `network` values to the
`error_type` doc list so it matches every token `classify_inference_error`
actually returns (addresses @coderabbitai on web_errors.rs:160).
Collapse the identical `select!{biased; cancel => None, drive_turn_with_deadline(
with_origin(scope(run_chat_task))) => Some}` block duplicated between
`start_chat` and `spawn_parallel_turn` into `run_turn_under_cancel_and_deadline`,
so the two call sites can't drift apart. Only the fork flag and run-queue handle
differ per site (addresses @coderabbitai on web/ops.rs:844).
Wrap the process-global `OPENHUMAN_WEB_TURN_TIMEOUT_SECS=1` override in an RAII
`EnvGuard` so an assertion unwind can't leak the 1s backstop into unrelated
tests sharing the binary (addresses @coderabbitai on web_tests.rs:1986).
…nnowmann)

A wedged web turn that trips the wall-clock ceiling is a deterministic,
user-surfaced, retryable outcome — the client already receives a graceful
`turn_timeout` chat_error. The run_chat_task Err branch only suppressed the
Sentry emit for the max-iteration cap, so every turn_timeout fell into
report_error_or_expected and paged Sentry as an unexpected error, contradicting
this PR's own framing of turn_timeout as a graceful agent-loop outcome (same
tier as MaxIterationsExceeded / EmptyProviderResponse, both explicitly demoted).

Extract sentry_suppression_reason() — a pure predicate over the formatted error
string — and gate the emit site on it, suppressing both the max-iteration cap
and the wall-clock backstop / harness Timeout (is_turn_timeout_error). Add a
unit test asserting turn_timeout (marker + harness renderings) is suppressed
while a genuine provider error still pages.
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

Rebased onto main, resolved one conflict in src/openhuman/channels/providers/web_tests.rs — the use super::{…} import list. Kept main's current set and added sentry_suppression_reason (used by the new suppression test); dropped optional_bool/optional_u64 from the incoming context since main no longer uses them in this test file (they'd be unused imports). Other 6 commits applied cleanly across web/{mod,ops}.rs, web_errors.rs, and tinyagents/{mod,tests}.rs.

@M3gA-Mind M3gA-Mind force-pushed the fix/GH-4746-turn-wall-clock-backstop branch from 42340d4 to 5455964 Compare July 13, 2026 11:50
@coderabbitai coderabbitai Bot added the rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. label Jul 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openhuman/channels/providers/web_tests.rs`:
- Around line 2036-2112: Serialize access to the process-wide run_chat_task test
hook used by
wedged_turn_hits_wall_clock_backstop_and_emits_turn_timeout_chat_error across
all tests that call start_chat/run_chat_task, not only tests in web_tests.rs.
Reuse the existing FORCED_ERROR_TEST_LOCK or establish a shared lock at the
common hook boundary so TEST_RUN_CHAT_TASK_BLOCK and
OPENHUMAN_WEB_TURN_TIMEOUT_SECS cannot overlap with concurrent tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b8970fa6-71ac-4589-a7d6-d11a298180bb

📥 Commits

Reviewing files that changed from the base of the PR and between 270febc and 5455964.

📒 Files selected for processing (4)
  • src/openhuman/channels/providers/web/mod.rs
  • src/openhuman/channels/providers/web/ops.rs
  • src/openhuman/channels/providers/web_errors.rs
  • src/openhuman/channels/providers/web_tests.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/openhuman/channels/providers/web_errors.rs
  • src/openhuman/channels/providers/web/ops.rs

Comment thread src/openhuman/channels/providers/web_tests.rs
…k boundary

The wall-clock backstop test (and the other forced-block/forced-error tests)
drive process-global toggles — TEST_RUN_CHAT_TASK_BLOCK,
TEST_FORCED_RUN_CHAT_TASK_ERROR, OPENHUMAN_WEB_TURN_TIMEOUT_SECS. They were
serialized only through a file-local FORCED_ERROR_TEST_LOCK in web_tests.rs, so
any test in another module that calls start_chat/run_chat_task could still race
these globals and flake.

Define the serialization lock (RUN_CHAT_TASK_TEST_LOCK) at the shared hook
boundary in web::ops where the hooks live, and have web_tests.rs acquire that
shared lock instead of its own. Any test exercising the same path now
serializes on one lock regardless of which module it lives in.
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

W4 update — pushed 767297cd8:
Addressed the CodeRabbit stability finding: the run_chat_task test-hook serialization lock now lives at the shared hook boundary in web::ops (RUN_CHAT_TASK_TEST_LOCK), next to the hooks and the OPENHUMAN_WEB_TURN_TIMEOUT_SECS override, instead of a file-local lock in web_tests.rs. Any test in another module that exercises start_chat/run_chat_task now serializes on the same lock rather than racing these process-globals.

Review thread resolved; only the Core coverage job is finishing.

@oxoxDev oxoxDev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review ✅ — approve

RCA is correct and verified against the pinned tinyagents SHA: run_policy_for left RunLimits::max_wall_clock_ms = None, which disables the harness per-call tokio::time::timeout (its budget is Some only when config timeout_ms OR policy max_wall_clock_ms is set). Arming it (600s) bounds both reported wedge shapes — with_call_budget wraps the streaming model call, the buffered call, and every tool/sub-agent call. The 900s channel backstop is defense-in-depth for hangs outside the harness run.

Verified

  • Timers nest correctly (inner 600 < outer 900): harness fires first, returns Err via normal unwind, outer never double-fires → exactly one chat_error{turn_timeout, retryable}, client stops spinning. Closes the symptom.
  • Terminal event reliable: both harness timeout strings + the synthetic marker route to turn_timeout in the classifier.

Non-blocking

  • [low] The two ceilings are independent env vars; if an operator sets OPENHUMAN_WEB_TURN_TIMEOUT_SECS < OPENHUMAN_AGENT_TURN_TIMEOUT_SECS, the outer backstop fires first and drops the whole harness future instead of letting it unwind gracefully (widening the #4774 approval-leak window). Defaults are safe. Consider clamping outer to max(web,agent)+slack or a debug-assert/log on inversion.
  • [low] The RCA interrupt path isn't asserted openhuman-side end-to-end — the integration test drives the outer backstop via a test block that replaces run_chat_task, so the harness never runs; coverage relies on the config-arm test + classifier units + tinyagents' own suite. Fine given the fix is a one-line config arm, worth noting.

Dangling-resource seam on forced teardown correctly scoped out to #4774 (approval-gate slice = #4819). CodeRabbit majors (error_type doc, shared wiring, cross-module test lock) all resolved in-branch.

@senamakel senamakel merged commit 108942b into tinyhumansai:main Jul 13, 2026
16 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Team Openhuman Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[agent] Turn ships an EMPTY reply on iteration/time-budget exhaustion (missing terminal event) — degrade gracefully

4 participants