Skip to content

test(tui): stabilize the forced-idle-timeout worker-reuse test on Windows (#5898) - #5913

Merged
Hmbown merged 2 commits into
mainfrom
fix/win-idle-timeout-flake-5898
Sep 6, 2026
Merged

test(tui): stabilize the forced-idle-timeout worker-reuse test on Windows (#5898)#5913
Hmbown merged 2 commits into
mainfrom
fix/win-idle-timeout-flake-5898

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes #5898.

Root cause (from the failing CI log, job 101421344166): the panic was left: Failed, right: Completed in 3.177s — not a wait-budget timeout. The follow-up task ("run after hang") inherits the same short_for_tests budgets that exist to force-terminalize the stuck task (idle_progress 150ms, wall_time 400ms, cancel_grace 50ms), and the mock executor's follow-up path performs real awaits. Under a ≥150ms scheduler stall mid-flight — before its events queue, so the drain-on-idle-interrupt rescue cannot retract it — the guard interrupts with IdleTimeout, the executor observes cancellation and returns Canceled, and preserve_timeout_reason rewrites it to the timeout reason → Failed.

Fix (test module only): the follow-up branch of the test executor now completes with zero await points (one synchronous events.try_send to keep the released worker's event pipeline exercised, then immediate Completed). run_task's biased select polls the executor future first and the first evaluate runs at elapsed ≈ 0, so an await-free future always finishes before any interrupt can be recorded — deterministic under any load, closed by construction rather than by margin. Both asserts now print the full terminal record on failure, and a new assert pins the follow-up's terminal_reason to "completed".

Evidence: this exact test blocked #5899 and #5905 Windows matrices today. Local: task_manager suite 44/44; forced_idle_timeout ×5 single-threaded 5/5; ×10 under 8 CPU-burner starvation 10/10; full suite ×3 under the same load 3×44/44; cargo fmt clean. The hang branch (std::future::pending) is unchanged, so the test still proves a forcibly idle-timed-out task releases its worker.


Note

Medium Risk
Changes production ExecutionGuard timeout reason selection in run_task and engine turns; behavior is more accurate but could alter which terminal reason users see when both limits elapse under scheduler delay.

Overview
Fixes flaky forced idle timeout / worker reuse coverage on loaded CI (including Windows) by tightening timeout attribution and making the follow-up test executor deterministic.

ExecutionGuard::evaluate no longer picks wall vs idle timeout from check order alone. It compares Instant deadlines (checked_add on wall and idle budgets) so a starved watchdog still reports whichever limit actually expired first; when both are past due, idle wins unless late progress pushed idle past wall (ties keep wall precedence per #5898).

The PromptRouterExecutor test follow-up path stops delegating to MockExecutor (multiple awaits plus sleep). It now finishes without await points—one synchronous try_send and an immediate Completed—so run_task’s biased poll completes before short test budgets can interrupt and rewrite the result via preserve_timeout_reason.

Adds a unit test for dual-expired guard behavior, extends the progress/wall guard test, and improves integration asserts (including terminal_reason: completed on the follow-up task).

Reviewed by Cursor Bugbot for commit 5ad5c76. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI lite review requested due to automatic review settings September 6, 2026 04:16
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T04:19:18.791755Z aabf8e7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ffb7ad54-d3dc-4358-be24-0509911b47cd)

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 1m 44s —— View job


Review: stabilize forced-idle-timeout worker-reuse test on Windows

Summary: Solid, narrowly-scoped fix. I traced the claimed race through run_task's biased select! (crates/tui/src/task_manager.rs:1761-1789) and it holds up:

  • On the first loop iteration, guard.evaluate(Instant::now(), ...) runs with elapsed ≈ 0, so it returns GuardAction::Run (the 150ms idle_progress / 400ms wall_time short-test budgets haven't been reached yet), landing in the select!.
  • biased polls exec_fut first each poll. Since the follow-up branch of PromptRouterExecutor::execute (crates/tui/src/task_manager.rs:3707-3736) now contains zero .await points (events.try_send is synchronous, not send().await), the generated future resolves to Ready on that very first poll — it can't be preempted by the idle/cancel branches, regardless of scheduler load. This is closed by construction, not by margin, matching the PR description.
  • Confirmed #[async_trait]'s boxing doesn't introduce an extra suspension point — an await-free async body still completes on first poll even when boxed.

Scope check: PromptRouterExecutor (crates/tui/src/task_manager.rs:3704) is used by exactly one test (forced_idle_timeout_releases_the_worker_for_later_tasks, line 4116), so this is fully isolated — no other test relies on the old real-await follow-up path, and production MockExecutor/TaskExecutionLimits are untouched.

  • The struct literal for TaskExecutionResult matches its field set exactly (status, result_text, error, terminal_reason — task_manager.rs:681-686), so this compiles cleanly.
  • cancel_cancel rename on the follow-up branch's now-unused parameter is correct and avoids an unused-variable warning; it doesn't change any cancellation semantics since production code paths are untouched.

Provider/model safety (EPIC #2608): N/A — task.prompt.starts_with("hang ") only selects between two mock test behaviors (hang vs. immediate completion) and never touches provider/model routing.

Minor, non-blocking observations:

  • The added inline comment on the execute follow-up branch (lines 3717-3729) is fairly long for the "why" it conveys, but it captures a genuinely non-obvious invariant (await-free future + biased select ordering), so I'd keep it rather than trim it.
  • Nice touch: printing the full terminal record ({finished:?} / {completed:?}) on assertion failure, and pinning terminal_reason to "completed" — this will make any future regression much faster to diagnose than the original bare assert_eq!.

I was not able to execute cargo test/cargo fmt in this sandbox (Bash tool required interactive approval that wasn't available), so I relied on static tracing of the guard/select logic plus the PR's own reported local verification (44/44, 5/5, 10/10, 3×44/44 under load). Nothing in the diff looks logically unsound; recommend a CI Windows run to confirm empirically before merge, consistent with the "gate is its artifact" rule in AGENTS.md.

No changes requested.

Copilot AI 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.

🟢 Approval recommended

The change is confined to test helpers/assertions and is consistent with the task manager’s biased polling behavior, reducing flakiness without altering production logic.

Pull request overview

This PR stabilizes the Windows-flaky TUI task-manager regression test for forced idle timeouts by making the follow-up (“worker reuse”) task complete deterministically under scheduler stalls, without changing any production task-manager behavior.

Changes:

  • In the test-only PromptRouterExecutor, the follow-up (non-hang) path now completes with no .await points, emitting at most a single events.try_send(...) and immediately returning Completed.
  • Improves failure diagnostics by including full terminal records in assertion messages.
  • Adds an assertion pinning the follow-up task’s terminal_reason to "completed".
File summaries
File Description
crates/tui/src/task_manager.rs Adjusts a test-only executor to eliminate await points in the follow-up branch and strengthens the test’s assertions/diagnostics to remove Windows CI flakiness.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codewhale-agent codewhale-agent 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.

Codewhale review

The PR stabilizes the forced-idle-timeout worker-reuse test by making the non-hang follow-up executor branch await-free and by strengthening the failing assertions to print terminal records and pin the follow-up terminal_reason to "completed". The change is confined to the test module.

Findings

  • [INFO] Status event delivery is best-effort and can be silently dropped (crates/tui/src/task_manager.rs:3730)
    The new non-hang branch uses let _ = events.try_send(...), so a full or closed events channel will drop the only status event emitted for the follow-up task. This is intentional to avoid awaits and no assertion depends on this event, but the adjacent comment claims the released worker's event pipeline is exercised; when try_send fails, it is not.

Assessment

Looks good. The test-only change correctly removes await points from the follow-up path so the shortened idle timeout budgets cannot interrupt it, and the added terminal_reason assertion pins the expected outcome. No production execution path is affected.


Advisory review by Codewhale (codewhale review --pr 5913 --post, head aabf8e7fd4f2a7cef375e4c28d9400ec0bd891aa). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

// reason -> `Failed` (issue #5898). `try_send` keeps the
// released worker's event pipeline exercised without
// suspending this future.
let _ = events.try_send(TaskExecutionEvent::Status {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] Status event delivery is best-effort and can be silently dropped

The new non-hang branch uses let _ = events.try_send(...), so a full or closed events channel will drop the only status event emitted for the follow-up task. This is intentional to avoid awaits and no assertion depends on this event, but the adjacent comment claims the released worker's event pipeline is exercised; when try_send fails, it is not.

CodeWhale Bot and others added 2 commits September 5, 2026 22:01
Root cause (from CI job 101421344166, run 34008939993 attempt 1):
the second assert in forced_idle_timeout_releases_the_worker_for_later_tasks
panicked with left: Failed, right: Completed in 3.177s — not a wait-budget
timeout. The follow-up task ("run after hang") ran under the same
short_for_tests budgets (idle 150ms, wall 400ms, grace 50ms) that exist to
force-terminalize the *stuck* task, and its MockExecutor path performs real
awaits (3x send().await, a 50ms sleep, a post-sleep cancel check). When a
loaded Windows runner stalls the tokio scheduler or the fsync-bound event
processing for >=150ms while the follow-up task is mid-flight (and before its
events are queued, so the drain-on-idle-interrupt rescue in run_task cannot
retract the interrupt), the guard interrupts with IdleTimeout (or WallTimeout
past 400ms), cancels the token, MockExecutor observes the cancellation and
returns Canceled, and preserve_timeout_reason rewrites it to the timeout
reason -> TaskStatus::Failed. Once note_interrupt fires, progress can no
longer retract it, so the race is one-sided against the test.

Fix (test module only; production TaskExecutionLimits and MockExecutor
untouched): the follow-up branch of PromptRouterExecutor now completes with
zero await points — one synchronous events.try_send(Status) to keep the
released worker's event pipeline exercised, then an immediate Completed
result. run_task polls the executor future first in its biased select and the
first guard evaluate() runs with elapsed ~= 0, so an await-free future always
finishes before any interrupt can be recorded, deterministically, regardless
of machine load. The stuck-task branch (std::future::pending) is unchanged,
so the test still proves a forcibly idle-timed-out task releases its worker
for later tasks. Per the issue, both asserts now print the full terminal
record on failure, and a new assert pins the follow-up's terminal_reason to
"completed" so any future regression shows which reason won.

Verification (macOS local; no Windows runner available here):
- cargo test -p codewhale-tui task_manager: 44 passed / 0 failed
- cargo test -p codewhale-tui forced_idle_timeout -- --test-threads=1 x5: 5/5 pass
- forced_idle_timeout x10 with 8 CPU burners (scheduler-starvation stress,
  emulating the CI contention mechanism): 10/10 pass
- full task_manager suite x3 under the same load: 3x 44/44 pass
- cargo fmt -p codewhale-tui -- --check: clean

Residual risk: the hang task itself could theoretically exceed the 10s
wait_for_terminal_state budget only under extreme multi-second stalls, which
the drain + grace design already tolerates; nothing in this change increases
that exposure.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
)

The Windows matrix also flaked on
task_manager::tests::heartbeat_status_does_not_refresh_idle_timeout with
`left: Some("wall_timeout"), right: Some("idle_timeout")`. The guard
checked the wall limit before the idle limit on every tick, so a scheduler
stall that carried the watchdog past both test budgets (150 ms idle, 400 ms
wall) reported the wall limit even though the idle limit had expired first.

ExecutionGuard::evaluate now compares the two deadlines and reports the one
that was crossed first; a tie keeps the wall limit's precedence, and a limit
whose deadline does not fit in Instant never fires. The reason a task carries
no longer depends on when the watchdog happened to wake, which is also the
truthful reason in production.

Tests: a new guard unit test covers both orderings under a starved tick, and
the existing progress-refreshes-idle test now keeps progress flowing up to
the wall deadline, which is what its name claims.
cargo test -p codewhale-tui task_manager::tests: 45 passed, 0 failed.

Signed-off-by: CodeWhale Bot <hmbown@gmail.com>
@Hmbown

Hmbown commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Second Windows flake in the same family, fixed on this branch: heartbeat_status_does_not_refresh_idle_timeout failed on #5911's matrix with left: Some("wall_timeout"), right: Some("idle_timeout"). Root cause is in the guard, not the test: ExecutionGuard::evaluate checked the wall limit before the idle limit, so a scheduler stall that carried the watchdog past both test budgets (150 ms idle, 400 ms wall) reported the wall limit even though idle expired first. The guard now attributes the timeout to whichever deadline was crossed first (tie keeps wall precedence). New unit test covers both orderings under a starved tick. Local: task_manager::tests 45/45. Rebased onto current main.

@Hmbown
Hmbown force-pushed the fix/win-idle-timeout-flake-5898 branch from aabf8e7 to 5ad5c76 Compare September 6, 2026 05:01
@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_dd37999f-d8d8-4d8b-8215-eab697a72955)

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 12s —— View job


Review: Windows watchdog-timeout flake fixes (commits f2431c2 + 5ad5c76)

  • Gather context (PR body, prior reviews, new commit description)
  • Review diff against origin/main (includes the new guard-ordering fix for heartbeat_status_does_not_refresh_idle_timeout)
  • Check correctness of ExecutionGuard::evaluate ordering change
  • Check provider/model routing safety (N/A — no such code touched)
  • Check reuse/simplification and test coverage
  • Post final review

Summary: Two real fixes for the same starved-watchdog family, and the second one is a genuine production-code change, not "test helpers only."

1. Scope note: the description is now stale (please update before merge)

The PR body/AI overview still say "Changes are confined to test helpers and assertions... no production execution path is modified." That was true for commit f2431c2 (aabf8e7), which is what the earlier Copilot/Codex/Claude review passes evaluated. Commit 5ad5c76 changes real production logic in ExecutionGuard::evaluate (crates/tui/src/task_manager.rs:515-554) — the branch that decides whether a live task's runtime timeout is attributed to wall_timeout or idle_timeout. It's a narrow, well-justified change, but it isn't test-only anymore, and the earlier "Low Risk" sign-offs didn't see it. Per AGENTS.md ("read the review thread, not the check rollup"), worth a fresh look at this commit specifically before merge, and an updated PR description.

2. Correctness of the new attribution logic — traced and looks right

Old code (task_manager.rs pre-PR) checked wall_elapsed >= wall_time before idle_elapsed >= idle_progress, so whenever a scheduler stall pushed both counters past their budgets, it always blamed wall_timeout — even when the idle deadline (shorter, at 150ms vs 400ms in short_for_tests) had actually expired first. That's the real root cause of the second flake (heartbeat_status_does_not_refresh_idle_timeout, #5911).

The new match (wall_deadline, idle_deadline) (lines 546-553) instead compares absolute deadlines (started_at + wall_time vs last_progress_at + idle_progress) and reports whichever was crossed first, tie going to wall. I walked all four Option combinations plus both new/adjusted unit tests (execution_guard_reports_the_limit_that_expired_first_when_both_elapsed, and the added note_progress call in execution_guard_progress_refreshes_idle_until_wall_timeout) by hand — the arithmetic checks out for each case, including the checked_addNone fallback for a deadline that can't fit in Instant (extremely unlikely with real durations, but harmless and consistent with the existing Instant::checked_add idiom already used in frame_rate_limiter.rs/streaming/mod.rs).

Also confirmed the un-changed wait computation just below (lines 559-564) still uses wall_elapsed/idle_elapsed, so nothing there is now dead or mismatched.

3. Minor, non-blocking

  • No unit test exercises the exact tie case (wall_deadline == idle_deadline) to pin the documented "tie keeps wall precedence" behavior — cheap to add given the doc comment already calls it out, but not required.
  • The earlier bot finding about let _ = events.try_send(...) being best-effort (from the first commit) still stands as an accepted, intentional tradeoff — nothing new here.

4. Provider/model safety

N/A — no provider/model/routing code is touched by either commit.

Verification limits

Same constraint as the earlier automated pass on this PR: I couldn't execute cargo test/cargo check/cargo fmt in this sandbox (no interactive approval available for Bash here), so this is a static trace of the guard logic and tests, not an executed run. The PR's self-reported local evidence (task_manager::tests 45/45) covers this; recommend confirming on the actual Windows CI matrix before merge, consistent with "the gate is its artifact."

No changes requested from me — the logic is sound. Just flagging the description/scope mismatch for a maintainer to reconcile before this merges as "test-only."

@codewhale-agent codewhale-agent 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.

Codewhale review

The PR stabilizes the Windows forced-idle-timeout worker-reuse test by making the follow-up test executor await-free and improves timeout attribution in ExecutionGuard. The await-free follow-up should prevent scheduler-stall-driven interrupts, but the PR also changes production timeout-reason behavior and reduces async follow-up coverage.

Findings

  • [WARNING] Production timeout attribution change is broader than a test stabilization (crates/tui/src/task_manager.rs:541)
    ExecutionGuard::evaluate now chooses wall vs idle timeouts by comparing Instant deadlines and reports whichever limit actually expired first; when both are elapsed, idle wins unless its deadline was pushed past wall. This is a user-visible behavior change to task-manager terminal reasons under scheduler delay, not just a test-only fix. It should be explicitly justified or separated from the Windows flake fix.
  • [WARNING] Follow-up test path no longer exercises the real async executor (crates/tui/src/task_manager.rs:3745)
    The non-hang branch now returns a synthetic Completed result after one synchronous try_send instead of delegating to MockExecutor. The forced-idle test still proves a hung task releases a worker, but it no longer verifies that a released worker can run a normal task with awaited sends and sleep. Regressions in async follow-up execution could pass unnoticed.
  • [INFO] Missing tie-case coverage for wall/idle deadline precedence (crates/tui/src/task_manager.rs:3864)
    The new comment says ties keep wall precedence, but the new ExecutionGuard test covers idle-expired-first and idle-pushed-past-wall, not equal deadlines. A tie case would pin the documented behavior.

Suggestions

  • crates/tui/src/task_manager.rs:3895 — Add an explicit tie case to execution_guard_reports_the_limit_that_expired_first_when_both_elapsed that sets both deadlines equal and expects WallTimeout, so the wall-precedence behavior is protected.
  • crates/tui/src/task_manager.rs:3745 — Keep the await-free follow-up for this flake fix, but add a separate test that runs a normal non-hang task through MockExecutor on a reused worker with normal or longer budgets to retain async execution coverage.

Assessment

The test flake fix is likely effective and the improved assert messages are useful. However, the PR includes a production behavior change that should be explicitly accepted rather than hidden in a test-stabilization PR, and the follow-up path weakens executor coverage. I would approve only after confirming the ExecutionGuard change is intended and adding at least tie and async follow-up coverage.


Advisory review by Codewhale (codewhale review --pr 5913 --post, head 5ad5c76d7c3f768865f88e65f19c263b9f768673). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

} else {
None
// Attribute the timeout to the limit that was crossed first, not
// to the one this tick happens to check first. When the watchdog

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] Production timeout attribution change is broader than a test stabilization

ExecutionGuard::evaluate now chooses wall vs idle timeouts by comparing Instant deadlines and reports whichever limit actually expired first; when both are elapsed, idle wins unless its deadline was pushed past wall. This is a user-visible behavior change to task-manager terminal reasons under scheduler delay, not just a test-only fix. It should be explicitly justified or separated from the Windows flake fix.

let _ = events.try_send(TaskExecutionEvent::Status {
message: format!("running after forced release {}", task.id),
});
TaskExecutionResult {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] Follow-up test path no longer exercises the real async executor

The non-hang branch now returns a synthetic Completed result after one synchronous try_send instead of delegating to MockExecutor. The forced-idle test still proves a hung task releases a worker, but it no longer verifies that a released worker can run a normal task with awaited sends and sleep. Regressions in async follow-up execution could pass unnoticed.

}

#[test]
fn execution_guard_reports_the_limit_that_expired_first_when_both_elapsed() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] Missing tie-case coverage for wall/idle deadline precedence

The new comment says ties keep wall precedence, but the new ExecutionGuard test covers idle-expired-first and idle-pushed-past-wall, not equal deadlines. A tie case would pin the documented behavior.

}
other => panic!("expected wall interrupt, got {other:?}"),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add an explicit tie case to execution_guard_reports_the_limit_that_expired_first_when_both_elapsed that sets both deadlines equal and expects WallTimeout, so the wall-precedence behavior is protected.

result_text: Some("done after hang".to_string()),
error: None,
terminal_reason: TaskTerminalReason::Completed,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keep the await-free follow-up for this flake fix, but add a separate test that runs a normal non-hang task through MockExecutor on a reused worker with normal or longer budgets to retain async execution coverage.

@Hmbown
Hmbown merged commit e745be5 into main Sep 6, 2026
34 checks passed
@Hmbown
Hmbown deleted the fix/win-idle-timeout-flake-5898 branch September 6, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stabilize the Windows worker-idle-timeout regression test

2 participants