mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
perf(tui): an idle session relaxes its poll during prompts and background jobs (#6728)
The event loop only reached its 250 ms quiet poll when nothing at all was live, so the states an idle user actually sits in kept the 24-48 ms poll: - an approval, elevation or question card on top (the turn is still loading, so the active 24 ms poll applied); - a running background job (durable task) or a once-a-second label, which counted as "animating" or "busy" even though the frame requester already wakes the loop at the next frame deadline. Now a waiting decision prompt takes the idle path and counts as quiescent; only motion faster than the quiet poll counts as animation (slower motion is woken by its frame deadline); a live durable task no longer blocks the quiet poll (the task panel refreshes on its own 2.5 s cadence). The terminal input thread polls every 200 ms instead of 50 ms after 5 s without input, which keeps the child-terminal pause ack well inside its 500 ms timeout. `decision_prompt_waiting` replaces the duplicated ModalKind match in status_animation_interval_ms. Known limits: the loop is still polled, not woken, so an engine event, a remote decision or a prompt timeout lands up to 250 ms late once the UI has been quiet for 5 s. The input pump still wakes 5x/s at rest. Measured on macOS arm64, release builds, codewhale process only, 60 s windows (30 s where noted), timer wakeups from proc_pid_rusage interrupt_wkups, through a loopback OpenAI-compatible provider (no provider spend). Before: installed 0.10.2 (3400ebb5c9; this poll logic is unchanged between it and main). After: release build of this tree. | state | before wakeups/s, CPU | after wakeups/s, CPU | | launch screen | ~25 (composer), 0.20% | 10.4, 0.22% (30 s) | | after a finished turn | 25.0, 0.23% (30 s) | 10.8, 0.17% (30 s) | | approval card waiting | 29.4, 0.35% (30 s) | 12.6, 0.17% | | background `sleep 900` | 32.4, 0.45% | 15.9, 0.21% | The approval card still decided normally (`y` ran the command and wrote its file). Not measured: FreeBSD, where the reporter's ktrace showed the per-wake clock_gettime cost dominating. Tests: scripts/dev-test.sh tui idle_poll_tests terminal_input session_manager persistence_actor thread_history runtime_store_convergence tui::approval snapshot checkpoint resume motion: 784 tests run: 784 passed, 13351 skipped (idle_poll_tests 5, tui::approval 128). The idle-poll test row for durable tasks was removed with the field. Release build: Finished release in 8m 43s, 0 warnings. Refs #6728, CW-6993 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a4bbb21564
commit
ccda707286
6 files changed
+77
-45
No files matched your search
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
|
||||
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
|
||||
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
|
||||
- **`codewhale-dependencies`, `codewhale-snapshot`, `codewhale-lsp`, `codewhale-repl` crates:** external-tool resolution, workspace snapshots, the language-server client and the Python REPL runtime moved out of `codewhale-tui` into their own crates; `write_context_file` now lives in `codewhale-repl`. No behavior change. (CW-6129)
|
||||
- **`codewhale-skill-install` crate:** the community-skill installer, package content digest and `SKILL.md` frontmatter reader move out of `codewhale-tui` into a new crate; the install SSRF guard and timeouts are unchanged in production builds. (CW-6129)
|
||||
@@ -171,6 +173,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
seconds without input or engine events, as it already did in an idle
|
||||
session, instead of starting about a dozen git processes every 2 seconds for
|
||||
as long as the job lives (#6728).
|
||||
- An idle session wakes less often. While an approval, elevation or question
|
||||
prompt waits on you, while a background job runs, or while only a
|
||||
once-a-second label ticks, the event loop now relaxes to its 250 ms quiet
|
||||
poll instead of polling every 24 to 48 ms; animations and the prompt's
|
||||
timeout still wake it on their own deadlines. The terminal input thread
|
||||
polls every 200 ms instead of 50 ms after 5 seconds without input. Measured
|
||||
on macOS with release builds: an approval wait went from 29 to 12 timer
|
||||
wakeups per second, a background job from 32 to 16 and an idle composer
|
||||
from 25 to 11 (#6728).
|
||||
- `write` over an existing file now says when it kept that file's CRLF or LF
|
||||
line endings, so the agent no longer reports a line-ending conversion that
|
||||
did not happen (#7070).
|
||||
|
||||
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
|
||||
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
|
||||
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
|
||||
- **`codewhale-dependencies`, `codewhale-snapshot`, `codewhale-lsp`, `codewhale-repl` crates:** external-tool resolution, workspace snapshots, the language-server client and the Python REPL runtime moved out of `codewhale-tui` into their own crates; `write_context_file` now lives in `codewhale-repl`. No behavior change. (CW-6129)
|
||||
- **`codewhale-skill-install` crate:** the community-skill installer, package content digest and `SKILL.md` frontmatter reader move out of `codewhale-tui` into a new crate; the install SSRF guard and timeouts are unchanged in production builds. (CW-6129)
|
||||
@@ -171,6 +173,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
seconds without input or engine events, as it already did in an idle
|
||||
session, instead of starting about a dozen git processes every 2 seconds for
|
||||
as long as the job lives (#6728).
|
||||
- An idle session wakes less often. While an approval, elevation or question
|
||||
prompt waits on you, while a background job runs, or while only a
|
||||
once-a-second label ticks, the event loop now relaxes to its 250 ms quiet
|
||||
poll instead of polling every 24 to 48 ms; animations and the prompt's
|
||||
timeout still wake it on their own deadlines. The terminal input thread
|
||||
polls every 200 ms instead of 50 ms after 5 seconds without input. Measured
|
||||
on macOS with release builds: an approval wait went from 29 to 12 timer
|
||||
wakeups per second, a background job from 32 to 16 and an idle composer
|
||||
from 25 to 11 (#6728).
|
||||
- `write` over an existing file now says when it kept that file's CRLF or LF
|
||||
line endings, so the agent no longer reports a line-ending conversion that
|
||||
did not happen (#7070).
|
||||
|
||||
@@ -5081,13 +5081,19 @@ pub(crate) async fn run_event_loop(
|
||||
let underwater_motion =
|
||||
underwater_ambient_motion || underwater_completion_motion || launch_motion;
|
||||
let animation_active = status_motion || underwater_motion;
|
||||
let animation_interval = Duration::from_millis(animation_interval_ms(
|
||||
app,
|
||||
status_motion,
|
||||
underwater_motion,
|
||||
cadence_tier,
|
||||
));
|
||||
// #6728: what the loop knows about its own quiet. Anything that
|
||||
// wants a prompt reaction moves `last_ui_activity`; the idle poll
|
||||
// and the automation scan back off from it.
|
||||
let idle_facts = IdleFacts {
|
||||
has_running_agents,
|
||||
animation_active,
|
||||
durable_tasks_active,
|
||||
animation_active: animation_active
|
||||
&& animation_interval < Duration::from_millis(UI_QUIESCENT_POLL_MS),
|
||||
input_pending: !pending_terminal_events.is_empty(),
|
||||
pending_engine_op: pending_subagent_list_refresh,
|
||||
};
|
||||
@@ -5113,12 +5119,6 @@ pub(crate) async fn run_event_loop(
|
||||
),
|
||||
));
|
||||
}
|
||||
let animation_interval = Duration::from_millis(animation_interval_ms(
|
||||
app,
|
||||
status_motion,
|
||||
underwater_motion,
|
||||
cadence_tier,
|
||||
));
|
||||
let motion_policy = app.motion_policy();
|
||||
if animation_active && last_status_frame.elapsed() >= animation_interval {
|
||||
let translation_animated = streaming_thinking::animate_pending_translation(
|
||||
@@ -5325,20 +5325,23 @@ pub(crate) async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
let mut poll_timeout =
|
||||
if app.is_loading || has_running_agents || app.is_compacting || app.is_purging {
|
||||
Duration::from_millis(active_poll_ms(app))
|
||||
} else {
|
||||
// Relaxes only once the UI has been quiescent and quiet for
|
||||
// `UI_QUIESCENT_AFTER` (#6728); every deadline below still
|
||||
// shortens it, and input returns the loop at once.
|
||||
idle_poll_duration(
|
||||
app,
|
||||
&idle_facts,
|
||||
now,
|
||||
now.saturating_duration_since(last_ui_activity),
|
||||
)
|
||||
};
|
||||
let mut poll_timeout = if (app.is_loading && !decision_prompt_waiting(app))
|
||||
|| has_running_agents
|
||||
|| app.is_compacting
|
||||
|| app.is_purging
|
||||
{
|
||||
Duration::from_millis(active_poll_ms(app))
|
||||
} else {
|
||||
// Relaxes only once the UI has been quiescent and quiet for
|
||||
// `UI_QUIESCENT_AFTER` (#6728); every deadline below still
|
||||
// shortens it, and input returns the loop at once.
|
||||
idle_poll_duration(
|
||||
app,
|
||||
&idle_facts,
|
||||
now,
|
||||
now.saturating_duration_since(last_ui_activity),
|
||||
)
|
||||
};
|
||||
if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
|
||||
poll_timeout = poll_timeout.min(until_flush);
|
||||
}
|
||||
|
||||
@@ -158,13 +158,11 @@ pub(crate) fn rail_min_chat_width(idle_empty: bool) -> u16 {
|
||||
pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
|
||||
if app.effective_low_motion_for_status() {
|
||||
crate::tui::display_refresh::adaptive_animation_interval_ms(true)
|
||||
} else if matches!(
|
||||
app.view_stack.top_kind(),
|
||||
Some(ModalKind::Approval | ModalKind::Elevation | ModalKind::UserInput)
|
||||
) || !(app.is_loading
|
||||
|| app.is_compacting
|
||||
|| app.is_purging
|
||||
|| crate::tui::subagent_routing::running_agent_count(app) > 0)
|
||||
} else if decision_prompt_waiting(app)
|
||||
|| !(app.is_loading
|
||||
|| app.is_compacting
|
||||
|| app.is_purging
|
||||
|| crate::tui::subagent_routing::running_agent_count(app) > 0)
|
||||
{
|
||||
UI_QUIET_STATUS_ANIMATION_MS
|
||||
} else {
|
||||
|
||||
@@ -821,10 +821,10 @@ pub(crate) const UI_QUIESCENT_POLL_MS: u64 = 250;
|
||||
pub(crate) struct IdleFacts {
|
||||
/// Live sub-agents (`running_agent_count(app) > 0`).
|
||||
pub(crate) has_running_agents: bool,
|
||||
/// A spinner, the underwater scene, or a launch animation wants frames.
|
||||
/// A spinner, the underwater scene, or a launch animation wants frames
|
||||
/// faster than [`UI_QUIESCENT_POLL_MS`]; slower motion is woken by its
|
||||
/// own frame deadline.
|
||||
pub(crate) animation_active: bool,
|
||||
/// Durable tasks queued, running or waiting.
|
||||
pub(crate) durable_tasks_active: bool,
|
||||
/// A terminal event is already buffered for this iteration.
|
||||
pub(crate) input_pending: bool,
|
||||
/// A sub-agent list refresh is waiting for room in the engine mailbox.
|
||||
@@ -837,18 +837,18 @@ pub(crate) struct IdleFacts {
|
||||
/// the list errs towards "busy".
|
||||
pub(crate) fn ui_state_is_quiescent(app: &App, facts: &IdleFacts, now: Instant) -> bool {
|
||||
let toast_live = |toast: &StatusToast| toast.ttl_ms.is_some() && !toast.is_expired(now);
|
||||
!(app.is_loading
|
||||
let deciding = decision_prompt_waiting(app);
|
||||
!((app.is_loading && !deciding)
|
||||
|| app.is_compacting
|
||||
|| app.is_purging
|
||||
|| app.turn_started_at.is_some()
|
||||
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|
||||
|| (app.turn_started_at.is_some() && !deciding)
|
||||
|| (matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) && !deciding)
|
||||
|| facts.has_running_agents
|
||||
|| facts.animation_active
|
||||
|| facts.durable_tasks_active
|
||||
|| facts.input_pending
|
||||
|| facts.pending_engine_op
|
||||
|| app.needs_redraw
|
||||
|| !app.view_stack.is_empty()
|
||||
|| (!app.view_stack.is_empty() && !deciding)
|
||||
|| app.onboarding != OnboardingState::None
|
||||
|| app.redaction_gate
|
||||
|| !app.queued_messages.is_empty()
|
||||
@@ -862,6 +862,13 @@ pub(crate) fn ui_state_is_quiescent(app: &App, facts: &IdleFacts, now: Instant)
|
||||
|| app.sticky_status.as_ref().is_some_and(toast_live))
|
||||
}
|
||||
|
||||
pub(crate) fn decision_prompt_waiting(app: &App) -> bool {
|
||||
matches!(
|
||||
app.view_stack.top_kind(),
|
||||
Some(ModalKind::Approval | ModalKind::Elevation | ModalKind::UserInput)
|
||||
)
|
||||
}
|
||||
|
||||
/// The idle poll for this iteration: [`idle_poll_ms`] normally, relaxed to
|
||||
/// [`UI_QUIESCENT_POLL_MS`] once the UI has been quiescent (see
|
||||
/// [`ui_state_is_quiescent`]) and quiet for [`UI_QUIESCENT_AFTER`]. `quiet_for`
|
||||
@@ -991,13 +998,6 @@ mod idle_poll_tests {
|
||||
},
|
||||
"something is animating",
|
||||
),
|
||||
(
|
||||
IdleFacts {
|
||||
durable_tasks_active: true,
|
||||
..IdleFacts::default()
|
||||
},
|
||||
"a durable task is live",
|
||||
),
|
||||
(
|
||||
IdleFacts {
|
||||
input_pending: true,
|
||||
|
||||
@@ -12,6 +12,8 @@ use std::time::{Duration, Instant};
|
||||
use crossterm::event::{self, Event};
|
||||
|
||||
const TERMINAL_INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const TERMINAL_INPUT_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
const TERMINAL_INPUT_IDLE_AFTER: Duration = Duration::from_secs(5);
|
||||
const TERMINAL_INPUT_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(500);
|
||||
pub(super) const TERMINAL_INPUT_STALL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub(super) const TERMINAL_INPUT_RECOVERY_COOLDOWN: Duration = Duration::from_secs(10);
|
||||
@@ -186,6 +188,7 @@ impl TerminalInputPump {
|
||||
.name("codewhale-terminal-input".to_string())
|
||||
.spawn(move || {
|
||||
let mut last_heartbeat = Instant::now();
|
||||
let mut last_event = last_heartbeat;
|
||||
while !thread_stop.load(Ordering::Acquire) {
|
||||
if thread_paused.load(Ordering::Acquire) {
|
||||
thread_paused_ack.store(true, Ordering::Release);
|
||||
@@ -193,11 +196,17 @@ impl TerminalInputPump {
|
||||
continue;
|
||||
}
|
||||
thread_paused_ack.store(false, Ordering::Release);
|
||||
match event::poll(TERMINAL_INPUT_POLL_INTERVAL) {
|
||||
let poll_interval = if last_event.elapsed() < TERMINAL_INPUT_IDLE_AFTER {
|
||||
TERMINAL_INPUT_POLL_INTERVAL
|
||||
} else {
|
||||
TERMINAL_INPUT_IDLE_POLL_INTERVAL
|
||||
};
|
||||
match event::poll(poll_interval) {
|
||||
Ok(true) if thread_stop.load(Ordering::Acquire) => break,
|
||||
Ok(true) => match event::read() {
|
||||
Ok(event) => {
|
||||
last_heartbeat = Instant::now();
|
||||
last_event = last_heartbeat;
|
||||
let observed = ObservedTerminalEvent::new(event, last_heartbeat);
|
||||
if tx.send(TerminalInputMessage::Event(observed)).is_err() {
|
||||
break;
|
||||
|
||||
Reference in new issue
Block a user